@loomcore/api 0.1.74 → 0.1.76

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.
Files changed (24) hide show
  1. package/dist/__tests__/common-test.utils.js +4 -1
  2. package/dist/__tests__/postgres-test-migrations/postgres-test-schema.js +76 -1
  3. package/dist/controllers/auth.controller.d.ts +1 -2
  4. package/dist/controllers/auth.controller.js +2 -2
  5. package/dist/databases/migrations/migration-runner.d.ts +1 -3
  6. package/dist/databases/migrations/migration-runner.js +3 -5
  7. package/dist/databases/mongo-db/migrations/mongo-initial-schema.d.ts +1 -2
  8. package/dist/databases/mongo-db/migrations/mongo-initial-schema.js +4 -4
  9. package/dist/databases/operations/__tests__/models/client-report.model.d.ts +24 -0
  10. package/dist/databases/operations/__tests__/models/client-report.model.js +3 -1
  11. package/dist/databases/operations/__tests__/models/policy.model.d.ts +30 -0
  12. package/dist/databases/operations/__tests__/models/policy.model.js +9 -0
  13. package/dist/databases/postgres/migrations/__tests__/test-migration-helper.js +1 -2
  14. package/dist/databases/postgres/migrations/postgres-initial-schema.d.ts +1 -2
  15. package/dist/databases/postgres/migrations/postgres-initial-schema.js +4 -4
  16. package/dist/databases/postgres/utils/build-join-clauses.js +185 -10
  17. package/dist/databases/postgres/utils/build-select-clause.js +21 -1
  18. package/dist/databases/postgres/utils/transform-join-results.js +38 -6
  19. package/dist/models/base-api-config.interface.d.ts +4 -0
  20. package/dist/services/auth.service.d.ts +1 -2
  21. package/dist/services/auth.service.js +2 -2
  22. package/dist/services/email.service.d.ts +1 -2
  23. package/dist/services/email.service.js +7 -2
  24. package/package.json +1 -1
@@ -27,7 +27,7 @@ const newUser1Email = 'one@test.com';
27
27
  const newUser1Password = 'testone';
28
28
  const constDeviceIdCookie = crypto.randomBytes(16).toString('hex');
29
29
  function initialize(database) {
30
- authService = new AuthService(database, new TestEmailClient());
30
+ authService = new AuthService(database);
31
31
  organizationService = new OrganizationService(database);
32
32
  deviceIdCookie = constDeviceIdCookie;
33
33
  }
@@ -210,6 +210,9 @@ export function setupTestConfig(isMultiTenant = true, dbType) {
210
210
  fromAddress: 'test@test.com',
211
211
  systemEmailAddress: 'system@test.com'
212
212
  },
213
+ thirdPartyClients: {
214
+ emailClient: new TestEmailClient()
215
+ },
213
216
  multiTenant: isMultiTenant ? {
214
217
  metaOrgName: 'Test Meta Organization',
215
218
  metaOrgCode: 'TEST_META_ORG'
@@ -146,7 +146,30 @@ export const getPostgresTestSchema = (config) => {
146
146
  }
147
147
  });
148
148
  migrations.push({
149
- name: '00000000000105_6_schema-clients',
149
+ name: '00000000000105_6_schema-policies',
150
+ up: async ({ context: pool }) => {
151
+ const orgColumnDef = isMultiTenant ? '"_orgId" INTEGER,' : '';
152
+ await pool.query(`
153
+ CREATE TABLE IF NOT EXISTS "policies" (
154
+ "_id" INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
155
+ ${orgColumnDef}
156
+ "amount" NUMERIC NOT NULL,
157
+ "frequency" VARCHAR NOT NULL,
158
+ "_created" TIMESTAMPTZ NOT NULL,
159
+ "_createdBy" INTEGER NOT NULL,
160
+ "_updated" TIMESTAMPTZ NOT NULL,
161
+ "_updatedBy" INTEGER NOT NULL,
162
+ "_deleted" TIMESTAMPTZ,
163
+ "_deletedBy" INTEGER
164
+ )
165
+ `);
166
+ },
167
+ down: async ({ context: pool }) => {
168
+ await pool.query('DROP TABLE IF EXISTS "policies"');
169
+ }
170
+ });
171
+ migrations.push({
172
+ name: '00000000000105_7_schema-clients',
150
173
  up: async ({ context: pool }) => {
151
174
  const orgColumnDef = isMultiTenant ? '"_orgId" INTEGER,' : '';
152
175
  await pool.query(`
@@ -171,6 +194,58 @@ export const getPostgresTestSchema = (config) => {
171
194
  await pool.query('DROP TABLE IF EXISTS "clients"');
172
195
  }
173
196
  });
197
+ migrations.push({
198
+ name: '00000000000105_8_schema-clients-policies',
199
+ up: async ({ context: pool }) => {
200
+ const orgColumnDef = isMultiTenant ? '"_orgId" INTEGER,' : '';
201
+ await pool.query(`
202
+ CREATE TABLE IF NOT EXISTS clients_policies (
203
+ "_id" INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
204
+ ${orgColumnDef}
205
+ "client_id" INTEGER NOT NULL,
206
+ "policy_id" INTEGER NOT NULL,
207
+ "_created" TIMESTAMPTZ NOT NULL,
208
+ "_createdBy" INTEGER NOT NULL,
209
+ "_updated" TIMESTAMPTZ NOT NULL,
210
+ "_updatedBy" INTEGER NOT NULL,
211
+ "_deleted" TIMESTAMPTZ,
212
+ "_deletedBy" INTEGER,
213
+ CONSTRAINT fk_clients_policies_client_id FOREIGN KEY ("client_id") REFERENCES clients("_id") ON DELETE CASCADE,
214
+ CONSTRAINT fk_clients_policies_policy_id FOREIGN KEY ("policy_id") REFERENCES policies("_id") ON DELETE CASCADE,
215
+ CONSTRAINT uk_clients_policies_client_policy UNIQUE ("client_id", "policy_id")
216
+ )
217
+ `);
218
+ },
219
+ down: async ({ context: pool }) => {
220
+ await pool.query('DROP TABLE IF EXISTS "clients_policies"');
221
+ }
222
+ });
223
+ migrations.push({
224
+ name: '00000000000105_8_schema-agents-policies',
225
+ up: async ({ context: pool }) => {
226
+ const orgColumnDef = isMultiTenant ? '"_orgId" INTEGER,' : '';
227
+ await pool.query(`
228
+ CREATE TABLE IF NOT EXISTS agents_policies (
229
+ "_id" INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
230
+ ${orgColumnDef}
231
+ "policy_id" INTEGER NOT NULL,
232
+ "agent_id" INTEGER NOT NULL,
233
+ "_created" TIMESTAMPTZ NOT NULL,
234
+ "_createdBy" INTEGER NOT NULL,
235
+ "_updated" TIMESTAMPTZ NOT NULL,
236
+ "_updatedBy" INTEGER NOT NULL,
237
+ "_deleted" TIMESTAMPTZ,
238
+ "_deletedBy" INTEGER,
239
+ CONSTRAINT fk_agents_policies_policy_id FOREIGN KEY ("policy_id") REFERENCES policies("_id") ON DELETE CASCADE,
240
+ CONSTRAINT fk_agents_policies_agent_id FOREIGN KEY ("agent_id") REFERENCES agents("_id") ON DELETE CASCADE,
241
+ CONSTRAINT uk_agents_policies_policy_agent UNIQUE ("policy_id", "agent_id")
242
+ )
243
+ `);
244
+ },
245
+ down: async ({ context: pool }) => {
246
+ await pool.query('DROP TABLE IF EXISTS "agents_policies"');
247
+ }
248
+ });
174
249
  migrations.push({
175
250
  name: '00000000000106_schema-email-addresses',
176
251
  up: async ({ context: pool }) => {
@@ -1,10 +1,9 @@
1
1
  import { Application, Request, Response, NextFunction } from 'express';
2
2
  import { AuthService } from '../services/index.js';
3
3
  import { IDatabase } from '../databases/models/index.js';
4
- import { IEmailClient } from '../models/email-client.interface.js';
5
4
  export declare class AuthController {
6
5
  authService: AuthService;
7
- constructor(app: Application, database: IDatabase, emailClient: IEmailClient);
6
+ constructor(app: Application, database: IDatabase);
8
7
  mapRoutes(app: Application): void;
9
8
  login(req: Request, res: Response, next: NextFunction): Promise<void>;
10
9
  registerUser(req: Request, res: Response): Promise<void>;
@@ -6,8 +6,8 @@ import { AuthService } from '../services/index.js';
6
6
  import { isAuthorized } from '../middleware/index.js';
7
7
  export class AuthController {
8
8
  authService;
9
- constructor(app, database, emailClient) {
10
- this.authService = new AuthService(database, emailClient);
9
+ constructor(app, database) {
10
+ this.authService = new AuthService(database);
11
11
  this.mapRoutes(app);
12
12
  }
13
13
  mapRoutes(app) {
@@ -1,5 +1,4 @@
1
1
  import { IBaseApiConfig } from '../../models/base-api-config.interface.js';
2
- import { IEmailClient } from '../../models/email-client.interface.js';
3
2
  export declare class MigrationRunner {
4
3
  private config;
5
4
  private dbType;
@@ -7,8 +6,7 @@ export declare class MigrationRunner {
7
6
  private migrationsDir;
8
7
  private primaryTimezone;
9
8
  private dbConnection;
10
- private emailClient?;
11
- constructor(config: IBaseApiConfig, emailClient?: IEmailClient);
9
+ constructor(config: IBaseApiConfig);
12
10
  private getTimestamp;
13
11
  private parseSql;
14
12
  private getMigrator;
@@ -15,15 +15,13 @@ export class MigrationRunner {
15
15
  migrationsDir;
16
16
  primaryTimezone;
17
17
  dbConnection;
18
- emailClient;
19
- constructor(config, emailClient) {
18
+ constructor(config) {
20
19
  setBaseApiConfig(config);
21
20
  this.config = config;
22
21
  this.dbType = config.app.dbType;
23
22
  this.dbUrl = this.dbType === 'postgres' ? buildPostgresUrl(config) : buildMongoUrl(config);
24
23
  this.migrationsDir = path.join(process.cwd(), 'database', 'migrations');
25
24
  this.primaryTimezone = config.app.primaryTimezone || 'UTC';
26
- this.emailClient = emailClient;
27
25
  }
28
26
  getTimestamp() {
29
27
  const now = new Date();
@@ -66,7 +64,7 @@ export class MigrationRunner {
66
64
  this.dbConnection = pool;
67
65
  return new Umzug({
68
66
  migrations: async () => {
69
- const initialSchema = getPostgresInitialSchema(this.config, this.emailClient).map(m => ({
67
+ const initialSchema = getPostgresInitialSchema(this.config).map(m => ({
70
68
  name: m.name,
71
69
  up: async () => {
72
70
  console.log(` Running [LIBRARY] ${m.name}...`);
@@ -105,7 +103,7 @@ export class MigrationRunner {
105
103
  console.log(`🔎 Looking for migrations in: ${globPattern}`);
106
104
  return new Umzug({
107
105
  migrations: async () => {
108
- const initialSchema = getMongoInitialSchema(this.config, this.emailClient).map(m => ({
106
+ const initialSchema = getMongoInitialSchema(this.config).map(m => ({
109
107
  name: m.name,
110
108
  up: async () => {
111
109
  console.log(` Running [LIBRARY] ${m.name}...`);
@@ -1,6 +1,5 @@
1
1
  import { Db } from 'mongodb';
2
2
  import { IBaseApiConfig } from '../../../models/base-api-config.interface.js';
3
- import { IEmailClient } from '../../../models/email-client.interface.js';
4
3
  export interface ISyntheticMigration {
5
4
  name: string;
6
5
  up: (context: {
@@ -10,4 +9,4 @@ export interface ISyntheticMigration {
10
9
  context: Db;
11
10
  }) => Promise<void>;
12
11
  }
13
- export declare const getMongoInitialSchema: (config: IBaseApiConfig, emailClient?: IEmailClient) => ISyntheticMigration[];
12
+ export declare const getMongoInitialSchema: (config: IBaseApiConfig) => ISyntheticMigration[];
@@ -2,7 +2,7 @@ import { randomUUID } from 'crypto';
2
2
  import { initializeSystemUserContext, EmptyUserContext, getSystemUserContext, isSystemUserContextInitialized } from '@loomcore/common/models';
3
3
  import { MongoDBDatabase } from '../mongo-db.database.js';
4
4
  import { AuthService, OrganizationService } from '../../../services/index.js';
5
- export const getMongoInitialSchema = (config, emailClient) => {
5
+ export const getMongoInitialSchema = (config) => {
6
6
  const migrations = [];
7
7
  const isMultiTenant = config.app.isMultiTenant;
8
8
  if (isMultiTenant && !config.multiTenant) {
@@ -12,7 +12,7 @@ export const getMongoInitialSchema = (config, emailClient) => {
12
12
  if (isAuthEnabled && !config.auth) {
13
13
  throw new Error('Auth enabled without auth configuration');
14
14
  }
15
- if (isAuthEnabled && (!emailClient || !config.email)) {
15
+ if (isAuthEnabled && (!config.thirdPartyClients?.emailClient || !config.email)) {
16
16
  throw new Error('Auth enabled without email client or email configuration');
17
17
  }
18
18
  if (isMultiTenant)
@@ -166,7 +166,7 @@ export const getMongoInitialSchema = (config, emailClient) => {
166
166
  name: '00000000000009_data-admin-user',
167
167
  up: async ({ context: db }) => {
168
168
  const database = new MongoDBDatabase(db);
169
- const authService = new AuthService(database, emailClient);
169
+ const authService = new AuthService(database);
170
170
  if (!isSystemUserContextInitialized()) {
171
171
  const errorMessage = isMultiTenant
172
172
  ? 'SystemUserContext has not been initialized. The meta-org migration (00000000000008_data-meta-org) should have run before this migration. ' +
@@ -201,7 +201,7 @@ export const getMongoInitialSchema = (config, emailClient) => {
201
201
  up: async ({ context: db }) => {
202
202
  const database = new MongoDBDatabase(db);
203
203
  const organizationService = new OrganizationService(database);
204
- const authService = new AuthService(database, emailClient);
204
+ const authService = new AuthService(database);
205
205
  const metaOrg = await organizationService.getMetaOrg(EmptyUserContext);
206
206
  if (!metaOrg) {
207
207
  throw new Error('Meta organization not found. Ensure meta-org migration ran successfully.');
@@ -1,9 +1,11 @@
1
1
  import { IPersonModel } from "./person.model.js";
2
2
  import { IAgentModel } from "./agent.model.js";
3
+ import { IPolicyModel } from "./policy.model.js";
3
4
  import type { IAuditable, IEntity } from "@loomcore/common/models";
4
5
  export interface IClientReportsModel extends IEntity, IAuditable {
5
6
  client_person: IPersonModel;
6
7
  agent?: IAgentModel;
8
+ policies?: IPolicyModel[];
7
9
  }
8
10
  export declare const clientReportsSchema: import("@sinclair/typebox").TObject<{
9
11
  client_person: import("@sinclair/typebox").TObject<{
@@ -39,5 +41,27 @@ export declare const clientReportsSchema: import("@sinclair/typebox").TObject<{
39
41
  }>>;
40
42
  }>>;
41
43
  }>>;
44
+ policies: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TObject<{
45
+ amount: import("@sinclair/typebox").TNumber;
46
+ frequency: import("@sinclair/typebox").TString;
47
+ agents: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TObject<{
48
+ person_id: import("@sinclair/typebox").TNumber;
49
+ agent_person: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
50
+ first_name: import("@sinclair/typebox").TString;
51
+ middle_name: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
52
+ last_name: import("@sinclair/typebox").TString;
53
+ phone_numbers: import("@sinclair/typebox").TArray<import("@sinclair/typebox").TObject<{
54
+ phone_number: import("@sinclair/typebox").TString;
55
+ phone_number_type: import("@sinclair/typebox").TString;
56
+ is_default: import("@sinclair/typebox").TBoolean;
57
+ }>>;
58
+ email_addresses: import("@sinclair/typebox").TArray<import("@sinclair/typebox").TObject<{
59
+ person_id: import("@sinclair/typebox").TNumber;
60
+ email_address: import("@sinclair/typebox").TString;
61
+ is_default: import("@sinclair/typebox").TBoolean;
62
+ }>>;
63
+ }>>;
64
+ }>>>;
65
+ }>>>;
42
66
  }>;
43
67
  export declare const clientReportsModelSpec: import("@loomcore/common/models").IModelSpec<import("@sinclair/typebox").TSchema>;
@@ -1,9 +1,11 @@
1
1
  import { personSchema } from "./person.model.js";
2
2
  import { agentSchema } from "./agent.model.js";
3
+ import { policySchema } from "./policy.model.js";
3
4
  import { entityUtils } from "@loomcore/common/utils";
4
5
  import { Type } from "@sinclair/typebox";
5
6
  export const clientReportsSchema = Type.Object({
6
7
  client_person: personSchema,
7
- agent: Type.Optional(agentSchema)
8
+ agent: Type.Optional(agentSchema),
9
+ policies: Type.Optional(Type.Array(policySchema))
8
10
  });
9
11
  export const clientReportsModelSpec = entityUtils.getModelSpec(clientReportsSchema, { isAuditable: true });
@@ -0,0 +1,30 @@
1
+ import type { IAuditable, IEntity } from "@loomcore/common/models";
2
+ import { IAgentModel } from "./agent.model.js";
3
+ export interface IPolicyModel extends IEntity, IAuditable {
4
+ amount: number;
5
+ frequency: string;
6
+ agents?: IAgentModel[];
7
+ }
8
+ export declare const policySchema: import("@sinclair/typebox").TObject<{
9
+ amount: import("@sinclair/typebox").TNumber;
10
+ frequency: import("@sinclair/typebox").TString;
11
+ agents: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TObject<{
12
+ person_id: import("@sinclair/typebox").TNumber;
13
+ agent_person: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
14
+ first_name: import("@sinclair/typebox").TString;
15
+ middle_name: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
16
+ last_name: import("@sinclair/typebox").TString;
17
+ phone_numbers: import("@sinclair/typebox").TArray<import("@sinclair/typebox").TObject<{
18
+ phone_number: import("@sinclair/typebox").TString;
19
+ phone_number_type: import("@sinclair/typebox").TString;
20
+ is_default: import("@sinclair/typebox").TBoolean;
21
+ }>>;
22
+ email_addresses: import("@sinclair/typebox").TArray<import("@sinclair/typebox").TObject<{
23
+ person_id: import("@sinclair/typebox").TNumber;
24
+ email_address: import("@sinclair/typebox").TString;
25
+ is_default: import("@sinclair/typebox").TBoolean;
26
+ }>>;
27
+ }>>;
28
+ }>>>;
29
+ }>;
30
+ export declare const policyModelSpec: import("@loomcore/common/models").IModelSpec<import("@sinclair/typebox").TSchema>;
@@ -0,0 +1,9 @@
1
+ import { entityUtils } from "@loomcore/common/utils";
2
+ import { Type } from "@sinclair/typebox";
3
+ import { agentSchema } from "./agent.model.js";
4
+ export const policySchema = Type.Object({
5
+ amount: Type.Number(),
6
+ frequency: Type.String(),
7
+ agents: Type.Optional(Type.Array(agentSchema))
8
+ });
9
+ export const policyModelSpec = entityUtils.getModelSpec(policySchema, { isAuditable: true });
@@ -1,9 +1,8 @@
1
1
  import { Umzug } from 'umzug';
2
2
  import { getPostgresInitialSchema } from '../postgres-initial-schema.js';
3
3
  import { getPostgresTestSchema } from '../../../../__tests__/postgres-test-migrations/postgres-test-schema.js';
4
- import { TestEmailClient } from '../../../../__tests__/test-email-client.js';
5
4
  export async function runInitialSchemaMigrations(pool, config) {
6
- const initialSchema = getPostgresInitialSchema(config, new TestEmailClient());
5
+ const initialSchema = getPostgresInitialSchema(config);
7
6
  const umzug = new Umzug({
8
7
  migrations: async () => {
9
8
  return initialSchema.map(m => ({
@@ -1,6 +1,5 @@
1
1
  import { Pool } from 'pg';
2
2
  import { IBaseApiConfig } from '../../../models/base-api-config.interface.js';
3
- import { IEmailClient } from '../../../models/email-client.interface.js';
4
3
  export interface SyntheticMigration {
5
4
  name: string;
6
5
  up: (context: {
@@ -10,4 +9,4 @@ export interface SyntheticMigration {
10
9
  context: Pool;
11
10
  }) => Promise<void>;
12
11
  }
13
- export declare const getPostgresInitialSchema: (config: IBaseApiConfig, emailClient?: IEmailClient) => SyntheticMigration[];
12
+ export declare const getPostgresInitialSchema: (config: IBaseApiConfig) => SyntheticMigration[];
@@ -1,7 +1,7 @@
1
1
  import { initializeSystemUserContext, EmptyUserContext, getSystemUserContext, isSystemUserContextInitialized } from '@loomcore/common/models';
2
2
  import { PostgresDatabase } from '../postgres.database.js';
3
3
  import { AuthService, OrganizationService } from '../../../services/index.js';
4
- export const getPostgresInitialSchema = (config, emailClient) => {
4
+ export const getPostgresInitialSchema = (config) => {
5
5
  const migrations = [];
6
6
  const isMultiTenant = config.app.isMultiTenant;
7
7
  if (isMultiTenant && !config.multiTenant) {
@@ -11,7 +11,7 @@ export const getPostgresInitialSchema = (config, emailClient) => {
11
11
  if (isAuthEnabled && !config.auth) {
12
12
  throw new Error('Auth enabled without auth configuration');
13
13
  }
14
- if (isAuthEnabled && (!emailClient || !config.email)) {
14
+ if (isAuthEnabled && (!config.thirdPartyClients?.emailClient || !config.email)) {
15
15
  throw new Error('Auth enabled without email client or email configuration');
16
16
  }
17
17
  if (isMultiTenant) {
@@ -259,7 +259,7 @@ export const getPostgresInitialSchema = (config, emailClient) => {
259
259
  const client = await pool.connect();
260
260
  try {
261
261
  const database = new PostgresDatabase(client);
262
- const authService = new AuthService(database, emailClient);
262
+ const authService = new AuthService(database);
263
263
  if (!isSystemUserContextInitialized()) {
264
264
  const errorMessage = isMultiTenant
265
265
  ? 'SystemUserContext has not been initialized. The meta-org migration (00000000000009_data-meta-org) should have run before this migration. ' +
@@ -301,7 +301,7 @@ export const getPostgresInitialSchema = (config, emailClient) => {
301
301
  try {
302
302
  const database = new PostgresDatabase(client);
303
303
  const organizationService = new OrganizationService(database);
304
- const authService = new AuthService(database, emailClient);
304
+ const authService = new AuthService(database);
305
305
  const metaOrg = isMultiTenant ? await organizationService.getMetaOrg(EmptyUserContext) : undefined;
306
306
  if (isMultiTenant && !metaOrg) {
307
307
  throw new Error('Meta organization not found. Ensure meta-org migration ran successfully.');
@@ -5,6 +5,23 @@ import { JoinThroughMany } from "../../operations/join-through-many.operation.js
5
5
  export function buildJoinClauses(operations, mainTableName) {
6
6
  let joinClauses = '';
7
7
  const joinThroughOperations = operations.filter(op => op instanceof JoinThrough);
8
+ const processedAliases = new Set();
9
+ const aliasesToSkip = new Set();
10
+ for (const operation of operations) {
11
+ if (operation instanceof JoinThroughMany && operation.localField.includes('.')) {
12
+ const [tableAlias] = operation.localField.split('.');
13
+ const referencedJoinThroughMany = operations
14
+ .filter(op => op instanceof JoinThroughMany)
15
+ .find(jtm => jtm.as === tableAlias);
16
+ if (referencedJoinThroughMany) {
17
+ const referencedIndex = operations.indexOf(referencedJoinThroughMany);
18
+ const currentIndex = operations.indexOf(operation);
19
+ if (referencedIndex < currentIndex) {
20
+ aliasesToSkip.add(tableAlias);
21
+ }
22
+ }
23
+ }
24
+ }
8
25
  for (const operation of operations) {
9
26
  if (operation instanceof Join) {
10
27
  let localFieldRef;
@@ -67,24 +84,182 @@ export function buildJoinClauses(operations, mainTableName) {
67
84
  }
68
85
  else if (operation instanceof JoinThroughMany) {
69
86
  let localFieldRef;
87
+ let shouldSkipOriginalJoin = false;
70
88
  if (operation.localField.includes('.')) {
71
89
  const [tableAlias, columnName] = operation.localField.split('.');
72
- localFieldRef = `${tableAlias}."${columnName}"`;
90
+ const referencedJoinThroughMany = operations
91
+ .filter(op => op instanceof JoinThroughMany)
92
+ .find(jtm => jtm.as === tableAlias);
93
+ if (referencedJoinThroughMany) {
94
+ const referencedIndex = operations.indexOf(referencedJoinThroughMany);
95
+ const currentIndex = operations.indexOf(operation);
96
+ if (referencedIndex < currentIndex) {
97
+ shouldSkipOriginalJoin = true;
98
+ const originalJoinWasSkipped = aliasesToSkip.has(tableAlias);
99
+ const originalJoin = referencedJoinThroughMany;
100
+ const mainTableRef = mainTableName ? `"${mainTableName}"."_id"` : '"_id"';
101
+ const isAgentsJoin = operation.from === 'agents';
102
+ if (isAgentsJoin) {
103
+ if (originalJoinWasSkipped) {
104
+ joinClauses += ` LEFT JOIN LATERAL (
105
+ SELECT COALESCE(
106
+ JSON_AGG(
107
+ policy_elem.value || jsonb_build_object(
108
+ 'agents',
109
+ COALESCE(
110
+ (SELECT JSON_AGG(agent_elem.value || jsonb_build_object('agent_person', person_data.value))
111
+ FROM jsonb_array_elements(COALESCE(agents_agg.agents, '[]'::json)::jsonb) AS agent_elem
112
+ LEFT JOIN LATERAL (
113
+ SELECT row_to_json(p) AS value
114
+ FROM "persons" AS p
115
+ WHERE p."_id" = (agent_elem.value->>'person_id')::integer
116
+ AND p."_deleted" IS NULL
117
+ LIMIT 1
118
+ ) AS person_data ON true),
119
+ '[]'::json
120
+ )
121
+ )
122
+ ),
123
+ '[]'::json
124
+ ) AS aggregated
125
+ FROM (
126
+ SELECT COALESCE(JSON_AGG(row_to_json(${tableAlias})), '[]'::json) AS aggregated
127
+ FROM "${originalJoin.through}"
128
+ INNER JOIN "${originalJoin.from}" AS ${tableAlias}
129
+ ON ${tableAlias}."${originalJoin.foreignField}" = "${originalJoin.through}"."${originalJoin.throughForeignField}"
130
+ WHERE "${originalJoin.through}"."${originalJoin.throughLocalField}" = ${mainTableRef}
131
+ AND "${originalJoin.through}"."_deleted" IS NULL
132
+ AND ${tableAlias}."_deleted" IS NULL
133
+ ) AS policies_subquery
134
+ CROSS JOIN LATERAL jsonb_array_elements(policies_subquery.aggregated::jsonb) AS policy_elem
135
+ LEFT JOIN LATERAL (
136
+ SELECT COALESCE(JSON_AGG(row_to_json(${operation.as})), '[]'::json) AS agents
137
+ FROM "${operation.through}"
138
+ INNER JOIN "${operation.from}" AS ${operation.as}
139
+ ON ${operation.as}."${operation.foreignField}" = "${operation.through}"."${operation.throughForeignField}"
140
+ WHERE "${operation.through}"."${operation.throughLocalField}" = (policy_elem.value->>'${columnName}')::integer
141
+ AND "${operation.through}"."_deleted" IS NULL
142
+ AND ${operation.as}."_deleted" IS NULL
143
+ ) AS agents_agg ON true
144
+ ) AS ${operation.as} ON true`;
145
+ }
146
+ else {
147
+ joinClauses += ` LEFT JOIN LATERAL (
148
+ SELECT COALESCE(
149
+ JSON_AGG(
150
+ policy_elem.value || jsonb_build_object(
151
+ 'agents',
152
+ COALESCE(
153
+ (SELECT JSON_AGG(agent_elem.value || jsonb_build_object('agent_person', person_data.value))
154
+ FROM jsonb_array_elements(COALESCE(agents_agg.agents, '[]'::json)::jsonb) AS agent_elem
155
+ LEFT JOIN LATERAL (
156
+ SELECT row_to_json(p) AS value
157
+ FROM "persons" AS p
158
+ WHERE p."_id" = (agent_elem.value->>'person_id')::integer
159
+ AND p."_deleted" IS NULL
160
+ LIMIT 1
161
+ ) AS person_data ON true),
162
+ '[]'::json
163
+ )
164
+ )
165
+ ),
166
+ '[]'::json
167
+ ) AS aggregated
168
+ FROM jsonb_array_elements(COALESCE(${tableAlias}.aggregated, '[]'::json)::jsonb) AS policy_elem
169
+ LEFT JOIN LATERAL (
170
+ SELECT COALESCE(JSON_AGG(row_to_json(${operation.as})), '[]'::json) AS agents
171
+ FROM "${operation.through}"
172
+ INNER JOIN "${operation.from}" AS ${operation.as}
173
+ ON ${operation.as}."${operation.foreignField}" = "${operation.through}"."${operation.throughForeignField}"
174
+ WHERE "${operation.through}"."${operation.throughLocalField}" = (policy_elem.value->>'${columnName}')::integer
175
+ AND "${operation.through}"."_deleted" IS NULL
176
+ AND ${operation.as}."_deleted" IS NULL
177
+ ) AS agents_agg ON true
178
+ ) AS ${operation.as} ON true`;
179
+ }
180
+ }
181
+ else {
182
+ if (originalJoinWasSkipped) {
183
+ joinClauses += ` LEFT JOIN LATERAL (
184
+ SELECT COALESCE(
185
+ JSON_AGG(
186
+ policy_elem.value || jsonb_build_object('agents', COALESCE(agents_agg.agents, '[]'::json))
187
+ ),
188
+ '[]'::json
189
+ ) AS aggregated
190
+ FROM (
191
+ SELECT COALESCE(JSON_AGG(row_to_json(${tableAlias})), '[]'::json) AS aggregated
192
+ FROM "${originalJoin.through}"
193
+ INNER JOIN "${originalJoin.from}" AS ${tableAlias}
194
+ ON ${tableAlias}."${originalJoin.foreignField}" = "${originalJoin.through}"."${originalJoin.throughForeignField}"
195
+ WHERE "${originalJoin.through}"."${originalJoin.throughLocalField}" = ${mainTableRef}
196
+ AND "${originalJoin.through}"."_deleted" IS NULL
197
+ AND ${tableAlias}."_deleted" IS NULL
198
+ ) AS policies_subquery
199
+ CROSS JOIN LATERAL jsonb_array_elements(policies_subquery.aggregated::jsonb) AS policy_elem
200
+ LEFT JOIN LATERAL (
201
+ SELECT COALESCE(JSON_AGG(row_to_json(${operation.as})), '[]'::json) AS agents
202
+ FROM "${operation.through}"
203
+ INNER JOIN "${operation.from}" AS ${operation.as}
204
+ ON ${operation.as}."${operation.foreignField}" = "${operation.through}"."${operation.throughForeignField}"
205
+ WHERE "${operation.through}"."${operation.throughLocalField}" = (policy_elem.value->>'${columnName}')::integer
206
+ AND "${operation.through}"."_deleted" IS NULL
207
+ AND ${operation.as}."_deleted" IS NULL
208
+ ) AS agents_agg ON true
209
+ ) AS ${operation.as} ON true`;
210
+ }
211
+ else {
212
+ joinClauses += ` LEFT JOIN LATERAL (
213
+ SELECT COALESCE(
214
+ JSON_AGG(
215
+ policy_elem.value || jsonb_build_object('agents', COALESCE(agents_agg.agents, '[]'::json))
216
+ ),
217
+ '[]'::json
218
+ ) AS aggregated
219
+ FROM jsonb_array_elements(COALESCE(${tableAlias}.aggregated, '[]'::json)::jsonb) AS policy_elem
220
+ LEFT JOIN LATERAL (
221
+ SELECT COALESCE(JSON_AGG(row_to_json(${operation.as})), '[]'::json) AS agents
222
+ FROM "${operation.through}"
223
+ INNER JOIN "${operation.from}" AS ${operation.as}
224
+ ON ${operation.as}."${operation.foreignField}" = "${operation.through}"."${operation.throughForeignField}"
225
+ WHERE "${operation.through}"."${operation.throughLocalField}" = (policy_elem.value->>'${columnName}')::integer
226
+ AND "${operation.through}"."_deleted" IS NULL
227
+ AND ${operation.as}."_deleted" IS NULL
228
+ ) AS agents_agg ON true
229
+ ) AS ${operation.as} ON true`;
230
+ }
231
+ }
232
+ processedAliases.add(operation.as);
233
+ continue;
234
+ }
235
+ else {
236
+ localFieldRef = `${tableAlias}."${columnName}"`;
237
+ }
238
+ }
239
+ else {
240
+ localFieldRef = `${tableAlias}."${columnName}"`;
241
+ }
73
242
  }
74
243
  else {
75
244
  localFieldRef = mainTableName
76
245
  ? `"${mainTableName}"."${operation.localField}"`
77
246
  : `"${operation.localField}"`;
78
247
  }
79
- joinClauses += ` LEFT JOIN LATERAL (
80
- SELECT COALESCE(JSON_AGG(row_to_json(${operation.as})), '[]'::json) AS aggregated
81
- FROM "${operation.through}"
82
- INNER JOIN "${operation.from}" AS ${operation.as}
83
- ON ${operation.as}."${operation.foreignField}" = "${operation.through}"."${operation.throughForeignField}"
84
- WHERE "${operation.through}"."${operation.throughLocalField}" = ${localFieldRef}
85
- AND "${operation.through}"."_deleted" IS NULL
86
- AND ${operation.as}."_deleted" IS NULL
87
- ) AS ${operation.as} ON true`;
248
+ if (!shouldSkipOriginalJoin && !aliasesToSkip.has(operation.as)) {
249
+ joinClauses += ` LEFT JOIN LATERAL (
250
+ SELECT COALESCE(JSON_AGG(row_to_json(${operation.as})), '[]'::json) AS aggregated
251
+ FROM "${operation.through}"
252
+ INNER JOIN "${operation.from}" AS ${operation.as}
253
+ ON ${operation.as}."${operation.foreignField}" = "${operation.through}"."${operation.throughForeignField}"
254
+ WHERE "${operation.through}"."${operation.throughLocalField}" = ${localFieldRef}
255
+ AND "${operation.through}"."_deleted" IS NULL
256
+ AND ${operation.as}."_deleted" IS NULL
257
+ ) AS ${operation.as} ON true`;
258
+ processedAliases.add(operation.as);
259
+ }
260
+ else if (aliasesToSkip.has(operation.as)) {
261
+ processedAliases.add(operation.as);
262
+ }
88
263
  }
89
264
  }
90
265
  return joinClauses;
@@ -35,8 +35,28 @@ export async function buildSelectClause(client, mainTableName, mainTableAlias, o
35
35
  for (const joinThrough of joinThroughOperations) {
36
36
  joinSelects.push(`${joinThrough.as}.aggregated AS "${joinThrough.as}"`);
37
37
  }
38
+ const replacedJoins = new Map();
38
39
  for (const joinThroughMany of joinThroughManyOperations) {
39
- joinSelects.push(`${joinThroughMany.as}.aggregated AS "${joinThroughMany.as}"`);
40
+ if (joinThroughMany.localField.includes('.')) {
41
+ const [tableAlias] = joinThroughMany.localField.split('.');
42
+ const referencedJoin = joinThroughManyOperations.find(j => j.as === tableAlias);
43
+ if (referencedJoin) {
44
+ const referencedIndex = operations.indexOf(referencedJoin);
45
+ const currentIndex = operations.indexOf(joinThroughMany);
46
+ if (referencedIndex < currentIndex) {
47
+ replacedJoins.set(tableAlias, joinThroughMany.as);
48
+ }
49
+ }
50
+ }
51
+ }
52
+ for (const joinThroughMany of joinThroughManyOperations) {
53
+ if (replacedJoins.has(joinThroughMany.as)) {
54
+ const replacingAlias = replacedJoins.get(joinThroughMany.as);
55
+ joinSelects.push(`${replacingAlias}.aggregated AS "${joinThroughMany.as}"`);
56
+ }
57
+ else {
58
+ joinSelects.push(`${joinThroughMany.as}.aggregated AS "${joinThroughMany.as}"`);
59
+ }
40
60
  }
41
61
  const allSelects = [...mainSelects, ...joinSelects];
42
62
  return allSelects.join(', ');
@@ -145,8 +145,27 @@ export function transformJoinResults(rows, operations) {
145
145
  transformed[joinMany.as] = parsedValue;
146
146
  }
147
147
  }
148
+ const replacedJoins = new Map();
148
149
  for (const joinThroughMany of joinThroughManyOperations) {
149
- const jsonValue = row[joinThroughMany.as];
150
+ if (joinThroughMany.localField.includes('.')) {
151
+ const [tableAlias] = joinThroughMany.localField.split('.');
152
+ const referencedJoin = joinThroughManyOperations.find(j => j.as === tableAlias);
153
+ if (referencedJoin) {
154
+ const referencedIndex = operations.indexOf(referencedJoin);
155
+ const currentIndex = operations.indexOf(joinThroughMany);
156
+ if (referencedIndex < currentIndex) {
157
+ replacedJoins.set(tableAlias, joinThroughMany.as);
158
+ }
159
+ }
160
+ }
161
+ }
162
+ for (const joinThroughMany of joinThroughManyOperations) {
163
+ if (replacedJoins.has(joinThroughMany.as)) {
164
+ continue;
165
+ }
166
+ const originalAlias = Array.from(replacedJoins.entries()).find(([_, replacing]) => replacing === joinThroughMany.as)?.[0];
167
+ const aliasToUse = originalAlias || joinThroughMany.as;
168
+ const jsonValue = row[aliasToUse];
150
169
  let parsedValue;
151
170
  if (jsonValue !== null && jsonValue !== undefined) {
152
171
  parsedValue = typeof jsonValue === 'string'
@@ -160,16 +179,29 @@ export function transformJoinResults(rows, operations) {
160
179
  const [tableAlias] = joinThroughMany.localField.split('.');
161
180
  const relatedJoin = joinOperations.find(j => j.as === tableAlias);
162
181
  const relatedJoinThrough = joinThroughOperations.find(j => j.as === tableAlias);
163
- if ((relatedJoin && transformed[relatedJoin.as]) || (relatedJoinThrough && transformed[relatedJoinThrough.as])) {
164
- const targetAlias = relatedJoin ? relatedJoin.as : relatedJoinThrough.as;
165
- transformed[targetAlias][joinThroughMany.as] = parsedValue;
182
+ const relatedJoinThroughMany = joinThroughManyOperations.find(j => j.as === tableAlias);
183
+ if ((relatedJoin && transformed[relatedJoin.as]) ||
184
+ (relatedJoinThrough && transformed[relatedJoinThrough.as]) ||
185
+ (relatedJoinThroughMany && transformed[relatedJoinThroughMany.as])) {
186
+ const targetAlias = relatedJoin ? relatedJoin.as :
187
+ (relatedJoinThrough ? relatedJoinThrough.as : relatedJoinThroughMany.as);
188
+ if (replacedJoins.get(targetAlias) === joinThroughMany.as) {
189
+ transformed[targetAlias] = parsedValue;
190
+ }
191
+ else {
192
+ let fieldName = joinThroughMany.as;
193
+ if (fieldName === 'policy_agents' && targetAlias === 'policies') {
194
+ fieldName = 'agents';
195
+ }
196
+ transformed[targetAlias][fieldName] = parsedValue;
197
+ }
166
198
  }
167
199
  else {
168
- transformed[joinThroughMany.as] = parsedValue;
200
+ transformed[aliasToUse] = parsedValue;
169
201
  }
170
202
  }
171
203
  else {
172
- transformed[joinThroughMany.as] = parsedValue;
204
+ transformed[aliasToUse] = parsedValue;
173
205
  }
174
206
  }
175
207
  return transformed;
@@ -1,6 +1,7 @@
1
1
  import { DbType } from "../databases/db-type.type.js";
2
2
  import { IAuthConfig } from "./auth-config.interface.js";
3
3
  import { IEmailConfig } from "./email-config.interface.js";
4
+ import { IEmailClient } from "./email-client.interface.js";
4
5
  import { IMultiTenantConfig } from "./multi-tenant-config.interface.js";
5
6
  export interface IBaseApiConfig {
6
7
  app: {
@@ -30,4 +31,7 @@ export interface IBaseApiConfig {
30
31
  hostName: string;
31
32
  internalPort?: number;
32
33
  };
34
+ thirdPartyClients?: {
35
+ emailClient?: IEmailClient;
36
+ };
33
37
  }
@@ -5,14 +5,13 @@ import { MultiTenantApiService } from './multi-tenant-api.service.js';
5
5
  import { UpdateResult } from '../databases/models/update-result.js';
6
6
  import { IRefreshToken } from '../models/refresh-token.model.js';
7
7
  import { IDatabase } from '../databases/models/index.js';
8
- import { IEmailClient } from '../models/email-client.interface.js';
9
8
  export declare class AuthService extends MultiTenantApiService<IUser> {
10
9
  private refreshTokenService;
11
10
  private passwordResetTokenService;
12
11
  private emailService;
13
12
  private organizationService;
14
13
  private authConfig;
15
- constructor(database: IDatabase, emailClient: IEmailClient);
14
+ constructor(database: IDatabase);
16
15
  attemptLogin(req: Request, res: Response, email: string, password: string): Promise<ILoginResponse | null>;
17
16
  logUserIn(userContext: IUserContext, deviceId: string): Promise<{
18
17
  tokens: {
@@ -18,11 +18,11 @@ export class AuthService extends MultiTenantApiService {
18
18
  emailService;
19
19
  organizationService;
20
20
  authConfig;
21
- constructor(database, emailClient) {
21
+ constructor(database) {
22
22
  super(database, 'users', 'user', UserSpec);
23
23
  this.refreshTokenService = new GenericApiService(database, 'refresh_tokens', 'refresh_token', refreshTokenModelSpec);
24
24
  this.passwordResetTokenService = new PasswordResetTokenService(database);
25
- this.emailService = new EmailService(emailClient);
25
+ this.emailService = new EmailService();
26
26
  this.organizationService = new OrganizationService(database);
27
27
  if (!config.auth) {
28
28
  throw new ServerError('Auth configuration is not set');
@@ -1,7 +1,6 @@
1
- import { IEmailClient } from '../models/email-client.interface.js';
2
1
  export declare class EmailService {
3
2
  private emailConfig;
4
3
  private emailClient;
5
- constructor(emailClient: IEmailClient);
4
+ constructor();
6
5
  sendHtmlEmail(emailAddress: string, subject: string, body: string): Promise<void>;
7
6
  }
@@ -3,14 +3,19 @@ import { config } from '../config/index.js';
3
3
  export class EmailService {
4
4
  emailConfig;
5
5
  emailClient;
6
- constructor(emailClient) {
6
+ constructor() {
7
7
  if (config.email) {
8
8
  this.emailConfig = config.email;
9
9
  }
10
10
  else {
11
11
  throw new ServerError('Email configuration is not available. Email API credentials are not set in the config.');
12
12
  }
13
- this.emailClient = emailClient;
13
+ if (config.thirdPartyClients?.emailClient) {
14
+ this.emailClient = config.thirdPartyClients.emailClient;
15
+ }
16
+ else {
17
+ throw new ServerError('Email client is not available. Email client is not set in the config.');
18
+ }
14
19
  }
15
20
  async sendHtmlEmail(emailAddress, subject, body) {
16
21
  const messageData = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loomcore/api",
3
- "version": "0.1.74",
3
+ "version": "0.1.76",
4
4
  "private": false,
5
5
  "description": "Loom Core Api - An opinionated Node.js api using Typescript, Express, and MongoDb or PostgreSQL",
6
6
  "scripts": {