diff --git a/MICROSOFT_ACTIVE_DIRECTORY_OAUTH_FLOW.md b/MICROSOFT_ACTIVE_DIRECTORY_OAUTH_FLOW.md new file mode 100644 index 00000000..9b503ce8 --- /dev/null +++ b/MICROSOFT_ACTIVE_DIRECTORY_OAUTH_FLOW.md @@ -0,0 +1,616 @@ +# Microsoft Active Directory OAuth Flow + +This document explains how Microsoft Active Directory, also called Azure AD or Microsoft Entra ID, OAuth login works in Solid Core and Solid Core UI. + +## Important Short Answer + +On button click, frontend does not call the backend with `axios` or `fetch`. + +Frontend only builds this URL: + +```ts +const backendApiUrl = ( + env("NEXT_PUBLIC_BACKEND_API_URL") || env("API_URL") +).replace(/\/+$/, ""); + +const getOAuthConnectUrl = (provider: string) => + `${backendApiUrl}/api/iam/${provider}/connect`; +``` + +For Microsoft Active Directory, this becomes: + +```text +http://localhost:3000/api/iam/microsoft-active-directory/connect +``` + +Then frontend does: + +```ts +router.push(getOAuthConnectUrl("microsoft-active-directory")); +``` + +So frontend creates a URL and navigates the browser to that URL. Browser navigation itself makes the `GET /connect` request. + +This is correct for OAuth because OAuth needs full browser redirects: + +```text +Frontend page +-> Backend /connect +-> Microsoft login page +-> Backend /connect/callback +-> Frontend callback page +-> Backend /authenticate +``` + +## Why It Is Not A Normal API Call + +Normal login with username/password can use an API call: + +```text +frontend axios/fetch -> backend -> response JSON +``` + +OAuth login cannot work like that for the first step because the user must leave our app and open Microsoft login in the browser. + +So the first step is navigation: + +```text +browser location changes to backend /connect +``` + +After Microsoft login completes, the final token exchange uses an API call: + +```text +frontend -> backend /authenticate?accessCode=... +``` + +## Files Involved + +Backend files: + +```text +src/controllers/microsoft-active-directory-authentication.controller.ts +src/passport-strategies/microsoft-active-directory-oauth.strategy.ts +src/helpers/microsoft-active-directory-oauth.helper.ts +src/services/user.service.ts +src/services/authentication.service.ts +src/services/settings/default-settings-provider.service.ts +``` + +Frontend files: + +```text +src/components/common/SocialMediaLogin.tsx +src/routes/pages/auth/InitiateMicrosoftActiveDirectoryOauthPage.tsx +src/components/auth/MicrosoftActiveDirectoryAuthChecking.tsx +src/adapters/auth/signInWithOAuthAccessCode.ts +src/routes/solidRoutes.tsx +``` + +## Backend Endpoints + +### 1. Start Microsoft Active Directory OAuth + +```http +GET /api/iam/microsoft-active-directory/connect +``` + +Controller method: + +```ts +@Public() +@UseGuards(MicrosoftActiveDirectoryOauthGuard) +@Get("connect") +async connect() { + await this.validateConfiguration(); +} +``` + +This endpoint starts OAuth. The guard runs before the method body. + +The guard: + +```ts +export class MicrosoftActiveDirectoryOauthGuard + extends AuthGuard("microsoft-active-directory") {} +``` + +This tells Passport to use the strategy named: + +```text +microsoft-active-directory +``` + +### 2. Microsoft Callback + +```http +GET /api/iam/microsoft-active-directory/connect/callback +``` + +Controller method: + +```ts +@Public() +@Get("connect/callback") +@UseGuards(MicrosoftActiveDirectoryOauthGuard) +async microsoftActiveDirectoryAuthCallback(@Req() req, @Res() res) { + const config = await this.validateConfiguration(); + const user = req.user; + + return res.redirect( + this.buildFrontendRedirectUrl(config.redirectURL, user["accessCode"]), + ); +} +``` + +Microsoft redirects to this endpoint after successful login. + +This route also uses `MicrosoftActiveDirectoryOauthGuard`, but now Microsoft sends a `code` in the URL. Passport uses that code to get an access token and profile from Microsoft. + +### 3. Authenticate With accessCode + +```http +GET /api/iam/microsoft-active-directory/authenticate?accessCode= +``` + +Controller method: + +```ts +@Public() +@Get("authenticate") +async microsoftActiveDirectoryAuth(@Query("accessCode") accessCode: string) { + await this.validateConfiguration(); + return this.authService.signInUsingMicrosoftActiveDirectory(accessCode); +} +``` + +Frontend calls this after it receives `accessCode` from the callback redirect. + +This returns application JWT tokens. + +## Complete Request Flow + +### Step 1: User Clicks Button + +Frontend file: + +```text +src/components/common/SocialMediaLogin.tsx +``` + +Click handler: + +```ts +onClick={() => router.push(getOAuthConnectUrl("microsoft-active-directory"))} +``` + +This creates: + +```text +http://localhost:3000/api/iam/microsoft-active-directory/connect +``` + +Then browser opens that URL. + +Note: yahan frontend direct API call nahi kar raha. Sirf URL bana kar browser ko us URL par bhej raha hai. + +### Step 2: Backend /connect Runs Guard + +Backend route: + +```http +GET /api/iam/microsoft-active-directory/connect +``` + +Before `connect()` runs, Nest runs: + +```ts +MicrosoftActiveDirectoryOauthGuard +``` + +Guard calls Passport strategy: + +```ts +AuthGuard("microsoft-active-directory") +``` + +Strategy config: + +```ts +super({ + clientID, + clientSecret, + callbackURL, + tenant, + scope: MICROSOFT_ACTIVE_DIRECTORY_OAUTH_SCOPES, + addUPNAsEmail: true, +}); +``` + +Passport redirects the browser to Microsoft login page. + +### Step 3: Microsoft Login Page Opens + +Browser leaves our app and opens Microsoft login. + +Microsoft receives values like: + +```text +client_id +redirect_uri +scope +tenant +response_type=code +``` + +The `redirect_uri` comes from: + +```env +IAM_MICROSOFT_ACTIVE_DIRECTORY_OAUTH_CALLBACK_URL +``` + +Example: + +```env +IAM_MICROSOFT_ACTIVE_DIRECTORY_OAUTH_CALLBACK_URL=http://localhost:3000/api/iam/microsoft-active-directory/connect/callback +``` + +This exact URL must be registered in Azure App Registration. + +### Step 4: Microsoft Redirects Back To Backend + +After successful Microsoft login, Microsoft redirects browser to: + +```text +http://localhost:3000/api/iam/microsoft-active-directory/connect/callback?code=... +``` + +This is the callback route. + +### Step 5: Guard Runs Again On Callback + +The callback route also has: + +```ts +@UseGuards(MicrosoftActiveDirectoryOauthGuard) +``` + +Now the guard sees Microsoft `code`. + +Passport exchanges that `code` with Microsoft and gets: + +```text +access token +profile +``` + +Then Passport calls the strategy `validate()` method. + +### Step 6: Strategy validate() Creates accessCode + +Backend file: + +```text +src/passport-strategies/microsoft-active-directory-oauth.strategy.ts +``` + +Method: + +```ts +async validate(_accessToken, _refreshToken, profile, done) { + const loginAccessCode = uuid(); + + const user = { + provider: "microsoftActiveDirectory", + providerId, + email, + name, + picture, + accessCode: loginAccessCode, + }; + + await this.userService.resolveUserOnOauthMicrosoftActiveDirectory({ + ...user, + accessToken: _accessToken, + refreshToken: null, + }); + + done(null, user); +} +``` + +This method creates a temporary `accessCode`. This is not the JWT. It is only a one-time code used by frontend to complete login. + +### Step 7: User Is Created Or Updated + +Backend file: + +```text +src/services/user.service.ts +``` + +Method: + +```ts +resolveUserOnOauthMicrosoftActiveDirectory() +``` + +It checks user by email: + +```text +if user does not exist: + create user + save Microsoft Active Directory id/token/profile picture + save accessCode + initialize default role + +if user exists: + update Microsoft Active Directory id/token/profile picture + update accessCode +``` + +Important fields saved: + +```text +accessCode +microsoftActiveDirectoryId +microsoftActiveDirectoryAccessToken +microsoftActiveDirectoryProfilePicture +lastLoginProvider +``` + +### Step 8: Backend Redirects To Frontend + +After strategy validation, callback controller gets `req.user`. + +Then it redirects to frontend: + +```ts +this.buildFrontendRedirectUrl(config.redirectURL, user["accessCode"]) +``` + +`config.redirectURL` comes from: + +```env +IAM_MICROSOFT_ACTIVE_DIRECTORY_OAUTH_REDIRECT_URL +``` + +Example: + +```env +IAM_MICROSOFT_ACTIVE_DIRECTORY_OAUTH_REDIRECT_URL=http://localhost:3001/auth/initiate-microsoft-active-directory-oauth +``` + +Final redirect URL becomes: + +```text +http://localhost:3001/auth/initiate-microsoft-active-directory-oauth?accessCode= +``` + +This URL is frontend, not backend. + +### Step 9: Frontend Reads accessCode + +Frontend route: + +```text +/auth/initiate-microsoft-active-directory-oauth +``` + +Frontend file: + +```text +src/routes/pages/auth/InitiateMicrosoftActiveDirectoryOauthPage.tsx +``` + +It renders: + +```ts + +``` + +That component reads: + +```ts +const accessCode = searchParams.get("accessCode"); +``` + +### Step 10: Frontend Calls Backend /authenticate + +Frontend file: + +```text +src/adapters/auth/signInWithOAuthAccessCode.ts +``` + +It calls: + +```ts +solidGet( + `${apiUrl}/api/iam/${provider}/authenticate?accessCode=${encodeURIComponent(accessCode)}` +) +``` + +For Microsoft Active Directory: + +```text +GET /api/iam/microsoft-active-directory/authenticate?accessCode= +``` + +This is the real frontend API call. + +### Step 11: Backend Validates accessCode And Returns JWT + +Backend file: + +```text +src/services/authentication.service.ts +``` + +Method: + +```ts +signInUsingMicrosoftActiveDirectory(accessCode) +``` + +It does: + +```text +1. Find user by accessCode +2. Check account is not blocked +3. Validate saved Microsoft access token using Microsoft Graph /me +4. Verify Microsoft profile id/email matches the saved user +5. Generate application JWT accessToken and refreshToken +6. Return user and tokens +``` + +After frontend receives tokens, it stores session the same way as existing OAuth login. + +## callbackURL vs redirectURL + +These two are different and both are required. + +### callbackURL + +Used by Microsoft to come back to backend: + +```env +IAM_MICROSOFT_ACTIVE_DIRECTORY_OAUTH_CALLBACK_URL=http://localhost:3000/api/iam/microsoft-active-directory/connect/callback +``` + +This must be registered in Azure App Registration. + +### redirectURL + +Used by backend to send the user back to frontend: + +```env +IAM_MICROSOFT_ACTIVE_DIRECTORY_OAUTH_REDIRECT_URL=http://localhost:3001/auth/initiate-microsoft-active-directory-oauth +``` + +This does not go in Azure redirect URI list unless your Azure app directly redirects to frontend, which this backend flow does not do. + +## Required Environment Variables + +Backend: + +```env +IAM_MICROSOFT_ACTIVE_DIRECTORY_OAUTH_CLIENT_ID= +IAM_MICROSOFT_ACTIVE_DIRECTORY_OAUTH_CLIENT_SECRET= +IAM_MICROSOFT_ACTIVE_DIRECTORY_OAUTH_TENANT_ID= +IAM_MICROSOFT_ACTIVE_DIRECTORY_OAUTH_CALLBACK_URL=http://localhost:3000/api/iam/microsoft-active-directory/connect/callback +IAM_MICROSOFT_ACTIVE_DIRECTORY_OAUTH_REDIRECT_URL=http://localhost:3001/auth/initiate-microsoft-active-directory-oauth +``` + +Frontend: + +```env +VITE_BACKEND_API_URL=http://localhost:3000 +VITE_API_URL=http://localhost:3000 +VITE_BASE_URL=http://localhost:3001 +``` + +## Azure App Registration Setup + +In Azure Portal: + +```text +Microsoft Entra ID +-> App registrations +-> Your app +-> Authentication +-> Add platform +-> Web +-> Redirect URIs +``` + +Add exactly: + +```text +http://localhost:3000/api/iam/microsoft-active-directory/connect/callback +``` + +The value must exactly match `IAM_MICROSOFT_ACTIVE_DIRECTORY_OAUTH_CALLBACK_URL`. + +Exact means same: + +```text +protocol: http vs https +host: localhost +port: 3000 +path: /api/iam/microsoft-active-directory/connect/callback +trailing slash: no extra slash +``` + +## Common Mistakes + +### redirect_uri is not valid + +Reason: + +```text +IAM_MICROSOFT_ACTIVE_DIRECTORY_OAUTH_CALLBACK_URL does not match Azure redirect URI. +``` + +Fix: + +```text +Register the exact callback URL in Azure. +``` + +### Using frontend URL as callbackURL + +Wrong: + +```env +IAM_MICROSOFT_ACTIVE_DIRECTORY_OAUTH_CALLBACK_URL=http://localhost:3001/auth/initiate-microsoft-active-directory-oauth +``` + +Correct: + +```env +IAM_MICROSOFT_ACTIVE_DIRECTORY_OAUTH_CALLBACK_URL=http://localhost:3000/api/iam/microsoft-active-directory/connect/callback +``` + +### Using backend API URL as redirectURL + +Wrong: + +```env +IAM_MICROSOFT_ACTIVE_DIRECTORY_OAUTH_REDIRECT_URL=http://localhost:3000/api/auth/initiate-microsoft-active-directory-oauth +``` + +Correct: + +```env +IAM_MICROSOFT_ACTIVE_DIRECTORY_OAUTH_REDIRECT_URL=http://localhost:3001/auth/initiate-microsoft-active-directory-oauth +``` + +### Empty client id crashes strategy + +Wrong: + +```env +IAM_MICROSOFT_ACTIVE_DIRECTORY_OAUTH_CLIENT_ID= +``` + +If OAuth is not configured, leave it absent or let the strategy use dummy startup values. If OAuth should work, set the real Azure client id. + +## Final Flow Summary + +```text +1. User clicks Microsoft Active Directory button +2. Frontend builds backend /connect URL +3. Frontend navigates browser to backend /connect +4. Backend Passport guard redirects browser to Microsoft +5. User logs in on Microsoft +6. Microsoft redirects browser to backend /connect/callback with code +7. Backend exchanges code for Microsoft token/profile +8. Backend creates/updates user and stores temporary accessCode +9. Backend redirects browser to frontend redirectURL with accessCode +10. Frontend reads accessCode +11. Frontend calls backend /authenticate?accessCode=... +12. Backend verifies user/token and returns application JWT +13. Frontend stores JWT session +``` + diff --git a/ai-interaction-cleanup.sql b/ai-interaction-cleanup.sql new file mode 100644 index 00000000..c8f3e4c6 --- /dev/null +++ b/ai-interaction-cleanup.sql @@ -0,0 +1,53 @@ +-- ============================================================= +-- AI Interaction Metadata Cleanup Script +-- Run this against your PostgreSQL database to remove all +-- metadata and data associated with the aiInteraction model. +-- ============================================================= + +BEGIN; + +-- 1. Remove menu item +DELETE FROM ss_menu_item_metadata +WHERE name = 'aiInteraction-menu-item'; + +-- 2. Remove action +DELETE FROM ss_action_metadata +WHERE name = 'aiInteraction-list-action'; + +-- 3. Remove user-level view customisations for aiInteraction views +DELETE FROM ss_user_view_metadata +WHERE view_metadata_id IN ( + SELECT id FROM ss_view_metadata + WHERE name IN ('aiInteraction-list-view', 'aiInteraction-form-view') +); + +-- 4. Remove saved filters pointing at the aiInteraction model +DELETE FROM ss_saved_fitlers +WHERE model_id = ( + SELECT id FROM ss_model_metadata WHERE singular_name = 'aiInteraction' +); + +-- 5. Remove security rules scoped to the aiInteraction model +DELETE FROM ss_security_rule +WHERE model_metadata_id = ( + SELECT id FROM ss_model_metadata WHERE singular_name = 'aiInteraction' +); + +-- 6. Remove views +DELETE FROM ss_view_metadata +WHERE name IN ('aiInteraction-list-view', 'aiInteraction-form-view'); + +-- 7. Remove field metadata belonging to the aiInteraction model +DELETE FROM ss_field_metadata +WHERE model_id = ( + SELECT id FROM ss_model_metadata WHERE singular_name = 'aiInteraction' +); + +-- 8. Remove the model metadata record itself +DELETE FROM ss_model_metadata +WHERE singular_name = 'aiInteraction'; + +-- 9. Drop the actual data table +DROP TABLE IF EXISTS ss_ai_interactions; + +COMMIT; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index a2a0e669..9831fb0c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,13 @@ { "name": "@solidxai/core", - "version": "0.1.9", + "version": "0.1.10-beta.28", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@solidxai/core", - "version": "0.1.9", - "license": "ISC", + "version": "0.1.10-beta.28", + "license": "BUSL-1.1", "dependencies": { "@aws-sdk/client-s3": "^3.637.0", "@aws-sdk/client-textract": "^3.873.0", @@ -19,6 +19,7 @@ "@nestjs/throttler": "^6.4.0", "@types/passport-apple": "^2.0.3", "amqplib": "^0.10.4", + "archiver": "^5.3.2", "axios": "^1.7.0", "bcrypt": "^5.1.1", "bson": "^6.10.1", @@ -49,6 +50,7 @@ "passport-local": "^1.0.0", "passport-microsoft": "^2.1.0", "pg": "^8.11.3", + "playwright": ">=1.0.0", "pluralize": "^8.0.0", "puppeteer": "^23.2.0", "qs": "^6.12.0", @@ -78,11 +80,13 @@ "@nestjs/mongoose": "^10.0.10", "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.0.0", + "@nestjs/platform-ws": "^10.0.0", "@nestjs/serve-static": "^4.0.2", "@nestjs/swagger": "^7.2.0", "@nestjs/testing": "^10.0.0", "@nestjs/typeorm": "^10.0.1", - "@solidxai/code-builder": "^0.0.2", + "@nestjs/websockets": "^10.0.0", + "@solidxai/code-builder": "^0.1.8", "@types/express": "^4.17.17", "@types/hapi__joi": "^17.1.12", "@types/jest": "^29.5.2", @@ -135,22 +139,18 @@ "@nestjs/mongoose": "^10.0.10", "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.0.0", + "@nestjs/platform-ws": "^10.0.0", "@nestjs/serve-static": "^4.0.2", "@nestjs/swagger": "^7.2.0", "@nestjs/typeorm": "^10.0.1", + "@nestjs/websockets": "^10.0.0", "nest-commander": "^3.12.5", "nest-winston": "^1.9.7", "nestjs-cls": "^5.4.3", - "playwright": ">=1.0.0", "reflect-metadata": "^0.2.2", "typeorm": "^0.3.20", "typeorm-naming-strategies": "^4.1.0", "winston": "^3.17.0" - }, - "peerDependenciesMeta": { - "playwright": { - "optional": true - } } }, "node_modules/@angular-devkit/core": { @@ -3879,6 +3879,48 @@ "@nestjs/core": "^10.0.0" } }, + "node_modules/@nestjs/platform-ws": { + "version": "10.4.22", + "resolved": "https://registry.npmjs.org/@nestjs/platform-ws/-/platform-ws-10.4.22.tgz", + "integrity": "sha512-ZBL66p8axCyvQw6lP6R5uMAamVGfDb0/LtbdxDjMjbWb5/wi070P0MWrjzTudEA3ThsDMNOsfawZlsFUkSfCzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "2.8.1", + "ws": "8.18.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0", + "@nestjs/websockets": "^10.0.0", + "rxjs": "^7.1.0" + } + }, + "node_modules/@nestjs/platform-ws/node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/@nestjs/schedule": { "version": "6.1.3", "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-6.1.3.tgz", @@ -4194,6 +4236,30 @@ "typeorm": "^0.3.0" } }, + "node_modules/@nestjs/websockets": { + "version": "10.4.22", + "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.4.22.tgz", + "integrity": "sha512-OLd4i0Faq7vgdtB5vVUrJ54hWEtcXy9poJ6n7kbbh/5ms+KffUl+wwGsbe7uSXLrkoyI8xXU6fZPkFArI+XiRg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "iterare": "1.2.1", + "object-hash": "3.0.0", + "tslib": "2.8.1" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0", + "@nestjs/core": "^10.0.0", + "@nestjs/platform-socket.io": "^10.0.0", + "reflect-metadata": "^0.1.12 || ^0.2.0", + "rxjs": "^7.1.0" + }, + "peerDependenciesMeta": { + "@nestjs/platform-socket.io": { + "optional": true + } + } + }, "node_modules/@nicolo-ribaudo/chokidar-2": { "version": "2.1.8-no-fsevents.3", "resolved": "https://registry.npmjs.org/@nicolo-ribaudo/chokidar-2/-/chokidar-2-2.1.8-no-fsevents.3.tgz", @@ -5232,11 +5298,11 @@ } }, "node_modules/@solidxai/code-builder": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/@solidxai/code-builder/-/code-builder-0.0.2.tgz", - "integrity": "sha512-W+JLL+V5LJjn2zYyHuoyZMJFm7g5ahzjiJ+XtM+bnMF+1PjHKXcu2h/vC3Nq8c72Alz25aNu03nqbQPBBhnhiA==", + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@solidxai/code-builder/-/code-builder-0.1.8.tgz", + "integrity": "sha512-rrHZdPufn0RCtTbSoWPqWCePXmZOLSC+uyYqFSpSZsZB23WDcBRV9oYFB8i44rhIeOS/lgL+C1EgFylKetT6vQ==", "dev": true, - "license": "MIT", + "license": "BUSL-1.1", "dependencies": { "@angular-devkit/core": "^18.0.1", "@angular-devkit/schematics": "^18.0.1", @@ -13697,6 +13763,16 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -14489,8 +14565,6 @@ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", "license": "Apache-2.0", - "optional": true, - "peer": true, "dependencies": { "playwright-core": "1.59.1" }, @@ -14509,8 +14583,6 @@ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", "license": "Apache-2.0", - "optional": true, - "peer": true, "bin": { "playwright-core": "cli.js" }, @@ -14528,7 +14600,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } diff --git a/package.json b/package.json index e61ecb1e..6ffc30ce 100755 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@solidxai/core", - "version": "0.1.9", + "version": "0.1.10-beta.28", "description": "This module is a NestJS module containing all the required core providers required by a Solid application", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -40,6 +40,7 @@ "@nestjs/throttler": "^6.4.0", "@types/passport-apple": "^2.0.3", "amqplib": "^0.10.4", + "archiver": "^5.3.2", "axios": "^1.7.0", "bcrypt": "^5.1.1", "bson": "^6.10.1", @@ -70,6 +71,7 @@ "passport-local": "^1.0.0", "passport-microsoft": "^2.1.0", "pg": "^8.11.3", + "playwright": ">=1.0.0", "pluralize": "^8.0.0", "puppeteer": "^23.2.0", "qs": "^6.12.0", @@ -81,11 +83,7 @@ "uuid": "^9.0.1", "xlsx": "^0.18.5" }, - "peerDependenciesMeta": { - "playwright": { - "optional": true - } - }, + "peerDependenciesMeta": {}, "peerDependencies": { "@nestjs/axios": "^3.0.2", "@nestjs/cache-manager": "^2.2.2", @@ -98,13 +96,14 @@ "@nestjs/mongoose": "^10.0.10", "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.0.0", + "@nestjs/platform-ws": "^10.0.0", "@nestjs/serve-static": "^4.0.2", "@nestjs/swagger": "^7.2.0", "@nestjs/typeorm": "^10.0.1", + "@nestjs/websockets": "^10.0.0", "nest-commander": "^3.12.5", "nest-winston": "^1.9.7", "nestjs-cls": "^5.4.3", - "playwright": ">=1.0.0", "reflect-metadata": "^0.2.2", "typeorm": "^0.3.20", "typeorm-naming-strategies": "^4.1.0", @@ -125,11 +124,13 @@ "@nestjs/mongoose": "^10.0.10", "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.0.0", + "@nestjs/platform-ws": "^10.0.0", "@nestjs/serve-static": "^4.0.2", "@nestjs/swagger": "^7.2.0", "@nestjs/testing": "^10.0.0", "@nestjs/typeorm": "^10.0.1", - "@solidxai/code-builder": "^0.0.2", + "@nestjs/websockets": "^10.0.0", + "@solidxai/code-builder": "^0.1.8", "@types/express": "^4.17.17", "@types/hapi__joi": "^17.1.12", "@types/jest": "^29.5.2", diff --git a/sql/default/mariadb/proc_CleanupModelMetadata.sql b/sql/default/mariadb/proc_CleanupModelMetadata.sql deleted file mode 100644 index 19627346..00000000 --- a/sql/default/mariadb/proc_CleanupModelMetadata.sql +++ /dev/null @@ -1,153 +0,0 @@ -DROP PROCEDURE IF EXISTS cleanup_model_metadata; - -DELIMITER $$ - -CREATE PROCEDURE cleanup_model_metadata( - IN p_model_singular_name VARCHAR(255), - IN p_also_drop_model TINYINT(1) -- 0 = false, 1 = true (DEFAULT 0) -) -BEGIN - DECLARE v_model_id INT; - DECLARE v_controller_name VARCHAR(512); - - -- Default p_also_drop_model to 0 when NULL is passed - IF p_also_drop_model IS NULL THEN - SET p_also_drop_model = 0; - END IF; - - -------------------------------------------------------------------- - -- Derive ControllerName from model singular name - -- e.g. 'city' -> 'CityController' - -- 'bankIfscMaster' -> 'BankIfscMasterController' - -------------------------------------------------------------------- - SET v_controller_name = CONCAT( - UPPER(LEFT(p_model_singular_name, 1)), - SUBSTR(p_model_singular_name, 2), - 'Controller' - ); - - SELECT m.id - INTO v_model_id - FROM ss_model_metadata AS m - WHERE m.singular_name = p_model_singular_name - LIMIT 1; - - IF v_model_id IS NULL THEN - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = CONCAT('CleanupModelMetadata: Model with singular_name "', p_model_singular_name, '" not found.'); - END IF; - - -------------------------------------------------------------------- - -- Temp tables for IDs (session-scoped, dropped at end of session) - -------------------------------------------------------------------- - DROP TEMPORARY TABLE IF EXISTS tmp_view_ids; - DROP TEMPORARY TABLE IF EXISTS tmp_action_ids; - DROP TEMPORARY TABLE IF EXISTS tmp_permission_ids; - - CREATE TEMPORARY TABLE tmp_view_ids (id INT PRIMARY KEY); - CREATE TEMPORARY TABLE tmp_action_ids (id INT PRIMARY KEY); - CREATE TEMPORARY TABLE tmp_permission_ids (id INT PRIMARY KEY); - - INSERT INTO tmp_view_ids (id) - SELECT v.id - FROM ss_view_metadata AS v - WHERE v.model_id = v_model_id; - - INSERT INTO tmp_action_ids (id) - SELECT a.id - FROM ss_action_metadata AS a - WHERE a.model_id = v_model_id; - - -------------------------------------------------------------------- - -- Collect permissions for this controller: - -- e.g. CityController.create, CityController.update, etc. - -------------------------------------------------------------------- - INSERT INTO tmp_permission_ids (id) - SELECT p.id - FROM ss_permission_metadata AS p - WHERE p.name LIKE CONCAT(v_controller_name, '.%'); - - -------------------------------------------------------------------- - -- 1) Delete user-view metadata - -------------------------------------------------------------------- - DELETE FROM ss_user_view_metadata - WHERE view_metadata_id IN (SELECT id FROM tmp_view_ids); - - -------------------------------------------------------------------- - -- 2) Delete menu items linked via action_id - -------------------------------------------------------------------- - DELETE FROM ss_menu_item_metadata - WHERE action_id IN (SELECT id FROM tmp_action_ids); - - -------------------------------------------------------------------- - -- 3) Delete actions - -------------------------------------------------------------------- - DELETE FROM ss_action_metadata - WHERE id IN (SELECT id FROM tmp_action_ids); - - -------------------------------------------------------------------- - -- 4) Delete views - -------------------------------------------------------------------- - DELETE FROM ss_view_metadata - WHERE id IN (SELECT id FROM tmp_view_ids); - - -------------------------------------------------------------------- - -- 5) Delete import transaction error logs for this model's imports - -------------------------------------------------------------------- - DELETE FROM ss_import_transaction_error_log - WHERE import_transaction_id IN ( - SELECT it.id - FROM ss_import_transaction AS it - WHERE it.model_metadata_id = v_model_id - ); - - -------------------------------------------------------------------- - -- 6) Delete import transactions linked to the model - -------------------------------------------------------------------- - DELETE FROM ss_import_transaction - WHERE model_metadata_id = v_model_id; - - -------------------------------------------------------------------- - -- 7) Delete permissions for this controller - -- a) Delete from role-permission join table - -- b) Delete from permission metadata - -------------------------------------------------------------------- - DELETE FROM ss_role_metadata_permissions_ss_permission_metadata - WHERE ss_permission_metadata_id IN (SELECT id FROM tmp_permission_ids); - - DELETE FROM ss_permission_metadata - WHERE id IN (SELECT id FROM tmp_permission_ids); - - -------------------------------------------------------------------- - -- 8) Optionally delete the model row itself (+ fields) - -------------------------------------------------------------------- - IF p_also_drop_model = 1 THEN - - -- 8a) Null user_key_field_id pointing to fields of this model - UPDATE ss_model_metadata AS mm - SET user_key_field_id = NULL - WHERE user_key_field_id IN ( - SELECT f.id - FROM ss_field_metadata AS f - WHERE f.model_id = v_model_id - ); - - -- 8b) Null parent_model_id pointing to this model - UPDATE ss_model_metadata - SET parent_model_id = NULL - WHERE parent_model_id = v_model_id; - - -- 8c) Delete model (fields deleted via ON DELETE CASCADE) - DELETE FROM ss_model_metadata - WHERE id = v_model_id; - - END IF; - - -- Cleanup temp tables - DROP TEMPORARY TABLE IF EXISTS tmp_view_ids; - DROP TEMPORARY TABLE IF EXISTS tmp_action_ids; - DROP TEMPORARY TABLE IF EXISTS tmp_permission_ids; - -END$$ - -DELIMITER ; diff --git a/sql/default/mariadb/proc_CleanupModuleMetadata.sql b/sql/default/mariadb/proc_CleanupModuleMetadata.sql deleted file mode 100644 index 97c81206..00000000 --- a/sql/default/mariadb/proc_CleanupModuleMetadata.sql +++ /dev/null @@ -1,56 +0,0 @@ -DROP PROCEDURE IF EXISTS cleanup_module_metadata; - -DELIMITER $$ - -CREATE PROCEDURE cleanup_module_metadata( - IN p_module_name VARCHAR(255), - IN p_also_drop_module TINYINT(1) -- 0 = false, 1 = true (DEFAULT 0) -) -BEGIN - DECLARE v_module_id INT; - - -- Default p_also_drop_module to 0 when NULL is passed - IF p_also_drop_module IS NULL THEN - SET p_also_drop_module = 0; - END IF; - - SELECT m.id - INTO v_module_id - FROM ss_module_metadata AS m - WHERE m.name = p_module_name - LIMIT 1; - - IF v_module_id IS NULL THEN - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = CONCAT('CleanupModuleMetadata: Module "', p_module_name, '" not found.'); - END IF; - - ---------------------------------------------------------------- - -- 1) Delete menus for this module (children first, then roots) - ---------------------------------------------------------------- - DELETE FROM ss_menu_item_metadata - WHERE module_id = v_module_id - AND parent_menu_item_id IS NOT NULL; - - DELETE FROM ss_menu_item_metadata - WHERE module_id = v_module_id - AND parent_menu_item_id IS NULL; - - ---------------------------------------------------------------- - -- 2) Delete actions for this module - ---------------------------------------------------------------- - DELETE FROM ss_action_metadata - WHERE module_id = v_module_id; - - ---------------------------------------------------------------- - -- 3) Optionally delete the module row itself - -- (assumes ss_model_metadata.module_id has ON DELETE SET NULL) - ---------------------------------------------------------------- - IF p_also_drop_module = 1 THEN - DELETE FROM ss_module_metadata - WHERE id = v_module_id; - END IF; - -END$$ - -DELIMITER ; diff --git a/sql/default/mssql/proc_CleanupModuleMetadata.sql b/sql/default/mssql/proc_CleanupModuleMetadata.sql deleted file mode 100644 index d0f78976..00000000 --- a/sql/default/mssql/proc_CleanupModuleMetadata.sql +++ /dev/null @@ -1,78 +0,0 @@ -CREATE PROCEDURE [dbo].[CleanupModuleMetadata] - @ModuleName NVARCHAR(255), - @AlsoDropModule BIT = 0 -- 0 = only menus + actions, 1 = also delete module row -AS -BEGIN - SET NOCOUNT ON; - - DECLARE @ModuleId INT; - - SELECT @ModuleId = m.id - FROM dbo.ss_module_metadata AS m - WHERE m.name = @ModuleName; - - IF @ModuleId IS NULL - BEGIN - RAISERROR ('CleanupModuleMetadata: Module "%s" not found.', 16, 1, @ModuleName); - RETURN; - END - - BEGIN TRY - BEGIN TRAN; - - ---------------------------------------------------------------- - -- 1) Delete menu items for this module - -- (children first because parent FK has NO cascade) - ---------------------------------------------------------------- - -- Children - DELETE mi - FROM dbo.ss_menu_item_metadata AS mi - WHERE mi.module_id = @ModuleId - AND mi.parent_menu_item_id IS NOT NULL; - - -- Roots - DELETE mi - FROM dbo.ss_menu_item_metadata AS mi - WHERE mi.module_id = @ModuleId - AND mi.parent_menu_item_id IS NULL; - - -- Note: Role join table - -- dbo.ss_menu_item_metadata_roles_ss_role_metadata - -- is cleaned automatically via ON DELETE CASCADE from menu items. - - ---------------------------------------------------------------- - -- 2) Delete actions for this module - ---------------------------------------------------------------- - DELETE a - FROM dbo.ss_action_metadata AS a - WHERE a.module_id = @ModuleId; - - ---------------------------------------------------------------- - -- 3) Optionally delete the module row itself - -- Models keep their rows; module_id is set to NULL on delete - -- because of ON DELETE SET NULL in ss_model_metadata. - ---------------------------------------------------------------- - IF @AlsoDropModule = 1 - BEGIN - DELETE m - FROM dbo.ss_module_metadata AS m - WHERE m.id = @ModuleId; - END - - COMMIT TRAN; - END TRY - BEGIN CATCH - IF @@TRANCOUNT > 0 - ROLLBACK TRAN; - - DECLARE @ErrorMessage NVARCHAR(4000), - @ErrorSeverity INT, - @ErrorState INT; - - SELECT @ErrorMessage = ERROR_MESSAGE(), - @ErrorSeverity = ERROR_SEVERITY(), - @ErrorState = ERROR_STATE(); - - RAISERROR(@ErrorMessage, @ErrorSeverity, @ErrorState); - END CATCH -END diff --git a/sql/default/mysql/proc_CleanupModelMetadata.sql b/sql/default/mysql/proc_CleanupModelMetadata.sql deleted file mode 100644 index 19627346..00000000 --- a/sql/default/mysql/proc_CleanupModelMetadata.sql +++ /dev/null @@ -1,153 +0,0 @@ -DROP PROCEDURE IF EXISTS cleanup_model_metadata; - -DELIMITER $$ - -CREATE PROCEDURE cleanup_model_metadata( - IN p_model_singular_name VARCHAR(255), - IN p_also_drop_model TINYINT(1) -- 0 = false, 1 = true (DEFAULT 0) -) -BEGIN - DECLARE v_model_id INT; - DECLARE v_controller_name VARCHAR(512); - - -- Default p_also_drop_model to 0 when NULL is passed - IF p_also_drop_model IS NULL THEN - SET p_also_drop_model = 0; - END IF; - - -------------------------------------------------------------------- - -- Derive ControllerName from model singular name - -- e.g. 'city' -> 'CityController' - -- 'bankIfscMaster' -> 'BankIfscMasterController' - -------------------------------------------------------------------- - SET v_controller_name = CONCAT( - UPPER(LEFT(p_model_singular_name, 1)), - SUBSTR(p_model_singular_name, 2), - 'Controller' - ); - - SELECT m.id - INTO v_model_id - FROM ss_model_metadata AS m - WHERE m.singular_name = p_model_singular_name - LIMIT 1; - - IF v_model_id IS NULL THEN - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = CONCAT('CleanupModelMetadata: Model with singular_name "', p_model_singular_name, '" not found.'); - END IF; - - -------------------------------------------------------------------- - -- Temp tables for IDs (session-scoped, dropped at end of session) - -------------------------------------------------------------------- - DROP TEMPORARY TABLE IF EXISTS tmp_view_ids; - DROP TEMPORARY TABLE IF EXISTS tmp_action_ids; - DROP TEMPORARY TABLE IF EXISTS tmp_permission_ids; - - CREATE TEMPORARY TABLE tmp_view_ids (id INT PRIMARY KEY); - CREATE TEMPORARY TABLE tmp_action_ids (id INT PRIMARY KEY); - CREATE TEMPORARY TABLE tmp_permission_ids (id INT PRIMARY KEY); - - INSERT INTO tmp_view_ids (id) - SELECT v.id - FROM ss_view_metadata AS v - WHERE v.model_id = v_model_id; - - INSERT INTO tmp_action_ids (id) - SELECT a.id - FROM ss_action_metadata AS a - WHERE a.model_id = v_model_id; - - -------------------------------------------------------------------- - -- Collect permissions for this controller: - -- e.g. CityController.create, CityController.update, etc. - -------------------------------------------------------------------- - INSERT INTO tmp_permission_ids (id) - SELECT p.id - FROM ss_permission_metadata AS p - WHERE p.name LIKE CONCAT(v_controller_name, '.%'); - - -------------------------------------------------------------------- - -- 1) Delete user-view metadata - -------------------------------------------------------------------- - DELETE FROM ss_user_view_metadata - WHERE view_metadata_id IN (SELECT id FROM tmp_view_ids); - - -------------------------------------------------------------------- - -- 2) Delete menu items linked via action_id - -------------------------------------------------------------------- - DELETE FROM ss_menu_item_metadata - WHERE action_id IN (SELECT id FROM tmp_action_ids); - - -------------------------------------------------------------------- - -- 3) Delete actions - -------------------------------------------------------------------- - DELETE FROM ss_action_metadata - WHERE id IN (SELECT id FROM tmp_action_ids); - - -------------------------------------------------------------------- - -- 4) Delete views - -------------------------------------------------------------------- - DELETE FROM ss_view_metadata - WHERE id IN (SELECT id FROM tmp_view_ids); - - -------------------------------------------------------------------- - -- 5) Delete import transaction error logs for this model's imports - -------------------------------------------------------------------- - DELETE FROM ss_import_transaction_error_log - WHERE import_transaction_id IN ( - SELECT it.id - FROM ss_import_transaction AS it - WHERE it.model_metadata_id = v_model_id - ); - - -------------------------------------------------------------------- - -- 6) Delete import transactions linked to the model - -------------------------------------------------------------------- - DELETE FROM ss_import_transaction - WHERE model_metadata_id = v_model_id; - - -------------------------------------------------------------------- - -- 7) Delete permissions for this controller - -- a) Delete from role-permission join table - -- b) Delete from permission metadata - -------------------------------------------------------------------- - DELETE FROM ss_role_metadata_permissions_ss_permission_metadata - WHERE ss_permission_metadata_id IN (SELECT id FROM tmp_permission_ids); - - DELETE FROM ss_permission_metadata - WHERE id IN (SELECT id FROM tmp_permission_ids); - - -------------------------------------------------------------------- - -- 8) Optionally delete the model row itself (+ fields) - -------------------------------------------------------------------- - IF p_also_drop_model = 1 THEN - - -- 8a) Null user_key_field_id pointing to fields of this model - UPDATE ss_model_metadata AS mm - SET user_key_field_id = NULL - WHERE user_key_field_id IN ( - SELECT f.id - FROM ss_field_metadata AS f - WHERE f.model_id = v_model_id - ); - - -- 8b) Null parent_model_id pointing to this model - UPDATE ss_model_metadata - SET parent_model_id = NULL - WHERE parent_model_id = v_model_id; - - -- 8c) Delete model (fields deleted via ON DELETE CASCADE) - DELETE FROM ss_model_metadata - WHERE id = v_model_id; - - END IF; - - -- Cleanup temp tables - DROP TEMPORARY TABLE IF EXISTS tmp_view_ids; - DROP TEMPORARY TABLE IF EXISTS tmp_action_ids; - DROP TEMPORARY TABLE IF EXISTS tmp_permission_ids; - -END$$ - -DELIMITER ; diff --git a/sql/default/mysql/proc_CleanupModuleMetadata.sql b/sql/default/mysql/proc_CleanupModuleMetadata.sql deleted file mode 100644 index 97c81206..00000000 --- a/sql/default/mysql/proc_CleanupModuleMetadata.sql +++ /dev/null @@ -1,56 +0,0 @@ -DROP PROCEDURE IF EXISTS cleanup_module_metadata; - -DELIMITER $$ - -CREATE PROCEDURE cleanup_module_metadata( - IN p_module_name VARCHAR(255), - IN p_also_drop_module TINYINT(1) -- 0 = false, 1 = true (DEFAULT 0) -) -BEGIN - DECLARE v_module_id INT; - - -- Default p_also_drop_module to 0 when NULL is passed - IF p_also_drop_module IS NULL THEN - SET p_also_drop_module = 0; - END IF; - - SELECT m.id - INTO v_module_id - FROM ss_module_metadata AS m - WHERE m.name = p_module_name - LIMIT 1; - - IF v_module_id IS NULL THEN - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = CONCAT('CleanupModuleMetadata: Module "', p_module_name, '" not found.'); - END IF; - - ---------------------------------------------------------------- - -- 1) Delete menus for this module (children first, then roots) - ---------------------------------------------------------------- - DELETE FROM ss_menu_item_metadata - WHERE module_id = v_module_id - AND parent_menu_item_id IS NOT NULL; - - DELETE FROM ss_menu_item_metadata - WHERE module_id = v_module_id - AND parent_menu_item_id IS NULL; - - ---------------------------------------------------------------- - -- 2) Delete actions for this module - ---------------------------------------------------------------- - DELETE FROM ss_action_metadata - WHERE module_id = v_module_id; - - ---------------------------------------------------------------- - -- 3) Optionally delete the module row itself - -- (assumes ss_model_metadata.module_id has ON DELETE SET NULL) - ---------------------------------------------------------------- - IF p_also_drop_module = 1 THEN - DELETE FROM ss_module_metadata - WHERE id = v_module_id; - END IF; - -END$$ - -DELIMITER ; diff --git a/sql/default/postgres/proc_CleanupModelMetadata.sql b/sql/default/postgres/proc_CleanupModelMetadata.sql deleted file mode 100644 index ed5067e3..00000000 --- a/sql/default/postgres/proc_CleanupModelMetadata.sql +++ /dev/null @@ -1,148 +0,0 @@ -CREATE OR REPLACE PROCEDURE cleanup_model_metadata( - IN p_model_singular_name text, - IN p_also_drop_model boolean DEFAULT false -) -LANGUAGE plpgsql -AS $$ -DECLARE - v_model_id int; - v_controller_name text; -BEGIN - -------------------------------------------------------------------- - -- Derive ControllerName from model singular name - -- e.g. 'city' -> 'CityController' - -- 'bankIfscMaster' -> 'BankIfscMasterController' - -------------------------------------------------------------------- - v_controller_name := - upper(left(p_model_singular_name, 1)) - || substr(p_model_singular_name, 2) - || 'Controller'; - - SELECT m.id - INTO v_model_id - FROM ss_model_metadata AS m - WHERE m.singular_name = p_model_singular_name; - - IF v_model_id IS NULL THEN - RAISE EXCEPTION - 'CleanupModelMetadata: Model with singular_name "%" not found.', - p_model_singular_name; - END IF; - - -------------------------------------------------------------------- - -- Temp tables for IDs (session-scoped) - -------------------------------------------------------------------- - DROP TABLE IF EXISTS view_ids; - DROP TABLE IF EXISTS action_ids; - DROP TABLE IF EXISTS permission_ids; - - CREATE TEMP TABLE view_ids (id int PRIMARY KEY) ON COMMIT DROP; - CREATE TEMP TABLE action_ids (id int PRIMARY KEY) ON COMMIT DROP; - CREATE TEMP TABLE permission_ids (id int PRIMARY KEY) ON COMMIT DROP; - - INSERT INTO view_ids (id) - SELECT v.id - FROM ss_view_metadata AS v - WHERE v.model_id = v_model_id; - - INSERT INTO action_ids (id) - SELECT a.id - FROM ss_action_metadata AS a - WHERE a.model_id = v_model_id; - - -------------------------------------------------------------------- - -- Collect permissions for this controller: - -- e.g. CityController.create, CityController.update, etc. - -------------------------------------------------------------------- - INSERT INTO permission_ids (id) - SELECT p.id - FROM ss_permission_metadata AS p - WHERE p.name LIKE (v_controller_name || '.%'); - - -------------------------------------------------------------------- - -- 1) Delete user-view metadata - -------------------------------------------------------------------- - DELETE FROM ss_user_view_metadata AS uvm - WHERE uvm.view_metadata_id IN (SELECT id FROM view_ids); - - -------------------------------------------------------------------- - -- 2) Delete menu items linked via action_id - -- Role join table is handled by ON DELETE CASCADE (assumed) - -------------------------------------------------------------------- - DELETE FROM ss_menu_item_metadata AS mi - WHERE mi.action_id IN (SELECT id FROM action_ids); - - -------------------------------------------------------------------- - -- 3) Delete actions - -------------------------------------------------------------------- - DELETE FROM ss_action_metadata AS a - WHERE a.id IN (SELECT id FROM action_ids); - - -------------------------------------------------------------------- - -- 4) Delete views - -------------------------------------------------------------------- - DELETE FROM ss_view_metadata AS v - WHERE v.id IN (SELECT id FROM view_ids); - - -------------------------------------------------------------------- - -- 5) Delete import transaction error logs for this model's imports - -------------------------------------------------------------------- - DELETE FROM ss_import_transaction_error_log AS itel - WHERE itel.import_transaction_id IN ( - SELECT it.id - FROM ss_import_transaction AS it - WHERE it.model_metadata_id = v_model_id - ); - - -------------------------------------------------------------------- - -- 6) Delete import transactions linked to the model - -------------------------------------------------------------------- - DELETE FROM ss_import_transaction AS it - WHERE it.model_metadata_id = v_model_id; - - -------------------------------------------------------------------- - -- 7) Delete permissions for this controller - -- a) Delete from role-permission join table - -- b) Delete from permission metadata - -------------------------------------------------------------------- - DELETE FROM ss_role_metadata_permissions_ss_permission_metadata AS rp - WHERE rp.ss_permission_metadata_id IN (SELECT id FROM permission_ids); - - DELETE FROM ss_permission_metadata AS p - WHERE p.id IN (SELECT id FROM permission_ids); - - -------------------------------------------------------------------- - -- 8) Optionally delete the model row itself (+ fields) - -------------------------------------------------------------------- - IF p_also_drop_model THEN - -------------------------------------------------------------- - -- 8a) Null user_key_field_id pointing to fields of this model - -------------------------------------------------------------- - UPDATE ss_model_metadata AS mm - SET user_key_field_id = NULL - WHERE mm.user_key_field_id IN ( - SELECT f.id - FROM ss_field_metadata AS f - WHERE f.model_id = v_model_id - ); - - -------------------------------------------------------------- - -- 8b) Null parent_model_id pointing to this model - -------------------------------------------------------------- - UPDATE ss_model_metadata AS mm - SET parent_model_id = NULL - WHERE mm.parent_model_id = v_model_id; - - -------------------------------------------------------------- - -- 8c) Delete model - -- Fields are deleted automatically via ON DELETE CASCADE - -------------------------------------------------------------- - DELETE FROM ss_model_metadata AS m - WHERE m.id = v_model_id; - END IF; - -EXCEPTION - WHEN others THEN - RAISE; -END; -$$; \ No newline at end of file diff --git a/sql/default/postgres/proc_CleanupModuleMetadata.sql b/sql/default/postgres/proc_CleanupModuleMetadata.sql deleted file mode 100644 index e37b2343..00000000 --- a/sql/default/postgres/proc_CleanupModuleMetadata.sql +++ /dev/null @@ -1,50 +0,0 @@ -CREATE OR REPLACE PROCEDURE cleanup_module_metadata( - IN p_module_name text, - IN p_also_drop_module boolean DEFAULT false -) -LANGUAGE plpgsql -AS $$ -DECLARE - v_module_id int; -BEGIN - SELECT m.id - INTO v_module_id - FROM ss_module_metadata AS m - WHERE m.name = p_module_name; - - IF v_module_id IS NULL THEN - RAISE EXCEPTION 'CleanupModuleMetadata: Module "%" not found.', p_module_name; - END IF; - - ---------------------------------------------------------------- - -- 1) Delete menus for this module (children first, then roots) - ---------------------------------------------------------------- - DELETE FROM ss_menu_item_metadata AS mi - WHERE mi.module_id = v_module_id - AND mi.parent_menu_item_id IS NOT NULL; - - DELETE FROM ss_menu_item_metadata AS mi - WHERE mi.module_id = v_module_id - AND mi.parent_menu_item_id IS NULL; - - ---------------------------------------------------------------- - -- 2) Delete actions for this module - ---------------------------------------------------------------- - DELETE FROM ss_action_metadata AS a - WHERE a.module_id = v_module_id; - - ---------------------------------------------------------------- - -- 3) Optionally delete the module row itself - -- (assumes ss_model_metadata.module_id has ON DELETE SET NULL) - ---------------------------------------------------------------- - IF p_also_drop_module THEN - DELETE FROM ss_module_metadata AS m - WHERE m.id = v_module_id; - END IF; - -EXCEPTION - WHEN others THEN - -- preserve original error message/SQLSTATE - RAISE; -END; -$$; \ No newline at end of file diff --git a/sql/default/postgres/scratchpad.sql.txt b/sql/default/postgres/scratchpad.sql.txt deleted file mode 100644 index df26098d..00000000 --- a/sql/default/postgres/scratchpad.sql.txt +++ /dev/null @@ -1,12 +0,0 @@ --- delete models -CALL cleanup_model_metadata('myrusPincodeMaster', false); -CALL cleanup_model_metadata('subSubCategoryMaster', true); -CALL cleanup_model_metadata('subCategoryMaster', true); -CALL cleanup_model_metadata('categoryMaster', true); -CALL cleanup_model_metadata('city', true); -CALL cleanup_model_metadata('state', true); -CALL cleanup_model_metadata('bankIfscMaster', true); -CALL cleanup_model_metadata('applicationDocumentVerificationMob2Vtb', true); - --- delete module -CALL cleanup_module_metadata('myrus-address-master', false); \ No newline at end of file diff --git a/src/commands/info.command.ts b/src/commands/info.command.ts index 540a86d4..71ca8f9f 100644 --- a/src/commands/info.command.ts +++ b/src/commands/info.command.ts @@ -53,8 +53,6 @@ export class InfoCommand extends CommandRunner { selectionProviders: this.getWrapperNames(this.solidRegistry.getSelectionProviders()), computedFieldProviders: this.getWrapperNames(this.solidRegistry.getComputedFieldProviders()), solidDatabaseModules: this.getWrapperNames(this.solidRegistry.getSolidDatabaseModules()), - dashboardVariableSelectionProviders: this.getWrapperNames(this.solidRegistry.getDashboardVariableSelectionProviders()), - dashboardQuestionDataProviders: this.getWrapperNames(this.solidRegistry.getDashboardQuestionDataProviders()), mailProviders: this.getWrapperNames(this.solidRegistry.getMailProviders()), whatsappProviders: this.getWrapperNames(this.solidRegistry.getWhatsappProviders()), smsProviders: this.getWrapperNames(this.solidRegistry.getSmsProviders()), diff --git a/src/commands/ingest.command.ts b/src/commands/ingest.command.ts deleted file mode 100755 index 7d1d9a29..00000000 --- a/src/commands/ingest.command.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { Logger } from '@nestjs/common'; -import { Command, CommandRunner, Option } from 'nest-commander'; -import { SolidRegistry } from 'src/helpers/solid-registry'; -import { IngestMetadataService } from 'src/services/genai/ingest-metadata.service'; - -interface IngestCommandOptions { - module?: string; - // seeder?: string; -} - -@Command({ name: 'ingest', description: 'Ingests solid metadata json for all modules deployed in the consuming project' }) -export class IngestCommand extends CommandRunner { - private readonly logger = new Logger(IngestCommand.name); - - constructor( - private readonly solidRegistry: SolidRegistry, - private readonly ingestMetadataService: IngestMetadataService, - ) { - super(); - } - - async run(passedParam: string[], options?: IngestCommandOptions): Promise { - this.logger.log(`Running the solid ingest for module ${options.module} at ${new Date()}`); - await this.ingestMetadataService.ingest(); - } - - @Option({ - flags: '-m, --module [module name]', - description: 'The seeder to run.', - required: false, - defaultValue: '' - }) - parseString(val: string): string { - return val; - } -} diff --git a/src/commands/refresh-model.command.ts b/src/commands/refresh-model.command.ts index 9c5e2661..983d6d97 100755 --- a/src/commands/refresh-model.command.ts +++ b/src/commands/refresh-model.command.ts @@ -1,14 +1,10 @@ -import { BadRequestException, forwardRef, Inject, Logger } from '@nestjs/common'; +import { forwardRef, Inject, Logger } from '@nestjs/common'; import { Command, CommandRunner, Option } from 'nest-commander'; import { ModelMetadataService } from 'src/services/model-metadata.service'; import { CommandError } from './helper'; -import { In } from 'typeorm'; interface CommandOptions { - name?: string; - id?: number; - fieldIds?: number[]; - fieldNames?: string[]; + name: string; dryRun?: boolean; } @@ -33,34 +29,20 @@ export class RefreshModelCommand extends CommandRunner { } const codeGenerationOptions = { - modelId: options.id, modelUserKey: options.name, dryRun: options.dryRun, - fieldIdsForRefresh: options.fieldIds, - fieldNamesForRefresh: options.fieldNames, }; await this.modelMetadataService.handleGenerateCode(codeGenerationOptions); } - // Accept the model ID as an argument @Option({ - flags: '-i, --id [model ID]', - description: 'Model ID from the ss_model_metadata table', - }) - parseId(val: string): number { - return +val; - } - - // Accept the module name as an argument - @Option({ - flags: '-n, --name [model name]', - description: 'Model Name from the ss_model_metadata table', + flags: '-n, --name ', + description: 'Model name (singularName) from the ss_model_metadata table', }) parseName(val: string): string { return val; } - // Accept dry run as an argument @Option({ flags: '-d, --dryRun [dry run]', description: 'Dry run the command', @@ -70,36 +52,9 @@ export class RefreshModelCommand extends CommandRunner { return (val === 'false') ? false : true; } - // Accept field IDs as an argument - @Option({ - flags: '-fids, --fieldIds [Array of field IDs]', - description: 'Json array of Field IDs from the ss_field_metadata table', - }) - parseFieldIds(val: string): number[] { - //Check if the value is a json array - if (!val.startsWith('[') || !val.endsWith(']')) { - throw new BadRequestException('Field IDs should be a json array'); - } - return JSON.parse(val).map((id: string) => parseInt(id)); - } - - // Accept field Names as an argument - @Option({ - flags: '-fnames, --fieldNames [Array of field Names]', - description: 'Json array of Field Names from the ss_field_metadata table', - }) - parseFieldNames(val: string): string[] { - //Check if the value is a json array - if (!val.startsWith('[') || !val.endsWith(']')) { - throw new BadRequestException('Field Names should be a json array'); - } - return JSON.parse(val).map((name: string) => name.toString()); - } - - // Validate the options passed private validate(options: CommandOptions): CommandError[] { - if (!options.id && !options.name) { - return [new CommandError('Model ID or Model Name is required')]; + if (!options.name) { + return [new CommandError('Model Name is required')]; } return []; } diff --git a/src/commands/refresh-module.command.ts b/src/commands/refresh-module.command.ts index 1583c66d..91390b44 100755 --- a/src/commands/refresh-module.command.ts +++ b/src/commands/refresh-module.command.ts @@ -4,7 +4,6 @@ import { ModuleMetadataService } from '../services/module-metadata.service'; import { CommandError } from './helper'; interface CommandOptions { - id: number; name: string; dryRun: boolean; } @@ -28,7 +27,6 @@ export class RefreshModuleCommand extends CommandRunner { return; } const codeGenerationOptions = { - moduleId: options.id, moduleUserKey: options.name, dryRun: options.dryRun, }; @@ -36,16 +34,7 @@ export class RefreshModuleCommand extends CommandRunner { } @Option({ - flags: '-i, --id [module ID]', - description: 'Module ID from the ss_module_metadata table', - }) - parseId(val: string): number { - return +val; - } - - // Accept the module name as an argument - @Option({ - flags: '-n, --name [module name]', + flags: '-n, --name ', description: 'Module Name from the ss_module_metadata table', }) parseName(val: string): string { @@ -61,11 +50,9 @@ export class RefreshModuleCommand extends CommandRunner { return (val === 'false') ? false : true; } - - // Validate the options passed validate(options: CommandOptions): CommandError[] { - if (!options.id && !options.name) { - return [new CommandError('Module ID or Module Name is required')]; + if (!options.name) { + return [new CommandError('Module Name is required')]; } return []; } diff --git a/src/commands/run-tests.command.ts b/src/commands/run-tests.command.ts index e2f12356..c1f5b270 100644 --- a/src/commands/run-tests.command.ts +++ b/src/commands/run-tests.command.ts @@ -3,6 +3,7 @@ import { SubCommand, CommandRunner, Option } from 'nest-commander'; import * as path from 'path'; import { ModuleMetadataHelperService } from 'src/helpers/module-metadata-helper.service'; import { ConsoleReporter } from 'src/testing/reporter/console-reporter'; +import { WebhookReporter } from 'src/testing/reporter/webhook-reporter'; import { runFromMetadata } from 'src/testing/runner/run-from-metadata'; import type { TestingMetadata } from 'src/testing/contracts/testing-metadata.types'; import { SpecRegistry } from 'src/testing/core/spec-registry'; @@ -21,6 +22,7 @@ interface TestRunCommandOptions { retries?: number; listSpecs?: boolean; printApiLogs?: boolean; + runName?: string; } @SubCommand({ @@ -89,23 +91,40 @@ export class TestRunCommand extends CommandRunner { const headless = options?.headless ?? true; const printApiLogs = options?.printApiLogs ?? false; - await runFromMetadata({ - metadata: metadata as TestingMetadata, - scenarioIds, - includeTags, - skipScenarioIds, - reporter: new ConsoleReporter(), - api: apiBaseUrl ? { baseUrl: apiBaseUrl } : undefined, - ui: { baseUrl: uiBaseUrl, headless }, - defaults: { - timeoutMs: options?.timeoutMs, - retries: options?.retries, - }, - options: { printApiLogs }, - specs: specEntries.length - ? (registry) => loadSpecRegistrations(specEntries, metadataPath, registry) - : undefined, - }); + const webhookUrl = process.env.SOLIDCTL_WEBHOOK_URL; + const runName = options?.runName ?? `run-${Date.now()}`; + const reporter = webhookUrl + ? new WebhookReporter(webhookUrl, runName) + : new ConsoleReporter(); + + try { + await runFromMetadata({ + metadata: metadata as TestingMetadata, + scenarioIds, + includeTags, + skipScenarioIds, + reporter, + api: apiBaseUrl ? { baseUrl: apiBaseUrl } : undefined, + ui: { baseUrl: uiBaseUrl, headless }, + defaults: { + timeoutMs: options?.timeoutMs, + retries: options?.retries, + }, + options: { printApiLogs }, + specs: specEntries.length + ? (registry) => loadSpecRegistrations(specEntries, metadataPath, registry) + : undefined, + }); + } catch (err: any) { + this.logger.error('Run tests command failed'); + console.error('Run tests command failed'); + process.exitCode = 1; + } finally { + if (reporter instanceof WebhookReporter) { + await reporter.flush(Number(process.exitCode ?? 0)); + } + } + return; } catch (err: any) { this.logger.error('Run tests command failed'); console.error('Run tests command failed'); @@ -223,6 +242,15 @@ export class TestRunCommand extends CommandRunner { parseRetries(val: string): number { return Number(val); } + + @Option({ + flags: '--run-name [name]', + description: 'Logical name for this test run (used by webhook reporter).', + required: false, + }) + parseRunName(val: string): string { + return val; + } } function splitCsv(value?: string): string[] | undefined { diff --git a/src/commands/test-data.command.ts b/src/commands/test-data.command.ts index 1b3e0364..93684bfc 100644 --- a/src/commands/test-data.command.ts +++ b/src/commands/test-data.command.ts @@ -7,6 +7,7 @@ interface TestDataCommandOptions { modulesToTest?: string; setup?: boolean; teardown?: boolean; + unlink?: boolean; } @SubCommand({ @@ -25,17 +26,18 @@ export class TestDataCommand extends CommandRunner { const load = Boolean(options?.load); const setup = Boolean(options?.setup); const teardown = Boolean(options?.teardown); + const unlink = Boolean(options?.unlink); - const selectedModes = [load, setup, teardown].filter(Boolean).length; + const selectedModes = [load, setup, teardown, unlink].filter(Boolean).length; if (selectedModes > 1) { - this.logger.error('Please specify only one of --load, --setup, or --teardown.'); - console.log('Please specify only one of --load, --setup, or --teardown.'); + this.logger.error('Please specify only one of --load, --setup, --teardown, or --unlink.'); + console.log('Please specify only one of --load, --setup, --teardown, or --unlink.'); return; } - if (!load && !setup && !teardown) { - this.logger.error('Please specify one of --load, --setup, or --teardown.'); - console.log('Please specify one of --load, --setup, or --teardown.'); + if (!load && !setup && !teardown && !unlink) { + this.logger.error('Please specify one of --load, --setup, --teardown, or --unlink.'); + console.log('Please specify one of --load, --setup, --teardown, or --unlink.'); return; } @@ -65,6 +67,19 @@ export class TestDataCommand extends CommandRunner { await this.testDataService.setupTestData(modulesToTest ?? undefined); return; } + + if (unlink) { + const modulesToTest = options?.modulesToTest ? options.modulesToTest.split(',').map((m) => m.trim()).filter(Boolean) : null; + if (modulesToTest?.length) { + this.logger.log(`Test data unlink for modules: ${modulesToTest.join(', ')}`); + console.log(`Test data unlink for modules: ${modulesToTest.join(', ')}`); + } else { + this.logger.log('Test data unlink for all modules.'); + console.log('Test data unlink for all modules.'); + } + await this.testDataService.removeTestData(modulesToTest ?? undefined); + return; + } } catch (err: any) { const message = err?.message ?? String(err); this.logger.error(message); @@ -109,4 +124,12 @@ export class TestDataCommand extends CommandRunner { return val; } + @Option({ + flags: '--unlink', + description: 'Delete test data from testing.data sections in reverse order', + }) + parseUnlink(): boolean { + return true; + } + } diff --git a/src/constants/error-messages.ts b/src/constants/error-messages.ts index a64e7f04..8f888d0e 100644 --- a/src/constants/error-messages.ts +++ b/src/constants/error-messages.ts @@ -28,6 +28,7 @@ export const ERROR_MESSAGES = { ACCESS_DENIED: 'Access denied', INVALID_USER_PROFILE: 'Invalid user profile', GOOGLE_OAUTH_PROFILE_FETCH_FAILED: 'Failed to fetch user profile from Google OAuth service', + MICROSOFT_ACTIVE_DIRECTORY_OAUTH_PROFILE_FETCH_FAILED: 'Failed to fetch user profile from Microsoft Active Directory OAuth service', LOGOUT_FAILED: 'Logout failed due to an unexpected error.', INVALID_CREDENTIALS: 'Invalid credentials', @@ -130,4 +131,4 @@ export const ERROR_MESSAGES = { `You do not have permission to import ${model ?? 'these'} records.`, } as Record string>, -}; \ No newline at end of file +}; diff --git a/src/controllers/action-metadata.controller.ts b/src/controllers/action-metadata.controller.ts index 719c4c2a..b163420d 100755 --- a/src/controllers/action-metadata.controller.ts +++ b/src/controllers/action-metadata.controller.ts @@ -7,7 +7,7 @@ import { UpdateActionMetadataDto } from '../dtos/update-action-metadata.dto'; import { SolidRequestContextDto } from 'src/dtos/solid-request-context.dto'; import { SolidRequestContextDecorator } from 'src/decorators/solid-request-context.decorator'; -@ApiTags('App Builder') +@ApiTags('Solid Core') @Controller('action-metadata') //FIXME: Change this to the model plural name export class ActionMetadataController { constructor(private readonly service: ActionMetadataService) { } diff --git a/src/controllers/ai-interaction.controller.ts b/src/controllers/ai-interaction.controller.ts deleted file mode 100644 index eb168c93..00000000 --- a/src/controllers/ai-interaction.controller.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { Controller, Post, Body, Param, UploadedFiles, UseInterceptors, Put, Get, Query, Delete, Patch } from '@nestjs/common'; -import { AnyFilesInterceptor } from "@nestjs/platform-express"; -import { ApiBearerAuth, ApiQuery, ApiTags } from '@nestjs/swagger'; -import { AiInteractionService } from '../services/ai-interaction.service'; -import { CreateAiInteractionDto } from '../dtos/create-ai-interaction.dto'; -import { UpdateAiInteractionDto } from '../dtos/update-ai-interaction.dto'; -import { InvokeAiPromptDto } from '../dtos/invoke-ai-prompt.dto'; -import { ActiveUser } from 'src/decorators/active-user.decorator'; -import { ActiveUserData } from 'src/interfaces/active-user-data.interface'; - -enum ShowSoftDeleted { - INCLUSIVE = "inclusive", - EXCLUSIVE = "exclusive", -} - -@ApiTags('Solid Core') -@Controller('ai-interaction') -export class AiInteractionController { - constructor(private readonly service: AiInteractionService) { } - - @ApiBearerAuth("jwt") - @Post() - @UseInterceptors(AnyFilesInterceptor()) - create(@Body() createDto: CreateAiInteractionDto, @UploadedFiles() files: Array) { - return this.service.create(createDto, files); - } - - @ApiBearerAuth("jwt") - @Post('/bulk') - @UseInterceptors(AnyFilesInterceptor()) - insertMany(@Body() createDtos: CreateAiInteractionDto[], @UploadedFiles() filesArray: Express.Multer.File[][] = []) { - return this.service.insertMany(createDtos, filesArray); - } - - - @ApiBearerAuth("jwt") - @Put(':id') - @UseInterceptors(AnyFilesInterceptor()) - update(@Param('id') id: number, @Body() updateDto: UpdateAiInteractionDto, @UploadedFiles() files: Array) { - return this.service.update(id, updateDto, files); - } - - @ApiBearerAuth("jwt") - @Patch(':id') - @UseInterceptors(AnyFilesInterceptor()) - partialUpdate(@Param('id') id: number, @Body() updateDto: UpdateAiInteractionDto, @UploadedFiles() files: Array) { - return this.service.update(id, updateDto, files, true); - } - - @ApiBearerAuth("jwt") - @Post('/bulk-recover') - async recoverMany(@Body() ids: number[]) { - return this.service.recoverMany(ids); - } - - @ApiBearerAuth("jwt") - @Get('/recover/:id') - async recover(@Param('id') id: number) { - return this.service.recover(id); - } - - @ApiBearerAuth("jwt") - @ApiQuery({ name: 'showSoftDeleted', required: false, enum: ShowSoftDeleted }) - @ApiQuery({ name: 'limit', required: false, type: Number }) - @ApiQuery({ name: 'offset', required: false, type: Number }) - @ApiQuery({ name: 'fields', required: false, type: Array }) - @ApiQuery({ name: 'sort', required: false, type: Array }) - @ApiQuery({ name: 'groupBy', required: false, type: Array }) - @ApiQuery({ name: 'populate', required: false, type: Array }) - @ApiQuery({ name: 'populateMedia', required: false, type: Array }) - @ApiQuery({ name: 'filters', required: false, type: Array }) - @Get() - async findMany(@Query() query: any) { - return this.service.find(query); - } - - @ApiBearerAuth("jwt") - @Get(':id') - async findOne(@Param('id') id: string, @Query() query: any) { - return this.service.findOne(+id, query); - } - - @ApiBearerAuth("jwt") - @Delete('/bulk') - async deleteMany(@Body() ids: number[]) { - return this.service.deleteMany(ids); - } - - @ApiBearerAuth("jwt") - @Delete(':id') - async delete(@Param('id') id: number) { - return this.service.delete(id); - } - - @ApiBearerAuth("jwt") - @Post('/trigger-mcp-client-job') - async triggerMcpClientJob(@Body() dto: InvokeAiPromptDto, @ActiveUser() activeUser: ActiveUserData) { - return this.service.triggerMcpClientJob(dto, activeUser.sub); - } - - @ApiBearerAuth("jwt") - @Post(':id/apply-solid-ai-interaction') - async applySolidAiInteraction(@Param('id') id: number) { - return this.service.applySolidAiInteraction(+id); - } - - @ApiBearerAuth("jwt") - @Post('/run-mcp-prompt') - async runMcpPrompt(@Body() dto: InvokeAiPromptDto) { - return this.service.runMcpPrompt(dto.prompt); - } -} diff --git a/src/controllers/chatter-message.controller.ts b/src/controllers/chatter-message.controller.ts index 48bfe2aa..361b1feb 100644 --- a/src/controllers/chatter-message.controller.ts +++ b/src/controllers/chatter-message.controller.ts @@ -5,6 +5,7 @@ import { ChatterMessageService } from '../services/chatter-message.service'; import { CreateChatterMessageDto } from '../dtos/create-chatter-message.dto'; import { UpdateChatterMessageDto } from '../dtos/update-chatter-message.dto'; import { PostChatterMessageDto } from '../dtos/post-chatter-message.dto'; +import { UpdateChatterNoteMessageDto } from '../dtos/update-chatter-note-message.dto'; import { SolidRequestContextDecorator } from 'src/decorators/solid-request-context.decorator'; import { SolidRequestContextDto } from 'src/dtos/solid-request-context.dto'; import { Public } from 'src/decorators/public.decorator'; @@ -127,4 +128,15 @@ export class ChatterMessageController { async markCompleted(@Param('id') id: string) { return this.service.markCompleted(+id); } + + @ApiBearerAuth("jwt") + @Patch(':id/note') + @UseInterceptors(AnyFilesInterceptor()) + async updateCustomNoteMessage( + @Param('id') id: string, + @Body() updateDto: UpdateChatterNoteMessageDto, + @UploadedFiles() files: Array, + ) { + return this.service.updateCustomNoteMessage(+id, updateDto, files); + } } diff --git a/src/controllers/dashboard-layout.controller.ts b/src/controllers/dashboard-layout.controller.ts deleted file mode 100644 index ea83ef2a..00000000 --- a/src/controllers/dashboard-layout.controller.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { Controller, Post, Body, Param, UploadedFiles, UseInterceptors, Put, Get, Query, Delete, Patch } from '@nestjs/common'; -import { AnyFilesInterceptor } from "@nestjs/platform-express"; -import { ApiBearerAuth, ApiQuery, ApiTags } from '@nestjs/swagger'; -import { CreateDashboardLayoutDto } from 'src/dtos/create-dashboard-layout.dto'; -import { UpdateDashboardLayoutDto } from 'src/dtos/update-dashboard-layout.dto'; -import { DashboardLayoutService } from 'src/services/dashboard-layout.service'; - -enum ShowSoftDeleted { - INCLUSIVE = "inclusive", - EXCLUSIVE = "exclusive", -} - -@ApiTags('Solid Core') -@Controller('dashboard-layout') -export class DashboardLayoutController { - constructor(private readonly service: DashboardLayoutService) { } - - @ApiBearerAuth("jwt") - @Post() - @UseInterceptors(AnyFilesInterceptor()) - create(@Body() createDto: CreateDashboardLayoutDto, @UploadedFiles() files: Array) { - return this.service.create(createDto, files); - } - - @ApiBearerAuth("jwt") - @Post('/bulk') - @UseInterceptors(AnyFilesInterceptor()) - insertMany(@Body() createDtos: CreateDashboardLayoutDto[], @UploadedFiles() filesArray: Express.Multer.File[][] = []) { - return this.service.insertMany(createDtos, filesArray); - } - - @ApiBearerAuth("jwt") - @Post('/upsert-user-dashboard-layout') - @UseInterceptors(AnyFilesInterceptor()) - upsertUserDashboardLayout(@Body() createDtos: CreateDashboardLayoutDto) { - return this.service.upsertUserDashboardLayout(createDtos); - } - - - @ApiBearerAuth("jwt") - @Put(':id') - @UseInterceptors(AnyFilesInterceptor()) - update(@Param('id') id: number, @Body() updateDto: UpdateDashboardLayoutDto, @UploadedFiles() files: Array) { - return this.service.update(id, updateDto, files); - } - - @ApiBearerAuth("jwt") - @Patch(':id') - @UseInterceptors(AnyFilesInterceptor()) - partialUpdate(@Param('id') id: number, @Body() updateDto: UpdateDashboardLayoutDto, @UploadedFiles() files: Array) { - return this.service.update(id, updateDto, files, true); - } - - @ApiBearerAuth("jwt") - @Post('/bulk-recover') - async recoverMany(@Body() ids: number[]) { - return this.service.recoverMany(ids); - } - - @ApiBearerAuth("jwt") - @Get('/user-dashboard-layout/:dashboardId') - async getUserDashboardLayoutByDashboardId(@Param('dashboardId') dashboardId: number) { - return this.service.getUserDashboardLayoutByDashboardId(dashboardId); - } - - @ApiBearerAuth("jwt") - @Get('/recover/:id') - async recover(@Param('id') id: number) { - return this.service.recover(id); - } - - @ApiBearerAuth("jwt") - @ApiQuery({ name: 'showSoftDeleted', required: false, enum: ShowSoftDeleted }) - @ApiQuery({ name: 'limit', required: false, type: Number }) - @ApiQuery({ name: 'offset', required: false, type: Number }) - @ApiQuery({ name: 'fields', required: false, type: Array }) - @ApiQuery({ name: 'sort', required: false, type: Array }) - @ApiQuery({ name: 'groupBy', required: false, type: Array }) - @ApiQuery({ name: 'populate', required: false, type: Array }) - @ApiQuery({ name: 'populateMedia', required: false, type: Array }) - @ApiQuery({ name: 'filters', required: false, type: Array }) - @Get() - async findMany(@Query() query: any) { - return this.service.find(query); - } - - @ApiBearerAuth("jwt") - @Get(':id') - async findOne(@Param('id') id: string, @Query() query: any) { - return this.service.findOne(+id, query); - } - - @ApiBearerAuth("jwt") - @Delete('/bulk') - async deleteMany(@Body() ids: number[]) { - return this.service.deleteMany(ids); - } - - @ApiBearerAuth("jwt") - @Delete(':id') - async delete(@Param('id') id: number) { - return this.service.delete(id); - } - - -} diff --git a/src/controllers/dashboard-question-sql-dataset-config.controller.ts b/src/controllers/dashboard-question-sql-dataset-config.controller.ts deleted file mode 100644 index 6bd2db0a..00000000 --- a/src/controllers/dashboard-question-sql-dataset-config.controller.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { Controller, Post, Body, Param, UploadedFiles, UseInterceptors, Put, Get, Query, Delete, Patch } from '@nestjs/common'; -import { AnyFilesInterceptor } from "@nestjs/platform-express"; -import { ApiBearerAuth, ApiQuery, ApiTags } from '@nestjs/swagger'; -import { DashboardQuestionSqlDatasetConfigService } from '../services/dashboard-question-sql-dataset-config.service'; -import { CreateDashboardQuestionSqlDatasetConfigDto } from '../dtos/create-dashboard-question-sql-dataset-config.dto'; -import { UpdateDashboardQuestionSqlDatasetConfigDto } from '../dtos/update-dashboard-question-sql-dataset-config.dto'; - -enum ShowSoftDeleted { - INCLUSIVE = "inclusive", - EXCLUSIVE = "exclusive", -} - -@ApiTags('Solid Core') -@Controller('dashboard-question-sql-dataset-config') -export class DashboardQuestionSqlDatasetConfigController { - constructor(private readonly service: DashboardQuestionSqlDatasetConfigService) {} - - @ApiBearerAuth("jwt") - @Post() - @UseInterceptors(AnyFilesInterceptor()) - create(@Body() createDto: CreateDashboardQuestionSqlDatasetConfigDto, @UploadedFiles() files: Array) { - return this.service.create(createDto, files); - } - - @ApiBearerAuth("jwt") - @Post('/bulk') - @UseInterceptors(AnyFilesInterceptor()) - insertMany(@Body() createDtos: CreateDashboardQuestionSqlDatasetConfigDto[], @UploadedFiles() filesArray: Express.Multer.File[][] = []) { - return this.service.insertMany(createDtos, filesArray); - } - - - @ApiBearerAuth("jwt") - @Put(':id') - @UseInterceptors(AnyFilesInterceptor()) - update(@Param('id') id: number, @Body() updateDto: UpdateDashboardQuestionSqlDatasetConfigDto, @UploadedFiles() files: Array) { - return this.service.update(id, updateDto, files); - } - - @ApiBearerAuth("jwt") - @Patch(':id') - @UseInterceptors(AnyFilesInterceptor()) - partialUpdate(@Param('id') id: number, @Body() updateDto: UpdateDashboardQuestionSqlDatasetConfigDto, @UploadedFiles() files: Array) { - return this.service.update(id, updateDto, files, true); - } - - @ApiBearerAuth("jwt") - @Post('/bulk-recover') - async recoverMany(@Body() ids: number[]) { - return this.service.recoverMany(ids); - } - - @ApiBearerAuth("jwt") - @Get('/recover/:id') - async recover(@Param('id') id: number) { - return this.service.recover(id); - } - - @ApiBearerAuth("jwt") - @ApiQuery({ name: 'showSoftDeleted', required: false, enum: ShowSoftDeleted }) - @ApiQuery({ name: 'limit', required: false, type: Number }) - @ApiQuery({ name: 'offset', required: false, type: Number }) - @ApiQuery({ name: 'fields', required: false, type: Array }) - @ApiQuery({ name: 'sort', required: false, type: Array }) - @ApiQuery({ name: 'groupBy', required: false, type: Array }) - @ApiQuery({ name: 'populate', required: false, type: Array }) - @ApiQuery({ name: 'populateMedia', required: false, type: Array }) - @ApiQuery({ name: 'filters', required: false, type: Array }) - @Get() - async findMany(@Query() query: any) { - return this.service.find(query); - } - - @ApiBearerAuth("jwt") - @Get(':id') - async findOne(@Param('id') id: string, @Query() query: any) { - return this.service.findOne(+id, query); - } - - @ApiBearerAuth("jwt") - @Delete('/bulk') - async deleteMany(@Body() ids: number[]) { - return this.service.deleteMany(ids); - } - - @ApiBearerAuth("jwt") - @Delete(':id') - async delete(@Param('id') id: number) { - return this.service.delete(id); - } - - -} diff --git a/src/controllers/dashboard-question.controller.ts b/src/controllers/dashboard-question.controller.ts deleted file mode 100644 index 0bece398..00000000 --- a/src/controllers/dashboard-question.controller.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { Controller, Post, Body, Param, UploadedFiles, UseInterceptors, Put, Get, Query, Delete, Patch } from '@nestjs/common'; -import { AnyFilesInterceptor } from "@nestjs/platform-express"; -import { ApiBearerAuth, ApiQuery, ApiTags } from '@nestjs/swagger'; -import { DashboardQuestionService } from '../services/dashboard-question.service'; -import { CreateDashboardQuestionDto } from '../dtos/create-dashboard-question.dto'; -import { UpdateDashboardQuestionDto } from '../dtos/update-dashboard-question.dto'; -import { SqlExpression } from 'src/services/question-data-providers/chartjs-sql-data-provider.service'; - -enum ShowSoftDeleted { - INCLUSIVE = "inclusive", - EXCLUSIVE = "exclusive", -} - -@ApiTags('Solid Core') -@Controller('dashboard-question') -export class DashboardQuestionController { - constructor(private readonly service: DashboardQuestionService) { } - - @ApiBearerAuth("jwt") - @Post() - @UseInterceptors(AnyFilesInterceptor()) - create(@Body() createDto: CreateDashboardQuestionDto, @UploadedFiles() files: Array) { - return this.service.create(createDto, files); - } - - @ApiBearerAuth("jwt") - @Post('/bulk') - @UseInterceptors(AnyFilesInterceptor()) - insertMany(@Body() createDtos: CreateDashboardQuestionDto[], @UploadedFiles() filesArray: Express.Multer.File[][] = []) { - return this.service.insertMany(createDtos, filesArray); - } - - - @ApiBearerAuth("jwt") - @Put(':id') - @UseInterceptors(AnyFilesInterceptor()) - update(@Param('id') id: number, @Body() updateDto: UpdateDashboardQuestionDto, @UploadedFiles() files: Array) { - return this.service.update(id, updateDto, files); - } - - @ApiBearerAuth("jwt") - @Patch(':id') - @UseInterceptors(AnyFilesInterceptor()) - partialUpdate(@Param('id') id: number, @Body() updateDto: UpdateDashboardQuestionDto, @UploadedFiles() files: Array) { - return this.service.update(id, updateDto, files, true); - } - - @ApiBearerAuth("jwt") - @Post('/bulk-recover') - async recoverMany(@Body() ids: number[]) { - return this.service.recoverMany(ids); - } - - @ApiBearerAuth("jwt") - @Get('/recover/:id') - async recover(@Param('id') id: number) { - return this.service.recover(id); - } - - @ApiBearerAuth("jwt") - @ApiQuery({ name: 'showSoftDeleted', required: false, enum: ShowSoftDeleted }) - @ApiQuery({ name: 'limit', required: false, type: Number }) - @ApiQuery({ name: 'offset', required: false, type: Number }) - @ApiQuery({ name: 'fields', required: false, type: Array }) - @ApiQuery({ name: 'sort', required: false, type: Array }) - @ApiQuery({ name: 'groupBy', required: false, type: Array }) - @ApiQuery({ name: 'populate', required: false, type: Array }) - @ApiQuery({ name: 'populateMedia', required: false, type: Array }) - @ApiQuery({ name: 'filters', required: false, type: Array }) - @Get() - async findMany(@Query() query: any) { - return this.service.find(query); - } - - @ApiBearerAuth("jwt") - @Get(':id/data') - async getData( - @Param('id') id: string, - @Query('filters') filters: SqlExpression[], - @Query('isPreview') isPreview?: string - ) { - const previewMode = isPreview === 'true'; - return this.service.getData(+id, filters, previewMode); - } - - @ApiBearerAuth("jwt") - @Get(':id') - async findOne(@Param('id') id: string, @Query() query: any) { - return this.service.findOne(+id, query); - } - - @ApiBearerAuth("jwt") - @Delete('/bulk') - async deleteMany(@Body() ids: number[]) { - return this.service.deleteMany(ids); - } - - @ApiBearerAuth("jwt") - @Delete(':id') - async delete(@Param('id') id: number) { - return this.service.delete(id); - } - -} diff --git a/src/controllers/dashboard-variable.controller.ts b/src/controllers/dashboard-user-layout.controller.ts similarity index 75% rename from src/controllers/dashboard-variable.controller.ts rename to src/controllers/dashboard-user-layout.controller.ts index ec2be829..b9ac8219 100644 --- a/src/controllers/dashboard-variable.controller.ts +++ b/src/controllers/dashboard-user-layout.controller.ts @@ -1,9 +1,9 @@ import { Controller, Post, Body, Param, UploadedFiles, UseInterceptors, Put, Get, Query, Delete, Patch } from '@nestjs/common'; import { AnyFilesInterceptor } from "@nestjs/platform-express"; import { ApiBearerAuth, ApiQuery, ApiTags } from '@nestjs/swagger'; -import { DashboardVariableService } from '../services/dashboard-variable.service'; -import { CreateDashboardVariableDto } from '../dtos/create-dashboard-variable.dto'; -import { UpdateDashboardVariableDto } from '../dtos/update-dashboard-variable.dto'; +import { DashboardUserLayoutService } from '../services/dashboard-user-layout.service'; +import { CreateDashboardUserLayoutDto } from '../dtos/create-dashboard-user-layout.dto'; +import { UpdateDashboardUserLayoutDto } from '../dtos/update-dashboard-user-layout.dto'; enum ShowSoftDeleted { INCLUSIVE = "inclusive", @@ -11,21 +11,21 @@ enum ShowSoftDeleted { } @ApiTags('Solid Core') -@Controller('dashboard-variable') -export class DashboardVariableController { - constructor(private readonly service: DashboardVariableService) {} +@Controller('dashboard-user-layout') +export class DashboardUserLayoutController { + constructor(private readonly service: DashboardUserLayoutService) {} @ApiBearerAuth("jwt") @Post() @UseInterceptors(AnyFilesInterceptor()) - create(@Body() createDto: CreateDashboardVariableDto, @UploadedFiles() files: Array) { + create(@Body() createDto: CreateDashboardUserLayoutDto, @UploadedFiles() files: Array) { return this.service.create(createDto, files); } @ApiBearerAuth("jwt") @Post('/bulk') @UseInterceptors(AnyFilesInterceptor()) - insertMany(@Body() createDtos: CreateDashboardVariableDto[], @UploadedFiles() filesArray: Express.Multer.File[][] = []) { + insertMany(@Body() createDtos: CreateDashboardUserLayoutDto[], @UploadedFiles() filesArray: Express.Multer.File[][] = []) { return this.service.insertMany(createDtos, filesArray); } @@ -33,14 +33,14 @@ export class DashboardVariableController { @ApiBearerAuth("jwt") @Put(':id') @UseInterceptors(AnyFilesInterceptor()) - update(@Param('id') id: number, @Body() updateDto: UpdateDashboardVariableDto, @UploadedFiles() files: Array) { + update(@Param('id') id: number, @Body() updateDto: UpdateDashboardUserLayoutDto, @UploadedFiles() files: Array) { return this.service.update(id, updateDto, files); } @ApiBearerAuth("jwt") @Patch(':id') @UseInterceptors(AnyFilesInterceptor()) - partialUpdate(@Param('id') id: number, @Body() updateDto: UpdateDashboardVariableDto, @UploadedFiles() files: Array) { + partialUpdate(@Param('id') id: number, @Body() updateDto: UpdateDashboardUserLayoutDto, @UploadedFiles() files: Array) { return this.service.update(id, updateDto, files, true); } diff --git a/src/controllers/dashboard.controller.ts b/src/controllers/dashboard.controller.ts index f9a9a7e3..615534e0 100644 --- a/src/controllers/dashboard.controller.ts +++ b/src/controllers/dashboard.controller.ts @@ -1,99 +1,80 @@ -import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, UploadedFiles, UseInterceptors } from '@nestjs/common'; -import { AnyFilesInterceptor } from "@nestjs/platform-express"; -import { ApiBearerAuth, ApiQuery, ApiTags } from '@nestjs/swagger'; -import { DashboardVariableSelectionDynamicQueryDto } from 'src/dtos/dashboard-variable-selection-dynamic-query.dto'; -import { CreateDashboardDto } from '../dtos/create-dashboard.dto'; -import { UpdateDashboardDto } from '../dtos/update-dashboard.dto'; -import { DashboardService } from '../services/dashboard.service'; +import { Body, Controller, Get, Param, Post, Put, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { ActiveUser } from 'src/decorators/active-user.decorator'; +import { DashboardVariableOptionsQueryDto } from 'src/dtos/dashboard-variable-options-query.dto'; +import { DashboardBatchDataRequestDto, DashboardSaveLayoutDto, DashboardWidgetDataRequestDto } from 'src/dtos/dashboard-widget-data-request.dto'; +import { ActiveUserData } from 'src/interfaces/active-user-data.interface'; +import { DashboardRuntimeService } from 'src/services/dashboard-runtime.service'; -enum ShowSoftDeleted { - INCLUSIVE = "inclusive", - EXCLUSIVE = "exclusive", -} - -@ApiTags('Solid Core') @Controller('dashboard') +@ApiTags('Solid Core') export class DashboardController { - constructor(private readonly service: DashboardService) { } - - @ApiBearerAuth("jwt") - @Post() - @UseInterceptors(AnyFilesInterceptor()) - create(@Body() createDto: CreateDashboardDto, @UploadedFiles() files: Array) { - return this.service.create(createDto, files); - } - - @ApiBearerAuth("jwt") - @Post('/bulk') - @UseInterceptors(AnyFilesInterceptor()) - insertMany(@Body() createDtos: CreateDashboardDto[], @UploadedFiles() filesArray: Express.Multer.File[][] = []) { - return this.service.insertMany(createDtos, filesArray); - } + constructor( + private readonly dashboardRuntimeService: DashboardRuntimeService, + ) { } + @ApiBearerAuth('jwt') + @Get(':moduleName/:dashboardName/definition') + async getDefinition( + @Param('moduleName') moduleName: string, + @Param('dashboardName') dashboardName: string, + ) { + return this.dashboardRuntimeService.getDashboardDefinition(moduleName, dashboardName); + } - @ApiBearerAuth("jwt") - @Put(':id') - @UseInterceptors(AnyFilesInterceptor()) - update(@Param('id') id: number, @Body() updateDto: UpdateDashboardDto, @UploadedFiles() files: Array) { - return this.service.update(id, updateDto, files); - } + @ApiBearerAuth('jwt') + @Post(':moduleName/:dashboardName/widgets/:widgetName/data') + async getWidgetData( + @Param('moduleName') moduleName: string, + @Param('dashboardName') dashboardName: string, + @Param('widgetName') widgetName: string, + @Body() request: DashboardWidgetDataRequestDto, + @ActiveUser() activeUser: ActiveUserData, + ) { + return this.dashboardRuntimeService.getWidgetData(moduleName, dashboardName, widgetName, request, activeUser); + } - @ApiBearerAuth("jwt") - @Patch(':id') - @UseInterceptors(AnyFilesInterceptor()) - partialUpdate(@Param('id') id: number, @Body() updateDto: UpdateDashboardDto, @UploadedFiles() files: Array) { - return this.service.update(id, updateDto, files, true); - } + @ApiBearerAuth('jwt') + @Post(':moduleName/:dashboardName/data') + async getDashboardData( + @Param('moduleName') moduleName: string, + @Param('dashboardName') dashboardName: string, + @Body() request: DashboardBatchDataRequestDto, + @ActiveUser() activeUser: ActiveUserData, + ) { + return this.dashboardRuntimeService.getDashboardData(moduleName, dashboardName, request, activeUser); + } - @ApiBearerAuth("jwt") - @Post('/bulk-recover') - async recoverMany(@Body() ids: number[]) { - return this.service.recoverMany(ids); - } + @ApiBearerAuth('jwt') + @Get(':moduleName/:dashboardName/variable-options/:variableName') + async getVariableOptions( + @Param('moduleName') moduleName: string, + @Param('dashboardName') dashboardName: string, + @Param('variableName') variableName: string, + @Query() query: DashboardVariableOptionsQueryDto, + ) { + return this.dashboardRuntimeService.getVariableOptions(moduleName, dashboardName, variableName, query); + } - @ApiBearerAuth("jwt") - @Get('/recover/:id') - async recover(@Param('id') id: number) { - return this.service.recover(id); - } - - @ApiBearerAuth("jwt") - @ApiQuery({ name: 'showSoftDeleted', required: false, enum: ShowSoftDeleted }) - @ApiQuery({ name: 'limit', required: false, type: Number }) - @ApiQuery({ name: 'offset', required: false, type: Number }) - @ApiQuery({ name: 'fields', required: false, type: Array }) - @ApiQuery({ name: 'sort', required: false, type: Array }) - @ApiQuery({ name: 'groupBy', required: false, type: Array }) - @ApiQuery({ name: 'populate', required: false, type: Array }) - @ApiQuery({ name: 'populateMedia', required: false, type: Array }) - @ApiQuery({ name: 'filters', required: false, type: Array }) - @Get() - async findMany(@Query() query: any) { - return this.service.find(query); - } - - @ApiBearerAuth("jwt") - @Get('/selection-dynamic-values') - async getSelectionDynamicValues(@Query() query: DashboardVariableSelectionDynamicQueryDto) { - return this.service.getSelectionDynamicValues(query); - } - - @ApiBearerAuth("jwt") - @Get(':id') - async findOne(@Param('id') id: string, @Query() query: any) { - return this.service.findOne(+id, query); - } - - @ApiBearerAuth("jwt") - @Delete('/bulk') - async deleteMany(@Body() ids: number[]) { - return this.service.deleteMany(ids); - } - - @ApiBearerAuth("jwt") - @Delete(':id') - async delete(@Param('id') id: number) { - return this.service.delete(id); - } + @ApiBearerAuth('jwt') + @Get(':moduleName/:dashboardName/layout') + async getLayout( + @Param('moduleName') moduleName: string, + @Param('dashboardName') dashboardName: string, + @ActiveUser() activeUser: ActiveUserData, + ) { + return this.dashboardRuntimeService.getLayout(moduleName, dashboardName, activeUser); + } + @ApiBearerAuth('jwt') + @Put(':moduleName/:dashboardName/layout') + async saveLayout( + @Param('moduleName') moduleName: string, + @Param('dashboardName') dashboardName: string, + @Body() body: DashboardSaveLayoutDto, + @ActiveUser() activeUser: ActiveUserData, + ) { + return this.dashboardRuntimeService.saveLayout(moduleName, dashboardName, body, activeUser); + } } + diff --git a/src/controllers/facebook-authentication.controller.ts b/src/controllers/facebook-authentication.controller.ts index 912616b1..a2acb886 100644 --- a/src/controllers/facebook-authentication.controller.ts +++ b/src/controllers/facebook-authentication.controller.ts @@ -24,7 +24,7 @@ import { UserService } from "../services/user.service"; import type { SolidCoreSetting } from "../services/settings/default-settings-provider.service"; @Auth(AuthType.None) -@ApiTags("Iam") +@ApiTags("Solid Core") @Controller("iam/facebook") export class FacebookAuthenticationController { constructor( diff --git a/src/controllers/google-authentication.controller.ts b/src/controllers/google-authentication.controller.ts index 5deee164..dd63d205 100755 --- a/src/controllers/google-authentication.controller.ts +++ b/src/controllers/google-authentication.controller.ts @@ -16,7 +16,7 @@ import type { SolidCoreSetting } from "src/services/settings/default-settings-pr @Auth(AuthType.None) @Controller('iam/google') -@ApiTags("Iam") +@ApiTags("Solid Core") // @UseGuards(ThrottlerGuard) // @SkipThrottle({ login: false, short: false, burst: true, sustained: true }) //Enable the login throttle only export class GoogleAuthenticationController { diff --git a/src/controllers/gupshup-webhook.controller.ts b/src/controllers/gupshup-webhook.controller.ts new file mode 100644 index 00000000..ba7ab5d1 --- /dev/null +++ b/src/controllers/gupshup-webhook.controller.ts @@ -0,0 +1,91 @@ +import { + Body, + Controller, + HttpCode, + HttpStatus, + Logger, + Post, + Req, +} from "@nestjs/common"; +import { ApiTags } from "@nestjs/swagger"; +import { Request } from "express"; +import { Auth } from "src/decorators/auth.decorator"; +import { Public } from "src/decorators/public.decorator"; +import { AuthType } from "src/enums/auth-type.enum"; + +@Auth(AuthType.None) +@Controller("webhook/whatsapp/gupshup") +@ApiTags("Solid Core") +export class GupshupWebhookController { + private readonly logger = new Logger(GupshupWebhookController.name); + + @Public() + @Post() + @HttpCode(HttpStatus.OK) + async receiveWebhook(@Req() req: Request, @Body() body: unknown) { + const userAgent = req.headers["user-agent"] ?? null; + this.logger.log( + `Received Gupshup WhatsApp webhook${userAgent ? ` from ${userAgent}` : ""}`, + ); + this.logger.debug(`Gupshup webhook payload: ${JSON.stringify(body)}`); + + const statusInfo = this.extractStatusInfo(body); + if (statusInfo) { + this.logger.log( + `Gupshup delivery update: status=${statusInfo.status ?? "unknown"}, messageId=${statusInfo.messageId ?? "n/a"}, destination=${statusInfo.destination ?? "n/a"}, reason=${statusInfo.reason ?? "n/a"}`, + ); + } + + return { + success: true, + message: "Webhook received", + }; + } + + private extractStatusInfo(body: unknown): { + status?: string; + messageId?: string; + destination?: string; + reason?: string; + } | null { + if (!body || typeof body !== "object") { + return null; + } + + const payload = body as Record; + + const status = + this.asString(payload.status) || + this.asString(payload.messageStatus) || + this.asString((payload.payload as Record)?.status) || + this.asString((payload.payload as Record)?.type); + + const messageId = + this.asString(payload.messageId) || + this.asString(payload.id) || + this.asString((payload.payload as Record)?.id) || + this.asString((payload.payload as Record)?.messageId); + + const destination = + this.asString(payload.destination) || + this.asString(payload.phone) || + this.asString((payload.payload as Record)?.destination) || + this.asString((payload.payload as Record)?.phone); + + const reason = + this.asString(payload.reason) || + this.asString(payload.error) || + this.asString((payload.payload as Record)?.reason) || + this.asString((payload.payload as Record)?.error); + + if (!status && !messageId && !destination && !reason) { + return null; + } + + return { status, messageId, destination, reason }; + } + + private asString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; + } +} diff --git a/src/controllers/mcp-audit-log.controller.ts b/src/controllers/mcp-audit-log.controller.ts new file mode 100644 index 00000000..6f7ff6a0 --- /dev/null +++ b/src/controllers/mcp-audit-log.controller.ts @@ -0,0 +1,70 @@ +import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, UploadedFiles, UseInterceptors } from '@nestjs/common'; +import { AnyFilesInterceptor } from '@nestjs/platform-express'; +import { ApiBearerAuth, ApiQuery, ApiTags } from '@nestjs/swagger'; +import { CreateMcpAuditLogDto } from 'src/dtos/create-mcp-audit-log.dto'; +import { UpdateMcpAuditLogDto } from 'src/dtos/update-mcp-audit-log.dto'; +import { McpAuditLogService } from 'src/services/mcp-audit-log.service'; + +@ApiTags('Solid Core') +@Controller('mcp-audit-log') +export class McpAuditLogController { + constructor(private readonly service: McpAuditLogService) {} + + @ApiBearerAuth('jwt') + @Post() + @UseInterceptors(AnyFilesInterceptor()) + create(@Body() createDto: CreateMcpAuditLogDto, @UploadedFiles() files: Array) { + return this.service.create(createDto, files); + } + + @ApiBearerAuth('jwt') + @Post('/bulk') + @UseInterceptors(AnyFilesInterceptor()) + insertMany(@Body() createDtos: CreateMcpAuditLogDto[], @UploadedFiles() filesArray: Express.Multer.File[][] = []) { + return this.service.insertMany(createDtos, filesArray); + } + + @ApiBearerAuth('jwt') + @Put(':id') + @UseInterceptors(AnyFilesInterceptor()) + update(@Param('id') id: number, @Body() updateDto: UpdateMcpAuditLogDto, @UploadedFiles() files: Array) { + return this.service.update(id, updateDto, files); + } + + @ApiBearerAuth('jwt') + @Patch(':id') + @UseInterceptors(AnyFilesInterceptor()) + partialUpdate(@Param('id') id: number, @Body() updateDto: UpdateMcpAuditLogDto, @UploadedFiles() files: Array) { + return this.service.update(id, updateDto, files, true); + } + + @ApiBearerAuth('jwt') + @ApiQuery({ name: 'limit', required: false, type: Number }) + @ApiQuery({ name: 'offset', required: false, type: Number }) + @ApiQuery({ name: 'fields', required: false, type: Array }) + @ApiQuery({ name: 'sort', required: false, type: Array }) + @ApiQuery({ name: 'populate', required: false, type: Array }) + @ApiQuery({ name: 'filters', required: false, type: Array }) + @Get() + findMany(@Query() query: any) { + return this.service.find(query); + } + + @ApiBearerAuth('jwt') + @Get(':id') + findOne(@Param('id') id: string, @Query() query: any) { + return this.service.findOne(+id, query); + } + + @ApiBearerAuth('jwt') + @Delete('/bulk') + deleteMany(@Body() ids: number[]) { + return this.service.deleteMany(ids); + } + + @ApiBearerAuth('jwt') + @Delete(':id') + delete(@Param('id') id: number) { + return this.service.delete(id); + } +} diff --git a/src/controllers/menu-item-metadata.controller.ts b/src/controllers/menu-item-metadata.controller.ts index d38a88aa..25fac2f6 100755 --- a/src/controllers/menu-item-metadata.controller.ts +++ b/src/controllers/menu-item-metadata.controller.ts @@ -9,7 +9,7 @@ import { ActiveUserData } from 'src/interfaces/active-user-data.interface'; import { SolidRequestContextDto } from 'src/dtos/solid-request-context.dto'; import { SolidRequestContextDecorator } from 'src/decorators/solid-request-context.decorator'; -@ApiTags('App Builder') +@ApiTags('Solid Core') @Controller('menu-item-metadata') //FIXME: Change this to the model plural name export class MenuItemMetadataController { constructor(private readonly service: MenuItemMetadataService) { } diff --git a/src/controllers/meta-cloud-whatsapp-webhook.controller.ts b/src/controllers/meta-cloud-whatsapp-webhook.controller.ts new file mode 100644 index 00000000..1c97c706 --- /dev/null +++ b/src/controllers/meta-cloud-whatsapp-webhook.controller.ts @@ -0,0 +1,155 @@ +import { + Body, + Controller, + Get, + HttpCode, + HttpStatus, + Logger, + Post, + Query, + Res, +} from "@nestjs/common"; +import { ApiTags } from "@nestjs/swagger"; +import { Response } from "express"; +import { Auth } from "src/decorators/auth.decorator"; +import { Public } from "src/decorators/public.decorator"; +import { AuthType } from "src/enums/auth-type.enum"; +import { SettingService } from "src/services/setting.service"; +import type { SolidCoreSetting } from "src/services/settings/default-settings-provider.service"; + +@Auth(AuthType.None) +@Controller("webhook/whatsapp/meta-cloud") +@ApiTags("Solid Core") +export class MetaCloudWhatsappWebhookController { + private readonly logger = new Logger(MetaCloudWhatsappWebhookController.name); + + constructor(private readonly settingService: SettingService) {} + + @Public() + @Get() + verifyWebhook(@Query() query: Record, @Res() res: Response) { + const mode = this.resolveQueryValue(query, "hub.mode", "hub_mode", "mode"); + const verifyToken = this.resolveQueryValue( + query, + "hub.verify_token", + "hub_verify_token", + "verify_token", + ); + const challenge = this.resolveQueryValue( + query, + "hub.challenge", + "hub_challenge", + "challenge", + ); + + const configuredVerifyToken = + this.settingService.getConfigValue( + "metaWhatsappWebhookVerifyToken", + ) || process.env.COMMON_META_WHATSAPP_WEBHOOK_VERIFY_TOKEN; + + const isVerificationCall = mode === "subscribe"; + const tokenMatches = + !!configuredVerifyToken && verifyToken === configuredVerifyToken; + + if (isVerificationCall && tokenMatches && challenge) { + this.logger.log("Meta Cloud WhatsApp webhook verified successfully."); + res.writeHead(HttpStatus.OK, { + "Content-Type": "text/plain", + "Content-Length": Buffer.byteLength(String(challenge)), + }); + res.write(String(challenge)); + return res.end(); + } + + this.logger.warn( + `Meta Cloud webhook verification failed. mode=${mode ?? "n/a"}, tokenMatch=${tokenMatches}`, + ); + + res.writeHead(HttpStatus.FORBIDDEN, { "Content-Type": "text/plain" }); + return res.end("Webhook verification failed"); + } + + @Public() + @Post() + @HttpCode(HttpStatus.OK) + async receiveWebhook(@Body() body: unknown) { + this.logger.log("Received Meta Cloud WhatsApp webhook"); + this.logger.debug(`Meta Cloud webhook payload: ${JSON.stringify(body)}`); + + const statusInfo = this.extractStatusInfo(body); + if (statusInfo) { + this.logger.log( + `Meta Cloud delivery update: status=${statusInfo.status ?? "unknown"}, messageId=${statusInfo.messageId ?? "n/a"}, destination=${statusInfo.destination ?? "n/a"}, reason=${statusInfo.reason ?? "n/a"}`, + ); + } + + return { + success: true, + message: "Webhook received", + }; + } + + private extractStatusInfo(body: unknown): { + status?: string; + messageId?: string; + destination?: string; + reason?: string; + } | null { + if (!body || typeof body !== "object") { + return null; + } + + const payload = body as Record; + const entries = payload.entry as Array> | undefined; + const changes = entries?.[0]?.changes as Array> | undefined; + const value = changes?.[0]?.value as Record | undefined; + const statuses = value?.statuses as Array> | undefined; + const status = statuses?.[0]; + + if (!status) { + return null; + } + + const errors = status.errors as Array> | undefined; + + return { + status: this.asString(status.status), + messageId: this.asString(status.id), + destination: this.asString(status.recipient_id), + reason: + this.asString(errors?.[0]?.title) || this.asString(errors?.[0]?.message), + }; + } + + private asString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; + } + + private resolveQueryValue( + query: Record, + ...keys: string[] + ): string | undefined { + for (const key of keys) { + const directValue = this.asString(query[key]); + if (directValue) { + return directValue; + } + } + + const hubRaw = query.hub; + if (hubRaw && typeof hubRaw === "object" && !Array.isArray(hubRaw)) { + const hub = hubRaw as Record; + for (const key of ["mode", "verify_token", "challenge"]) { + const value = this.asString(hub[key]); + if ( + value && + keys.some((candidate) => candidate.endsWith(key) || candidate === key) + ) { + return value; + } + } + } + + return undefined; + } +} diff --git a/src/controllers/microsoft-active-directory-authentication.controller.ts b/src/controllers/microsoft-active-directory-authentication.controller.ts new file mode 100644 index 00000000..a742d404 --- /dev/null +++ b/src/controllers/microsoft-active-directory-authentication.controller.ts @@ -0,0 +1,104 @@ +import { + Controller, + Get, + InternalServerErrorException, + Query, + Req, + Res, + UseGuards, +} from "@nestjs/common"; +import { ApiQuery, ApiTags } from "@nestjs/swagger"; +import { Request, Response } from "express"; +import { + DEFAULT_MICROSOFT_ACTIVE_DIRECTORY_OAUTH_TENANT, + MicrosoftActiveDirectoryAuthConfiguration, + isMicrosoftActiveDirectoryOAuthConfigured, +} from "../helpers/microsoft-active-directory-oauth.helper"; +import { AuthenticationService } from "../services/authentication.service"; +import { SettingService } from "../services/setting.service"; +import { Public } from "src/decorators/public.decorator"; +import { Auth } from "../decorators/auth.decorator"; +import { AuthType } from "../enums/auth-type.enum"; +import { MicrosoftActiveDirectoryOauthGuard } from "../passport-strategies/microsoft-active-directory-oauth.strategy"; +import { UserService } from "../services/user.service"; +import type { SolidCoreSetting } from "../services/settings/default-settings-provider.service"; + +@Auth(AuthType.None) +@ApiTags("Solid Core") +@Controller("iam/microsoft-active-directory") +export class MicrosoftActiveDirectoryAuthenticationController { + constructor( + private readonly userService: UserService, + private readonly authService: AuthenticationService, + private readonly settingService: SettingService, + ) {} + + private async getConfiguration(): Promise { + return { + clientID: this.settingService.getConfigValue("MICROSOFT_ACTIVE_DIRECTORY_CLIENT_ID"), + clientSecret: this.settingService.getConfigValue("MICROSOFT_ACTIVE_DIRECTORY_CLIENT_SECRET"), + tenant: this.settingService.getConfigValue("MICROSOFT_ACTIVE_DIRECTORY_TENANT_ID" ) ?? DEFAULT_MICROSOFT_ACTIVE_DIRECTORY_OAUTH_TENANT, + callbackURL: this.settingService.getConfigValue("MICROSOFT_ACTIVE_DIRECTORY_CALLBACK_URL"), + redirectURL: this.settingService.getConfigValue("MICROSOFT_ACTIVE_DIRECTORY_REDIRECT_URL"), + }; + } + + private buildFrontendRedirectUrl(redirectURL: string, accessCode: string) { + const separator = redirectURL.includes("?") ? "&" : "?"; + return `${redirectURL}${separator}accessCode=${encodeURIComponent(accessCode)}`; + } + + private async validateConfiguration() { + const config = await this.getConfiguration(); + if (!isMicrosoftActiveDirectoryOAuthConfigured(config)) { + throw new InternalServerErrorException("Microsoft Active Directory OAuth is not configured"); + } + return config; + } + + @Public() + @UseGuards(MicrosoftActiveDirectoryOauthGuard) + @Get("connect") + async connect() { + await this.validateConfiguration(); + } + + @Public() + @Get("connect/callback") + @UseGuards(MicrosoftActiveDirectoryOauthGuard) + async microsoftActiveDirectoryAuthCallback( + @Req() req: Request, + @Res() res: Response, + ) { + const config = await this.validateConfiguration(); + const user = req.user; + return res.redirect( + this.buildFrontendRedirectUrl(config.redirectURL, user["accessCode"]), + ); + } + + @Public() + @Get("dummy-redirect") + async dummyMicrosoftActiveDirectoryAuthRedirect( + @Query("accessCode") accessCode, + ) { + await this.validateConfiguration(); + const user = await this.userService.findOneByAccessCode(accessCode); + + if (user) { + delete user["password"]; + } + + return user; + } + + @Public() + @Get("authenticate") + @ApiQuery({ name: "accessCode", required: true, type: String }) + async microsoftActiveDirectoryAuth( + @Query("accessCode") accessCode: string, + ) { + await this.validateConfiguration(); + return this.authService.signInUsingMicrosoftActiveDirectory(accessCode); + } +} diff --git a/src/controllers/microsoft-authentication.controller.ts b/src/controllers/microsoft-authentication.controller.ts index 7e5149ff..f34aab27 100644 --- a/src/controllers/microsoft-authentication.controller.ts +++ b/src/controllers/microsoft-authentication.controller.ts @@ -24,7 +24,7 @@ import { UserService } from "../services/user.service"; import type { SolidCoreSetting } from "../services/settings/default-settings-provider.service"; @Auth(AuthType.None) -@ApiTags("Iam") +@ApiTags("Solid Core") @Controller("iam/microsoft") export class MicrosoftAuthenticationController { constructor( diff --git a/src/controllers/model-metadata.controller.ts b/src/controllers/model-metadata.controller.ts index c124eed2..8ccde7e2 100755 --- a/src/controllers/model-metadata.controller.ts +++ b/src/controllers/model-metadata.controller.ts @@ -92,7 +92,7 @@ export class ModelMetadataController { @ApiBearerAuth("jwt") @Post(':id/generate-code') generateCode(@Param('id', ParseIntPipe) id: number) { - return this.modelMetadataService.handleGenerateCode({ modelId: id }); + return this.modelMetadataService.generateCodeViaCtl(id); } @ApiBearerAuth("jwt") diff --git a/src/controllers/module-metadata-explorer.controller.ts b/src/controllers/module-metadata-explorer.controller.ts new file mode 100644 index 00000000..4466320b --- /dev/null +++ b/src/controllers/module-metadata-explorer.controller.ts @@ -0,0 +1,83 @@ +import { Body, Controller, Get, Param, Post, Put, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { MetadataExplorerReferencesQueryDto } from '../dtos/metadata-explorer-references-query.dto'; +import { MetadataExplorerSearchQueryDto } from '../dtos/metadata-explorer-search-query.dto'; +import { MetadataExplorerWriteDto } from '../dtos/metadata-explorer-write.dto'; +import { ModuleMetadataExplorerService } from '../services/module-metadata-explorer.service'; + +@ApiTags('Solid Core') +@ApiBearerAuth('jwt') +@Controller('module-metadata-explorer') +export class ModuleMetadataExplorerController { + constructor( + private readonly moduleMetadataExplorerService: ModuleMetadataExplorerService, + ) { } + + @Get(':moduleName/manifest') + async getManifest(@Param('moduleName') moduleName: string) { + return this.moduleMetadataExplorerService.getManifest(moduleName); + } + + @Get(':moduleName/document') + async getDocument(@Param('moduleName') moduleName: string) { + return this.moduleMetadataExplorerService.getDocument(moduleName); + } + + @Put(':moduleName/document') + async updateDocument( + @Param('moduleName') moduleName: string, + @Body() body: MetadataExplorerWriteDto, + ) { + return this.moduleMetadataExplorerService.updateDocument(moduleName, body); + } + + @Post(':moduleName/document/validate') + async validateDocument( + @Param('moduleName') moduleName: string, + @Body() body: MetadataExplorerWriteDto, + ) { + return this.moduleMetadataExplorerService.validateDocument(moduleName, body); + } + + @Get(':moduleName/sections/:sectionKey') + async getSection( + @Param('moduleName') moduleName: string, + @Param('sectionKey') sectionKey: string, + ) { + return this.moduleMetadataExplorerService.getSection(moduleName, sectionKey); + } + + @Put(':moduleName/sections/:sectionKey') + async updateSection( + @Param('moduleName') moduleName: string, + @Param('sectionKey') sectionKey: string, + @Body() body: MetadataExplorerWriteDto, + ) { + return this.moduleMetadataExplorerService.updateSection(moduleName, sectionKey, body); + } + + @Post(':moduleName/sections/:sectionKey/validate') + async validateSection( + @Param('moduleName') moduleName: string, + @Param('sectionKey') sectionKey: string, + @Body() body: MetadataExplorerWriteDto, + ) { + return this.moduleMetadataExplorerService.validateSection(moduleName, sectionKey, body); + } + + @Get(':moduleName/search') + async search( + @Param('moduleName') moduleName: string, + @Query() query: MetadataExplorerSearchQueryDto, + ) { + return this.moduleMetadataExplorerService.search(moduleName, query); + } + + @Get(':moduleName/references') + async findReferences( + @Param('moduleName') moduleName: string, + @Query() query: MetadataExplorerReferencesQueryDto, + ) { + return this.moduleMetadataExplorerService.findReferences(moduleName, query); + } +} diff --git a/src/controllers/module-metadata.controller.ts b/src/controllers/module-metadata.controller.ts index 1f3c2b40..629ba774 100755 --- a/src/controllers/module-metadata.controller.ts +++ b/src/controllers/module-metadata.controller.ts @@ -62,7 +62,13 @@ export class ModuleMetadataController { @ApiBearerAuth("jwt") @Post(':id/generate-code') generateCode(@Param('id', ParseIntPipe) id: number) { - return this.moduleMetadataService.generateCode({ moduleId: id }); + return this.moduleMetadataService.generateCodeViaCtl(id); + } + + @ApiBearerAuth("jwt") + @Post(':id/seed') + seedModule(@Param('id', ParseIntPipe) id: number) { + return this.moduleMetadataService.seedModuleFromMetadata(id); } diff --git a/src/controllers/module-package.controller.ts b/src/controllers/module-package.controller.ts new file mode 100644 index 00000000..e90dea43 --- /dev/null +++ b/src/controllers/module-package.controller.ts @@ -0,0 +1,102 @@ +import { + Controller, + Get, + Param, + Post, + Body, + Res, + UploadedFiles, + UseInterceptors, +} from '@nestjs/common'; +import { AnyFilesInterceptor } from '@nestjs/platform-express'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { Response } from 'express'; +import { ConfirmModulePackageImportDto } from 'src/dtos/confirm-module-package-import.dto'; +import { RunModulePackageBuildDto } from 'src/dtos/run-module-package-build.dto'; +import { RunModulePackageSeedDto } from 'src/dtos/run-module-package-seed.dto'; +import { ModulePackageService } from 'src/services/module-package.service'; + +@Controller('module-packages') +@ApiTags('Solid Core') +export class ModulePackageController { + constructor( + private readonly modulePackageService: ModulePackageService, + ) { } + + @ApiBearerAuth('jwt') + @Post('import/validate') + @UseInterceptors(AnyFilesInterceptor()) + async validateImportPackage( + @UploadedFiles() files: Array, + ) { + return this.modulePackageService.validateUpload(files?.[0]); + } + + @ApiBearerAuth('jwt') + @Get('export/:moduleName') + async exportModulePackage( + @Param('moduleName') moduleName: string, + @Res() res: Response, + ) { + const archive = await this.modulePackageService.exportModulePackage(moduleName); + res.setHeader('Content-Disposition', `attachment; filename="${archive.fileName}"`); + res.setHeader('Content-Type', archive.mimeType); + res.setHeader('Access-Control-Expose-Headers', 'Content-Disposition, Content-Type'); + res.sendFile(archive.filePath); + } + + @ApiBearerAuth('jwt') + @Get('import/resumable/latest') + async getLatestResumableImport() { + return this.modulePackageService.getLatestResumableImport(); + } + + @ApiBearerAuth('jwt') + @Post('runtime/clear') + async clearPackageRuntime() { + return this.modulePackageService.clearPackageRuntime(); + } + + @ApiBearerAuth('jwt') + @Post('import/:id/confirm') + async confirmImport( + @Param('id') id: string, + @Body() dto: ConfirmModulePackageImportDto, + ) { + return this.modulePackageService.confirmImport(id, dto); + } + + @ApiBearerAuth('jwt') + @Post('import/:id/dismiss') + async dismissImport( + @Param('id') id: string, + ) { + return this.modulePackageService.dismissImport(id); + } + + @ApiBearerAuth('jwt') + @Get('import/:id/status') + async getImportStatus( + @Param('id') id: string, + ) { + return this.modulePackageService.getStatus(id); + } + + @ApiBearerAuth('jwt') + @Post('import/:id/build') + async runBuild( + @Param('id') id: string, + @Body() dto: RunModulePackageBuildDto, + ) { + return this.modulePackageService.runBuild(id, dto); + } + + @ApiBearerAuth('jwt') + @Post('import/:id/seed') + async runSeed( + @Param('id') id: string, + @Body() dto: RunModulePackageSeedDto, + ) { + return this.modulePackageService.runSeed(id, dto); + } +} diff --git a/src/controllers/mq-message-queue.controller.ts b/src/controllers/mq-message-queue.controller.ts index e4f29f49..965866c2 100755 --- a/src/controllers/mq-message-queue.controller.ts +++ b/src/controllers/mq-message-queue.controller.ts @@ -7,7 +7,7 @@ import { MqMessageQueueService } from '../services/mq-message-queue.service'; import { SolidRequestContextDto } from 'src/dtos/solid-request-context.dto'; import { SolidRequestContextDecorator } from 'src/decorators/solid-request-context.decorator'; -@ApiTags('Queues') +@ApiTags('Solid Core') @Controller('mq-message-queue') //FIXME: Change this to the model plural name export class MqMessageQueueController { constructor(protected readonly service: MqMessageQueueService) { } diff --git a/src/controllers/mq-message.controller.ts b/src/controllers/mq-message.controller.ts index 15bbd3c7..89ec2919 100755 --- a/src/controllers/mq-message.controller.ts +++ b/src/controllers/mq-message.controller.ts @@ -7,7 +7,7 @@ import { AnyFilesInterceptor } from '@nestjs/platform-express'; import { SolidRequestContextDecorator } from 'src/decorators/solid-request-context.decorator'; import { SolidRequestContextDto } from 'src/dtos/solid-request-context.dto'; -@ApiTags('Queues') +@ApiTags('Solid Core') @Controller('mq-message') //FIXME: Change this to the model plural name export class MqMessageController { constructor(protected readonly service: MqMessageService) { } diff --git a/src/controllers/service.controller.ts b/src/controllers/service.controller.ts index 8e6bf045..fca3b735 100755 --- a/src/controllers/service.controller.ts +++ b/src/controllers/service.controller.ts @@ -4,8 +4,6 @@ import { ActiveUser } from 'src/decorators/active-user.decorator'; import { Public } from 'src/decorators/public.decorator'; import { ErrorMapperService } from 'src/helpers/error-mapper.service'; import { ActiveUserData } from 'src/interfaces/active-user-data.interface'; -import { AiInteractionService } from 'src/services/ai-interaction.service'; -import { IngestMetadataService } from 'src/services/genai/ingest-metadata.service'; import { MqMessageService } from 'src/services/mq-message.service'; import { SolidRegistry } from '../helpers/solid-registry'; @@ -22,10 +20,8 @@ export class ServiceController { constructor( private readonly solidRegistry: SolidRegistry, - private readonly aiInteractionService: AiInteractionService, private readonly mqMessageService: MqMessageService, private readonly errorMapper: ErrorMapperService, - private readonly ingestMetadataService: IngestMetadataService, ) { } @Public() @@ -94,18 +90,19 @@ export class ServiceController { @ApiBearerAuth("jwt") @Post('seed') async seedData(@Body() seedData: any) { + const seederName = seedData?.seeder ?? 'ModuleMetadataSeederService'; const seeder = this.solidRegistry .getSeeders() - .filter((seeder) => seeder.name === seedData.seeder) + .filter((seeder) => seeder.name === seederName) .map((seeder) => seeder.instance) .pop(); if (!seeder) { - this.logger.error(`Seeder service ${seedData.seeder} not found. Does your service have a seed() method?`); + this.logger.error(`Seeder service ${seederName} not found. Does your service have a seed() method?`); return; } this.logger.log(`Running the seed() method for seeder :${seeder.constructor.name}`); await seeder.seed(); - return { message: `seed data for ${seedData.seeder}` }; + return { message: `seed data for ${seederName}` }; } @ApiBearerAuth("jwt") diff --git a/src/controllers/test.controller.ts b/src/controllers/test.controller.ts index c3e45614..a1801168 100755 --- a/src/controllers/test.controller.ts +++ b/src/controllers/test.controller.ts @@ -4,7 +4,6 @@ import { ApiBearerAuth, ApiTags } from "@nestjs/swagger"; import { SolidRegistry } from "src/helpers/solid-registry"; import { Auth } from "src/decorators/auth.decorator"; import { AuthType } from "src/enums/auth-type.enum"; -import { IngestMetadataService } from "src/services/genai/ingest-metadata.service"; export class SeedData { seeder: string; @@ -16,7 +15,6 @@ export class TestController { private readonly logger = new Logger(TestController.name); constructor( private readonly solidRegistry: SolidRegistry, - private readonly ingestMetadataService: IngestMetadataService, ) { } @Auth(AuthType.None) @@ -37,11 +35,4 @@ export class TestController { return { filename: file.originalname }; } - @ApiBearerAuth("jwt") - @Post('mcp/ingest') - @UseInterceptors(FileInterceptor('file')) - async mcpIngest(@UploadedFile() file: Express.Multer.File, @Body() body: any) { - await this.ingestMetadataService.ingest(); - return { ok: true }; - } } diff --git a/src/controllers/user.controller.ts b/src/controllers/user.controller.ts index 412b0a6e..96f5fa56 100755 --- a/src/controllers/user.controller.ts +++ b/src/controllers/user.controller.ts @@ -20,37 +20,37 @@ export class UserController { @ApiBearerAuth("jwt") @Post() @UseInterceptors(AnyFilesInterceptor()) - create(@Body() createDto: CreateUserDto, @UploadedFiles() files: Array,@SolidRequestContextDecorator() solidRequestContext:SolidRequestContextDto) { - return this.service.create(createDto, files,solidRequestContext); + create(@Body() createDto: CreateUserDto, @UploadedFiles() files: Array, @SolidRequestContextDecorator() solidRequestContext: SolidRequestContextDto) { + return this.service.create(createDto, files, solidRequestContext); } @ApiBearerAuth("jwt") @Post('/bulk') @UseInterceptors(AnyFilesInterceptor()) - insertMany(@Body() createDtos: CreateUserDto[], @UploadedFiles() filesArray: Express.Multer.File[][] = [],@SolidRequestContextDecorator() solidRequestContext:SolidRequestContextDto) { - return this.service.insertMany(createDtos, filesArray,solidRequestContext); + insertMany(@Body() createDtos: CreateUserDto[], @UploadedFiles() filesArray: Express.Multer.File[][] = [], @SolidRequestContextDecorator() solidRequestContext: SolidRequestContextDto) { + return this.service.insertMany(createDtos, filesArray, solidRequestContext); } @ApiBearerAuth("jwt") @Put(':id') @UseInterceptors(AnyFilesInterceptor()) - update(@Param('id') id: number, @Body() updateDto: UpdateUserDto, @UploadedFiles() files: Array ,@SolidRequestContextDecorator() solidRequestContext:SolidRequestContextDto) { - return this.service.update(id, updateDto, files,false,solidRequestContext); + update(@Param('id') id: number, @Body() updateDto: UpdateUserDto, @UploadedFiles() files: Array, @SolidRequestContextDecorator() solidRequestContext: SolidRequestContextDto) { + return this.service.update(id, updateDto, files, false, solidRequestContext); } @ApiBearerAuth("jwt") @Patch(':id/update-user-and-roles') - updateUser(@Param('id') id: number,@Body() updateDto: any, @UploadedFiles() files: Array, @SolidRequestContextDecorator() solidRequestContext:SolidRequestContextDto) { - return this.service.updateUser(id, updateDto, files,solidRequestContext); + updateUser(@Param('id') id: number, @Body() updateDto: any, @UploadedFiles() files: Array, @SolidRequestContextDecorator() solidRequestContext: SolidRequestContextDto) { + return this.service.updateUser(id, updateDto, files, solidRequestContext); } @ApiBearerAuth("jwt") @Patch(':id') @UseInterceptors(AnyFilesInterceptor()) - partialUpdate(@Param('id') id: number, @Body() updateDto: UpdateUserDto, @UploadedFiles() files: Array,@SolidRequestContextDecorator() solidRequestContext:SolidRequestContextDto) { + partialUpdate(@Param('id') id: number, @Body() updateDto: UpdateUserDto, @UploadedFiles() files: Array, @SolidRequestContextDecorator() solidRequestContext: SolidRequestContextDto) { return this.service.update(id, updateDto, files, true, solidRequestContext); } @@ -67,8 +67,8 @@ export class UserController { @ApiQuery({ name: 'populateMedia', required: false, type: Array }) @ApiQuery({ name: 'filters', required: false, type: Array }) @Get() - async findMany(@Query() query: any, @SolidRequestContextDecorator() solidRequestContext:SolidRequestContextDto) { - return this.service.find(query,solidRequestContext); + async findMany(@Query() query: any, @SolidRequestContextDecorator() solidRequestContext: SolidRequestContextDto) { + return this.service.find(query, solidRequestContext); } @ApiBearerAuth("jwt") @@ -81,18 +81,18 @@ export class UserController { @ApiBearerAuth("jwt") @Get(':id') - async findOne(@Param('id') id: string, @Query() query: any,@SolidRequestContextDecorator() solidRequestContext:SolidRequestContextDto) { - return this.service.findOne(+id, query,solidRequestContext); + async findOne(@Param('id') id: string, @Query() query: any, @SolidRequestContextDecorator() solidRequestContext: SolidRequestContextDto) { + return this.service.findOne(+id, query, solidRequestContext); } @Delete('/bulk') - async deleteMany(@Body() ids: number[],@SolidRequestContextDecorator() solidRequestContext:SolidRequestContextDto) { - return this.service.deleteMany(ids,solidRequestContext); + async deleteMany(@Body() ids: number[], @SolidRequestContextDecorator() solidRequestContext: SolidRequestContextDto) { + return this.service.deleteMany(ids, solidRequestContext); } @ApiBearerAuth("jwt") @Delete(':id') - async delete(@Param('id') id: number,@SolidRequestContextDecorator() solidRequestContext:SolidRequestContextDto) { + async delete(@Param('id') id: number, @SolidRequestContextDecorator() solidRequestContext: SolidRequestContextDto) { return this.service.delete(id, solidRequestContext); } diff --git a/src/controllers/view-metadata.controller.ts b/src/controllers/view-metadata.controller.ts index 9dba068c..9eda28fb 100755 --- a/src/controllers/view-metadata.controller.ts +++ b/src/controllers/view-metadata.controller.ts @@ -9,7 +9,7 @@ import { SolidRequestContextDto } from 'src/dtos/solid-request-context.dto'; import { ActiveUser } from 'src/decorators/active-user.decorator'; import { ActiveUserData } from 'src/interfaces/active-user-data.interface'; -@ApiTags('App Builder') +@ApiTags('Solid Core') @Controller('view-metadata') //FIXME: Change this to the model plural name export class ViewMetadataController { constructor(private readonly service: ViewMetadataService) { } diff --git a/src/decorators/dashboard-question-data-provider.decorator.ts b/src/decorators/dashboard-question-data-provider.decorator.ts deleted file mode 100644 index 17988e7a..00000000 --- a/src/decorators/dashboard-question-data-provider.decorator.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const IS_DASHBOARD_QUESTION_DATA_PROVIDER = 'IS_DASHBOARD_QUESTION_DATA_PROVIDER'; - -export const DashboardQuestionDataProvider = () => { - return (target: Function) => { - Reflect.defineMetadata(IS_DASHBOARD_QUESTION_DATA_PROVIDER, true, target); - }; -}; \ No newline at end of file diff --git a/src/decorators/dashboard-selection-provider.decorator.ts b/src/decorators/dashboard-selection-provider.decorator.ts deleted file mode 100644 index 034f860d..00000000 --- a/src/decorators/dashboard-selection-provider.decorator.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const IS_DASHBOARD_VARIABLE_SELECTION_PROVIDER = 'IS_DASHBOARD_VARIABLE_SELECTION_PROVIDER'; - -export const DashboardVariableSelectionProvider = () => { - return (target: Function) => { - Reflect.defineMetadata(IS_DASHBOARD_VARIABLE_SELECTION_PROVIDER, true, target); - }; -}; \ No newline at end of file diff --git a/src/decorators/dashboard-widget-data-provider.decorator.ts b/src/decorators/dashboard-widget-data-provider.decorator.ts new file mode 100644 index 00000000..b222876a --- /dev/null +++ b/src/decorators/dashboard-widget-data-provider.decorator.ts @@ -0,0 +1,6 @@ +import { SetMetadata } from "@nestjs/common"; + +export const IS_DASHBOARD_WIDGET_DATA_PROVIDER = "IS_DASHBOARD_WIDGET_DATA_PROVIDER"; + +export const DashboardWidgetDataProvider = () => SetMetadata(IS_DASHBOARD_WIDGET_DATA_PROVIDER, true); + diff --git a/src/dtos/confirm-module-package-import.dto.ts b/src/dtos/confirm-module-package-import.dto.ts new file mode 100644 index 00000000..9d16cefb --- /dev/null +++ b/src/dtos/confirm-module-package-import.dto.ts @@ -0,0 +1,12 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsBoolean, IsOptional } from 'class-validator'; + +export class ConfirmModulePackageImportDto { + @ApiPropertyOptional({ + description: 'Allow overwriting an existing module folder in solid-api and solid-ui.', + default: false, + }) + @IsOptional() + @IsBoolean() + overwriteExisting?: boolean = false; +} diff --git a/src/dtos/create-ai-interaction.dto.ts b/src/dtos/create-ai-interaction.dto.ts deleted file mode 100644 index 2985bcb5..00000000 --- a/src/dtos/create-ai-interaction.dto.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsInt } from 'class-validator'; -import { IsOptional } from 'class-validator'; -import { IsString, IsNotEmpty, IsJSON, IsBoolean } from 'class-validator'; - -export class CreateAiInteractionDto { - @IsOptional() - @IsInt() - @ApiProperty() - userId: number; - @IsString() - @IsOptional() - @ApiProperty() - userUserKey: string; - @IsNotEmpty() - @IsString() - @ApiProperty() - threadId: string; - @IsNotEmpty() - @IsString() - @ApiProperty() - role: string; - @IsNotEmpty() - @IsString() - @ApiProperty() - message: string; - @IsOptional() - @IsString() - @ApiProperty() - contentType: string; - @IsOptional() - @IsString() - @ApiProperty() - status: string; - @IsOptional() - @IsString() - @ApiProperty() - errorMessage: string; - @IsOptional() - @IsString() - @ApiProperty() - modelUsed: string; - @IsOptional() - @IsInt() - @ApiProperty() - responseTimeMs: number; - @IsOptional() - @IsJSON() - @ApiProperty() - metadata: any; - @IsOptional() - @IsBoolean() - @ApiProperty() - isApplied: boolean = false; - @IsOptional() - @IsInt() - @ApiProperty() - parentInteractionId: number; - @IsString() - @IsOptional() - @ApiProperty() - parentInteractionUserKey: string; - @IsOptional() - @IsBoolean() - @ApiProperty() - isAutoApply: boolean = false; - @IsOptional() - @IsInt() - @ApiProperty() - inputTokens: number; - @IsOptional() - @IsInt() - @ApiProperty() - outputTokens: number; - @IsOptional() - @IsInt() - @ApiProperty() - totalTokens: number; - -@IsOptional() -@IsString() -@ApiProperty() -originalMessage: string; - -@IsOptional() -@IsBoolean() -@ApiProperty() -isEdited: boolean = false; -} \ No newline at end of file diff --git a/src/dtos/create-dashboard-layout.dto.ts b/src/dtos/create-dashboard-layout.dto.ts deleted file mode 100644 index da15180e..00000000 --- a/src/dtos/create-dashboard-layout.dto.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsString } from 'class-validator'; -import { IsNotEmpty, IsOptional, IsInt } from 'class-validator'; - - -export class CreateDashboardLayoutDto { - @IsNotEmpty() - @IsString() - @ApiProperty() - layout: string; - - @IsOptional() - @IsInt() - @ApiProperty({ description: "Related Dashboard Model" }) - dashboardId: number; - - @IsString() - @IsOptional() - @ApiProperty({ description: "Related Dashboard Model" }) - dashboardUserKey: string; - - @IsOptional() - @IsInt() - @ApiProperty() - userId: number; - - @IsString() - @IsOptional() - @ApiProperty() - userUserKey: string; -} \ No newline at end of file diff --git a/src/dtos/create-dashboard-question-sql-dataset-config.dto.ts b/src/dtos/create-dashboard-question-sql-dataset-config.dto.ts deleted file mode 100644 index f941c628..00000000 --- a/src/dtos/create-dashboard-question-sql-dataset-config.dto.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsString } from 'class-validator'; -import { IsNotEmpty, IsOptional, IsInt, IsJSON } from 'class-validator'; - -export class CreateDashboardQuestionSqlDatasetConfigDto { - @IsNotEmpty() - @IsString() - @ApiProperty() - datasetName: string; - @IsNotEmpty() - @IsString() - @ApiProperty({ description: "This is the display name for the dataset configuration, which can be used in UI components to represent the dataset in a user-friendly manner." }) - datasetDisplayName: string; - @IsOptional() - @IsString() - @ApiProperty({ description: "This is a description of the dataset configuration, providing context and details about its purpose." }) - description: string; - @IsNotEmpty() - @IsString() - @ApiProperty() - sql: string; - @IsNotEmpty() - @IsString() - @ApiProperty() - labelColumnName: string; - @IsNotEmpty() - @IsString() - @ApiProperty() - valueColumnName: string; - @IsOptional() - @IsInt() - @ApiProperty({ description: "Related Question Model" }) - questionId: number; - @IsString() - @IsOptional() - @ApiProperty({ description: "Related Question Model" }) - questionUserKey: string; - @IsOptional() - @IsJSON() - @ApiProperty({ description: "This allows you to set the dataset options e.g border-color, background-color, etc. This is a JSON object that can be used to customize the dataset appearance or behavior in the UI." }) - options: any; -} \ No newline at end of file diff --git a/src/dtos/create-dashboard-question.dto.ts b/src/dtos/create-dashboard-question.dto.ts deleted file mode 100644 index 7efbf5f4..00000000 --- a/src/dtos/create-dashboard-question.dto.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsString } from 'class-validator'; -import { IsNotEmpty, IsOptional, IsJSON, IsInt, ValidateNested, IsArray } from 'class-validator'; -import { Type } from 'class-transformer'; -import { UpdateDashboardQuestionSqlDatasetConfigDto } from 'src/dtos/update-dashboard-question-sql-dataset-config.dto'; - -export class CreateDashboardQuestionDto { - @IsNotEmpty() - @IsString() - @ApiProperty() - name: string; - @IsNotEmpty() - @IsString() - @ApiProperty() - sourceType: string; - @IsNotEmpty() - @IsString() - @ApiProperty() - visualisedAs: string; - @IsOptional() - @IsString() - @ApiProperty({ description: "This is only applicable when sourceType is set to provider. It allows the user to select any pre-existing Dashboard Question Data provider implementation used to fetch a dynamic dropdown of values to choose from when this question is presented to the user." }) - providerName: string; - @IsOptional() - @IsInt() - @ApiProperty({ description: "Related Dashboard Model" }) - dashboardId: number; - @IsString() - @IsOptional() - @ApiProperty({ description: "Related Dashboard Model" }) - dashboardUserKey: string; - @IsOptional() - @ApiProperty({ description: "Related Question SQL Dataset Config Model" }) - @IsArray() - @ValidateNested({ each: true }) - @Type(() => UpdateDashboardQuestionSqlDatasetConfigDto) - questionSqlDatasetConfigs: UpdateDashboardQuestionSqlDatasetConfigDto[]; - @IsOptional() - @IsArray() - @ApiProperty({ description: "Related Question SQL Dataset Config Model" }) - questionSqlDatasetConfigsIds: number[]; - @IsString() - @IsOptional() - @ApiProperty({ description: "Related Question SQL Dataset Config Model" }) - questionSqlDatasetConfigsCommand: string; - @IsOptional() - @IsJSON() - @ApiProperty({ description: "This is a JSON object representing each labels display and color options for the bar chart" }) - chartOptions: any; - @IsOptional() - @IsString() - @ApiProperty({ description: "This is the SQL query to fetch the label values for the question" }) - labelSql: string; - @IsOptional() - @IsString() - @ApiProperty({ description: "This is the SQL query to fetch the KPI value for the question" }) - kpiSql: string; - @IsOptional() - @IsInt() - @ApiProperty() - sequenceNumber: number; -} \ No newline at end of file diff --git a/src/dtos/create-dashboard-user-layout.dto.ts b/src/dtos/create-dashboard-user-layout.dto.ts new file mode 100644 index 00000000..d9fd84ab --- /dev/null +++ b/src/dtos/create-dashboard-user-layout.dto.ts @@ -0,0 +1,41 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsInt } from 'class-validator'; +import { IsOptional } from 'class-validator'; +import { IsString, IsNotEmpty } from 'class-validator'; + +export class CreateDashboardUserLayoutDto { + @IsOptional() + @IsInt() + @ApiProperty() + userId: number; + + @IsString() + @IsOptional() + @ApiProperty() + userUserKey: string; + + @IsOptional() + @IsInt() + @ApiProperty() + moduleId: number; + + @IsString() + @IsOptional() + @ApiProperty() + moduleUserKey: string; + + @IsNotEmpty() + @IsString() + @ApiProperty() + dashboardName: string; + + @IsNotEmpty() + @IsString() + @ApiProperty() + layoutJson: string; + + @IsOptional() + @IsInt() + @ApiProperty() + version: number; +} \ No newline at end of file diff --git a/src/dtos/create-dashboard-variable.dto.ts b/src/dtos/create-dashboard-variable.dto.ts deleted file mode 100644 index c8155d64..00000000 --- a/src/dtos/create-dashboard-variable.dto.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsString } from 'class-validator'; -import { IsNotEmpty, IsJSON, IsOptional, IsBoolean, IsInt } from 'class-validator'; - -export enum SelectionDynamicSourceType { - SQL = "sql", - PROVIDER = "provider", -} - - -export class CreateDashboardVariableDto { - @IsNotEmpty() - @IsString() - @ApiProperty() - variableName: string; - @IsNotEmpty() - @IsString() - @ApiProperty() - variableType: string; - @IsOptional() - @IsJSON() - @ApiProperty() - selectionStaticValues: any; - @IsOptional() - @IsString() - @ApiProperty() - selectionDynamicSourceType: string; - @IsOptional() - @IsString() - @ApiProperty({ description: "SQL query to fetch the data for this variable when it is rendered at runtime. This is only applicable when selectionDynamicSourceType is set to SQL." }) - selectionDynamicSQL: string; - @IsOptional() - @IsString() - @ApiProperty({ description: "This is only applicable when selectionDynamicSourceType is set to provider. It allows the user to select any pre-existing Dashboard SelectionDynamicProvider implementation used to fetch a dynamic dropdown of values to choose from when this variable is presented to the user." }) - selectionDynamicProviderName: string; - @IsOptional() - @IsBoolean() - @ApiProperty({ description: "This is relevant only for variables of type \"selectionStatic\" or \"selectionDynamic\". When set to true, it allows the user to select multiple values from the dropdown." }) - isMultiSelect: boolean = true; - @IsOptional() - @IsInt() - @ApiProperty({ description: "Related Dashboard Model" }) - dashboardId: number; - @IsString() - @IsOptional() - @ApiProperty({ description: "Related Dashboard Model" }) - dashboardUserKey: string; - @IsOptional() - @IsString() - @ApiProperty({ description: "This is the default value for this variable when it is rendered at runtime. It can be a static value for this variable when it is rendered at runtime." }) - defaultValue: string; - @IsOptional() - @IsString() - @ApiProperty({ description: "This is the default operator for this variable when it is rendered at runtime. It can be a static value for this variable when it is rendered at runtime." }) - defaultOperator: string; - @IsString() - @IsOptional() - @ApiProperty() - externalId: string; -} \ No newline at end of file diff --git a/src/dtos/create-dashboard.dto.ts b/src/dtos/create-dashboard.dto.ts deleted file mode 100644 index 11abd1df..00000000 --- a/src/dtos/create-dashboard.dto.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsString } from 'class-validator'; -import { IsNotEmpty, ValidateNested, IsArray, IsOptional, IsJSON, IsInt } from 'class-validator'; -import { Type } from 'class-transformer'; -import { UpdateDashboardVariableDto } from 'src/dtos/update-dashboard-variable.dto'; -import { UpdateDashboardQuestionDto } from 'src/dtos/update-dashboard-question.dto'; - -export class CreateDashboardDto { - @IsNotEmpty() - @IsString() - @ApiProperty() - name: string; - @IsNotEmpty() - @IsJSON() - @ApiProperty() - layoutJson: any; - @IsOptional() - @ApiProperty() - @IsArray() - @ValidateNested({ each: true }) - @Type(() => UpdateDashboardVariableDto) - dashboardVariables: UpdateDashboardVariableDto[]; - @IsOptional() - @IsArray() - @ApiProperty() - dashboardVariablesIds: number[]; - @IsString() - @IsOptional() - @ApiProperty() - dashboardVariablesCommand: string; - @IsOptional() - @ApiProperty() - @IsArray() - @ValidateNested({ each: true }) - @Type(() => UpdateDashboardQuestionDto) - questions: UpdateDashboardQuestionDto[]; - @IsOptional() - @IsArray() - @ApiProperty() - questionsIds: number[]; - @IsString() - @IsOptional() - @ApiProperty() - questionsCommand: string; - @IsOptional() - @IsInt() - @ApiProperty() - moduleId: number; - @IsString() - @IsOptional() - @ApiProperty() - moduleUserKey: string; - @IsOptional() - @IsString() - @ApiProperty() - displayName: string; - @IsOptional() - @IsString() - @ApiProperty({ description: "This is a description of the dashboard configuration, providing context and details about the dashboard." }) - description: string; -} \ No newline at end of file diff --git a/src/dtos/create-mcp-audit-log.dto.ts b/src/dtos/create-mcp-audit-log.dto.ts new file mode 100644 index 00000000..b48da21b --- /dev/null +++ b/src/dtos/create-mcp-audit-log.dto.ts @@ -0,0 +1,84 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsDate, IsInt, IsNotEmpty, IsNumber, IsOptional, IsString } from 'class-validator'; + +export class CreateMcpAuditLogDto { + @IsOptional() + @IsInt() + @ApiProperty() + userId: number; + + @IsOptional() + @IsInt() + @ApiProperty() + apiKeyId: number; + + @IsOptional() + @IsString() + @ApiProperty() + username: string; + + @IsNotEmpty() + @IsString() + @ApiProperty() + transport: string; + + @IsOptional() + @IsString() + @ApiProperty() + mcpSessionId: string; + + @IsOptional() + @IsString() + @ApiProperty() + clientAddr: string; + + @IsNotEmpty() + @IsString() + @ApiProperty() + method: string; + + @IsOptional() + @IsString() + @ApiProperty() + requestId: string; + + @IsOptional() + @IsString() + @ApiProperty() + toolName: string; + + @IsOptional() + @IsString() + @ApiProperty() + requestParams: string; + + @IsNotEmpty() + @IsString() + @ApiProperty() + status: string; + + @IsOptional() + @IsString() + @ApiProperty() + responseResult: string; + + @IsOptional() + @IsInt() + @ApiProperty() + errorCode: number; + + @IsOptional() + @IsString() + @ApiProperty() + errorMessage: string; + + @IsOptional() + @IsNumber() + @ApiProperty() + durationMs: number; + + @IsOptional() + @IsDate() + @ApiProperty() + createdAt: Date; +} diff --git a/src/dtos/create-model-metadata.dto.ts b/src/dtos/create-model-metadata.dto.ts index 4acc9602..04487186 100755 --- a/src/dtos/create-model-metadata.dto.ts +++ b/src/dtos/create-model-metadata.dto.ts @@ -1,6 +1,7 @@ import { ApiProperty } from "@nestjs/swagger"; import { Type } from "class-transformer"; -import { IsArray, IsBoolean, IsInt, IsOptional, IsString, Matches, ValidateNested } from "class-validator"; +import { IsArray, IsBoolean, IsEnum, IsInt, IsOptional, IsString, Matches, ValidateNested } from "class-validator"; +import { LegacyTableType } from "src/enums/legacy-table-type.enum"; import { CreateFieldMetadataDto } from "./create-field-metadata.dto"; import { IsNotInEnum } from "src/decorators/is-not-in-enum.decorator"; import { RESERVED_SOLID_KEYWORDS } from "src/helpers/solid-registry"; @@ -113,13 +114,8 @@ export class CreateModelMetadataDto { @IsOptional() parentModelUserKey: string; - @ApiProperty({ description: 'Is legacy table' }) - @IsBoolean() - @IsOptional() - isLegacyTable?: boolean; - - @ApiProperty({ description: 'Is legacy table with id' }) - @IsBoolean() + @ApiProperty({ enum: LegacyTableType, description: 'Legacy table ID strategy' }) + @IsEnum(LegacyTableType) @IsOptional() - isLegacyTableWithId?: boolean; + legacyTableType?: LegacyTableType; } diff --git a/src/dtos/create-permission-metadata.dto.ts b/src/dtos/create-permission-metadata.dto.ts index 60aa11d3..370d8ee2 100755 --- a/src/dtos/create-permission-metadata.dto.ts +++ b/src/dtos/create-permission-metadata.dto.ts @@ -2,9 +2,12 @@ import { IsString } from 'class-validator'; import { IsNotEmpty, ValidateNested, IsArray, IsOptional } from 'class-validator'; import { Type } from 'class-transformer'; import { UpdateRoleMetadataDto } from 'src/dtos/update-role-metadata.dto'; +import { ApiProperty } from '@nestjs/swagger'; + export class CreatePermissionMetadataDto { @IsNotEmpty() @IsString() + @ApiProperty() name: string; @IsArray() diff --git a/src/dtos/create-role-metadata.dto.ts b/src/dtos/create-role-metadata.dto.ts index c966e1ba..e14e9156 100755 --- a/src/dtos/create-role-metadata.dto.ts +++ b/src/dtos/create-role-metadata.dto.ts @@ -1,5 +1,5 @@ import { IsString } from 'class-validator'; -import { IsNotEmpty, ValidateNested, IsArray, IsOptional } from 'class-validator'; +import { IsNotEmpty, ValidateNested, IsArray, IsOptional, IsInt } from 'class-validator'; import { Type } from 'class-transformer'; import { UpdatePermissionMetadataDto } from 'src/dtos/update-permission-metadata.dto'; import { UpdateUserDto } from 'src/dtos/update-user.dto'; @@ -66,4 +66,14 @@ export class CreateRoleMetadataDto { @IsOptional() @ApiProperty() menuItemsCommand: string; + + @IsOptional() + @IsInt() + @ApiProperty() + moduleId: number; + + @IsString() + @IsOptional() + @ApiProperty() + moduleUserKey: string; } diff --git a/src/dtos/dashboard-variable-options-query.dto.ts b/src/dtos/dashboard-variable-options-query.dto.ts new file mode 100644 index 00000000..b56e0308 --- /dev/null +++ b/src/dtos/dashboard-variable-options-query.dto.ts @@ -0,0 +1,20 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsOptional, IsString } from "class-validator"; +import { PaginationQueryDto } from "src/dtos/pagination-query.dto"; + +export class DashboardVariableOptionsQueryDto extends PaginationQueryDto { + @ApiProperty({ description: "Search query string", type: String, required: false }) + @IsString() + @IsOptional() + query?: string; + + @ApiProperty({ description: "Single option value", type: String, required: false }) + @IsString() + @IsOptional() + optionValue?: string; + + @ApiProperty({ description: "Form values object serialized as JSON or querystring", required: false }) + @IsOptional() + formValues?: Record | string; +} + diff --git a/src/dtos/dashboard-variable-selection-dynamic-query.dto.ts b/src/dtos/dashboard-variable-selection-dynamic-query.dto.ts deleted file mode 100644 index c28a509c..00000000 --- a/src/dtos/dashboard-variable-selection-dynamic-query.dto.ts +++ /dev/null @@ -1,29 +0,0 @@ - -import { ApiProperty } from "@nestjs/swagger"; -import { Transform, Type } from "class-transformer"; -import { IsNumber, IsOptional, IsString } from "class-validator"; -import { PaginationQueryDto } from "src/dtos/pagination-query.dto"; -import integerTransformer from "../transformers/integer-transformer"; - -export class DashboardVariableSelectionDynamicQueryDto extends PaginationQueryDto { - constructor(variableId: number, query: string, limit: number, offset: number) { - super(limit, offset); - this.variableId = variableId; - this.query = query; - } - - @ApiProperty({ description: "Field ID of the field against which the dynamic value provider is registered.", type: Number }) - @IsNumber() - @Transform(integerTransformer) - variableId: number; - - @ApiProperty({ description: "Search query string", type: String }) - @IsString() - @IsOptional() - query?: any; - - @ApiProperty({ description: "Value of a single dynamic option", type: String }) - @IsString() - @IsOptional() - optionValue?: string = ''; -} diff --git a/src/dtos/dashboard-widget-data-request.dto.ts b/src/dtos/dashboard-widget-data-request.dto.ts new file mode 100644 index 00000000..a0596e32 --- /dev/null +++ b/src/dtos/dashboard-widget-data-request.dto.ts @@ -0,0 +1,34 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsArray, IsObject, IsOptional, IsString } from "class-validator"; + +export class DashboardWidgetDataRequestDto { + @ApiProperty({ description: "Runtime variable values", type: Object, required: false }) + @IsOptional() + @IsObject() + variables?: Record; + + @ApiProperty({ description: "Optional provider context overrides", type: Object, required: false }) + @IsOptional() + @IsObject() + providerContext?: Record; +} + +export class DashboardBatchDataRequestDto extends DashboardWidgetDataRequestDto { + @ApiProperty({ description: "Subset of widget ids/names to evaluate", type: [String], required: false }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + widgetNames?: string[]; +} + +export class DashboardSaveLayoutDto { + @ApiProperty({ description: "Grid/layout payload from UI", type: Object }) + layoutJson: Record | any[]; + + @ApiProperty({ + description: "Backward-compatible alias for layoutJson.", + type: Object, + required: false, + }) + layout?: Record | any[]; +} diff --git a/src/dtos/import-instructions.dto.ts b/src/dtos/import-instructions.dto.ts index 7da28351..13a37d63 100644 --- a/src/dtos/import-instructions.dto.ts +++ b/src/dtos/import-instructions.dto.ts @@ -20,11 +20,21 @@ export class StandardImportInstructionsResponseDto { description: 'List of date fields for import', }) dateFields: string[]; + @ApiProperty({ + type: String, + description: 'Expected format for date fields during import', + }) + dateFieldFormat: string; @ApiProperty({ type: [String], description: 'List of date-time fields for import', }) dateTimeFields: string[]; + @ApiProperty({ + type: String, + description: 'Expected format for date-time fields during import', + }) + dateTimeFieldFormat: string; @ApiProperty({ type: [String], description: 'List of number fields for import', @@ -63,4 +73,4 @@ export class RegexFieldInstructionResponseDto { description: 'Regex pattern for the field', }) regexPattern: string; -} \ No newline at end of file +} diff --git a/src/dtos/invoke-ai-prompt.dto.ts b/src/dtos/invoke-ai-prompt.dto.ts deleted file mode 100644 index f106de73..00000000 --- a/src/dtos/invoke-ai-prompt.dto.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { IsOptional, IsString } from 'class-validator'; - -export class InvokeAiPromptDto { - @IsString() - prompt: string; - - @IsOptional() - @IsString() - moduleName: string; -} \ No newline at end of file diff --git a/src/dtos/metadata-explorer-references-query.dto.ts b/src/dtos/metadata-explorer-references-query.dto.ts new file mode 100644 index 00000000..2bd7554b --- /dev/null +++ b/src/dtos/metadata-explorer-references-query.dto.ts @@ -0,0 +1,39 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsBoolean, IsOptional, IsString } from "class-validator"; +import { PaginationQueryDto } from "./pagination-query.dto"; + +export class MetadataExplorerReferencesQueryDto extends PaginationQueryDto { + @ApiProperty({ description: "Value or key to locate references for", required: false, type: String }) + @IsString() + @IsOptional() + needle?: string; + + @ApiProperty({ description: "Optional section key to scope the reference search", required: false, type: String }) + @IsString() + @IsOptional() + sectionKey?: string; + + @ApiProperty({ description: "Optional JSON path to exclude from results", required: false, type: String }) + @IsString() + @IsOptional() + excludePath?: string; + + @ApiProperty({ description: "Whether to match the full key/value exactly", required: false, type: Boolean, default: true }) + @Type(() => Boolean) + @IsBoolean() + @IsOptional() + exact?: boolean = true; + + @ApiProperty({ description: "Whether object keys should be considered when finding references", required: false, type: Boolean, default: true }) + @Type(() => Boolean) + @IsBoolean() + @IsOptional() + matchKeys?: boolean = true; + + @ApiProperty({ description: "Whether primitive values should be considered when finding references", required: false, type: Boolean, default: true }) + @Type(() => Boolean) + @IsBoolean() + @IsOptional() + matchValues?: boolean = true; +} diff --git a/src/dtos/metadata-explorer-search-query.dto.ts b/src/dtos/metadata-explorer-search-query.dto.ts new file mode 100644 index 00000000..c4a5cd7d --- /dev/null +++ b/src/dtos/metadata-explorer-search-query.dto.ts @@ -0,0 +1,28 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsBoolean, IsOptional, IsString, Min } from "class-validator"; +import { PaginationQueryDto } from "./pagination-query.dto"; + +export class MetadataExplorerSearchQueryDto extends PaginationQueryDto { + @ApiProperty({ description: "Search query string", required: false, type: String }) + @IsString() + @IsOptional() + query?: string; + + @ApiProperty({ description: "Optional section key to scope the search", required: false, type: String }) + @IsString() + @IsOptional() + sectionKey?: string; + + @ApiProperty({ description: "Whether to match the full value exactly", required: false, type: Boolean, default: false }) + @Type(() => Boolean) + @IsBoolean() + @IsOptional() + exact?: boolean = false; + + @ApiProperty({ description: "Maximum preview length for match snippets", required: false, type: Number, default: 120 }) + @Type(() => Number) + @Min(20) + @IsOptional() + previewLength?: number = 120; +} diff --git a/src/dtos/metadata-explorer-write.dto.ts b/src/dtos/metadata-explorer-write.dto.ts new file mode 100644 index 00000000..89025ba5 --- /dev/null +++ b/src/dtos/metadata-explorer-write.dto.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsOptional } from "class-validator"; + +export class MetadataExplorerWriteDto { + @ApiProperty({ + description: "Arbitrary JSON value to persist for the full metadata document or a section", + required: false, + }) + @IsOptional() + value?: any; +} diff --git a/src/dtos/run-module-package-build.dto.ts b/src/dtos/run-module-package-build.dto.ts new file mode 100644 index 00000000..0006cade --- /dev/null +++ b/src/dtos/run-module-package-build.dto.ts @@ -0,0 +1,14 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsBoolean, IsOptional } from 'class-validator'; + +export class RunModulePackageBuildDto { + @ApiPropertyOptional({ default: true }) + @IsOptional() + @IsBoolean() + buildSolidApi?: boolean = true; + + @ApiPropertyOptional({ default: true }) + @IsOptional() + @IsBoolean() + buildSolidUi?: boolean = true; +} diff --git a/src/dtos/run-module-package-seed.dto.ts b/src/dtos/run-module-package-seed.dto.ts new file mode 100644 index 00000000..8d645811 --- /dev/null +++ b/src/dtos/run-module-package-seed.dto.ts @@ -0,0 +1,12 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsBoolean, IsOptional } from 'class-validator'; + +export class RunModulePackageSeedDto { + @ApiPropertyOptional({ + description: 'Whether to seed global metadata as part of the module seed run.', + default: false, + }) + @IsOptional() + @IsBoolean() + seedGlobalMetadata?: boolean = false; +} diff --git a/src/dtos/update-ai-interaction.dto.ts b/src/dtos/update-ai-interaction.dto.ts deleted file mode 100644 index 1124742e..00000000 --- a/src/dtos/update-ai-interaction.dto.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { IsInt,IsOptional, IsString, IsNotEmpty, IsJSON, IsBoolean } from 'class-validator'; -import { ApiProperty } from '@nestjs/swagger'; - -export class UpdateAiInteractionDto { - @IsOptional() - @IsInt() - id: number; - @IsOptional() - @IsInt() - @ApiProperty() - userId: number; - @IsString() - @IsOptional() - @ApiProperty() - userUserKey: string; - @IsNotEmpty() - @IsOptional() - @IsString() - @ApiProperty() - threadId: string; - @IsNotEmpty() - @IsOptional() - @IsString() - @ApiProperty() - role: string; - @IsNotEmpty() - @IsOptional() - @IsString() - @ApiProperty() - message: string; - @IsOptional() - @IsString() - @ApiProperty() - contentType: string; - @IsOptional() - @IsString() - @ApiProperty() - status: string; - @IsOptional() - @IsString() - @ApiProperty() - errorMessage: string; - @IsOptional() - @IsString() - @ApiProperty() - modelUsed: string; - @IsOptional() - @IsInt() - @ApiProperty() - responseTimeMs: number; - @IsOptional() - @IsJSON() - @ApiProperty() - metadata: any; - @IsOptional() - @IsBoolean() - @ApiProperty() - isApplied: boolean; - @IsOptional() - @IsInt() - @ApiProperty() - parentInteractionId: number; - @IsString() - @IsOptional() - @ApiProperty() - parentInteractionUserKey: string; - @IsOptional() - @IsBoolean() - @ApiProperty() - isAutoApply: boolean; - @IsOptional() - @IsInt() - @ApiProperty() - inputTokens: number; - @IsOptional() - @IsInt() - @ApiProperty() - outputTokens: number; - @IsOptional() - @IsInt() - @ApiProperty() - totalTokens: number; - -@IsOptional() -@IsString() -@ApiProperty() -originalMessage: string; - -@IsOptional() -@IsBoolean() -@ApiProperty() -isEdited: boolean; -} \ No newline at end of file diff --git a/src/dtos/update-chatter-note-message.dto.ts b/src/dtos/update-chatter-note-message.dto.ts new file mode 100644 index 00000000..0086adeb --- /dev/null +++ b/src/dtos/update-chatter-note-message.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; + +export class UpdateChatterNoteMessageDto { + @IsString() + @IsOptional() + @ApiProperty() + messageBody: string; + + @IsString() + @IsOptional() + @ApiProperty({ required: false, description: 'Comma-separated media IDs to remove from this note.' }) + removeAttachmentIds?: string; +} diff --git a/src/dtos/update-dashboard-layout.dto.ts b/src/dtos/update-dashboard-layout.dto.ts deleted file mode 100644 index cdaa8c3f..00000000 --- a/src/dtos/update-dashboard-layout.dto.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { IsInt, IsOptional, IsString, IsNotEmpty } from 'class-validator'; -import { ApiProperty } from '@nestjs/swagger'; - -export class UpdateDashboardLayoutDto { - @IsNotEmpty() - @IsOptional() - @IsString() - @ApiProperty() - layout: string; - - @IsOptional() - @IsInt() - @ApiProperty({ description: "Related Dashboard Model" }) - dashboardId: number; - - @IsString() - @IsOptional() - @ApiProperty({ description: "Related Dashboard Model" }) - dashboardUserKey: string; - - @IsOptional() - @IsInt() - @ApiProperty() - userId: number; - - @IsString() - @IsOptional() - @ApiProperty() - userUserKey: string; -} \ No newline at end of file diff --git a/src/dtos/update-dashboard-question-sql-dataset-config.dto.ts b/src/dtos/update-dashboard-question-sql-dataset-config.dto.ts deleted file mode 100644 index bd271273..00000000 --- a/src/dtos/update-dashboard-question-sql-dataset-config.dto.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { IsInt, IsOptional, IsString, IsNotEmpty, IsJSON } from 'class-validator'; -import { ApiProperty } from '@nestjs/swagger'; - -export class UpdateDashboardQuestionSqlDatasetConfigDto { - @IsOptional() - @IsInt() - id: number; - @IsNotEmpty() - @IsOptional() - @IsString() - @ApiProperty() - datasetName: string; - @IsNotEmpty() - @IsOptional() - @IsString() - @ApiProperty({ description: "This is the display name for the dataset configuration, which can be used in UI components to represent the dataset in a user-friendly manner." }) - datasetDisplayName: string; - @IsOptional() - @IsString() - @ApiProperty({ description: "This is a description of the dataset configuration, providing context and details about its purpose." }) - description: string; - @IsNotEmpty() - @IsOptional() - @IsString() - @ApiProperty() - sql: string; - @IsNotEmpty() - @IsOptional() - @IsString() - @ApiProperty() - labelColumnName: string; - @IsNotEmpty() - @IsOptional() - @IsString() - @ApiProperty() - valueColumnName: string; - @IsOptional() - @IsInt() - @ApiProperty({ description: "Related Question Model" }) - questionId: number; - @IsString() - @IsOptional() - @ApiProperty({ description: "Related Question Model" }) - questionUserKey: string; - @IsOptional() - @IsJSON() - @ApiProperty({ description: "This allows you to set the dataset options e.g border-color, background-color, etc. This is a JSON object that can be used to customize the dataset appearance or behavior in the UI." }) - options: any; -} \ No newline at end of file diff --git a/src/dtos/update-dashboard-question.dto.ts b/src/dtos/update-dashboard-question.dto.ts deleted file mode 100644 index 49dd70e0..00000000 --- a/src/dtos/update-dashboard-question.dto.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { IsInt,IsOptional, IsString, IsNotEmpty, IsJSON, ValidateNested, IsArray } from 'class-validator'; -import { ApiProperty } from '@nestjs/swagger'; -import { Type } from 'class-transformer'; -import { UpdateDashboardQuestionSqlDatasetConfigDto } from 'src/dtos/update-dashboard-question-sql-dataset-config.dto'; - -export class UpdateDashboardQuestionDto { - @IsOptional() - @IsInt() - id: number; - @IsNotEmpty() - @IsOptional() - @IsString() - @ApiProperty() - name: string; - @IsNotEmpty() - @IsOptional() - @IsString() - @ApiProperty() - sourceType: string; - @IsNotEmpty() - @IsOptional() - @IsString() - @ApiProperty() - visualisedAs: string; - @IsOptional() - @IsString() - @ApiProperty({ description: "This is only applicable when sourceType is set to provider. It allows the user to select any pre-existing Dashboard Question Data provider implementation used to fetch a dynamic dropdown of values to choose from when this question is presented to the user." }) - providerName: string; - @IsOptional() - @IsInt() - @ApiProperty({ description: "Related Dashboard Model" }) - dashboardId: number; - @IsString() - @IsOptional() - @ApiProperty({ description: "Related Dashboard Model" }) - dashboardUserKey: string; - @IsOptional() - @ApiProperty({ description: "Related Question SQL Dataset Config Model" }) - @IsArray() - @ValidateNested({ each: true }) - @Type(() => UpdateDashboardQuestionSqlDatasetConfigDto) - questionSqlDatasetConfigs: UpdateDashboardQuestionSqlDatasetConfigDto[]; - @IsOptional() - @IsArray() - @ApiProperty({ description: "Related Question SQL Dataset Config Model" }) - questionSqlDatasetConfigsIds: number[]; - @IsString() - @IsOptional() - @ApiProperty({ description: "Related Question SQL Dataset Config Model" }) - questionSqlDatasetConfigsCommand: string; - @IsOptional() - @IsJSON() - @ApiProperty({ description: "This is a JSON object representing each labels display and color options for the bar chart" }) - chartOptions: any; - @IsOptional() - @IsString() - @ApiProperty({ description: "This is the SQL query to fetch the label values for the question" }) - labelSql: string; - @IsOptional() - @IsString() - @ApiProperty({ description: "This is the SQL query to fetch the KPI value for the question" }) - kpiSql: string; - @IsOptional() - @IsInt() - @ApiProperty() - sequenceNumber: number; -} \ No newline at end of file diff --git a/src/dtos/update-dashboard-user-layout.dto.ts b/src/dtos/update-dashboard-user-layout.dto.ts new file mode 100644 index 00000000..ed929129 --- /dev/null +++ b/src/dtos/update-dashboard-user-layout.dto.ts @@ -0,0 +1,45 @@ +import { IsInt,IsOptional, IsString, IsNotEmpty } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class UpdateDashboardUserLayoutDto { + @IsOptional() + @IsInt() + id: number; + + @IsOptional() + @IsInt() + @ApiProperty() + userId: number; + + @IsString() + @IsOptional() + @ApiProperty() + userUserKey: string; + + @IsOptional() + @IsInt() + @ApiProperty() + moduleId: number; + + @IsString() + @IsOptional() + @ApiProperty() + moduleUserKey: string; + + @IsNotEmpty() + @IsOptional() + @IsString() + @ApiProperty() + dashboardName: string; + + @IsNotEmpty() + @IsOptional() + @IsString() + @ApiProperty() + layoutJson: string; + + @IsOptional() + @IsInt() + @ApiProperty() + version: number; +} \ No newline at end of file diff --git a/src/dtos/update-dashboard-variable.dto.ts b/src/dtos/update-dashboard-variable.dto.ts deleted file mode 100644 index 5d65f811..00000000 --- a/src/dtos/update-dashboard-variable.dto.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { IsInt, IsOptional, IsString, IsNotEmpty, IsJSON, IsBoolean } from 'class-validator'; -import { ApiProperty } from '@nestjs/swagger'; - -export class UpdateDashboardVariableDto { - @IsOptional() - @IsInt() - id: number; - @IsNotEmpty() - @IsOptional() - @IsString() - @ApiProperty() - variableName: string; - @IsNotEmpty() - @IsOptional() - @IsString() - @ApiProperty() - variableType: string; - @IsOptional() - @IsJSON() - @ApiProperty() - selectionStaticValues: any; - @IsOptional() - @IsString() - @ApiProperty() - selectionDynamicSourceType: string; - @IsOptional() - @IsString() - @ApiProperty({ description: "SQL query to fetch the data for this variable when it is rendered at runtime. This is only applicable when selectionDynamicSourceType is set to SQL." }) - selectionDynamicSQL: string; - @IsOptional() - @IsString() - @ApiProperty({ description: "This is only applicable when selectionDynamicSourceType is set to provider. It allows the user to select any pre-existing Dashboard SelectionDynamicProvider implementation used to fetch a dynamic dropdown of values to choose from when this variable is presented to the user." }) - selectionDynamicProviderName: string; - @IsOptional() - @IsBoolean() - @ApiProperty({ description: "This is relevant only for variables of type \"selectionStatic\" or \"selectionDynamic\". When set to true, it allows the user to select multiple values from the dropdown." }) - isMultiSelect: boolean; - @IsOptional() - @IsInt() - @ApiProperty({ description: "Related Dashboard Model" }) - dashboardId: number; - @IsString() - @IsOptional() - @ApiProperty({ description: "Related Dashboard Model" }) - dashboardUserKey: string; - @IsOptional() - @IsString() - @ApiProperty({ description: "This is the default value for this variable when it is rendered at runtime. It can be a static value for this variable when it is rendered at runtime." }) - defaultValue: string; - @IsOptional() - @IsString() - @ApiProperty({ description: "This is the default operator for this variable when it is rendered at runtime. It can be a static value for this variable when it is rendered at runtime." }) - defaultOperator: string; - @IsString() - @IsOptional() - @ApiProperty() - externalId: string; -} \ No newline at end of file diff --git a/src/dtos/update-dashboard.dto.ts b/src/dtos/update-dashboard.dto.ts deleted file mode 100644 index 60359fc2..00000000 --- a/src/dtos/update-dashboard.dto.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { IsInt,IsOptional, IsString, IsNotEmpty, ValidateNested, IsArray, IsJSON } from 'class-validator'; -import { ApiProperty } from '@nestjs/swagger'; -import { Type } from 'class-transformer'; -import { UpdateDashboardVariableDto } from 'src/dtos/update-dashboard-variable.dto'; -import { UpdateDashboardQuestionDto } from 'src/dtos/update-dashboard-question.dto'; - -export class UpdateDashboardDto { - @IsOptional() - @IsInt() - id: number; - @IsNotEmpty() - @IsOptional() - @IsString() - @ApiProperty() - name: string; - @IsNotEmpty() - @IsOptional() - @IsJSON() - @ApiProperty() - layoutJson: any; - @IsOptional() - @ApiProperty() - @IsArray() - @ValidateNested({ each: true }) - @Type(() => UpdateDashboardVariableDto) - dashboardVariables: UpdateDashboardVariableDto[]; - @IsOptional() - @IsArray() - @ApiProperty() - dashboardVariablesIds: number[]; - @IsString() - @IsOptional() - @ApiProperty() - dashboardVariablesCommand: string; - @IsOptional() - @ApiProperty() - @IsArray() - @ValidateNested({ each: true }) - @Type(() => UpdateDashboardQuestionDto) - questions: UpdateDashboardQuestionDto[]; - @IsOptional() - @IsArray() - @ApiProperty() - questionsIds: number[]; - @IsString() - @IsOptional() - @ApiProperty() - questionsCommand: string; - @IsOptional() - @IsInt() - @ApiProperty() - moduleId: number; - @IsString() - @IsOptional() - @ApiProperty() - moduleUserKey: string; - @IsOptional() - @IsString() - @ApiProperty() - displayName: string; - @IsOptional() - @IsString() - @ApiProperty({ description: "This is a description of the dashboard configuration, providing context and details about the dashboard." }) - description: string; -} \ No newline at end of file diff --git a/src/dtos/update-mcp-audit-log.dto.ts b/src/dtos/update-mcp-audit-log.dto.ts new file mode 100644 index 00000000..6186e043 --- /dev/null +++ b/src/dtos/update-mcp-audit-log.dto.ts @@ -0,0 +1,83 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsInt, IsNumber, IsOptional, IsString } from 'class-validator'; + +export class UpdateMcpAuditLogDto { + @IsOptional() + @IsInt() + id: number; + + @IsOptional() + @IsInt() + @ApiProperty() + userId: number; + + @IsOptional() + @IsInt() + @ApiProperty() + apiKeyId: number; + + @IsOptional() + @IsString() + @ApiProperty() + username: string; + + @IsOptional() + @IsString() + @ApiProperty() + transport: string; + + @IsOptional() + @IsString() + @ApiProperty() + mcpSessionId: string; + + @IsOptional() + @IsString() + @ApiProperty() + clientAddr: string; + + @IsOptional() + @IsString() + @ApiProperty() + method: string; + + @IsOptional() + @IsString() + @ApiProperty() + requestId: string; + + @IsOptional() + @IsString() + @ApiProperty() + toolName: string; + + @IsOptional() + @IsString() + @ApiProperty() + requestParams: string; + + @IsOptional() + @IsString() + @ApiProperty() + status: string; + + @IsOptional() + @IsString() + @ApiProperty() + responseResult: string; + + @IsOptional() + @IsInt() + @ApiProperty() + errorCode: number; + + @IsOptional() + @IsString() + @ApiProperty() + errorMessage: string; + + @IsOptional() + @IsNumber() + @ApiProperty() + durationMs: number; +} diff --git a/src/dtos/update-permission-metadata.dto.ts b/src/dtos/update-permission-metadata.dto.ts index bb0e9b12..09847ca3 100755 --- a/src/dtos/update-permission-metadata.dto.ts +++ b/src/dtos/update-permission-metadata.dto.ts @@ -1,14 +1,17 @@ import { IsInt, IsOptional, IsString, IsNotEmpty, ValidateNested, IsArray } from 'class-validator'; import { Type } from 'class-transformer'; import { UpdateRoleMetadataDto } from 'src/dtos/update-role-metadata.dto'; +import { ApiProperty } from '@nestjs/swagger'; + export class UpdatePermissionMetadataDto { @IsOptional() @IsInt() id: number; - @IsOptional() @IsNotEmpty() + @IsOptional() @IsString() + @ApiProperty() name: string; @IsArray() diff --git a/src/dtos/update-role-metadata.dto.ts b/src/dtos/update-role-metadata.dto.ts index d61daddc..b643dc08 100755 --- a/src/dtos/update-role-metadata.dto.ts +++ b/src/dtos/update-role-metadata.dto.ts @@ -1,4 +1,4 @@ -import { IsInt,IsOptional, IsString, IsNotEmpty, ValidateNested, IsArray } from 'class-validator'; +import { IsInt, IsOptional, IsString, IsNotEmpty, ValidateNested, IsArray } from 'class-validator'; import { Type } from 'class-transformer'; import { UpdatePermissionMetadataDto } from 'src/dtos/update-permission-metadata.dto'; import { UpdateUserDto } from 'src/dtos/update-user.dto'; @@ -9,51 +9,71 @@ export class UpdateRoleMetadataDto { @IsOptional() @IsInt() id: number; + @IsNotEmpty() @IsOptional() @IsString() @ApiProperty() name: string; + @IsOptional() @ApiProperty() @IsArray() @ValidateNested({ each: true }) @Type(() => UpdatePermissionMetadataDto) permissions: UpdatePermissionMetadataDto[]; + @IsOptional() @IsArray() @ApiProperty() permissionsIds: number[]; + @IsString() @IsOptional() @ApiProperty() permissionsCommand: string; + @IsOptional() @ApiProperty() @IsArray() @ValidateNested({ each: true }) @Type(() => UpdateUserDto) users: UpdateUserDto[]; + @IsOptional() @IsArray() @ApiProperty() usersIds: number[]; + @IsString() @IsOptional() @ApiProperty() usersCommand: string; + @IsOptional() @ApiProperty() @IsArray() @ValidateNested({ each: true }) @Type(() => UpdateMenuItemMetadataDto) menuItems: UpdateMenuItemMetadataDto[]; + @IsOptional() @IsArray() @ApiProperty() menuItemsIds: number[]; + @IsString() @IsOptional() @ApiProperty() menuItemsCommand: string; + + @IsOptional() + @IsInt() + @ApiProperty() + moduleId: number; + + @IsString() + @IsOptional() + @ApiProperty() + moduleUserKey: string; } \ No newline at end of file diff --git a/src/dtos/update-user.dto.ts b/src/dtos/update-user.dto.ts index 04dcc0d7..ed976074 100755 --- a/src/dtos/update-user.dto.ts +++ b/src/dtos/update-user.dto.ts @@ -161,4 +161,8 @@ export class UpdateUserDto { @IsOptional() @ApiProperty() userViewMetadataCommand: string; + @IsOptional() + @IsInt() + @ApiProperty() + failedLoginAttempts: number; } \ No newline at end of file diff --git a/src/entities/action-metadata.entity.ts b/src/entities/action-metadata.entity.ts index 55e94532..279c8169 100755 --- a/src/entities/action-metadata.entity.ts +++ b/src/entities/action-metadata.entity.ts @@ -9,6 +9,7 @@ export class ActionMetadata extends CommonEntity { @Column({ name: "name", type: "varchar", unique: true }) name: string; + @Index() @Column({ name: "display_name", type: "varchar" }) displayName: string; diff --git a/src/entities/agent-event.entity.ts b/src/entities/agent-event.entity.ts index d7f066f8..2e357ddb 100644 --- a/src/entities/agent-event.entity.ts +++ b/src/entities/agent-event.entity.ts @@ -49,6 +49,7 @@ export class AgentEvent extends CommonEntity { @Column({ nullable: true }) outputTokens: number; + @Index() @Column({ nullable: true }) modelUsed: string; } diff --git a/src/entities/agent-session.entity.ts b/src/entities/agent-session.entity.ts index 3bb80ffa..8b1be1a0 100644 --- a/src/entities/agent-session.entity.ts +++ b/src/entities/agent-session.entity.ts @@ -15,6 +15,7 @@ export class AgentSession extends CommonEntity { @Column({ nullable: true, ...getColumnType('longText') }) projectRoot: string; + @Index() @Column({ }) modelName: string; diff --git a/src/entities/ai-interaction.entity.ts b/src/entities/ai-interaction.entity.ts deleted file mode 100644 index 7728ad6d..00000000 --- a/src/entities/ai-interaction.entity.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { CommonEntity } from 'src/entities/common.entity' -import { Entity, JoinColumn, ManyToOne, Index, Column } from 'typeorm'; -import { User } from 'src/entities/user.entity' -import { getColumnType } from 'src/helpers/typeorm-db-helper'; - -@Entity("ss_ai_interactions") -export class AiInteraction extends CommonEntity { - @Index() - @ManyToOne(() => User, { nullable: true }) - @JoinColumn() - user: User; - - @Index() - @Column({ type: "varchar" }) - threadId: string; - - @Column({ type: "varchar" }) - role: string; - - @Column({ ...getColumnType('longText'), nullable: true }) - message: string; - - @Column({ type: "varchar", nullable: true }) - contentType: string; - - @Index() - @Column({ type: "varchar", nullable: true }) - status: string; - - @Column({ nullable: true, ...getColumnType('longText') }) - errorMessage: string; - - @Column({ type: "varchar", nullable: true }) - modelUsed: string; - - @Column({ type: "integer", nullable: true }) - responseTimeMs: number; - - @Column({ type: "simple-json", nullable: true, ...getColumnType('simpleJsonLargeText') }) - metadata: any; - - @Column({ nullable: true, default: false }) - isApplied: boolean = false; - - @Index() - @ManyToOne(() => AiInteraction, { nullable: true }) - @JoinColumn() - parentInteraction: AiInteraction; - - @Index({ unique: true }) - @Column({ type: "varchar" }) - externalId: string; - - @Column({ nullable: true, default: false }) - isAutoApply: boolean = false; - - @Column({ type: "integer", nullable: true }) - inputTokens: number; - - @Column({ type: "integer", nullable: true }) - outputTokens: number; - - @Column({ type: "integer", nullable: true }) - totalTokens: number; - - @Column({ nullable: true, ...getColumnType('longText') }) - originalMessage: string; - - @Column({ nullable: true, default: false }) - isEdited: boolean = false; -} diff --git a/src/entities/chatter-message-details.entity.ts b/src/entities/chatter-message-details.entity.ts index 4e8927be..5104ce36 100644 --- a/src/entities/chatter-message-details.entity.ts +++ b/src/entities/chatter-message-details.entity.ts @@ -18,7 +18,6 @@ export class ChatterMessageDetails extends CommonEntity { @Column({ type: "varchar", nullable: true }) oldValueDisplay: string; - @Index() @Column({ type: "varchar", nullable: true }) newValueDisplay: string; @@ -30,4 +29,4 @@ export class ChatterMessageDetails extends CommonEntity { @Column({ type: "varchar", nullable: true }) fieldType: string; -} \ No newline at end of file +} diff --git a/src/entities/chatter-message.entity.ts b/src/entities/chatter-message.entity.ts index e328173a..cd84be31 100644 --- a/src/entities/chatter-message.entity.ts +++ b/src/entities/chatter-message.entity.ts @@ -11,6 +11,7 @@ export class ChatterMessage extends CommonEntity { @Column({ type: "varchar" }) messageType: string; // audit | custom + @Index() @Column({ type: "varchar" }) messageSubType: string; // audit_update | audit_insert | audit_delete | custom | note | task @@ -33,6 +34,7 @@ export class ChatterMessage extends CommonEntity { @OneToMany(() => ChatterMessageDetails, (chatterMessageDetails) => chatterMessageDetails.chatterMessage, { cascade: true }) chatterMessageDetails: ChatterMessageDetails[]; + @Index() @Column({ nullable: true }) modelDisplayName: string; diff --git a/src/entities/dashboard-layout.entity.ts b/src/entities/dashboard-layout.entity.ts deleted file mode 100644 index c6ba347f..00000000 --- a/src/entities/dashboard-layout.entity.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { CommonEntity } from 'src/entities/common.entity' -import { Entity, Column, JoinColumn, ManyToOne } from 'typeorm'; -import { Dashboard } from 'src/entities/dashboard.entity' -import { User } from './user.entity'; - -@Entity("ss_dashboard_layout") -export class DashboardLayout extends CommonEntity { - @Column({ type: "text", nullable: true }) - layout: string; - - @ManyToOne(() => Dashboard, { nullable: true }) - @JoinColumn() - dashboard: Dashboard; - - @ManyToOne(() => User, { nullable: true }) - @JoinColumn() - user: User; -} \ No newline at end of file diff --git a/src/entities/dashboard-question-sql-dataset-config.entity.ts b/src/entities/dashboard-question-sql-dataset-config.entity.ts deleted file mode 100644 index af0a8ea4..00000000 --- a/src/entities/dashboard-question-sql-dataset-config.entity.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { CommonEntity } from 'src/entities/common.entity' -import { Entity, Column, Index, JoinColumn, ManyToOne } from 'typeorm'; -import { DashboardQuestion } from 'src/entities/dashboard-question.entity' -import { getColumnType } from 'src/helpers/typeorm-db-helper'; - -@Entity("ss_dashboard_question_sql_dataset_config") -export class DashboardQuestionSqlDatasetConfig extends CommonEntity { - @Index() - @Column({ type: "varchar" }) - datasetName: string; - - @Column({ type: "varchar" }) - datasetDisplayName: string; - - @Column({ nullable: true }) - description: string; - - @Column({ ...getColumnType('longText'), nullable: true }) - sql: string; - - @Column({ type: "varchar" }) - labelColumnName: string; - - @Column({ type: "varchar" }) - valueColumnName: string; - - @ManyToOne(() => DashboardQuestion, { nullable: false }) - @JoinColumn() - question: DashboardQuestion; - - @Column({ nullable: true, ...getColumnType('longText') }) - options: any; - - @Index({ unique: true }) - @Column({ type: "varchar", nullable: false }) - externalId: string; -} diff --git a/src/entities/dashboard-question.entity.ts b/src/entities/dashboard-question.entity.ts deleted file mode 100644 index 4618d381..00000000 --- a/src/entities/dashboard-question.entity.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { CommonEntity } from 'src/entities/common.entity' -import { Entity, Column, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; -import { getColumnType } from 'src/helpers/typeorm-db-helper'; -import { Dashboard } from 'src/entities/dashboard.entity'; -import { DashboardQuestionSqlDatasetConfig } from 'src/entities/dashboard-question-sql-dataset-config.entity' - -@Entity("ss_dashboard_question") -export class DashboardQuestion extends CommonEntity { - @Index() - @Column({ type: "varchar" }) - name: string; - - @Index() - @Column({}) - sourceType: string; - - @Index() - @Column({}) - visualisedAs: string; - - @Column({ type: "varchar", nullable: true }) - providerName: string; - - @ManyToOne(() => Dashboard, { nullable: true }) - @JoinColumn() - dashboard: Dashboard; - - @OneToMany(() => DashboardQuestionSqlDatasetConfig, dashboardQuestionSqlDatasetConfig => dashboardQuestionSqlDatasetConfig.question, { cascade: true }) - questionSqlDatasetConfigs: DashboardQuestionSqlDatasetConfig[]; - - @Column({ type: "simple-json", nullable: true, ...getColumnType('simpleJsonLargeText') }) - chartOptions: any; - - @Column({ nullable: true, ...getColumnType('longText') }) - labelSql: string; - - @Column({ nullable: true, ...getColumnType('longText') }) - kpiSql: string; - - @Column({ type: "integer", nullable: true }) - sequenceNumber: number; - - @Index({ unique: true }) - @Column({ type: "varchar", nullable: false }) - externalId: string; -} \ No newline at end of file diff --git a/src/entities/dashboard-user-layout.entity.ts b/src/entities/dashboard-user-layout.entity.ts new file mode 100644 index 00000000..c6ccdcd9 --- /dev/null +++ b/src/entities/dashboard-user-layout.entity.ts @@ -0,0 +1,27 @@ +import { CommonEntity } from 'src/entities/common.entity'; +import { Entity, JoinColumn, ManyToOne, Index, Column } from 'typeorm'; +import { User } from 'src/entities/user.entity'; +import { ModuleMetadata } from 'src/entities/module-metadata.entity' + +@Entity('ss_dashboard_user_layout') +export class DashboardUserLayout extends CommonEntity { + @Index() + @ManyToOne(() => User, { onDelete: "CASCADE", nullable: false }) + @JoinColumn() + user: User; + + @Index() + @ManyToOne(() => ModuleMetadata, { onDelete: "CASCADE", nullable: false }) + @JoinColumn() + module: ModuleMetadata; + + @Index() + @Column({ name: "dashboard_name", type: "varchar" }) + dashboardName: string; + + @Column({ name: "layout_json", type: "text" }) + layoutJson: string; + + @Column({ type: "integer", nullable: true }) + version: number; +} \ No newline at end of file diff --git a/src/entities/dashboard-variable.entity.ts b/src/entities/dashboard-variable.entity.ts deleted file mode 100644 index 33aa05d1..00000000 --- a/src/entities/dashboard-variable.entity.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { CommonEntity } from 'src/entities/common.entity' -import { Entity, Column, Index, JoinColumn, ManyToOne } from 'typeorm'; -import { Dashboard } from 'src/entities/dashboard.entity' -import { getColumnType } from 'src/helpers/typeorm-db-helper'; - -@Entity("ss_dashboard_variable") -export class DashboardVariable extends CommonEntity { - @Index() - @Column({ type: "varchar" }) - variableName: string; - - @Index() - @Column({ type: "varchar" }) - variableType: string; - - @Column({ type: "simple-json", nullable: true }) - selectionStaticValues: any; - - @Column({ nullable: true }) - selectionDynamicSourceType: string; - - @Column({ nullable: true, ...getColumnType('longText') }) - selectionDynamicSQL: string; - - @Column({ type: "varchar", nullable: true }) - selectionDynamicProviderName: string; - - @Column({ nullable: true, default: true }) - isMultiSelect: boolean = true; - - @ManyToOne(() => Dashboard, { nullable: true }) - @JoinColumn() - dashboard: Dashboard; - - @Column({ nullable: true}) - defaultValue: string; - - @Column({ type: "varchar", nullable: true }) - defaultOperator: string; - - @Index({ unique: true }) - @Column({ type: "varchar" }) - externalId: string; -} \ No newline at end of file diff --git a/src/entities/dashboard.entity.ts b/src/entities/dashboard.entity.ts deleted file mode 100644 index 2eb65092..00000000 --- a/src/entities/dashboard.entity.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { CommonEntity } from 'src/entities/common.entity' -import { Entity, Column, Index, OneToMany, JoinColumn, ManyToOne } from 'typeorm'; -import { getColumnType } from 'src/helpers/typeorm-db-helper'; -import { DashboardVariable } from 'src/entities/dashboard-variable.entity'; -import { DashboardQuestion } from 'src/entities/dashboard-question.entity'; -import { ModuleMetadata } from 'src/entities/module-metadata.entity' -import { DashboardLayout } from './dashboard-layout.entity'; - -@Entity("ss_dashboard") -export class Dashboard extends CommonEntity { - @Index({ unique: true }) - @Column({ type: "varchar" }) - name: string; - - @Column({ ...getColumnType('longText'), nullable: true }) - layoutJson: any; - - @OneToMany(() => DashboardVariable, dashboardVariable => dashboardVariable.dashboard, { cascade: true }) - dashboardVariables: DashboardVariable[]; - - @OneToMany(() => DashboardQuestion, dashboardQuestion => dashboardQuestion.dashboard, { cascade: true }) - questions: DashboardQuestion[]; - - @OneToMany(() => DashboardLayout, dashboardLayout => dashboardLayout.dashboard, { cascade: true }) - dashboardLayouts: DashboardLayout[]; - - @ManyToOne(() => ModuleMetadata, { nullable: false }) - @JoinColumn() - module: ModuleMetadata; - - @Column({ type: "varchar", nullable: true }) - displayName: string; - - @Column({ nullable: true }) - description: string; -} \ No newline at end of file diff --git a/src/entities/email-template.entity.ts b/src/entities/email-template.entity.ts index da6b8b97..515b5b5d 100755 --- a/src/entities/email-template.entity.ts +++ b/src/entities/email-template.entity.ts @@ -8,6 +8,7 @@ export class EmailTemplate extends CommonEntity { @Index({ unique: true }) @Column({ name: "name", type: "varchar"}) name: string; + @Index() @Column({ name: "display_name", type: "varchar" }) displayName: string; @Column({ name: "body", default: '', ...getColumnType('longText') }) diff --git a/src/entities/field-metadata.entity.ts b/src/entities/field-metadata.entity.ts index c2f4a00a..aad78cc2 100755 --- a/src/entities/field-metadata.entity.ts +++ b/src/entities/field-metadata.entity.ts @@ -8,10 +8,11 @@ import { ERROR_MESSAGES } from "src/constants/error-messages"; @Entity("ss_field_metadata") export class FieldMetadata extends CommonEntity { - // @Index({ unique: true }) + @Index() @Column({ name: "name" }) name: string; + @Index() @Column({ name: "display_name" }) displayName: string; @@ -19,6 +20,7 @@ export class FieldMetadata extends CommonEntity { description: string; /** int, char etc... */ + @Index() @Column({ name: 'type' }) type: string; @@ -75,6 +77,7 @@ export class FieldMetadata extends CommonEntity { @Column({ name: 'media_max_size_kb', nullable: true }) mediaMaxSizeKb: number; + @Index() @ManyToOne(() => MediaStorageProviderMetadata, { onDelete: 'SET NULL' }) @JoinColumn({ name: 'media_storage_provider_id' }) mediaStorageProvider: MediaStorageProviderMetadata; diff --git a/src/entities/legacy-common-with-id.entity.ts b/src/entities/legacy-common-with-id.entity.ts index 402b9708..d4fe6864 100644 --- a/src/entities/legacy-common-with-id.entity.ts +++ b/src/entities/legacy-common-with-id.entity.ts @@ -1,9 +1,9 @@ import { Exclude, Expose } from "class-transformer"; import { Column, Generated } from "typeorm"; -import { LEGACY_TABLE_FIELDS_PREFIX, LegacyCommonEntity } from "./legacy-common.entity"; +import { LEGACY_TABLE_FIELDS_PREFIX, LegacyCommonEntityWithExistingId } from "./legacy-common.entity"; @Exclude() -export abstract class LegacyCommonWithIdEntity extends LegacyCommonEntity { +export abstract class LegacyCommonEntityWithGeneratedId extends LegacyCommonEntityWithExistingId { @Expose() @Column({ type: 'integer', name: `${LEGACY_TABLE_FIELDS_PREFIX}_id`, unique: true }) @Generated("increment") diff --git a/src/entities/legacy-common.entity.ts b/src/entities/legacy-common.entity.ts index 6ba2f8d8..85acb0c1 100644 --- a/src/entities/legacy-common.entity.ts +++ b/src/entities/legacy-common.entity.ts @@ -5,7 +5,7 @@ import { Column, CreateDateColumn, DeleteDateColumn, Index, UpdateDateColumn } f export const LEGACY_TABLE_FIELDS_PREFIX = 'ss'; @Exclude() -export abstract class LegacyCommonEntity { +export abstract class LegacyCommonEntityWithExistingId { // @Expose() // @Column({ type: 'integer', name: `${LEGACY_TABLE_FIELDS_PREFIX}_id`, unique: true }) // @Generated("increment") diff --git a/src/entities/list-of-values.entity.ts b/src/entities/list-of-values.entity.ts index 45ea5baf..bb15a256 100644 --- a/src/entities/list-of-values.entity.ts +++ b/src/entities/list-of-values.entity.ts @@ -1,16 +1,19 @@ import { CommonEntity } from 'src/entities/common.entity' -import { Entity, Column, ManyToOne, JoinColumn, Unique } from 'typeorm' +import { Entity, Column, Index, ManyToOne, JoinColumn, Unique } from 'typeorm' import { ModuleMetadata } from 'src/entities/module-metadata.entity' @Entity("ss_list_of_values") @Unique(['type', 'value']) export class ListOfValues extends CommonEntity { + @Index() @Column({ type: "varchar" }) type: string; + @Index() @Column({ type: "varchar" }) value: string; + @Index() @Column({ type: "varchar" }) display: string; @@ -23,6 +26,7 @@ export class ListOfValues extends CommonEntity { @Column({ type: "integer", nullable: true }) sequence: number; + @Index() @ManyToOne(() => ModuleMetadata, { nullable: true }) @JoinColumn() module: ModuleMetadata; diff --git a/src/entities/locale.entity.ts b/src/entities/locale.entity.ts index 6fa9fd63..f9fcd08e 100644 --- a/src/entities/locale.entity.ts +++ b/src/entities/locale.entity.ts @@ -6,6 +6,7 @@ export class Locale extends CommonEntity { @Index({ unique: true }) @Column({ type: "varchar" }) locale: string; + @Index() @Column({ type: "varchar" }) displayName: string; @Index() diff --git a/src/entities/mcp-audit-log.entity.ts b/src/entities/mcp-audit-log.entity.ts new file mode 100644 index 00000000..20003eae --- /dev/null +++ b/src/entities/mcp-audit-log.entity.ts @@ -0,0 +1,55 @@ +import { Column, Entity, Index } from 'typeorm'; +import { CommonEntity } from 'src/entities/common.entity'; +import { getColumnType } from 'src/helpers/typeorm-db-helper'; + +@Entity({ name: 'ss_mcp_audit_log' }) +export class McpAuditLog extends CommonEntity { + @Index() + @Column({ nullable: true }) + userId: number; + + @Column({ nullable: true }) + apiKeyId: number; + + @Column({ nullable: true, length: 128 }) + username: string; + + @Column({ length: 32 }) + transport: string; + + @Index() + @Column({ nullable: true, length: 64 }) + mcpSessionId: string; + + @Column({ nullable: true, length: 64 }) + clientAddr: string; + + @Index() + @Column({ length: 64 }) + method: string; + + @Column({ nullable: true, length: 64 }) + requestId: string; + + @Index() + @Column({ nullable: true, length: 128 }) + toolName: string; + + @Column({ nullable: true, ...getColumnType('longText') }) + requestParams: string; + + @Column({ length: 16 }) + status: string; + + @Column({ nullable: true, ...getColumnType('longText') }) + responseResult: string; + + @Column({ nullable: true }) + errorCode: number; + + @Column({ nullable: true, ...getColumnType('longText') }) + errorMessage: string; + + @Column({ nullable: true, ...getColumnType('decimal') }) + durationMs: number; +} diff --git a/src/entities/media.entity.ts b/src/entities/media.entity.ts index f08e522d..a0f73b0a 100644 --- a/src/entities/media.entity.ts +++ b/src/entities/media.entity.ts @@ -12,6 +12,7 @@ export class Media extends CommonEntity { @Column({ type: "varchar", nullable: true }) relativeUri: string; + @Index() @Column({ type: "integer", nullable: true }) fileSize: number; diff --git a/src/entities/menu-item-metadata.entity.ts b/src/entities/menu-item-metadata.entity.ts index 0eb0521a..240ad544 100755 --- a/src/entities/menu-item-metadata.entity.ts +++ b/src/entities/menu-item-metadata.entity.ts @@ -10,6 +10,7 @@ export class MenuItemMetadata extends CommonEntity { @Column({ name: "name", type: "varchar" }) name: string; + @Index() @Column({ name: "display_name", type: "varchar" }) displayName: string; @@ -32,6 +33,7 @@ export class MenuItemMetadata extends CommonEntity { @JoinTable() roles: RoleMetadata[]; + @Index() @Column({ name: "sequence_number", type: "int", nullable: true }) sequenceNumber: number; diff --git a/src/entities/model-metadata.entity.ts b/src/entities/model-metadata.entity.ts index bb3e5309..dc5fb1a2 100755 --- a/src/entities/model-metadata.entity.ts +++ b/src/entities/model-metadata.entity.ts @@ -1,4 +1,5 @@ import { CommonEntity } from "src/entities/common.entity"; +import { LegacyTableType } from "src/enums/legacy-table-type.enum"; import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from "typeorm"; import { FieldMetadata } from "./field-metadata.entity"; import { ModuleMetadata } from "./module-metadata.entity"; @@ -17,9 +18,11 @@ export class ModelMetadata extends CommonEntity { @Column({ name: "plural_name" }) pluralName: string; + @Index() @Column({ name: "display_name" }) displayName: string; + @Index() @Column({ name: "description", nullable: true }) description: string; @@ -54,6 +57,7 @@ export class ModelMetadata extends CommonEntity { // 1. Single field. // 2. Composite field. // 3. Auto generated human readable sequence. + @Index() @ManyToOne(() => FieldMetadata, {}) userKeyField: FieldMetadata; @@ -66,10 +70,7 @@ export class ModelMetadata extends CommonEntity { @ManyToOne(() => ModelMetadata, {}) parentModel: ModelMetadata; - @Column({ default: false }) - isLegacyTable: boolean; - - @Column({ default: false }) - isLegacyTableWithId: boolean; + @Column({ type: 'varchar', default: LegacyTableType.NONE }) + legacyTableType: LegacyTableType; } diff --git a/src/entities/module-metadata.entity.ts b/src/entities/module-metadata.entity.ts index f9f2eac0..647bd7b6 100755 --- a/src/entities/module-metadata.entity.ts +++ b/src/entities/module-metadata.entity.ts @@ -4,6 +4,7 @@ import { ModelMetadata } from "./model-metadata.entity"; @Entity("ss_module_metadata") export class ModuleMetadata extends CommonEntity { + @Index() @Column({ name: "display_name" }) displayName: string; @@ -17,6 +18,7 @@ export class ModuleMetadata extends CommonEntity { @Column({ nullable: true }) menuIconUrl: string; + @Index() @Column({ nullable: true }) menuSequenceNumber: number; diff --git a/src/entities/permission-metadata.entity.ts b/src/entities/permission-metadata.entity.ts index d7e36cf8..6dc58fe8 100755 --- a/src/entities/permission-metadata.entity.ts +++ b/src/entities/permission-metadata.entity.ts @@ -1,10 +1,10 @@ import { CommonEntity } from "src/entities/common.entity" import { Entity, Column, Index, ManyToMany } from "typeorm"; import { RoleMetadata } from 'src/entities/role-metadata.entity' + @Entity("ss_permission_metadata") export class PermissionMetadata extends CommonEntity { - - @Index({unique: true}) + @Index({ unique: true }) @Column({ name: "name", type: "varchar" }) name: string; diff --git a/src/entities/role-metadata.entity.ts b/src/entities/role-metadata.entity.ts index 31e56e61..6a431da4 100755 --- a/src/entities/role-metadata.entity.ts +++ b/src/entities/role-metadata.entity.ts @@ -1,11 +1,14 @@ import { CommonEntity } from "src/entities/common.entity" -import { Entity, Column, JoinTable, ManyToMany } from "typeorm"; +import { Entity, Column, JoinTable, ManyToMany, Index, JoinColumn, ManyToOne } from "typeorm"; import { PermissionMetadata } from 'src/entities/permission-metadata.entity'; import { User } from 'src/entities/user.entity'; -import { MenuItemMetadata } from 'src/entities/menu-item-metadata.entity' +import { MenuItemMetadata } from 'src/entities/menu-item-metadata.entity'; +import { ModuleMetadata } from 'src/entities/module-metadata.entity' + @Entity("ss_role_metadata") export class RoleMetadata extends CommonEntity { - @Column({ name: "name", type: "varchar", unique: true }) + @Index({ unique: true }) + @Column({ name: "name", type: "varchar"}) name: string; @ManyToMany(() => PermissionMetadata, permissionMetadata => permissionMetadata.roles, { cascade: true }) @@ -17,4 +20,9 @@ export class RoleMetadata extends CommonEntity { @ManyToMany(() => MenuItemMetadata, menuItemMetadata => menuItemMetadata.roles, { cascade: ['insert', 'update'] }) menuItems: MenuItemMetadata[]; + + @Index() + @ManyToOne(() => ModuleMetadata, { onDelete: "SET NULL", nullable: true }) + @JoinColumn() + module: ModuleMetadata; } \ No newline at end of file diff --git a/src/entities/saved-filters.entity.ts b/src/entities/saved-filters.entity.ts index e960efb6..7d62ce7a 100644 --- a/src/entities/saved-filters.entity.ts +++ b/src/entities/saved-filters.entity.ts @@ -17,14 +17,17 @@ export class SavedFilters extends CommonEntity { @Column({ nullable: true, default: false }) isPrivate: boolean = false; + @Index() @ManyToOne(() => User, { nullable: true }) @JoinColumn() user: User; + @Index() @ManyToOne(() => ModelMetadata, { nullable: false }) @JoinColumn() model: ModelMetadata; + @Index() @ManyToOne(() => ViewMetadata, { nullable: false }) @JoinColumn() view: ViewMetadata; diff --git a/src/entities/scheduled-job.entity.ts b/src/entities/scheduled-job.entity.ts index 5bbfb0b7..51cc35dd 100644 --- a/src/entities/scheduled-job.entity.ts +++ b/src/entities/scheduled-job.entity.ts @@ -11,6 +11,7 @@ export class ScheduledJob extends CommonEntity { @Column({ default: false }) isActive: boolean = false; + @Index() @Column({ type: "varchar" }) frequency: string; @@ -38,6 +39,7 @@ export class ScheduledJob extends CommonEntity { @Column({ type: "varchar", nullable: true }) dayOfWeek: string; + @Index() @Column({ type: "varchar" }) job: string; diff --git a/src/entities/security-rule.entity.ts b/src/entities/security-rule.entity.ts index a1db924f..708ca714 100644 --- a/src/entities/security-rule.entity.ts +++ b/src/entities/security-rule.entity.ts @@ -10,6 +10,7 @@ export class SecurityRule extends CommonEntity { @Column({ type: "varchar" }) name: string; + @Index() @Column({ type: "varchar" }) description: string; diff --git a/src/entities/sms-template.entity.ts b/src/entities/sms-template.entity.ts index 822acc05..c8781619 100755 --- a/src/entities/sms-template.entity.ts +++ b/src/entities/sms-template.entity.ts @@ -7,10 +7,12 @@ export class SmsTemplate extends CommonEntity { @Index({ unique: true }) @Column({ name: "name", type: "varchar" }) name: string; + @Index() @Column({ name: "display_name", type: "varchar" }) displayName: string; @Column({ name: "body", ...getColumnType('longText'), nullable: true }) body: string; + @Index() @Column({ type: "varchar", nullable: true }) smsProviderTemplateId: string; @Column({ name: "description", nullable: true }) diff --git a/src/entities/user-activity-history.entity.ts b/src/entities/user-activity-history.entity.ts index e88a178b..f03117f1 100644 --- a/src/entities/user-activity-history.entity.ts +++ b/src/entities/user-activity-history.entity.ts @@ -3,6 +3,7 @@ import { Entity, JoinColumn, ManyToOne, Column, Index } from 'typeorm'; import { User } from 'src/entities/user.entity' @Entity("ss_user_activity_history") export class UserActivityHistory extends CommonEntity { + @Index() @ManyToOne(() => User, { nullable: true }) @JoinColumn() user: User; diff --git a/src/entities/user.entity.ts b/src/entities/user.entity.ts index c362f223..e05a2278 100755 --- a/src/entities/user.entity.ts +++ b/src/entities/user.entity.ts @@ -9,6 +9,7 @@ import { Exclude, Expose } from "class-transformer"; @TableInheritance({ column: { type: "varchar", name: "type", default: "User" } }) @Exclude() export class User extends CommonEntity { + @Index() @Column({ type: "varchar", nullable: true }) @Expose() fullName: string; @@ -88,6 +89,19 @@ export class User extends CommonEntity { // don't send to client microsoftProfilePicture: string; + @Column({ type: "varchar", nullable: true }) + // don't send to client + microsoftActiveDirectoryId: string; + + @Column({ type: "varchar", nullable: true }) + // don't send to client + microsoftActiveDirectoryAccessToken: string; + + @Column({ type: "varchar", nullable: true }) + // don't send to client + microsoftActiveDirectoryProfilePicture: string; + + @Index() @Column({ default: true }) @Expose() active: boolean = true; @@ -177,7 +191,7 @@ export class User extends CommonEntity { @Column({ nullable: true }) rehashedAt: Date; - // dont send to client + @Expose() @Column({ type: "int", default: 0 }) failedLoginAttempts: number = 0; @@ -192,4 +206,4 @@ export class User extends CommonEntity { @Expose() apiKeys: UserApiKey[]; -} \ No newline at end of file +} diff --git a/src/entities/view-metadata.entity.ts b/src/entities/view-metadata.entity.ts index e787dc84..94658d2a 100755 --- a/src/entities/view-metadata.entity.ts +++ b/src/entities/view-metadata.entity.ts @@ -11,9 +11,11 @@ export class ViewMetadata extends CommonEntity { @Column({ name: "name", type: "varchar"}) name: string; + @Index() @Column({ name: "display_name", type: "varchar" }) displayName: string; + @Index() @Column({ name: "type", type: "varchar" }) type: string; diff --git a/src/enums/legacy-table-type.enum.ts b/src/enums/legacy-table-type.enum.ts new file mode 100644 index 00000000..112c8d48 --- /dev/null +++ b/src/enums/legacy-table-type.enum.ts @@ -0,0 +1,5 @@ +export enum LegacyTableType { + NONE = 'none', + EXISTING_ID = 'existing_id', + GENERATED_ID = 'generated_id', +} diff --git a/src/factories/whatsapp.factory.ts b/src/factories/whatsapp.factory.ts index fc1b4949..9938c17c 100644 --- a/src/factories/whatsapp.factory.ts +++ b/src/factories/whatsapp.factory.ts @@ -1,42 +1,43 @@ -import { Inject, Injectable, Logger } from "@nestjs/common"; -import { ConfigType } from "@nestjs/config"; +import { Injectable, Logger } from "@nestjs/common"; import { ModuleRef } from "@nestjs/core"; import { SolidRegistry } from "src/helpers/solid-registry"; import { IWhatsAppTransport } from "src/interfaces"; import { SettingService } from "src/services/setting.service"; import type { SolidCoreSetting } from "src/services/settings/default-settings-provider.service"; -function norm(s?: string) { - return s?.trim().toLowerCase(); -} - -// This factory will be use to return a mail service instance, using the configured environment variables @Injectable() export class WhatsAppFactory { - private readonly logger = new Logger(this.constructor.name); - constructor( - private readonly moduleRef: ModuleRef, // Use the module ref to dynamically resolve the mail service - private readonly solidRegistry: SolidRegistry, - private readonly settingService: SettingService, - ) { } - - getWhatsappService(name: string = null): IWhatsAppTransport { - // This is the default provider - const whatsappServiceName = name || this.settingService.getConfigValue("whatsappProvider"); - if (!whatsappServiceName) { - throw new Error("Unable to resolve whatsapp provider") - } - const whatsappProviders = this.solidRegistry.getWhatsappProviders(); - - // Return the instance which matches the whatsappServiceName - if (!whatsappProviders.length) { - // throw new Error("No mail providers are registered."); - this.logger.error("No whatsapp providers are registered."); - } - - const whatsappServiceProvider = whatsappProviders.find(provider => provider.name === whatsappServiceName); - - return whatsappServiceProvider.instance as IWhatsAppTransport; + private readonly logger = new Logger(WhatsAppFactory.name); + + constructor( + private readonly moduleRef: ModuleRef, + private readonly solidRegistry: SolidRegistry, + private readonly settingService: SettingService, + ) {} + + getWhatsappService(name?: string): IWhatsAppTransport { + const providerKey = + name || + this.settingService.getConfigValue("whatsappProvider"); + + if (!providerKey) { + throw new Error("Unable to resolve whatsapp provider"); + } + + const whatsappProviders = this.solidRegistry.getWhatsappProviders(); + + if (!whatsappProviders.length) { + throw new Error("No whatsapp providers are registered."); + } + + const whatsappServiceProvider = whatsappProviders.find((provider) => + provider.name?.toLowerCase().includes(providerKey.toLowerCase()), + ); + + if (!whatsappServiceProvider) { + throw new Error(`WhatsApp provider '${providerKey}' not found`); } -} \ No newline at end of file + return whatsappServiceProvider.instance as IWhatsAppTransport; + } +} diff --git a/src/helpers/bootstrap.helper.ts b/src/helpers/bootstrap.helper.ts index bca20eb2..cd06ba7e 100644 --- a/src/helpers/bootstrap.helper.ts +++ b/src/helpers/bootstrap.helper.ts @@ -1,5 +1,6 @@ import { ValidationPipe } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; +import { WsAdapter } from '@nestjs/platform-ws'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { NextFunction, Request, Response } from 'express'; import helmet from 'helmet'; @@ -8,7 +9,7 @@ import { existsSync } from 'fs'; import { resolve } from 'path'; import { WINSTON_MODULE_NEST_PROVIDER, WinstonModule } from 'nest-winston'; import { CommandFactory } from 'nest-commander'; -import { WinstonLoggerConfig } from '../winston.logger'; +import { createWinstonLoggerConfig } from '../winston.logger'; import { WrapResponseInterceptor } from '../interceptors/wrap-response.interceptor'; import { buildDefaultCorsOptions } from './cors.helper'; import { buildDefaultSecurityHeaderOptions, buildPermissionsPolicyHeader, PermissionsPolicyConfig } from './security.helper'; @@ -49,6 +50,8 @@ export interface SolidBootstrapOptions { swagger?: SolidSwaggerOptions | false; /** Permissions-Policy header overrides (merged with defaults). */ permissionsPolicyOverrides?: Partial; + /** Show full NestJS init logs during bootstrap (route mapping, module deps, pollers). Defaults to false. */ + verboseBootstrap?: boolean; } /** @@ -69,11 +72,12 @@ export async function bootstrapSolidApp( ): Promise { registerGlobalProcessHandlers(); - const { globalPrefix = 'api', swagger = {}, permissionsPolicyOverrides = {} } = options; + const { globalPrefix = 'api', swagger = {}, permissionsPolicyOverrides = {}, verboseBootstrap = false } = options; + const startTime = Date.now(); const appModule = await appModuleFactory(); const app = await NestFactory.create(appModule, { - logger: WinstonModule.createLogger(WinstonLoggerConfig), + logger: WinstonModule.createLogger({ ...createWinstonLoggerConfig(), level: verboseBootstrap ? 'debug' : 'error' }), }); const apiEnabled = parseBooleanEnv('API_ENABLED', true); @@ -114,9 +118,6 @@ export async function bootstrapSolidApp( next(); }); - // Winston logger - app.useLogger(app.get(WINSTON_MODULE_NEST_PROVIDER)); - const port = process.env.PORT || 3000; if (globalPrefix) { @@ -178,7 +179,16 @@ export async function bootstrapSolidApp( const types = require('pg').types; types.setTypeParser(types.builtins.INT8, (val: string) => parseInt(val)); + app.useWebSocketAdapter(new WsAdapter(app)); + await app.listen(port); + + // Wire up Winston as the runtime logger only AFTER listen — this suppresses all + // framework init noise (route mapping, module deps, onModuleInit) during boot. + app.useLogger(app.get(WINSTON_MODULE_NEST_PROVIDER)); + + const elapsed = ((Date.now() - startTime) / 1000).toFixed(2); + process.stdout.write(`\x1b[32mServer started on port ${port} in ${elapsed}s\x1b[0m\n`); } // ---- CLI bootstrap ---- diff --git a/src/helpers/command.service.ts b/src/helpers/command.service.ts index df332ca8..9ab604b0 100755 --- a/src/helpers/command.service.ts +++ b/src/helpers/command.service.ts @@ -4,6 +4,7 @@ import { spawn } from 'child_process'; export type CommandWithArgs = { command: string; args: string[]; + cwd?: string; }; @Injectable() @@ -43,6 +44,7 @@ export class CommandService { const child = spawn(command, spawnArgs, { shell: isWindows, // Use shell on Windows to handle .cmd files stdio: ['pipe', 'pipe', 'pipe'], + cwd: commandWithArgs.cwd, }); let stdout = ''; @@ -64,7 +66,7 @@ export class CommandService { child.on('close', (code) => { if (code !== 0) { this.logger.error(`Command failed with code ${code}: ${command}`, stderr); - reject(new Error(stderr || `Command failed with exit code ${code}`)); + reject(new Error([stderr, stdout].filter(Boolean).join('\n').trim() || `Command failed with exit code ${code}`)); return; } resolve(stdout); diff --git a/src/helpers/field-crud-managers/BigIntFieldCrudManager.ts b/src/helpers/field-crud-managers/BigIntFieldCrudManager.ts index 24eb1fed..77b78d8f 100755 --- a/src/helpers/field-crud-managers/BigIntFieldCrudManager.ts +++ b/src/helpers/field-crud-managers/BigIntFieldCrudManager.ts @@ -20,7 +20,7 @@ export class BigIntFieldCrudManager implements FieldCrudManager { if (typeof fieldValue === 'string' || typeof fieldValue === 'number') { fieldValue = BigInt(fieldValue); } - } catch (err) { + } catch (err: any) { return [{ field: this.options.fieldName, error: 'Invalid numeric value' }]; } } diff --git a/src/helpers/field-crud-managers/SelectionDynamicFieldCrudManager.ts b/src/helpers/field-crud-managers/SelectionDynamicFieldCrudManager.ts index 91090b19..44bd8a68 100755 --- a/src/helpers/field-crud-managers/SelectionDynamicFieldCrudManager.ts +++ b/src/helpers/field-crud-managers/SelectionDynamicFieldCrudManager.ts @@ -119,7 +119,7 @@ export class SelectionDynamicFieldCrudManager implements FieldCrudManager { } return false; } - catch (error) { + catch (error: any) { // Use the values method as a fallback, if the value method is not implemented const values = await providerInstance.values('', ctxt); const isValid = values.some(v => v.value === fieldValue); diff --git a/src/helpers/field-crud-managers/field-quality-check-fixes.md b/src/helpers/field-crud-managers/field-quality-check-fixes.md new file mode 100644 index 00000000..c2e51f6b --- /dev/null +++ b/src/helpers/field-crud-managers/field-quality-check-fixes.md @@ -0,0 +1,208 @@ +# Field Quality Checks And Fixes + +This checklist tracks backend issues and logical enhancements for each field type in `solid-core-module`. + +Use it for backend concerns only: + +- CRUD validation +- transformation and normalization +- persistence behavior +- relation semantics +- field-level correctness and consistency + +Frontend widget and rendering concerns belong in `solid-core-ui`. + +## `shortText` + +- [ ] Resolve the `length` versus `max` contract for `shortText` and document one clear meaning for each attribute. +- [ ] Decide whether backend validation should also enforce `min`, so `shortText` constraints remain consistent even outside generated form flows. +- [ ] Return the configured regex mismatch message when `regexPatternNotMatchingErrorMsg` is present instead of always using a generic regex error. +- [ ] Clarify and implement the expected behavior for `defaultValue` when a create payload omits a `shortText` field. +- [ ] Decide whether `shortText` should support optional normalization such as trimming or whitespace collapsing, and keep that decision consistent across comparable text field types. +- [ ] Review whether uniqueness should receive any pre-save validation support at the CRUD layer or continue to rely entirely on persistence-layer constraints. +- [ ] Add targeted coverage for partial update behavior, empty string versus null handling, regex validation, and max-length enforcement. +- [ ] Review whether the text-oriented managers share enough behavior to justify a common validation utility or base manager without weakening field-specific semantics. + +## `longText` + +- [ ] Decide whether backend validation should also enforce `min` and `max`, so `longText` constraints remain consistent outside generated form flows. +- [ ] Return the configured regex mismatch message when `regexPatternNotMatchingErrorMsg` is present instead of always using a generic regex error. +- [ ] Clarify and implement the expected behavior for `defaultValue` when a create payload omits a `longText` field. +- [ ] Decide whether `longText` should support optional normalization such as trimming trailing whitespace while preserving intentional line breaks. +- [ ] Review whether uniqueness should receive any pre-save validation support at the CRUD layer or continue to rely entirely on persistence-layer constraints. +- [ ] Add targeted coverage for multiline content, regex validation, partial updates, and empty string versus null handling. +- [ ] Review whether the text-oriented managers share enough behavior to justify a common validation utility or base manager without weakening field-specific semantics. + +## `richText` + +- [ ] Decide whether backend validation should also enforce `min` and `max`, so `richText` constraints remain consistent outside generated form flows. +- [ ] Return the configured regex mismatch message when `regexPatternNotMatchingErrorMsg` is present instead of always using a generic regex error. +- [ ] Clarify and implement the expected behavior for `defaultValue` when a create payload omits a `richText` field. +- [ ] Decide whether `richText` should support optional normalization or sanitization at save time, and define that behavior explicitly. +- [ ] Review whether uniqueness should receive any pre-save validation support at the CRUD layer or continue to rely entirely on persistence-layer constraints. +- [ ] Add targeted coverage for rich HTML-like content, regex validation, partial updates, and empty string versus null handling. +- [ ] Review whether the text-oriented managers share enough behavior to justify a common validation utility or base manager without weakening field-specific semantics. + +## `json` + +- [ ] Clarify and implement the expected behavior for `defaultValue` when a create payload omits a `json` field. +- [ ] Decide whether backend validation should accept only stringified JSON or also handle already-parsed object and array payloads more explicitly. +- [ ] Review whether the current JSON validation error should distinguish between invalid JSON syntax and unsupported runtime value shapes. +- [ ] Decide whether normalization or canonical stringification should happen before persistence for comparable JSON values. +- [ ] Review whether uniqueness should receive any pre-save validation support at the CRUD layer or continue to rely entirely on persistence-layer constraints. +- [ ] Add targeted coverage for object payloads, array payloads, stringified JSON payloads, invalid JSON, partial updates, and empty string versus null handling. + +## `int` + +- [ ] Fix the current min/max activation rule, because backend validation only applies numeric bounds when the configured value is greater than `0`, which skips valid `0` and negative thresholds. +- [ ] Clarify and implement the expected behavior for `defaultValue` when a create payload omits an `int` field. +- [ ] Decide whether the CRUD layer should normalize numeric strings into integers more explicitly before validation and persistence. +- [ ] Review whether integer fields should reject decimal-shaped inputs more clearly when values arrive from metadata-driven clients as strings. +- [ ] Review whether uniqueness should receive any pre-save validation support at the CRUD layer or continue to rely entirely on persistence-layer constraints. +- [ ] Add targeted coverage for `0`, negative values, numeric strings, invalid decimal-like input, min/max bounds, and partial update behavior. + + +## `bigint` + +- [ ] Clarify the authored support surface for `bigint`, because the CRUD manager contains numeric bound logic while the authored field surface does not currently present `min` and `max` in the same way as `int` and `decimal`. +- [ ] Fix or clarify the current numeric-bound activation rule if bigint bounds are intended to be supported, since the logic only applies bounds when the configured value is greater than `0`. +- [ ] Clarify and implement the expected behavior for `defaultValue` when a create payload omits a `bigint` field. +- [ ] Decide what the canonical runtime contract should be for bigint inputs: native `bigint`, numeric strings, or finite JavaScript numbers. +- [ ] Review whether accepting finite JavaScript numbers for bigint validation is sufficient when exact large-integer fidelity matters. +- [ ] Review whether uniqueness should receive any pre-save validation support at the CRUD layer or continue to rely entirely on persistence-layer constraints. +- [ ] Add targeted coverage for numeric strings, very large values, invalid numeric input, bound handling, null handling, and partial update behavior. + + +## `decimal` + +- [ ] Fix the current min/max activation rule, because backend validation only applies numeric bounds when the configured value is greater than `0`, which skips valid `0` and negative thresholds. +- [ ] Clarify and implement the expected behavior for `defaultValue` when a create payload omits a `decimal` field. +- [ ] Decide whether the CRUD layer should normalize numeric strings into decimal numbers more explicitly before validation and persistence. +- [ ] Review whether decimal fields need an explicit precision-and-scale contract instead of relying entirely on the persistence-layer `ormType`. +- [ ] Review whether uniqueness should receive any pre-save validation support at the CRUD layer or continue to rely entirely on persistence-layer constraints. +- [ ] Add targeted coverage for `0`, negative values, fractional values, numeric strings, invalid numeric input, min/max bounds, and partial update behavior. + + +## `boolean` + +- [ ] Clarify and implement the expected behavior for `defaultValue` when a create payload omits a `boolean` field. +- [ ] Decide whether boolean CRUD validation should accept only runtime booleans or also normalize `"true"` and `"false"` string payloads when they arrive from metadata-driven clients. +- [ ] Review whether the required validation path should distinguish more clearly between an omitted value and an explicit `false` value, especially for partial updates. +- [ ] Review whether uniqueness should receive any pre-save validation support at the CRUD layer or continue to rely entirely on persistence-layer constraints. +- [ ] Add targeted coverage for `true`, `false`, null, empty string, string booleans, and partial update behavior. + +## `date` + +- [ ] Clarify and implement the expected behavior for `defaultValue` when a create payload omits a `date` field. +- [ ] Review whether the CRUD layer should accept only runtime `Date` objects or also normalize common serialized date inputs more explicitly before validation. +- [ ] Decide whether `date` and `datetime` should continue to share the same backend validation path or receive more distinct normalization and validation behavior. +- [ ] Review whether uniqueness should receive any pre-save validation support at the CRUD layer or continue to rely entirely on persistence-layer constraints. +- [ ] Add targeted coverage for `Date` objects, serialized date strings, null handling, invalid date inputs, and partial update behavior. + +## `datetime` + +- [ ] Clarify and implement the expected behavior for `defaultValue` when a create payload omits a `datetime` field. +- [ ] Review whether the CRUD layer should accept only runtime `Date` objects or also normalize common serialized datetime inputs more explicitly before validation. +- [ ] Decide whether `date` and `datetime` should continue to share the same backend validation path or receive more distinct normalization and validation behavior. +- [ ] Review whether timezone normalization rules should be documented and enforced more explicitly at save time. +- [ ] Review whether uniqueness should receive any pre-save validation support at the CRUD layer or continue to rely entirely on persistence-layer constraints. +- [ ] Add targeted coverage for `Date` objects, serialized datetime strings, null handling, timezone-sensitive values, invalid datetime inputs, and partial update behavior. + +## `time` + +- [ ] Add an explicit CRUD-manager path for `SolidFieldType.time`, since the field currently has core UI support but is not routed through a dedicated backend field manager. +- [ ] Clarify and implement the expected behavior for `defaultValue` when a create payload omits a `time` field. +- [ ] Decide what canonical persisted shape `time` should use: pure time string, database-native time value, or a normalized timestamp-derived representation. +- [ ] Review whether the CRUD layer should normalize common time inputs such as `HH:mm:ss`, ISO timestamps, and UI-submitted values more explicitly before validation and persistence. +- [ ] Review whether uniqueness should receive any pre-save validation support at the CRUD layer or continue to rely entirely on persistence-layer constraints. +- [ ] Add targeted coverage for `HH:mm:ss` inputs, ISO-style inputs, invalid time values, null handling, and partial update behavior. + +## `email` + +- [ ] Decide whether backend validation should also enforce `min`, so email constraints remain consistent outside generated form flows. +- [ ] Return the configured regex mismatch message when `regexPatternNotMatchingErrorMsg` is present instead of always using a generic regex error. +- [ ] Clarify and implement the expected behavior for `defaultValue` when a create payload omits an `email` field. +- [ ] Decide whether email values should support optional normalization such as trimming and lowercasing before persistence. +- [ ] Review whether uniqueness should receive any pre-save validation support at the CRUD layer or continue to rely entirely on persistence-layer constraints. +- [ ] Add targeted coverage for max-length enforcement, regex overrides, invalid email formats, null handling, and partial update behavior. + +## `password` + +- [ ] Return the configured regex mismatch message when `regexPatternNotMatchingErrorMsg` is present instead of always using a generic password-regex error. +- [ ] Review whether password validation should produce clearer error messages for min, max, regex, and confirm-password mismatch failures. +- [ ] Clarify and implement the expected behavior for `defaultValue` on password fields, including whether it should be ignored entirely for secure create flows. +- [ ] Review whether password hashing and password-update flows should share a more explicit single contract across create and update operations. +- [ ] Decide whether password normalization should include trimming or whether exact raw user input should always be preserved before hashing. +- [ ] Add targeted coverage for create-required behavior, update-optional behavior, confirm-password mismatch, hashing behavior, regex fallback behavior, and partial update behavior. + +## `selectionStatic` + +- [ ] Clarify and implement the expected behavior for `defaultValue` when a create payload omits a `selectionStatic` field, especially for multi-select fields. +- [ ] Decide whether the CRUD layer should normalize single-select and multi-select payloads into one consistent stored shape before persistence. +- [ ] Review whether the current `selectionStaticValues` parsing should validate malformed `value:label` entries more explicitly instead of assuming a valid authored format. +- [ ] Add clearer error messaging when the submitted value has the wrong type versus when it is simply not part of the authored option set. +- [ ] Review whether numeric selection values need more explicit normalization, especially when values can arrive as strings from metadata-driven clients. +- [ ] Add targeted coverage for single-select string values, single-select numeric values, JSON-stringified multi-select arrays, invalid option tokens, malformed arrays, and partial update behavior. + +## `selectionDynamic` + +- [ ] Clarify and implement the expected behavior for `defaultValue` when a create payload omits a `selectionDynamic` field, especially for multi-select fields. +- [ ] Review whether the CRUD layer should normalize single-select and multi-select payloads into one consistent stored shape before persistence. +- [ ] Add clearer error messaging when the submitted value has the wrong type, when the provider rejects the value, and when the provider itself cannot be resolved. +- [ ] Review whether numeric selection values need more explicit normalization, especially when values can arrive as strings from metadata-driven clients. +- [ ] Clarify how `validateOnSave` in `selectionDynamicProviderCtxt` should be treated as part of the supported field contract and whether more provider-context keys should be validated. +- [ ] Add targeted coverage for provider-backed single-select values, JSON-stringified multi-select arrays, invalid provider names, `validateOnSave: false`, invalid option values, and partial update behavior. + +## `many-to-one` + +- [ ] Clarify and document the preferred request contract for single relations, because accepting both `Id` and `UserKey` is useful but easy to apply inconsistently across clients. +- [ ] Review whether the CRUD layer should normalize empty strings for `Id` and `UserKey` more explicitly before required validation runs. +- [ ] Decide whether user-key-based lookup failures should return a more specific error message than generic required or invalid relation errors. +- [ ] Review whether relation resolution should support optional fixed-filter enforcement at save time in addition to UI-level filtering. +- [ ] Consider whether uniqueness on a `many-to-one` field should receive clearer pre-save validation when the authored intent effectively makes the relation one-to-one. +- [ ] Add targeted coverage for id-based lookup, user-key-based lookup, missing required relations, invalid id shapes, invalid user keys, and partial update behavior. + +## `one-to-many` + +- [ ] Clarify and document the command contract for collection relations so `set`, `clear`, `link`, `unlink`, `create`, `update`, and `delete` are easier to use consistently from clients. +- [ ] Review whether malformed or unsupported command values should return more specific validation errors than the current generic failure path. +- [ ] Decide whether create-time and update-time collection mutation semantics should be normalized more explicitly, especially where some commands are intentionally update-only. +- [ ] Review whether `relationCoModelFieldName` should be validated more aggressively, because this field is central to child binding and inverse resolution. +- [ ] Consider whether `relationFieldFixedFilter` should be enforced during relation mutation so child records cannot be linked outside the authored collection scope. +- [ ] Add targeted coverage for `set`, `clear`, `link`, `unlink`, `create`, `update`, `delete`, invalid command payloads, and partial update behavior. + +## `many-to-many` + +- [ ] Clarify and document owner-side versus inverse-side mutation semantics so clients understand which effective field names and id lists are honored during CRUD operations. +- [ ] Review whether malformed owner-side metadata such as missing `isRelationManyToManyOwner` or mismatched inverse names should fail earlier and more clearly. +- [ ] Decide whether `relationJoinTableName` and related ownership attrs need stronger validation at metadata authoring time to avoid ambiguous join behavior later. +- [ ] Review whether `relationFieldFixedFilter` should be enforced during membership mutation so unsupported links cannot be created through direct payloads. +- [ ] Consider whether create, update, and delete operations against related entities should be more clearly separated from plain membership operations such as `set`, `link`, and `unlink`. +- [ ] Add targeted coverage for owner-side mutations, inverse-side mutations, `set`, `clear`, `link`, `unlink`, related-entity create/update/delete flows, and partial update behavior. + +## `mediaSingle` + +- [ ] Clarify and implement the expected behavior for `defaultValue` when a create payload omits a `mediaSingle` field, including whether the field should ever accept persisted references without a new upload. +- [ ] Review whether media validation should normalize authored `mediaTypes` values more explicitly so unsupported tokens fail early and predictably. +- [ ] Consider whether storage-provider resolution should fail with a clearer field-specific error when `mediaStorageProviderUserKey` is missing or invalid. +- [ ] Review whether replace and delete semantics should be more explicit in the CRUD layer when an existing asset is being updated or removed. +- [ ] Consider whether upload validation should be refactored into shared utilities so size, type, and required checks stay consistent across media field types. +- [ ] Add targeted coverage for required create-time uploads, oversized files, unsupported media types, missing providers, replacement flows, and partial update behavior. + +## `mediaMultiple` + +- [ ] Align collection-level validation with the single-media path so `mediaTypes` and `mediaMaxSizeKb` are enforced consistently for every uploaded file. +- [ ] Clarify and implement the expected behavior for `defaultValue` when a create payload omits a `mediaMultiple` field, including whether persisted media references should ever be accepted directly. +- [ ] Review whether storage-provider resolution should fail with a clearer field-specific error when `mediaStorageProviderUserKey` is missing or invalid. +- [ ] Decide what the canonical update semantics should be for attachment collections: append-only, replace-all, explicit remove, or a more command-style contract. +- [ ] Consider whether media collection validation should report per-file errors more explicitly when one file fails and others succeed. +- [ ] Add targeted coverage for required create-time collections, mixed valid and invalid uploads, oversized files, unsupported media types, missing providers, and partial update behavior. + +## `computed` + +- [ ] Clarify and document the supported provider contract more explicitly, including what a computed-field provider must return and how failures should be surfaced to callers. +- [ ] Review whether `computedFieldValueProviderCtxt` should be validated as JSON earlier and with clearer error messaging before provider execution begins. +- [ ] Decide whether inline computation should remain skipped on partial updates by default, or whether some computed fields need an opt-in recompute path during patch-style operations. +- [ ] Review whether `computedFieldTriggerConfig` should be validated more aggressively so unsupported operations or malformed trigger definitions fail earlier. +- [ ] Consider whether the CRUD layer should verify that the provider’s returned value matches `computedFieldValueType` before persistence. +- [ ] Add targeted coverage for inline provider execution, malformed provider context, missing providers, trigger-configured fields, partial updates, and mismatched computed value types. diff --git a/src/helpers/microsoft-active-directory-oauth.helper.ts b/src/helpers/microsoft-active-directory-oauth.helper.ts new file mode 100644 index 00000000..e941b352 --- /dev/null +++ b/src/helpers/microsoft-active-directory-oauth.helper.ts @@ -0,0 +1,83 @@ +export type MicrosoftActiveDirectoryAuthConfiguration = { + clientID: string; + clientSecret: string; + tenant: string; + callbackURL: string; + redirectURL: string; +}; + +export const DEFAULT_MICROSOFT_ACTIVE_DIRECTORY_OAUTH_TENANT = "common"; +export const MICROSOFT_ACTIVE_DIRECTORY_OAUTH_SCOPES = [ + "openid", + "profile", + "email", + "User.Read", +]; + +type MicrosoftActiveDirectoryOauthProfileValue = { + value?: string; +}; + +export type MicrosoftActiveDirectoryOauthProfile = { + id?: string; + displayName?: string; + emails?: MicrosoftActiveDirectoryOauthProfileValue[]; + photos?: MicrosoftActiveDirectoryOauthProfileValue[]; + _json?: { + id?: string; + displayName?: string; + mail?: string; + userPrincipalName?: string; + email?: string; + preferred_username?: string; + picture?: string; + }; +}; + +export const isMicrosoftActiveDirectoryOAuthConfigured = ( + config: MicrosoftActiveDirectoryAuthConfiguration, +): boolean => { + return !!( + config.clientID && + config.clientSecret && + config.tenant && + config.callbackURL && + config.redirectURL + ); +}; + +export const getMicrosoftActiveDirectoryOAuthProfileId = ( + profile: MicrosoftActiveDirectoryOauthProfile, +): string | null => { + return profile.id || profile._json?.id || null; +}; + +export const getMicrosoftActiveDirectoryOAuthEmail = ( + profile: MicrosoftActiveDirectoryOauthProfile, +): string | null => { + const email = + profile.emails?.find((item) => !!item.value)?.value || + profile._json?.mail || + profile._json?.userPrincipalName || + profile._json?.email || + profile._json?.preferred_username || + null; + + return email?.trim().toLowerCase() || null; +}; + +export const getMicrosoftActiveDirectoryOAuthDisplayName = ( + profile: MicrosoftActiveDirectoryOauthProfile, +): string | null => { + return profile.displayName || profile._json?.displayName || null; +}; + +export const getMicrosoftActiveDirectoryOAuthPicture = ( + profile: MicrosoftActiveDirectoryOauthProfile, +): string | null => { + return ( + profile.photos?.find((item) => !!item.value)?.value || + profile._json?.picture || + null + ); +}; diff --git a/src/helpers/model-metadata-helper.service.ts b/src/helpers/model-metadata-helper.service.ts index 3c88becf..63526926 100644 --- a/src/helpers/model-metadata-helper.service.ts +++ b/src/helpers/model-metadata-helper.service.ts @@ -3,6 +3,7 @@ import { forwardRef, Inject, Injectable, Logger } from "@nestjs/common"; import { _ } from "lodash"; import { LEGACY_TABLE_FIELDS_PREFIX } from "src/entities/legacy-common.entity"; +import { LegacyTableType } from "src/enums/legacy-table-type.enum"; import { ModelMetadataRepository } from "src/repository/model-metadata.repository"; import { SolidRegistry } from "./solid-registry"; @@ -19,12 +20,12 @@ export class ModelMetadataHelperService { ) { } - getSystemFieldsMetadata(isLegacyTable: boolean=false, isLegacyTableWithId: boolean=false): any[] { + getSystemFieldsMetadata(legacyTableType: LegacyTableType = LegacyTableType.NONE): any[] { let systemFieldsMetadata: any[]; - if (isLegacyTableWithId) { + if (legacyTableType === LegacyTableType.GENERATED_ID) { systemFieldsMetadata = this.getSystemFieldsMetadataMappingForLegacyTable(true); } - else if (isLegacyTable) { + else if (legacyTableType === LegacyTableType.EXISTING_ID) { systemFieldsMetadata = this.getSystemFieldsMetadataMappingForLegacyTable(false); } else { diff --git a/src/helpers/module-metadata-helper.service.ts b/src/helpers/module-metadata-helper.service.ts index 46822cab..99036973 100644 --- a/src/helpers/module-metadata-helper.service.ts +++ b/src/helpers/module-metadata-helper.service.ts @@ -23,7 +23,7 @@ export class ModuleMetadataHelperService { const fileContent = await fs.readFile(configFilePath, 'utf8'); return JSON.parse(fileContent); } - catch (error) { + catch (error: any) { this.logger.error(`module metadata configuration non existent at: ${configFilePath}`); } @@ -37,41 +37,54 @@ export class ModuleMetadataHelperService { return path.resolve(process.cwd(), 'src', moduleName); } + async getSolidUiModulePath(moduleName: string): Promise { + if (!moduleName) { + return ''; + } + + const dashModuleName = kebabCase(moduleName); + return path.resolve(process.cwd(), '..', 'solid-ui', 'src', dashModuleName); + } + + private resolveModuleMetadataFolderPath(moduleName: string): string { + const dashModuleName = kebabCase(moduleName); + return path.resolve(process.cwd(), 'src', dashModuleName, 'metadata'); + } + async getModuleMetadataFilePath(moduleName: string): Promise { if (!moduleName) { return ''; } const dashModuleName = kebabCase(moduleName); - const folderPath = path.resolve(process.cwd(), 'module-metadata', dashModuleName); - const filePath = path.join(folderPath, `${dashModuleName}-metadata.json`); - // Check if filePath exists - const fileExists = await this.fileService.exists(filePath); - // this.logger.debug(`File exists: ${fileExists} at ${filePath}`); - if (!fileExists) { - // If the module is solid-core, try the fallback path, in case the current root directory is the solid core project - if (dashModuleName === SOLID_CORE_MODULE_NAME) { - const fallbackPath = path.resolve(process.cwd(), 'src', 'seeders', 'seed-data', `${dashModuleName}-metadata.json`); + const filePath = path.join( + this.resolveModuleMetadataFolderPath(dashModuleName), + `${dashModuleName}-metadata.json`, + ); + + if (dashModuleName === SOLID_CORE_MODULE_NAME) { + const fallbackPath = path.resolve(process.cwd(), 'src', 'seeders', 'seed-data', `${dashModuleName}-metadata.json`); + const fallbackExists = await this.fileService.exists(fallbackPath); + if (fallbackExists) { this.logger.debug(`Fallback path: ${fallbackPath}`); return fallbackPath; } + + const consumingProjectFallbackPath = path.resolve(process.cwd(), 'node_modules', '@solidxai', 'core', 'src', 'seeders', 'seed-data', `${dashModuleName}-metadata.json`); + const consumingProjectFallbackExists = await this.fileService.exists(consumingProjectFallbackPath); + if (consumingProjectFallbackExists) { + this.logger.debug(`Fallback path: ${consumingProjectFallbackPath}`); + return consumingProjectFallbackPath; + } } + return filePath; } + async getModuleMetadataFolderPath(moduleName: string): Promise { if (!moduleName) { return ''; } - - const dashModuleName = kebabCase(moduleName); - - const folderPath = path.resolve( - process.cwd(), - 'module-metadata', - dashModuleName, - ); - - const exists = await this.fileService.exists(folderPath); - return exists ? folderPath : ''; + return this.resolveModuleMetadataFolderPath(moduleName); } } diff --git a/src/helpers/module.helper.ts b/src/helpers/module.helper.ts index 48e40fc4..7fe6365d 100755 --- a/src/helpers/module.helper.ts +++ b/src/helpers/module.helper.ts @@ -38,26 +38,29 @@ export const getDynamicModuleNames = (): string[] => { export const getDynamicModuleNamesBasedOnMetadata = (): string[] => { const dynamicModulesToExclude = process.env.SOLID_DYNAMIC_MODULES_TO_EXCLUDE?.split(',') || []; - // Find a path that is ../${srcPath}/module-metadata save it in a variable. - // const srcPath = path.join(process.cwd(), 'src'); - const moduleMetadataPath = path.join(process.cwd(), 'module-metadata'); + const srcPath = path.join(process.cwd(), 'src'); const coreModuleNames = getCoreModuleNames(); const allExcludedModules = [...new Set([...coreModuleNames, ...dynamicModulesToExclude])]; - // if module-metadata path does not exist, return empty array - if (!fs.existsSync(moduleMetadataPath)) { - logger.warn(`Module metadata path does not exist: ${moduleMetadataPath}`); + if (!fs.existsSync(srcPath)) { + logger.warn(`Source path does not exist: ${srcPath}`); return []; } - const moduleMetadataDirectories = fs.readdirSync(moduleMetadataPath, { withFileTypes: true }); - const enabledModules = moduleMetadataDirectories + const moduleDirectories = fs.readdirSync(srcPath, { withFileTypes: true }); + const enabledModules = moduleDirectories .filter(dirent => { const isValidDirectory = dirent.isDirectory() && !allExcludedModules.includes(dirent.name); if (!isValidDirectory) return false; - const fullPath = path.join(moduleMetadataPath, dirent.name, `${dirent.name}-metadata.json`); + // const moduleManifestPath = path.join(srcPath, dirent.name, `${dirent.name}.module.ts`); + // const moduleManifestStats = fs.statSync(moduleManifestPath, { throwIfNoEntry: false }); + // if (!moduleManifestStats || !moduleManifestStats.isFile()) { + // return false; + // } // Commenting this out, since we want to rely solely on the presence of metadata files and be able to load this as a dynamic module + + const fullPath = path.join(srcPath, dirent.name, 'metadata', `${dirent.name}-metadata.json`); const stats = fs.statSync(fullPath, { throwIfNoEntry: false }); return !!stats && stats.isFile(); diff --git a/src/helpers/solid-registry.ts b/src/helpers/solid-registry.ts index ee65f982..328cc67d 100755 --- a/src/helpers/solid-registry.ts +++ b/src/helpers/solid-registry.ts @@ -7,7 +7,7 @@ import { CommonEntity } from 'src/entities/common.entity'; import { Locale } from 'src/entities/locale.entity'; import { SecurityRule } from 'src/entities/security-rule.entity'; import { IScheduledJob } from 'src/services/scheduled-jobs/scheduled-job.interface'; -import { IDashboardQuestionDataProvider, IDashboardVariableSelectionProvider, IErrorCodeProvider, ISecurityRuleConfigProvider, ISelectionProvider, ISelectionProviderContext, ISolidDatabaseModule } from "../interfaces"; +import { IDashboardWidgetDataProvider, IErrorCodeProvider, ISecurityRuleConfigProvider, ISelectionProvider, ISelectionProviderContext, ISolidDatabaseModule } from "../interfaces"; import { ObjectLiteral } from 'typeorm'; type ControllerMetadata = { @@ -71,6 +71,7 @@ export class SolidRegistry { private seeders: Set = new Set(); private scheduledJobProviders: Set = new Set(); private selectionProviders: Set = new Set(); + private dashboardWidgetDataProviders: Set = new Set(); private computedFieldProviders: Set = new Set(); private solidDatabaseModules: Set = new Set(); private controllers: Set = new Set(); @@ -78,8 +79,6 @@ export class SolidRegistry { private securityRules: SecurityRule[] = []; private locales: Locale[] = []; private computedFieldMetadata: ComputedFieldMetadata[] = []; - private dashboardVariableSelectionProviders: Set = new Set(); - private dashboardQuestionDataProviders: Set = new Set(); private mailProviders: Set = new Set(); private whatsappProviders: Set = new Set(); private smsProviders: Set = new Set(); @@ -138,12 +137,8 @@ export class SolidRegistry { this.selectionProviders.add(selectionProvider); } - registerDashboardVariableSelectionProvider(dashboardSelectionProvider: InstanceWrapper): void { - this.dashboardVariableSelectionProviders.add(dashboardSelectionProvider); - } - - registerDashboardQuestionDataProvider(dashboardQuestionDataProvider: InstanceWrapper): void { - this.dashboardQuestionDataProviders.add(dashboardQuestionDataProvider); + registerDashboardWidgetDataProvider(provider: InstanceWrapper): void { + this.dashboardWidgetDataProviders.add(provider); } registerComputedFieldProvider(computedFieldProvider: InstanceWrapper): void { @@ -232,6 +227,10 @@ export class SolidRegistry { return Array.from(this.selectionProviders); } + getDashboardWidgetDataProviders(): Array { + return Array.from(this.dashboardWidgetDataProviders); + } + getSelectionProviderInstance(name: string): ISelectionProvider { const selectionProviders = this.getSelectionProviders(); @@ -243,19 +242,15 @@ export class SolidRegistry { } } - getDashboardVariableSelectionProviders(): Array { - return Array.from(this.dashboardVariableSelectionProviders); - } - - getDashboardVariableSelectionProviderInstance(name: string): IDashboardVariableSelectionProvider { - const dashboardSelectionProviders = this.getDashboardVariableSelectionProviders(); - - for (let i = 0; i < dashboardSelectionProviders.length; i++) { - const dashboardSelectionProvider = dashboardSelectionProviders[i]; - if (dashboardSelectionProvider.instance.name() === name) { - return dashboardSelectionProvider.instance; + getDashboardWidgetDataProviderInstance(name: string): IDashboardWidgetDataProvider | undefined { + const providers = this.getDashboardWidgetDataProviders(); + for (let i = 0; i < providers.length; i++) { + const provider = providers[i]; + if (provider?.instance?.name?.() === name || provider?.name === name) { + return provider.instance as IDashboardWidgetDataProvider; } } + return undefined; } getErrorCodeProviders(): Array { @@ -271,21 +266,6 @@ export class SolidRegistry { return undefined; } - getDashboardQuestionDataProviders(): Array { - return Array.from(this.dashboardQuestionDataProviders) - } - - getDashboardQuestionDataProviderInstance(name: string): IDashboardQuestionDataProvider { - const dashboardQuestionDataProviders = this.getDashboardQuestionDataProviders(); - - for (let i = 0; i < dashboardQuestionDataProviders.length; i++) { - const dasbhoardQuestionDataProvider = dashboardQuestionDataProviders[i]; - if (dasbhoardQuestionDataProvider.instance.name() === name) { - return dasbhoardQuestionDataProvider.instance; - } - } - } - getComputedFieldProviders(): Array { return Array.from(this.computedFieldProviders); } @@ -380,4 +360,3 @@ export class SolidRegistry { } } } - diff --git a/src/helpers/typeorm-migration-helpers.ts b/src/helpers/typeorm-migration-helpers.ts new file mode 100644 index 00000000..4ff58bb3 --- /dev/null +++ b/src/helpers/typeorm-migration-helpers.ts @@ -0,0 +1,163 @@ +import { QueryRunner } from 'typeorm'; + +export async function addColumnIfNotExists( + queryRunner: QueryRunner, + table: string, + column: string, + ddl: string, +): Promise { + await queryRunner.query(` +IF COL_LENGTH('${table}', '${column}') IS NULL +BEGIN + ${ddl} +END + `); +} + +export async function createUniqueIndexIfNotExists( + queryRunner: QueryRunner, + table: string, + indexName: string, + ddl: string, +): Promise { + await queryRunner.query(` +IF NOT EXISTS ( + SELECT 1 + FROM sys.indexes + WHERE name = '${indexName}' + AND object_id = OBJECT_ID('${table}') +) +BEGIN + ${ddl} +END + `); +} + +export async function addConstraintIfNotExists( + queryRunner: QueryRunner, + constraintName: string, + ddl: string, +): Promise { + await queryRunner.query(` +IF NOT EXISTS ( + SELECT 1 FROM sys.objects + WHERE name = '${constraintName}' +) +BEGIN + ${ddl} +END + `); +} + +export async function dropColumnIfExists( + queryRunner: QueryRunner, + table: string, + column: string, + ddl: string, +): Promise { + await queryRunner.query(` +IF COL_LENGTH('${table}', '${column}') IS NOT NULL +BEGIN + ${ddl} +END + `); +} + +export async function dropUniqueIndexIfExists( + queryRunner: QueryRunner, + table: string, + indexName: string, + ddl: string, +): Promise { + await queryRunner.query(` +IF EXISTS ( + SELECT 1 + FROM sys.indexes + WHERE name = '${indexName}' + AND object_id = OBJECT_ID('${table}') +) +BEGIN + ${ddl} +END + `); +} + +export async function dropConstraintIfExists( + queryRunner: QueryRunner, + constraintName: string, + ddl: string, +): Promise { + await queryRunner.query(` +IF EXISTS ( + SELECT 1 FROM sys.objects + WHERE name = '${constraintName}' +) +BEGIN + ${ddl} +END + `); +} + +export async function createTableIfNotExists( + queryRunner: QueryRunner, + table: string, + ddl: string, +): Promise { + await queryRunner.query(` +IF OBJECT_ID('${table}', 'U') IS NULL +BEGIN + ${ddl} +END + `); +} + +export async function dropTableIfExists( + queryRunner: QueryRunner, + table: string, + ddl: string, +): Promise { + await queryRunner.query(` +IF OBJECT_ID('${table}', 'U') IS NOT NULL +BEGIN + ${ddl} +END + `); +} + +export async function createIndexIfNotExists( + queryRunner: QueryRunner, + table: string, + indexName: string, + ddl: string, +): Promise { + await queryRunner.query(` +IF NOT EXISTS ( + SELECT 1 + FROM sys.indexes + WHERE name = '${indexName}' + AND object_id = OBJECT_ID('${table}') +) +BEGIN + ${ddl} +END + `); +} + +export async function dropIndexIfExists( + queryRunner: QueryRunner, + table: string, + indexName: string, + ddl: string, +): Promise { + await queryRunner.query(` +IF EXISTS ( + SELECT 1 + FROM sys.indexes + WHERE name = '${indexName}' + AND object_id = OBJECT_ID('${table}') +) +BEGIN + ${ddl} +END + `); +} diff --git a/src/helpers/user-helper.ts b/src/helpers/user-helper.ts index 001d8726..52a2d737 100644 --- a/src/helpers/user-helper.ts +++ b/src/helpers/user-helper.ts @@ -15,6 +15,10 @@ export function getUserExcludedFields(): string[] { "facebookId", "microsoftAccessToken", "microsoftId", + "microsoftProfilePicture", + "microsoftActiveDirectoryAccessToken", + "microsoftActiveDirectoryId", + "microsoftActiveDirectoryProfilePicture", "forgotPasswordConfirmedAt", "verificationTokenOnForgotPassword", "verificationTokenOnForgotPasswordExpiresAt", @@ -38,4 +42,4 @@ export function getUserExcludedFields(): string[] { "userViewMetadataIds", "userViewMetadataCommand", ]; -} \ No newline at end of file +} diff --git a/src/index.ts b/src/index.ts index 9a63c27d..95a4a82c 100755 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,7 @@ export * from './config/cache.options' export * from './decorators/active-user.decorator' export * from './decorators/solid-request-context.decorator' export * from './decorators/auth.decorator' +export * from './decorators/dashboard-widget-data-provider.decorator' export * from './decorators/computed-field-provider.decorator' export * from './decorators/scheduled-job-provider.decorator' export * from './decorators/is-not-in-enum.decorator' @@ -29,6 +30,7 @@ export * from './decorators/settings-provider.decorator' export * from './decorators/extension-user-creation-provider.decorator' export * from './dtos/post-chatter-message.dto' +export * from './dtos/update-chatter-note-message.dto' export * from './dtos/security-rule-config.dto' export * from './dtos/basic-filters.dto' export * from './dtos/solid-request-context.dto' @@ -49,6 +51,7 @@ export * from './dtos/create-mq-message-queue.dto' export * from './dtos/create-mq-message.dto' export * from './dtos/create-agent-session.dto' export * from './dtos/create-agent-event.dto' +export * from './dtos/create-mcp-audit-log.dto' export * from './dtos/create-scheduled-job.dto' export * from './dtos/create-permission-metadata.dto' export * from './dtos/create-role-metadata.dto' @@ -89,6 +92,7 @@ export * from './dtos/update-mq-message-queue.dto' export * from './dtos/update-mq-message.dto' export * from './dtos/update-agent-session.dto' export * from './dtos/update-agent-event.dto' +export * from './dtos/update-mcp-audit-log.dto' export * from './dtos/update-scheduled-job.dto' export * from './dtos/update-permission-metadata.dto' export * from './dtos/update-role-metadata.dto' @@ -104,6 +108,13 @@ export * from './dtos/update-chatter-message-details.dto' export * from './dtos/update-locale.dto' export * from './dtos/create-user-activity-history.dto' export * from './dtos/update-user-activity-history.dto' +export * from './dtos/dashboard-variable-options-query.dto' +export * from './dtos/dashboard-widget-data-request.dto' +export * from './dtos/metadata-explorer-references-query.dto' +export * from './dtos/metadata-explorer-search-query.dto' +export * from './dtos/metadata-explorer-write.dto' +export * from './dtos/create-dashboard-user-layout.dto' +export * from './dtos/update-dashboard-user-layout.dto' export * from './entities/action-metadata.entity' export * from './entities/common.entity' @@ -122,6 +133,7 @@ export * from './entities/mq-message-queue.entity' export * from './entities/mq-message.entity' export * from './entities/agent-session.entity' export * from './entities/agent-event.entity' +export * from './entities/mcp-audit-log.entity' export * from './entities/scheduled-job.entity' export * from './entities/permission-metadata.entity' export * from './entities/role-metadata.entity' @@ -142,16 +154,12 @@ export * from './entities/import-transaction.entity' export * from './entities/import-transaction-error-log.entity' export * from './entities/locale.entity' export * from './entities/user-activity-history.entity' -export * from './entities/dashboard.entity' -export * from './entities/dashboard-variable.entity' -export * from './entities/dashboard-question.entity' -export * from './entities/dashboard-layout.entity' -export * from './entities/dashboard-question-sql-dataset-config.entity' -export * from './entities/ai-interaction.entity' export * from './entities/model-sequence.entity' +export * from './entities/dashboard-user-layout.entity' export * from './entities/user-api-key.entity' export * from './enums/auth-type.enum' +export * from './enums/legacy-table-type.enum' export * from './decorators/disallow-in-production.decorator' export * from './filters/http-exception.filter' @@ -194,6 +202,7 @@ export * from './helpers/model-metadata-helper.service' export * from './helpers/image-encoding.helper' export * from './helpers/solid-microservice-adapter.service' export * from './helpers/typeorm-db-helper'; +export * from './helpers/typeorm-migration-helpers'; export * from './services/crud.service' export * from './interceptors/logging.interceptor' @@ -251,9 +260,6 @@ export * from './jobs/redis/test-queue-subscriber-redis.service' export * from './jobs/redis/three60-whatsapp-publisher-redis.service' export * from './jobs/redis/three60-whatsapp-queue-options-redis' export * from './jobs/redis/three60-whatsapp-subscriber-redis.service' -export * from './jobs/redis/trigger-mcp-client-publisher-redis.service' -export * from './jobs/redis/trigger-mcp-client-queue-options-redis' -export * from './jobs/redis/trigger-mcp-client-subscriber-redis.service' export * from './jobs/redis/twilio-sms-publisher-redis.service' export * from './jobs/redis/twilio-sms-queue-options-redis' export * from './jobs/redis/twilio-sms-subscriber-redis.service' @@ -263,6 +269,7 @@ export * from './listeners/user-registration.listener' export * from './passport-strategies/google-oauth.strategy' export * from './passport-strategies/facebook-oauth.strategy' export * from './passport-strategies/microsoft-oauth.strategy' +export * from './passport-strategies/microsoft-active-directory-oauth.strategy' export * from './services/selection-providers/list-of-values-selection-providers.service' @@ -292,11 +299,14 @@ export * from './services/mediaStorageProviders/file-storage-provider' export * from './services/mediaStorageProviders/index' export * from './services/menu-item-metadata.service' export * from './services/model-metadata.service' +export * from './services/module-metadata-explorer.service' export * from './services/module-metadata.service' +export * from './services/module-package.service' export * from './services/mq-message-queue.service' export * from './services/mq-message.service' export * from './services/agent-session.service' export * from './services/agent-event.service' +export * from './services/mcp-audit-log.service' export * from './services/scheduled-job.service' export * from './services/pdf.service' export * from './services/permission-metadata.service' @@ -307,6 +317,8 @@ export * from './services/queues/redis-subscriber.service' export * from './services/refresh-token-ids-storage.service' export * from './services/role-metadata.service' export * from './services/selection-providers/list-of-models-selection-provider.service' +export * from './services/selection-providers/mq-dashboard-message-broker-variable-options-provider.service' +export * from './services/selection-providers/mq-dashboard-queue-name-variable-options-provider.service' export * from './services/short-url/tiny-url.service' export * from './services/sms/Msg91BaseSMSService' //rename export * from './services/sms/Msg91OTPService' //rename @@ -318,10 +330,13 @@ export * from './services/solid-introspect.service' export * from './services/user.service' export * from './services/view-metadata.service' export * from './services/whatsapp/Msg91WhatsappService' //rename +export * from './services/whatsapp/GupshupOtpWhatsappService' export * from './services/setting.service' export * from './services/encryption.service' export * from './services/info.service' export * from './controllers/info.controller' +export * from './controllers/module-metadata-explorer.controller' +export * from './controllers/module-package.controller' export * from './services/settings/default-settings-provider.service' export * from './services/security-rule.service' export * from './services/request-context.service' @@ -333,10 +348,22 @@ export * from './services/import-transaction.service' export * from './services/import-transaction-error-log.service' export * from './services/excel.service' export * from './services/csv.service' +export * from './services/dashboard-runtime.service' export * from './services/queues/publisher-factory.service' export * from './services/queues/database-publisher.service' export * from './services/queues/database-subscriber.service' -export * from './services/ai-interaction.service' +export * from './services/dashboard-providers/mq-dashboard-total-messages-kpi-provider.service' +export * from './services/dashboard-providers/mq-dashboard-succeeded-messages-kpi-provider.service' +export * from './services/dashboard-providers/mq-dashboard-failed-messages-kpi-provider.service' +export * from './services/dashboard-providers/mq-dashboard-inflight-messages-kpi-provider.service' +export * from './services/dashboard-providers/mq-dashboard-success-rate-kpi-provider.service' +export * from './services/dashboard-providers/mq-dashboard-avg-elapsed-kpi-provider.service' +export * from './services/dashboard-providers/mq-dashboard-messages-over-time-provider.service' +export * from './services/dashboard-providers/mq-dashboard-stage-distribution-provider.service' +export * from './services/dashboard-providers/mq-dashboard-queue-wise-failures-provider.service' +export * from './services/dashboard-providers/mq-dashboard-queue-wise-avg-elapsed-provider.service' +export * from './services/dashboard-providers/mq-dashboard-latency-trend-provider.service' +export * from './services/dashboard-providers/mq-dashboard-recent-failures-provider.service' // Factories export * from './factories/mail.factory' diff --git a/src/interfaces.ts b/src/interfaces.ts index c9ca03db..b1705aae 100755 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -12,11 +12,7 @@ import { CreateRoleMetadataDto } from './dtos/create-role-metadata.dto'; import { CreateSecurityRuleDto } from './dtos/create-security-rule.dto'; import { FieldMetadata } from './entities/field-metadata.entity'; import { Media } from './entities/media.entity'; -import { DashboardQuestion } from './entities/dashboard-question.entity'; import { ComputedFieldMetadata } from './helpers/solid-registry'; -import { SqlExpression } from './services/question-data-providers/chartjs-sql-data-provider.service'; -import { CreateDashboardDto } from './dtos/create-dashboard.dto'; -import { AiInteraction } from './entities/ai-interaction.entity'; import { ActiveUserData } from './interfaces/active-user-data.interface'; import { SecurityRuleConfig } from './dtos/security-rule-config.dto'; import { SecurityRule } from './entities/security-rule.entity'; @@ -42,6 +38,7 @@ export interface ValidationError { export interface MediaStorageProvider { store(files: Express.Multer.File[], entity: T, mediaFieldMetadata: FieldMetadata): Promise; delete(entity: T, mediaFieldMetadata: FieldMetadata): Promise; + deleteByMediaRecord(media: Media): Promise; retrieve(entity: T, mediaFieldMetadata: FieldMetadata): Promise; storeStreams(streamPairs: [Readable, string][], entity: T, mediaFieldMetadata: FieldMetadata): Promise; // delete(file: string): Promise; @@ -58,7 +55,7 @@ export interface ModuleMetadataConfiguration { smsTemplates?: CreateSmsTemplateDto[], mediaStorageProviders?: CreateMediaStorageProviderMetadataDto[] securityRules?: CreateSecurityRuleDto[], - dashboards?: CreateDashboardDto[], + dashboards?: any[], } export enum SettingLevel { @@ -121,14 +118,8 @@ export interface CodeGenerationOptions { fieldIdsForRemoval?: number[]; fieldNamesForRemoval?: string[]; dryRun?: boolean; - fieldIdsForRefresh?: number[]; - fieldNamesForRefresh?: string[]; } -export interface TriggerMcpClientOptions { - aiInteractionId: number; - moduleName: string; -} export interface McpResponse { success: boolean; @@ -167,27 +158,40 @@ export interface ISelectionProvider { values(query: any, ctxt: T): Promise; } -export interface IDashboardVariableSelectionProvider extends ISelectionProvider { -} - -export interface IMcpToolResponseHandler { - apply(aiInteraction: AiInteraction); -} - -export interface QuestionSqlDataProviderContext { - // questionSqlDatasetConfig: QuestionSqlDatasetConfig; - // questionId: number; - // question: Question; - expressions?: SqlExpression[] -} -export interface IDashboardQuestionDataProvider { +export interface IDashboardWidgetDataProviderContext, TProviderContext = Record> { + moduleName: string; + dashboardName: string; + widgetName: string; + variables: TVariables; + providerContext: TProviderContext; + activeUser?: ActiveUserData; +} + +export interface IDashboardWidgetDataResponseEnvelope> { + meta: { + providerName: string; + generatedAt: string; + widgetName: string; + durationMs: number; + [key: string]: any; + }; + data: TData; + uiHints?: TUiHints; +} + +export interface IDashboardWidgetDataProvider< + TContext extends IDashboardWidgetDataProviderContext = IDashboardWidgetDataProviderContext, + TResponse = any, +> { help(): string; - name(): string; - - getData(question: DashboardQuestion, ctxt?: TContext): Promise; + getData( + widgetDefinition: Record, + ctxt: TContext, + ): Promise | any>; } + /** * @deprecated Use `IEntityComputedFieldProvider` instead. */ @@ -450,4 +454,3 @@ export interface AuditQueuePayload { updatedColumnNames?: string[]; userId?: number | null; } - diff --git a/src/jobs/database/chatter-queue-subscriber-database.service.ts b/src/jobs/database/chatter-queue-subscriber-database.service.ts index 1bfea5ea..391fc4ac 100644 --- a/src/jobs/database/chatter-queue-subscriber-database.service.ts +++ b/src/jobs/database/chatter-queue-subscriber-database.service.ts @@ -35,7 +35,7 @@ export class ChatterQueueSubscriberDatabase extends DatabaseSubscriber ({ propertyName: n })), + false, + p.userId, ); break; case 'delete': - await this.chatterMessageService.postAuditMessageOnDelete(p.modelName, p.before); + await this.chatterMessageService.postAuditMessageOnDelete(p.modelName, p.before, false, p.userId); break; } } diff --git a/src/jobs/database/trigger-mcp-client-publisher-database.service.ts b/src/jobs/database/trigger-mcp-client-publisher-database.service.ts deleted file mode 100644 index bb2e13d9..00000000 --- a/src/jobs/database/trigger-mcp-client-publisher-database.service.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Injectable } from "@nestjs/common"; -import { QueuesModuleOptions, TriggerMcpClientOptions } from "src/interfaces"; -import { MqMessageQueueService } from "src/services/mq-message-queue.service"; -import { MqMessageService } from "src/services/mq-message.service"; -import { DatabasePublisher } from "src/services/queues/database-publisher.service"; -import triggerMcpClientQueueOptions from "./trigger-mcp-client-queue-options"; - -@Injectable() -export class TriggerMcpClientPublisherDatabase extends DatabasePublisher { - constructor( - protected readonly mqMessageService: MqMessageService, - protected readonly mqMessageQueueService: MqMessageQueueService, - ) { - super(mqMessageService, mqMessageQueueService); - } - - options(): QueuesModuleOptions { - return { - ...triggerMcpClientQueueOptions - }; - } -} \ No newline at end of file diff --git a/src/jobs/database/trigger-mcp-client-queue-options.ts b/src/jobs/database/trigger-mcp-client-queue-options.ts deleted file mode 100644 index 62dacb6d..00000000 --- a/src/jobs/database/trigger-mcp-client-queue-options.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { BrokerType } from "src/interfaces"; - -const TRIGGER_MCP_CLIENT_QUEUE_NAME = 'solid_trigger_mcp_client_queue'; - -export default { - name: TRIGGER_MCP_CLIENT_QUEUE_NAME, - type: BrokerType.Database, - queueName: TRIGGER_MCP_CLIENT_QUEUE_NAME, -}; diff --git a/src/jobs/database/trigger-mcp-client-subscriber-database.service.ts b/src/jobs/database/trigger-mcp-client-subscriber-database.service.ts deleted file mode 100644 index f3d71ef0..00000000 --- a/src/jobs/database/trigger-mcp-client-subscriber-database.service.ts +++ /dev/null @@ -1,311 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ModelMetadataService } from '../../services/model-metadata.service'; -import { SolidRegistry } from 'src/helpers/solid-registry'; -import { QueueMessage } from 'src/interfaces/mq'; -import { DashboardRepository } from 'src/repository/dashboard.repository'; -import { AiInteractionService } from 'src/services/ai-interaction.service'; -import { ModuleMetadataService } from 'src/services/module-metadata.service'; -import { PollerService } from 'src/services/poller.service'; -import { DatabaseSubscriber } from 'src/services/queues/database-subscriber.service'; -import { Not } from 'typeorm'; -import { McpResponse, QueuesModuleOptions, TriggerMcpClientOptions } from "../../interfaces"; -import { MqMessageQueueService } from '../../services/mq-message-queue.service'; -import { MqMessageService } from '../../services/mq-message.service'; -import triggerMcpClientQueueOptions from "./trigger-mcp-client-queue-options"; - -@Injectable() -export class TriggerMcpClientSubscriberDatabase extends DatabaseSubscriber { - private readonly triggerMcpClientSubscriberLogger = new Logger(TriggerMcpClientSubscriberDatabase.name); - - constructor( - readonly mqMessageService: MqMessageService, - readonly mqMessageQueueService: MqMessageQueueService, - readonly poller: PollerService, - readonly aiInteractionService: AiInteractionService, - readonly moduleMetadataService: ModuleMetadataService, - readonly modelMetadataService: ModelMetadataService, - private readonly solidRegistry: SolidRegistry, - // private readonly dashboardService: DashboardService - private readonly dashboardRepository: DashboardRepository - - ) { - super(mqMessageService, mqMessageQueueService, poller); - } - - options(): QueuesModuleOptions { - return { - ...triggerMcpClientQueueOptions - } - } - - cleanNestedResponse(aiResponse: McpResponse) { - let nestedResponse: any; - - try { - let raw = aiResponse.response; - - if (typeof raw === "string") { - raw = raw.trim(); - try { - // Try to parse as JSON - nestedResponse = JSON.parse(raw); - } catch { - // Not JSON, just keep as string - nestedResponse = raw; - } - } else if (typeof raw === "object" && raw !== null) { - // Already JSON - nestedResponse = raw; - } else { - // Fallback - nestedResponse = String(raw); - } - } catch (err) { - this.triggerMcpClientSubscriberLogger.error("Error processing AI response:", err); - nestedResponse = `Error handling response: ${err?.message || String(err)}`; - } - - return nestedResponse; - } - - async subscribe(message: QueueMessage) { - this.triggerMcpClientSubscriberLogger.debug(`Received message: ${JSON.stringify(message)}`); - - const codeGnerationOptions = message.payload; - - const aiInteraction = await this.aiInteractionService.findOne(codeGnerationOptions.aiInteractionId, { - populate: ['user'] - }); - if (!aiInteraction) { - const m = `Unable to identified the aiInteraction entry that triggered this job... using id: ${codeGnerationOptions.aiInteractionId}` - this.triggerMcpClientSubscriberLogger.log(m); - throw new Error(m); - } - - // The message contains the users prompt. - const prompt = aiInteraction.message; - - // Use this to invoke our mcp client - // TODO: try / catch ... - // Handle the rejection gracefully... - - // We create the aiInteraction entry first - const genAiInteraction = await this.aiInteractionService.create({ - userId: aiInteraction.user.id, - threadId: aiInteraction.threadId, - parentInteractionId: aiInteraction.id, - role: 'gen-ai', - message: '...', // Updated in the tool - contentType: '', // Updated in the tool - errorMessage: '', // Updated after we receive the response - modelUsed: '', // Updated after we receive the response - responseTimeMs: 0, // Updated after we receive the response - metadata: '', // Updated in the tool - isApplied: false, // Updated after we receive the response - status: 'pending' // Updated after we receive the response - }); - - const existingComputationProviders = this.solidRegistry.getComputedFieldProviders(); - - const { records: existingModules } = await this.moduleMetadataService.findMany({ - filters: { - name: { $ne: 'solid-core' } - }, - fields: ['id', 'name', 'displayName', 'description'], - limit: 1000 - }); - const { records: existingModels } = await this.modelMetadataService.findMany({ - filters: { - module: { - name: { - $ne: 'solid-core', - } - } - }, - limit: 1000, - populate: ['module'] - }); - - // Get the list of dashboards - // TODO: Ideally we should fetch dashboard like below, but used below approach to avoid below CLS issues for now. - // Cannot set the key "filter". No CLS context available, please make sure that a ClsMiddleware/Guard/Interceptor has set up the context, or wrap any calls that depend on CLS with "ClsService#run" - // const { records: existingDashboards } = await this.dashboardService.find({ - // fields - // }) - const existingDashboards = await this.dashboardRepository.find( - { - where: { - module: { - name: Not('solid-core') - } - }, - } - ); - - const existingControllerAndTheirMethods = this.solidRegistry.getControllers(); - - - - // Build markdown sections using template interpolation - const modulesSection = (existingModules ?? []) - .map(m => [ - `### ${m.displayName}`, - `- name: ${m.name}`, - `- description: ${m.description ?? ""}`, - ].join('\n')) - .join('\n\n'); - - const modelsSection = (existingModels ?? []) - .map(m => [ - `### ${m.displayName}`, - `- singularName: ${m.singularName}`, - `- description: ${m.description ?? ""}`, - `- moduleName: ${m.module.name}`, - ].join('\n')) - .join('\n\n'); - - const dashboardsSection = (existingDashboards ?? []) - .map(d => [ - `### ${d.displayName}`, - `- name: ${d.name}`, - `- description: ${d.description ?? ""}`, - ].join('\n')) - .join('\n\n'); - - const computationProvidersSection = (existingComputationProviders ?? []) - .map(m => [ - `### ${m.instance.name()}`, - `- name: ${m.instance.name()}`, - `- description: ${m.instance.help() ?? ""}`, - ].join('\n')) - .join('\n\n'); - - const controllersAndTheirMethods = (existingControllerAndTheirMethods ?? []) - .map(m => [ - `### ${m.name}`, - `- methods: ${m.methods.length ? m.methods.map(m => `- ${m}`).join('\n') : '- No methods found'}`, - ].join('\n')) - .join('\n\n'); - - - - const finalPrompt = ` -# User Prompt: -${prompt} - -# System Instructions: -- aiInteractionId: ${genAiInteraction.id} -- You will be invoking tools if needed, hence you will have to choose the applicable tools based on the tool context given to you. -- If a tool is invoked, you must return **exactly** the raw output from the tool, without any json envelopes, additional formatting, commentary, or text. -- Do not wrap the result in quotes, JSON, or markdown fences. -- Do not explain what the result means. - -# LISTS FOR REFERENCE AND VALIDATIONS - -## LIST OF EXISTING MODULES -Use the below list of modules to infer which module the user is referring to, . - -${modulesSection} - -## LIST OF EXISTING MODELS -Use the below list of models to infer which model the user is referring to. -You need to pull out the singularName for the model from the user prompt to match against the below list. - -${modelsSection} - -## LIST OF EXISTING DASHBOARDS -Use the below list of dashboards to infer which dashboard the user is referring to. - -${dashboardsSection} - -## LIST OF EXISTING COMPUTED FIELD PROVIDERS -Use the below list of computed field providers to infer which provider the user is referring to. - -${computationProvidersSection} -`.trim(); - - // ## LIST OF EXISTING CONTROLLERS AND THEIR METHODS - // Use the below list of controllers and their methods to infer whether the api call being made to a particular controller exists or not. - - // ${controllersAndTheirMethods} - - const aiResponse = await this.aiInteractionService.runMcpPrompt(finalPrompt); - this.triggerMcpClientSubscriberLogger.log(`aiResponse: `); - this.triggerMcpClientSubscriberLogger.log(JSON.stringify(aiResponse)); - - if (!aiResponse.success) { - this.triggerMcpClientSubscriberLogger.log(`Gen ai has returned with a false status code`); - - const errorsStr = aiResponse.errors?.join('\n '); - const errorTrace = aiResponse.error_trace?.join('\n'); - - // await this.aiInteractionService.create({ - // userId: aiInteraction.user.id, - // threadId: aiInteraction.threadId, - // parentInteractionId: aiInteraction.id, - // role: 'gen-ai', - // message: '-', - // contentType: aiResponse.content_type, - // errorMessage: `${errorsStr}\n\n${errorTrace}`, - // modelUsed: aiResponse.model, - // responseTimeMs: aiResponse.duration_ms, - // metadata: JSON.stringify(aiResponse, null, 2), - // isApplied: aiInteraction.isApplied, - // status: aiResponse.success ? 'succeeded' : 'failed' - // }); - - // TODO: Update the previously created genAiInteraction record with the respective error fields and save to DB - await this.aiInteractionService.update(genAiInteraction.id, { - contentType: aiResponse.content_type, - errorMessage: `${errorsStr}\n\n${errorTrace}`, - modelUsed: aiResponse.model, - responseTimeMs: aiResponse.duration_ms, - isApplied: aiInteraction.isApplied, - status: aiResponse.success ? 'succeeded' : 'failed' - }, [], true); - - // update the job entry with failure... raising an error will lead the job to be marked as failed... - throw new Error(errorsStr); - } - else { - let nestedResponse = this.cleanNestedResponse(aiResponse); - - // TODO: Update the previously created genAiInteraction record with the respective success fields and save to DB - const errorsStr = nestedResponse?.status == "error" && nestedResponse?.errors.join('\n '); - const errorTrace = nestedResponse?.status == "error" && nestedResponse?.error_trace?.join('\n'); - - // const genAiInteraction = await this.aiInteractionService.create({ - // userId: aiInteraction.user.id, - // threadId: aiInteraction.threadId, - // parentInteractionId: aiInteraction.id, - // role: 'gen-ai', - // message: nestedResponse, - // contentType: aiResponse.content_type, - // errorMessage: `${errorsStr}\n\n${errorTrace}`, - // modelUsed: aiResponse.model, - // responseTimeMs: aiResponse.duration_ms, - // metadata: JSON.stringify(aiResponse, null, 2), - // isApplied: aiInteraction.isApplied, - // status: aiResponse.success ? 'succeeded' : 'failed' - // }); - - await this.aiInteractionService.update(genAiInteraction.id, { - errorMessage: nestedResponse.status == "error" ? `${errorsStr}\n\n${errorTrace}` : "", - contentType: aiResponse.content_type, - message: JSON.stringify(nestedResponse), - modelUsed: aiResponse.model, - responseTimeMs: aiResponse.duration_ms, - metadata: JSON.stringify(aiResponse, null, 2), - isApplied: aiInteraction.isApplied, - status: aiResponse.success && nestedResponse.status == "success" ? 'succeeded' : 'failed' - }, [], true); - - // If the human interaction was with isAutoApply=true, then we can go ahead and autoApply. - if (aiInteraction.isAutoApply) { - this.aiInteractionService.applySolidAiInteraction(genAiInteraction.id); - } - } - - return aiResponse; - } -} diff --git a/src/jobs/rabbitmq/chatter-queue-subscriber.service.ts b/src/jobs/rabbitmq/chatter-queue-subscriber.service.ts index 8b1dc46c..3c14c202 100644 --- a/src/jobs/rabbitmq/chatter-queue-subscriber.service.ts +++ b/src/jobs/rabbitmq/chatter-queue-subscriber.service.ts @@ -33,7 +33,7 @@ export class ChatterQueueSubscriberRabbitmq extends RabbitMqSubscriber ({ propertyName: n })), + false, + p.userId, ); break; case 'delete': - await this.chatterMessageService.postAuditMessageOnDelete(p.modelName, p.before); + await this.chatterMessageService.postAuditMessageOnDelete(p.modelName, p.before, false, p.userId); break; } } diff --git a/src/jobs/rabbitmq/trigger-mcp-client-publisher.service.ts b/src/jobs/rabbitmq/trigger-mcp-client-publisher.service.ts deleted file mode 100644 index 350722ec..00000000 --- a/src/jobs/rabbitmq/trigger-mcp-client-publisher.service.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Injectable } from "@nestjs/common"; -import { QueuesModuleOptions, TriggerMcpClientOptions } from "../../interfaces"; -import { MqMessageQueueService } from "src/services/mq-message-queue.service"; -import { MqMessageService } from "src/services/mq-message.service"; -import { RabbitMqPublisher } from "src/services/queues/rabbitmq-publisher.service"; -import triggerMcpClientQueueOptions from "./trigger-mcp-client-queue-options"; - -@Injectable() -export class TriggerMcpClientPublisherRabbitmq extends RabbitMqPublisher { - constructor( - protected readonly mqMessageService: MqMessageService, - protected readonly mqMessageQueueService: MqMessageQueueService, - ) { - super(mqMessageService, mqMessageQueueService); - } - - options(): QueuesModuleOptions { - return { - ...triggerMcpClientQueueOptions - }; - } -} diff --git a/src/jobs/rabbitmq/trigger-mcp-client-queue-options.ts b/src/jobs/rabbitmq/trigger-mcp-client-queue-options.ts deleted file mode 100644 index b1230a32..00000000 --- a/src/jobs/rabbitmq/trigger-mcp-client-queue-options.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { BrokerType } from "../../interfaces"; - -const TRIGGER_MCP_CLIENT_QUEUE_NAME = 'solid_trigger_mcp_client_queue_rabbitmq'; - -export default { - name: TRIGGER_MCP_CLIENT_QUEUE_NAME, - type: BrokerType.RabbitMQ, - queueName: TRIGGER_MCP_CLIENT_QUEUE_NAME, -}; diff --git a/src/jobs/rabbitmq/trigger-mcp-client-subscriber.service.ts b/src/jobs/rabbitmq/trigger-mcp-client-subscriber.service.ts deleted file mode 100644 index 0cbad5fd..00000000 --- a/src/jobs/rabbitmq/trigger-mcp-client-subscriber.service.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; - -import { QueueMessage } from 'src/interfaces/mq'; -import triggerMcpClientQueueOptions from "./trigger-mcp-client-queue-options"; -import { AiInteractionService } from 'src/services/ai-interaction.service'; -import { PollerService } from 'src/services/poller.service'; -import { TriggerMcpClientOptions, QueuesModuleOptions } from '../../interfaces'; -import { MqMessageQueueService } from 'src/services/mq-message-queue.service'; -import { MqMessageService } from 'src/services/mq-message.service'; -import { RabbitMqSubscriber } from 'src/services/queues/rabbitmq-subscriber.service'; - -@Injectable() -export class TriggerMcpClientSubscriberRabbitmq extends RabbitMqSubscriber { - private readonly triggerMcpClientSubscriberLogger = new Logger(TriggerMcpClientSubscriberRabbitmq.name); - - constructor( - readonly mqMessageService: MqMessageService, - readonly mqMessageQueueService: MqMessageQueueService, - readonly poller: PollerService, - readonly aiInteractionService: AiInteractionService, - ) { - super(mqMessageService, mqMessageQueueService); - } - - options(): QueuesModuleOptions { - return { - ...triggerMcpClientQueueOptions - } - } - - async subscribe(message: QueueMessage) { - this.triggerMcpClientSubscriberLogger.debug(`Received message: ${JSON.stringify(message)}`); - - const codeGnerationOptions = message.payload; - - const aiInteraction = await this.aiInteractionService.findOne(codeGnerationOptions.aiInteractionId, { - populate: ['user'] - }); - if (!aiInteraction) { - const m = `Unable to identified the aiInteraction entry that triggered this job... using id: ${codeGnerationOptions.aiInteractionId}` - this.triggerMcpClientSubscriberLogger.log(m); - throw new Error(m); - } - - // The message contains the users prompt. - const prompt = aiInteraction.message; - - // Use this to invoke our mcp client - // TODO: try / catch ... - // Handle the rejection gracefully... - const aiResponse = await this.aiInteractionService.runMcpPrompt(prompt); - this.triggerMcpClientSubscriberLogger.log(`aiResponse: `); - this.triggerMcpClientSubscriberLogger.log(JSON.stringify(aiResponse)); - - if (!aiResponse.success) { - this.triggerMcpClientSubscriberLogger.log(`Gen ai has returned with a false status code`); - - const errorsStr = aiResponse.errors.join('; '); - - await this.aiInteractionService.create({ - userId: aiInteraction.user.id, - threadId: aiInteraction.threadId, - parentInteractionId: aiInteraction.id, - role: 'gen-ai', - message: '-', - contentType: aiResponse.content_type, - errorMessage: errorsStr, - modelUsed: aiResponse.model, - responseTimeMs: aiResponse.duration_ms, - metadata: JSON.stringify(aiResponse), - isApplied: aiInteraction.isApplied, - status: aiResponse.success ? 'succeeded' : 'failed' - }); - - // update the job entry with failure... raising an error will lead the job to be marked as failed... - throw new Error(errorsStr); - } - else { - let nestedResponse = aiResponse.response.trim(); - - const genAiInteraction = await this.aiInteractionService.create({ - userId: aiInteraction.user.id, - threadId: aiInteraction.threadId, - parentInteractionId: aiInteraction.id, - role: 'gen-ai', - message: nestedResponse, - contentType: aiResponse.content_type, - errorMessage: '', - modelUsed: aiResponse.model, - responseTimeMs: aiResponse.duration_ms, - metadata: JSON.stringify(aiResponse), - isApplied: aiInteraction.isApplied, - status: aiResponse.success ? 'succeeded' : 'failed' - }); - - // If the human interaction was with isAutoApply=true, then we can go ahead and autoApply. - if (aiInteraction.isAutoApply) { - this.aiInteractionService.applySolidAiInteraction(genAiInteraction.id); - } - } - - return aiResponse; - } -} diff --git a/src/jobs/redis/chatter-queue-subscriber-redis.service.ts b/src/jobs/redis/chatter-queue-subscriber-redis.service.ts index f1748880..15fa0856 100644 --- a/src/jobs/redis/chatter-queue-subscriber-redis.service.ts +++ b/src/jobs/redis/chatter-queue-subscriber-redis.service.ts @@ -33,13 +33,20 @@ export class ChatterQueueSubscriberRedis extends RedisSubscriber { switch (p.eventType) { case 'insert': - await this.chatterMessageService.postAuditMessageOnInsert(p.after, { name: p.modelName } as any); + await this.chatterMessageService.postAuditMessageOnInsert(p.after, p.modelName, false, p.userId); break; case 'update': - await this.chatterMessageService.postAuditMessageOnUpdate(p.after, { name: p.modelName } as any, p.before, (p.updatedColumnNames || []).map(n => ({ propertyName: n }))); + await this.chatterMessageService.postAuditMessageOnUpdate( + p.after, + p.modelName, + p.before, + (p.updatedColumnNames || []).map(n => ({ propertyName: n })), + false, + p.userId, + ); break; case 'delete': - await this.chatterMessageService.postAuditMessageOnDelete(p.before, { name: p.modelName } as any, p.before); + await this.chatterMessageService.postAuditMessageOnDelete(p.modelName, p.before, false, p.userId); break; } } diff --git a/src/jobs/redis/trigger-mcp-client-publisher-redis.service.ts b/src/jobs/redis/trigger-mcp-client-publisher-redis.service.ts deleted file mode 100644 index 6857491b..00000000 --- a/src/jobs/redis/trigger-mcp-client-publisher-redis.service.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { RedisPublisher } from 'src/services/queues/redis-publisher.service'; -import triggerMcpClientQueueConfig from './trigger-mcp-client-queue-options-redis'; -import { MqMessageQueueService } from '../../services/mq-message-queue.service'; -import { MqMessageService } from '../../services/mq-message.service'; -import { TriggerMcpClientOptions, QueuesModuleOptions } from "../../interfaces"; - -@Injectable() -export class TriggerMcpClientPublisherRedis extends RedisPublisher { - constructor( - protected readonly mqMessageService: MqMessageService, - protected readonly mqMessageQueueService: MqMessageQueueService, - ) { - super(mqMessageService, mqMessageQueueService); - } - - options(): QueuesModuleOptions { - return { - ...triggerMcpClientQueueConfig - } - } -} diff --git a/src/jobs/redis/trigger-mcp-client-queue-options-redis.ts b/src/jobs/redis/trigger-mcp-client-queue-options-redis.ts deleted file mode 100644 index fc3de41f..00000000 --- a/src/jobs/redis/trigger-mcp-client-queue-options-redis.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { BrokerType } from "../../interfaces"; - -const QUEUE_NAME = 'solid_trigger_mcp_client_queue_redis'; - -export default { - name: QUEUE_NAME, - type: BrokerType.Redis, - queueName: QUEUE_NAME, -}; diff --git a/src/jobs/redis/trigger-mcp-client-subscriber-redis.service.ts b/src/jobs/redis/trigger-mcp-client-subscriber-redis.service.ts deleted file mode 100644 index b0b1c3c5..00000000 --- a/src/jobs/redis/trigger-mcp-client-subscriber-redis.service.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; - -import { RedisSubscriber } from 'src/services/queues/redis-subscriber.service'; -import { QueueMessage } from 'src/interfaces/mq'; -import triggerMcpClientQueueConfig from './trigger-mcp-client-queue-options-redis'; -import { MqMessageService } from '../../services/mq-message.service'; -import { MqMessageQueueService } from '../../services/mq-message-queue.service'; -import { TriggerMcpClientOptions, QueuesModuleOptions } from "../../interfaces"; -import { AiInteractionService } from '../../services/ai-interaction.service'; -import { PollerService } from '../../services/poller.service'; - -@Injectable() -export class TriggerMcpClientSubscriberRedis extends RedisSubscriber { - private readonly triggerMcpClientSubscriberLogger = new Logger(TriggerMcpClientSubscriberRedis.name); - - constructor( - readonly mqMessageService: MqMessageService, - readonly mqMessageQueueService: MqMessageQueueService, - readonly poller: PollerService, - readonly aiInteractionService: AiInteractionService, - ) { - super(mqMessageService, mqMessageQueueService); - } - - options(): QueuesModuleOptions { - return { - ...triggerMcpClientQueueConfig - } - } - - async subscribe(message: QueueMessage) { - this.triggerMcpClientSubscriberLogger.debug(`Received message: ${JSON.stringify(message)}`); - - const codeGnerationOptions = message.payload; - - const aiInteraction = await this.aiInteractionService.findOne(codeGnerationOptions.aiInteractionId, { - populate: ['user'] - }); - if (!aiInteraction) { - const m = `Unable to identified the aiInteraction entry that triggered this job... using id: ${codeGnerationOptions.aiInteractionId}` - this.triggerMcpClientSubscriberLogger.log(m); - throw new Error(m); - } - - const prompt = aiInteraction.message; - - const aiResponse = await this.aiInteractionService.runMcpPrompt(prompt); - this.triggerMcpClientSubscriberLogger.log(`aiResponse: `); - this.triggerMcpClientSubscriberLogger.log(JSON.stringify(aiResponse)); - - if (!aiResponse.success) { - this.triggerMcpClientSubscriberLogger.log(`Gen ai has returned with a false status code`); - - const errorsStr = aiResponse.errors.join('; '); - - await this.aiInteractionService.create({ - userId: aiInteraction.user.id, - threadId: aiInteraction.threadId, - parentInteractionId: aiInteraction.id, - role: 'gen-ai', - message: '-', - contentType: aiResponse.content_type, - errorMessage: errorsStr, - modelUsed: aiResponse.model, - responseTimeMs: aiResponse.duration_ms, - metadata: JSON.stringify(aiResponse), - isApplied: aiInteraction.isApplied, - status: aiResponse.success ? 'succeeded' : 'failed' - }); - - throw new Error(errorsStr); - } - else { - let nestedResponse = aiResponse.response.trim(); - - const genAiInteraction = await this.aiInteractionService.create({ - userId: aiInteraction.user.id, - threadId: aiInteraction.threadId, - parentInteractionId: aiInteraction.id, - role: 'gen-ai', - message: nestedResponse, - contentType: aiResponse.content_type, - errorMessage: '', - modelUsed: aiResponse.model, - responseTimeMs: aiResponse.duration_ms, - metadata: JSON.stringify(aiResponse), - isApplied: aiInteraction.isApplied, - status: aiResponse.success ? 'succeeded' : 'failed' - }); - - if (aiInteraction.isAutoApply) { - this.aiInteractionService.applySolidAiInteraction(genAiInteraction.id); - } - } - - return aiResponse; - } -} diff --git a/src/listeners/user-registration.listener.ts b/src/listeners/user-registration.listener.ts index 91703e9f..9915aa83 100755 --- a/src/listeners/user-registration.listener.ts +++ b/src/listeners/user-registration.listener.ts @@ -1,14 +1,57 @@ - import { User } from "../entities/user.entity"; import { OnEvent } from "@nestjs/event-emitter"; import { Injectable, Logger } from "@nestjs/common"; import { EventDetails, EventType } from "../interfaces"; +import { WhatsAppFactory } from "src/factories/whatsapp.factory"; @Injectable() export class UserRegistrationListener { - private logger = new Logger(UserRegistrationListener.name); - @OnEvent(EventType.USER_REGISTERED) - handleUserRegistration(event: EventDetails) { - this.logger.log(`User registered with details: ${JSON.stringify(event.payload)}`); + private readonly logger = new Logger(UserRegistrationListener.name); + + constructor(private readonly whatsAppFactory: WhatsAppFactory) {} + + @OnEvent(EventType.USER_REGISTERED) + async handleUserRegistration(event: EventDetails) { + this.logger.log(`User registered with details: ${JSON.stringify(event.payload)}`); + + const notifyTo = process.env.WHATSAPP_EVENT_NOTIFY_TO; + if (!notifyTo) { + this.logger.debug("WHATSAPP_EVENT_NOTIFY_TO not set. Skipping registration WhatsApp notification."); + return; + } + + try { + const whatsappService = this.whatsAppFactory.getWhatsappService(); + const username = event.payload?.username || "User"; + const userId = event.payload?.id || "N/A"; + + await whatsappService.sendWhatsAppMessage( + notifyTo, + "registration_event", + { + payload: { + channel: "whatsapp", + source: process.env.COMMON_GUPSHUP_WHATSAPP_SOURCE, + destination: notifyTo, + "src.name": process.env.COMMON_GUPSHUP_APP_NAME || "solidx", + message: { + type: "text", + text: `New user registered: ${username} (id: ${userId})`, + }, + }, + }, + ); + + this.logger.log(`Sent registration WhatsApp notification to ${notifyTo}`); + } catch (error: any) { + const status = error?.response?.status; + const responseData = error?.response?.data; + const errorMessage = error?.message; + const stack = error?.stack; + + this.logger.error( + `Failed to send registration WhatsApp notification to ${notifyTo}. status=${status ?? "unknown"}, response=${typeof responseData === "object" ? JSON.stringify(responseData) : responseData}, message=${errorMessage}, stack=${stack}`, + ); } -} \ No newline at end of file + } +} diff --git a/src/mappers/dashboard-mapper.ts b/src/mappers/dashboard-mapper.ts deleted file mode 100644 index 5372e7fb..00000000 --- a/src/mappers/dashboard-mapper.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { Injectable } from "@nestjs/common"; -import { SelectionDynamicSourceType } from "src/dtos/create-dashboard-variable.dto"; -import { Dashboard } from "src/entities/dashboard.entity"; - -@Injectable() -export class DashboardMapper { - toDto(dashboard: Dashboard): any { - return { - name: dashboard.name, - layoutJson: this.safeParseJSON(dashboard.layoutJson, {}), - moduleUserKey: dashboard.module?.name ?? null, // safer fallback - - dashboardVariables: (dashboard.dashboardVariables || []).map(variable => ({ - variableName: variable.variableName, - variableType: variable.variableType, - selectionStaticValues: this.safeParseJSON(variable.selectionStaticValues, []), - selectionDynamicSourceType: variable.selectionDynamicSourceType as SelectionDynamicSourceType, - selectionDynamicSQL: variable.selectionDynamicSQL ?? null, - selectionDynamicProviderName: variable.selectionDynamicProviderName ?? null, - defaultValue: this.safeParseJSON(variable.defaultValue, []), - defaultOperator: variable.defaultOperator ?? null, - })), - questions: (dashboard.questions || []).map(question => ({ - name: question.name, - sourceType: question.sourceType, - visualisedAs: question.visualisedAs, - providerName: question.providerName ?? null, - chartOptions: question.chartOptions ?? null, - labelSql: question.labelSql ?? null, - kpiSql: question.kpiSql ?? null, - externalId: question.externalId, - questionSqlDatasetConfigs: (question.questionSqlDatasetConfigs || []).map(config => ({ - sql: config.sql, - datasetName: config.datasetName, - datasetDisplayName: config.datasetDisplayName, // 🔧 fixed typo: `daataSetDisplayName` - description: config.description, - labelColumnName: config.labelColumnName, - valueColumnName: config.valueColumnName, - options: this.safeParseJSON(config.options, {}), - externalId: config.externalId - })) - })) - }; - } - - private safeParseJSON(json: string | null | undefined, fallback: any): any { - try { - return json ? JSON.parse(json) : fallback; - } catch { - return fallback; - } - } -} \ No newline at end of file diff --git a/src/passport-strategies/microsoft-active-directory-oauth.strategy.ts b/src/passport-strategies/microsoft-active-directory-oauth.strategy.ts new file mode 100644 index 00000000..239e43bc --- /dev/null +++ b/src/passport-strategies/microsoft-active-directory-oauth.strategy.ts @@ -0,0 +1,63 @@ +import { Injectable, Logger, UnauthorizedException } from '@nestjs/common'; +import { AuthGuard, PassportStrategy } from '@nestjs/passport'; +import { Strategy } from 'passport-microsoft'; +import { DEFAULT_MICROSOFT_ACTIVE_DIRECTORY_OAUTH_TENANT, MICROSOFT_ACTIVE_DIRECTORY_OAUTH_SCOPES, MicrosoftActiveDirectoryAuthConfiguration, getMicrosoftActiveDirectoryOAuthDisplayName, getMicrosoftActiveDirectoryOAuthEmail, getMicrosoftActiveDirectoryOAuthPicture, getMicrosoftActiveDirectoryOAuthProfileId, isMicrosoftActiveDirectoryOAuthConfigured } from 'src/helpers/microsoft-active-directory-oauth.helper'; +import { v4 as uuid } from 'uuid'; +import { UserService } from '../services/user.service'; + +const DUMMY_CLIENT_ID = 'DUMMY_CLIENT_ID'; +const DUMMY_CLIENT_SECRET = 'DUMMY_CLIENT_SECRET'; +const DUMMY_CALLBACK_URL = 'DUMMY_CALLBACK_URL'; + +@Injectable() +export class MicrosoftActiveDirectoryOauthGuard extends AuthGuard('microsoft-active-directory') { } + + +@Injectable() +export class MicrosoftActiveDirectoryOAuthStrategy extends PassportStrategy(Strategy, 'microsoft-active-directory') { + private readonly logger = new Logger(MicrosoftActiveDirectoryOAuthStrategy.name); + constructor(private readonly userService: UserService) { + // TODO: Have added default dummy values for the configuration, since the configuration is not mandatory. + // Perhaps a cleaner way needs to be figured out + const clientID = process.env.IAM_MICROSOFT_ACTIVE_DIRECTORY_OAUTH_CLIENT_ID || DUMMY_CLIENT_ID; + const clientSecret = process.env.IAM_MICROSOFT_ACTIVE_DIRECTORY_OAUTH_CLIENT_SECRET || DUMMY_CLIENT_SECRET; + const tenant = process.env.IAM_MICROSOFT_ACTIVE_DIRECTORY_OAUTH_TENANT_ID || DEFAULT_MICROSOFT_ACTIVE_DIRECTORY_OAUTH_TENANT; + const callbackURL = process.env.IAM_MICROSOFT_ACTIVE_DIRECTORY_OAUTH_CALLBACK_URL || DUMMY_CALLBACK_URL; + const redirectURL = process.env.IAM_MICROSOFT_ACTIVE_DIRECTORY_OAUTH_REDIRECT_URL; + + super({ clientID, clientSecret, callbackURL, tenant, scope: MICROSOFT_ACTIVE_DIRECTORY_OAUTH_SCOPES, addUPNAsEmail: true }); + + const microsoftActiveDirectoryOauth: MicrosoftActiveDirectoryAuthConfiguration = { clientID, clientSecret, tenant, callbackURL, redirectURL } + if (!isMicrosoftActiveDirectoryOAuthConfigured(microsoftActiveDirectoryOauth)) { + this.logger.debug('Microsoft Active Directory OAuth strategy is not configured'); + } + } + + async validate(_accessToken: string, _refreshToken: string, profile: any, done: any): Promise { + const providerId = getMicrosoftActiveDirectoryOAuthProfileId(profile); + + if (!providerId) { + return done(new UnauthorizedException('Microsoft Active Directory OAuth profile is missing an id'), false); + } + + // generate a unique access code. + const loginAccessCode: string = uuid(); + + const user = { + provider: 'microsoftActiveDirectory', + providerId, + email: getMicrosoftActiveDirectoryOAuthEmail(profile), + name: getMicrosoftActiveDirectoryOAuthDisplayName(profile) || getMicrosoftActiveDirectoryOAuthEmail(profile) || 'Microsoft Active Directory User', + picture: getMicrosoftActiveDirectoryOAuthPicture(profile), + accessCode: loginAccessCode, + }; + + // store the access code and the access token in the database. + // while doing this we also check if the user exists in the database if not we create one. + // if exists then we update the user and store the specified access code & token. + await this.userService.resolveUserOnOauthMicrosoftActiveDirectory({ ...user, accessToken: _accessToken, refreshToken: null }); + + done(null, user); + } + +} diff --git a/src/repositories/dashboard-user-layout.repository.ts b/src/repositories/dashboard-user-layout.repository.ts new file mode 100644 index 00000000..bc453466 --- /dev/null +++ b/src/repositories/dashboard-user-layout.repository.ts @@ -0,0 +1,19 @@ +import { Injectable } from '@nestjs/common'; +import { SecurityRuleRepository } from 'src/repository/security-rule.repository'; +import { SolidBaseRepository } from 'src/repository/solid-base.repository' ; +import { RequestContextService } from 'src/services/request-context.service'; +import { DataSource } from 'typeorm'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DashboardUserLayout } from '../entities/dashboard-user-layout.entity'; + +@Injectable() +export class DashboardUserLayoutRepository extends SolidBaseRepository { + constructor( + @InjectDataSource("default") + readonly dataSource: DataSource, + readonly requestContextService: RequestContextService, + readonly securityRuleRepository: SecurityRuleRepository, + ) { + super(DashboardUserLayout, dataSource, requestContextService, securityRuleRepository); + } +} \ No newline at end of file diff --git a/src/repository/ai-interaction.repository.ts b/src/repository/ai-interaction.repository.ts deleted file mode 100644 index ae8c224a..00000000 --- a/src/repository/ai-interaction.repository.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { AiInteraction } from 'src/entities/ai-interaction.entity'; -import { RequestContextService } from 'src/services/request-context.service'; -import { DataSource } from 'typeorm'; -import { SecurityRuleRepository } from './security-rule.repository'; -import { SolidBaseRepository } from './solid-base.repository'; - -@Injectable() -export class AiInteractionRepository extends SolidBaseRepository { - constructor( - readonly dataSource: DataSource, - readonly requestContextService: RequestContextService, - readonly securityRuleRepository: SecurityRuleRepository, - ) { - super(AiInteraction, dataSource, requestContextService, securityRuleRepository); - } -} \ No newline at end of file diff --git a/src/repository/dashboard-layout.repository.ts b/src/repository/dashboard-layout.repository.ts deleted file mode 100644 index 22eab3db..00000000 --- a/src/repository/dashboard-layout.repository.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { RequestContextService } from 'src/services/request-context.service'; -import { DataSource } from 'typeorm'; -import { SecurityRuleRepository } from './security-rule.repository'; -import { SolidBaseRepository } from './solid-base.repository'; -import { DashboardLayout } from 'src/entities/dashboard-layout.entity'; - -@Injectable() -export class DashboardLayoutRepository extends SolidBaseRepository { - constructor( - readonly dataSource: DataSource, - readonly requestContextService: RequestContextService, - readonly securityRuleRepository: SecurityRuleRepository, - ) { - super(DashboardLayout, dataSource, requestContextService, securityRuleRepository); - } -} \ No newline at end of file diff --git a/src/repository/dashboard-question-sql-dataset-config.repository.ts b/src/repository/dashboard-question-sql-dataset-config.repository.ts deleted file mode 100644 index a2a7514b..00000000 --- a/src/repository/dashboard-question-sql-dataset-config.repository.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { DashboardQuestionSqlDatasetConfig } from '../entities/dashboard-question-sql-dataset-config.entity'; -import { RequestContextService } from 'src/services/request-context.service'; -import { DataSource } from 'typeorm'; -import { SecurityRuleRepository } from './security-rule.repository'; -import { SolidBaseRepository } from './solid-base.repository'; - -@Injectable() -export class DashboardQuestionSqlDatasetConfigRepository extends SolidBaseRepository { - constructor( - readonly dataSource: DataSource, - readonly requestContextService: RequestContextService, - readonly securityRuleRepository: SecurityRuleRepository, - ) { - super(DashboardQuestionSqlDatasetConfig, dataSource, requestContextService, securityRuleRepository); - } -} \ No newline at end of file diff --git a/src/repository/dashboard-question.repository.ts b/src/repository/dashboard-question.repository.ts deleted file mode 100644 index 998e28e7..00000000 --- a/src/repository/dashboard-question.repository.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { DashboardQuestion } from '../entities/dashboard-question.entity'; -import { RequestContextService } from 'src/services/request-context.service'; -import { DataSource } from 'typeorm'; -import { SecurityRuleRepository } from './security-rule.repository'; -import { SolidBaseRepository } from './solid-base.repository'; - -@Injectable() -export class DashboardQuestionRepository extends SolidBaseRepository { - constructor( - readonly dataSource: DataSource, - readonly requestContextService: RequestContextService, - readonly securityRuleRepository: SecurityRuleRepository, - ) { - super(DashboardQuestion, dataSource, requestContextService, securityRuleRepository); - } -} \ No newline at end of file diff --git a/src/repository/dashboard-variable.repository.ts b/src/repository/dashboard-variable.repository.ts deleted file mode 100644 index f4416f4f..00000000 --- a/src/repository/dashboard-variable.repository.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { DashboardVariable } from '../entities/dashboard-variable.entity'; -import { RequestContextService } from 'src/services/request-context.service'; -import { DataSource } from 'typeorm'; -import { SecurityRuleRepository } from './security-rule.repository'; -import { SolidBaseRepository } from './solid-base.repository'; - -@Injectable() -export class DashboardVariableRepository extends SolidBaseRepository { - constructor( - readonly dataSource: DataSource, - readonly requestContextService: RequestContextService, - readonly securityRuleRepository: SecurityRuleRepository, - ) { - super(DashboardVariable, dataSource, requestContextService, securityRuleRepository); - } -} \ No newline at end of file diff --git a/src/repository/dashboard.repository.ts b/src/repository/dashboard.repository.ts deleted file mode 100644 index f731b3eb..00000000 --- a/src/repository/dashboard.repository.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { Injectable } from "@nestjs/common"; -import { Dashboard } from "src/entities/dashboard.entity"; -import { ModuleMetadata } from "src/entities/module-metadata.entity"; -import { RequestContextService } from "src/services/request-context.service"; -import { DataSource } from "typeorm"; -import { SecurityRuleRepository } from "./security-rule.repository"; -import { SolidBaseRepository } from "./solid-base.repository"; - -@Injectable() -export class DashboardRepository extends SolidBaseRepository { - constructor( - readonly dataSource: DataSource, - readonly requestContextService: RequestContextService, - readonly securityRuleRepository: SecurityRuleRepository, - ) { - super(Dashboard, dataSource, requestContextService, securityRuleRepository); - } - - - async upsertWithDto(createDto: any) { - const moduleMetadataRepository = this.dataSource.getRepository(ModuleMetadata); - const module = await moduleMetadataRepository.findOneBy({ name: createDto.moduleUserKey }); - - if (!module) throw new Error(`Module with key ${createDto.moduleUserKey} not found`); - - const existingDashboard = await this.findOne({ - where: { name: createDto.name }, - relations: ['dashboardVariables', 'questions', 'questions.questionSqlDatasetConfigs'], - }); - - if (existingDashboard) { - // Update basic fields - existingDashboard.layoutJson = JSON.stringify(createDto.layoutJson ?? {}); - existingDashboard.module = module; - - // Upsert dashboard variables - existingDashboard.dashboardVariables = createDto.dashboardVariables?.map(variable => { - const existingVar = existingDashboard.dashboardVariables.find(v => v.variableName === variable.variableName); - if (existingVar) { - return Object.assign(existingVar, { - ...variable, - selectionStaticValues: JSON.stringify(variable.selectionStaticValues ?? []), - defaultValue: JSON.stringify(variable.defaultValue ?? []), - }); - } - return { - ...variable, - selectionStaticValues: JSON.stringify(variable.selectionStaticValues ?? []), - defaultValue: JSON.stringify(variable.defaultValue ?? []), - }; - }) ?? []; - - // Upsert questions and their configs - existingDashboard.questions = createDto.questions?.map(question => { - const existingQuestion = existingDashboard.questions.find(q => q.name === question.name); - const questionData: any = { - ...question, - questionSqlDatasetConfigs: question.questionSqlDatasetConfigs?.map(cfg => { - const existingCfg = existingQuestion?.questionSqlDatasetConfigs.find(c => c.datasetName === cfg.datasetName); - if (existingCfg) { - return Object.assign(existingCfg, { - ...cfg, - options: JSON.stringify(cfg.options ?? {}), - }); - } - return { - ...cfg, - options: JSON.stringify(cfg.options ?? {}), - }; - }) ?? [], - }; - - return existingQuestion ? Object.assign(existingQuestion, questionData) : questionData; - }) ?? []; - - return this.save(existingDashboard); - } - - // Else: new dashboard - const dashboardData = { - ...createDto, - module, - layoutJson: JSON.stringify(createDto.layoutJson ?? {}), - dashboardVariables: createDto.dashboardVariables?.map(variable => ({ - ...variable, - selectionStaticValues: JSON.stringify(variable.selectionStaticValues ?? []), - defaultValue: JSON.stringify(variable.defaultValue ?? []), - })), - questions: createDto.questions?.map(question => ({ - ...question, - questionSqlDatasetConfigs: question.questionSqlDatasetConfigs?.map(cfg => ({ - ...cfg, - options: JSON.stringify(cfg.options ?? {}), - })), - })), - }; - - const newDashboard = this.create(dashboardData); - return this.save(newDashboard); - } -} \ No newline at end of file diff --git a/src/repository/mcp-audit-log.repository.ts b/src/repository/mcp-audit-log.repository.ts new file mode 100644 index 00000000..e106587f --- /dev/null +++ b/src/repository/mcp-audit-log.repository.ts @@ -0,0 +1,17 @@ +import { Injectable } from '@nestjs/common'; +import { McpAuditLog } from 'src/entities/mcp-audit-log.entity'; +import { RequestContextService } from 'src/services/request-context.service'; +import { DataSource } from 'typeorm'; +import { SecurityRuleRepository } from './security-rule.repository'; +import { SolidBaseRepository } from './solid-base.repository'; + +@Injectable() +export class McpAuditLogRepository extends SolidBaseRepository { + constructor( + readonly dataSource: DataSource, + readonly requestContextService: RequestContextService, + readonly securityRuleRepository: SecurityRuleRepository, + ) { + super(McpAuditLog, dataSource, requestContextService, securityRuleRepository); + } +} diff --git a/src/repository/security-rule.repository.ts b/src/repository/security-rule.repository.ts index e6ae3dfd..6fe45711 100644 --- a/src/repository/security-rule.repository.ts +++ b/src/repository/security-rule.repository.ts @@ -56,7 +56,7 @@ export class SecurityRuleRepository extends SolidBaseRepository { evaluatedRules.push(evaluatedRule); - } catch (error) { + } catch (error: any) { this.logger.error(`Error parsing security rule: ${rule.securityRuleConfig}`, error); this.logger.error(error.stack); throw error; diff --git a/src/seeders/module-metadata-seeder.service.ts b/src/seeders/module-metadata-seeder.service.ts index 1ebc5b09..6b1cbdbc 100755 --- a/src/seeders/module-metadata-seeder.service.ts +++ b/src/seeders/module-metadata-seeder.service.ts @@ -3,12 +3,10 @@ import * as fs from 'fs'; import * as path from 'path'; import { v4 as uuidv4 } from 'uuid'; -import { CreateDashboardDto } from 'src/dtos/create-dashboard.dto'; import { CreateEmailTemplateDto } from 'src/dtos/create-email-template.dto'; import { CreateListOfValuesDto } from 'src/dtos/create-list-of-values.dto'; import { CreateSecurityRuleDto } from 'src/dtos/create-security-rule.dto'; import { CreateSmsTemplateDto } from 'src/dtos/create-sms-template.dto'; -import { DashboardRepository } from 'src/repository/dashboard.repository'; import { SecurityRuleRepository } from 'src/repository/security-rule.repository'; import { AuthenticationService } from 'src/services/authentication.service'; import { EmailTemplateService } from 'src/services/email-template.service'; @@ -52,7 +50,6 @@ import { SavedFilters } from 'src/entities/saved-filters.entity'; import { ScheduledJob } from 'src/entities/scheduled-job.entity'; import { SecurityRule } from 'src/entities/security-rule.entity'; import { ListOfValues } from 'src/entities/list-of-values.entity'; -import { Dashboard } from 'src/entities/dashboard.entity'; import { FieldMetadata } from 'src/entities/field-metadata.entity'; import { ModelMetadata } from 'src/entities/model-metadata.entity'; import { PermissionMetadata } from 'src/entities/permission-metadata.entity'; @@ -88,7 +85,6 @@ export class ModuleMetadataSeederService { private readonly settingsRepo: SettingRepository, readonly securityRuleRepo: SecurityRuleRepository, readonly systemFieldsSeederService: SystemFieldsSeederService, - readonly dashboardRepo: DashboardRepository, readonly scheduledJobRepository: ScheduledJobRepository, readonly savedFiltersRepo: SavedFiltersRepository, readonly dataSource: DataSource, @@ -99,14 +95,19 @@ export class ModuleMetadataSeederService { let currentModule = 'global'; let currentStep = 'bootstrap'; let modulesToSeed: string[] | null = null; + const shouldSeedGlobalMetadata = conf?.seedGlobalMetadata !== false; try { this.enablePruning = Boolean(conf?.pruneMetadata); console.log(this.enablePruning ? '▶ Pruning enabled: metadata not present in JSON will be removed.' : '▶ Pruning disabled: existing metadata will be kept.'); // Global seeding steps i.e across all modules - currentStep = 'seedGlobalMetadata'; - await this.seedGlobalMetadata(); + if (shouldSeedGlobalMetadata) { + currentStep = 'seedGlobalMetadata'; + await this.seedGlobalMetadata(); + } else { + this.logger.log(`Skipping global metadata seeding.`); + } // Module specific seeding steps. // Get all the module metadata files which needs to be seeded. @@ -143,6 +144,11 @@ export class ModuleMetadataSeederService { const moduleMetadata: CreateModuleMetadataDto = overallMetadata.moduleMetadata; currentModule = moduleMetadata?.name ?? 'unknown'; + if (!moduleMetadata?.name) { + this.logger.warn(`Skipping seed metadata file because moduleMetadata.name is missing.`); + continue; + } + console.log(`▶ Seeding Metadata for Module: ${moduleMetadata.name}`); this.logger.log(`Seeding Metadata for Module: ${moduleMetadata.name}`); @@ -207,11 +213,6 @@ export class ModuleMetadataSeederService { const lovCounts = await this.seedListOfValues(moduleMetadata, overallMetadata); console.log(`${this.formatSeedResult(moduleMetadata.name, 'List Of Values', lovCounts)}`); - currentStep = 'seedDashboards'; - this.logger.log(`Seeding Dashboards`); - const dashboardCounts = await this.seedDashboards(moduleMetadata, overallMetadata); - console.log(`${this.formatSeedResult(moduleMetadata.name, 'Dashboards', dashboardCounts)}`); - currentStep = 'seedScheduledJobs'; this.logger.log(`Seeding Scheduled Jobs`); const scheduledJobCounts = await this.seedScheduledJobs(moduleMetadata, overallMetadata); @@ -238,7 +239,7 @@ export class ModuleMetadataSeederService { //FIXME: Handle displaying the created users credentials in a better way. // this.logger.log(`Newly created username is: ${usersDetail?.length > 0 ? usersDetail[0]?.username : ''} and password is ${usersDetail?.length > 0 ? usersDetail[0]?.password : ''}`); - } catch (error) { + } catch (error: any) { this.logSeedFailureForCli(error, { moduleName: currentModule, step: currentStep, @@ -251,42 +252,33 @@ export class ModuleMetadataSeederService { private async seedScheduledJobs(moduleMetadata: CreateModuleMetadataDto, overallMetadata: any): Promise<{ pruned: number; upserted: number }> { this.logger.debug(`[Start] Processing scheduled jobs for ${moduleMetadata.name}`); - const scheduledJobs: CreateScheduledJobDto[] = overallMetadata.scheduledJobs; + const scheduledJobs = this.getSeedArray(overallMetadata?.scheduledJobs); const pruned = this.enablePruning ? await this.pruneScheduledJobs(scheduledJobs, moduleMetadata.name) : 0; - if (scheduledJobs?.length > 0) { + if (scheduledJobs.length > 0) { await this.handleSeedScheduledJobs(scheduledJobs); } this.logger.debug(`[End] Processing scheduled jobs for ${moduleMetadata.name}`); - return { pruned, upserted: scheduledJobs?.length ?? 0 }; + return { pruned, upserted: scheduledJobs.length }; } private async seedSavedFilters(moduleMetadata: CreateModuleMetadataDto, overallMetadata: any): Promise<{ pruned: number; upserted: number }> { this.logger.debug(`[Start] Processing saved filters for ${moduleMetadata.name}`); - const savedFilters: CreateSavedFiltersDto[] = overallMetadata.savedFilters; + const savedFilters = this.getSeedArray(overallMetadata?.savedFilters); const pruned = this.enablePruning ? await this.pruneSavedFilters(savedFilters, moduleMetadata.name) : 0; - if (savedFilters?.length > 0) { + if (savedFilters.length > 0) { await this.handleSeedSavedFilters(savedFilters); } this.logger.debug(`[End] Processing saved filters for ${moduleMetadata.name}`); - return { pruned, upserted: savedFilters?.length ?? 0 }; - } - - private async seedDashboards(moduleMetadata: CreateModuleMetadataDto, overallMetadata: any): Promise<{ pruned: number; upserted: number }> { - this.logger.debug(`[Start] Processing dashboards for ${moduleMetadata.name}`); - const dashboards: CreateDashboardDto[] = overallMetadata.dashboards; - const pruned = this.enablePruning ? await this.pruneDashboards(dashboards, moduleMetadata.name) : 0; - await this.handleSeedDashboards(dashboards); - this.logger.debug(`[End] Processing dashboards for ${moduleMetadata.name}`); - return { pruned, upserted: dashboards?.length ?? 0 }; + return { pruned, upserted: savedFilters.length }; } private async seedListOfValues(moduleMetadata: CreateModuleMetadataDto, overallMetadata: any): Promise<{ pruned: number; upserted: number }> { this.logger.debug(`[Start] Processing List Of Values for ${moduleMetadata.name}`); - const listOfValues: CreateListOfValuesDto[] = overallMetadata.listOfValues; + const listOfValues = this.getSeedArray(overallMetadata?.listOfValues); const pruned = this.enablePruning ? await this.pruneListOfValues(listOfValues, moduleMetadata.name) : 0; await this.handleSeedListOfValues(listOfValues); this.logger.debug(`[End] Processing List Of Values for ${moduleMetadata.name}`); - return { pruned, upserted: listOfValues?.length ?? 0 }; + return { pruned, upserted: listOfValues.length }; } private async setupDefaultRolesWithPermissions() { @@ -304,11 +296,11 @@ export class ModuleMetadataSeederService { private async seedSecurityRules(overallMetadata: any): Promise<{ pruned: number; upserted: number }> { this.logger.debug(`[Start] Processing security rules`); - const securityRules: CreateSecurityRuleDto[] = overallMetadata.securityRules; + const securityRules = this.getSeedArray(overallMetadata?.securityRules); const pruned = this.enablePruning ? await this.pruneSecurityRules(securityRules, overallMetadata?.moduleMetadata?.name) : 0; await this.handleSeedSecurityRules(securityRules); this.logger.debug(`[End] Processing security rules`); - return { pruned, upserted: securityRules?.length ?? 0 }; + return { pruned, upserted: securityRules.length }; } // Ok @@ -320,75 +312,85 @@ export class ModuleMetadataSeederService { private async seedSmsTemplates(overallMetadata: any, moduleName: string): Promise<{ pruned: number; upserted: number }> { this.logger.debug(`[Start] Processing sms templates`); - const smsTemplates: CreateSmsTemplateDto[] = overallMetadata.smsTemplates; + const smsTemplates = this.getSeedArray(overallMetadata?.smsTemplates); await this.handleSeedSmsTemplates(smsTemplates, moduleName); this.logger.debug(`[End] Processing sms templates`); - return { pruned: 0, upserted: smsTemplates?.length ?? 0 }; + return { pruned: 0, upserted: smsTemplates.length }; } // OK private async seedEmailTemplates(overallMetadata: any, moduleName: string): Promise<{ pruned: number; upserted: number }> { this.logger.debug(`[Start] Processing email templates`); - const emailTemplates: CreateEmailTemplateDto[] = overallMetadata.emailTemplates; + const emailTemplates = this.getSeedArray(overallMetadata?.emailTemplates); await this.handleSeedEmailTemplates(emailTemplates, moduleName); this.logger.debug(`[End] Processing email templates`); - return { pruned: 0, upserted: emailTemplates?.length ?? 0 }; + return { pruned: 0, upserted: emailTemplates.length }; } // Ok private async seedMenus(overallMetadata: any): Promise<{ pruned: number; upserted: number }> { this.logger.debug(`[Start] Processing menus`); - const menus = overallMetadata.menus; + const menus = this.getSeedArray(overallMetadata?.menus); const pruned = this.enablePruning ? await this.pruneMenus(menus, overallMetadata?.moduleMetadata?.name) : 0; await this.handleSeedMenus(menus); this.logger.debug(`[End] Processing menus`); - return { pruned, upserted: menus?.length ?? 0 }; + return { pruned, upserted: menus.length }; } // Ok private async seedActions(overallMetadata: any): Promise<{ pruned: number; upserted: number }> { this.logger.debug(`[Start] Processing actions`); - const actions = overallMetadata.actions; + const actions = this.getSeedArray(overallMetadata?.actions); const pruned = this.enablePruning ? await this.pruneActions(actions, overallMetadata?.moduleMetadata?.name) : 0; await this.handleSeedActions(actions); this.logger.debug(`[End] Processing actions`); - return { pruned, upserted: actions?.length ?? 0 }; + return { pruned, upserted: actions.length }; } // Ok private async seedViews(overallMetadata: any): Promise<{ pruned: number; upserted: number }> { this.logger.debug(`[Start] Processing views`); - const views = overallMetadata.views; + const views = this.getSeedArray(overallMetadata?.views); const pruned = this.enablePruning ? await this.pruneViews(views, overallMetadata?.moduleMetadata?.name) : 0; await this.handleSeedViews(views); this.logger.debug(`[End] Processing views`); - return { pruned, upserted: views?.length ?? 0 }; + return { pruned, upserted: views.length }; } // Ok private async seedUsers(overallMetadata: any): Promise<{ pruned: number; upserted: number }> { this.logger.debug(`[Start] Processing users`); - const users = overallMetadata.users; + const users = this.getSeedArray(overallMetadata?.users); // usersDetail = users; await this.handleSeedUsers(users); this.logger.debug(`[End] Processing users`); - return { pruned: 0, upserted: users?.length ?? 0 }; + return { pruned: 0, upserted: users.length }; } // OK private async seedRoles(overallMetadata: any): Promise<{ pruned: number; upserted: number }> { this.logger.debug(`[Start] Processing roles`); + const roles = this.getSeedArray(overallMetadata?.roles); // While creating roles we are only passing the role name to be used. - await this.roleService.createRolesIfNotExists(overallMetadata.roles.map(role => { return { name: role.name }; })); + await this.roleService.createRolesIfNotExists( + roles + .filter((role) => role?.name) + .map((role) => ({ name: role.name } as any)), + ); // After roles are created, we iterate over all roles and attach permissions (if specified in the seeder json) to the respective role. // Every role configuration in the seeder json can optionally have a permissions attribute. - for (const role of overallMetadata.roles) { + for (const role of roles) { if (role.permissions) { - await this.roleService.addPermissionsToRole(role.name, role.permissions); + await this.roleService.addPermissionsToRole( + role.name, + role.permissions + .map((permission: any) => typeof permission === 'string' ? permission : permission?.name) + .filter(Boolean), + ); } } this.logger.debug(`[End] Processing roles`); - return { pruned: 0, upserted: overallMetadata.roles?.length ?? 0 }; + return { pruned: 0, upserted: roles.length }; } private async seedPermissions(overallMetadata: any): Promise<{ pruned: number; upserted: number }> { @@ -406,7 +408,7 @@ export class ModuleMetadataSeederService { // const enabledModules = getDynamicModuleNames(); const enabledModules = getDynamicModuleNamesBasedOnMetadata(); for (const enabledModule of enabledModules) { - const enabledModuleSeedFile = `module-metadata/${enabledModule}/${enabledModule}-metadata.json`; + const enabledModuleSeedFile = `src/${enabledModule}/metadata/${enabledModule}-metadata.json`; const fullPath = path.join(process.cwd(), enabledModuleSeedFile); if (fs.existsSync(fullPath)) { @@ -463,7 +465,7 @@ export class ModuleMetadataSeederService { await this.createPermissionIfNotExists(permissionName); } - } catch (error) { + } catch (error: any) { this.logger.error(error); } } @@ -507,22 +509,23 @@ export class ModuleMetadataSeederService { // OK private async seedMediaStorageProviders(mediaStorageProviders: any[]): Promise<{ pruned: number; upserted: number }> { this.logger.debug(`[Start] Processing Media Storage Provider`); + const providers = this.getSeedArray(mediaStorageProviders); - for (let i = 0; i < mediaStorageProviders.length; i++) { - const mediaStorageProvider = mediaStorageProviders[i]; + for (let i = 0; i < providers.length; i++) { + const mediaStorageProvider = providers[i]; await this.mediaStorageProviderMetadataService.upsert(mediaStorageProvider); } this.logger.debug(`[End] Processing Media Storage Provider`); - return { pruned: 0, upserted: mediaStorageProviders?.length ?? 0 }; + return { pruned: 0, upserted: providers.length }; } private async seedModelSequences(overallMetadata: any): Promise<{ pruned: number; upserted: number }> { this.logger.debug(`[Start] Processing model sequences`); - const modelSequences: CreateModelSequenceDto[] = overallMetadata.modelSequences; + const modelSequences = this.getSeedArray(overallMetadata?.modelSequences); const pruned = this.enablePruning ? await this.pruneModelSequences(modelSequences, overallMetadata?.moduleMetadata?.name) : 0; await this.handleSeedModelSequences(modelSequences); this.logger.debug(`[End] Processing model sequences`); - return { pruned, upserted: modelSequences?.length ?? 0 }; + return { pruned, upserted: modelSequences.length }; } // OK @@ -544,7 +547,7 @@ export class ModuleMetadataSeederService { moduleRoot = path.dirname( require.resolve('@solidxai/core/package.json'), ); - } catch (err) { + } catch (err: any) { this.logger.debug( 'Could not resolve @solidxai/core from node_modules, assuming local execution', ); @@ -576,7 +579,7 @@ export class ModuleMetadataSeederService { } else { // Check if file exists - const emailTemplateHandlebar = `module-metadata/${moduleName}/email-templates/${emailTemplate.body}` + const emailTemplateHandlebar = `src/${moduleName}/metadata/email-templates/${emailTemplate.body}` const fullPath = path.join(process.cwd(), emailTemplateHandlebar); // this.logger.log(`Seeding custom email template from consuming model at path: ${fullPath}`); if (fs.existsSync(fullPath)) { @@ -610,7 +613,7 @@ export class ModuleMetadataSeederService { moduleRoot = path.dirname( require.resolve('@solidxai/core/package.json'), ); - } catch (err) { + } catch (err: any) { this.logger.debug( 'Could not resolve @solidxai/core from node_modules, assuming local execution', ); @@ -642,7 +645,7 @@ export class ModuleMetadataSeederService { } else { // Check if file exists - const emailTemplateHandlebar = `module-metadata/${moduleName}/sms-templates/${smsTemplate.body}` + const emailTemplateHandlebar = `src/${moduleName}/metadata/sms-templates/${smsTemplate.body}` const fullPath = path.join(process.cwd(), emailTemplateHandlebar); // this.logger.log(`Seeding custom sms template from consuming model at path: ${fullPath}`); if (fs.existsSync(fullPath)) { @@ -845,8 +848,8 @@ export class ModuleMetadataSeederService { let upserted = 1; // Next create all the models. - const modelsMetadata: CreateModelMetadataDto[] = moduleMetadata.models; - upserted += modelsMetadata?.length ?? 0; + const modelsMetadata = this.getSeedArray(moduleMetadata?.models); + upserted += modelsMetadata.length; for (let j = 0; j < modelsMetadata.length; j++) { const modelMetadata = modelsMetadata[j]; @@ -900,6 +903,10 @@ export class ModuleMetadataSeederService { return { pruned, upserted }; } + private getSeedArray(value: T[] | null | undefined): T[] { + return Array.isArray(value) ? value : []; + } + private async handleSeedSecurityRules(rulesDto: CreateSecurityRuleDto[]) { if (!rulesDto || rulesDto.length === 0) { this.logger.debug(`No security rules found to seed`); @@ -923,16 +930,6 @@ export class ModuleMetadataSeederService { } } - private async handleSeedDashboards(dashboardDtos: CreateDashboardDto[]) { - if (!dashboardDtos || dashboardDtos.length === 0) { - this.logger.debug(`No dashboards found to seed`); - return; - } - for (const dto of dashboardDtos) { - await this.dashboardRepo.upsertWithDto(dto); - } - } - private async handleSeedScheduledJobs(createScheduledJobDto: CreateScheduledJobDto[]) { if (!createScheduledJobDto || createScheduledJobDto.length === 0) { this.logger.debug(`No scheduled jobs found to seed`); @@ -1189,45 +1186,6 @@ export class ModuleMetadataSeederService { return 0; } - private async pruneDashboards(dashboardsDto: CreateDashboardDto[] | undefined, moduleName?: string): Promise { - if (!moduleName) { - this.logger.warn(`Skipping dashboards prune: missing module name in metadata.`); - return 0; - } - const dashboards = dashboardsDto ?? []; - - const module = await this.moduleMetadataService.findOneByUserKey(moduleName); - if (!module) { - this.logger.warn(`Skipping dashboards prune: module not found for ${moduleName}.`); - return 0; - } - - const dashboardNames = [...new Set(dashboards.map(dto => dto.name).filter(Boolean))]; - const repo = this.dataSource.getRepository(Dashboard); - const idsToDeleteQuery = repo - .createQueryBuilder('db') - .select('db.id', 'id') - .innerJoin('db.module', 'module') - .where('module.id = :moduleId', { moduleId: module.id }); - - if (dashboardNames.length > 0) { - idsToDeleteQuery.andWhere('db.name NOT IN (:...dashboardNames)', { dashboardNames }); - } - - const rows = await idsToDeleteQuery.getRawMany(); - const ids = rows.map((row) => row.id); - if (ids.length > 0) { - const result = await repo - .createQueryBuilder() - .delete() - .from(Dashboard) - .whereInIds(ids) - .execute(); - return result.affected ?? 0; - } - return 0; - } - private async pruneMenus(menusDto: any[] | undefined, moduleName?: string): Promise { if (!moduleName) { this.logger.warn(`Skipping menus prune: missing module name in metadata.`); diff --git a/src/seeders/module-test-data.service.ts b/src/seeders/module-test-data.service.ts index 28bfa9a4..b697d4bc 100644 --- a/src/seeders/module-test-data.service.ts +++ b/src/seeders/module-test-data.service.ts @@ -19,11 +19,12 @@ import { ModelMetadataService } from 'src/services/model-metadata.service'; import { RoleMetadataService } from 'src/services/role-metadata.service'; import { UserService } from 'src/services/user.service'; import { getMediaStorageProvider } from 'src/services/mediaStorageProviders'; -import { TestingRoleSpec, TestingUserSpec } from 'src/testing/contracts/testing-metadata.types'; +import { TestingDataRecord, TestingRoleSpec, TestingUserSpec } from 'src/testing/contracts/testing-metadata.types'; @Injectable() export class ModuleTestDataService { private readonly logger = new Logger(ModuleTestDataService.name); + private static readonly TEARDOWN_RETRY_ATTEMPTS = 5; constructor( private readonly moduleRef: ModuleRef, @@ -50,6 +51,25 @@ export class ModuleTestDataService { } } + async removeTestData(modulesToTest?: string[]): Promise { + const testDataFiles = this.testDataFiles; + const filteredFiles = modulesToTest?.length ? testDataFiles.filter((file) => modulesToTest.includes(file.moduleMetadata?.name)) : testDataFiles; + + if (filteredFiles.length === 0) { + this.logger.warn('No modules matched the provided modulesToTest list.'); + console.log('No modules matched the provided modulesToTest list.'); + return; + } + + for (const overallMetadata of filteredFiles) { + const moduleName = overallMetadata?.moduleMetadata?.name ?? 'unknown'; + this.logger.log(`Removing test data for module: ${moduleName}`); + console.log(`Removing test data for module: ${moduleName}`); + await this.unlinkTestData(overallMetadata); + console.log(`✔ Test data unlink complete for module: ${moduleName}`); + } + } + async createTestDatasources(): Promise { const manifestPath = path.join(process.cwd(), '.solidx-test-manifest'); if (fs.existsSync(manifestPath)) { @@ -176,7 +196,7 @@ export class ModuleTestDataService { const testDataFiles = [typedSolidCoreMetadata]; const enabledModules = getDynamicModuleNamesBasedOnMetadata(); for (const enabledModule of enabledModules) { - const enabledModuleSeedFile = `module-metadata/${enabledModule}/${enabledModule}-metadata.json`; + const enabledModuleSeedFile = `src/${enabledModule}/metadata/${enabledModule}-metadata.json`; const fullPath = path.join(process.cwd(), enabledModuleSeedFile); if (fs.existsSync(fullPath)) { @@ -198,7 +218,7 @@ export class ModuleTestDataService { const testingRoles: TestingRoleSpec[] = overallMetadata?.testing?.roles ?? []; const testingUsers: TestingUserSpec[] = overallMetadata?.testing?.users ?? []; - const testingData: Array<{ modelUserKey: string; data: Record }> = overallMetadata?.testing?.data ?? []; + const testingData: TestingDataRecord[] = overallMetadata?.testing?.data ?? []; if (testingRoles.length > 0) { await this.seedTestRoles(testingRoles); @@ -289,16 +309,21 @@ export class ModuleTestDataService { // Upsert entity, capturing the saved result for post-save steps let savedEntity: any; const userKeyField = modelDef.userKeyFieldUserKey; + const recordUserKeyValue = entry.recUserKeyValue ?? payload[userKeyField]; + const recordLabel = `${modelUserKey}.${userKeyField}=${recordUserKeyValue}`; if (userKeyField && payload[userKeyField] !== undefined) { const existing = await entityRepo.findOne({ where: { [userKeyField]: payload[userKeyField] }, }); if (existing) { + console.log(`Updating test data record: ${recordLabel}`); savedEntity = await entityRepo.save(entityRepo.merge(existing, payload)); } else { + console.log(`Creating test data record: ${recordLabel}`); savedEntity = await entityRepo.save(entityRepo.create(payload)); } } else { + console.log(`Creating test data record without user key lookup: ${modelUserKey}`); savedEntity = await entityRepo.save(entityRepo.create(payload)); } @@ -312,6 +337,59 @@ export class ModuleTestDataService { } } + private async unlinkTestData(overallMetadata: any): Promise { + const moduleMetadata: CreateModuleMetadataDto = overallMetadata.moduleMetadata; + if (!moduleMetadata) { + throw new Error('Module metadata missing from test data payload.'); + } + + const testingData: TestingDataRecord[] = overallMetadata?.testing?.data ?? []; + if (testingData.length === 0) { + this.logger.debug(`No test data found for ${moduleMetadata.name}`); + return; + } + + const modelsByName = new Map( + (moduleMetadata.models ?? []).map((m) => [m.singularName, m]), + ); + + for (const entry of [...testingData].reverse()) { + const modelUserKey = entry.modelUserKey; + const modelDef = modelsByName.get(modelUserKey); + if (!modelDef) { + throw new Error(`Test data modelUserKey not found in metadata: ${modelUserKey}`); + } + + const userKeyField = modelDef.userKeyFieldUserKey; + if (!userKeyField) { + throw new Error(`Cannot unlink test data for model ${modelUserKey} because userKeyFieldUserKey is not configured.`); + } + + const userKeyValue = entry.data?.[userKeyField]; + if (userKeyValue === undefined || userKeyValue === null || userKeyValue === '') { + throw new Error( + `Cannot unlink test data for model ${modelUserKey} because testing.data entry is missing the actual user key field "${userKeyField}" in data.`, + ); + } + const recordLabel = `${modelUserKey}.${userKeyField}=${userKeyValue}`; + + const entityRepo = this.resolveRepository(modelUserKey); + const existing = typeof entityRepo.findOneByUserKey === 'function' + ? await entityRepo.findOneByUserKey(userKeyValue) + : await entityRepo.findOne({ where: { [userKeyField]: userKeyValue } }); + + if (!existing) { + console.log(`Skipping unlink; test data record not found: ${recordLabel}`); + this.logger.debug(`Test data record not found for unlink: ${modelUserKey}.${userKeyField}=${userKeyValue}`); + continue; + } + + console.log(`Deleting test data record: ${recordLabel}`); + await entityRepo.remove(existing); + this.logger.debug(`Removed test data record: ${modelUserKey}.${userKeyField}=${userKeyValue}`); + } + } + private async seedTestRoles(roles: TestingRoleSpec[]): Promise { const roleService = this.moduleRef.get(RoleMetadataService, { strict: false }); if (!roleService) { @@ -665,31 +743,122 @@ export class ModuleTestDataService { private async dropTestDatabaseObjects(databases: Record): Promise { const entries = Object.entries(databases); for (const [dsName, dbName] of entries) { - const dataSource = this.resolveDataSourceByName(dsName); - if (!dataSource.isInitialized) { - await dataSource.initialize(); - } + await this.dropTestDatabaseObjectsWithRetry(dsName, dbName); + } + } - console.log(`Dropping test database/schema "${dbName}" on datasource "${dsName}"...`); + private async dropTestDatabaseObjectsWithRetry(dsName: string, dbName: string): Promise { + let lastError: unknown; + + for (let attempt = 1; attempt <= ModuleTestDataService.TEARDOWN_RETRY_ATTEMPTS; attempt += 1) { + console.log(`Attempting to tear down "${dbName}" on datasource "${dsName}" (${attempt}/${ModuleTestDataService.TEARDOWN_RETRY_ATTEMPTS})...`); - const queryRunner = dataSource.createQueryRunner(); try { - const type = dataSource.options.type; - if (type === 'postgres') { - await queryRunner.query(`DROP DATABASE IF EXISTS "${dbName}"`); - } else if (type === 'mssql') { - await this.dropMssqlSchema(queryRunner, dbName); - } else if (type === 'mysql' || type === 'mariadb') { - await queryRunner.query(`DROP DATABASE IF EXISTS \`${dbName}\``); - } else { - throw new Error(`Unsupported database type for test data deletion: ${type}`); + await this.dropSingleTestDatabaseObject(dsName, dbName); + return; + } catch (error) { + lastError = error; + if (attempt >= ModuleTestDataService.TEARDOWN_RETRY_ATTEMPTS) { + throw error; } + + await this.sleep(this.teardownRetryDelayMs(attempt)); + } + } + + throw lastError instanceof Error ? lastError : new Error(String(lastError)); + } + + private teardownRetryDelayMs(attempt: number): number { + const baseMs = 500; + const incrementMs = 350; + const jitterMs = Math.floor(Math.random() * 250); + return baseMs + ((attempt - 1) * incrementMs) + jitterMs; + } + + private async sleep(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); + } + + private async dropSingleTestDatabaseObject(dsName: string, dbName: string): Promise { + const dataSource = this.resolveDataSourceByName(dsName); + + if (dataSource.options.type === 'postgres') { + await this.dropPostgresDatabase(dataSource, dbName); + return; + } + + if (!dataSource.isInitialized) { + await dataSource.initialize(); + } + + const queryRunner = dataSource.createQueryRunner(); + try { + const type = dataSource.options.type; + if (type === 'mssql') { + await this.dropMssqlSchema(queryRunner, dbName); + } else if (type === 'mysql' || type === 'mariadb') { + await queryRunner.query(`DROP DATABASE IF EXISTS \`${dbName}\``); + } else { + throw new Error(`Unsupported database type for test data deletion: ${type}`); + } + } finally { + await queryRunner.release(); + } + } + + private async dropPostgresDatabase(dataSource: DataSource, dbName: string): Promise { + if (dataSource.isInitialized) { + await dataSource.destroy(); + } + + const adminDataSource = new DataSource({ + ...(dataSource.options as any), + database: this.resolvePostgresMaintenanceDatabase(dataSource), + name: `${String(dataSource.name ?? 'default')}_teardown_admin_${Date.now()}`, + synchronize: false, + migrationsRun: false, + entities: [], + subscribers: [], + migrations: [], + }); + + try { + await adminDataSource.initialize(); + const queryRunner = adminDataSource.createQueryRunner(); + try { + await queryRunner.query( + `SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = $1 + AND pid <> pg_backend_pid()`, + [dbName], + ); + await queryRunner.query(`DROP DATABASE IF EXISTS "${dbName}"`); } finally { await queryRunner.release(); } + } finally { + if (adminDataSource.isInitialized) { + await adminDataSource.destroy(); + } } } + private resolvePostgresMaintenanceDatabase(dataSource: DataSource): string { + const configured = process.env.POSTGRES_MAINTENANCE_DATABASE?.trim(); + if (configured) { + return configured; + } + + const currentDb = String((dataSource.options as any)?.database ?? '').trim(); + if (currentDb && currentDb !== 'postgres') { + return 'postgres'; + } + + return 'template1'; + } + private async dropMssqlSchema(queryRunner: ReturnType, schemaName: string): Promise { const foreignKeys: Array<{ fk_name: string; table_name: string }> = await queryRunner.query( `SELECT fk.name AS fk_name, t.name AS table_name diff --git a/src/seeders/permission-metadata-seeder.service.ts b/src/seeders/permission-metadata-seeder.service.ts index 2c7529ee..137e6e3f 100755 --- a/src/seeders/permission-metadata-seeder.service.ts +++ b/src/seeders/permission-metadata-seeder.service.ts @@ -52,7 +52,7 @@ export class PermissionMetadataSeederService { } } - } catch (error) { + } catch (error: any) { this.logger.error(error); } } diff --git a/src/seeders/seed-data/solid-core-metadata.json b/src/seeders/seed-data/solid-core-metadata.json index dd9fb29b..3927f551 100755 --- a/src/seeders/seed-data/solid-core-metadata.json +++ b/src/seeders/seed-data/solid-core-metadata.json @@ -27,7 +27,7 @@ "length": 512, "required": true, "unique": false, - "index": false, + "index": true, "private": false, "encrypt": false, "columnName": "display_name", @@ -54,7 +54,6 @@ "length": 512, "required": false, "unique": false, - "index": false, "private": false, "encrypt": false, "isSystem": true @@ -67,7 +66,6 @@ "length": 512, "required": false, "unique": false, - "index": false, "private": false, "encrypt": false, "isSystem": true @@ -80,7 +78,7 @@ "length": 512, "required": false, "unique": false, - "index": false, + "index": true, "private": false, "encrypt": false, "isSystem": true @@ -93,7 +91,6 @@ "length": 512, "required": false, "unique": false, - "index": false, "private": false, "encrypt": false, "isSystem": true @@ -105,7 +102,6 @@ "defaultValue": "false", "required": false, "unique": false, - "index": true, "private": false, "encrypt": false, "isSystem": true @@ -173,7 +169,7 @@ "length": 512, "required": true, "unique": false, - "index": false, + "index": true, "private": false, "encrypt": false, "columnName": "display_name", @@ -186,7 +182,7 @@ "ormType": "text", "required": false, "unique": false, - "index": false, + "index": true, "private": false, "encrypt": false, "columnName": "description", @@ -227,7 +223,6 @@ "defaultValue": "true", "required": false, "unique": false, - "index": false, "private": false, "encrypt": false, "columnName": "enable_soft_delete", @@ -311,7 +306,6 @@ "defaultValue": "false", "required": false, "unique": false, - "index": true, "private": false, "encrypt": false, "isSystem": true @@ -364,7 +358,7 @@ "ormType": "varchar", "required": true, "unique": false, - "index": false, + "index": true, "private": false, "encrypt": false, "columnName": "name", @@ -377,7 +371,7 @@ "ormType": "varchar", "required": true, "unique": false, - "index": false, + "index": true, "private": false, "encrypt": false, "columnName": "display_name", @@ -390,7 +384,7 @@ "ormType": "varchar", "required": true, "unique": false, - "index": false, + "index": true, "private": false, "encrypt": false, "columnName": "type", @@ -955,7 +949,7 @@ "ormType": "varchar", "required": true, "unique": true, - "index": false, + "index": true, "private": false, "encrypt": false, "columnName": "name", @@ -1028,7 +1022,7 @@ }, { "name": "signedUrlExpiry", - "displayName": "Signed Url Expiry Time", + "displayName": "Signed Url Expiry Time (In Minutes)", "type": "int", "ormType": "integer", "required": false, @@ -1059,7 +1053,7 @@ "ormType": "varchar", "required": true, "unique": true, - "index": false, + "index": true, "private": false, "encrypt": false, "columnName": "name", @@ -1074,7 +1068,7 @@ "ormType": "varchar", "required": true, "unique": false, - "index": false, + "index": true, "private": false, "encrypt": false, "columnName": "display_name", @@ -1087,7 +1081,7 @@ "ormType": "varchar", "required": true, "unique": false, - "index": false, + "index": true, "private": false, "encrypt": false, "columnName": "type", @@ -1229,6 +1223,92 @@ } ] }, + { + "singularName": "dashboardUserLayout", + "pluralName": "dashboardUserLayouts", + "displayName": "Dashboard User Layout", + "description": "Stores per-user dashboard layout overrides.", + "dataSource": "default", + "dataSourceType": "postgres", + "tableName": "ss_dashboard_user_layout", + "userKeyFieldUserKey": "dashboardName", + "isSystem": true, + "fields": [ + { + "name": "user", + "displayName": "User", + "type": "relation", + "required": true, + "unique": false, + "index": true, + "private": false, + "encrypt": false, + "relationType": "many-to-one", + "relationCoModelSingularName": "user", + "relationCreateInverse": false, + "relationCascade": "cascade", + "relationModelModuleName": "solid-core", + "isSystem": true + }, + { + "name": "module", + "displayName": "Module", + "type": "relation", + "ormType": "integer", + "required": true, + "unique": false, + "index": true, + "private": false, + "encrypt": false, + "relationType": "many-to-one", + "relationCreateInverse": false, + "relationCoModelSingularName": "moduleMetadata", + "relationModelModuleName": "solid-core", + "relationCascade": "cascade", + "isSystem": true + }, + { + "name": "dashboardName", + "displayName": "Dashboard Name", + "type": "shortText", + "ormType": "varchar", + "length": 128, + "required": true, + "unique": false, + "index": true, + "private": false, + "encrypt": false, + "columnName": "dashboard_name", + "isSystem": true + }, + { + "name": "layoutJson", + "displayName": "Layout Json", + "type": "longText", + "ormType": "text", + "required": true, + "unique": false, + "index": false, + "private": false, + "encrypt": false, + "columnName": "layout_json", + "isSystem": true + }, + { + "name": "version", + "displayName": "Version", + "type": "int", + "ormType": "integer", + "required": false, + "unique": false, + "index": false, + "private": false, + "encrypt": false, + "defaultValue": "1", + "isSystem": true + } + ] + }, { "singularName": "actionMetadata", "tableName": "ss_action_metadata", @@ -1247,7 +1327,7 @@ "ormType": "varchar", "required": true, "unique": true, - "index": false, + "index": true, "private": false, "encrypt": false, "columnName": "name", @@ -1262,7 +1342,7 @@ "ormType": "varchar", "required": true, "unique": false, - "index": false, + "index": true, "private": false, "encrypt": false, "columnName": "display_name", @@ -1418,7 +1498,7 @@ "ormType": "varchar", "required": true, "unique": true, - "index": false, + "index": true, "private": false, "encrypt": false, "columnName": "name", @@ -1433,7 +1513,7 @@ "ormType": "varchar", "required": true, "unique": false, - "index": false, + "index": true, "private": false, "encrypt": false, "columnName": "display_name", @@ -1446,7 +1526,7 @@ "ormType": "integer", "required": false, "unique": false, - "index": false, + "index": true, "private": false, "encrypt": false, "columnName": "sequence_number", @@ -1580,7 +1660,7 @@ "required": false, "defaultValue": "0", "unique": false, - "index": false, + "index": true, "private": false, "encrypt": false, "columnName": "file_size", @@ -1688,7 +1768,7 @@ "length": 512, "required": false, "unique": false, - "index": false, + "index": true, "private": false, "encrypt": false, "isSystem": true, @@ -1761,6 +1841,19 @@ "encrypt": false, "isSystem": true }, + { + "name": "failedLoginAttempts", + "displayName": "Failed Login Attempts", + "type": "int", + "ormType": "integer", + "defaultValue": "0", + "required": false, + "unique": false, + "index": false, + "private": false, + "encrypt": false, + "isSystem": true + }, { "name": "profilePicture", "displayName": "Profile Picture", @@ -1782,7 +1875,6 @@ "required": true, "defaultValue": "local", "unique": false, - "index": false, "private": false, "encrypt": false, "isSystem": true @@ -1847,7 +1939,7 @@ "defaultValue": "false", "required": true, "unique": false, - "index": false, + "index": true, "private": false, "encrypt": false, "isSystem": true @@ -2284,7 +2376,7 @@ "relationCascade": "cascade", "required": false, "unique": false, - "index": false, + "index": true, "private": false, "encrypt": false, "encryptionType": null, @@ -2381,7 +2473,7 @@ "ormType": "varchar", "required": true, "unique": true, - "index": false, + "index": true, "private": false, "encrypt": false, "columnName": "name", @@ -2440,6 +2532,22 @@ "relationModelModuleName": "solid-core", "isSystem": true, "isRelationManyToManyOwner": false + }, + { + "name": "module", + "displayName": "Module", + "type": "relation", + "required": false, + "unique": false, + "index": true, + "private": false, + "encrypt": false, + "relationType": "many-to-one", + "relationCoModelSingularName": "moduleMetadata", + "relationCreateInverse": false, + "relationCascade": "set null", + "relationModelModuleName": "solid-core", + "isSystem": true } ] }, @@ -2462,7 +2570,8 @@ "length": 128, "required": true, "unique": true, - "isSystem": true + "isSystem": true, + "index": true }, { "name": "description", @@ -2511,7 +2620,6 @@ "length": 128, "required": false, "unique": false, - "index": false, "private": false, "encrypt": false, "isSystem": true @@ -2581,7 +2689,6 @@ "type": "datetime", "length": 512, "required": false, - "index": false, "isSystem": true }, { @@ -2590,7 +2697,6 @@ "type": "datetime", "length": 512, "required": false, - "index": false, "isSystem": true }, { @@ -2657,7 +2763,6 @@ "length": 128, "required": false, "unique": false, - "index": false, "private": false, "encrypt": false, "selectionValueType": "string", @@ -2707,7 +2812,7 @@ "max": null, "required": true, "unique": true, - "index": false, + "index": true, "private": false, "encrypt": false, "encryptionType": null, @@ -2747,7 +2852,7 @@ "selectionValueType": "string", "required": true, "unique": false, - "index": false, + "index": true, "private": false, "encrypt": false, "encryptionType": null, @@ -2905,7 +3010,7 @@ "max": null, "required": true, "unique": false, - "index": false, + "index": true, "private": false, "encrypt": false, "encryptionType": null, @@ -3091,7 +3196,7 @@ "relationCascade": "restrict", "required": false, "unique": false, - "index": false, + "index": true, "private": false, "encrypt": false, "encryptionType": null, @@ -3116,7 +3221,7 @@ "relationCascade": "restrict", "required": true, "unique": false, - "index": false, + "index": true, "private": false, "encrypt": false, "encryptionType": null, @@ -3141,7 +3246,7 @@ "relationCascade": "restrict", "required": true, "unique": false, - "index": false, + "index": true, "private": false, "encrypt": false, "encryptionType": null, @@ -3170,7 +3275,7 @@ "length": 512, "required": true, "unique": true, - "index": false, + "index": true, "private": false, "encrypt": false, "isSystem": false @@ -3244,7 +3349,7 @@ "length": 128, "required": true, "unique": false, - "index": false, + "index": true, "private": false, "encrypt": false, "isSystem": true @@ -3329,7 +3434,7 @@ "ormType": "varchar", "required": true, "unique": true, - "index": false, + "index": true, "private": false, "encrypt": false, "columnName": "name", @@ -3344,7 +3449,7 @@ "ormType": "varchar", "required": true, "unique": false, - "index": false, + "index": true, "private": false, "encrypt": false, "columnName": "display_name", @@ -3535,7 +3640,7 @@ "ormType": "varchar", "required": true, "unique": true, - "index": false, + "index": true, "private": false, "encrypt": false, "columnName": "name", @@ -3550,7 +3655,7 @@ "ormType": "varchar", "required": true, "unique": false, - "index": false, + "index": true, "private": false, "encrypt": false, "columnName": "display_name", @@ -3592,7 +3697,7 @@ "ormType": "varchar", "required": false, "unique": false, - "index": false, + "index": true, "private": false, "encrypt": false, "isSystem": true @@ -3643,7 +3748,7 @@ "length": 512, "required": true, "unique": false, - "index": false, + "index": true, "private": false, "encrypt": false, "isSystem": false @@ -3656,7 +3761,7 @@ "length": 512, "required": true, "unique": false, - "index": false, + "index": true, "private": false, "encrypt": false, "isSystem": false @@ -3669,7 +3774,7 @@ "length": 512, "required": true, "unique": false, - "index": false, + "index": true, "private": false, "encrypt": false, "isSystem": false @@ -3730,7 +3835,7 @@ "ormType": "integer", "required": false, "unique": false, - "index": false, + "index": true, "private": false, "encrypt": false, "relationType": "many-to-one", @@ -3773,7 +3878,7 @@ "ormType": "varchar", "length": 256, "required": true, - "index": false, + "index": true, "isSystem": false, "selectionValueType": "string", "selectionStaticValues": [ @@ -3861,7 +3966,7 @@ "relationCascade": "cascade", "required": false, "unique": false, - "index": false, + "index": true, "private": false, "encrypt": false, "encryptionType": null, @@ -3877,7 +3982,7 @@ "ormType": "text", "required": false, "unique": false, - "index": false, + "index": true, "private": false, "encrypt": false, "isSystem": false @@ -3952,7 +4057,6 @@ "length": 512, "required": false, "unique": false, - "index": false, "private": false, "encrypt": false, "isSystem": false @@ -3978,7 +4082,6 @@ "length": 512, "required": true, "unique": false, - "index": false, "private": false, "encrypt": false, "isSystem": false @@ -3991,7 +4094,6 @@ "length": 512, "required": false, "unique": false, - "index": false, "private": false, "encrypt": false, "isSystem": false @@ -4437,385 +4539,374 @@ ] }, { - "singularName": "dashboard", - "pluralName": "dashboards", - "displayName": "Dashboard", - "description": "This is used to maintain dashboards", + "singularName": "modelSequence", + "tableName": "ss_model_sequence", + "pluralName": "modelSequences", + "displayName": "Model Sequence", + "description": "This is used to maintain model field sequence", "dataSource": "default", "dataSourceType": "postgres", - "tableName": "ss_dashboard", - "userKeyFieldUserKey": "name", + "isSystem": true, + "userKeyFieldUserKey": "sequenceName", "fields": [ { - "name": "name", - "displayName": "Name", - "type": "shortText", - "ormType": "varchar", - "length": 256, + "name": "module", + "displayName": "Module", + "type": "relation", + "ormType": "integer", "required": true, - "unique": true, + "unique": false, "index": true, "private": false, "encrypt": false, - "isSystem": true, - "isUserKey": true + "relationType": "many-to-one", + "relationCreateInverse": false, + "relationCoModelSingularName": "moduleMetadata", + "relationModelModuleName": "solid-core", + "relationCascade": "cascade", + "isSystem": true }, { - "name": "displayName", - "displayName": "Display Name", - "type": "shortText", - "ormType": "varchar", - "length": 256, - "required": false, + "name": "model", + "displayName": "Model", + "type": "relation", + "ormType": "integer", + "required": true, "unique": false, - "index": false, + "index": true, "private": false, "encrypt": false, - "isSystem": true, - "isUserKey": false + "relationType": "many-to-one", + "relationCreateInverse": false, + "relationCoModelSingularName": "modelMetadata", + "relationModelModuleName": "solid-core", + "relationCascade": "cascade", + "isSystem": true }, { - "name": "description", - "displayName": "Description", - "type": "longText", - "ormType": "text", - "required": false, - "unique": false, - "index": false, - "private": false, - "encrypt": false, - "isSystem": true, - "description": "This is a description of the dashboard configuration, providing context and details about the dashboard." + "name": "field", + "displayName": "Field", + "type": "relation", + "ormType": "integer", + "required": true, + "unique": false, + "index": true, + "private": false, + "encrypt": false, + "relationType": "many-to-one", + "relationCreateInverse": false, + "relationCoModelSingularName": "fieldMetadata", + "relationModelModuleName": "solid-core", + "relationCascade": "cascade", + "isSystem": true }, { - "name": "layoutJson", - "displayName": "Layout Json", - "type": "json", - "ormType": "simple-json", + "name": "sequenceName", + "displayName": "Sequence Name", + "type": "shortText", + "ormType": "varchar", + "length": 512, "required": true, + "unique": true, + "index": true, + "private": false, + "encrypt": false, + "isSystem": false, + "isUserKey": true + }, + { + "name": "currentValue", + "displayName": "Current Value", + "type": "int", + "ormType": "integer", + "required": false, "unique": false, "index": false, "private": false, "encrypt": false, - "isSystem": true + "isSystem": false, + "defaultValue": "1" }, { - "name": "dashboardVariables", - "displayName": "Dashboard Variables", - "type": "relation", - "ormType": "", + "name": "prefix", + "displayName": "Prefix", + "type": "shortText", + "ormType": "varchar", + "length": 512, "required": false, "unique": false, "index": false, - "relationType": "one-to-many", - "relationCoModelFieldName": "dashboard", - "relationCreateInverse": true, - "relationCoModelSingularName": "dashboardVariable", - "relationModelModuleName": "solid-core", - "isSystem": true + "private": false, + "encrypt": false, + "isSystem": false }, { - "name": "questions", - "displayName": "Dashboard Questions", - "type": "relation", + "name": "padding", + "displayName": "Padding", + "type": "int", + "ormType": "integer", "required": false, "unique": false, "index": false, - "relationType": "one-to-many", - "relationCoModelFieldName": "dashboard", - "relationCreateInverse": true, - "relationCoModelSingularName": "dashboardQuestion", - "relationModelModuleName": "solid-core", - "isSystem": true + "private": false, + "encrypt": false, + "isSystem": false, + "defaultValue": "5" }, { - "name": "module", - "displayName": "Module", - "type": "relation", - "ormType": "integer", - "required": true, + "name": "separator", + "displayName": "Separator", + "type": "shortText", + "ormType": "varchar", + "length": 512, + "required": false, "unique": false, "index": false, "private": false, "encrypt": false, - "relationType": "many-to-one", - "relationCreateInverse": false, - "relationCoModelSingularName": "moduleMetadata", - "relationModelModuleName": "solid-core", - "relationCascade": "cascade", - "isSystem": true + "isSystem": false } ] }, { - "singularName": "dashboardVariable", - "pluralName": "dashboardVariables", - "displayName": "Dashboard Variable", - "description": "This is used to maintain dashboard variables", + "singularName": "agentSession", + "pluralName": "agentSessions", + "displayName": "Agent Session", + "description": "AI agent sessions", "dataSource": "default", "dataSourceType": "postgres", - "tableName": "ss_dashboard_variable", - "userKeyFieldUserKey": "externalId", + "tableName": "ss_agent_sessions", + "isChild": false, + "isLegacyTable": true, + "isLegacyTableWithId": true, + "enableAuditTracking": false, + "enableSoftDelete": false, + "draftPublishWorkflow": false, + "internationalisation": false, + "isSystem": true, + "userKeyFieldUserKey": "sessionId", "fields": [ { - "name": "variableName", - "displayName": "Variable Name", + "name": "sessionId", + "displayName": "Session ID", "type": "shortText", - "ormType": "varchar", - "length": 256, + "columnName": "session_id", + "length": 36, "required": true, - "unique": false, + "unique": true, "index": true, "private": false, "encrypt": false, - "isSystem": true, - "isUserKey": false - }, - { - "name": "variableType", - "displayName": "Variable Type", - "type": "selectionStatic", - "ormType": "varchar", - "length": 256, - "required": true, - "index": true, - "isSystem": true, - "selectionValueType": "string", - "selectionStaticValues": [ - "date:Date", - "selectionStatic:Selection Static", - "selectionDynamic:Selection Dynamic" - ] + "isSystem": true }, { - "name": "selectionStaticValues", - "displayName": "Selection Static Values", - "type": "json", - "ormType": "simple-json", + "name": "userId", + "displayName": "User ID", + "type": "int", + "columnName": "user_id", "required": false, "unique": false, - "index": false, + "index": true, "private": false, "encrypt": false, "isSystem": true }, { - "name": "selectionDynamicSourceType", - "displayName": "Selection Dynamic Source Type", - "type": "selectionStatic", - "ormType": "", - "length": 256, + "name": "projectRoot", + "displayName": "Project Root", + "type": "longText", + "columnName": "project_root", "required": false, "unique": false, "index": false, "private": false, "encrypt": false, - "isSystem": true, - "selectionValueType": "string", - "selectionStaticValues": [ - "sql:SQL", - "provider:Provider" - ] + "isSystem": true }, { - "name": "selectionDynamicSQL", - "displayName": "Selection Dynamic SQL", - "type": "longText", - "ormType": "text", - "required": false, + "name": "modelName", + "displayName": "Model Name", + "type": "shortText", + "columnName": "model_name", + "length": 255, + "required": true, "unique": false, - "index": false, + "index": true, "private": false, "encrypt": false, - "isSystem": true, - "description": "SQL query to fetch the data for this variable when it is rendered at runtime. This is only applicable when selectionDynamicSourceType is set to SQL." + "isSystem": true }, { - "name": "selectionDynamicProviderName", - "displayName": "Selection Dynamic Provider Name", - "type": "selectionDynamic", - "ormType": "varchar", - "length": 256, - "required": false, + "name": "status", + "displayName": "Status", + "type": "selectionStatic", + "columnName": "status", + "length": 32, + "required": true, "unique": false, - "index": false, + "index": true, "private": false, "encrypt": false, "isSystem": true, - "selectionValueType": "string", - "selectionDynamicProvider": "ListOfDashboardVariableProvidersSelectionProvider", - "selectionDynamicProviderCtxt": "{}", - "description": "This is only applicable when selectionDynamicSourceType is set to provider. It allows the user to select any pre-existing Dashboard SelectionDynamicProvider implementation used to fetch a dynamic dropdown of values to choose from when this variable is presented to the user." + "selectionStaticValues": [ + "active:Active", + "completed:Completed", + "failed:Failed", + "cancelled:Cancelled" + ], + "selectionValueType": "string" }, { - "name": "isMultiSelect", - "displayName": "Is Multi Select", - "type": "boolean", - "defaultValue": "false", - "required": false, + "name": "totalCost", + "displayName": "Total Cost", + "type": "decimal", + "columnName": "total_cost", + "required": true, "unique": false, "index": false, "private": false, "encrypt": false, "isSystem": true, - "description": "This is relevant only for variables of type \"selectionStatic\" or \"selectionDynamic\". When set to true, it allows the user to select multiple values from the dropdown." + "defaultValue": "0" }, { - "name": "dashboard", - "displayName": "Dashboard", - "description": "Related Dashboard Model", - "type": "relation", - "ormType": "integer", - "isSystem": true, - "relationType": "many-to-one", - "relationCoModelFieldName": "dashboardVariables", - "relationCreateInverse": true, - "relationCoModelSingularName": "dashboard", - "relationModelModuleName": "solid-core", - "relationCascade": "cascade", - "required": false, + "name": "totalSteps", + "displayName": "Total Steps", + "type": "int", + "columnName": "total_steps", + "required": true, "unique": false, "index": false, "private": false, - "encrypt": false + "encrypt": false, + "isSystem": true, + "defaultValue": "0" }, { - "name": "defaultValue", - "displayName": "Default Value", - "type": "longText", - "ormType": "text", - "required": false, + "name": "totalInputTokens", + "displayName": "Total Input Tokens", + "type": "int", + "columnName": "total_input_tokens", + "required": true, "unique": false, "index": false, "private": false, "encrypt": false, "isSystem": true, - "description": "This is the default value for this variable when it is rendered at runtime. It can be a static value for this variable when it is rendered at runtime." + "defaultValue": "0" }, { - "name": "defaultOperator", - "displayName": "Default Operator", - "type": "selectionStatic", - "ormType": "varchar", - "length": 256, - "required": false, + "name": "totalOutputTokens", + "displayName": "Total Output Tokens", + "type": "int", + "columnName": "total_output_tokens", + "required": true, "unique": false, "index": false, "private": false, "encrypt": false, "isSystem": true, - "selectionValueType": "string", - "selectionStaticValues": [ - "$equals:Equals", - "$notEquals:Not Equals", - "$contains:Contains", - "$notContains:Not Contains", - "$startsWith:Starts With", - "$endsWith:Ends With", - "$in:In", - "$notIn:Not In", - "$between:Between", - "$lt:Less Than", - "$lte:Less Than or Equal To", - "$gt:Greater Than", - "$gte:Greater Than or Equal To" - ], - "description": "This is the default operator for this variable when it is rendered at runtime. It can be a static value for this variable when it is rendered at runtime." + "defaultValue": "0" }, { - "name": "externalId", - "displayName": "Dashboard Variable User KExternal IDey", - "description": "Concatenation of variable name and dashboard name", - "type": "computed", - "ormType": "varchar", - "isSystem": false, - "computedFieldValueType": "string", - "computedFieldTriggerConfig": [ - { - "modelName": "dashboardVariable", - "moduleName": "solid-core", - "operations": [ - "before-insert" - ] - } - ], - "computedFieldValueProvider": "ConcatEntityComputedFieldProvider", - "computedFieldValueProviderCtxt": "{\n \"fields\": [\n \"variableName\",\n \"dashboard.name\"\n ],\n \"separator\": \"-\",\n \"slugify\": true\n}", + "name": "summary", + "displayName": "Summary", + "type": "longText", + "columnName": "summary", "required": false, - "unique": true, - "index": true, + "unique": false, + "index": false, "private": false, "encrypt": false, - "encryptionType": null, - "decryptWhen": null, - "columnName": null, - "isUserKey": true + "isSystem": true } ] }, { - "singularName": "dashboardQuestion", - "pluralName": "dashboardQuestions", - "displayName": "Dashboard Question", - "description": "This is used to maintain dashboard questions for dashboards", + "singularName": "agentEvent", + "pluralName": "agentEvents", + "displayName": "Agent Event", + "description": "AI agent events per session", "dataSource": "default", "dataSourceType": "postgres", - "tableName": "ss_dashboard_question", - "userKeyFieldUserKey": "externalId", - "fields": [ - { - "name": "name", - "displayName": "Name", - "type": "shortText", - "ormType": "varchar", - "length": 256, - "required": true, + "tableName": "ss_agent_events", + "isChild": true, + "isLegacyTable": true, + "isLegacyTableWithId": true, + "enableAuditTracking": false, + "enableSoftDelete": false, + "draftPublishWorkflow": false, + "internationalisation": false, + "isSystem": true, + "userKeyFieldUserKey": "id", + "fields": [ + { + "name": "sessionId", + "displayName": "Session ID", + "type": "shortText", + "columnName": "session_id", + "length": 36, + "required": true, "unique": false, - "index": false, + "index": true, "private": false, "encrypt": false, - "isSystem": true, - "isUserKey": true + "isSystem": true }, { - "name": "sourceType", - "displayName": "Source Type", - "type": "selectionStatic", - "ormType": "", - "length": 256, + "name": "turnNumber", + "displayName": "Turn Number", + "type": "int", + "columnName": "turn_number", "required": true, - "index": true, - "isSystem": true, - "selectionValueType": "string", - "selectionStaticValues": [ - "sql:SQL", - "provider:Provider" - ] + "unique": false, + "index": false, + "private": false, + "encrypt": false, + "isSystem": true + }, + { + "name": "stepNumber", + "displayName": "Step Number", + "type": "int", + "columnName": "step_number", + "required": false, + "unique": false, + "index": false, + "private": false, + "encrypt": false, + "isSystem": true }, { - "name": "visualisedAs", - "displayName": "Visualised As", + "name": "eventType", + "displayName": "Event Type", "type": "selectionStatic", - "ormType": "", - "length": 256, + "columnName": "event_type", + "length": 64, "required": true, + "unique": false, "index": true, + "private": false, + "encrypt": false, "isSystem": true, - "selectionValueType": "string", "selectionStaticValues": [ - "bar:ChartJS - Bar", - "line:ChartJS - Line", - "pie:ChartJS - Pie", - "donut:ChartJS - Donut", - "prime-meter-group:Prime - Meter Group", - "prime-datatable:Prime - Datatable" - ] + "user_message:User Message", + "assistant_message:Assistant Message", + "tool_use:Tool Use", + "tool_result:Tool Result", + "system_prompt:System Prompt", + "thinking:Thinking", + "error:Error", + "session_start:Session Start", + "session_end:Session End" + ], + "selectionValueType": "string" }, { - "name": "sequenceNumber", - "displayName": "Sequence Number", - "type": "int", - "ormType": "integer", + "name": "eventData", + "displayName": "Event Data", + "type": "json", + "columnName": "event_data", "required": false, "unique": false, "index": false, @@ -4824,147 +4915,96 @@ "isSystem": true }, { - "name": "labelSql", - "displayName": "Label SQL Query", + "name": "content", + "displayName": "Content", "type": "longText", - "ormType": "text", + "columnName": "content", "required": false, "unique": false, "index": false, "private": false, "encrypt": false, - "isSystem": true, - "description": "This is the SQL query to fetch the label values for the question" + "isSystem": true }, { - "name": "kpiSql", - "displayName": "KPI SQL Query", - "type": "longText", - "ormType": "text", + "name": "toolName", + "displayName": "Tool Name", + "type": "shortText", + "columnName": "tool_name", + "length": 128, "required": false, "unique": false, - "index": false, + "index": true, "private": false, "encrypt": false, - "isSystem": true, - "description": "This is the SQL query to fetch the KPI value for the question" + "isSystem": true }, { - "name": "providerName", - "displayName": "Provider Name", - "type": "selectionDynamic", - "ormType": "varchar", - "length": 256, + "name": "toolArguments", + "displayName": "Tool Arguments", + "type": "json", + "columnName": "tool_arguments", "required": false, "unique": false, "index": false, "private": false, "encrypt": false, - "isSystem": true, - "selectionValueType": "string", - "selectionDynamicProvider": "ListOfDashboardQuestionProvidersSelectionProvider", - "selectionDynamicProviderCtxt": "{}", - "description": "This is only applicable when sourceType is set to provider. It allows the user to select any pre-existing Dashboard Question Data provider implementation used to fetch a dynamic dropdown of values to choose from when this question is presented to the user." + "isSystem": true }, { - "name": "chartOptions", - "displayName": "Bar Chart Label Options", + "name": "toolOutput", + "displayName": "Tool Output", "type": "json", - "ormType": "simple-json", + "columnName": "tool_output", "required": false, "unique": false, "index": false, "private": false, "encrypt": false, - "isSystem": true, - "description": "This is a JSON object representing each labels display and color options for the bar chart" + "isSystem": true }, { - "name": "dashboard", - "displayName": "Dashboard", - "description": "Related Dashboard Model", - "type": "relation", - "ormType": "integer", - "isSystem": true, - "relationType": "many-to-one", - "relationCoModelFieldName": "questions", - "relationCreateInverse": true, - "relationCoModelSingularName": "dashboard", - "relationModelModuleName": "solid-core", - "relationCascade": "cascade", + "name": "toolReturncode", + "displayName": "Tool Return Code", + "type": "int", + "columnName": "tool_returncode", "required": false, "unique": false, "index": false, "private": false, - "encrypt": false + "encrypt": false, + "isSystem": true }, { - "name": "questionSqlDatasetConfigs", - "displayName": "Related Dashboard Question SQL Dataset Config", - "description": "Related Question SQL Dataset Config Model", - "type": "relation", - "ormType": "integer", - "isSystem": true, - "relationType": "one-to-many", - "relationCoModelFieldName": "question", - "relationCreateInverse": true, - "relationCoModelSingularName": "dashboardQuestionSqlDatasetConfig", - "relationModelModuleName": "solid-core", - "relationCascade": "cascade", + "name": "durationMs", + "displayName": "Duration (ms)", + "type": "decimal", + "columnName": "duration_ms", "required": false, "unique": false, "index": false, "private": false, - "encrypt": false + "encrypt": false, + "isSystem": true }, { - "name": "externalId", - "displayName": "External ID", - "description": "Concatenation of question name and dashboard name", - "type": "computed", - "ormType": "varchar", - "isSystem": false, - "computedFieldValueType": "string", - "computedFieldTriggerConfig": [ - { - "modelName": "dashboardQuestion", - "moduleName": "solid-core", - "operations": [ - "before-insert" - ] - } - ], - "computedFieldValueProvider": "ConcatEntityComputedFieldProvider", - "computedFieldValueProviderCtxt": "{\n \"fields\": [\n \"name\",\n \"dashboard.name\"\n ],\n \"separator\": \"-\",\n \"slugify\": true\n}", + "name": "cost", + "displayName": "Cost", + "type": "decimal", + "columnName": "cost", "required": false, - "unique": true, - "index": true, + "unique": false, + "index": false, "private": false, "encrypt": false, - "encryptionType": null, - "decryptWhen": null, - "columnName": null, - "isUserKey": true - } - ] - }, - { - "singularName": "dashboardQuestionSqlDatasetConfig", - "pluralName": "dashboardQuestionSqlDatasetConfigs", - "displayName": "Dashboard Question SQL Dataset Config", - "description": "This is used to maintain Dashboard Question SQL dataset configurations", - "dataSource": "default", - "dataSourceType": "postgres", - "tableName": "ss_dashboard_question_sql_dataset_config", - "userKeyFieldUserKey": "externalId", - "fields": [ + "isSystem": true + }, { - "name": "datasetName", - "displayName": "Dataset Name", - "type": "shortText", - "ormType": "varchar", - "length": 256, - "required": true, + "name": "inputTokens", + "displayName": "Input Tokens", + "type": "int", + "columnName": "input_tokens", + "required": false, "unique": false, "index": false, "private": false, @@ -4972,38 +5012,68 @@ "isSystem": true }, { - "name": "datasetDisplayName", - "displayName": "Dataset Display Name", - "type": "shortText", - "ormType": "varchar", - "length": 256, - "required": true, + "name": "outputTokens", + "displayName": "Output Tokens", + "type": "int", + "columnName": "output_tokens", + "required": false, "unique": false, "index": false, "private": false, "encrypt": false, - "isSystem": true, - "description": "This is the display name for the dataset configuration, which can be used in UI components to represent the dataset in a user-friendly manner." + "isSystem": true }, { - "name": "description", - "displayName": "Description", - "type": "longText", - "ormType": "text", + "name": "modelUsed", + "displayName": "Model Used", + "type": "shortText", + "columnName": "model_used", + "length": 255, "required": false, "unique": false, - "index": false, + "index": true, "private": false, "encrypt": false, - "isSystem": true, - "description": "This is a description of the dataset configuration, providing context and details about its purpose." + "isSystem": true + } + ] + }, + { + "singularName": "mcpAuditLog", + "pluralName": "mcpAuditLogs", + "displayName": "MCP Audit Log", + "description": "Audit trail of MCP requests handled by the server", + "dataSource": "default", + "dataSourceType": "postgres", + "tableName": "ss_mcp_audit_log", + "isChild": false, + "isLegacyTable": true, + "isLegacyTableWithId": true, + "enableAuditTracking": false, + "enableSoftDelete": false, + "draftPublishWorkflow": false, + "internationalisation": false, + "isSystem": true, + "userKeyFieldUserKey": "id", + "fields": [ + { + "name": "userId", + "displayName": "User ID", + "type": "int", + "columnName": "user_id", + "required": false, + "unique": false, + "index": true, + "private": false, + "encrypt": false, + "isSystem": true }, { - "name": "sql", - "displayName": "SQL Query", - "type": "longText", - "ormType": "text", - "required": true, + "name": "apiKeyId", + "displayName": "API Key ID", + "type": "int", + "columnName": "api_key_id", + "required": false, "unique": false, "index": false, "private": false, @@ -5011,912 +5081,164 @@ "isSystem": true }, { - "name": "labelColumnName", - "displayName": "Label Column Name", + "name": "username", + "displayName": "Username", "type": "shortText", - "ormType": "varchar", - "length": 256, - "required": true, + "columnName": "username", + "length": 128, + "required": false, "unique": false, - "index": false, "private": false, "encrypt": false, "isSystem": true }, { - "name": "valueColumnName", - "displayName": "Value Column Name", + "name": "transport", + "displayName": "Transport", "type": "shortText", - "ormType": "varchar", - "length": 256, + "columnName": "transport", + "length": 32, "required": true, "unique": false, - "index": false, "private": false, "encrypt": false, "isSystem": true }, { - "name": "options", - "displayName": "Dataset options", - "type": "json", - "ormType": "simple-json", + "name": "mcpSessionId", + "displayName": "MCP Session ID", + "type": "shortText", + "columnName": "mcp_session_id", + "length": 64, "required": false, "unique": false, - "index": false, + "index": true, "private": false, "encrypt": false, - "isSystem": true, - "description": "This allows you to set the dataset options e.g border-color, background-color, etc. This is a JSON object that can be used to customize the dataset appearance or behavior in the UI." + "isSystem": true }, { - "name": "question", - "displayName": "Question", - "description": "Related Question Model", - "type": "relation", - "ormType": "integer", - "isSystem": true, - "relationType": "many-to-one", - "relationCoModelFieldName": "questionSqlDatasetConfigs", - "relationCreateInverse": true, - "relationCoModelSingularName": "dashboardQuestion", - "relationModelModuleName": "solid-core", - "relationCascade": "cascade", - "required": true, - "unique": false, - "index": false, - "private": false, - "encrypt": false - }, - { - "name": "externalId", - "displayName": "External ID", - "description": "Concatenation of dataset name, question name and dashboard name", - "type": "computed", - "ormType": "varchar", - "isSystem": false, - "computedFieldValueType": "string", - "computedFieldTriggerConfig": [ - { - "modelName": "dashboardQuestionSqlDatasetConfig", - "moduleName": "solid-core", - "operations": [ - "before-insert" - ] - } - ], - "computedFieldValueProvider": "ConcatEntityComputedFieldProvider", - "computedFieldValueProviderCtxt": "{\n \"fields\": [\n \"datasetName\",\n \"question.name\",\n \"question.dashboard.name\"\n ],\n \"separator\": \"-\",\n \"slugify\": true\n}", - "required": false, - "unique": true, - "index": true, - "private": false, - "encrypt": false, - "encryptionType": null, - "decryptWhen": null, - "columnName": null, - "isUserKey": true - } - ] - }, - { - "singularName": "dashboardLayout", - "pluralName": "dashboardLayouts", - "displayName": "Dashboard Layout", - "description": "This is used to maintain dashboard layouts", - "dataSource": "default", - "dataSourceType": "postgres", - "tableName": "ss_dashboard_layout", - "userKeyFieldUserKey": "externalId", - "fields": [ - { - "name": "layout", - "displayName": "Layout", - "type": "longText", - "ormType": "text", + "name": "clientAddr", + "displayName": "Client Address", + "type": "shortText", + "columnName": "client_addr", + "length": 64, "required": false, "unique": false, - "index": false, "private": false, "encrypt": false, - "isSystem": true, - "description": "This stores the dashboard layout" + "isSystem": true }, { - "name": "dashboard", - "displayName": "Dashboard", - "description": "Related Dashboard Model", - "type": "relation", - "ormType": "integer", - "isSystem": true, - "relationType": "many-to-one", - "relationCoModelFieldName": "dashboardLayouts", - "relationCreateInverse": true, - "relationCoModelSingularName": "dashboard", - "relationModelModuleName": "solid-core", - "relationCascade": "cascade", - "required": false, + "name": "method", + "displayName": "Method", + "type": "shortText", + "columnName": "method", + "length": 64, + "required": true, "unique": false, - "index": false, + "index": true, "private": false, - "encrypt": false + "encrypt": false, + "isSystem": true }, { - "name": "user", - "displayName": "User", - "description": null, - "type": "relation", - "ormType": "integer", - "isSystem": false, - "relationType": "many-to-one", - "relationCoModelFieldName": null, - "relationCreateInverse": false, - "relationCoModelSingularName": "user", - "relationCoModelColumnName": "", - "relationModelModuleName": "solid-core", - "relationCascade": "cascade", + "name": "requestId", + "displayName": "Request ID", + "type": "shortText", + "columnName": "request_id", + "length": 64, "required": false, "unique": false, "index": false, "private": false, "encrypt": false, - "encryptionType": null, - "decryptWhen": null, - "columnName": null, - "relationJoinTableName": "", - "isRelationManyToManyOwner": null - } - ] - }, - { - "singularName": "aiInteraction", - "pluralName": "aiInteractions", - "displayName": "AI Interaction", - "description": "Stores user and assistant messages in an AI chat session.", - "tableName": "ss_ai_interactions", - "dataSource": "default", - "dataSourceType": "postgres", - "userKeyFieldUserKey": "externalId", - "isSystem": false, - "fields": [ - { - "name": "user", - "displayName": "User", - "type": "relation", - "required": false, - "unique": false, - "index": true, - "private": false, - "encrypt": false, - "relationType": "many-to-one", - "relationCoModelSingularName": "user", - "relationCreateInverse": true, - "relationCascade": "cascade", - "relationModelModuleName": "solid-core", "isSystem": true }, { - "name": "externalId", - "displayName": "External ID", - "description": "Used to track using a reference number of each ai interaction.", - "type": "computed", - "ormType": "varchar", - "isSystem": false, - "computedFieldValueType": "string", - "computedFieldTriggerConfig": [ - { - "modelName": "aiInteraction", - "moduleName": "solid-core", - "operations": [ - "before-insert" - ] - } - ], - "computedFieldValueProvider": "AlphaNumExternalIdComputationProvider", - "computedFieldValueProviderCtxt": "{\n \"prefix\": \"AI\",\n \"length\": \"10\"\n}", - "required": true, - "unique": true, - "index": true, - "private": false, - "encrypt": false, - "encryptionType": null, - "decryptWhen": null, - "columnName": null, - "isUserKey": true - }, - { - "name": "threadId", - "displayName": "Thread ID", + "name": "toolName", + "displayName": "Tool Name", "type": "shortText", - "ormType": "varchar", + "columnName": "tool_name", "length": 128, - "required": true, - "unique": false, - "index": true, - "private": false, - "encrypt": false - }, - { - "name": "parentInteraction", - "displayName": "Parent Interaction", - "type": "relation", "required": false, "unique": false, "index": true, "private": false, "encrypt": false, - "relationType": "many-to-one", - "relationCoModelSingularName": "aiInteraction", - "relationCreateInverse": false, - "relationCascade": "set null", - "relationModelModuleName": "solid-core", "isSystem": true }, { - "name": "role", - "displayName": "Role", - "type": "shortText", - "ormType": "varchar", - "length": 32, - "required": true, - "unique": false, - "index": false, - "private": false, - "encrypt": false - }, - { - "name": "message", - "displayName": "Message", - "type": "longText", - "ormType": "text", - "required": true, - "unique": false, - "index": false, - "private": false, - "encrypt": false - }, - { - "name": "originalMessage", - "displayName": "Original Message", + "name": "requestParams", + "displayName": "Request Params", "type": "longText", - "ormType": "text", - "required": false, - "unique": false, - "index": false, - "private": false, - "encrypt": false - }, - { - "name": "contentType", - "displayName": "Content Type", - "type": "shortText", - "ormType": "varchar", - "length": 64, + "columnName": "request_params", "required": false, "unique": false, "index": false, "private": false, - "encrypt": false + "encrypt": false, + "isSystem": true }, { "name": "status", "displayName": "Status", - "type": "selectionStatic", - "ormType": "varchar", - "length": 64, - "required": false, + "type": "shortText", + "columnName": "status", + "length": 16, + "required": true, "unique": false, - "index": true, "private": false, "encrypt": false, - "selectionStaticValues": [ - "pending:Pending", - "failed:Failed", - "succeeded:Succeeded" - ] + "isSystem": true }, { - "name": "errorMessage", - "displayName": "Error Message", + "name": "responseResult", + "displayName": "Response Result", "type": "longText", - "ormType": "text", - "required": false, - "unique": false, - "index": false, - "private": true, - "encrypt": false - }, - { - "name": "modelUsed", - "displayName": "Model Used", - "type": "shortText", - "ormType": "varchar", - "length": 128, - "required": false, - "unique": false, - "index": false, - "private": false, - "encrypt": false - }, - { - "name": "responseTimeMs", - "displayName": "Response Time (ms)", - "type": "int", - "ormType": "integer", - "required": false, - "unique": false, - "index": false, - "private": false, - "encrypt": false - }, - { - "name": "metadata", - "displayName": "Metadata", - "type": "json", - "ormType": "simple-json", - "required": false, - "unique": false, - "index": false, - "private": false, - "encrypt": false - }, - { - "name": "isApplied", - "displayName": "Is Applied", - "type": "boolean", - "required": false, - "unique": false, - "index": false, - "private": false, - "encrypt": false - }, - { - "name": "isEdited", - "displayName": "Is Edited", - "type": "boolean", - "required": false, - "unique": false, - "index": false, - "private": false, - "encrypt": false - }, - { - "name": "isAutoApply", - "displayName": "Is Auto Apply", - "type": "boolean", + "columnName": "response_result", "required": false, "unique": false, "index": false, "private": false, - "encrypt": false + "encrypt": false, + "isSystem": true }, { - "name": "inputTokens", - "displayName": "Input Tokens", + "name": "errorCode", + "displayName": "Error Code", "type": "int", - "ormType": "integer", + "columnName": "error_code", "required": false, "unique": false, "index": false, "private": false, - "encrypt": false + "encrypt": false, + "isSystem": true }, { - "name": "outputTokens", - "displayName": "Output Tokens", - "type": "int", - "ormType": "integer", + "name": "errorMessage", + "displayName": "Error Message", + "type": "longText", + "columnName": "error_message", "required": false, "unique": false, "index": false, "private": false, - "encrypt": false + "encrypt": false, + "isSystem": true }, { - "name": "totalTokens", - "displayName": "Total Tokens", - "type": "int", - "ormType": "integer", + "name": "durationMs", + "displayName": "Duration (ms)", + "type": "decimal", + "columnName": "duration_ms", "required": false, "unique": false, "index": false, "private": false, - "encrypt": false - } - ] - }, - { - "singularName": "modelSequence", - "tableName": "ss_model_sequence", - "pluralName": "modelSequences", - "displayName": "Model Sequence", - "description": "This is used to maintain model field sequence", - "dataSource": "default", - "dataSourceType": "postgres", - "isSystem": true, - "userKeyFieldUserKey": "sequenceName", - "fields": [ - { - "name": "module", - "displayName": "Module", - "type": "relation", - "ormType": "integer", - "required": true, - "unique": false, - "index": true, - "private": false, - "encrypt": false, - "relationType": "many-to-one", - "relationCreateInverse": false, - "relationCoModelSingularName": "moduleMetadata", - "relationModelModuleName": "solid-core", - "relationCascade": "cascade", - "isSystem": true - }, - { - "name": "model", - "displayName": "Model", - "type": "relation", - "ormType": "integer", - "required": true, - "unique": false, - "index": true, - "private": false, - "encrypt": false, - "relationType": "many-to-one", - "relationCreateInverse": false, - "relationCoModelSingularName": "modelMetadata", - "relationModelModuleName": "solid-core", - "relationCascade": "cascade", - "isSystem": true - }, - { - "name": "field", - "displayName": "Field", - "type": "relation", - "ormType": "integer", - "required": true, - "unique": false, - "index": true, - "private": false, - "encrypt": false, - "relationType": "many-to-one", - "relationCreateInverse": false, - "relationCoModelSingularName": "fieldMetadata", - "relationModelModuleName": "solid-core", - "relationCascade": "cascade", - "isSystem": true - }, - { - "name": "sequenceName", - "displayName": "Sequence Name", - "type": "shortText", - "ormType": "varchar", - "length": 512, - "required": true, - "unique": true, - "index": true, - "private": false, - "encrypt": false, - "isSystem": false, - "isUserKey": true - }, - { - "name": "currentValue", - "displayName": "Current Value", - "type": "int", - "ormType": "integer", - "required": false, - "unique": false, - "index": false, - "private": false, - "encrypt": false, - "isSystem": false, - "defaultValue": "1" - }, - { - "name": "prefix", - "displayName": "Prefix", - "type": "shortText", - "ormType": "varchar", - "length": 512, - "required": false, - "unique": false, - "index": false, - "private": false, - "encrypt": false, - "isSystem": false - }, - { - "name": "padding", - "displayName": "Padding", - "type": "int", - "ormType": "integer", - "required": false, - "unique": false, - "index": false, - "private": false, - "encrypt": false, - "isSystem": false, - "defaultValue": "5" - }, - { - "name": "separator", - "displayName": "Separator", - "type": "shortText", - "ormType": "varchar", - "length": 512, - "required": false, - "unique": false, - "index": false, - "private": false, - "encrypt": false, - "isSystem": false - } - ] - }, - { - "singularName": "agentSession", - "pluralName": "agentSessions", - "displayName": "Agent Session", - "description": "AI agent sessions", - "dataSource": "default", - "dataSourceType": "postgres", - "tableName": "ss_agent_sessions", - "isChild": false, - "isLegacyTable": true, - "isLegacyTableWithId": true, - "enableAuditTracking": false, - "enableSoftDelete": false, - "draftPublishWorkflow": false, - "internationalisation": false, - "isSystem": true, - "userKeyFieldUserKey": "sessionId", - "fields": [ - { - "name": "sessionId", - "displayName": "Session ID", - "type": "shortText", - "columnName": "session_id", - "length": 36, - "required": true, - "unique": true, - "index": true, - "private": false, - "encrypt": false, - "isSystem": true - }, - { - "name": "userId", - "displayName": "User ID", - "type": "int", - "columnName": "user_id", - "required": false, - "unique": false, - "index": true, - "private": false, - "encrypt": false, - "isSystem": true - }, - { - "name": "projectRoot", - "displayName": "Project Root", - "type": "longText", - "columnName": "project_root", - "required": false, - "unique": false, - "index": false, - "private": false, - "encrypt": false, - "isSystem": true - }, - { - "name": "modelName", - "displayName": "Model Name", - "type": "shortText", - "columnName": "model_name", - "length": 255, - "required": true, - "unique": false, - "index": false, - "private": false, - "encrypt": false, - "isSystem": true - }, - { - "name": "status", - "displayName": "Status", - "type": "selectionStatic", - "columnName": "status", - "length": 32, - "required": true, - "unique": false, - "index": true, - "private": false, - "encrypt": false, - "isSystem": true, - "selectionStaticValues": ["active:Active", "completed:Completed", "failed:Failed", "cancelled:Cancelled"], - "selectionValueType": "string" - }, - { - "name": "totalCost", - "displayName": "Total Cost", - "type": "decimal", - "columnName": "total_cost", - "required": true, - "unique": false, - "index": false, - "private": false, - "encrypt": false, - "isSystem": true, - "defaultValue": "0" - }, - { - "name": "totalSteps", - "displayName": "Total Steps", - "type": "int", - "columnName": "total_steps", - "required": true, - "unique": false, - "index": false, - "private": false, - "encrypt": false, - "isSystem": true, - "defaultValue": "0" - }, - { - "name": "totalInputTokens", - "displayName": "Total Input Tokens", - "type": "int", - "columnName": "total_input_tokens", - "required": true, - "unique": false, - "index": false, - "private": false, - "encrypt": false, - "isSystem": true, - "defaultValue": "0" - }, - { - "name": "totalOutputTokens", - "displayName": "Total Output Tokens", - "type": "int", - "columnName": "total_output_tokens", - "required": true, - "unique": false, - "index": false, - "private": false, - "encrypt": false, - "isSystem": true, - "defaultValue": "0" - }, - { - "name": "summary", - "displayName": "Summary", - "type": "longText", - "columnName": "summary", - "required": false, - "unique": false, - "index": false, - "private": false, - "encrypt": false, - "isSystem": true - } - ] - }, - { - "singularName": "agentEvent", - "pluralName": "agentEvents", - "displayName": "Agent Event", - "description": "AI agent events per session", - "dataSource": "default", - "dataSourceType": "postgres", - "tableName": "ss_agent_events", - "isChild": true, - "isLegacyTable": true, - "isLegacyTableWithId": true, - "enableAuditTracking": false, - "enableSoftDelete": false, - "draftPublishWorkflow": false, - "internationalisation": false, - "isSystem": true, - "userKeyFieldUserKey": "id", - "fields": [ - { - "name": "sessionId", - "displayName": "Session ID", - "type": "shortText", - "columnName": "session_id", - "length": 36, - "required": true, - "unique": false, - "index": true, - "private": false, - "encrypt": false, - "isSystem": true - }, - { - "name": "turnNumber", - "displayName": "Turn Number", - "type": "int", - "columnName": "turn_number", - "required": true, - "unique": false, - "index": false, - "private": false, - "encrypt": false, - "isSystem": true - }, - { - "name": "stepNumber", - "displayName": "Step Number", - "type": "int", - "columnName": "step_number", - "required": false, - "unique": false, - "index": false, - "private": false, - "encrypt": false, - "isSystem": true - }, - { - "name": "eventType", - "displayName": "Event Type", - "type": "selectionStatic", - "columnName": "event_type", - "length": 64, - "required": true, - "unique": false, - "index": true, - "private": false, - "encrypt": false, - "isSystem": true, - "selectionStaticValues": [ - "user_message:User Message", - "assistant_message:Assistant Message", - "tool_use:Tool Use", - "tool_result:Tool Result", - "system_prompt:System Prompt", - "thinking:Thinking", - "error:Error", - "session_start:Session Start", - "session_end:Session End" - ], - "selectionValueType": "string" - }, - { - "name": "eventData", - "displayName": "Event Data", - "type": "json", - "columnName": "event_data", - "required": false, - "unique": false, - "index": false, - "private": false, - "encrypt": false, - "isSystem": true - }, - { - "name": "content", - "displayName": "Content", - "type": "longText", - "columnName": "content", - "required": false, - "unique": false, - "index": false, - "private": false, - "encrypt": false, - "isSystem": true - }, - { - "name": "toolName", - "displayName": "Tool Name", - "type": "shortText", - "columnName": "tool_name", - "length": 128, - "required": false, - "unique": false, - "index": true, - "private": false, - "encrypt": false, - "isSystem": true - }, - { - "name": "toolArguments", - "displayName": "Tool Arguments", - "type": "json", - "columnName": "tool_arguments", - "required": false, - "unique": false, - "index": false, - "private": false, - "encrypt": false, - "isSystem": true - }, - { - "name": "toolOutput", - "displayName": "Tool Output", - "type": "json", - "columnName": "tool_output", - "required": false, - "unique": false, - "index": false, - "private": false, - "encrypt": false, - "isSystem": true - }, - { - "name": "toolReturncode", - "displayName": "Tool Return Code", - "type": "int", - "columnName": "tool_returncode", - "required": false, - "unique": false, - "index": false, - "private": false, - "encrypt": false, - "isSystem": true - }, - { - "name": "durationMs", - "displayName": "Duration (ms)", - "type": "decimal", - "columnName": "duration_ms", - "required": false, - "unique": false, - "index": false, - "private": false, - "encrypt": false, - "isSystem": true - }, - { - "name": "cost", - "displayName": "Cost", - "type": "decimal", - "columnName": "cost", - "required": false, - "unique": false, - "index": false, - "private": false, - "encrypt": false, - "isSystem": true - }, - { - "name": "inputTokens", - "displayName": "Input Tokens", - "type": "int", - "columnName": "input_tokens", - "required": false, - "unique": false, - "index": false, - "private": false, - "encrypt": false, - "isSystem": true - }, - { - "name": "outputTokens", - "displayName": "Output Tokens", - "type": "int", - "columnName": "output_tokens", - "required": false, - "unique": false, - "index": false, - "private": false, - "encrypt": false, - "isSystem": true - }, - { - "name": "modelUsed", - "displayName": "Model Used", - "type": "shortText", - "columnName": "model_used", - "length": 255, - "required": false, - "unique": false, - "index": false, - "private": false, - "encrypt": false, - "isSystem": true + "encrypt": false, + "isSystem": true } ] } @@ -5925,7 +5247,8 @@ "permissions": [ "mcp:invoke", "agent:invoke", - "settings:view_encrypted" + "settings:view_encrypted", + "dashboard:queue-health:*" ], "roles": [ { @@ -5970,6 +5293,7 @@ "ChatterMessageController.postMessage", "ChatterMessageController.findMany", "ChatterMessageController.markCompleted", + "ChatterMessageController.updateCustomNoteMessage", "ImportTransactionController.getImportTemplate", "ImportTransactionController.getImportInstructions", "ImportTransactionController.getImportMappingInfo", @@ -5985,6 +5309,14 @@ "AgentSessionController.findOne", "AgentEventController.findMany", "AgentEventController.findOne", + "McpAuditLogController.findMany", + "McpAuditLogController.findOne", + "DashboardController.getDefinition", + "DashboardController.getWidgetData", + "DashboardController.getDashboardData", + "DashboardController.getVariableOptions", + "DashboardController.getLayout", + "DashboardController.saveLayout", "mcp:invoke", "agent:invoke" ] @@ -6026,10 +5358,10 @@ { "displayName": "Model List Action", "name": "modelMetadata-list-action", - "type": "custom", + "type": "solid", "domain": "", "context": "", - "customComponent": "/admin/core/solid-core/model-metadata/list", + "customComponent": "", "customIsModal": true, "serverEndpoint": "", "viewUserKey": "modelMetadata-list-view", @@ -6039,16 +5371,29 @@ { "displayName": "Field List Action", "name": "fieldMetadata-list-action", - "type": "custom", + "type": "solid", "domain": "", "context": "", - "customComponent": "/admin/core/solid-core/field-metadata/list", + "customComponent": "", "customIsModal": true, "serverEndpoint": "", "viewUserKey": "fieldMetadata-list-view", "moduleUserKey": "solid-core", "modelUserKey": "fieldMetadata" }, + { + "displayName": "Field Metadata Tree Action", + "name": "fieldMetadata-tree-action", + "type": "solid", + "domain": "", + "context": "", + "customComponent": "", + "customIsModal": true, + "serverEndpoint": "", + "viewUserKey": "fieldMetadata-tree-view", + "moduleUserKey": "solid-core", + "modelUserKey": "fieldMetadata" + }, { "displayName": "View Metadata List Action", "name": "viewMetadata-list-action", @@ -6440,160 +5785,281 @@ "modelUserKey": "setting" }, { - "displayName": "Dashboard List Action", - "name": "dashboard-list-action", + "displayName": "Model Sequence List Action", + "name": "modelSequence-list-action", + "type": "solid", + "domain": "", + "context": "", + "customComponent": "", + "customIsModal": true, + "serverEndpoint": "", + "viewUserKey": "modelSequence-list-view", + "moduleUserKey": "solid-core", + "modelUserKey": "modelSequence" + }, + { + "displayName": "Agent Session List Action", + "name": "agentSession-list-action", + "type": "solid", + "domain": "", + "context": "", + "customComponent": "", + "customIsModal": true, + "serverEndpoint": "", + "viewUserKey": "agentSession-list-view", + "moduleUserKey": "solid-core", + "modelUserKey": "agentSession" + }, + { + "displayName": "Agent Session Form Action", + "name": "agentSession-form-action", "type": "solid", "domain": "", "context": "", "customComponent": "", "customIsModal": true, "serverEndpoint": "", - "viewUserKey": "dashboard-list-view", + "viewUserKey": "agentSession-form-view", "moduleUserKey": "solid-core", - "modelUserKey": "dashboard" + "modelUserKey": "agentSession" }, { - "displayName": "Dashboard Variable List Action", - "name": "dashboardVariable-list-action", + "displayName": "Agent Event List Action", + "name": "agentEvent-list-action", "type": "solid", "domain": "", "context": "", "customComponent": "", "customIsModal": true, "serverEndpoint": "", - "viewUserKey": "dashboardVariable-list-view", + "viewUserKey": "agentEvent-list-view", "moduleUserKey": "solid-core", - "modelUserKey": "dashboardVariable" + "modelUserKey": "agentEvent" }, { - "displayName": "Dashboard Question List Action", - "name": "dashboardQuestion-list-action", + "displayName": "Agent Event Form Action", + "name": "agentEvent-form-action", "type": "solid", "domain": "", "context": "", "customComponent": "", "customIsModal": true, "serverEndpoint": "", - "viewUserKey": "dashboardQuestion-list-view", + "viewUserKey": "agentEvent-form-view", "moduleUserKey": "solid-core", - "modelUserKey": "dashboardQuestion" + "modelUserKey": "agentEvent" }, { - "displayName": "Dashboard Layout List Action", - "name": "dashboardLayout-list-action", + "displayName": "MCP Audit Log List Action", + "name": "mcpAuditLog-list-action", "type": "solid", "domain": "", "context": "", "customComponent": "", "customIsModal": true, "serverEndpoint": "", - "viewUserKey": "dashboardLayout-list-view", + "viewUserKey": "mcpAuditLog-list-view", "moduleUserKey": "solid-core", - "modelUserKey": "dashboardLayout" + "modelUserKey": "mcpAuditLog" }, { - "displayName": "Dashboard Question SQL Dataset Config List Action", - "name": "dashboardQuestionSqlDatasetConfig-list-action", + "displayName": "MCP Audit Log Form Action", + "name": "mcpAuditLog-form-action", "type": "solid", "domain": "", "context": "", "customComponent": "", "customIsModal": true, "serverEndpoint": "", - "viewUserKey": "dashboardQuestionSqlDatasetConfig-list-view", + "viewUserKey": "mcpAuditLog-form-view", + "moduleUserKey": "solid-core", + "modelUserKey": "mcpAuditLog" + }, + { + "displayName": "Queue Health", + "name": "solid-core-queue-health-dashboard-action", + "type": "custom", + "domain": "", + "context": "", + "customComponent": "/admin/core/solid-core/dashboard/queue-health", + "customIsModal": false, + "serverEndpoint": "", + "viewUserKey": "", "moduleUserKey": "solid-core", - "modelUserKey": "dashboardQuestionSqlDatasetConfig" + "modelUserKey": "mqMessage" }, { - "displayName": "AI Interactions", - "name": "aiInteraction-list-action", + "displayName": "Dashboard User Layout List Action", + "name": "dashboardUserLayout-list-action", "type": "solid", "domain": "", "context": "", "customComponent": "", "customIsModal": true, "serverEndpoint": "", - "viewUserKey": "aiInteraction-list-view", + "viewUserKey": "dashboardUserLayout-list-view", "moduleUserKey": "solid-core", - "modelUserKey": "aiInteraction" + "modelUserKey": "dashboardUserLayout" }, { - "displayName": "Import Error Logs List Action", - "name": "importTransactionErrorLog-list-action", + "displayName": "Dashboard User Layout Tree View Action", + "name": "dashboardUserLayout-tree-action", "type": "solid", "domain": "", "context": "", "customComponent": "", "customIsModal": true, "serverEndpoint": "", - "viewUserKey": "importTransactionErrorLog-list-view", + "viewUserKey": "dashboardUserLayout-tree-view", "moduleUserKey": "solid-core", - "modelUserKey": "importTransactionErrorLog" + "modelUserKey": "dashboardUserLayout" }, { - "displayName": "Model Sequence List Action", - "name": "modelSequence-list-action", + "displayName": "Model Tree Action", + "name": "modelMetadata-tree-action", "type": "solid", "domain": "", "context": "", "customComponent": "", "customIsModal": true, "serverEndpoint": "", - "viewUserKey": "modelSequence-list-view", + "viewUserKey": "modelMetadata-tree-view", "moduleUserKey": "solid-core", - "modelUserKey": "modelSequence" + "modelUserKey": "modelMetadata" }, { - "displayName": "Agent Session List Action", - "name": "agentSession-list-action", + "displayName": "Menu Item Metadata Tree Action", + "name": "menuItemMetadata-tree-action", "type": "solid", "domain": "", "context": "", "customComponent": "", "customIsModal": true, "serverEndpoint": "", - "viewUserKey": "agentSession-list-view", + "viewUserKey": "menuItemMetadata-tree-view", "moduleUserKey": "solid-core", - "modelUserKey": "agentSession" + "modelUserKey": "menuItemMetadata" }, { - "displayName": "Agent Session Form Action", - "name": "agentSession-form-action", + "displayName": "View Metadata Tree Action", + "name": "viewMetadata-tree-action", "type": "solid", "domain": "", "context": "", "customComponent": "", "customIsModal": true, "serverEndpoint": "", - "viewUserKey": "agentSession-form-view", + "viewUserKey": "viewMetadata-tree-view", "moduleUserKey": "solid-core", - "modelUserKey": "agentSession" + "modelUserKey": "viewMetadata" }, { - "displayName": "Agent Event List Action", - "name": "agentEvent-list-action", + "displayName": "Action Metadata Tree Action", + "name": "actionMetadata-tree-action", "type": "solid", "domain": "", "context": "", "customComponent": "", "customIsModal": true, "serverEndpoint": "", - "viewUserKey": "agentEvent-list-view", + "viewUserKey": "actionMetadata-tree-view", "moduleUserKey": "solid-core", - "modelUserKey": "agentEvent" + "modelUserKey": "actionMetadata" }, { - "displayName": "Agent Event Form Action", - "name": "agentEvent-form-action", + "displayName": "User View Metadata Tree Action", + "name": "userViewMetadata-tree-action", "type": "solid", "domain": "", "context": "", "customComponent": "", "customIsModal": true, "serverEndpoint": "", - "viewUserKey": "agentEvent-form-view", + "viewUserKey": "userViewMetadata-tree-view", "moduleUserKey": "solid-core", - "modelUserKey": "agentEvent" + "modelUserKey": "userViewMetadata" + }, + { + "displayName": "Media Tree Action", + "name": "media-tree-action", + "type": "solid", + "domain": "", + "context": "", + "customComponent": "", + "customIsModal": true, + "serverEndpoint": "", + "viewUserKey": "media-tree-view", + "moduleUserKey": "solid-core", + "modelUserKey": "media" + }, + { + "name": "roleMetadata-tree-action", + "displayName": "Roles Tree", + "type": "solid", + "moduleUserKey": "solid-core", + "modelUserKey": "roleMetadata", + "viewUserKey": "roleMetadata-tree-view" + }, + { + "name": "securityRule-tree-action", + "displayName": "Security Rules Tree", + "type": "solid", + "moduleUserKey": "solid-core", + "modelUserKey": "securityRule", + "viewUserKey": "securityRule-tree-view" + }, + { + "name": "modelSequence-tree-action", + "displayName": "Model Sequences Tree", + "type": "solid", + "moduleUserKey": "solid-core", + "modelUserKey": "modelSequence", + "viewUserKey": "modelSequence-tree-view" + }, + { + "name": "listOfValues-tree-action", + "displayName": "List Of Values Tree", + "type": "solid", + "moduleUserKey": "solid-core", + "modelUserKey": "listOfValues", + "viewUserKey": "listOfValues-tree-view" + }, + { + "name": "chatterMessage-tree-action", + "displayName": "Chatter Messages Tree", + "type": "solid", + "moduleUserKey": "solid-core", + "modelUserKey": "chatterMessage", + "viewUserKey": "chatterMessage-tree-view" + }, + { + "name": "scheduledJob-tree-action", + "displayName": "Scheduled Jobs Tree", + "type": "solid", + "moduleUserKey": "solid-core", + "modelUserKey": "scheduledJob", + "viewUserKey": "scheduledJob-tree-view" + }, + { + "name": "savedFilters-tree-action", + "displayName": "Saved Filters Tree", + "type": "solid", + "moduleUserKey": "solid-core", + "modelUserKey": "savedFilters", + "viewUserKey": "savedFilters-tree-view" + }, + { + "displayName": "Role Tree View Action", + "name": "roleMetadata-tree-action", + "type": "solid", + "domain": "", + "context": "", + "customComponent": "", + "customIsModal": true, + "serverEndpoint": "", + "viewUserKey": "roleMetadata-tree-view", + "moduleUserKey": "solid-core", + "modelUserKey": "roleMetadata" } ], "menus": [ @@ -6606,6 +6072,15 @@ "parentMenuItemUserKey": "", "iconName": "app_registration" }, + { + "displayName": "Layout Builder", + "name": "layoutBuilder-menu-item", + "sequenceNumber": 2, + "actionUserKey": "", + "moduleUserKey": "solid-core", + "parentMenuItemUserKey": "", + "iconName": "space_dashboard" + }, { "displayName": "Module", "name": "moduleMetadata-menu-item", @@ -6628,16 +6103,7 @@ "sequenceNumber": 3, "actionUserKey": "fieldMetadata-list-action", "moduleUserKey": "solid-core", - "parentMenuItemUserKey": "appBuilder-menu-item" - }, - { - "displayName": "Layout Builder", - "name": "layoutBuilder-menu-item", - "sequenceNumber": 2, - "actionUserKey": "", - "moduleUserKey": "solid-core", - "parentMenuItemUserKey": "", - "iconName": "space_dashboard" + "parentMenuItemUserKey": "appBuilder-menu-item" }, { "displayName": "Menu Item", @@ -6755,10 +6221,10 @@ "iconName": "low_priority" }, { - "displayName": "Messages", - "name": "mqMessage-menu-item", + "displayName": "Queue Health", + "name": "solid-core-queue-health-dashboard-menu-item", "sequenceNumber": 1, - "actionUserKey": "mqMessage-list-action", + "actionUserKey": "solid-core-queue-health-dashboard-action", "moduleUserKey": "solid-core", "parentMenuItemUserKey": "queues-menu-item" }, @@ -6770,6 +6236,14 @@ "moduleUserKey": "solid-core", "parentMenuItemUserKey": "queues-menu-item" }, + { + "displayName": "Messages", + "name": "mqMessage-menu-item", + "sequenceNumber": 3, + "actionUserKey": "mqMessage-list-action", + "moduleUserKey": "solid-core", + "parentMenuItemUserKey": "queues-menu-item" + }, { "displayName": "Notification", "name": "notification-menu-item", @@ -6820,14 +6294,6 @@ "moduleUserKey": "solid-core", "parentMenuItemUserKey": "other-menu-item" }, - { - "displayName": "AI Interactions", - "name": "aiInteraction-menu-item", - "sequenceNumber": 3, - "actionUserKey": "aiInteraction-list-action", - "moduleUserKey": "solid-core", - "parentMenuItemUserKey": "other-menu-item" - }, { "displayName": "Saved Filters", "name": "savedFilters-menu-item", @@ -6844,14 +6310,6 @@ "moduleUserKey": "solid-core", "parentMenuItemUserKey": "other-menu-item" }, - { - "displayName": "Chatter Message Details", - "name": "chatterMessageDetails-menu-item", - "sequenceNumber": 6, - "actionUserKey": "chatterMessageDetails-list-action", - "moduleUserKey": "solid-core", - "parentMenuItemUserKey": "other-menu-item" - }, { "displayName": "Locale", "name": "locale-menu-item", @@ -6868,47 +6326,6 @@ "moduleUserKey": "solid-core", "parentMenuItemUserKey": "other-menu-item" }, - { - "displayName": "Import Error Logs", - "name": "importTransactionErrorLog-menu-item", - "sequenceNumber": 9, - "actionUserKey": "importTransactionErrorLog-list-action", - "moduleUserKey": "solid-core", - "parentMenuItemUserKey": "other-menu-item" - }, - { - "displayName": "Dashboards", - "name": "dashboardManagement-menu-item", - "sequenceNumber": 8, - "actionUserKey": "", - "moduleUserKey": "solid-core", - "parentMenuItemUserKey": "", - "iconName": "dashboard_customize" - }, - { - "displayName": "Dashboard", - "name": "dashboard-menu-item", - "sequenceNumber": 1, - "actionUserKey": "dashboard-list-action", - "moduleUserKey": "solid-core", - "parentMenuItemUserKey": "dashboardManagement-menu-item" - }, - { - "displayName": "Dashboard Question", - "name": "dashboardQuestion-menu-item", - "sequenceNumber": 2, - "actionUserKey": "dashboardQuestion-list-action", - "moduleUserKey": "solid-core", - "parentMenuItemUserKey": "dashboardManagement-menu-item" - }, - { - "displayName": "Dashboard Layout", - "name": "dashboardLayout-menu-item", - "sequenceNumber": 3, - "actionUserKey": "dashboardLayout-list-action", - "moduleUserKey": "solid-core", - "parentMenuItemUserKey": "dashboardManagement-menu-item" - }, { "displayName": "Settings", "name": "settings-menu-item", @@ -6926,6 +6343,15 @@ "moduleUserKey": "solid-core", "parentMenuItemUserKey": "other-menu-item" }, + { + "displayName": "Dashboard User Layout", + "name": "dashboardUserLayout-menu-item", + "sequenceNumber": 42, + "actionUserKey": "dashboardUserLayout-list-action", + "moduleUserKey": "solid-core", + "parentMenuItemUserKey": "", + "iconName": "" + }, { "displayName": "Agent", "name": "agent-menu-item", @@ -6944,10 +6370,10 @@ "parentMenuItemUserKey": "agent-menu-item" }, { - "displayName": "Events", - "name": "agentEvent-menu-item", - "sequenceNumber": 2, - "actionUserKey": "agentEvent-list-action", + "displayName": "MCP Audit Log", + "name": "mcpAuditLog-menu-item", + "sequenceNumber": 3, + "actionUserKey": "mcpAuditLog-list-action", "moduleUserKey": "solid-core", "parentMenuItemUserKey": "agent-menu-item" } @@ -6962,6 +6388,7 @@ "modelUserKey": "moduleMetadata", "layout": { "type": "list", + "onListLoad": "moduleMetadataListOnLoad", "attrs": { "pagination": true, "pageSizeOptions": [ @@ -6973,6 +6400,39 @@ "create": true, "edit": true, "delete": false, + "headerButtons": [ + { + "attrs": { + "label": "Import Module", + "action": "ModuleImportListHeaderAction", + "actionInContextMenu": false, + "env": [ + "dev" + ], + "openInPopup": true, + "icon": "si-upload", + "closable": false, + "popupWidth": "min(1180px, calc(100vw - 32px))", + "customComponentIsSystem": true + } + }, + { + "attrs": { + "label": "Clear Package Runtime", + "action": "ClearModulePackageRuntimeHeaderAction", + "actionInContextMenu": true, + "env": [ + "dev" + ], + "openInPopup": true, + "icon": "pi pi-trash", + "closable": true, + "popupWidth": "min(820px, calc(100vw - 32px))", + "buttonClassName": "solid-header-dropdown-item-danger", + "customComponentIsSystem": true + } + } + ], "rowButtons": [ { "attrs": { @@ -6995,57 +6455,71 @@ "actionInContextMenu": true, "customComponentIsSystem": true } + }, + { + "attrs": { + "className": "p-button-text", + "icon": "pi pi-download", + "label": "Export Module", + "action": "ExportModulePackageRowAction", + "openInPopup": true, + "actionInContextMenu": true, + "customComponentIsSystem": true, + "env": [ + "dev" + ], + "popupWidth": "32vw" + } } - ] + ], + "import": false, + "export": false }, "children": [ { "type": "field", "attrs": { - "name": "displayName", - "isSearchable": true + "name": "name", + "isSearchable": true, + "sortable": true } }, { "type": "field", "attrs": { - "name": "name", + "name": "displayName", "isSearchable": true } }, { "type": "field", "attrs": { - "name": "description", - "isSearchable": true + "name": "description" } }, { "type": "field", "attrs": { - "name": "menuIconUrl", - "isSearchable": true + "name": "menuIconUrl" } }, { "type": "field", "attrs": { "name": "menuSequenceNumber", - "isSearchable": true + "sortable": true } }, { "type": "field", "attrs": { - "name": "defaultDataSource", - "isSearchable": true + "name": "defaultDataSource" } }, { "type": "field", "attrs": { - "name": "isSystem", - "isSearchable": true + "name": "isSystem" } } ] @@ -7153,7 +6627,7 @@ ], "enableGlobalSearch": true, "truncateAfter": 50, - "create": true, + "create": false, "edit": true, "delete": false, "rowButtons": [ @@ -7179,27 +6653,34 @@ "openInPopup": true } } + ], + "import": false, + "export": false, + "allowedViews": [ + "list", + "tree" ] }, "children": [ { "type": "field", "attrs": { - "name": "singularName", + "name": "module", "isSearchable": true } }, { "type": "field", "attrs": { - "name": "tableName", - "isSearchable": true + "name": "singularName", + "isSearchable": true, + "sortable": true } }, { "type": "field", "attrs": { - "name": "pluralName", + "name": "tableName", "isSearchable": true } }, @@ -7221,21 +6702,13 @@ "type": "field", "attrs": { "name": "dataSource", - "isSearchable": true - } - }, - { - "type": "field", - "attrs": { - "name": "enableSoftDelete", - "isSearchable": true + "isSearchable": false } }, { "type": "field", "attrs": { - "name": "module", - "isSearchable": true + "name": "enableSoftDelete" } } ] @@ -7253,7 +6726,9 @@ "attrs": { "name": "form-1", "label": "Model Metadata", - "className": "grid" + "className": "grid", + "showAddFormButton": false, + "showEditFormButton": false }, "children": [ { @@ -7357,30 +6832,59 @@ 50 ], "enableGlobalSearch": true, - "create": true, - "edit": true, - "delete": true + "create": false, + "edit": false, + "delete": false, + "import": false, + "export": false, + "allowedViews": [ + "list", + "tree" + ] }, "children": [ + { + "type": "field", + "attrs": { + "name": "model", + "label": "Model", + "isSearchable": true, + "searchField": "model.singularName" + } + }, + { + "type": "field", + "attrs": { + "name": "view", + "label": "View", + "isSearchable": true, + "searchField": "view.name" + } + }, { "type": "field", "attrs": { "name": "name", - "isSearchable": true + "label": "Name", + "isSearchable": true, + "sortable": true } }, { "type": "field", "attrs": { - "name": "model", - "isSearchable": true + "name": "isPrivate", + "isSearchable": false } }, { "type": "field", "attrs": { - "name": "view", - "isSearchable": true + "name": "user", + "label": "User", + "isSearchable": true, + "searchField": "user.fullName", + "coModelFieldToDisplay": "fullName" } } ] @@ -7398,7 +6902,9 @@ "attrs": { "name": "form-1", "label": "Solid Saved Filter Model", - "className": "grid" + "className": "grid", + "showAddFormButton": false, + "showEditFormButton": false }, "children": [ { @@ -7408,48 +6914,84 @@ }, "children": [ { - "type": "group", + "type": "notebook", "attrs": { - "name": "group-1", - "label": "", - "className": "col-12 sm:col-12 md:col-6 lg:col-6" + "name": "notebook-1" }, "children": [ { - "type": "field", - "attrs": { - "name": "name" - } - }, - { - "type": "field", - "attrs": { - "name": "filterQueryJson" - } - }, - { - "type": "field", - "attrs": { - "name": "view" - } - }, - { - "type": "field", - "attrs": { - "name": "model" - } - }, - { - "type": "field", + "type": "page", "attrs": { - "name": "user" - } + "name": "page-general", + "label": "General" + }, + "children": [ + { + "type": "group", + "attrs": { + "name": "group-1", + "label": "", + "className": "col-12 sm:col-12 md:col-6 lg:col-6" + }, + "children": [ + { + "type": "field", + "attrs": { + "name": "name" + } + }, + { + "type": "field", + "attrs": { + "name": "filterQueryJson" + } + }, + { + "type": "field", + "attrs": { + "name": "view" + } + }, + { + "type": "field", + "attrs": { + "name": "model" + } + }, + { + "type": "field", + "attrs": { + "name": "user" + } + }, + { + "type": "field", + "attrs": { + "name": "isPrivate" + } + } + ] + } + ] }, { - "type": "field", + "type": "page", "attrs": { - "name": "isPrivate" - } + "name": "page-explorer", + "label": "Explorer" + }, + "children": [ + { + "type": "custom", + "attrs": { + "name": "metadata-explorer-widget", + "widget": "MetadataExplorerFormWidget", + "moduleName": "solid-core", + "arrayPath": "savedFilters", + "itemNameField": "name" + } + } + ] } ] } @@ -7470,7 +7012,9 @@ "attrs": { "name": "form-1", "label": "Solid List of Values Model", - "className": "grid" + "className": "grid", + "showAddFormButton": true, + "showEditFormButton": true }, "children": [ { @@ -7555,55 +7099,70 @@ "enableGlobalSearch": true, "create": true, "edit": true, - "delete": true + "delete": true, + "import": true, + "export": true, + "allowedViews": [ + "list", + "tree" + ] }, "children": [ { "type": "field", "attrs": { - "name": "type", - "isSearchable": true + "name": "module", + "label": "Module", + "isSearchable": true, + "searchField": "module.name" } }, { "type": "field", "attrs": { - "name": "value", + "name": "type", + "label": "Type", "isSearchable": true } }, { "type": "field", "attrs": { - "name": "display", + "name": "value", + "label": "Value", "isSearchable": true } }, { "type": "field", "attrs": { - "name": "description", + "name": "display", + "label": "Display Value", "isSearchable": true } }, { "type": "field", "attrs": { - "name": "default", - "isSearchable": true + "name": "description", + "label": "Description", + "isSearchable": false } }, { "type": "field", "attrs": { "name": "sequence", - "isSearchable": true + "label": "Sequence", + "isSearchable": false } }, { "type": "field", "attrs": { - "name": "module" + "name": "default", + "label": "Default", + "isSearchable": false } } ] @@ -7626,9 +7185,11 @@ 50 ], "enableGlobalSearch": true, - "create": true, - "edit": true, - "delete": true + "create": false, + "edit": false, + "delete": false, + "import": false, + "export": false }, "children": [ { @@ -7642,35 +7203,14 @@ "type": "field", "attrs": { "name": "type", - "isSearchable": true - } - }, - { - "type": "field", - "attrs": { - "name": "region", - "isSearchable": true - } - }, - { - "type": "field", - "attrs": { - "name": "bucketName", - "isSearchable": true + "isSearchable": false } }, { "type": "field", "attrs": { "name": "isPublic", - "isSearchable": true - } - }, - { - "type": "field", - "attrs": { - "name": "localPath", - "isSearchable": true + "isSearchable": false } }, { @@ -7695,7 +7235,9 @@ "attrs": { "name": "form-1", "label": "Solid Media Storage Provider Metadata Model", - "className": "grid" + "className": "grid", + "showAddFormButton": false, + "showEditFormButton": false }, "children": [ { @@ -7921,7 +7463,13 @@ "enableGlobalSearch": true, "create": false, "edit": false, - "delete": false + "delete": false, + "import": false, + "export": false, + "allowedViews": [ + "list", + "tree" + ] }, "children": [ { @@ -7955,7 +7503,9 @@ { "type": "field", "attrs": { - "name": "user" + "name": "user", + "isSearchable": true, + "searchField": "user.fullName" } } ] @@ -7980,72 +7530,116 @@ }, "children": [ { - "type": "sheet", + "type": "notebook", "attrs": { - "name": "sheet-1" + "name": "notebook-1" }, "children": [ { - "type": "group", + "type": "page", "attrs": { - "name": "group-1", - "label": "", - "className": "col-12 sm:col-12 md:col-6 lg:col-6" + "name": "page-general", + "label": "General" }, "children": [ { - "type": "field", - "attrs": { - "name": "messageType" - } - }, - { - "type": "field", - "attrs": { - "name": "messageSubType" - } - }, - { - "type": "field", - "attrs": { - "name": "modelDisplayName" - } - }, - { - "type": "field", - "attrs": { - "name": "coModelEntityId" - } - }, - { - "type": "field", - "attrs": { - "name": "modelUserKey" - } - }, - { - "type": "field", - "attrs": { - "name": "user" - } - }, - { - "type": "field", - "attrs": { - "name": "messageBody" - } - }, - { - "type": "field", + "type": "sheet", "attrs": { - "name": "messageAttachments" - } - }, + "name": "sheet-1" + }, + "children": [ + { + "type": "group", + "attrs": { + "name": "group-1", + "label": "", + "className": "col-12 sm:col-12 md:col-6 lg:col-6" + }, + "children": [ + { + "type": "field", + "attrs": { + "name": "messageType" + } + }, + { + "type": "field", + "attrs": { + "name": "messageSubType" + } + }, + { + "type": "field", + "attrs": { + "name": "modelDisplayName" + } + }, + { + "type": "field", + "attrs": { + "name": "coModelEntityId" + } + }, + { + "type": "field", + "attrs": { + "name": "modelUserKey" + } + }, + { + "type": "field", + "attrs": { + "name": "user" + } + }, + { + "type": "field", + "attrs": { + "name": "messageBody" + } + }, + { + "type": "field", + "attrs": { + "name": "messageAttachments" + } + } + ] + } + ] + } + ] + }, + { + "type": "page", + "attrs": { + "name": "page-details", + "label": "Details" + }, + "children": [ { - "type": "field", + "type": "sheet", "attrs": { - "name": "chatterMessageDetails" - } + "name": "sheet-details" + }, + "children": [ + { + "type": "group", + "attrs": { + "name": "group-details", + "label": "", + "className": "col-12" + }, + "children": [ + { + "type": "field", + "attrs": { + "name": "chatterMessageDetails" + } + } + ] + } + ] } ] } @@ -8174,57 +7768,51 @@ 50 ], "enableGlobalSearch": true, - "create": true, - "edit": true, - "delete": true + "create": false, + "edit": false, + "delete": false, + "import": false, + "export": false, + "allowedViews": [ + "list", + "tree" + ] }, "children": [ { "type": "field", "attrs": { - "name": "name", - "isSearchable": true - } - }, - { - "type": "field", - "attrs": { - "name": "displayName", - "isSearchable": true - } - }, - { - "type": "field", - "attrs": { - "name": "type", + "name": "module", "isSearchable": true } }, { "type": "field", "attrs": { - "name": "context", + "name": "model", "isSearchable": true } }, { "type": "field", "attrs": { - "name": "layout", - "isSearchable": true + "name": "name", + "isSearchable": true, + "sortable": true } }, { "type": "field", "attrs": { - "name": "module", - "isSearchable": true + "name": "displayName", + "isSearchable": true, + "sortable": true } }, { "type": "field", "attrs": { - "name": "model", + "name": "type", "isSearchable": true } } @@ -8243,7 +7831,9 @@ "attrs": { "name": "form-1", "label": "View Metadata", - "className": "grid" + "className": "grid", + "showAddFormButton": false, + "showEditFormButton": false }, "children": [ { @@ -8312,62 +7902,29 @@ } } ] - }, - { - "type": "column", - "attrs": { - "name": "page-1-row-1-col-2", - "label": "", - "className": "col-12 sm:col-12 md:col-6 lg:col-6" - }, - "children": [ - { - "type": "field", - "attrs": { - "name": "context" - } - } - ] - } - ] - } - ] - }, - { - "type": "page", - "attrs": { - "name": "page-2", - "label": "Layout" - }, - "children": [ - { - "type": "row", - "attrs": { - "name": "page-2-row-1", - "label": "", - "className": "" - }, - "children": [ - { - "type": "column", - "attrs": { - "name": "page-2-row-1-col-1", - "label": "", - "className": "col-12" - }, - "children": [ - { - "type": "field", - "attrs": { - "name": "layout", - "height": "80vh" - } - } - ] } ] } ] + }, + { + "type": "page", + "attrs": { + "name": "page-explorer", + "label": "Explorer" + }, + "children": [ + { + "type": "custom", + "attrs": { + "name": "metadata-explorer-widget", + "widget": "MetadataExplorerFormWidget", + "moduleName": "solid-core", + "arrayPath": "views", + "itemNameField": "name" + } + } + ] } ] } @@ -8393,27 +7950,60 @@ 50 ], "enableGlobalSearch": true, - "create": true, - "edit": true, - "delete": true + "create": false, + "edit": false, + "delete": true, + "import": false, + "export": false, + "allowedViews": [ + "list", + "tree" + ] }, "children": [ { "type": "field", "attrs": { - "name": "viewMetadata" + "name": "viewMetadata", + "label": "Name", + "coModelFieldToDisplay": "name", + "isSearchable": false, + "searchField": "viewMetadata.name" + } + }, + { + "type": "field", + "attrs": { + "name": "viewMetadata", + "label": "Display Name", + "coModelFieldToDisplay": "displayName", + "isSearchable": true, + "searchField": "viewMetadata.displayName" + } + }, + { + "type": "field", + "attrs": { + "name": "viewMetadata", + "label": "Type", + "coModelFieldToDisplay": "type", + "isSearchable": false } }, { "type": "field", "attrs": { - "name": "user" + "name": "user", + "isSearchable": true, + "searchField": "user.fullName", + "coModelFieldToDisplay": "fullName" } }, { "type": "field", "attrs": { - "name": "layout" + "name": "createdAt", + "isSearchable": false } } ] @@ -8431,7 +8021,9 @@ "attrs": { "name": "form-1", "label": "User View Metadata", - "className": "grid" + "className": "grid", + "showAddFormButton": false, + "showEditFormButton": false }, "children": [ { @@ -8557,50 +8149,58 @@ 50 ], "enableGlobalSearch": true, - "create": true, - "edit": true, - "delete": true + "create": false, + "edit": false, + "delete": false, + "import": false, + "export": false, + "allowedViews": [ + "list", + "tree" + ] }, "children": [ { "type": "field", "attrs": { - "name": "name", + "name": "module", "isSearchable": true } }, { "type": "field", "attrs": { - "name": "displayName", + "name": "model", "isSearchable": true } }, { "type": "field", "attrs": { - "name": "type", + "name": "view", "isSearchable": true } }, { "type": "field", "attrs": { - "name": "module", - "isSearchable": true + "name": "name", + "isSearchable": true, + "sortable": true } }, { "type": "field", "attrs": { - "name": "model", - "isSearchable": true + "name": "displayName", + "isSearchable": true, + "sortable": true } }, { "type": "field", "attrs": { - "name": "view", + "name": "type", "isSearchable": true } } @@ -8619,7 +8219,9 @@ "attrs": { "name": "form-1", "label": "Action Metadata", - "className": "grid" + "className": "grid", + "showAddFormButton": false, + "showEditFormButton": false }, "children": [ { @@ -8730,53 +8332,19 @@ { "type": "page", "attrs": { - "name": "page-2", - "label": "Layout" + "name": "page-explorer", + "label": "Explorer" }, "children": [ { - "type": "row", + "type": "custom", "attrs": { - "name": "page-2-row-1", - "label": "", - "className": "" - }, - "children": [ - { - "type": "column", - "attrs": { - "name": "page-2-row-1-col-1", - "label": "", - "className": "col-12 sm:col-12 md:col-6 lg:col-6" - }, - "children": [ - { - "type": "field", - "attrs": { - "name": "domain", - "height": "80vh" - } - } - ] - }, - { - "type": "column", - "attrs": { - "name": "page-2-row-1-col-1", - "label": "", - "className": "col-12 sm:col-12 md:col-6 lg:col-6" - }, - "children": [ - { - "type": "field", - "attrs": { - "name": "context", - "height": "80vh" - } - } - ] - } - ] + "name": "metadata-explorer-widget", + "widget": "MetadataExplorerFormWidget", + "moduleName": "solid-core", + "arrayPath": "actions", + "itemNameField": "name" + } } ] } @@ -8804,44 +8372,60 @@ 50 ], "enableGlobalSearch": true, - "create": true, - "edit": true, - "delete": true + "create": false, + "edit": false, + "delete": false, + "import": false, + "export": false, + "allowedViews": [ + "list", + "tree" + ] }, "children": [ { "type": "field", "attrs": { - "name": "name", + "name": "module", "isSearchable": true } }, { "type": "field", "attrs": { - "name": "displayName", + "name": "parentMenuItem", "isSearchable": true } }, { "type": "field", "attrs": { - "name": "sequenceNumber", - "isSearchable": true + "name": "name", + "isSearchable": true, + "sortable": true } }, { "type": "field", "attrs": { - "name": "module", - "isSearchable": true + "name": "displayName", + "isSearchable": true, + "sortable": true } }, { "type": "field", "attrs": { - "name": "parentMenuItem", - "isSearchable": true + "name": "sequenceNumber", + "isSearchable": true, + "sortable": true + } + }, + { + "type": "field", + "attrs": { + "name": "roles", + "isSearchable": false } }, { @@ -8854,8 +8438,8 @@ { "type": "field", "attrs": { - "name": "roles", - "isSearchable": true + "name": "iconName", + "isSearchable": false } } ] @@ -8873,7 +8457,9 @@ "attrs": { "name": "form-1", "label": "Solid Menu Item Model", - "className": "grid" + "className": "grid", + "showAddFormButton": false, + "showEditFormButton": false }, "children": [ { @@ -8883,111 +8469,147 @@ }, "children": [ { - "type": "group", + "type": "notebook", "attrs": { - "name": "group-1", - "label": "", - "className": "col-12 sm:col-12 md:col-6 lg:col-6" + "name": "notebook-1" }, "children": [ { - "type": "field", - "attrs": { - "name": "name" - } - }, - { - "type": "field", - "attrs": { - "name": "displayName" - } - }, - { - "type": "field", - "attrs": { - "name": "sequenceNumber" - } - }, - { - "type": "field", - "attrs": { - "name": "module" - } - }, - { - "type": "field", - "attrs": { - "name": "parentMenuItem" - } - }, - { - "type": "field", - "attrs": { - "name": "action" - } - }, - { - "type": "field", + "type": "page", "attrs": { - "name": "roles", - "widget": "checkbox", - "inlineCreateAutoSave": "true", - "renderModeCheckboxPreprocessor": "MenuItemMetadataRolesFormFieldPreprocessor", - "inlineCreate": "true", - "inlineCreateLayout": { - "type": "form", + "name": "page-general", + "label": "General" + }, + "children": [ + { + "type": "group", "attrs": { - "name": "form-1", - "label": "User", - "className": "grid", - "width": "20vw" + "name": "group-1", + "label": "", + "className": "col-12 sm:col-12 md:col-6 lg:col-6" }, "children": [ { - "type": "sheet", + "type": "field", + "attrs": { + "name": "name" + } + }, + { + "type": "field", "attrs": { - "name": "sheet-1" - }, - "children": [ - { - "type": "group", + "name": "displayName" + } + }, + { + "type": "field", + "attrs": { + "name": "sequenceNumber" + } + }, + { + "type": "field", + "attrs": { + "name": "module" + } + }, + { + "type": "field", + "attrs": { + "name": "parentMenuItem" + } + }, + { + "type": "field", + "attrs": { + "name": "action" + } + }, + { + "type": "field", + "attrs": { + "name": "roles", + "widget": "checkbox", + "inlineCreateAutoSave": "true", + "renderModeCheckboxPreprocessor": "MenuItemMetadataRolesFormFieldPreprocessor", + "inlineCreate": "true", + "inlineCreateLayout": { + "type": "form", "attrs": { - "name": "group-1", - "label": "", - "className": "col-12" + "name": "form-1", + "label": "User", + "className": "grid", + "width": "20vw" }, "children": [ { - "type": "field", + "type": "sheet", "attrs": { - "name": "name" - } + "name": "sheet-1" + }, + "children": [ + { + "type": "group", + "attrs": { + "name": "group-1", + "label": "", + "className": "col-12" + }, + "children": [ + { + "type": "field", + "attrs": { + "name": "name" + } + } + ] + } + ] } ] } - ] + } + } + ] + }, + { + "type": "group", + "attrs": { + "name": "group-1", + "label": "", + "className": "col-12 sm:col-12 md:col-6 lg:col-6" + }, + "children": [ + { + "type": "field", + "attrs": { + "name": "iconName", + "editWidget": "SolidIconEditWidget", + "viewWidget": "SolidIconViewWidget" + } } ] } - } - } - ] - }, - { - "type": "group", - "attrs": { - "name": "group-1", - "label": "", - "className": "col-12 sm:col-12 md:col-6 lg:col-6" - }, - "children": [ + ] + }, { - "type": "field", + "type": "page", "attrs": { - "name": "iconName", - "editWidget": "SolidIconEditWidget", - "viewWidget": "SolidIconViewWidget" - } + "name": "page-explorer", + "label": "Explorer" + }, + "children": [ + { + "type": "custom", + "attrs": { + "name": "metadata-explorer-widget", + "widget": "MetadataExplorerFormWidget", + "moduleName": "solid-core", + "arrayPath": "menus", + "itemNameField": "name" + } + } + ] } ] } @@ -9013,49 +8635,57 @@ 50 ], "enableGlobalSearch": true, - "create": true, - "edit": true, + "create": false, + "edit": false, "delete": true, "allowedViews": [ "list", - "card" - ] + "card", + "tree" + ], + "import": false, + "export": false }, "children": [ { "type": "field", "attrs": { - "name": "entityId", - "isSearchable": true + "name": "modelMetadata", + "isSearchable": true, + "sortable": true, + "searchField": "modelMetadata.name" } }, { "type": "field", "attrs": { - "name": "relativeUri", - "widget": "image", - "isSearchable": true + "name": "fieldMetadata", + "isSearchable": true, + "sortable": true, + "searchField": "fieldMetadata.name" } }, { "type": "field", "attrs": { - "name": "modelMetadata", - "isSearchable": true + "name": "entityId", + "isSearchable": true, + "sortable": false } }, { "type": "field", "attrs": { "name": "mediaStorageProviderMetadata", - "isSearchable": true + "isSearchable": false, + "sortable": false } }, { "type": "field", "attrs": { - "name": "fieldMetadata", - "isSearchable": true + "name": "fileSize", + "sortable": true } } ] @@ -9168,7 +8798,9 @@ "attrs": { "name": "form-1", "label": "Solid Media Model", - "className": "grid" + "className": "grid", + "showAddFormButton": false, + "showEditFormButton": false }, "children": [ { @@ -9239,275 +8871,182 @@ 50 ], "enableGlobalSearch": true, - "create": true, - "edit": true, - "delete": true + "create": false, + "edit": false, + "delete": false, + "import": false, + "export": false, + "allowedViews": [ + "list", + "tree" + ] }, "children": [ { "type": "field", "attrs": { - "name": "name", - "isSearchable": true - } - }, - { - "type": "field", - "attrs": { - "name": "displayName", - "isSearchable": true - } - }, - { - "type": "field", - "attrs": { - "name": "type", + "name": "model", "isSearchable": true } }, { "type": "field", "attrs": { - "name": "ormType", - "isSearchable": true + "name": "name", + "isSearchable": true, + "sortable": true } }, { "type": "field", "attrs": { - "name": "model", + "name": "displayName", "isSearchable": true } }, { "type": "field", "attrs": { - "name": "defaultValue", + "name": "type", "isSearchable": true } }, { "type": "field", "attrs": { - "name": "regexPattern", - "isSearchable": true + "name": "ormType", + "isSearchable": false } }, { "type": "field", "attrs": { - "name": "regexPatternNotMatchingErrorMsg", - "isSearchable": true + "name": "relationType", + "isSearchable": false } }, { "type": "field", "attrs": { "name": "required", - "isSearchable": true + "isSearchable": false } }, { "type": "field", "attrs": { "name": "unique", - "isSearchable": true - } - }, - { - "type": "field", - "attrs": { - "name": "encrypt", - "isSearchable": true - } - }, - { - "type": "field", - "attrs": { - "name": "encryptionType", - "isSearchable": true - } - }, - { - "type": "field", - "attrs": { - "name": "decryptWhen", - "isSearchable": true + "isSearchable": false } }, { "type": "field", "attrs": { "name": "index", - "isSearchable": true - } - }, - { - "type": "field", - "attrs": { - "name": "length", - "isSearchable": true + "isSearchable": false } }, { "type": "field", "attrs": { - "name": "max", - "isSearchable": true + "name": "columnName", + "isSearchable": false } - }, + } + ] + } + }, + { + "name": "fieldMetadata-tree-view", + "displayName": "Field Metadata", + "type": "tree", + "context": "{}", + "moduleUserKey": "solid-core", + "modelUserKey": "fieldMetadata", + "layout": { + "type": "list", + "attrs": { + "pagination": true, + "pageSizeOptions": [ + 10, + 25, + 50 + ], + "enableGlobalSearch": true, + "create": false, + "edit": false, + "delete": false + }, + "children": [ { "type": "field", "attrs": { - "name": "min", + "name": "model", "isSearchable": true } }, { "type": "field", "attrs": { - "name": "private", - "isSearchable": true + "name": "name", + "isSearchable": true, + "sortable": true } }, { "type": "field", "attrs": { - "name": "mediaTypes", + "name": "displayName", "isSearchable": true } }, { "type": "field", "attrs": { - "name": "mediaMaxSizeKb", + "name": "type", "isSearchable": true } }, { "type": "field", "attrs": { - "name": "mediaStorageProvider", - "isSearchable": true + "name": "ormType", + "isSearchable": false } }, { "type": "field", "attrs": { "name": "relationType", - "isSearchable": true - } - }, - { - "type": "field", - "attrs": { - "name": "relationCoModelSingularName", - "isSearchable": true - } - }, - { - "type": "field", - "attrs": { - "name": "relationCreateInverse", - "isSearchable": true - } - }, - { - "type": "field", - "attrs": { - "name": "relationCascade", - "isSearchable": true - } - }, - { - "type": "field", - "attrs": { - "name": "relationModelModuleName", - "isSearchable": true - } - }, - { - "type": "field", - "attrs": { - "name": "relationCoModelFieldName", - "isSearchable": true - } - }, - { - "type": "field", - "attrs": { - "name": "selectionDynamicProvider", - "isSearchable": true - } - }, - { - "type": "field", - "attrs": { - "name": "selectionDynamicProviderCtxt", - "isSearchable": true - } - }, - { - "type": "field", - "attrs": { - "name": "selectionStaticValues", - "isSearchable": true - } - }, - { - "type": "field", - "attrs": { - "name": "selectionValueType", - "isSearchable": true - } - }, - { - "type": "field", - "attrs": { - "name": "computedFieldValueProvider", - "isSearchable": true - } - }, - { - "type": "field", - "attrs": { - "name": "computedFieldValueProviderCtxt", - "isSearchable": true - } - }, - { - "type": "field", - "attrs": { - "name": "computedFieldValueType", - "isSearchable": true + "isSearchable": false } }, { "type": "field", "attrs": { - "name": "uuid", - "isSearchable": true + "name": "required", + "isSearchable": false } }, { "type": "field", "attrs": { - "name": "isSystem", - "isSearchable": true + "name": "unique", + "isSearchable": false } }, { "type": "field", "attrs": { - "name": "isMarkedForRemoval", - "isSearchable": true + "name": "index", + "isSearchable": false } }, { "type": "field", "attrs": { "name": "columnName", - "isSearchable": true + "isSearchable": false } } ] @@ -9525,7 +9064,9 @@ "attrs": { "name": "form-1", "label": "Field Metadata", - "className": "grid" + "className": "grid", + "showAddFormButton": false, + "showEditFormButton": false }, "children": [ { @@ -9831,8 +9372,7 @@ { "type": "field", "attrs": { - "name": "lastLoginProvider", - "isSearchable": true + "name": "lastLoginProvider" } }, { @@ -9841,6 +9381,14 @@ "name": "active", "isSearchable": true } + }, + { + "type": "field", + "attrs": { + "label": "Blocked / Unblocked", + "name": "failedLoginAttempts", + "viewWidget": "SolidUserBlockedStatusListWidget" + } } ] } @@ -9877,7 +9425,13 @@ { "type": "field", "attrs": { - "name": "username" + "name": "username" + } + }, + { + "type": "field", + "attrs": { + "name": "fullName" } }, { @@ -9937,7 +9491,9 @@ { "type": "field", "attrs": { - "name": "roles" + "name": "roles", + "editWidget": "RolesGroupedByModuleWidget", + "showLabel": false } }, { @@ -10044,12 +9600,289 @@ } }, { - "name": "permissionMetadata-list-view", - "displayName": "Permissions", + "name": "permissionMetadata-list-view", + "displayName": "Permissions", + "type": "list", + "context": "{}", + "moduleUserKey": "solid-core", + "modelUserKey": "permissionMetadata", + "layout": { + "type": "list", + "attrs": { + "pagination": true, + "pageSizeOptions": [ + 10, + 25, + 50 + ], + "enableGlobalSearch": true, + "create": true, + "edit": true, + "delete": true, + "import": true, + "export": true + }, + "children": [ + { + "type": "field", + "attrs": { + "name": "name", + "isSearchable": true, + "sortable": true + } + } + ] + } + }, + { + "name": "permissionMetadata-form-view", + "displayName": "Permission", + "type": "form", + "context": "{}", + "moduleUserKey": "solid-core", + "modelUserKey": "permissionMetadata", + "layout": { + "type": "form", + "attrs": { + "name": "form-1", + "label": "Permission", + "className": "grid", + "disabled": true, + "readonly": true, + "showAddFormButton": true, + "showEditFormButton": true + }, + "children": [ + { + "type": "sheet", + "attrs": { + "name": "sheet-1" + }, + "children": [ + { + "type": "group", + "attrs": { + "name": "group-1", + "label": "", + "className": "col-12 sm:col-12 md:col-6 lg:col-6" + }, + "children": [ + { + "type": "field", + "attrs": { + "name": "name" + } + } + ] + } + ] + } + ] + } + }, + { + "name": "roleMetadata-list-view", + "displayName": "Roles", + "type": "list", + "context": "{}", + "moduleUserKey": "solid-core", + "modelUserKey": "roleMetadata", + "layout": { + "type": "list", + "attrs": { + "pagination": true, + "pageSizeOptions": [ + 10, + 25, + 50 + ], + "enableGlobalSearch": true, + "create": true, + "edit": true, + "delete": true, + "import": true, + "export": true, + "allowedViews": [ + "list" + ] + }, + "children": [ + { + "type": "field", + "attrs": { + "name": "name", + "isSearchable": true, + "sortable": true + } + }, + { + "type": "field", + "attrs": { + "name": "module", + "isSearchable": false + } + } + ] + } + }, + { + "name": "roleMetadata-form-view", + "displayName": "Role", + "type": "form", + "context": "{}", + "moduleUserKey": "solid-core", + "modelUserKey": "roleMetadata", + "layout": { + "type": "form", + "attrs": { + "name": "form-1", + "label": "User", + "className": "grid", + "showAddFormButton": true, + "showEditFormButton": true + }, + "children": [ + { + "type": "sheet", + "attrs": { + "name": "sheet-1" + }, + "children": [ + { + "type": "notebook", + "attrs": { + "name": "notebook-1" + }, + "children": [ + { + "type": "page", + "attrs": { + "name": "page-1", + "label": "Name" + }, + "children": [ + { + "type": "group", + "attrs": { + "name": "group-1", + "label": "", + "className": "col-12 sm:col-12 md:col-6 lg:col-6" + }, + "children": [ + { + "type": "field", + "attrs": { + "name": "name", + "showLabel": false + } + }, + { + "type": "field", + "attrs": { + "name": "module", + "label": "Module", + "showLabel": true + } + } + ] + } + ] + }, + { + "type": "page", + "attrs": { + "name": "page-2", + "label": "Permissions" + }, + "children": [ + { + "type": "group", + "attrs": { + "name": "group-1", + "label": "", + "className": "col-12" + }, + "children": [ + { + "type": "field", + "attrs": { + "name": "permissions", + "editWidget": "inputSwitch", + "showLabel": false + } + } + ] + } + ] + }, + { + "type": "page", + "attrs": { + "name": "page-3", + "label": "Users" + }, + "children": [ + { + "type": "group", + "attrs": { + "name": "group-1", + "label": "", + "className": "col-12 sm:col-12 md:col-12 lg:col-12" + }, + "children": [ + { + "type": "field", + "attrs": { + "name": "users", + "editWidget": "DefaultRelationManyToManyCheckBoxFormEditWidget", + "showLabel": false + } + } + ] + } + ] + }, + { + "type": "page", + "attrs": { + "name": "page-4", + "label": "Menu Items" + }, + "children": [ + { + "type": "group", + "attrs": { + "name": "group-1", + "label": "", + "className": "col-12 sm:col-12 md:col-12 lg:col-12" + }, + "children": [ + { + "type": "field", + "attrs": { + "name": "menuItems", + "editWidget": "DefaultRelationManyToManyCheckBoxFormEditWidget", + "showLabel": false + } + } + ] + } + ] + } + ] + } + ] + } + ] + } + }, + { + "name": "mqMessage-list-view", + "displayName": "Messages", "type": "list", "context": "{}", "moduleUserKey": "solid-core", - "modelUserKey": "permissionMetadata", + "modelUserKey": "mqMessage", "layout": { "type": "list", "attrs": { @@ -10068,7 +9901,64 @@ { "type": "field", "attrs": { - "name": "name", + "name": "messageId", + "isSearchable": true + } + }, + { + "type": "field", + "attrs": { + "name": "messageBroker", + "isSearchable": true + } + }, + { + "type": "field", + "attrs": { + "name": "stage", + "isSearchable": true + } + }, + { + "type": "field", + "attrs": { + "name": "startedAt", + "isSearchable": true + } + }, + { + "type": "field", + "attrs": { + "name": "finishedAt", + "isSearchable": true + } + }, + { + "type": "field", + "attrs": { + "name": "elapsedMillis", + "isSearchable": true + } + }, + { + "type": "field", + "attrs": { + "name": "parentEntityId", + "isSearchable": true + } + }, + { + "type": "field", + "attrs": { + "name": "parentEntity", + "isSearchable": true + } + }, + { + "type": "field", + "attrs": { + "name": "mqMessageQueue", + "label": "Queue", "isSearchable": true } } @@ -10076,43 +9966,213 @@ } }, { - "name": "permissionMetadata-form-view", - "displayName": "Permission", - "type": "form", + "name": "mqMessage-tree-view", + "displayName": "Messages Tree View", + "type": "tree", "context": "{}", "moduleUserKey": "solid-core", - "modelUserKey": "permissionMetadata", + "modelUserKey": "mqMessage", "layout": { - "type": "form", + "type": "tree", "attrs": { - "name": "form-1", - "label": "Permission", - "className": "grid", - "disabled": true, - "readonly": true + "pagination": true, + "pageSizeOptions": [ + 10, + 25, + 50 + ], + "enableGlobalSearch": true, + "create": false, + "edit": false, + "delete": false }, "children": [ { - "type": "sheet", + "type": "field", "attrs": { - "name": "sheet-1" + "name": "messageId", + "isSearchable": true + } + }, + { + "type": "field", + "attrs": { + "name": "messageBroker", + "isSearchable": true + } + }, + { + "type": "field", + "attrs": { + "name": "stage", + "isSearchable": true + } + }, + { + "type": "field", + "attrs": { + "name": "startedAt", + "isSearchable": true + } + }, + { + "type": "field", + "attrs": { + "name": "finishedAt", + "isSearchable": true + } + }, + { + "type": "field", + "attrs": { + "name": "elapsedMillis", + "isSearchable": true + } + }, + { + "type": "field", + "attrs": { + "name": "parentEntityId", + "isSearchable": true + } + }, + { + "type": "field", + "attrs": { + "name": "parentEntity", + "isSearchable": true + } + }, + { + "type": "field", + "attrs": { + "name": "mqMessageQueue", + "label": "Queue", + "isSearchable": true + } + } + ] + } + }, + { + "name": "mqMessage-kanban-view", + "displayName": "Messages Kanban", + "type": "kanban", + "context": "{}", + "moduleUserKey": "solid-core", + "modelUserKey": "mqMessage", + "layout": { + "type": "kanban", + "attrs": { + "swimlanesCount": 5, + "recordsInSwimlane": 10, + "enableGlobalSearch": true, + "create": true, + "edit": true, + "delete": true, + "groupBy": "stage", + "draggable": false, + "allowedViews": [ + "list", + "kanban" + ] + }, + "children": [ + { + "type": "card", + "attrs": { + "name": "Card", + "cardWidget": "MqMessageKanbanCardWidget" }, "children": [ { - "type": "group", + "type": "field", + "attrs": { + "name": "messageId", + "isSearchable": true + } + }, + { + "type": "field", + "attrs": { + "name": "messageBroker", + "isSearchable": true + } + }, + { + "type": "field", + "attrs": { + "name": "messageType", + "isSearchable": true + } + }, + { + "type": "field", + "attrs": { + "name": "stage", + "isSearchable": true + } + }, + { + "type": "field", + "attrs": { + "name": "startedAt", + "isSearchable": true + } + }, + { + "type": "field", + "attrs": { + "name": "finishedAt", + "isSearchable": true + } + }, + { + "type": "field", + "attrs": { + "name": "elapsedMillis", + "isSearchable": true + } + }, + { + "type": "field", + "attrs": { + "name": "retryCount" + } + }, + { + "type": "field", + "attrs": { + "name": "retryInterval" + } + }, + { + "type": "field", "attrs": { - "name": "group-1", - "label": "", - "className": "col-12 sm:col-12 md:col-6 lg:col-6" - }, - "children": [ - { - "type": "field", - "attrs": { - "name": "name" - } - } - ] + "name": "parentEntityId", + "isSearchable": true + } + }, + { + "type": "field", + "attrs": { + "name": "parentEntity", + "isSearchable": true + } + }, + { + "type": "field", + "attrs": { + "name": "error" + } + }, + { + "type": "field", + "attrs": { + "name": "mqMessageQueue", + "label": "Queue", + "isSearchable": true + } } ] } @@ -10120,50 +10180,22 @@ } }, { - "name": "roleMetadata-list-view", - "displayName": "Roles", - "type": "list", - "context": "{}", - "moduleUserKey": "solid-core", - "modelUserKey": "roleMetadata", - "layout": { - "type": "list", - "attrs": { - "pagination": true, - "pageSizeOptions": [ - 10, - 25, - 50 - ], - "enableGlobalSearch": true, - "create": true, - "edit": true, - "delete": true - }, - "children": [ - { - "type": "field", - "attrs": { - "name": "name", - "isSearchable": true - } - } - ] - } - }, - { - "name": "roleMetadata-form-view", - "displayName": "Role", + "name": "mqMessage-form-view", + "displayName": "Mq Message", "type": "form", "context": "{}", "moduleUserKey": "solid-core", - "modelUserKey": "roleMetadata", + "modelUserKey": "mqMessage", "layout": { "type": "form", "attrs": { "name": "form-1", - "label": "User", - "className": "grid" + "label": "Mq Message", + "className": "grid", + "workflowField": "stage", + "workflowFieldUpdateEnabled": true, + "disabled": true, + "readonly": true }, "children": [ { @@ -10181,24 +10213,102 @@ { "type": "page", "attrs": { - "name": "page-1", - "label": "Name" + "name": "general-info", + "label": "General Info" }, "children": [ { - "type": "group", + "type": "row", "attrs": { - "name": "group-1", - "label": "", - "className": "col-12 sm:col-12 md:col-6 lg:col-6" + "name": "row-1", + "className": "" }, "children": [ { - "type": "field", + "type": "column", "attrs": { - "name": "name", - "showLabel": false - } + "name": "col-1", + "className": "col-12" + }, + "children": [ + { + "type": "field", + "attrs": { + "name": "messageType", + "label": "Message Type" + } + }, + { + "type": "field", + "attrs": { + "name": "messageId", + "label": "Message Id" + } + }, + { + "type": "field", + "attrs": { + "name": "messageBroker", + "label": "Message Broker" + } + }, + { + "type": "field", + "attrs": { + "name": "retryCount", + "label": "Retry Count" + } + }, + { + "type": "field", + "attrs": { + "name": "retryInterval", + "label": "Retry Interval" + } + }, + { + "type": "field", + "attrs": { + "name": "startedAt", + "label": "Started At" + } + }, + { + "type": "field", + "attrs": { + "name": "finishedAt", + "label": "Finished At" + } + }, + { + "type": "field", + "attrs": { + "name": "elapsedMillis", + "label": "Elapsed Millis" + } + }, + { + "type": "field", + "attrs": { + "name": "parentEntityId", + "label": "Parent Entity Id" + } + }, + { + "type": "field", + "attrs": { + "name": "parentEntity", + "label": "Parent Entity" + } + }, + { + "type": "field", + "attrs": { + "name": "mqMessageQueue", + "label": "Message Queue" + } + } + ] } ] } @@ -10207,25 +10317,31 @@ { "type": "page", "attrs": { - "name": "page-2", - "label": "Permissions" + "name": "input-tab", + "label": "Input" }, "children": [ { - "type": "group", + "type": "row", "attrs": { - "name": "group-1", - "label": "", - "className": "col-12" + "name": "input-row" }, "children": [ { - "type": "field", + "type": "column", "attrs": { - "name": "permissions", - "editWidget": "inputSwitch", - "showLabel": false - } + "name": "input-col", + "className": "col-12" + }, + "children": [ + { + "type": "field", + "attrs": { + "name": "input", + "label": "Input" + } + } + ] } ] } @@ -10234,25 +10350,31 @@ { "type": "page", "attrs": { - "name": "page-3", - "label": "Users" + "name": "output-tab", + "label": "Output" }, "children": [ { - "type": "group", + "type": "row", "attrs": { - "name": "group-1", - "label": "", - "className": "col-12 sm:col-12 md:col-12 lg:col-12" + "name": "output-row" }, "children": [ { - "type": "field", + "type": "column", "attrs": { - "name": "users", - "editWidget": "DefaultRelationManyToManyCheckBoxFormEditWidget", - "showLabel": false - } + "name": "output-col", + "className": "col-12" + }, + "children": [ + { + "type": "field", + "attrs": { + "name": "output", + "label": "Output" + } + } + ] } ] } @@ -10261,25 +10383,31 @@ { "type": "page", "attrs": { - "name": "page-4", - "label": "Menu Items" + "name": "error-tab", + "label": "Error" }, "children": [ { - "type": "group", + "type": "row", "attrs": { - "name": "group-1", - "label": "", - "className": "col-12 sm:col-12 md:col-12 lg:col-12" + "name": "error-row" }, "children": [ { - "type": "field", + "type": "column", "attrs": { - "name": "menuItems", - "editWidget": "DefaultRelationManyToManyCheckBoxFormEditWidget", - "showLabel": false - } + "name": "error-col", + "className": "col-12" + }, + "children": [ + { + "type": "field", + "attrs": { + "name": "error", + "label": "Error" + } + } + ] } ] } @@ -10293,12 +10421,12 @@ } }, { - "name": "mqMessage-list-view", - "displayName": "Messages", + "name": "mqMessageQueue-list-view", + "displayName": "Message Queues", "type": "list", "context": "{}", "moduleUserKey": "solid-core", - "modelUserKey": "mqMessage", + "modelUserKey": "mqMessageQueue", "layout": { "type": "list", "attrs": { @@ -10317,79 +10445,209 @@ { "type": "field", "attrs": { - "name": "messageId", - "isSearchable": true - } - }, - { - "type": "field", - "attrs": { - "name": "messageBroker", - "isSearchable": true - } - }, - { - "type": "field", - "attrs": { - "name": "stage", - "isSearchable": true - } - }, - { - "type": "field", - "attrs": { - "name": "startedAt", - "isSearchable": true - } - }, - { - "type": "field", - "attrs": { - "name": "finishedAt", - "isSearchable": true - } - }, - { - "type": "field", - "attrs": { - "name": "elapsedMillis", - "isSearchable": true - } - }, - { - "type": "field", - "attrs": { - "name": "parentEntityId", - "isSearchable": true - } - }, - { - "type": "field", - "attrs": { - "name": "parentEntity", + "name": "name", + "label": "Queue Name", "isSearchable": true } - }, + } + ] + } + }, + { + "name": "mqMessageQueue-form-view", + "displayName": "Mq Message Queue", + "type": "form", + "context": "{}", + "moduleUserKey": "solid-core", + "modelUserKey": "mqMessageQueue", + "layout": { + "type": "form", + "attrs": { + "name": "form-1", + "label": "Mq Message Queue", + "className": "grid", + "disabled": false, + "readonly": true + }, + "children": [ { - "type": "field", + "type": "sheet", "attrs": { - "name": "mqMessageQueue", - "label": "Queue", - "isSearchable": true - } + "name": "sheet-1" + }, + "children": [ + { + "type": "notebook", + "attrs": { + "name": "notebook-1" + }, + "children": [ + { + "type": "page", + "attrs": { + "name": "page-general-info", + "label": "General Info" + }, + "children": [ + { + "type": "row", + "attrs": { + "name": "row-1" + }, + "children": [ + { + "type": "column", + "attrs": { + "name": "col-1", + "className": "col-12 sm:col-12 md:col-6 lg:col-6" + }, + "children": [ + { + "type": "field", + "attrs": { + "name": "name", + "label": "Queue Name" + } + } + ] + }, + { + "type": "column", + "attrs": { + "name": "col-2", + "className": "col-12 sm:col-12 md:col-6 lg:col-6" + }, + "children": [ + { + "type": "field", + "attrs": { + "name": "description", + "label": "Queue Description" + } + } + ] + } + ] + } + ] + }, + { + "type": "page", + "attrs": { + "name": "page-messages", + "label": "Messages" + }, + "children": [ + { + "type": "row", + "attrs": { + "name": "row-2" + }, + "children": [ + { + "type": "column", + "attrs": { + "name": "col-1", + "className": "col-12" + }, + "children": [ + { + "type": "field", + "attrs": { + "name": "mqMessages", + "label": "mqMessages", + "inlineCreate": "false", + "showLabel": false, + "inlineListLayout": { + "type": "list", + "attrs": { + "pagination": true, + "pageSizeOptions": [ + 10, + 25, + 50 + ], + "enableGlobalSearch": true, + "create": false, + "edit": false, + "delete": false + }, + "children": [ + { + "type": "field", + "attrs": { + "name": "messageId", + "isSearchable": true + } + }, + { + "type": "field", + "attrs": { + "name": "stage", + "isSearchable": true + } + }, + { + "type": "field", + "attrs": { + "name": "startedAt", + "isSearchable": true + } + }, + { + "type": "field", + "attrs": { + "name": "finishedAt", + "isSearchable": true + } + }, + { + "type": "field", + "attrs": { + "name": "elapsedMillis", + "isSearchable": true + } + }, + { + "type": "field", + "attrs": { + "name": "parentEntityId", + "isSearchable": true + } + }, + { + "type": "field", + "attrs": { + "name": "parentEntity", + "isSearchable": true + } + } + ] + } + } + } + ] + } + ] + } + ] + } + ] + } + ] } ] } }, { - "name": "mqMessage-tree-view", - "displayName": "Messages Tree View", - "type": "tree", + "name": "scheduledJob-list-view", + "displayName": "Scheduled Job", + "type": "list", "context": "{}", "moduleUserKey": "solid-core", - "modelUserKey": "mqMessage", + "modelUserKey": "scheduledJob", "layout": { - "type": "tree", + "type": "list", "attrs": { "pagination": true, "pageSizeOptions": [ @@ -10400,70 +10658,299 @@ "enableGlobalSearch": true, "create": false, "edit": false, - "delete": false + "delete": false, + "import": false, + "export": false, + "allowedViews": [ + "list", + "tree" + ] }, "children": [ { "type": "field", "attrs": { - "name": "messageId", - "isSearchable": true - } - }, - { - "type": "field", - "attrs": { - "name": "messageBroker", - "isSearchable": true - } - }, - { - "type": "field", - "attrs": { - "name": "stage", - "isSearchable": true + "name": "module", + "label": "Module", + "isSearchable": true, + "searchField": "module.name" } }, { "type": "field", "attrs": { - "name": "startedAt", - "isSearchable": true + "name": "scheduleName", + "label": "Schedule Name", + "isSearchable": true, + "sortable": true } }, { "type": "field", "attrs": { - "name": "finishedAt", - "isSearchable": true + "name": "job", + "label": "Job Name", + "isSearchable": true, + "sortable": true } }, { "type": "field", "attrs": { - "name": "elapsedMillis", - "isSearchable": true + "name": "frequency", + "label": "Frequency", + "isSearchable": false, + "sortable": true } }, { "type": "field", "attrs": { - "name": "parentEntityId", - "isSearchable": true + "name": "isActive", + "isSearchable": false } - }, + } + ] + } + }, + { + "name": "scheduledJob-form-view", + "displayName": "Scheduled Job", + "type": "form", + "context": "{}", + "moduleUserKey": "solid-core", + "modelUserKey": "scheduledJob", + "layout": { + "type": "form", + "attrs": { + "name": "form-1", + "label": "Scheduled Job", + "className": "grid", + "showAddFormButton": false, + "showEditFormButton": false + }, + "onFieldChange": "scheduleFrequencyOnFieldChangeHandler", + "children": [ { - "type": "field", + "type": "sheet", "attrs": { - "name": "parentEntity", - "isSearchable": true - } - }, + "name": "sheet-1" + }, + "children": [ + { + "type": "notebook", + "attrs": { + "name": "notebook-1" + }, + "children": [ + { + "type": "page", + "attrs": { + "name": "page-general", + "label": "General" + }, + "children": [ + { + "type": "row", + "attrs": { + "name": "row-general" + }, + "children": [ + { + "type": "column", + "attrs": { + "name": "group-basic", + "label": "Basic", + "className": "col-12 sm:col-12 md:col-6 lg:col-6" + }, + "children": [ + { + "type": "field", + "attrs": { + "name": "scheduleName" + } + }, + { + "type": "field", + "attrs": { + "name": "job" + } + }, + { + "type": "field", + "attrs": { + "name": "module" + } + }, + { + "type": "field", + "attrs": { + "name": "isActive" + } + } + ] + }, + { + "type": "column", + "attrs": { + "name": "group-config", + "label": "Configuration", + "className": "col-12 sm:col-12 md:col-6 lg:col-6" + }, + "children": [ + { + "type": "field", + "attrs": { + "name": "frequency" + } + }, + { + "type": "field", + "attrs": { + "name": "cronExpression", + "visible": false + } + }, + { + "type": "field", + "attrs": { + "name": "startTime" + } + }, + { + "type": "field", + "attrs": { + "name": "endTime" + } + }, + { + "type": "field", + "attrs": { + "name": "startDate" + } + }, + { + "type": "field", + "attrs": { + "name": "endDate" + } + }, + { + "type": "field", + "attrs": { + "name": "dayOfWeek", + "visible": false + } + }, + { + "type": "field", + "attrs": { + "name": "dayOfMonth", + "visible": false + } + } + ] + } + ] + } + ] + }, + { + "type": "page", + "attrs": { + "name": "page-runtime", + "label": "Runtime" + }, + "children": [ + { + "type": "row", + "attrs": { + "name": "row-runtime" + }, + "children": [ + { + "type": "column", + "attrs": { + "name": "group-runtime", + "label": "Execution", + "className": "col-12" + }, + "children": [ + { + "type": "field", + "attrs": { + "name": "lastRunAt", + "disabled": true, + "readOnly": true + } + }, + { + "type": "field", + "attrs": { + "name": "nextRunAt", + "disabled": true, + "readOnly": true + } + } + ] + } + ] + } + ] + }, + { + "type": "page", + "attrs": { + "name": "page-explorer", + "label": "Explorer" + }, + "children": [ + { + "type": "custom", + "attrs": { + "name": "metadata-explorer-widget", + "widget": "MetadataExplorerFormWidget", + "moduleName": "solid-core", + "arrayPath": "scheduledJobs", + "itemNameField": "scheduleName" + } + } + ] + } + ] + } + ] + } + ] + } + }, + { + "name": "settings-list-view", + "displayName": "Settings", + "type": "list", + "context": "{}", + "moduleUserKey": "solid-core", + "modelUserKey": "setting", + "layout": { + "type": "list", + "attrs": { + "pagination": true, + "pageSizeOptions": [ + 10, + 25, + 50 + ], + "enableGlobalSearch": true, + "create": true, + "edit": true, + "delete": false + }, + "children": [ { "type": "field", "attrs": { - "name": "mqMessageQueue", - "label": "Queue", + "name": "appTitle", + "label": "App Name", "isSearchable": true } } @@ -10471,124 +10958,140 @@ } }, { - "name": "mqMessage-kanban-view", - "displayName": "Messages Kanban", - "type": "kanban", + "name": "settings-form-view", + "displayName": "Settings", + "type": "form", "context": "{}", "moduleUserKey": "solid-core", - "modelUserKey": "mqMessage", + "modelUserKey": "setting", "layout": { - "type": "kanban", + "type": "form", "attrs": { - "swimlanesCount": 5, - "recordsInSwimlane": 10, - "enableGlobalSearch": true, - "create": true, - "edit": true, - "delete": true, - "groupBy": "stage", - "draggable": false, - "allowedViews": [ - "list", - "kanban" - ] + "name": "form-1", + "label": "Settings", + "className": "grid" }, "children": [ { - "type": "card", + "type": "sheet", "attrs": { - "name": "Card", - "cardWidget": "MqMessageKanbanCardWidget" + "name": "sheet-1" }, "children": [ { - "type": "field", - "attrs": { - "name": "messageId", - "isSearchable": true - } - }, - { - "type": "field", - "attrs": { - "name": "messageBroker", - "isSearchable": true - } - }, - { - "type": "field", - "attrs": { - "name": "messageType", - "isSearchable": true - } - }, - { - "type": "field", - "attrs": { - "name": "stage", - "isSearchable": true - } - }, - { - "type": "field", - "attrs": { - "name": "startedAt", - "isSearchable": true - } - }, - { - "type": "field", - "attrs": { - "name": "finishedAt", - "isSearchable": true - } - }, - { - "type": "field", - "attrs": { - "name": "elapsedMillis", - "isSearchable": true - } - }, - { - "type": "field", - "attrs": { - "name": "retryCount" - } - }, - { - "type": "field", - "attrs": { - "name": "retryInterval" - } - }, - { - "type": "field", - "attrs": { - "name": "parentEntityId", - "isSearchable": true - } - }, - { - "type": "field", - "attrs": { - "name": "parentEntity", - "isSearchable": true - } - }, - { - "type": "field", - "attrs": { - "name": "error" - } - }, - { - "type": "field", + "type": "group", "attrs": { - "name": "mqMessageQueue", - "label": "Queue", - "isSearchable": true - } + "name": "group-1", + "label": "", + "className": "col-12 sm:col-12 md:col-6 lg:col-6" + }, + "children": [ + { + "type": "field", + "attrs": { + "name": "authPagesLayout", + "label": "Auth Pages Layout" + } + }, + { + "type": "field", + "attrs": { + "name": "appTitle", + "label": "App Title" + } + }, + { + "type": "field", + "attrs": { + "name": "appLogo", + "label": "App Logo" + } + }, + { + "type": "field", + "attrs": { + "name": "appDescription", + "label": "App Description" + } + }, + { + "type": "field", + "attrs": { + "name": "appTnc", + "label": "App TNC" + } + }, + { + "type": "field", + "attrs": { + "name": "appPrivacyPolicy", + "label": "App Privacy Policy" + } + }, + { + "type": "field", + "attrs": { + "name": "iamAllowPublicRegistration", + "label": "Iam Allow Public Registration" + } + }, + { + "type": "field", + "attrs": { + "name": "forceChangePasswordOnFirstLogin", + "label": "Force Password Change On First Login" + } + }, + { + "type": "field", + "attrs": { + "name": "iamPasswordRegistrationEnabled", + "label": "Iam Password Registration Enabled" + } + }, + { + "type": "field", + "attrs": { + "name": "iamPasswordLessRegistrationEnabled", + "label": "Iam Password Less Registration enabled" + } + }, + { + "type": "field", + "attrs": { + "name": "iamActivateUserOnRegistration", + "label": "Iam Activate User On Registration" + } + }, + { + "type": "field", + "attrs": { + "name": "iamDefaultRole", + "label": "Iam Default Role" + } + }, + { + "type": "field", + "attrs": { + "name": "iamGoogleOAuthEnabled", + "label": "Iam Google OAuth Enabled" + } + }, + { + "type": "field", + "attrs": { + "name": "shouldQueueEmails", + "label": "Should Queue Emails" + } + }, + { + "type": "field", + "attrs": { + "name": "shouldQueueSms", + "label": "Should Queue SMS" + } + } + ] } ] } @@ -10596,236 +11099,140 @@ } }, { - "name": "mqMessage-form-view", - "displayName": "Mq Message", - "type": "form", + "name": "securityRule-list-view", + "displayName": "Security rules", + "type": "list", "context": "{}", "moduleUserKey": "solid-core", - "modelUserKey": "mqMessage", + "modelUserKey": "securityRule", "layout": { - "type": "form", + "type": "list", "attrs": { - "name": "form-1", - "label": "Mq Message", - "className": "grid", - "workflowField": "stage", - "workflowFieldUpdateEnabled": true, - "disabled": true, - "readonly": true + "pagination": true, + "pageSizeOptions": [ + 10, + 25, + 50 + ], + "enableGlobalSearch": true, + "create": false, + "edit": false, + "delete": false, + "import": false, + "export": false, + "allowedViews": [ + "list", + "tree" + ] }, "children": [ { - "type": "sheet", + "type": "field", "attrs": { - "name": "sheet-1" - }, - "children": [ - { - "type": "notebook", - "attrs": { - "name": "notebook-1" - }, - "children": [ - { - "type": "page", - "attrs": { - "name": "general-info", - "label": "General Info" - }, - "children": [ - { - "type": "row", - "attrs": { - "name": "row-1", - "className": "" - }, - "children": [ - { - "type": "column", - "attrs": { - "name": "col-1", - "className": "col-12" - }, - "children": [ - { - "type": "field", - "attrs": { - "name": "messageType", - "label": "Message Type" - } - }, - { - "type": "field", - "attrs": { - "name": "messageId", - "label": "Message Id" - } - }, - { - "type": "field", - "attrs": { - "name": "messageBroker", - "label": "Message Broker" - } - }, - { - "type": "field", - "attrs": { - "name": "retryCount", - "label": "Retry Count" - } - }, - { - "type": "field", - "attrs": { - "name": "retryInterval", - "label": "Retry Interval" - } - }, - { - "type": "field", - "attrs": { - "name": "startedAt", - "label": "Started At" - } - }, - { - "type": "field", - "attrs": { - "name": "finishedAt", - "label": "Finished At" - } - }, - { - "type": "field", - "attrs": { - "name": "elapsedMillis", - "label": "Elapsed Millis" - } - }, - { - "type": "field", - "attrs": { - "name": "parentEntityId", - "label": "Parent Entity Id" - } - }, - { - "type": "field", - "attrs": { - "name": "parentEntity", - "label": "Parent Entity" - } - }, - { - "type": "field", - "attrs": { - "name": "mqMessageQueue", - "label": "Message Queue" - } - } - ] - } - ] - } - ] - }, - { - "type": "page", - "attrs": { - "name": "input-tab", - "label": "Input" - }, - "children": [ - { - "type": "row", - "attrs": { - "name": "input-row" - }, - "children": [ - { - "type": "column", - "attrs": { - "name": "input-col", - "className": "col-12" - }, - "children": [ - { - "type": "field", - "attrs": { - "name": "input", - "label": "Input" - } - } - ] - } - ] - } - ] - }, - { - "type": "page", - "attrs": { - "name": "output-tab", - "label": "Output" - }, - "children": [ - { - "type": "row", - "attrs": { - "name": "output-row" - }, - "children": [ - { - "type": "column", - "attrs": { - "name": "output-col", - "className": "col-12" - }, - "children": [ - { - "type": "field", - "attrs": { - "name": "output", - "label": "Output" - } - } - ] - } - ] - } - ] - }, + "name": "name", + "label": "Name", + "sortable": true, + "isSearchable": true + } + }, + { + "type": "field", + "attrs": { + "name": "description", + "label": "Description", + "sortable": true, + "isSearchable": true + } + }, + { + "type": "field", + "attrs": { + "name": "role", + "label": "Role", + "isSearchable": true, + "searchField": "role.name" + } + }, + { + "type": "field", + "attrs": { + "name": "modelMetadata", + "label": "Model", + "isSearchable": true, + "searchField": "modelMetadata.singularName" + } + } + ] + } + }, + { + "name": "securityRule-form-view", + "displayName": "Security rules", + "type": "form", + "context": "{}", + "moduleUserKey": "solid-core", + "modelUserKey": "securityRule", + "layout": { + "type": "form", + "attrs": { + "name": "form-1", + "label": "Security rules", + "className": "grid", + "showAddFormButton": false, + "showEditFormButton": false + }, + "children": [ + { + "type": "sheet", + "attrs": { + "name": "sheet-1" + }, + "children": [ + { + "type": "row", + "attrs": { + "name": "group-1", + "label": "", + "className": "" + }, + "children": [ { - "type": "page", + "type": "column", "attrs": { - "name": "error-tab", - "label": "Error" + "name": "group-1", + "label": "", + "className": "col-12 sm:col-12 md:col-6 lg:col-6" }, "children": [ { - "type": "row", + "type": "field", "attrs": { - "name": "error-row" - }, - "children": [ - { - "type": "column", - "attrs": { - "name": "error-col", - "className": "col-12" - }, - "children": [ - { - "type": "field", - "attrs": { - "name": "error", - "label": "Error" - } - } - ] - } - ] + "name": "name" + } + }, + { + "type": "field", + "attrs": { + "name": "description" + } + }, + { + "type": "field", + "attrs": { + "name": "securityRuleConfig" + } + }, + { + "type": "field", + "attrs": { + "name": "role" + } + }, + { + "type": "field", + "attrs": { + "name": "modelMetadata" + } } ] } @@ -10837,12 +11244,12 @@ } }, { - "name": "mqMessageQueue-list-view", - "displayName": "Message Queues", + "name": "emailTemplate-list-view", + "displayName": "Email", "type": "list", "context": "{}", "moduleUserKey": "solid-core", - "modelUserKey": "mqMessageQueue", + "modelUserKey": "emailTemplate", "layout": { "type": "list", "attrs": { @@ -10855,36 +11262,59 @@ "enableGlobalSearch": true, "create": false, "edit": false, - "delete": false + "delete": false, + "import": false, + "export": false }, "children": [ { "type": "field", "attrs": { "name": "name", - "label": "Queue Name", + "label": "Name", + "sortable": true, + "isSearchable": true + } + }, + { + "type": "field", + "attrs": { + "name": "displayName", + "label": "Display Name", + "sortable": true, "isSearchable": true } + }, + { + "type": "field", + "attrs": { + "name": "active", + "isSearchable": false + } } ] } }, { - "name": "mqMessageQueue-form-view", - "displayName": "Mq Message Queue", + "name": "emailTemplate-form-view", + "displayName": "Email", "type": "form", "context": "{}", "moduleUserKey": "solid-core", - "modelUserKey": "mqMessageQueue", + "modelUserKey": "emailTemplate", "layout": { "type": "form", "attrs": { "name": "form-1", - "label": "Mq Message Queue", + "label": "Email", "className": "grid", "disabled": false, - "readonly": true + "readonly": false, + "showAddFormButton": false, + "showEditFormButton": false }, + "onFieldChange": "emailFormTypeChangeHandler", + "onFormLayoutLoad": "emailFormTypeLoad", "children": [ { "type": "sheet", @@ -10901,152 +11331,99 @@ { "type": "page", "attrs": { - "name": "page-general-info", - "label": "General Info" + "name": "page-general", + "label": "General" }, "children": [ { - "type": "row", + "type": "group", "attrs": { - "name": "row-1" + "name": "group-1", + "label": "", + "className": "col-12 sm:col-12 md:col-6 lg:col-6" }, "children": [ { - "type": "column", + "type": "field", "attrs": { - "name": "col-1", - "className": "col-12 sm:col-12 md:col-6 lg:col-6" - }, - "children": [ - { - "type": "field", - "attrs": { - "name": "name", - "label": "Queue Name" - } - } - ] + "name": "name", + "label": "Name" + } }, { - "type": "column", + "type": "field", "attrs": { - "name": "col-2", - "className": "col-12 sm:col-12 md:col-6 lg:col-6" - }, - "children": [ - { - "type": "field", - "attrs": { - "name": "description", - "label": "Queue Description" - } - } - ] - } - ] - } - ] - }, - { - "type": "page", - "attrs": { - "name": "page-messages", - "label": "Messages" - }, - "children": [ - { - "type": "row", - "attrs": { - "name": "row-2" - }, - "children": [ + "name": "displayName", + "label": "Display Name" + } + }, { - "type": "column", + "type": "field", "attrs": { - "name": "col-1", - "className": "col-12" - }, - "children": [ - { - "type": "field", - "attrs": { - "name": "mqMessages", - "label": "mqMessages", - "inlineCreate": "false", - "showLabel": false, - "inlineListLayout": { - "type": "list", - "attrs": { - "pagination": true, - "pageSizeOptions": [ - 10, - 25, - 50 - ], - "enableGlobalSearch": true, - "create": false, - "edit": false, - "delete": false - }, - "children": [ - { - "type": "field", - "attrs": { - "name": "messageId", - "isSearchable": true - } - }, - { - "type": "field", - "attrs": { - "name": "stage", - "isSearchable": true - } - }, - { - "type": "field", - "attrs": { - "name": "startedAt", - "isSearchable": true - } - }, - { - "type": "field", - "attrs": { - "name": "finishedAt", - "isSearchable": true - } - }, - { - "type": "field", - "attrs": { - "name": "elapsedMillis", - "isSearchable": true - } - }, - { - "type": "field", - "attrs": { - "name": "parentEntityId", - "isSearchable": true - } - }, - { - "type": "field", - "attrs": { - "name": "parentEntity", - "isSearchable": true - } - } - ] - } - } - } - ] + "name": "type", + "label": "Type" + } + }, + { + "type": "field", + "attrs": { + "name": "body", + "label": "Body" + } + } + ] + }, + { + "type": "group", + "attrs": { + "name": "group-1", + "label": "", + "className": "col-12 sm:col-12 md:col-6 lg:col-6" + }, + "children": [ + { + "type": "field", + "attrs": { + "name": "subject", + "label": "Subject" + } + }, + { + "type": "field", + "attrs": { + "name": "description", + "label": "Description" + } + }, + { + "type": "field", + "attrs": { + "name": "active", + "label": "Active" + } } ] } ] + }, + { + "type": "page", + "attrs": { + "name": "page-explorer", + "label": "Explorer" + }, + "children": [ + { + "type": "custom", + "attrs": { + "name": "metadata-explorer-widget", + "widget": "MetadataExplorerFormWidget", + "moduleName": "solid-core", + "arrayPath": "emailTemplates", + "itemNameField": "name" + } + } + ] } ] } @@ -11056,12 +11433,12 @@ } }, { - "name": "scheduledJob-list-view", - "displayName": "Scheduled Job", + "name": "smsTemplate-list-view", + "displayName": "SMS Template", "type": "list", "context": "{}", "moduleUserKey": "solid-core", - "modelUserKey": "scheduledJob", + "modelUserKey": "smsTemplate", "layout": { "type": "list", "attrs": { @@ -11072,118 +11449,69 @@ 50 ], "enableGlobalSearch": true, - "create": true, - "edit": true, - "delete": true + "create": false, + "edit": false, + "delete": false, + "import": false, + "export": false }, "children": [ { "type": "field", "attrs": { - "name": "id" - } - }, - { - "type": "field", - "attrs": { - "name": "scheduleName", - "isSearchable": true - } - }, - { - "type": "field", - "attrs": { - "name": "module", + "name": "name", + "label": "Name", + "sortable": true, "isSearchable": true } }, { "type": "field", "attrs": { - "name": "isActive" - } - }, - { - "type": "field", - "attrs": { - "name": "frequency", + "name": "displayName", + "label": "Display Name", + "sortable": true, "isSearchable": true } }, { "type": "field", "attrs": { - "name": "startTime" - } - }, - { - "type": "field", - "attrs": { - "name": "endTime" - } - }, - { - "type": "field", - "attrs": { - "name": "startDate" - } - }, - { - "type": "field", - "attrs": { - "name": "endDate" - } - }, - { - "type": "field", - "attrs": { - "name": "dayOfMonth" - } - }, - { - "type": "field", - "attrs": { - "name": "lastRunAt" - } - }, - { - "type": "field", - "attrs": { - "name": "nextRunAt" - } - }, - { - "type": "field", - "attrs": { - "name": "dayOfWeek" + "name": "active", + "isSearchable": false } }, { "type": "field", "attrs": { - "name": "job", + "name": "smsProviderTemplateId", + "label": "Template Provider Id", "sortable": true, - "filterable": true + "isSearchable": true } } ] } }, { - "name": "scheduledJob-form-view", - "displayName": "Scheduled Job", + "name": "smsTemplate-form-view", + "displayName": "SMS Template", "type": "form", "context": "{}", "moduleUserKey": "solid-core", - "modelUserKey": "scheduledJob", + "modelUserKey": "smsTemplate", "layout": { "type": "form", "attrs": { "name": "form-1", - "label": "Scheduled Job", - "className": "grid" + "label": "Email", + "className": "grid", + "disabled": false, + "readonly": false, + "showAddFormButton": false, + "showEditFormButton": false }, - "onFieldChange": "scheduleFrequencyOnFieldChangeHandler", + "onFieldChange": "emailFormTypeChangeHandler", "children": [ { "type": "sheet", @@ -11205,105 +11533,71 @@ }, "children": [ { - "type": "row", + "type": "group", "attrs": { - "name": "row-general" + "name": "group-1", + "label": "", + "className": "col-12 sm:col-12 md:col-6 lg:col-6" }, "children": [ { - "type": "column", + "type": "field", "attrs": { - "name": "group-basic", - "label": "Basic", - "className": "col-12 sm:col-12 md:col-6 lg:col-6" - }, - "children": [ - { - "type": "field", - "attrs": { - "name": "scheduleName" - } - }, - { - "type": "field", - "attrs": { - "name": "job" - } - }, - { - "type": "field", - "attrs": { - "name": "module" - } - }, - { - "type": "field", - "attrs": { - "name": "isActive" - } - } - ] + "name": "name", + "label": "Name" + } }, { - "type": "column", + "type": "field", "attrs": { - "name": "group-config", - "label": "Configuration", - "className": "col-12 sm:col-12 md:col-6 lg:col-6" - }, - "children": [ - { - "type": "field", - "attrs": { - "name": "frequency" - } - }, - { - "type": "field", - "attrs": { - "name": "cronExpression", - "visible": false - } - }, - { - "type": "field", - "attrs": { - "name": "startTime" - } - }, - { - "type": "field", - "attrs": { - "name": "endTime" - } - }, - { - "type": "field", - "attrs": { - "name": "startDate" - } - }, - { - "type": "field", - "attrs": { - "name": "endDate" - } - }, - { - "type": "field", - "attrs": { - "name": "dayOfWeek", - "visible": false - } - }, - { - "type": "field", - "attrs": { - "name": "dayOfMonth", - "visible": false - } - } - ] + "name": "displayName", + "label": "Display Name" + } + }, + { + "type": "field", + "attrs": { + "name": "type", + "label": "Type" + } + } + ] + }, + { + "type": "group", + "attrs": { + "name": "group-1", + "label": "", + "className": "col-12 sm:col-12 md:col-6 lg:col-6" + }, + "children": [ + { + "type": "field", + "attrs": { + "name": "body", + "label": "Body" + } + }, + { + "type": "field", + "attrs": { + "name": "smsProviderTemplateId", + "label": "Sms Provider Template Id" + } + }, + { + "type": "field", + "attrs": { + "name": "description", + "label": "Description" + } + }, + { + "type": "field", + "attrs": { + "name": "active", + "label": "Active" + } } ] } @@ -11312,43 +11606,19 @@ { "type": "page", "attrs": { - "name": "page-runtime", - "label": "Runtime" + "name": "page-explorer", + "label": "Explorer" }, "children": [ { - "type": "row", + "type": "custom", "attrs": { - "name": "row-runtime" - }, - "children": [ - { - "type": "column", - "attrs": { - "name": "group-runtime", - "label": "Execution", - "className": "col-12" - }, - "children": [ - { - "type": "field", - "attrs": { - "name": "lastRunAt", - "disabled": true, - "readOnly": true - } - }, - { - "type": "field", - "attrs": { - "name": "nextRunAt", - "disabled": true, - "readOnly": true - } - } - ] - } - ] + "name": "metadata-explorer-widget", + "widget": "MetadataExplorerFormWidget", + "moduleName": "solid-core", + "arrayPath": "smsTemplates", + "itemNameField": "name" + } } ] } @@ -11360,12 +11630,12 @@ } }, { - "name": "settings-list-view", - "displayName": "Settings", + "name": "importTransaction-list-view", + "displayName": "Import Transactions", "type": "list", "context": "{}", "moduleUserKey": "solid-core", - "modelUserKey": "setting", + "modelUserKey": "importTransaction", "layout": { "type": "list", "attrs": { @@ -11376,35 +11646,75 @@ 50 ], "enableGlobalSearch": true, - "create": true, - "edit": true, - "delete": false + "create": false, + "edit": false, + "delete": false, + "import": false, + "export": false }, "children": [ { "type": "field", "attrs": { - "name": "appTitle", - "label": "App Name", - "isSearchable": true + "name": "id", + "label": "ID", + "sortable": true, + "isSearchable": false + } + }, + { + "type": "field", + "attrs": { + "name": "modelMetadata", + "label": "Model", + "isSearchable": true, + "searchField": "modelMetadata.name" + } + }, + { + "type": "field", + "attrs": { + "name": "status", + "label": "Status", + "isSearchable": true, + "sortable": true + } + }, + { + "type": "field", + "attrs": { + "name": "createdAt", + "label": "Created At", + "isSearchable": false, + "sortable": true + } + }, + { + "type": "field", + "attrs": { + "name": "createdBy", + "label": "Created By", + "isSearchable": false } } ] } }, { - "name": "settings-form-view", - "displayName": "Settings", + "name": "importTransaction-form-view", + "displayName": "Import Transactions", "type": "form", "context": "{}", "moduleUserKey": "solid-core", - "modelUserKey": "setting", + "modelUserKey": "importTransaction", "layout": { "type": "form", "attrs": { "name": "form-1", - "label": "Settings", - "className": "grid" + "label": "Import Transactions", + "className": "grid", + "showAddFormButton": false, + "showEditFormButton": false }, "children": [ { @@ -11414,117 +11724,85 @@ }, "children": [ { - "type": "group", + "type": "notebook", "attrs": { - "name": "group-1", - "label": "", - "className": "col-12 sm:col-12 md:col-6 lg:col-6" + "name": "notebook-1" }, "children": [ { - "type": "field", - "attrs": { - "name": "authPagesLayout", - "label": "Auth Pages Layout" - } - }, - { - "type": "field", - "attrs": { - "name": "appTitle", - "label": "App Title" - } - }, - { - "type": "field", - "attrs": { - "name": "appLogo", - "label": "App Logo" - } - }, - { - "type": "field", - "attrs": { - "name": "appDescription", - "label": "App Description" - } - }, - { - "type": "field", - "attrs": { - "name": "appTnc", - "label": "App TNC" - } - }, - { - "type": "field", - "attrs": { - "name": "appPrivacyPolicy", - "label": "App Privacy Policy" - } - }, - { - "type": "field", - "attrs": { - "name": "iamAllowPublicRegistration", - "label": "Iam Allow Public Registration" - } - }, - { - "type": "field", - "attrs": { - "name": "forceChangePasswordOnFirstLogin", - "label": "Force Password Change On First Login" - } - }, - { - "type": "field", - "attrs": { - "name": "iamPasswordRegistrationEnabled", - "label": "Iam Password Registration Enabled" - } - }, - { - "type": "field", - "attrs": { - "name": "iamPasswordLessRegistrationEnabled", - "label": "Iam Password Less Registration enabled" - } - }, - { - "type": "field", - "attrs": { - "name": "iamActivateUserOnRegistration", - "label": "Iam Activate User On Registration" - } - }, - { - "type": "field", - "attrs": { - "name": "iamDefaultRole", - "label": "Iam Default Role" - } - }, - { - "type": "field", - "attrs": { - "name": "iamGoogleOAuthEnabled", - "label": "Iam Google OAuth Enabled" - } - }, - { - "type": "field", + "type": "page", "attrs": { - "name": "shouldQueueEmails", - "label": "Should Queue Emails" - } + "name": "page-1", + "label": "Details" + }, + "children": [ + { + "type": "group", + "attrs": { + "name": "group-1", + "label": "", + "className": "col-12 sm:col-12 md:col-6 lg:col-6" + }, + "children": [ + { + "type": "field", + "attrs": { + "name": "modelMetadata" + } + }, + { + "type": "field", + "attrs": { + "name": "status" + } + }, + { + "type": "field", + "attrs": { + "name": "mapping" + } + }, + { + "type": "field", + "attrs": { + "name": "createdAt" + } + }, + { + "type": "field", + "attrs": { + "name": "createdBy" + } + } + ] + } + ] }, { - "type": "field", + "type": "page", "attrs": { - "name": "shouldQueueSms", - "label": "Should Queue SMS" - } + "name": "page-2", + "label": "Error Logs" + }, + "children": [ + { + "type": "group", + "attrs": { + "name": "group-1", + "label": "", + "className": "col-12" + }, + "children": [ + { + "type": "field", + "attrs": { + "name": "importTransactionErrorLog", + "showLabel": false + } + } + ] + } + ] } ] } @@ -11534,12 +11812,12 @@ } }, { - "name": "securityRule-list-view", - "displayName": "Security rules", + "name": "userActivityHistory-list-view", + "displayName": "User Activity History", "type": "list", "context": "{}", "moduleUserKey": "solid-core", - "modelUserKey": "securityRule", + "modelUserKey": "userActivityHistory", "layout": { "type": "list", "attrs": { @@ -11550,45 +11828,136 @@ 50 ], "enableGlobalSearch": true, - "create": true, - "edit": true, - "delete": false + "create": false, + "edit": false, + "delete": false, + "import": false, + "export": false, + "allowedViews": [ + "list", + "tree" + ] }, "children": [ { "type": "field", "attrs": { - "name": "id", - "label": "Id", + "name": "event", + "label": "Event", "sortable": true, - "filterable": true + "isSearchable": true } }, { "type": "field", "attrs": { - "name": "name", - "label": "Name", + "name": "user", + "label": "User", + "sortable": false, + "isSearchable": true, + "searchField": "user.fullName", + "coModelFieldToDisplay": "fullName" + } + }, + { + "type": "field", + "attrs": { + "name": "ipAddress", + "label": "IP Address", + "sortable": true, + "isSearchable": true + } + }, + { + "type": "field", + "attrs": { + "name": "createdAt", + "label": "Created At", "sortable": true, - "filterable": true + "isSearchable": false } } ] } }, { - "name": "securityRule-form-view", - "displayName": "Security rules", + "name": "userActivityHistory-tree-view", + "displayName": "User Activity History", + "type": "tree", + "context": "{}", + "moduleUserKey": "solid-core", + "modelUserKey": "userActivityHistory", + "layout": { + "type": "tree", + "attrs": { + "pagination": true, + "pageSizeOptions": [ + 10, + 25, + 50 + ], + "enableGlobalSearch": true, + "create": false, + "edit": false, + "delete": false, + "import": false, + "export": false + }, + "children": [ + { + "type": "field", + "attrs": { + "name": "event", + "label": "Event", + "sortable": true, + "isSearchable": true + } + }, + { + "type": "field", + "attrs": { + "name": "user", + "label": "User", + "isSearchable": true, + "searchField": "user.fullName" + } + }, + { + "type": "field", + "attrs": { + "name": "ipAddress", + "label": "IP Address", + "sortable": true, + "isSearchable": true + } + }, + { + "type": "field", + "attrs": { + "name": "createdAt", + "label": "Created At", + "sortable": true, + "isSearchable": false + } + } + ] + } + }, + { + "name": "userActivityHistory-form-view", + "displayName": "User Activity History", "type": "form", "context": "{}", "moduleUserKey": "solid-core", - "modelUserKey": "securityRule", + "modelUserKey": "userActivityHistory", "layout": { "type": "form", "attrs": { "name": "form-1", - "label": "Security rules", - "className": "grid" + "label": "User Activity History", + "className": "grid", + "showAddFormButton": false, + "showEditFormButton": false }, "children": [ { @@ -11600,9 +11969,7 @@ { "type": "row", "attrs": { - "name": "group-1", - "label": "", - "className": "" + "name": "sheet-1" }, "children": [ { @@ -11616,19 +11983,25 @@ { "type": "field", "attrs": { - "name": "name" + "name": "user" } }, { "type": "field", "attrs": { - "name": "description" + "name": "event" } }, { "type": "field", "attrs": { - "name": "securityRuleConfig" + "name": "ipAddress" + } + }, + { + "type": "field", + "attrs": { + "name": "userAgent" } } ] @@ -11640,20 +12013,7 @@ "label": "", "className": "col-12 sm:col-12 md:col-6 lg:col-6" }, - "children": [ - { - "type": "field", - "attrs": { - "name": "role" - } - }, - { - "type": "field", - "attrs": { - "name": "modelMetadata" - } - } - ] + "children": [] } ] } @@ -11663,12 +12023,12 @@ } }, { - "name": "emailTemplate-list-view", - "displayName": "Email", + "name": "importTransactionErrorLog-list-view", + "displayName": "Import Error Logs", "type": "list", "context": "{}", "moduleUserKey": "solid-core", - "modelUserKey": "emailTemplate", + "modelUserKey": "importTransactionErrorLog", "layout": { "type": "list", "attrs": { @@ -11681,39 +12041,38 @@ "enableGlobalSearch": true, "create": true, "edit": true, - "delete": false + "delete": true }, "children": [ { "type": "field", "attrs": { - "name": "name", - "label": "Email", - "sortable": true, - "filterable": true + "name": "id" + } + }, + { + "type": "field", + "attrs": { + "name": "importTransaction" } } ] } }, { - "name": "emailTemplate-form-view", - "displayName": "Email", + "name": "importTransactionErrorLog-form-view", + "displayName": "Import Error Logs", "type": "form", "context": "{}", "moduleUserKey": "solid-core", - "modelUserKey": "emailTemplate", + "modelUserKey": "importTransactionErrorLog", "layout": { "type": "form", "attrs": { "name": "form-1", - "label": "Email", - "className": "grid", - "disabled": false, - "readonly": false + "label": "Import Error Logs", + "className": "grid" }, - "onFieldChange": "emailFormTypeChangeHandler", - "onFormLayoutLoad": "emailFormTypeLoad", "children": [ { "type": "sheet", @@ -11722,71 +12081,35 @@ }, "children": [ { - "type": "group", - "attrs": { - "name": "group-1", - "label": "", - "className": "col-12 sm:col-12 md:col-6 lg:col-6" - }, - "children": [ - { - "type": "field", - "attrs": { - "name": "name", - "label": "Name" - } - }, - { - "type": "field", - "attrs": { - "name": "displayName", - "label": "Display Name" - } - }, - { - "type": "field", - "attrs": { - "name": "type", - "label": "Type" - } - }, - { - "type": "field", - "attrs": { - "name": "body", - "label": "Body" - } - } - ] - }, - { - "type": "group", + "type": "row", "attrs": { - "name": "group-1", - "label": "", - "className": "col-12 sm:col-12 md:col-6 lg:col-6" + "name": "sheet-1" }, "children": [ { - "type": "field", - "attrs": { - "name": "subject", - "label": "Subject" - } - }, - { - "type": "field", + "type": "column", "attrs": { - "name": "description", - "label": "Description" - } + "name": "group-1", + "label": "", + "className": "col-12 sm:col-12 md:col-6 lg:col-6" + }, + "children": [ + { + "type": "field", + "attrs": { + "name": "importTransaction" + } + } + ] }, { - "type": "field", + "type": "column", "attrs": { - "name": "active", - "label": "Active" - } + "name": "group-2", + "label": "", + "className": "col-12 sm:col-12 md:col-6 lg:col-6" + }, + "children": [] } ] } @@ -11796,12 +12119,12 @@ } }, { - "name": "smsTemplate-list-view", - "displayName": "SMS Template", + "name": "modelSequence-list-view", + "displayName": "Model Sequence", "type": "list", "context": "{}", "moduleUserKey": "solid-core", - "modelUserKey": "smsTemplate", + "modelUserKey": "modelSequence", "layout": { "type": "list", "attrs": { @@ -11812,40 +12135,103 @@ 50 ], "enableGlobalSearch": true, - "create": true, - "edit": true, - "delete": false + "create": false, + "edit": false, + "delete": false, + "import": false, + "export": false, + "allowedViews": [ + "list", + "tree" + ] }, "children": [ { "type": "field", "attrs": { - "name": "name", - "label": "Name", - "sortable": true, - "filterable": true + "name": "module", + "label": "Module", + "isSearchable": true, + "searchField": "module.name" + } + }, + { + "type": "field", + "attrs": { + "name": "model", + "label": "Model", + "isSearchable": true, + "searchField": "model.name" + } + }, + { + "type": "field", + "attrs": { + "name": "field", + "label": "Field", + "isSearchable": false + } + }, + { + "type": "field", + "attrs": { + "name": "sequenceName", + "label": "Sequence", + "isSearchable": false + } + }, + { + "type": "field", + "attrs": { + "name": "prefix", + "label": "Prefix", + "isSearchable": false + } + }, + { + "type": "field", + "attrs": { + "name": "separator", + "label": "Separator", + "isSearchable": false + } + }, + { + "type": "field", + "attrs": { + "name": "padding", + "label": "Padding", + "isSearchable": false + } + }, + { + "type": "field", + "attrs": { + "name": "currentValue", + "label": "Current Value", + "isSearchable": false } } ] } }, { - "name": "smsTemplate-form-view", - "displayName": "SMS Template", + "name": "modelSequence-form-view", + "displayName": "Model Sequence", "type": "form", "context": "{}", "moduleUserKey": "solid-core", - "modelUserKey": "smsTemplate", + "modelUserKey": "modelSequence", "layout": { "type": "form", "attrs": { "name": "form-1", - "label": "Email", + "label": "Model Sequence", "className": "grid", - "disabled": false, - "readonly": false + "showAddFormButton": false, + "showEditFormButton": false }, - "onFieldChange": "emailFormTypeChangeHandler", + "onFieldChange": "modelSequenceFormViewChangeHandler", "children": [ { "type": "sheet", @@ -11854,71 +12240,104 @@ }, "children": [ { - "type": "group", - "attrs": { - "name": "group-1", - "label": "", - "className": "col-12 sm:col-12 md:col-6 lg:col-6" - }, - "children": [ - { - "type": "field", - "attrs": { - "name": "name", - "label": "Name" - } - }, - { - "type": "field", - "attrs": { - "name": "displayName", - "label": "Display Name" - } - }, - { - "type": "field", - "attrs": { - "name": "type", - "label": "Type" - } - } - ] - }, - { - "type": "group", + "type": "notebook", "attrs": { - "name": "group-1", - "label": "", - "className": "col-12 sm:col-12 md:col-6 lg:col-6" + "name": "notebook-1" }, "children": [ { - "type": "field", - "attrs": { - "name": "body", - "label": "Body" - } - }, - { - "type": "field", - "attrs": { - "name": "smsProviderTemplateId", - "label": "Sms Provider Template Id" - } - }, - { - "type": "field", + "type": "page", "attrs": { - "name": "description", - "label": "Description" - } + "name": "page-general", + "label": "General" + }, + "children": [ + { + "type": "row", + "attrs": { + "name": "sheet-1" + }, + "children": [ + { + "type": "column", + "attrs": { + "name": "group-1", + "label": "", + "className": "col-12 sm:col-12 md:col-6 lg:col-6" + }, + "children": [ + { + "type": "field", + "attrs": { + "name": "sequenceName" + } + }, + { + "type": "field", + "attrs": { + "name": "prefix" + } + }, + { + "type": "field", + "attrs": { + "name": "separator" + } + }, + { + "type": "field", + "attrs": { + "name": "module" + } + }, + { + "type": "field", + "attrs": { + "name": "model" + } + }, + { + "type": "field", + "attrs": { + "name": "field" + } + }, + { + "type": "field", + "attrs": { + "name": "currentValue" + } + }, + { + "type": "field", + "attrs": { + "name": "padding" + } + } + ] + } + ] + } + ] }, { - "type": "field", + "type": "page", "attrs": { - "name": "active", - "label": "Active" - } + "name": "page-explorer", + "label": "Explorer" + }, + "children": [ + { + "type": "custom", + "attrs": { + "name": "metadata-explorer-widget", + "widget": "MetadataExplorerFormWidget", + "moduleName": "solid-core", + "arrayPath": "modelSequences", + "itemNameField": "sequenceName" + } + } + ] } ] } @@ -11928,12 +12347,12 @@ } }, { - "name": "importTransaction-list-view", - "displayName": "Import Transactions", + "name": "agentSession-list-view", + "displayName": "Agent Sessions", "type": "list", "context": "{}", "moduleUserKey": "solid-core", - "modelUserKey": "importTransaction", + "modelUserKey": "agentSession", "layout": { "type": "list", "attrs": { @@ -11944,43 +12363,78 @@ 50 ], "enableGlobalSearch": true, - "create": true, - "edit": true, - "delete": true + "create": false, + "edit": false, + "delete": false, + "import": false, + "export": false }, "children": [ { "type": "field", "attrs": { - "name": "id", - "sortable": true, - "filterable": true + "name": "sessionId", + "isSearchable": true } }, { "type": "field", "attrs": { - "name": "importTransactionErrorLog", - "sortable": true, - "filterable": true + "name": "modelName", + "label": "Model", + "isSearchable": true + } + }, + { + "type": "field", + "attrs": { + "name": "status", + "isSearchable": true + } + }, + { + "type": "field", + "attrs": { + "name": "projectRoot", + "isSearchable": false + } + }, + { + "type": "field", + "attrs": { + "name": "totalCost" + } + }, + { + "type": "field", + "attrs": { + "name": "totalInputTokens" + } + }, + { + "type": "field", + "attrs": { + "name": "totalOutputTokens" } } ] } }, { - "name": "importTransaction-form-view", - "displayName": "Import Transactions", + "name": "agentSession-form-view", + "displayName": "Agent Session", "type": "form", "context": "{}", "moduleUserKey": "solid-core", - "modelUserKey": "importTransaction", + "modelUserKey": "agentSession", "layout": { "type": "form", "attrs": { "name": "form-1", - "label": "Import Transactions", - "className": "grid" + "label": "Agent Session", + "className": "grid", + "showAddFormButton": false, + "showEditFormButton": false }, "children": [ { @@ -11992,33 +12446,72 @@ { "type": "row", "attrs": { - "name": "sheet-1" + "name": "row-1" }, "children": [ { "type": "column", "attrs": { - "name": "group-1", + "name": "col-1", "label": "", - "className": "col-12 sm:col-12 md:col-6 lg:col-6" + "className": "col-12" }, "children": [ { "type": "field", "attrs": { - "name": "importTransactionErrorLog" + "name": "sessionId" + } + }, + { + "type": "field", + "attrs": { + "name": "modelName" + } + }, + { + "type": "field", + "attrs": { + "name": "status" + } + }, + { + "type": "field", + "attrs": { + "name": "projectRoot" + } + }, + { + "type": "field", + "attrs": { + "name": "totalSteps" + } + }, + { + "type": "field", + "attrs": { + "name": "totalCost" + } + }, + { + "type": "field", + "attrs": { + "name": "totalInputTokens" + } + }, + { + "type": "field", + "attrs": { + "name": "totalOutputTokens" + } + }, + { + "type": "field", + "attrs": { + "name": "summary" } } ] - }, - { - "type": "column", - "attrs": { - "name": "group-2", - "label": "", - "className": "col-12 sm:col-12 md:col-6 lg:col-6" - }, - "children": [] } ] } @@ -12028,12 +12521,12 @@ } }, { - "name": "userActivityHistory-list-view", - "displayName": "User Activity History", + "name": "agentEvent-list-view", + "displayName": "Agent Events", "type": "list", "context": "{}", "moduleUserKey": "solid-core", - "modelUserKey": "userActivityHistory", + "modelUserKey": "agentEvent", "layout": { "type": "list", "attrs": { @@ -12044,132 +12537,85 @@ 50 ], "enableGlobalSearch": true, - "create": true, - "edit": true, - "delete": true + "create": false, + "edit": false, + "delete": false, + "import": false, + "export": false }, "children": [ { "type": "field", "attrs": { - "name": "id", - "sortable": true, - "filterable": true - } - }, - { - "type": "field", - "attrs": { - "name": "user", - "sortable": true, - "filterable": true + "name": "sessionId", + "label": "Session", + "isSearchable": true } }, { "type": "field", "attrs": { - "name": "event", - "sortable": true, - "filterable": true + "name": "eventType", + "isSearchable": true } }, { "type": "field", "attrs": { - "name": "ipAddress", - "sortable": true, - "filterable": true + "name": "toolName", + "isSearchable": true } }, { "type": "field", "attrs": { - "name": "createdAt", - "sortable": true, - "filterable": true - } - } - ] - } - }, - { - "name": "userActivityHistory-tree-view", - "displayName": "User Activity History", - "type": "tree", - "context": "{}", - "moduleUserKey": "solid-core", - "modelUserKey": "userActivityHistory", - "layout": { - "type": "tree", - "attrs": { - "pagination": true, - "pageSizeOptions": [ - 10, - 25, - 50 - ], - "enableGlobalSearch": true, - "create": true, - "edit": true, - "delete": true - }, - "children": [ - { - "type": "field", - "attrs": { - "name": "id", - "sortable": true, - "filterable": true + "name": "modelUsed", + "label": "Model Used", + "isSearchable": true } }, { "type": "field", "attrs": { - "name": "user", - "sortable": true, - "filterable": true + "name": "cost" } }, { "type": "field", "attrs": { - "name": "event", - "sortable": true, - "filterable": true + "name": "inputTokens" } }, { "type": "field", "attrs": { - "name": "ipAddress", - "sortable": true, - "filterable": true + "name": "outputTokens" } }, { "type": "field", "attrs": { - "name": "createdAt", - "sortable": true, - "filterable": true + "name": "turnNumber" } } ] } }, { - "name": "userActivityHistory-form-view", - "displayName": "User Activity History", + "name": "agentEvent-form-view", + "displayName": "Agent Event", "type": "form", "context": "{}", "moduleUserKey": "solid-core", - "modelUserKey": "userActivityHistory", + "modelUserKey": "agentEvent", "layout": { "type": "form", "attrs": { "name": "form-1", - "label": "User Activity History", - "className": "grid" + "label": "Agent Event", + "className": "grid", + "showAddFormButton": false, + "showEditFormButton": false }, "children": [ { @@ -12179,53 +12625,211 @@ }, "children": [ { - "type": "row", + "type": "notebook", "attrs": { - "name": "sheet-1" + "name": "notebook-1" }, "children": [ { - "type": "column", + "type": "page", "attrs": { - "name": "group-1", - "label": "", - "className": "col-12 sm:col-12 md:col-6 lg:col-6" + "name": "page-general", + "label": "General" }, "children": [ { - "type": "field", - "attrs": { - "name": "user" - } - }, - { - "type": "field", + "type": "row", "attrs": { - "name": "event" - } - }, + "name": "page-general-row-1" + }, + "children": [ + { + "type": "column", + "attrs": { + "name": "page-general-col-1", + "label": "", + "className": "col-12" + }, + "children": [ + { + "type": "field", + "attrs": { + "name": "sessionId" + } + }, + { + "type": "field", + "attrs": { + "name": "eventType" + } + }, + { + "type": "field", + "attrs": { + "name": "turnNumber" + } + }, + { + "type": "field", + "attrs": { + "name": "stepNumber" + } + }, + { + "type": "field", + "attrs": { + "name": "toolName" + } + }, + { + "type": "field", + "attrs": { + "name": "modelUsed" + } + }, + { + "type": "field", + "attrs": { + "name": "durationMs" + } + }, + { + "type": "field", + "attrs": { + "name": "cost" + } + }, + { + "type": "field", + "attrs": { + "name": "inputTokens" + } + }, + { + "type": "field", + "attrs": { + "name": "outputTokens" + } + }, + { + "type": "field", + "attrs": { + "name": "toolReturncode" + } + }, + { + "type": "field", + "attrs": { + "name": "content" + } + } + ] + } + ] + } + ] + }, + { + "type": "page", + "attrs": { + "name": "page-tool-arguments", + "label": "Tool Arguments" + }, + "children": [ { - "type": "field", + "type": "row", "attrs": { - "name": "ipAddress" - } - }, + "name": "page-tool-arguments-row-1" + }, + "children": [ + { + "type": "column", + "attrs": { + "name": "page-tool-arguments-col-1", + "label": "", + "className": "col-12" + }, + "children": [ + { + "type": "field", + "attrs": { + "name": "toolArguments", + "viewWidget": "SolidJsonFormViewWidget" + } + } + ] + } + ] + } + ] + }, + { + "type": "page", + "attrs": { + "name": "page-tool-output", + "label": "Tool Output" + }, + "children": [ { - "type": "field", + "type": "row", "attrs": { - "name": "userAgent" - } + "name": "page-tool-output-row-1" + }, + "children": [ + { + "type": "column", + "attrs": { + "name": "page-tool-output-col-1", + "label": "", + "className": "col-12" + }, + "children": [ + { + "type": "field", + "attrs": { + "name": "toolOutput", + "viewWidget": "SolidJsonFormViewWidget" + } + } + ] + } + ] } ] }, { - "type": "column", + "type": "page", "attrs": { - "name": "group-2", - "label": "", - "className": "col-12 sm:col-12 md:col-6 lg:col-6" + "name": "page-event-data", + "label": "Event Data" }, - "children": [] + "children": [ + { + "type": "row", + "attrs": { + "name": "page-event-data-row-1" + }, + "children": [ + { + "type": "column", + "attrs": { + "name": "page-event-data-col-1", + "label": "", + "className": "col-12" + }, + "children": [ + { + "type": "field", + "attrs": { + "name": "eventData", + "viewWidget": "SolidJsonFormViewWidget" + } + } + ] + } + ] + } + ] } ] } @@ -12235,12 +12839,12 @@ } }, { - "name": "dashboard-list-view", - "displayName": "Dashboard", + "name": "mcpAuditLog-list-view", + "displayName": "MCP Audit Logs", "type": "list", "context": "{}", "moduleUserKey": "solid-core", - "modelUserKey": "dashboard", + "modelUserKey": "mcpAuditLog", "layout": { "type": "list", "attrs": { @@ -12251,9 +12855,11 @@ 50 ], "enableGlobalSearch": true, - "create": true, - "edit": true, - "delete": true + "create": false, + "edit": false, + "delete": false, + "import": false, + "export": false }, "children": [ { @@ -12265,37 +12871,81 @@ { "type": "field", "attrs": { - "name": "name" + "name": "createdAt" + } + }, + { + "type": "field", + "attrs": { + "name": "method", + "isSearchable": true + } + }, + { + "type": "field", + "attrs": { + "name": "toolName", + "isSearchable": true + } + }, + { + "type": "field", + "attrs": { + "name": "status", + "isSearchable": true + } + }, + { + "type": "field", + "attrs": { + "name": "username", + "isSearchable": true + } + }, + { + "type": "field", + "attrs": { + "name": "transport", + "isSearchable": true + } + }, + { + "type": "field", + "attrs": { + "name": "clientAddr", + "isSearchable": true } }, { "type": "field", "attrs": { - "name": "displayName" + "name": "durationMs" } }, { "type": "field", "attrs": { - "name": "module" + "name": "errorCode" } } ] } }, { - "name": "dashboard-form-view", - "displayName": "Dashboard", + "name": "mcpAuditLog-form-view", + "displayName": "MCP Audit Log", "type": "form", "context": "{}", "moduleUserKey": "solid-core", - "modelUserKey": "dashboard", + "modelUserKey": "mcpAuditLog", "layout": { "type": "form", "attrs": { "name": "form-1", - "label": "Dashboard", - "className": "grid" + "label": "MCP Audit Log", + "className": "grid", + "showAddFormButton": false, + "showEditFormButton": false }, "children": [ { @@ -12313,62 +12963,128 @@ { "type": "page", "attrs": { - "name": "page-1", - "label": "Dashboard" + "name": "page-general", + "label": "General" }, "children": [ { "type": "row", "attrs": { - "name": "row-1" + "name": "page-general-row-1" }, "children": [ { "type": "column", "attrs": { - "name": "group-1", + "name": "page-general-col-1", "label": "", - "className": "col-12 sm:col-12 md:col-6 lg:col-6" + "className": "col-12" }, "children": [ { "type": "field", "attrs": { - "name": "name" + "name": "method" } }, { "type": "field", "attrs": { - "name": "displayName" + "name": "toolName" } }, { "type": "field", "attrs": { - "name": "description" + "name": "status" } }, { "type": "field", "attrs": { - "name": "module" + "name": "transport" + } + }, + { + "type": "field", + "attrs": { + "name": "mcpSessionId" + } + }, + { + "type": "field", + "attrs": { + "name": "requestId" + } + }, + { + "type": "field", + "attrs": { + "name": "userId" + } + }, + { + "type": "field", + "attrs": { + "name": "apiKeyId" + } + }, + { + "type": "field", + "attrs": { + "name": "username" + } + }, + { + "type": "field", + "attrs": { + "name": "clientAddr" + } + }, + { + "type": "field", + "attrs": { + "name": "durationMs" + } + }, + { + "type": "field", + "attrs": { + "name": "errorCode" } } ] - }, + } + ] + } + ] + }, + { + "type": "page", + "attrs": { + "name": "page-request-params", + "label": "Request Params" + }, + "children": [ + { + "type": "row", + "attrs": { + "name": "page-request-params-row-1" + }, + "children": [ { "type": "column", "attrs": { - "name": "group-2", + "name": "page-request-params-col-1", "label": "", - "className": "col-12 sm:col-12 md:col-6 lg:col-6" + "className": "col-12" }, "children": [ { "type": "field", "attrs": { - "name": "layoutJson" + "name": "requestParams", + "viewWidget": "SolidJsonFormViewWidget" } } ] @@ -12380,20 +13096,20 @@ { "type": "page", "attrs": { - "name": "page-2", - "label": "Dashboard Variables" + "name": "page-response-result", + "label": "Response Result" }, "children": [ { "type": "row", "attrs": { - "name": "row-2" + "name": "page-response-result-row-1" }, "children": [ { "type": "column", "attrs": { - "name": "group-3", + "name": "page-response-result-col-1", "label": "", "className": "col-12" }, @@ -12401,8 +13117,41 @@ { "type": "field", "attrs": { - "showLabel": false, - "name": "dashboardVariables" + "name": "responseResult", + "viewWidget": "SolidJsonFormViewWidget" + } + } + ] + } + ] + } + ] + }, + { + "type": "page", + "attrs": { + "name": "page-error-message", + "label": "Error Message" + }, + "children": [ + { + "type": "row", + "attrs": { + "name": "page-error-message-row-1" + }, + "children": [ + { + "type": "column", + "attrs": { + "name": "page-error-message-col-1", + "label": "", + "className": "col-12" + }, + "children": [ + { + "type": "field", + "attrs": { + "name": "errorMessage" } } ] @@ -12419,12 +13168,12 @@ } }, { - "name": "dashboardVariable-list-view", - "displayName": "Dashboard Variable", + "name": "dashboardUserLayout-list-view", + "displayName": "Dashboard User Layout", "type": "list", "context": "{}", "moduleUserKey": "solid-core", - "modelUserKey": "dashboardVariable", + "modelUserKey": "dashboardUserLayout", "layout": { "type": "list", "attrs": { @@ -12435,51 +13184,122 @@ 50 ], "enableGlobalSearch": true, - "create": true, - "edit": true, - "delete": true + "create": false, + "edit": false, + "delete": false, + "import": false, + "export": false }, "children": [ { "type": "field", "attrs": { - "name": "id" + "name": "module", + "isSearchable": true, + "searchField": "module.name" + } + }, + { + "type": "field", + "attrs": { + "name": "dashboardName", + "label": "Name", + "isSearchable": true + } + }, + { + "type": "field", + "attrs": { + "name": "version" + } + }, + { + "type": "field", + "attrs": { + "name": "user", + "isSearchable": true, + "searchField": "user.fullName", + "coModelFieldToDisplay": "fullName" + } + } + ] + } + }, + { + "name": "dashboardUserLayout-tree-view", + "displayName": "Dashboard User Layout", + "type": "tree", + "context": "{}", + "moduleUserKey": "solid-core", + "modelUserKey": "dashboardUserLayout", + "layout": { + "type": "tree", + "attrs": { + "pagination": true, + "pageSizeOptions": [ + 10, + 25, + 50 + ], + "enableGlobalSearch": true, + "create": false, + "edit": false, + "delete": false, + "import": false, + "export": false + }, + "children": [ + { + "type": "field", + "attrs": { + "name": "module", + "isSearchable": true, + "searchField": "module.name" } }, { "type": "field", "attrs": { - "name": "variableName" + "name": "dashboardName", + "label": "Name", + "isSearchable": true } }, { "type": "field", "attrs": { - "name": "variableType" + "name": "version" } }, { "type": "field", "attrs": { - "name": "isMultiSelect" + "name": "user", + "isSearchable": true, + "searchField": "user.fullName", + "coModelFieldToDisplay": "fullName" } } ] } }, { - "name": "dashboardVariable-form-view", - "displayName": "Dashboard Variable", + "name": "dashboardUserLayout-form-view", + "displayName": "Dashboard User Layout", "type": "form", "context": "{}", "moduleUserKey": "solid-core", - "modelUserKey": "dashboardVariable", + "modelUserKey": "dashboardUserLayout", "layout": { "type": "form", + "edit": false, "attrs": { "name": "form-1", - "label": "Dashboard Variable", - "className": "grid" + "label": "Dashboard User Layout", + "className": "grid", + "showAddFormButton": false, + "showEditFormButton": false, + "showDeleteFormButton": false }, "children": [ { @@ -12497,65 +13317,55 @@ { "type": "column", "attrs": { - "name": "group-1", - "label": "", - "className": "col-12" + "name": "dashboard-user-layout-meta", + "label": "Layout Metadata", + "className": "col-12 sm:col-12 md:col-6 lg:col-6" }, "children": [ { "type": "field", "attrs": { - "name": "variableName" - } - }, - { - "type": "field", - "attrs": { - "name": "variableType" - } - }, - { - "type": "field", - "attrs": { - "name": "selectionStaticValues" - } - }, - { - "type": "field", - "attrs": { - "name": "selectionDynamicSourceType" + "name": "dashboardName" } }, { "type": "field", "attrs": { - "name": "selectionDynamicSQL" + "name": "module" } }, { "type": "field", "attrs": { - "name": "selectionDynamicProviderName" + "name": "user" } }, { "type": "field", "attrs": { - "name": "defaultValue", - "editWidget": "codeEditor", - "editorLanguage": "json" + "name": "version" } }, { "type": "field", "attrs": { - "name": "defaultOperator" + "name": "updatedAt" } - }, + } + ] + }, + { + "type": "column", + "attrs": { + "name": "dashboard-user-layout-json", + "label": "Layout Payload", + "className": "col-12 sm:col-12 md:col-6 lg:col-6" + }, + "children": [ { "type": "field", "attrs": { - "name": "dashboard" + "name": "layoutJson" } } ] @@ -12568,14 +13378,14 @@ } }, { - "name": "dashboardQuestion-list-view", - "displayName": "Dashboard Question", - "type": "list", + "name": "modelMetadata-tree-view", + "displayName": "Model Metadata Tree View", + "type": "tree", "context": "{}", "moduleUserKey": "solid-core", - "modelUserKey": "dashboardQuestion", + "modelUserKey": "modelMetadata", "layout": { - "type": "list", + "type": "tree", "attrs": { "pagination": true, "pageSizeOptions": [ @@ -12584,269 +13394,155 @@ 50 ], "enableGlobalSearch": true, - "create": true, + "create": false, "edit": true, - "delete": true + "delete": false }, "children": [ { "type": "field", "attrs": { - "name": "id" + "name": "module", + "isSearchable": true + } + }, + { + "type": "field", + "attrs": { + "name": "singularName", + "isSearchable": true, + "sortable": true + } + }, + { + "type": "field", + "attrs": { + "name": "tableName", + "isSearchable": true } }, { "type": "field", "attrs": { - "name": "name" + "name": "displayName", + "isSearchable": true } }, { "type": "field", "attrs": { - "name": "dashboard" + "name": "description", + "isSearchable": true } }, { "type": "field", "attrs": { - "name": "visualisedAs" + "name": "dataSource", + "isSearchable": false } }, { "type": "field", "attrs": { - "name": "sequenceNumber" + "name": "enableSoftDelete", + "isSearchable": true } } ] } }, { - "name": "dashboardQuestion-form-view", - "displayName": "Dashboard Question", - "type": "form", + "name": "menuItemMetadata-tree-view", + "displayName": "Menu Item Metadata Tree View", + "type": "tree", "context": "{}", "moduleUserKey": "solid-core", - "modelUserKey": "dashboardQuestion", + "modelUserKey": "menuItemMetadata", "layout": { - "type": "form", + "type": "list", "attrs": { - "name": "form-1", - "label": "Dashboard Question", - "className": "grid" + "pagination": true, + "pageSizeOptions": [ + 10, + 25, + 50 + ], + "enableGlobalSearch": true, + "create": false, + "edit": false, + "delete": false }, - "onFieldChange": "dashboardQuestionFieldChangeHandler", - "onFormLoad": "dashboardQuestionOnFormLoadHandler", "children": [ { - "type": "sheet", + "type": "field", "attrs": { - "name": "sheet-1" - }, - "children": [ - { - "type": "notebook", - "attrs": { - "name": "notebook-1" - }, - "children": [ - { - "type": "page", - "attrs": { - "name": "general", - "label": "General" - }, - "children": [ - { - "type": "row", - "attrs": { - "name": "row-1" - }, - "children": [ - { - "type": "column", - "attrs": { - "name": "group-1", - "label": "General Info", - "className": "col-12 sm:col-12 md:col-6 lg:col-6" - }, - "children": [ - { - "type": "field", - "attrs": { - "name": "name" - } - }, - { - "type": "field", - "attrs": { - "name": "dashboard" - } - }, - { - "type": "field", - "attrs": { - "name": "visualisedAs" - } - }, - { - "type": "field", - "attrs": { - "name": "sequenceNumber" - } - } - ] - }, - { - "type": "column", - "attrs": { - "name": "group-1", - "label": "Data Source", - "className": "col-12 sm:col-12 md:col-6 lg:col-6" - }, - "children": [ - { - "type": "field", - "attrs": { - "name": "sourceType" - } - }, - { - "type": "field", - "attrs": { - "name": "labelSql", - "editWidget": "codeEditor", - "editorLanguage": "sql" - } - }, - { - "type": "field", - "attrs": { - "name": "kpiSql", - "editWidget": "codeEditor", - "editorLanguage": "sql" - } - }, - { - "type": "field", - "attrs": { - "name": "providerName" - } - } - ] - } - ] - } - ] - }, - { - "type": "page", - "attrs": { - "name": "sql-dataset-config", - "label": "SQL Queries" - }, - "children": [ - { - "type": "row", - "attrs": { - "name": "row-3" - }, - "children": [ - { - "type": "column", - "attrs": { - "name": "group-1", - "label": "", - "className": "col-12" - }, - "children": [ - { - "type": "field", - "attrs": { - "label": "Label SQL", - "name": "sql", - "editWidget": "codeEditor", - "editorLanguage": "sql" - } - }, - { - "type": "field", - "attrs": { - "showLabel": false, - "name": "questionSqlDatasetConfigs" - } - } - ] - } - ] - } - ] - }, - { - "type": "page", - "attrs": { - "name": "preview", - "label": "Preview" - }, - "children": [ - { - "type": "row", - "attrs": { - "name": "row-2" - }, - "children": [ - { - "type": "column", - "attrs": { - "name": "chart-settings", - "label": "Chart Settings", - "className": "col-12 xl:col-3" - }, - "children": [ - { - "type": "field", - "attrs": { - "name": "chartOptions" - } - } - ] - }, - { - "type": "column", - "attrs": { - "name": "chart-preview", - "label": "Chart Preview", - "className": "col-12 xl:col-9" - }, - "children": [ - { - "type": "custom", - "attrs": { - "name": "page-2-chart-preview-custom", - "widget": "chart" - } - } - ] - } - ] - } - ] - } - ] - } - ] + "name": "module", + "isSearchable": true + } + }, + { + "type": "field", + "attrs": { + "name": "parentMenuItem", + "isSearchable": true + } + }, + { + "type": "field", + "attrs": { + "name": "name", + "isSearchable": true, + "sortable": true + } + }, + { + "type": "field", + "attrs": { + "name": "displayName", + "isSearchable": true, + "sortable": true + } + }, + { + "type": "field", + "attrs": { + "name": "sequenceNumber", + "isSearchable": true, + "sortable": true + } + }, + { + "type": "field", + "attrs": { + "name": "roles", + "isSearchable": false + } + }, + { + "type": "field", + "attrs": { + "name": "action", + "isSearchable": true + } + }, + { + "type": "field", + "attrs": { + "name": "iconName", + "isSearchable": false + } } ] } }, { - "name": "dashboardQuestionSqlDatasetConfig-list-view", - "displayName": "Dashboard Question SQL Dataset Config", - "type": "list", + "name": "viewMetadata-tree-view", + "displayName": "View Metadata Tree View", + "type": "tree", "context": "{}", "moduleUserKey": "solid-core", - "modelUserKey": "dashboardQuestionSqlDatasetConfig", + "modelUserKey": "viewMetadata", "layout": { "type": "list", "attrs": { @@ -12857,133 +13553,58 @@ 50 ], "enableGlobalSearch": true, - "create": true, - "edit": true, - "delete": true + "create": false, + "edit": false, + "delete": false }, "children": [ { "type": "field", "attrs": { - "name": "datasetDisplayName" + "name": "module", + "isSearchable": true } }, { "type": "field", "attrs": { - "name": "labelColumnName" + "name": "model", + "isSearchable": true } }, { "type": "field", "attrs": { - "name": "valueColumnName" + "name": "name", + "isSearchable": true, + "sortable": true } }, { "type": "field", "attrs": { - "name": "options" + "name": "displayName", + "isSearchable": true, + "sortable": true } - } - ] - } - }, - { - "name": "dashboardQuestionSqlDatasetConfig-form-view", - "displayName": "Dashboard Question SQL Dataset Config", - "type": "form", - "context": "{}", - "moduleUserKey": "solid-core", - "modelUserKey": "dashboardQuestionSqlDatasetConfig", - "layout": { - "type": "form", - "attrs": { - "name": "form-1", - "label": "Dashboard Question SQL Dataset Config", - "className": "grid" - }, - "children": [ + }, { - "type": "sheet", + "type": "field", "attrs": { - "name": "sheet-1" - }, - "children": [ - { - "type": "row", - "attrs": { - "name": "sheet-1" - }, - "children": [ - { - "type": "column", - "attrs": { - "name": "group-1", - "label": "", - "className": "col-12" - }, - "children": [ - { - "type": "field", - "attrs": { - "name": "datasetName" - } - }, - { - "type": "field", - "attrs": { - "name": "datasetDisplayName" - } - }, - { - "type": "field", - "attrs": { - "name": "question" - } - }, - { - "type": "field", - "attrs": { - "name": "sql", - "editWidget": "codeEditor", - "editorLanguage": "sql" - } - }, - { - "type": "field", - "attrs": { - "name": "labelColumnName" - } - }, - { - "type": "field", - "attrs": { - "name": "valueColumnName" - } - }, - { - "type": "field", - "attrs": { - "name": "options" - } - } - ] - } - ] - } - ] + "name": "type", + "isSearchable": true + } } ] } }, { - "name": "dashboardLayout-list-view", - "displayName": "Dashboard Layout", - "type": "list", + "name": "actionMetadata-tree-view", + "displayName": "Action Metadata Tree View", + "type": "tree", "context": "{}", "moduleUserKey": "solid-core", - "modelUserKey": "dashboardLayout", + "modelUserKey": "actionMetadata", "layout": { "type": "list", "attrs": { @@ -12994,101 +13615,65 @@ 50 ], "enableGlobalSearch": true, - "create": true, - "edit": true, - "delete": true + "create": false, + "edit": false, + "delete": false }, "children": [ { "type": "field", "attrs": { - "name": "id" + "name": "module", + "isSearchable": true } }, { "type": "field", "attrs": { - "name": "dashboard" + "name": "model", + "isSearchable": true } }, { "type": "field", "attrs": { - "name": "user" + "name": "view", + "isSearchable": true } - } - ] - } - }, - { - "name": "dashboardLayout-form-view", - "displayName": "Dashboard Layout", - "type": "form", - "context": "{}", - "moduleUserKey": "solid-core", - "modelUserKey": "dashboardLayout", - "layout": { - "type": "form", - "attrs": { - "name": "form-1", - "label": "Dashboard Layout", - "className": "grid" - }, - "children": [ + }, { - "type": "sheet", + "type": "field", "attrs": { - "name": "sheet-1" - }, - "children": [ - { - "type": "row", - "attrs": { - "name": "sheet-1" - }, - "children": [ - { - "type": "column", - "attrs": { - "name": "group-1", - "label": "", - "className": "col-12" - }, - "children": [ - { - "type": "field", - "attrs": { - "name": "layout" - } - }, - { - "type": "field", - "attrs": { - "name": "dashboard" - } - }, - { - "type": "field", - "attrs": { - "name": "user" - } - } - ] - } - ] - } - ] + "name": "name", + "isSearchable": true, + "sortable": true + } + }, + { + "type": "field", + "attrs": { + "name": "displayName", + "isSearchable": true, + "sortable": true + } + }, + { + "type": "field", + "attrs": { + "name": "type", + "isSearchable": true + } } ] } }, { - "name": "aiInteraction-list-view", - "displayName": "AI Interaction", - "type": "list", + "name": "userViewMetadata-tree-view", + "displayName": "User View Metadata Tree View", + "type": "tree", "context": "{}", "moduleUserKey": "solid-core", - "modelUserKey": "aiInteraction", + "modelUserKey": "userViewMetadata", "layout": { "type": "list", "attrs": { @@ -13107,305 +13692,223 @@ { "type": "field", "attrs": { - "name": "user" - } - }, - { - "type": "field", - "attrs": { - "name": "threadId" + "name": "viewMetadata", + "label": "Name", + "coModelFieldToDisplay": "name", + "isSearchable": true, + "searchField": "viewMetadata.name" } }, { "type": "field", "attrs": { - "name": "externalId" + "name": "viewMetadata", + "label": "Display Name", + "coModelFieldToDisplay": "displayName", + "isSearchable": true, + "searchField": "viewMetadata.displayName" } }, { "type": "field", "attrs": { - "name": "parentInteraction" + "name": "viewMetadata", + "label": "Type", + "coModelFieldToDisplay": "type", + "isSearchable": false } }, { "type": "field", "attrs": { - "name": "role" + "name": "user", + "isSearchable": true, + "searchField": "user.fullName", + "coModelFieldToDisplay": "fullName" } }, { "type": "field", "attrs": { - "name": "message" + "name": "createdAt", + "isSearchable": false } - }, + } + ] + } + }, + { + "name": "media-tree-view", + "displayName": "Media Tree View", + "type": "tree", + "context": "{}", + "moduleUserKey": "solid-core", + "modelUserKey": "media", + "layout": { + "type": "list", + "attrs": { + "pagination": true, + "pageSizeOptions": [ + 10, + 25, + 50 + ], + "enableGlobalSearch": true, + "create": false, + "edit": false, + "delete": false + }, + "children": [ { "type": "field", "attrs": { - "name": "status" + "name": "modelMetadata", + "isSearchable": true, + "sortable": true, + "searchField": "modelMetadata.name" } }, { "type": "field", "attrs": { - "name": "contentType" + "name": "fieldMetadata", + "isSearchable": true, + "sortable": true, + "searchField": "fieldMetadata.name" } }, { "type": "field", "attrs": { - "name": "responseTimeMs" + "name": "entityId", + "isSearchable": true, + "sortable": false } }, { "type": "field", "attrs": { - "name": "inputTokens" + "name": "mediaStorageProviderMetadata", + "isSearchable": false, + "sortable": false } }, { "type": "field", "attrs": { - "name": "outputTokens" + "name": "relativeUri", + "widget": "image", + "isSearchable": false, + "sortable": false } - }, + } + ] + } + }, + { + "name": "roleMetadata-tree-view", + "displayName": "Roles", + "type": "tree", + "context": "{}", + "moduleUserKey": "solid-core", + "modelUserKey": "roleMetadata", + "layout": { + "type": "list", + "attrs": { + "pagination": true, + "pageSizeOptions": [ + 10, + 25, + 50 + ], + "enableGlobalSearch": true, + "create": false, + "edit": false, + "delete": false, + "import": false, + "export": false + }, + "children": [ { "type": "field", "attrs": { - "name": "totalTokens" + "name": "name", + "isSearchable": true, + "sortable": true } } ] } }, { - "name": "aiInteraction-form-view", - "displayName": "AI Interaction", - "type": "form", + "name": "securityRule-tree-view", + "displayName": "Security Rules", + "type": "tree", "context": "{}", "moduleUserKey": "solid-core", - "modelUserKey": "aiInteraction", + "modelUserKey": "securityRule", "layout": { - "type": "form", - "edit": false, + "type": "list", "attrs": { - "name": "form-1", - "label": "AI Interaction", - "className": "grid" + "pagination": true, + "pageSizeOptions": [ + 10, + 25, + 50 + ], + "enableGlobalSearch": true, + "create": false, + "edit": false, + "delete": false, + "import": false, + "export": false }, "children": [ { - "type": "sheet", + "type": "field", "attrs": { - "name": "sheet-1" - }, - "children": [ - { - "type": "notebook", - "attrs": { - "name": "notebook-1" - }, - "children": [ - { - "type": "page", - "attrs": { - "name": "general", - "label": "General Info" - }, - "children": [ - { - "type": "row", - "attrs": { - "name": "row-general" - }, - "children": [ - { - "type": "column", - "attrs": { - "name": "left-col", - "label": "Interaction Details", - "className": "col-12 sm:col-12 md:col-6 lg:col-6" - }, - "children": [ - { - "type": "field", - "attrs": { - "name": "user" - } - }, - { - "type": "field", - "attrs": { - "name": "externalId" - } - }, - { - "type": "field", - "attrs": { - "name": "threadId" - } - }, - { - "type": "field", - "attrs": { - "name": "role" - } - }, - { - "type": "field", - "attrs": { - "name": "message", - "viewWidget": "SolidAiInteractionMessageFieldFormWidget" - } - } - ] - }, - { - "type": "column", - "attrs": { - "name": "right-col", - "label": "Message Properties", - "className": "col-12 sm:col-12 md:col-6 lg:col-6" - }, - "children": [ - { - "type": "field", - "attrs": { - "name": "contentType" - } - }, - { - "type": "field", - "attrs": { - "name": "modelUsed" - } - }, - { - "type": "field", - "attrs": { - "name": "responseTimeMs" - } - }, - { - "type": "field", - "attrs": { - "name": "parentInteraction" - } - }, - { - "type": "field", - "attrs": { - "name": "inputTokens" - } - }, - { - "type": "field", - "attrs": { - "name": "outputTokens" - } - }, - { - "type": "field", - "attrs": { - "name": "totalTokens" - } - } - ] - } - ] - } - ] - }, - { - "type": "page", - "attrs": { - "name": "status", - "label": "Status & Debug" - }, - "children": [ - { - "type": "row", - "attrs": { - "name": "row-status" - }, - "children": [ - { - "type": "column", - "attrs": { - "name": "col-status", - "label": "Execution Info", - "className": "col-12" - }, - "children": [ - { - "type": "field", - "attrs": { - "name": "status" - } - }, - { - "type": "field", - "attrs": { - "name": "errorMessage", - "rows": 5 - } - } - ] - } - ] - } - ] - }, - { - "type": "page", - "attrs": { - "name": "meta", - "label": "System Metadata" - }, - "children": [ - { - "type": "row", - "attrs": { - "name": "row-meta" - }, - "children": [ - { - "type": "column", - "attrs": { - "name": "col-meta", - "label": "", - "className": "col-12" - }, - "children": [ - { - "type": "field", - "attrs": { - "name": "metadata", - "height": "80vh", - "viewWidget": "SolidAiInteractionMetadataFieldFormWidget" - } - } - ] - } - ] - } - ] - } - ] - } - ] + "name": "name", + "label": "Name", + "sortable": true, + "isSearchable": true + } + }, + { + "type": "field", + "attrs": { + "name": "description", + "label": "Description", + "isSearchable": true + } + }, + { + "type": "field", + "attrs": { + "name": "role", + "label": "Role", + "isSearchable": true, + "searchField": "role.name" + } + }, + { + "type": "field", + "attrs": { + "name": "modelMetadata", + "label": "Model", + "isSearchable": true, + "searchField": "modelMetadata.name" + } } ] } }, { - "name": "importTransactionErrorLog-list-view", - "displayName": "Import Error Logs", - "type": "list", + "name": "modelSequence-tree-view", + "displayName": "Model Sequences", + "type": "tree", "context": "{}", "moduleUserKey": "solid-core", - "modelUserKey": "importTransactionErrorLog", + "modelUserKey": "modelSequence", "layout": { "type": "list", "attrs": { @@ -13416,92 +13919,65 @@ 50 ], "enableGlobalSearch": true, - "create": true, - "edit": true, - "delete": true + "create": false, + "edit": false, + "delete": false, + "import": false, + "export": false }, "children": [ { "type": "field", "attrs": { - "name": "id" + "name": "module", + "label": "Module", + "isSearchable": true, + "searchField": "module.name" } }, { "type": "field", "attrs": { - "name": "importTransaction" + "name": "model", + "label": "Model", + "isSearchable": true, + "searchField": "model.name" } - } - ] - } - }, - { - "name": "importTransactionErrorLog-form-view", - "displayName": "Import Error Logs", - "type": "form", - "context": "{}", - "moduleUserKey": "solid-core", - "modelUserKey": "importTransactionErrorLog", - "layout": { - "type": "form", - "attrs": { - "name": "form-1", - "label": "Import Error Logs", - "className": "grid" - }, - "children": [ + }, { - "type": "sheet", + "type": "field", "attrs": { - "name": "sheet-1" - }, - "children": [ - { - "type": "row", - "attrs": { - "name": "sheet-1" - }, - "children": [ - { - "type": "column", - "attrs": { - "name": "group-1", - "label": "", - "className": "col-12 sm:col-12 md:col-6 lg:col-6" - }, - "children": [ - { - "type": "field", - "attrs": { - "name": "importTransaction" - } - } - ] - }, - { - "type": "column", - "attrs": { - "name": "group-2", - "label": "", - "className": "col-12 sm:col-12 md:col-6 lg:col-6" - }, - "children": [] - } - ] - } - ] + "name": "field", + "label": "Field", + "isSearchable": false + } + }, + { + "type": "field", + "attrs": { + "name": "sequenceName", + "label": "Sequence", + "isSearchable": false + } + }, + { + "type": "field", + "attrs": { + "name": "currentValue", + "label": "Current Value", + "isSearchable": false + } } ] } }, { - "name": "modelSequence-list-view", - "displayName": "Model Sequence", - "type": "list", + "name": "listOfValues-tree-view", + "displayName": "List Of Values", + "type": "tree", "context": "{}", "moduleUserKey": "solid-core", - "modelUserKey": "modelSequence", + "modelUserKey": "listOfValues", "layout": { "type": "list", "attrs": { @@ -13514,351 +13990,275 @@ "enableGlobalSearch": true, "create": true, "edit": true, - "delete": true + "delete": true, + "import": true, + "export": true }, "children": [ { "type": "field", "attrs": { - "name": "id" - } - }, - { - "type": "field", - "attrs": { - "name": "sequenceName" + "name": "module", + "label": "Module", + "isSearchable": true, + "searchField": "module.name" } }, { "type": "field", "attrs": { - "name": "currentValue" + "name": "type", + "label": "Type", + "isSearchable": true } }, { "type": "field", "attrs": { - "name": "prefix" + "name": "value", + "label": "Value", + "isSearchable": true } }, { "type": "field", "attrs": { - "name": "padding" + "name": "display", + "label": "Display Value", + "isSearchable": true } - }, + } + ] + } + }, + { + "name": "chatterMessage-tree-view", + "displayName": "Chatter Messages", + "type": "tree", + "context": "{}", + "moduleUserKey": "solid-core", + "modelUserKey": "chatterMessage", + "layout": { + "type": "list", + "attrs": { + "pagination": true, + "pageSizeOptions": [ + 10, + 25, + 50 + ], + "enableGlobalSearch": true, + "create": false, + "edit": false, + "delete": false, + "import": false, + "export": false + }, + "children": [ { "type": "field", "attrs": { - "name": "separator" + "name": "messageType", + "label": "Type", + "isSearchable": true } }, { "type": "field", "attrs": { - "name": "field" + "name": "messageSubType", + "label": "Sub Type", + "isSearchable": true } }, { "type": "field", "attrs": { - "name": "model" + "name": "modelDisplayName", + "label": "Model", + "isSearchable": true } }, { "type": "field", "attrs": { - "name": "module" + "name": "user", + "label": "User", + "isSearchable": true, + "searchField": "user.fullName" } } ] } }, { - "name": "modelSequence-form-view", - "displayName": "Model Sequence", - "type": "form", + "name": "scheduledJob-tree-view", + "displayName": "Scheduled Jobs", + "type": "tree", "context": "{}", "moduleUserKey": "solid-core", - "modelUserKey": "modelSequence", + "modelUserKey": "scheduledJob", "layout": { - "type": "form", + "type": "list", "attrs": { - "name": "form-1", - "label": "Model Sequence", - "className": "grid" + "pagination": true, + "pageSizeOptions": [ + 10, + 25, + 50 + ], + "enableGlobalSearch": true, + "create": false, + "edit": false, + "delete": false, + "import": false, + "export": false }, - "onFieldChange": "modelSequenceFormViewChangeHandler", "children": [ { - "type": "sheet", + "type": "field", "attrs": { - "name": "sheet-1" - }, - "children": [ - { - "type": "row", - "attrs": { - "name": "sheet-1" - }, - "children": [ - { - "type": "column", - "attrs": { - "name": "group-1", - "label": "", - "className": "col-12 sm:col-12 md:col-6 lg:col-6" - }, - "children": [ - { - "type": "field", - "attrs": { - "name": "sequenceName" - } - }, - { - "type": "field", - "attrs": { - "name": "prefix" - } - }, - { - "type": "field", - "attrs": { - "name": "separator" - } - }, - { - "type": "field", - "attrs": { - "name": "module" - } - }, - { - "type": "field", - "attrs": { - "name": "model" - } - }, - { - "type": "field", - "attrs": { - "name": "field" - } - }, - { - "type": "field", - "attrs": { - "name": "currentValue" - } - }, - { - "type": "field", - "attrs": { - "name": "padding" - } - } - ] - } - ] - } - ] + "name": "module", + "label": "Module", + "isSearchable": true, + "searchField": "module.name" + } + }, + { + "type": "field", + "attrs": { + "name": "scheduleName", + "label": "Schedule Name", + "isSearchable": true, + "sortable": true + } + }, + { + "type": "field", + "attrs": { + "name": "job", + "label": "Job Name", + "isSearchable": true, + "sortable": true + } + }, + { + "type": "field", + "attrs": { + "name": "frequency", + "label": "Frequency", + "isSearchable": false, + "sortable": true + } + }, + { + "type": "field", + "attrs": { + "name": "isActive", + "isSearchable": false + } } ] } }, { - "name": "agentSession-list-view", - "displayName": "Agent Sessions", - "type": "list", + "name": "savedFilters-tree-view", + "displayName": "Saved Filters", + "type": "tree", "context": "{}", "moduleUserKey": "solid-core", - "modelUserKey": "agentSession", + "modelUserKey": "savedFilters", "layout": { "type": "list", "attrs": { "pagination": true, - "pageSizeOptions": [10, 25, 50], + "pageSizeOptions": [ + 10, + 25, + 50 + ], "enableGlobalSearch": true, - "create": true, - "edit": true, - "delete": true - }, - "children": [ - { "type": "field", "attrs": { "name": "sessionId", "isSearchable": true } }, - { "type": "field", "attrs": { "name": "modelName", "isSearchable": true } }, - { "type": "field", "attrs": { "name": "status", "isSearchable": true } }, - { "type": "field", "attrs": { "name": "totalSteps" } }, - { "type": "field", "attrs": { "name": "totalCost" } }, - { "type": "field", "attrs": { "name": "totalInputTokens" } }, - { "type": "field", "attrs": { "name": "totalOutputTokens" } }, - { "type": "field", "attrs": { "name": "projectRoot", "isSearchable": true } }, - { "type": "field", "attrs": { "name": "createdAt" } }, - { "type": "field", "attrs": { "name": "updatedAt" } } - ] - } - }, - { - "name": "agentSession-form-view", - "displayName": "Agent Session", - "type": "form", - "context": "{}", - "moduleUserKey": "solid-core", - "modelUserKey": "agentSession", - "layout": { - "type": "form", - "attrs": { - "name": "form-1", - "label": "Agent Session", - "className": "grid" + "create": false, + "edit": false, + "delete": false, + "import": false, + "export": false }, "children": [ { - "type": "sheet", - "attrs": { "name": "sheet-1" }, - "children": [ - { - "type": "row", - "attrs": { "name": "row-1" }, - "children": [ - { - "type": "column", - "attrs": { "name": "col-1", "label": "", "className": "col-12 sm:col-12 md:col-6 lg:col-6" }, - "children": [ - { "type": "field", "attrs": { "name": "sessionId" } }, - { "type": "field", "attrs": { "name": "modelName" } }, - { "type": "field", "attrs": { "name": "status" } }, - { "type": "field", "attrs": { "name": "projectRoot" } } - ] - }, - { - "type": "column", - "attrs": { "name": "col-2", "label": "", "className": "col-12 sm:col-12 md:col-6 lg:col-6" }, - "children": [ - { "type": "field", "attrs": { "name": "totalSteps" } }, - { "type": "field", "attrs": { "name": "totalCost" } }, - { "type": "field", "attrs": { "name": "totalInputTokens" } }, - { "type": "field", "attrs": { "name": "totalOutputTokens" } } - ] - } - ] - }, - { - "type": "row", - "attrs": { "name": "row-2" }, - "children": [ - { - "type": "column", - "attrs": { "name": "col-summary", "label": "", "className": "col-12" }, - "children": [ - { "type": "field", "attrs": { "name": "summary" } } - ] - } - ] - } - ] + "type": "field", + "attrs": { + "name": "model", + "label": "Model", + "isSearchable": true, + "searchField": "model.singularName" + } + }, + { + "type": "field", + "attrs": { + "name": "view", + "label": "View", + "isSearchable": true, + "searchField": "view.name" + } + }, + { + "type": "field", + "attrs": { + "name": "name", + "label": "Name", + "isSearchable": true, + "sortable": true + } + }, + { + "type": "field", + "attrs": { + "name": "isPrivate", + "isSearchable": false + } + }, + { + "type": "field", + "attrs": { + "name": "user", + "label": "User", + "isSearchable": true, + "searchField": "user.fullName", + "coModelFieldToDisplay": "fullName" + } } ] } }, { - "name": "agentEvent-list-view", - "displayName": "Agent Events", - "type": "list", + "name": "roleMetadata-tree-view", + "displayName": "Role", + "type": "tree", "context": "{}", "moduleUserKey": "solid-core", - "modelUserKey": "agentEvent", + "modelUserKey": "roleMetadata", "layout": { - "type": "list", + "type": "tree", "attrs": { "pagination": true, - "pageSizeOptions": [10, 25, 50], + "pageSizeOptions": [ + 10, + 25, + 50 + ], "enableGlobalSearch": true, "create": true, "edit": true, "delete": true }, - "children": [ - { "type": "field", "attrs": { "name": "id" } }, - { "type": "field", "attrs": { "name": "sessionId", "isSearchable": true } }, - { "type": "field", "attrs": { "name": "eventType", "isSearchable": true } }, - { "type": "field", "attrs": { "name": "turnNumber" } }, - { "type": "field", "attrs": { "name": "stepNumber" } }, - { "type": "field", "attrs": { "name": "toolName", "isSearchable": true } }, - { "type": "field", "attrs": { "name": "durationMs" } }, - { "type": "field", "attrs": { "name": "cost" } }, - { "type": "field", "attrs": { "name": "inputTokens" } }, - { "type": "field", "attrs": { "name": "outputTokens" } }, - { "type": "field", "attrs": { "name": "modelUsed", "isSearchable": true } }, - { "type": "field", "attrs": { "name": "createdAt" } } - ] - } - }, - { - "name": "agentEvent-form-view", - "displayName": "Agent Event", - "type": "form", - "context": "{}", - "moduleUserKey": "solid-core", - "modelUserKey": "agentEvent", - "layout": { - "type": "form", - "attrs": { - "name": "form-1", - "label": "Agent Event", - "className": "grid" - }, "children": [ { - "type": "sheet", - "attrs": { "name": "sheet-1" }, - "children": [ - { - "type": "row", - "attrs": { "name": "row-1" }, - "children": [ - { - "type": "column", - "attrs": { "name": "col-1", "label": "", "className": "col-12 sm:col-12 md:col-6 lg:col-6" }, - "children": [ - { "type": "field", "attrs": { "name": "sessionId" } }, - { "type": "field", "attrs": { "name": "eventType" } }, - { "type": "field", "attrs": { "name": "turnNumber" } }, - { "type": "field", "attrs": { "name": "stepNumber" } }, - { "type": "field", "attrs": { "name": "toolName" } }, - { "type": "field", "attrs": { "name": "modelUsed" } } - ] - }, - { - "type": "column", - "attrs": { "name": "col-2", "label": "", "className": "col-12 sm:col-12 md:col-6 lg:col-6" }, - "children": [ - { "type": "field", "attrs": { "name": "durationMs" } }, - { "type": "field", "attrs": { "name": "cost" } }, - { "type": "field", "attrs": { "name": "inputTokens" } }, - { "type": "field", "attrs": { "name": "outputTokens" } }, - { "type": "field", "attrs": { "name": "toolReturncode" } } - ] - } - ] - }, - { - "type": "row", - "attrs": { "name": "row-2" }, - "children": [ - { - "type": "column", - "attrs": { "name": "col-content", "label": "", "className": "col-12" }, - "children": [ - { "type": "field", "attrs": { "name": "content" } }, - { "type": "field", "attrs": { "name": "toolArguments", "viewWidget": "SolidJsonFormViewWidget" } }, - { "type": "field", "attrs": { "name": "toolOutput", "viewWidget": "SolidJsonFormViewWidget" } }, - { "type": "field", "attrs": { "name": "eventData", "viewWidget": "SolidJsonFormViewWidget" } } - ] - } - ] - } - ] + "type": "field", + "attrs": { + "name": "id" + } } ] } @@ -13999,5 +14399,342 @@ } } ], - "modelSequences": [] -} + "modelSequences": [], + "dashboards": [ + { + "dashboardSchemaVersion": 1, + "name": "queue-health", + "displayName": "Queue Health Dashboard", + "description": "Operational visibility for MQ queues and messages.", + "moduleUserKey": "solid-core", + "variables": [ + { + "name": "date", + "label": "Created At", + "type": "date", + "required": true + }, + { + "name": "queueName", + "label": "Queue", + "type": "selectionDynamic", + "required": false, + "isMultiSelect": false, + "selectionConfig": { + "providerName": "MqDashboardQueueNameVariableOptionsProvider", + "providerContext": { + "labelField": "name", + "valueField": "name" + } + } + }, + { + "name": "stage", + "label": "Stage", + "type": "selectionStatic", + "required": false, + "isMultiSelect": true, + "selectionStaticValues": [ + "pending:Pending", + "scheduled:Scheduled", + "started:Started", + "retry:Retry", + "retrying:Retrying", + "failed:Failed", + "succeeded:Succeeded" + ] + }, + { + "name": "messageBroker", + "label": "Message Broker", + "type": "selectionDynamic", + "required": false, + "isMultiSelect": false, + "selectionConfig": { + "providerName": "MqDashboardMessageBrokerVariableOptionsProvider", + "providerContext": { + "labelField": "messageBroker", + "valueField": "messageBroker" + } + } + } + ], + "widgets": [ + { + "id": "kpi-total-messages", + "name": "Total Messages", + "type": "kpi", + "dataProvider": "MqDashboardTotalMessagesKpiProvider", + "providerContext": { + "metric": "count_total_messages" + } + }, + { + "id": "kpi-succeeded-messages", + "name": "Succeeded", + "type": "kpi", + "dataProvider": "MqDashboardSucceededMessagesKpiProvider", + "providerContext": { + "metric": "count_succeeded_messages" + } + }, + { + "id": "kpi-failed-messages", + "name": "Failed", + "type": "kpi", + "dataProvider": "MqDashboardFailedMessagesKpiProvider", + "providerContext": { + "metric": "count_failed_messages" + } + }, + { + "id": "kpi-in-flight-messages", + "name": "In Flight", + "type": "kpi", + "dataProvider": "MqDashboardInflightMessagesKpiProvider", + "providerContext": { + "metric": "count_inflight_messages" + } + }, + { + "id": "kpi-success-rate", + "name": "Success Rate", + "type": "kpi", + "dataProvider": "MqDashboardSuccessRateKpiProvider", + "providerContext": { + "metric": "success_rate_percentage" + } + }, + { + "id": "kpi-avg-elapsed-ms", + "name": "Avg Elapsed (ms)", + "type": "kpi", + "dataProvider": "MqDashboardAvgElapsedKpiProvider", + "providerContext": { + "metric": "average_elapsed_millis" + } + }, + { + "id": "chart-messages-over-time", + "name": "Messages Over Time", + "type": "lineChart", + "dataProvider": "MqDashboardMessagesOverTimeProvider", + "providerContext": { + "timeField": "createdAt", + "bucket": "hour", + "series": [ + "total", + "succeeded", + "failed", + "retrying" + ] + } + }, + { + "id": "chart-stage-distribution", + "name": "Stage Distribution", + "type": "pieChart", + "dataProvider": "MqDashboardStageDistributionProvider", + "providerContext": { + "groupBy": "stage", + "metric": "count" + } + }, + { + "id": "chart-queue-wise-failures", + "name": "Queue-wise Failures", + "type": "barChart", + "dataProvider": "MqDashboardQueueWiseFailuresProvider", + "providerContext": { + "groupBy": "mqMessageQueue.name", + "metric": "failed_count" + } + }, + { + "id": "chart-queue-wise-avg-elapsed", + "name": "Queue-wise Avg Processing Time", + "type": "barChart", + "dataProvider": "MqDashboardQueueWiseAvgElapsedProvider", + "providerContext": { + "groupBy": "mqMessageQueue.name", + "metric": "avg_elapsed_millis" + } + }, + { + "id": "chart-processing-latency-trend", + "name": "Processing Latency Trend", + "type": "lineChart", + "dataProvider": "MqDashboardLatencyTrendProvider", + "providerContext": { + "timeField": "createdAt", + "bucket": "hour", + "metric": "avg_elapsed_millis" + } + }, + { + "id": "chart-queue-sla-heatmap", + "name": "Queue SLA Heatmap", + "type": "customChart", + "dataProvider": "MqDashboardQueueSlaHeatmapProvider", + "componentName": "QueueSlaHeatmapWidget", + "providerContext": { + "bucket": "hour", + "tooltipFields": [ + "avgElapsedMillis", + "messageCount", + "peakElapsedMillis" + ], + "legendThresholds": [ + { + "label": "0-5s", + "color": "#22c55e", + "lte": 5000 + }, + { + "label": "5-15s", + "color": "#f59e0b", + "gt": 5000, + "lte": 15000 + }, + { + "label": "15-30s", + "color": "#f97316", + "gt": 15000, + "lte": 30000 + }, + { + "label": "30s+", + "color": "#ef4444", + "gt": 30000 + } + ] + } + }, + { + "id": "table-recent-failures", + "name": "Recent Failures", + "type": "table", + "dataProvider": "MqDashboardRecentFailuresProvider", + "providerContext": { + "stage": "failed", + "sort": { + "field": "createdAt", + "order": "desc" + }, + "columns": [ + "id", + "messageId", + "mqMessageQueue.name", + "stage", + "retryCount", + "elapsedMillis", + "startedAt", + "finishedAt", + "error", + "createdAt" + ], + "errorMaxLength": 160 + } + } + ], + "defaultLayout": { + "engine": "gridstack", + "columns": 12, + "items": [ + { + "widgetId": "kpi-total-messages", + "x": 0, + "y": 0, + "w": 2, + "h": 2 + }, + { + "widgetId": "kpi-in-flight-messages", + "x": 2, + "y": 0, + "w": 2, + "h": 2 + }, + { + "widgetId": "kpi-succeeded-messages", + "x": 4, + "y": 0, + "w": 2, + "h": 2 + }, + { + "widgetId": "kpi-success-rate", + "x": 6, + "y": 0, + "w": 2, + "h": 2 + }, + { + "widgetId": "kpi-failed-messages", + "x": 8, + "y": 0, + "w": 2, + "h": 2 + }, + { + "widgetId": "kpi-avg-elapsed-ms", + "x": 10, + "y": 0, + "w": 2, + "h": 2 + }, + { + "widgetId": "chart-queue-wise-failures", + "x": 0, + "y": 2, + "w": 4, + "h": 4 + }, + { + "widgetId": "chart-messages-over-time", + "x": 4, + "y": 2, + "w": 4, + "h": 4 + }, + { + "widgetId": "chart-processing-latency-trend", + "x": 8, + "y": 2, + "w": 4, + "h": 4 + }, + { + "widgetId": "chart-queue-wise-avg-elapsed", + "x": 0, + "y": 6, + "w": 6, + "h": 5 + }, + { + "widgetId": "chart-stage-distribution", + "x": 6, + "y": 6, + "w": 6, + "h": 5 + }, + { + "widgetId": "chart-queue-sla-heatmap", + "x": 0, + "y": 11, + "w": 12, + "h": 5 + }, + { + "widgetId": "table-recent-failures", + "x": 0, + "y": 16, + "w": 12, + "h": 4 + } + ] + } + } + ], + "savedFilters": [] +} \ No newline at end of file diff --git a/src/seeders/system-fields-seeder.service.ts b/src/seeders/system-fields-seeder.service.ts index 4c75d311..3e55259b 100644 --- a/src/seeders/system-fields-seeder.service.ts +++ b/src/seeders/system-fields-seeder.service.ts @@ -37,7 +37,7 @@ export class SystemFieldsSeederService { private async seedMissingSystemFields(model: ModelMetadata) { this.logger.debug(`Checking system fields for model: ${model.singularName}`); const existingSystemFields = model.fields.filter(field => field.isSystem); - const systemFieldsMetadata = this.modelHelperService.getSystemFieldsMetadata(model.isLegacyTable, model.isLegacyTableWithId); + const systemFieldsMetadata = this.modelHelperService.getSystemFieldsMetadata(model.legacyTableType); this.logger.debug(`Model: ${model.singularName} has ${existingSystemFields.length} existing system fields.`); // Find out which system fields are missing const missingFields = systemFieldsMetadata.filter( diff --git a/src/services/ai-interaction.service.ts b/src/services/ai-interaction.service.ts deleted file mode 100644 index ff1c4b3c..00000000 --- a/src/services/ai-interaction.service.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { BadRequestException, Injectable, Logger } from '@nestjs/common'; -import { ModuleRef } from "@nestjs/core"; -import { InjectEntityManager } from '@nestjs/typeorm'; -import { EntityManager } from 'typeorm'; -import type { SolidCoreSetting } from "src/services/settings/default-settings-provider.service"; - -import { spawn } from 'child_process'; -import * as fs from 'fs/promises'; -import { ERROR_MESSAGES } from 'src/constants/error-messages'; -import { InvokeAiPromptDto } from 'src/dtos/invoke-ai-prompt.dto'; -import { McpResponse, TriggerMcpClientOptions } from 'src/interfaces'; -import { AiInteractionRepository } from 'src/repository/ai-interaction.repository'; -import { CRUDService } from 'src/services/crud.service'; -import { AiInteraction } from '../entities/ai-interaction.entity'; -import { McpHandlerFactory } from './genai/mcp-handlers/mcp-handler-factory.service'; -import { PublisherFactory } from './queues/publisher-factory.service'; - -@Injectable() -export class AiInteractionService extends CRUDService { - private readonly logger = new Logger(AiInteractionService.name); - - constructor( - @InjectEntityManager() - readonly entityManager: EntityManager, - // @InjectRepository(AiInteraction, 'default') - // readonly repo: Repository, - readonly repo: AiInteractionRepository, - readonly moduleRef: ModuleRef, - readonly publisherFactory: PublisherFactory, - // readonly requestContextService: RequestContextService, - readonly mcpHandlerFactory: McpHandlerFactory, - - ) { - super(entityManager, repo, 'aiInteraction', 'solid-core', moduleRef); - } - - async triggerMcpClientJob(dto: InvokeAiPromptDto, userId: number, isAutoApply: boolean = false, threadId: string = null): Promise { - // const activeUser: ActiveUserData = this.requestContextService.getActiveUser(); - - const aiInteraction = await this.create({ - userId: userId, - threadId: threadId ? threadId : `thread-${userId}`, - role: 'human', - message: dto.prompt, - contentType: '', - errorMessage: '', - modelUsed: '', - responseTimeMs: 0, - metadata: '', - isAutoApply: isAutoApply - }); - const m = { - payload: { - aiInteractionId: aiInteraction.id, - moduleName: dto.moduleName - }, - parentEntity: 'aiInteraction', - parentEntityId: aiInteraction.id, - }; - - const queueMessageId = await this.publisherFactory.publish(m, 'TriggerMcpClientPublisher'); - - return { - queueMessageId: queueMessageId, - aiInteractionId: aiInteraction.id - } - } - - /** - * Runs the Python MCP client with a prompt and returns the parsed JSON embedded in the 'response'. - * @param prompt - The question or instruction to send to the MCP client. - * @returns The parsed object inside the 'response' field of the JSON output. - */ - async runMcpPrompt(prompt: string): Promise { - const pythonExecutable = this.settingService.getConfigValue('mcpPythonExecutable'); - const mcpClient = this.settingService.getConfigValue('mcpClient'); - - // TODO: We can return an error if the above env variables are not properly setup... - if (!pythonExecutable || !mcpClient) { - throw new BadRequestException(ERROR_MESSAGES.PYTHON_EXECUTABLE_NOT_CONFIGURED); - } - - // Check if both paths are valid and accessible - try { - const [pyStat, clientStat] = await Promise.all([ - fs.stat(pythonExecutable), - fs.stat(mcpClient), - ]); - - if (!pyStat.isFile()) { - throw new BadRequestException(`MCP_PYTHON_EXECUTABLE path is not a file: ${pythonExecutable}`); - } - - if (!clientStat.isFile()) { - throw new BadRequestException(`MCP_CLIENT path is not a file: ${mcpClient}`); - } - - } catch (err: any) { - throw new BadRequestException(`Invalid MCP executable or client path: ${err.message}`); - } - - // TODO: Refactor to use the command.service.ts instead... - return new Promise((resolve, reject) => { - this.logger.log(`Attempting to run command:`) - this.logger.log(`${pythonExecutable} ${mcpClient} "${prompt}"`); - - const python = spawn(pythonExecutable, [mcpClient, `"${prompt}"`]); - - let stdout = ''; - let stderr = ''; - - python.stdout.on('data', (data) => { - stdout += data.toString(); - }); - - python.stderr.on('data', (data) => { - stderr += data.toString(); - }); - - python.on('close', (code) => { - this.logger.log(`Python script exited with code ${code}`); - - if (code !== 0) { - this.logger.error(`Python script exited with a non-zero exit code: ${stderr}`); - return reject(new Error(`Python script exited with a non-zero exit code: ${stderr}`)); - } - - try { - this.logger.log(`Python script exited with zero exit code: ${stdout}`); - const raw: McpResponse = JSON.parse(stdout); - - // Sometimes the raw.response might not be a valid json - // TODO: examine the content type of the raw response.. - // if (raw.content_type==='json') { - // } - let parsedResponse = raw.response; - try { - parsedResponse = JSON.parse(raw.response); - } - catch (ex) { - this.logger.warn(`Attempting to parse mcp client response assuming it is JSON, however it is not: ${parsedResponse}`); - // raw.success = false - } - // Parse the response string into an object - // const parsedResponse = JSON.parse(raw.response); - - // Replace the string with the parsed object - const enrichedRaw = { - ...raw, - response: parsedResponse, - }; - // if (!raw.success) { - // return reject(new Error(`MCP error: ${raw.errors?.join(', ')}`)); - // } - // let cleaned = raw.response.trim(); - - // Don't need to re-parse this... - // const parsed = JSON.parse(cleaned); - // resolve(cleaned); - - resolve(enrichedRaw); - } catch (err: any) { - reject(new Error(`Mcp Invocation Failed: ${err.message}`)); - } - }); - }); - } - - cleanResponse(response: string) { - this.logger.log(`mcp server response is: ${response}`); - - // Remove markdown-style code block wrapper - if (response.startsWith('```json')) { - response = response.replace(/^```json/, '').trim(); - } - if (response.endsWith('```')) { - response = response.replace(/```$/, '').trim(); - } - this.logger.log(`mcp server response after removing doc tags is: ${response}`); - - return response; - } - - async applySolidAiInteraction(id: number) { - // Fetch the aiInteraction - const aiInteraction = await this.findOne(id, { - populate: ['user'] - }); - if (!aiInteraction) { - const m = `Unable to identified the aiInteraction entry that triggered this job... using id: ${id}` - - // TODO: RESPONSE SHAPE ALERT Check if we want to control the shape of the response.... - throw new Error(m); - } - - // TODO: Validation: Check if JSON.parse(metadata).tools_invoked starts with solid_ - let metadata: any = {}; - try { - if (typeof aiInteraction.metadata === "string") { - metadata = JSON.parse(aiInteraction.metadata); - } else if (typeof aiInteraction.metadata === "object" && aiInteraction.metadata !== null) { - metadata = aiInteraction.metadata; - } else { - // optional fallback - metadata = {}; - } - } catch (e) { - // TODO: RESPONSE SHAPE ALERT Check if we want to control the shape of the response.... - throw new Error(`Invalid metadata JSON: ${e}`); - } - - const toolsInvoked = metadata['tools_invoked']; - if (!toolsInvoked) { - // TODO: RESPONSE SHAPE ALERT Check if we want to control the shape of the response.... - throw new Error(ERROR_MESSAGES.UNABLE_TO_RESOLVE_SOLID_COMMAND); - } - - // TODO: OPTIMISATION for chained tool invocation, for now we are assuming only 1 tool was used. - const toolInvoked = toolsInvoked[0]; - - // TODO: use the toolInvoked to identify a service using some convention. - // TODO: Eg. if toolInvoked is solid_create_module <> SolidCreateModuleMcpToolHandler ... create a factory class to do this mapping and identify the relevant provider. - const mcpToolHandler = this.mcpHandlerFactory.getInstance(toolInvoked); - if (!mcpToolHandler) { - // TODO: RESPONSE SHAPE ALERT Check if we want to control the shape of the response.... - throw new Error(ERROR_MESSAGES.UNABLE_TO_RESOLVE_MCP_HANDLER); - } - - const handlerApplicationResponse = await mcpToolHandler.apply(aiInteraction); - - // TODO: This provider to implement an interface - IMcpToolResponseHandler ... apply(aiInteraction: AiInteraction) - // throw new Error('Method not implemented.'); - - // Mark the interaction as applied - await this.update(aiInteraction.id, { isApplied: true }, [], true); - - return handlerApplicationResponse; - } -} diff --git a/src/services/authentication.service.ts b/src/services/authentication.service.ts index 1f4b45ab..d9a2ddec 100755 --- a/src/services/authentication.service.ts +++ b/src/services/authentication.service.ts @@ -50,6 +50,7 @@ import { SettingService } from "./setting.service"; import { UserActivityHistoryService } from "./user-activity-history.service"; import { UserService } from "./user.service"; import { SmsFactory } from "src/factories/sms.factory"; +import { WhatsAppFactory } from "src/factories/whatsapp.factory"; import { SolidRegistry } from "src/helpers/solid-registry"; enum LoginProvider { @@ -79,6 +80,7 @@ export class AuthenticationService { private readonly mailServiceFactory: MailFactory, // private readonly smsService: Msg91OTPService, private readonly smsFactory: SmsFactory, + private readonly whatsAppFactory: WhatsAppFactory, private readonly eventEmitter: EventEmitter2, private readonly settingService: SettingService, private readonly roleMetadataService: RoleMetadataService, @@ -164,6 +166,8 @@ export class AuthenticationService { "mobile", "roles", "forcePasswordChange", + "isAllowedToGenerateApiKeys", + "failedLoginAttempts", ]); async signUp( @@ -228,9 +232,11 @@ export class AuthenticationService { userRoles.push(defaultRole); } await this.handlePostSignup(savedUser, userRoles, pwd, autoGeneratedPwd); + await this.handlePasswordlessSignupOtp(savedUser, signUpDto, autoGeneratedPwd, repo); + this.triggerRegistrationEvent(savedUser); return savedUser; - } catch (err) { + } catch (err: any) { const pgUniqueViolationErrorCode = "23505"; if (err.code === pgUniqueViolationErrorCode) { throw new ConflictException( @@ -243,6 +249,42 @@ export class AuthenticationService { } } + private async handlePasswordlessSignupOtp( + user: T, + signUpDto: SignUpDto, + autoGeneratedPwd: string, + repo: Repository, + ): Promise { + const isPasswordProvided = !!signUpDto.password; + const isAutoGeneratedPassword = !!autoGeneratedPwd; + if (isPasswordProvided || isAutoGeneratedPassword) { + return; + } + + if (!user.mobile) { + this.logger.warn( + `Skipping passwordless signup OTP WhatsApp notification for user ${user.username}: mobile is missing.`, + ); + return; + } + + const isPasswordlessRegistrationEnabled = + await this.isPasswordlessRegistrationEnabled(); + if (!isPasswordlessRegistrationEnabled) { + return; + } + + await this.assignRegistrationOtp( + PasswordlessRegistrationValidateWhatSources.MOBILE, + user, + ); + await repo.save(user); + await this.notifyUserOnOtpInitiateRegistration( + user, + PasswordlessRegistrationValidateWhatSources.MOBILE, + ); + } + /** @deprecated Use IExtensionUserCreationProvider instead. Kept for backward compatibility. */ async signupForExtensionUser( signUpDto: SignUpDto, @@ -485,7 +527,7 @@ export class AuthenticationService { validationSource, ); await this.notifyUserOnOtpInitiateRegistration(user, validationSource); - } catch (err) { + } catch (err: any) { if (err.code === "23505") { throw new ConflictException(ERROR_MESSAGES.USER_ALREADY_EXISTS); } @@ -614,7 +656,7 @@ export class AuthenticationService { const companyLogo = await this.getCompanyLogo(); if ( registrationValidationSource === - PasswordlessLoginValidateWhatSources.EMAIL + PasswordlessRegistrationValidateWhatSources.EMAIL ) { const mailService = this.mailServiceFactory.getMailService(); mailService.sendEmailUsingTemplate( @@ -644,25 +686,141 @@ export class AuthenticationService { } if ( registrationValidationSource === - PasswordlessLoginValidateWhatSources.MOBILE + PasswordlessRegistrationValidateWhatSources.MOBILE ) { - const smsService = this.smsFactory.getSmsService(); - smsService.sendSMSUsingTemplate( + const templateParams = { + solidAppName: + this.settingService.getConfigValue("appTitle"), + otp: user.mobileVerificationTokenOnRegistration, + mobileVerificationTokenOnRegistration: + user.mobileVerificationTokenOnRegistration, + firstName: user.username, + fullName: user.fullName ? user.fullName : user.username, + companyLogoUrl: companyLogo, + }; + + const whatsappDestination = this.normalizeWhatsAppDestination( user.mobile, - "otp-on-register", - { - solidAppName: - this.settingService.getConfigValue("appTitle"), - otp: user.mobileVerificationTokenOnRegistration, - mobileVerificationTokenOnRegistration: - user.mobileVerificationTokenOnRegistration, - firstName: user.username, - fullName: user.fullName ? user.fullName : user.username, - companyLogoUrl: companyLogo, - }, - this.settingService.getConfigValue("shouldQueueSms"), ); + const gupshupTemplateAppName = + process.env.COMMON_GUPSHUP_APP_NAME || "Gupshup"; + const whatsappTemplateId = + this.settingService.getConfigValue( + "otpWhatsappTemplateId", + ) || "common_otp"; + const whatsappIndependentEnabled = + this.settingService.getConfigValue( + "otpWhatsappIndependentEnabled", + ) !== false; + + let smsSent = false; + let whatsappSent = false; + let smsErrorMessage: string | undefined; + let whatsappErrorMessage: string | undefined; + + this.logger.debug( + `OTP SMS send attempt: destination=${user.mobile}, whatsappDestination=${whatsappDestination}`, + ); + + try { + const smsService = this.smsFactory.getSmsService(); + await smsService.sendSMSUsingTemplate( + user.mobile, + "otp-on-register", + templateParams, + false, + ); + smsSent = true; + } catch (smsError: any) { + smsErrorMessage = smsError?.message; + this.logger.warn( + `OTP SMS failed: destination=${user.mobile}, message=${smsErrorMessage}`, + ); + } + + if (whatsappIndependentEnabled) { + if (!whatsappDestination) { + whatsappErrorMessage = "Normalized WhatsApp destination is empty"; + this.logger.error( + `Independent OTP WhatsApp skipped: destination=${user.mobile}, message=${whatsappErrorMessage}`, + ); + } else { + this.logger.debug( + `Independent OTP WhatsApp send attempt: destination=${user.mobile}, whatsappDestination=${whatsappDestination}, templateId=${whatsappTemplateId}`, + ); + try { + await this.sendOtpToWhatsappProvider( + whatsappDestination, + String(whatsappTemplateId), + String(gupshupTemplateAppName), + String(templateParams.otp || ""), + ); + whatsappSent = true; + this.logger.log( + `Independent OTP WhatsApp success: destination=${user.mobile}, whatsappDestination=${whatsappDestination}, templateId=${whatsappTemplateId}`, + ); + } catch (waError: any) { + whatsappErrorMessage = waError?.message; + this.logger.error( + `Independent OTP WhatsApp failed: destination=${user.mobile}, whatsappDestination=${whatsappDestination}, templateId=${whatsappTemplateId}, message=${whatsappErrorMessage}`, + ); + } + } + } + + if (!smsSent && !whatsappSent) { + throw new Error( + `OTP delivery failed on both channels. smsError=${smsErrorMessage || "unknown"}, whatsappError=${whatsappErrorMessage || "disabled/unknown"}`, + ); + } + } + } + + private normalizeWhatsAppDestination(rawMobile: string): string { + const raw = (rawMobile || "").trim(); + let sanitized = raw.replace(/[^\d+]/g, ""); + + if (sanitized.startsWith("00")) { + sanitized = `+${sanitized.slice(2)}`; + } + + const defaultDialCode = String( + this.settingService.getConfigValue( + "otpDefaultCountryDialCode", + ) || "", + ).replace(/\D/g, ""); + + if (sanitized.startsWith("+")) { + const e164Digits = sanitized.slice(1).replace(/\D/g, ""); + if (e164Digits.length >= 8 && e164Digits.length <= 15) { + return e164Digits; + } + } + + const digits = sanitized.replace(/\D/g, ""); + + if (digits.length >= 11 && digits.length <= 15) { + return digits; } + + if (digits.length === 10 && defaultDialCode) { + return `${defaultDialCode}${digits}`; + } + + return digits; + } + + private async sendOtpToWhatsappProvider( + destination: string, + templateId: string, + appName: string, + otp: string, + ): Promise { + const whatsappService = this.whatsAppFactory.getWhatsappService(); + await whatsappService.sendWhatsAppMessage(destination, null, { + type: "text", + text: `${appName} OTP is ${otp}. It is valid for 10 mins.`, + }); } async otpConfirmRegistration(confirmSignUpDto: OTPConfirmOTPDto) { @@ -765,7 +923,7 @@ export class AuthenticationService { this.resolvePasswordlessValidationSource(); if ( registrationValidationSource === - PasswordlessLoginValidateWhatSources.EMAIL + PasswordlessRegistrationValidateWhatSources.EMAIL ) { if (!user.emailVerifiedOnRegistrationAt) { return false; @@ -773,7 +931,7 @@ export class AuthenticationService { } if ( registrationValidationSource === - PasswordlessLoginValidateWhatSources.MOBILE + PasswordlessRegistrationValidateWhatSources.MOBILE ) { if (!user.mobileVerifiedOnRegistrationAt) { return false; @@ -888,7 +1046,7 @@ export class AuthenticationService { const dummyOtp = this.getDummyOtpForUser(user); if (!dummyOtp) { await this.assignLoginOtp(user, type); - this.notifyUserOnOtpInititateLogin(user, type); + await this.notifyUserOnOtpInititateLogin(user, type); } return this.buildLoginOtpResponse(user, type); } @@ -1013,21 +1171,88 @@ export class AuthenticationService { ); } if (loginType === PasswordlessLoginValidateWhatSources.MOBILE) { - const smsService = this.smsFactory.getSmsService(); - smsService.sendSMSUsingTemplate( - user.mobile, - "otp-on-login", - { - solidAppName: - this.settingService.getConfigValue("appTitle"), - otp: user.mobileVerificationTokenOnLogin, - mobileVerificationTokenOnLogin: user.mobileVerificationTokenOnLogin, - firstName: user.username, - fullName: user.fullName ? user.fullName : user.username, - companyLogoUrl: companyLogo, - }, - this.settingService.getConfigValue("shouldQueueSms"), + const templateParams = { + solidAppName: + this.settingService.getConfigValue("appTitle"), + otp: user.mobileVerificationTokenOnLogin, + mobileVerificationTokenOnLogin: user.mobileVerificationTokenOnLogin, + firstName: user.username, + fullName: user.fullName ? user.fullName : user.username, + companyLogoUrl: companyLogo, + }; + + const whatsappDestination = this.normalizeWhatsAppDestination(user.mobile); + const gupshupTemplateAppName = + process.env.COMMON_GUPSHUP_APP_NAME || "Gupshup"; + const whatsappTemplateId = + this.settingService.getConfigValue( + "otpWhatsappTemplateId", + ) || "common_otp"; + const whatsappIndependentEnabled = + this.settingService.getConfigValue( + "otpWhatsappIndependentEnabled", + ) !== false; + + let smsSent = false; + let whatsappSent = false; + let smsErrorMessage: string | undefined; + let whatsappErrorMessage: string | undefined; + + this.logger.debug( + `OTP LOGIN SMS send attempt: destination=${user.mobile}, whatsappDestination=${whatsappDestination}`, ); + + try { + const smsService = this.smsFactory.getSmsService(); + await smsService.sendSMSUsingTemplate( + user.mobile, + "otp-on-login", + templateParams, + false, + ); + smsSent = true; + } catch (smsError: any) { + smsErrorMessage = smsError?.message; + this.logger.warn( + `OTP LOGIN SMS failed: destination=${user.mobile}, message=${smsErrorMessage}`, + ); + } + + if (whatsappIndependentEnabled) { + if (!whatsappDestination) { + whatsappErrorMessage = "Normalized WhatsApp destination is empty"; + this.logger.error( + `OTP LOGIN WhatsApp skipped: destination=${user.mobile}, message=${whatsappErrorMessage}`, + ); + } else { + this.logger.debug( + `OTP LOGIN WhatsApp send attempt: destination=${user.mobile}, whatsappDestination=${whatsappDestination}, templateId=${whatsappTemplateId}`, + ); + try { + await this.sendOtpToWhatsappProvider( + whatsappDestination, + String(whatsappTemplateId), + String(gupshupTemplateAppName), + String(templateParams.otp || ""), + ); + whatsappSent = true; + this.logger.log( + `OTP LOGIN WhatsApp success: destination=${user.mobile}, whatsappDestination=${whatsappDestination}, templateId=${whatsappTemplateId}`, + ); + } catch (waError: any) { + whatsappErrorMessage = waError?.message; + this.logger.error( + `OTP LOGIN WhatsApp failed: destination=${user.mobile}, whatsappDestination=${whatsappDestination}, templateId=${whatsappTemplateId}, message=${whatsappErrorMessage}`, + ); + } + } + } + + if (!smsSent && !whatsappSent) { + throw new Error( + `OTP LOGIN delivery failed on both channels. smsError=${smsErrorMessage || "unknown"}, whatsappError=${whatsappErrorMessage || "disabled/unknown"}`, + ); + } } } @@ -1335,12 +1560,10 @@ export class AuthenticationService { const user = await this.resolveUserByVerificationToken( confirmForgotPasswordDto.verificationToken, ); - if (!user) - throw new UnauthorizedException(ERROR_MESSAGES.INVALID_CREDENTIALS); + if (!user) throw new UnauthorizedException("Invalid verification token"); if (user.lastLoginProvider !== "local") throw new UnauthorizedException(ERROR_MESSAGES.INVALID_CREDENTIALS); - if (!user.active) - throw new UnauthorizedException(ERROR_MESSAGES.INVALID_CREDENTIALS); + if (!user.active) throw new UnauthorizedException("User is inactive"); // 1) Atomically consume the token (only one request can succeed) const { affected } = await m @@ -1548,7 +1771,7 @@ export class AuthenticationService { accessToken: await this.generateAccessToken(user), refreshToken: currentRefreshToken, }; - } catch (err) { + } catch (err: any) { if (err instanceof InvalidatedRefreshTokenError) { // Take action: notify user that his refresh token might have been stolen? throw new UnauthorizedException(ERROR_MESSAGES.ACCESS_DENIED); @@ -1599,7 +1822,7 @@ export class AuthenticationService { } else { throw new UnauthorizedException(ERROR_MESSAGES.INVALID_USER_PROFILE); } - } catch (error) { + } catch (error: any) { throw new UnauthorizedException( ERROR_MESSAGES.GOOGLE_OAUTH_PROFILE_FETCH_FAILED, ); @@ -1668,7 +1891,7 @@ export class AuthenticationService { } else { throw new UnauthorizedException(ERROR_MESSAGES.INVALID_USER_PROFILE); } - } catch (error) { + } catch (error: any) { if (error instanceof UnauthorizedException) { throw error; } @@ -1733,7 +1956,7 @@ export class AuthenticationService { } else { throw new UnauthorizedException(ERROR_MESSAGES.INVALID_USER_PROFILE); } - } catch (error) { + } catch (error: any) { throw new UnauthorizedException("Microsoft OAuth profile fetch failed"); } } @@ -1774,6 +1997,84 @@ export class AuthenticationService { }; } + async validateUserUsingMicrosoftActiveDirectory(user: User) { + if ( + !user.microsoftActiveDirectoryAccessToken || + !user.microsoftActiveDirectoryId + ) { + throw new UnauthorizedException(ERROR_MESSAGES.USER_NOT_FOUND); + } + + try { + const response = await this.httpService.axiosRef.get( + `https://graph.microsoft.com/v1.0/me?$select=id,displayName,mail,userPrincipalName`, + { + headers: { + Authorization: `Bearer ${user.microsoftActiveDirectoryAccessToken}`, + }, + }, + ); + const userProfile = response.data; + const profileEmail = (userProfile.mail || userProfile.userPrincipalName) + ?.trim() + .toLowerCase(); + const userEmail = user.email?.trim().toLowerCase(); + + if ( + userProfile.id === user.microsoftActiveDirectoryId && + (!userEmail || profileEmail === userEmail) + ) { + return userProfile; + } else { + throw new UnauthorizedException(ERROR_MESSAGES.INVALID_USER_PROFILE); + } + } catch (error: any) { + if (error instanceof UnauthorizedException) { + throw error; + } + + throw new UnauthorizedException( + ERROR_MESSAGES.MICROSOFT_ACTIVE_DIRECTORY_OAUTH_PROFILE_FETCH_FAILED, + ); + } + } + + async signInUsingMicrosoftActiveDirectory(accessCode: string) { + const user = await this.userRepository.findOne({ + where: { + accessCode: accessCode, + }, + relations: { + roles: true, + }, + }); + + if (!user) { + throw new UnauthorizedException(ERROR_MESSAGES.USER_NOT_FOUND); + } + this.checkAccountBlocked(user); + + try { + await this.validateUserUsingMicrosoftActiveDirectory(user); + } catch (e) { + await this.incrementFailedAttempts(user); + throw e; + } + + await this.resetFailedAttempts(user); + const tokens = await this.generateTokens(user); + return { + user: { + email: user.email, + mobile: user.mobile, + username: user.username, + id: user.id, + roles: user.roles.map((role) => role.name), + }, + ...tokens, + }; + } + async signInUsingApple(accessCode: string) { const user = await this.userRepository.findOne({ where: { @@ -1880,7 +2181,7 @@ export class AuthenticationService { await this.userActivityHistoryService.logEvent("logout", user); return { message: SUCCESS_MESSAGES.LOGOUT_SUCCESS }; - } catch (err) { + } catch (err: any) { throw err instanceof UnauthorizedException || err instanceof InternalServerErrorException ? err diff --git a/src/services/chatter-message.service.ts b/src/services/chatter-message.service.ts index 9ba47cd6..790c0184 100644 --- a/src/services/chatter-message.service.ts +++ b/src/services/chatter-message.service.ts @@ -1,18 +1,20 @@ import { LocalDateTimeTransformer, serializeDate } from 'src/transformers/typeorm/local-date-time-transformer'; -import { BadRequestException, forwardRef, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, ForbiddenException, forwardRef, Inject, Injectable, NotFoundException } from '@nestjs/common'; import { ModuleRef } from "@nestjs/core"; import { InjectEntityManager } from '@nestjs/typeorm'; -import { Brackets, EntityManager, EntityMetadata } from 'typeorm'; +import { Brackets, EntityManager, EntityMetadata, In } from 'typeorm'; import { classify } from '@angular-devkit/core/src/utils/strings'; import { CHATTER_MESSAGE_STATUS, CHATTER_MESSAGE_SUBTYPE, CHATTER_MESSAGE_TYPE } from 'src/constants/chatter-message.constants'; import { ERROR_MESSAGES } from 'src/constants/error-messages'; import { PostChatterMessageDto } from 'src/dtos/post-chatter-message.dto'; +import { UpdateChatterNoteMessageDto } from 'src/dtos/update-chatter-note-message.dto'; import { ModelMetadataHelperService } from 'src/helpers/model-metadata-helper.service'; import { lowerFirst } from 'src/helpers/string.helper'; import { ChatterMessageDetailsRepository } from 'src/repository/chatter-message-details.repository'; import { ChatterMessageRepository } from 'src/repository/chatter-message.repository'; import { FieldMetadataRepository } from 'src/repository/field-metadata.repository'; +import { MediaRepository } from 'src/repository/media.repository'; import { ModelMetadataRepository } from 'src/repository/model-metadata.repository'; import { CRUDService } from 'src/services/crud.service'; import { MediaStorageProviderType } from '../dtos/create-media-storage-provider-metadata.dto'; @@ -33,6 +35,7 @@ export class ChatterMessageService extends CRUDService { readonly repo: ChatterMessageRepository, // @InjectRepository(ChatterMessageDetailsRepository, 'default') readonly chatterMessageDetailsRepo: ChatterMessageDetailsRepository, + readonly mediaRepository: MediaRepository, // @InjectRepository(FieldMetadata, 'default') // readonly fieldMetadataRepo: Repository, readonly fieldMetadataRepo: FieldMetadataRepository, @@ -47,6 +50,41 @@ export class ChatterMessageService extends CRUDService { super(entityManager, repo, 'chatterMessage', 'solid-core', moduleRef); } + private resolveMessageUserId(userId?: number | null): number | null { + if (userId) { + return userId; + } + + return this.requestContextService.getActiveUser()?.sub ?? null; + } + + private resolveMessageUser(userId?: number | null) { + const resolvedUserId = this.resolveMessageUserId(userId); + return resolvedUserId ? ({ id: resolvedUserId } as any) : null; + } + + private stampMessageAuditFields(chatterMessage: ChatterMessage, userId?: number | null) { + const resolvedUserId = this.resolveMessageUserId(userId); + chatterMessage.user = resolvedUserId ? ({ id: resolvedUserId } as any) : null; + chatterMessage.createdBy = resolvedUserId; + chatterMessage.updatedBy = resolvedUserId; + } + + private isEditableCustomNoteMessage(message: ChatterMessage): boolean { + if (message.messageType !== CHATTER_MESSAGE_TYPE.CUSTOM) { + return false; + } + return [CHATTER_MESSAGE_SUBTYPE.CUSTOM, CHATTER_MESSAGE_SUBTYPE.NOTE].includes(message.messageSubType as any); + } + + private parseAttachmentIds(value?: string): number[] { + if (!value || typeof value !== 'string') return []; + return value + .split(',') + .map(v => Number(v.trim())) + .filter(v => Number.isInteger(v) && v > 0); + } + async markCompleted(id: number) { const activeUser = this.requestContextService.getActiveUser(); if (!activeUser) { @@ -62,6 +100,98 @@ export class ChatterMessageService extends CRUDService { return this.repo.save(message); } + async updateCustomNoteMessage(id: number, updateDto: UpdateChatterNoteMessageDto, files: Express.Multer.File[] = []) { + const activeUser = this.requestContextService.getActiveUser(); + if (!activeUser) { + throw new ForbiddenException(ERROR_MESSAGES.FORBIDDEN); + } + + const message = await this.repo.findOne({ where: { id }, relations: { user: true } }); + if (!message) { + throw new NotFoundException(`Entity [solid-core.chatterMessage] with id ${id} not found`); + } + + if (!this.isEditableCustomNoteMessage(message)) { + throw new BadRequestException('Only custom note messages can be edited.'); + } + + if (!message.user?.id || message.user.id !== activeUser.sub) { + throw new ForbiddenException('You can only edit your own custom note messages.'); + } + + const removeAttachmentIds = this.parseAttachmentIds(updateDto?.removeAttachmentIds); + const hasMessageBody = typeof updateDto?.messageBody === 'string'; + const trimmedMessageBody = (updateDto?.messageBody ?? '').trim(); + const hasNewFiles = Array.isArray(files) && files.length > 0; + + if (!hasMessageBody && removeAttachmentIds.length === 0 && !hasNewFiles) { + throw new BadRequestException('No note changes submitted.'); + } + + if (hasMessageBody && trimmedMessageBody.length === 0) { + throw new BadRequestException('Message body cannot be empty.'); + } + + if (hasMessageBody) { + message.messageBody = trimmedMessageBody; + } + message.updatedBy = activeUser.sub; + // Ensure updatedAt changes even for attachment-only edits. + message.updatedAt = new Date(); + const savedMessage = await this.repo.save(message); + + if (removeAttachmentIds.length > 0 || hasNewFiles) { + const model = await this.modelMetadataService.findOneBySingularName('chatterMessage', { + fields: { + model: true, + mediaStorageProvider: true, + }, + module: true, + }); + + const mediaFields = model.fields.filter(field => field.type === 'mediaSingle' || field.type === 'mediaMultiple'); + const attachmentFieldIds = mediaFields.map(field => field.id); + + if (removeAttachmentIds.length > 0 && attachmentFieldIds.length > 0) { + const mediaToRemove = await this.mediaRepository.find({ + where: { + id: In(removeAttachmentIds), + entityId: savedMessage.id, + modelMetadata: { id: model.id }, + fieldMetadata: { id: In(attachmentFieldIds) }, + }, + relations: { + mediaStorageProviderMetadata: true, + fieldMetadata: true, + }, + }); + + for (const media of mediaToRemove) { + const storageType = media.mediaStorageProviderMetadata?.type as MediaStorageProviderType; + const storageProvider = await getMediaStorageProvider(this.moduleRef, storageType); + await storageProvider.deleteByMediaRecord(media); + } + + if (mediaToRemove.length > 0) { + await this.mediaRepository.remove(mediaToRemove); + } + } + + for (const mediaField of mediaFields) { + const storageProviderMetadata = mediaField.mediaStorageProvider; + const storageProviderType = storageProviderMetadata.type as MediaStorageProviderType; + const storageProvider = await getMediaStorageProvider(this.moduleRef, storageProviderType); + + const media = files.filter(multerFile => multerFile.fieldname === mediaField.name); + if (media.length > 0) { + await storageProvider.store(media, savedMessage, mediaField); + } + } + } + + return savedMessage; + } + async postMessage(postDto: PostChatterMessageDto, files: Express.Multer.File[] = []) { const chatterMessage = new ChatterMessage(); chatterMessage.messageType = CHATTER_MESSAGE_TYPE.CUSTOM; @@ -78,14 +208,7 @@ export class ChatterMessageService extends CRUDService { }); chatterMessage.modelDisplayName = model?.displayName ?? null; - const activeUser = this.requestContextService.getActiveUser(); - - if (activeUser) { - const userId = activeUser?.sub; - chatterMessage.user = { id: userId } as any; - } else { - chatterMessage.user = null; - } + this.stampMessageAuditFields(chatterMessage); const savedMessage = await this.repo.save(chatterMessage); @@ -114,7 +237,7 @@ export class ChatterMessageService extends CRUDService { return savedMessage; } - async postAuditMessageOnInsert(entity: any, modelName: string, messageQueue: boolean = false) { + async postAuditMessageOnInsert(entity: any, modelName: string, messageQueue: boolean = false, userId?: number | null) { if (!entity) { return; } @@ -139,8 +262,6 @@ export class ChatterMessageService extends CRUDService { !(field.type === 'relation' && field.relationType === 'one-to-many') ); - const activeUser = this.requestContextService.getActiveUser(); - const chatterMessage = new ChatterMessage(); chatterMessage.messageType = CHATTER_MESSAGE_TYPE.AUDIT; chatterMessage.messageSubType = CHATTER_MESSAGE_SUBTYPE.AUDIT_INSERT; @@ -150,13 +271,7 @@ export class ChatterMessageService extends CRUDService { chatterMessage.modelDisplayName = model?.displayName; chatterMessage.modelUserKey = entity[model?.userKeyField?.name]; chatterMessage.messageBody = `New ${model?.displayName} created`; - - if (activeUser) { - const userId = activeUser?.sub; - chatterMessage.user = { id: userId } as any; - } else { - chatterMessage.user = null; - } + this.stampMessageAuditFields(chatterMessage, userId); const savedMessage = await this.repo.save(chatterMessage); @@ -177,7 +292,7 @@ export class ChatterMessageService extends CRUDService { } } - async postAuditMessageOnUpdate(entity: any, modelName: string, databaseEntity: any, updatedColumns: any[] = [], messageQueue: boolean = false) { + async postAuditMessageOnUpdate(entity: any, modelName: string, databaseEntity: any, updatedColumns: any[] = [], messageQueue: boolean = false, userId?: number | null) { if (!databaseEntity || !entity) { return; } @@ -259,8 +374,6 @@ export class ChatterMessageService extends CRUDService { return; } - const activeUser = this.requestContextService.getActiveUser(); - const chatterMessage = new ChatterMessage(); chatterMessage.messageType = CHATTER_MESSAGE_TYPE.AUDIT; chatterMessage.messageSubType = CHATTER_MESSAGE_SUBTYPE.AUDIT_UPDATE; @@ -270,13 +383,7 @@ export class ChatterMessageService extends CRUDService { chatterMessage.modelDisplayName = model.displayName; chatterMessage.modelUserKey = entity[model?.userKeyField?.name]; chatterMessage.messageBody = `${model?.displayName} updated`; - - if (activeUser) { - const userId = activeUser?.sub; - chatterMessage.user = { id: userId } as any; - } else { - chatterMessage.user = null; - } + this.stampMessageAuditFields(chatterMessage, userId); const savedMessage = await this.repo.save(chatterMessage); @@ -294,7 +401,7 @@ export class ChatterMessageService extends CRUDService { } } - async postAuditMessageOnDelete(modelName: string, databaseEntity: any, messageQueue: boolean = false) { + async postAuditMessageOnDelete(modelName: string, databaseEntity: any, messageQueue: boolean = false, userId?: number | null) { const model = await this.modelMetadataRepo.findOne({ where: { singularName: lowerFirst(modelName) @@ -335,14 +442,7 @@ export class ChatterMessageService extends CRUDService { chatterMessage.modelUserKey = databaseEntity[model?.userKeyField?.name]; chatterMessage.messageBody = `${model?.displayName} deleted`; - const activeUser = this.requestContextService.getActiveUser(); - - if (activeUser) { - const userId = activeUser?.sub; - chatterMessage.user = { id: userId } as any; - } else { - chatterMessage.user = null; - } + this.stampMessageAuditFields(chatterMessage, userId); const savedMessage = await this.repo.save(chatterMessage); @@ -421,7 +521,7 @@ export class ChatterMessageService extends CRUDService { if (value.id) { return value.id.toString(); } - } catch (error) { + } catch (error: any) { console.error('Error fetching related model metadata:', error); return value.id ? value.id.toString() : ''; } diff --git a/src/services/crud-helper.service.ts b/src/services/crud-helper.service.ts index f1676de2..e211c7f1 100755 --- a/src/services/crud-helper.service.ts +++ b/src/services/crud-helper.service.ts @@ -282,7 +282,15 @@ export class CrudHelperService { const orderOptionKeys = Object.keys(orderOptions) as Array; orderOptionKeys.forEach((key) => { const value = orderOptions[key] as 'ASC' | 'DESC'; - qb.addOrderBy(`${entityAlias}.${key}`, value); + const field = String(key); + if (field.includes('.')) { + const { alias, property, created } = this.ensureRelationPathJoined(qb, entityAlias, field.split('.')); + const orderColumn = `${alias}.${property}`; + qb.addOrderBy(orderColumn, value); + if (created) qb.addSelect(orderColumn); + } else { + qb.addOrderBy(`${entityAlias}.${field}`, value); + } }); } } @@ -341,6 +349,7 @@ export class CrudHelperService { qb.expressionMap?.aliases?.find(a => a.metadata)?.name || qb.expressionMap?.aliases?.[0]?.name; let parentAlias = mainAlias || rootAlias; + let leafJoinCreated = false; for (let i = 0; i < pathParts.length - 1; i++) { const part = pathParts[i]; const joinProperty = `${parentAlias}.${part}`; @@ -348,10 +357,13 @@ export class CrudHelperService { const joinAlias = existingAlias ?? this.sanitizeAlias(`${parentAlias}_${part}`); if (!existingAlias && !this.isRelationJoined(qb, joinProperty) && !this.isAliasJoined(qb, joinAlias)) { qb.leftJoin(joinProperty, joinAlias); + leafJoinCreated = true; + } else { + leafJoinCreated = false; } parentAlias = joinAlias; } - return { alias: parentAlias, property: pathParts[pathParts.length - 1] }; + return { alias: parentAlias, property: pathParts[pathParts.length - 1], created: leafJoinCreated }; } private getDriver(qb: SelectQueryBuilder) { @@ -743,4 +755,76 @@ export class CrudHelperService { return matchingPermssions.length > 0 } + pagedResponse(offset: number | undefined, limit: number | undefined, count: number, entities: T[]) { + const safeLimit = limit ?? count ?? 0; + const safeOffset = offset ?? 0; + const currentPage = safeLimit ? Math.floor(safeOffset / safeLimit) + 1 : 1; + const totalPages = safeLimit ? Math.ceil(count / safeLimit) : 1; + const nextPage = safeLimit && currentPage < totalPages ? currentPage + 1 : null; + const prevPage = safeLimit && currentPage > 1 ? currentPage - 1 : null; + return { + meta: { + totalRecords: count, + currentPage, + nextPage, + prevPage, + totalPages, + perPage: safeLimit ? +safeLimit : 0, + }, + records: entities, + }; + } + + async executeGroupPipeline( + filterQb: SelectQueryBuilder, + basicFilterDto: BasicFilterDto, + alias: string, + createQbFn: () => Promise>, + postProcessEntities?: (entities: T[]) => Promise + ): Promise<{ meta: { totalRecords: number }; groupMeta: any[]; groupRecords: any[] }> { + const groupByFields = this.normalize(basicFilterDto.groupBy); + if (!groupByFields.length) throw new BadRequestException(ERROR_MESSAGES.INVALID_GROUP_BY_COUNT); + + if (basicFilterDto.populateGroup) { + const hasRelationGroup = groupByFields.some(f => f.includes('.')); + if (hasRelationGroup) throw new BadRequestException('populateGroup is not supported when grouping on relation fields. Fetch group metadata first and retrieve records in a separate call.'); + } + + const { aliasMap: groupAliasMap, formatMap: groupFormatMap, expressionMap: groupExpressionMap } = + this.applyGroupBySelections(filterQb, groupByFields, alias); + const aggregateAliasMap = this.applyAggregates(filterQb, basicFilterDto.aggregates, alias); + this.applyGroupSortingAndPagination(filterQb, basicFilterDto.sort, { ...groupAliasMap, ...aggregateAliasMap }, basicFilterDto.limit, basicFilterDto.offset); + + const groupByResult = await filterQb.getRawMany(); + const totalGroups = await this.countGroups(filterQb); + const aggregateAliasSet = new Set(Object.values(aggregateAliasMap)); + + const groupMeta = []; + const groupRecords = []; + + for (const group of groupByResult) { + groupMeta.push(this.createGroupMeta(group, aggregateAliasSet, groupByFields, groupAliasMap, groupFormatMap)); + + if (basicFilterDto.populateGroup) { + let groupQb = await createQbFn(); + const { groupBy: _gb, aggregates: _agg, ...rest } = basicFilterDto; + const groupFilterDto: BasicFilterDto = { + ...rest, + ...basicFilterDto.groupFilter, + groupBy: undefined, + aggregates: undefined, + sort: basicFilterDto.groupFilter?.sort, + }; + groupQb = this.buildFilterQuery(groupQb, groupFilterDto, alias); + groupQb = this.buildGroupByRecordsQuery(groupQb, group, alias, groupAliasMap, aggregateAliasMap, groupExpressionMap); + const [entities, count] = await groupQb.getManyAndCount(); + if (postProcessEntities) await postProcessEntities(entities); + const groupData = this.pagedResponse(basicFilterDto.groupFilter?.offset, basicFilterDto.groupFilter?.limit, count, entities); + groupRecords.push(this.createGroupRecords(group, aggregateAliasSet, groupData, groupByFields, groupAliasMap, groupFormatMap)); + } + } + + return { meta: { totalRecords: totalGroups }, groupMeta, groupRecords }; + } + } diff --git a/src/services/crud.service.ts b/src/services/crud.service.ts index c0727890..a2f074ce 100755 --- a/src/services/crud.service.ts +++ b/src/services/crud.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, InternalServerErrorException, NotFoundException } from "@nestjs/common"; +import { BadRequestException, ConflictException, HttpException, InternalServerErrorException, NotFoundException } from "@nestjs/common"; import { DiscoveryService, ModuleRef } from "@nestjs/core"; import { isArray } from "class-validator"; import { CommonEntity } from "../entities/common.entity"; @@ -41,7 +41,6 @@ import { SolidRegistry } from "src/helpers/solid-registry"; import { getMediaStorageProvider } from "./mediaStorageProviders"; import { ModelMetadataService } from "./model-metadata.service"; import { RequestContextService } from "./request-context.service"; -import { BasicGroupFilterDto } from "src/dtos/basic-group-filters.dto"; export class CRUDService { // Add two generic value i.e Person,CreatePersonDto, so we get the proper types in our service @@ -129,7 +128,7 @@ export class CRUDService { // Add two generic value i.e await this.saveMedia(model, files, savedEntity); } return savedEntity; - } catch (error) { + } catch (error: any) { if (error instanceof QueryFailedError && error.message.includes('duplicate key value violates unique constraint')) { throw new BadRequestException(ERROR_MESSAGES.DUPLICATE_ENTRY); } @@ -480,10 +479,10 @@ export class CRUDService { // Add two generic value i.e return false; // If it is not a partial update, then do not skip computation } - async find(basicFilterDto: BasicFilterDto, solidRequestContext: any = {}) { + async find(basicFilterDto: BasicFilterDto, solidRequestContext: any = {}): Promise { const alias = 'entity'; // Extract the required keys from the input query - let { limit, offset, populateMedia, populateGroup, groupFilter } = basicFilterDto; + let { limit, offset, populateMedia } = basicFilterDto; const populateUserIdFields = this.crudHelperService.extractUserIdFieldsFromPopulate(basicFilterDto.populate); const { singularName, internationalisation, draftPublishWorkflow } = await this.loadModel(); @@ -502,41 +501,19 @@ export class CRUDService { // Add two generic value i.e // Create above query on pincode table using query builder var qb: SelectQueryBuilder = await this.repo.createSecurityRuleAwareQueryBuilder(alias) - if (basicFilterDto.groupBy) { + if (basicFilterDto.groupBy?.length) { const groupFilterQb = (internationalisation && draftPublishWorkflow) ? this.crudHelperService.buildFilterQuery(qb, basicFilterDto, alias, internationalisation, draftPublishWorkflow, this.moduleRef, FilterCombinator.AND, false, false) : this.crudHelperService.buildFilterQuery(qb, basicFilterDto, alias, undefined, undefined, undefined, FilterCombinator.AND, false, false); - const groupByFields = this.crudHelperService.normalize(basicFilterDto.groupBy); - if (!groupByFields.length) { - throw new BadRequestException(ERROR_MESSAGES.INVALID_GROUP_BY_COUNT); - } - - if (basicFilterDto.populateGroup) { - const hasRelationGroup = groupByFields.some(field => field.includes('.')); - if (hasRelationGroup) { - throw new BadRequestException('populateGroup is not supported when grouping on relation fields. Fetch group metadata first and retrieve records in a separate call.'); + return this.crudHelperService.executeGroupPipeline( + groupFilterQb, basicFilterDto, alias, + () => this.repo.createSecurityRuleAwareQueryBuilder(alias), + async (entities) => { + if (populateUserIdFields?.length) await this.handlePopulateUserIdFields(populateUserIdFields, entities); + if (populateMedia?.length) await this.handlePopulateMedia(populateMedia, entities); } - } - - const { aliasMap: groupAliasMap, formatMap: groupFormatMap, expressionMap: groupExpressionMap } = this.crudHelperService.applyGroupBySelections(groupFilterQb, groupByFields, alias); - const aggregateAliasMap = this.crudHelperService.applyAggregates(groupFilterQb, basicFilterDto.aggregates, alias); - const sortAliasMap = { ...groupAliasMap, ...aggregateAliasMap }; - this.crudHelperService.applyGroupSortingAndPagination(groupFilterQb, basicFilterDto.sort, sortAliasMap, limit, offset); - - const groupByResult = await groupFilterQb.getRawMany(); - const totalGroups = await this.crudHelperService.countGroups(groupFilterQb); - - const groupByFieldsOrdered = this.crudHelperService.normalize(basicFilterDto.groupBy || []); - const { groupMeta, groupRecords } = await this.handleGroupFind(groupByResult, groupFilter, populateGroup, alias, populateUserIdFields, populateMedia, basicFilterDto, groupAliasMap, aggregateAliasMap, groupByFieldsOrdered, groupFormatMap, groupExpressionMap); - - return { - meta: { - "totalRecords": totalGroups - }, - groupMeta, - groupRecords, - } + ); } else { qb = (internationalisation && draftPublishWorkflow) @@ -566,78 +543,8 @@ export class CRUDService { // Add two generic value i.e return this.wrapFindResponse(offset, limit, count, entities); } - private async handleGroupFind( - groupByResult: any[], - groupFilter: BasicGroupFilterDto | undefined, - populateGroup: boolean, - alias: string, - populateUserIdFields: UserIdFields[], - populateMedia: string[], - baseFilterDto: BasicFilterDto, - groupAliasMap: Record, - aggregateAliasMap: Record, - groupByFieldsOrdered: string[], - groupFormatMap: Record, - groupExpressionMap: Record - ) { - const groupMeta = []; - const groupRecords = []; - const aggregateAliasSet = new Set(Object.values(aggregateAliasMap)); - // For each group, get the records and the count - for (const group of groupByResult) { - if (populateGroup) { - let groupByQb: SelectQueryBuilder = await this.repo.createSecurityRuleAwareQueryBuilder(alias); - const groupFilterDto: BasicFilterDto = { - ...baseFilterDto, - ...groupFilter, - groupBy: undefined, - aggregates: undefined, - // Only use explicit groupFilter.sort for record ordering; group-level sorts can contain - // group expressions (e.g. createdAt:day) that are invalid on record queries. - sort: groupFilter?.sort, - }; - groupByQb = this.crudHelperService.buildFilterQuery(groupByQb, groupFilterDto, alias); - groupByQb = this.crudHelperService.buildGroupByRecordsQuery(groupByQb, group, alias, groupAliasMap, aggregateAliasMap, groupExpressionMap); - const [entities, count] = await groupByQb.getManyAndCount(); - - // Populate the entity with the userId fields - if (populateUserIdFields && populateUserIdFields.length > 0) { - await this.handlePopulateUserIdFields(populateUserIdFields, entities); - } - - // Populate the entity with the media - if (populateMedia && populateMedia.length > 0) { - await this.handlePopulateMedia(populateMedia, entities); - } - const groupData = this.wrapFindResponse(groupFilter?.offset, groupFilter?.limit, count, entities); - groupRecords.push(this.crudHelperService.createGroupRecords(group, aggregateAliasSet, groupData, groupByFieldsOrdered, groupAliasMap, groupFormatMap)); - } - groupMeta.push(this.crudHelperService.createGroupMeta(group, aggregateAliasSet, groupByFieldsOrdered, groupAliasMap, groupFormatMap)); - } - return { groupMeta, groupRecords }; - } - private wrapFindResponse(offset: number | undefined, limit: number | undefined, count: number, entities: T[]) { - const safeLimit = limit ?? count ?? 0; - const safeOffset = offset ?? 0; - const currentPage = safeLimit ? Math.floor(safeOffset / safeLimit) + 1 : 1; - const totalPages = safeLimit ? Math.ceil(count / safeLimit) : 1; - - const nextPage = safeLimit && currentPage < totalPages ? currentPage + 1 : null; - const prevPage = safeLimit && currentPage > 1 ? currentPage - 1 : null; - - const r = { - meta: { - totalRecords: count, - currentPage: currentPage, - nextPage: nextPage, - prevPage: prevPage, - totalPages: totalPages, - perPage: safeLimit ? +safeLimit : 0, - }, - records: entities - }; - return r; + return this.crudHelperService.pagedResponse(offset, limit, count, entities); } // entities is an array of T @@ -911,7 +818,7 @@ export class CRUDService { // Add two generic value i.e }); if (!softDeletedRows) { - throw new Error(ERROR_MESSAGES.NO_SOFT_DELETED_RECORD_FOUND); + throw new NotFoundException(ERROR_MESSAGES.NO_SOFT_DELETED_RECORD_FOUND); } await this.repo.update(id, { @@ -920,14 +827,14 @@ export class CRUDService { // Add two generic value i.e ); return { message: SUCCESS_MESSAGES.RECORD_RECOVERED, data: softDeletedRows }; - } catch (error) { - if (error instanceof QueryFailedError) { - if ((error as any).code === '23505') { - throw new Error(ERROR_MESSAGES.CONFLICTING_RECORD_ON_UNARCHIVE); - } + } catch (error: any) { + if (error instanceof QueryFailedError && (error as any).code === '23505') { + throw new ConflictException(ERROR_MESSAGES.CONFLICTING_RECORD_ON_UNARCHIVE); } - - throw new Error(error); + if (error instanceof HttpException) { + throw error; + } + throw new InternalServerErrorException(error?.message ?? String(error)); } } @@ -956,7 +863,7 @@ export class CRUDService { // Add two generic value i.e }); if (softDeletedRows.length === 0) { - throw new Error(ERROR_MESSAGES.NO_SOFT_DELETED_RECORDS_FOUND); + throw new NotFoundException(ERROR_MESSAGES.NO_SOFT_DELETED_RECORDS_FOUND); } // Recover the specific records by setting deletedAt to null @@ -966,14 +873,14 @@ export class CRUDService { // Add two generic value i.e ); return { message: SUCCESS_MESSAGES.SELECTED_RECORDS_RECOVERED, recoveredIds: ids }; - } catch (error) { - if (error instanceof QueryFailedError) { - if ((error as any).code === "23505") { - throw new Error(ERROR_MESSAGES.CONFLICTING_RECORD_ON_UNARCHIVE); - } + } catch (error: any) { + if (error instanceof QueryFailedError && (error as any).code === "23505") { + throw new ConflictException(ERROR_MESSAGES.CONFLICTING_RECORD_ON_UNARCHIVE); } - - throw new Error(error); + if (error instanceof HttpException) { + throw error; + } + throw new InternalServerErrorException(error?.message ?? String(error)); } } diff --git a/src/services/csv.service.ts b/src/services/csv.service.ts index f8795500..930567e5 100644 --- a/src/services/csv.service.ts +++ b/src/services/csv.service.ts @@ -68,7 +68,7 @@ export class CsvService { } csvStream.end(); // ✅ Ensure CSV stream is finalized - } catch (error) { + } catch (error: any) { this.logger.error(`❌ Error writing CSV: ${error.message}`); passThrough.destroy(error); // ✅ Properly destroy stream on error throw error; diff --git a/src/services/dashboard-layout.service.ts b/src/services/dashboard-layout.service.ts deleted file mode 100644 index 1d348e26..00000000 --- a/src/services/dashboard-layout.service.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ModuleRef } from "@nestjs/core"; -import { InjectEntityManager } from '@nestjs/typeorm'; -import { EntityManager } from 'typeorm'; - -import { CRUDService } from 'src/services/crud.service'; -import { DashboardLayout } from 'src/entities/dashboard-layout.entity'; -import { DashboardLayoutRepository } from 'src/repository/dashboard-layout.repository'; -import { CreateDashboardLayoutDto } from 'src/dtos/create-dashboard-layout.dto'; -import { RequestContextService } from './request-context.service'; - - -@Injectable() -export class DashboardLayoutService extends CRUDService { - private readonly logger = new Logger(this.constructor.name); - constructor( - @InjectEntityManager() - readonly entityManager: EntityManager, - readonly repo: DashboardLayoutRepository, - readonly requestContextService: RequestContextService, - readonly moduleRef: ModuleRef, - ) { - super(entityManager, repo, 'dashboardLayout', 'solid-core', moduleRef); - } - - async upsertUserDashboardLayout(createDtos: CreateDashboardLayoutDto) { - const activeUser = this.requestContextService.getActiveUser(); - - if (!activeUser) { - throw new Error('User not found'); - } - - let userId = null; - if (activeUser.roles.includes('Admin')) { - userId = null; - } else { - userId = activeUser?.sub; - } - const existingLayout = await this.repo.findOne({ - where: { - user: { id: userId }, - dashboard: { - id: createDtos.dashboardId - } - }, - relations: { - user: true, - dashboard: true, - } - }); - - if (existingLayout) { - return super.update(existingLayout.id, { layout: createDtos.layout }, [], true); - } else { - const createDto = { - layout: createDtos.layout, - dashboardId: createDtos.dashboardId, - uesrId: userId - } - return super.create(createDto, []); - } - } - - async getUserDashboardLayoutByDashboardId(dashboardId: any) { - const activeUser = this.requestContextService.getActiveUser(); - - if (!activeUser) { - throw new Error('User not found'); - } - const userId = activeUser?.sub; - const existingUserLayout = await this.repo.findOne({ - where: { - user: { id: userId }, - dashboard: { - id: dashboardId - } - }, - relations: { - user: true, - dashboard: true, - } - }); - if (existingUserLayout) { - // if dahsboard for userid exists - return existingUserLayout; - } - - // if not then check for default dashboard - const defaultLayout = await this.repo.findOne({ - where: { - user: { id: null }, - dashboard: { - id: dashboardId - } - }, - relations: { - user: true, - dashboard: true, - } - }); - if (defaultLayout) { - // if default layout exists return it - return defaultLayout; - } else { - // if default layout does not exist return empty layout - return { - layout: null - } - } - } -} diff --git a/src/services/dashboard-providers/README.md b/src/services/dashboard-providers/README.md new file mode 100644 index 00000000..9b65735b --- /dev/null +++ b/src/services/dashboard-providers/README.md @@ -0,0 +1,603 @@ +# Dashboarding Architecture and Developer Guide + +This document is the canonical guide for SolidX dashboarding in its new metadata-driven architecture. + +It covers: +- end-to-end architecture (`solid-core-module` + `solid-core-ui`) +- metadata authoring model +- backend widget data providers +- frontend default/custom widget rendering +- layout persistence model +- extension points and troubleshooting + +--- + +## 1. Guiding Principles + +- **Metadata-first**: dashboard definitions live in module metadata JSON under root-level `dashboards`. +- **Provider-driven backend**: every widget resolves data through a backend data provider. +- **Extension-driven frontend**: widgets render through extension registry components (`dashboardWidget` type). +- **User personalization**: only user layout overrides are persisted; dashboard definitions are not persisted. + +--- + +## 2. Source of Truth (Important) + +### 2.1 What is persisted vs what is not + +Persisted: +- user-specific layout override (`ss_dashboard_user_layout`) + +Not persisted: +- dashboard definitions (`dashboards` metadata) +- variables +- widgets +- default layout + +### 2.2 Metadata location resolution + +At runtime, metadata is read from file paths resolved by `ModuleMetadataHelperService`: + +1. `src//metadata/-metadata.json` +2. fallback (solid-core local run): `src/seeders/seed-data/solid-core-metadata.json` +3. fallback (consuming project): `node_modules/@solidxai/core/src/seeders/seed-data/solid-core-metadata.json` + +This is the most common reason for “changes not taking effect”: the running service may be reading the installed package metadata, not local source metadata. + +--- + +## 3. End-to-End Runtime Flow + +1. UI route opens dashboard page: +- `/admin/core/:moduleName/dashboard/:dashboardName` + +2. UI loads definition: +- `GET /dashboard/:module/:dashboardName/definition` + +3. UI loads layout: +- `GET /dashboard/:module/:dashboardName/layout` +- backend returns `{ defaultLayout, userLayout, effectiveLayout }` + +4. UI resolves filters and requests data: +- `POST /dashboard/:module/:dashboardName/data` + +5. Backend runtime service: +- reads metadata dashboard definition +- resolves provider per widget from registry +- checks widget permissions for the active user +- executes providers with variables + provider context only for authorized widgets +- returns normalized provider envelopes + +6. User drag/resize save: +- `PUT /dashboard/:module/:dashboardName/layout` +- persists user layout override + +--- + +## 4. Backend Architecture (`solid-core-module`) + +### 4.1 Core runtime controller + +File: +- `src/controllers/dashboard.controller.ts` + +Endpoints: +- `GET /dashboard/:moduleName/:dashboardName/definition` +- `POST /dashboard/:moduleName/:dashboardName/widgets/:widgetName/data` +- `POST /dashboard/:moduleName/:dashboardName/data` +- `GET /dashboard/:moduleName/:dashboardName/variable-options/:variableName` +- `GET /dashboard/:moduleName/:dashboardName/layout` +- `PUT /dashboard/:moduleName/:dashboardName/layout` + +All runtime dashboard endpoints are JWT-protected (`@ApiBearerAuth('jwt')`). + +### 4.2 Runtime service + +File: +- `src/services/dashboard-runtime.service.ts` + +Responsibilities: +- load dashboard definition from metadata +- resolve and invoke widget providers +- resolve static/dynamic variable options +- compute `effectiveLayout` via default+user merge +- save per-user layout + +### 4.3 Provider contract + +File: +- `src/interfaces.ts` + +Interfaces: +- `IDashboardWidgetDataProviderContext` +- `IDashboardWidgetDataResponseEnvelope` +- `IDashboardWidgetDataProvider` + +Provider contract: +- `name(): string` +- `help(): string` +- `getData(widgetDefinition, ctxt): Promise` + +Envelope shape: +- `meta`: provider name, widget name, duration, generatedAt +- `data`: widget payload +- `uiHints` (optional) + +### 4.4 Provider registration flow + +Files: +- decorator: `src/decorators/dashboard-widget-data-provider.decorator.ts` +- introspection: `src/services/solid-introspect.service.ts` +- registry: `src/helpers/solid-registry.ts` + +Pattern: +- annotate with `@DashboardWidgetDataProvider()` +- introspection discovers providers +- registry stores providers +- runtime resolves by `widget.dataProvider` + +### 4.5 Variable options providers + +Dynamic selection variables reuse existing selection provider infrastructure. + +Reference providers: +- `src/services/selection-providers/mq-dashboard-queue-name-variable-options-provider.service.ts` +- `src/services/selection-providers/mq-dashboard-message-broker-variable-options-provider.service.ts` + +Metadata variable uses: +- `type: "selectionDynamic"` +- `selectionConfig.providerName` + +### 4.6 Queue-health reference providers + +Folder: +- `src/services/dashboard-providers/` + +Includes KPI + charts + table providers: +- `mq-dashboard-total-messages-kpi-provider.service.ts` +- `mq-dashboard-succeeded-messages-kpi-provider.service.ts` +- `mq-dashboard-failed-messages-kpi-provider.service.ts` +- `mq-dashboard-inflight-messages-kpi-provider.service.ts` +- `mq-dashboard-success-rate-kpi-provider.service.ts` +- `mq-dashboard-avg-elapsed-kpi-provider.service.ts` +- `mq-dashboard-messages-over-time-provider.service.ts` +- `mq-dashboard-stage-distribution-provider.service.ts` +- `mq-dashboard-queue-wise-failures-provider.service.ts` +- `mq-dashboard-queue-wise-avg-elapsed-provider.service.ts` +- `mq-dashboard-latency-trend-provider.service.ts` +- `mq-dashboard-recent-failures-provider.service.ts` + +Shared filter utilities: +- `mq-dashboard-provider-utils.ts` + +### 4.7 Layout persistence model + +Generated model and assets: +- entity: `src/entities/dashboard-user-layout.entity.ts` +- repository: `src/repositories/dashboard-user-layout.repository.ts` +- service: `src/services/dashboard-user-layout.service.ts` +- controller: `src/controllers/dashboard-user-layout.controller.ts` + +Table: +- `ss_dashboard_user_layout` + +Uniqueness: +- `(user_id, module_id, dashboard_name)` + +Stored payload: +- `layoutJson` with Gridstack-compatible `items`. + +--- + +## 5. Metadata Contract (`dashboards`) + +Metadata file (solid-core reference): +- `src/seeders/seed-data/solid-core-metadata.json` + +`dashboards` is a root peer of `actions`, `menus`, `roles`, `users`, etc. + +### 5.1 Dashboard definition shape + +Typical fields: +- `dashboardSchemaVersion` +- `name` +- `displayName` +- `description` +- `moduleUserKey` +- `variables[]` +- `widgets[]` +- `defaultLayout` + +### 5.2 Variable types in current implementation + +- `date` +- `selectionStatic` +- `selectionDynamic` +- `isMultiSelect` supported for selection types + +Date variable payload sent to backend: +- `{ from?: ISO, to?: ISO }` +- UI preset flow supports `today`, `last_24_hours`, `last_7_days`, `last_30_days`, `custom` + +Static selection: +- `selectionStaticValues: ["value:Label", ...]` + +Dynamic selection: +- `selectionConfig.providerName` +- optional `selectionConfig.providerContext` + +### 5.3 Widget definition shape + +Typical fields: +- `id` +- `name` +- `type` (`kpi`, `lineChart`, `barChart`, `pieChart`, `table`, `customChart`, ...) +- `dataProvider` +- `providerContext` +- optional UI override: + - `componentName` (or equivalent custom component key) + +### 5.4 Default layout shape + +`defaultLayout`: +- `engine: "gridstack"` +- `columns: number` +- `items[]` each with: + - `widgetId`, `x`, `y`, `w`, `h`, optional `minW`, `minH` + +### 5.5 Explicit widget permissions + +Dashboards use SolidX's existing explicit permission mechanism for widget-level authorization. + +Permission format: +- `dashboard::` + +Rules: +- the first segment is always `dashboard` +- the dashboard name must match exactly +- the widget segment supports: + - exact widget names + - `*` + - regex-style patterns + +Queue-health reference example: +- `dashboard:queue-health:kpi-.*` +- `dashboard:queue-health:chart-queue-.*` +- `dashboard:queue-health:chart-processing-latency-trend` + +What this means: +- all KPI widgets are authorized by `kpi-.*` +- queue-oriented chart widgets such as `chart-queue-wise-failures`, `chart-queue-wise-avg-elapsed`, and `chart-queue-sla-heatmap` are authorized by `chart-queue-.*` +- `chart-processing-latency-trend` is authorized explicitly +- widgets like `chart-stage-distribution`, `chart-messages-over-time`, and `table-recent-failures` remain unauthorized unless extra permissions are granted + +Important: +- dashboard developers must declare these permissions explicitly in module metadata +- they must then assign them to roles +- users receive them through standard SolidX RBAC + +--- + +## 6. Frontend Architecture (`solid-core-ui`) + +### 6.1 Route and page + +Route registration: +- `src/routes/solidRoutes.tsx` +- path: `/admin/core/:moduleName/dashboard/:dashboardName` + +Page: +- `src/routes/pages/admin/core/DashboardPage.tsx` + +### 6.2 Dashboard API slice + +File: +- `src/redux/api/dashboardRuntimeApi.ts` + +Hooks: +- `useGetDashboardDefinitionQuery` +- `useGetDashboardLayoutQuery` +- `useSaveDashboardLayoutMutation` +- `useGetDashboardDataMutation` +- `useLazyGetDashboardVariableOptionsQuery` + +### 6.3 Filter UX (current) + +- Filters are rendered in a modal dialog (not inline panel) +- Header has icon CTAs: filter, refresh, save layout +- Filter icon shows applied-filter badge count +- Date variable UX: + - preset dropdown first + - selecting `custom` reveals start/end date pickers +- Dynamic selection uses Solid autocomplete primitives +- Static selection uses Solid primitives (single/multi behavior) + +### 6.4 Layout engine + +- Gridstack integration in `DashboardPage` +- layout source order: + - `effectiveLayout.items` from backend + - fallback computed layout from widget order +- save sends full layout snapshot for all widgets + +### 6.5 Widget extension system + +Types: +- `src/types/extension-registry.ts` +- includes `dashboardWidget` + +Registry: +- `src/helpers/registry.ts` + +Dashboard widget props: +- `src/types/dashboard.ts` (`DashboardWidgetComponentProps`) + +Default registered dashboard widgets: +- `DefaultDashboardKpiWidget` +- `DefaultDashboardLineChartWidget` +- `DefaultDashboardBarChartWidget` +- `DefaultDashboardPieChartWidget` +- `DefaultDashboardTableWidget` +- `DefaultDashboardUnknownWidget` + +### 6.6 Unauthorized widget rendering + +Authorization is enforced on the backend first and reflected in the frontend second. + +Current behavior: +- if the active user lacks widget permission, the backend does not invoke the widget provider at all +- the backend returns a normalized unauthorized envelope +- the frontend still renders the widget card in the dashboard grid +- the widget body shows a compact centered `Unauthorized` state + +This preserves layout stability while ensuring protected widget data never leaves the server. + +### 6.7 Chart abstraction + +- baseline chart engine: Apache ECharts +- mapper lives under: + - `src/components/core/dashboard/mappers/echartsOptionMapper` +- default chart widgets convert provider payload -> ECharts options + +--- + +## 7. Developer Workflows + +## 7.1 Create a new dashboard (metadata-first) + +1. Add dashboard block under root `dashboards[]` +2. Define variables +3. Define widgets with `dataProvider` +4. Define `defaultLayout` +5. Add action/menu entries for navigation +6. ensure route points to `/admin/core//dashboard/` + +## 7.2 Add a new backend widget provider + +1. Create provider class under `src/services/dashboard-providers` +2. Implement `IDashboardWidgetDataProvider` +3. Annotate with `@DashboardWidgetDataProvider()` + `@Injectable()` +4. Inject repositories and use security-rule-aware query builders +5. Return envelope with `meta` and `data` +6. Reference provider name in metadata widget definition + +Minimal skeleton: + +```ts +@DashboardWidgetDataProvider() +@Injectable() +export class MyWidgetProvider implements IDashboardWidgetDataProvider { + name(): string { + return "MyWidgetProvider"; + } + + help(): string { + return "Returns data for My Widget."; + } + + async getData(widgetDefinition: Record, ctxt: IDashboardWidgetDataProviderContext) { + return { + meta: { + providerName: this.name(), + widgetName: ctxt.widgetName, + generatedAt: new Date().toISOString(), + durationMs: 0, + }, + data: { + value: 123, + }, + }; + } +} +``` + +## 7.3 Add dynamic variable options provider + +1. Create `ISelectionProvider` class +2. Decorate with `@SelectionProvider()` +3. Implement `value()` and `values()` +4. Reference provider in variable `selectionConfig.providerName` + +## 7.4 Add a custom frontend widget (optional) + +Custom rendering is optional. + +Model: +- backend provider is mandatory for data +- custom widget component is optional override + +Steps: +1. Create component in UI project +2. Register with `registerExtensionComponent(..., ExtensionComponentTypes.dashboardWidget)` +3. Reference component in widget metadata (`componentName`) +4. If no custom widget is provided, framework default widgets are used + +--- + +## 8. Queue Health Reference Implementation + +Reference dashboard: +- module: `solid-core` +- dashboard: `queue-health` + +Variables: +- `date` (`type: date`) +- `queueName` (`selectionDynamic`) +- `stage` (`selectionStatic`, multi) +- `messageBroker` (`selectionDynamic`) + +Widgets include: +- KPIs (total, succeeded, failed, in-flight, success rate, avg elapsed) +- Charts (time series, stage distribution, queue failures, queue avg elapsed, latency trend) +- Custom chart (Queue SLA Heatmap) +- Table (recent failures) + +This RI is the baseline for future dashboards and for custom widget extension examples. + +It also now includes the canonical 100% custom widget example: +- backend provider: `MqDashboardQueueSlaHeatmapProvider` +- frontend widget: `QueueSlaHeatmapWidget` + +Heatmap provider response contract: +- `xCategories` +- `yCategories` +- `points` (`[xIndex, yIndex, metricValue]`) +- optional `tooltipFields` +- optional `pointDetails` +- optional `legendThresholds` + +Queue-health permission example used for validating the authorization flow: +- `dashboard:queue-health:kpi-.*` +- `dashboard:queue-health:chart-queue-.*` +- `dashboard:queue-health:chart-processing-latency-trend` + +--- + +## 9. Troubleshooting Guide + +### 9.1 Metadata change not reflecting + +Check which metadata file runtime reads (see Section 2.2 precedence). + +In consuming projects, ensure latest package is linked/installed: +- `@solidxai/core` +- `@solidxai/core-ui` + +Then restart backend and frontend dev servers. + +### 9.2 Layout saves but refresh shows old layout + +Checklist: +- verify `GET /dashboard/:module/:dashboard/layout` returns updated `userLayout` / `effectiveLayout` +- confirm UI bundle includes latest `DashboardPage` grid initialization logic +- clear Vite cache and hard refresh browser + +### 9.3 Dynamic options not showing + +Checklist: +- variable `type` is exactly `selectionDynamic` +- `selectionConfig.providerName` matches provider `name()` +- provider class has `@SelectionProvider()` and is registered via module wiring + +### 9.4 Widget shows fallback/unknown renderer + +Checklist: +- provider returns expected shape in `data` +- widget metadata `type` aligns with default mapper support +- if using custom renderer, ensure component is registered as `dashboardWidget` + +### 9.5 Auth / permission issues + +- Dashboard runtime endpoints require JWT +- verify the authenticated user can access the module and dashboard route in the consuming app +- verify explicit dashboard widget permissions are declared in metadata +- verify those permissions are assigned to the correct roles +- remember that unauthorized widgets still render their card, but the provider will not run and the body will show `Unauthorized` + +--- + +## 10. Security and Guardrails (Current + Near-term) + +Current: +- JWT-protected endpoints +- repository/security-rule-aware query builder usage in reference providers + +Recommended next: +- strict provider allowlist enforcement in runtime layer +- query timeout and payload-size limits +- row limits for heavy chart/table providers + +--- + +## 11. API Examples + +### 11.1 Fetch definition + +```bash +curl -H "Authorization: Bearer $TOKEN" \ + "$BASE_URL/dashboard/solid-core/queue-health/definition" +``` + +### 11.2 Fetch full dashboard data + +```bash +curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ + "$BASE_URL/dashboard/solid-core/queue-health/data" \ + -d '{ + "variables": { + "date": { "from": "2026-05-01T00:00:00.000Z", "to": "2026-05-31T23:59:59.999Z" }, + "queueName": "create_sandbox_dev_sandbox_provisioning_queue", + "stage": ["failed", "retrying"] + } + }' +``` + +### 11.3 Save layout + +```bash +curl -X PUT -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ + "$BASE_URL/dashboard/solid-core/queue-health/layout" \ + -d '{ + "layout": { + "engine": "gridstack", + "columns": 12, + "items": [ + { "widgetId": "kpi-total-messages", "x": 0, "y": 0, "w": 2, "h": 2 } + ] + } + }' +``` + +--- + +## 12. What to Reuse vs What to Customize + +Reuse defaults when: +- metric/table/chart fits existing renderer types +- ECharts mapper can render your shape + +Create custom component when: +- interaction/visual behavior is domain-specific +- advanced chart behavior is needed beyond generic renderer + +Always keep backend provider contract stable so UI remains pluggable. + +--- + +## 13. Current Status Snapshot + +Implemented: +- metadata-driven dashboard runtime +- backend provider contracts + registry wiring +- dashboard controller endpoints +- queue-health reference providers +- dynamic variable options providers +- frontend dashboard page, filter modal, Gridstack persistence +- ECharts-based default dashboard widgets +- `dashboardWidget` extension type and default registrations +- user layout persistence via generated model assets + +Pending/next: +- stronger runtime schema validation and guardrails +- richer renderer pluggability contract (`chartRenderer` beyond v1) +- additional documentation templates and automated validation helpers diff --git a/src/services/dashboard-providers/mq-dashboard-avg-elapsed-kpi-provider.service.ts b/src/services/dashboard-providers/mq-dashboard-avg-elapsed-kpi-provider.service.ts new file mode 100644 index 00000000..ee882da6 --- /dev/null +++ b/src/services/dashboard-providers/mq-dashboard-avg-elapsed-kpi-provider.service.ts @@ -0,0 +1,49 @@ +import { Injectable } from "@nestjs/common"; +import { DashboardWidgetDataProvider } from "src/decorators/dashboard-widget-data-provider.decorator"; +import { MqMessageRepository } from "src/repository/mq-message.repository"; +import { IDashboardWidgetDataProvider, IDashboardWidgetDataProviderContext, IDashboardWidgetDataResponseEnvelope } from "src/interfaces"; +import { applyMqDashboardFilters, toNumber } from "src/services/dashboard-providers/mq-dashboard-provider-utils"; + +@DashboardWidgetDataProvider() +@Injectable() +export class MqDashboardAvgElapsedKpiProvider implements IDashboardWidgetDataProvider { + constructor( + private readonly mqMessageRepository: MqMessageRepository, + ) { } + + name(): string { + return "MqDashboardAvgElapsedKpiProvider"; + } + + help(): string { + return "Returns average elapsed millis after applying dashboard variables."; + } + + async getData( + widgetDefinition: Record, + ctxt: IDashboardWidgetDataProviderContext, + ): Promise> { + const qb = await this.mqMessageRepository.createSecurityRuleAwareQueryBuilder("mqMessage"); + applyMqDashboardFilters(qb, ctxt.variables ?? {}); + qb.andWhere("mqMessage.elapsedMillis IS NOT NULL"); + + const raw = await qb + .select("AVG(mqMessage.elapsedMillis)", "avgElapsed") + .getRawOne<{ avgElapsed?: string | number }>(); + + const value = Number(toNumber(raw?.avgElapsed, 0).toFixed(2)); + return { + meta: { + providerName: this.name(), + generatedAt: new Date().toISOString(), + widgetName: ctxt.widgetName, + durationMs: 0, + }, + data: { + value, + label: widgetDefinition?.name ?? "Avg Elapsed (ms)", + }, + }; + } +} + diff --git a/src/services/dashboard-providers/mq-dashboard-failed-messages-kpi-provider.service.ts b/src/services/dashboard-providers/mq-dashboard-failed-messages-kpi-provider.service.ts new file mode 100644 index 00000000..2b41355e --- /dev/null +++ b/src/services/dashboard-providers/mq-dashboard-failed-messages-kpi-provider.service.ts @@ -0,0 +1,45 @@ +import { Injectable } from "@nestjs/common"; +import { DashboardWidgetDataProvider } from "src/decorators/dashboard-widget-data-provider.decorator"; +import { MqMessageRepository } from "src/repository/mq-message.repository"; +import { IDashboardWidgetDataProvider, IDashboardWidgetDataProviderContext, IDashboardWidgetDataResponseEnvelope } from "src/interfaces"; +import { applyMqDashboardFilters } from "src/services/dashboard-providers/mq-dashboard-provider-utils"; + +@DashboardWidgetDataProvider() +@Injectable() +export class MqDashboardFailedMessagesKpiProvider implements IDashboardWidgetDataProvider { + constructor( + private readonly mqMessageRepository: MqMessageRepository, + ) { } + + name(): string { + return "MqDashboardFailedMessagesKpiProvider"; + } + + help(): string { + return "Returns failed mq message count after applying dashboard variables."; + } + + async getData( + widgetDefinition: Record, + ctxt: IDashboardWidgetDataProviderContext, + ): Promise> { + const qb = await this.mqMessageRepository.createSecurityRuleAwareQueryBuilder("mqMessage"); + applyMqDashboardFilters(qb, ctxt.variables ?? {}, { ignoreStage: true }); + qb.andWhere("mqMessage.stage = :stage", { stage: "failed" }); + + const value = await qb.getCount(); + return { + meta: { + providerName: this.name(), + generatedAt: new Date().toISOString(), + widgetName: ctxt.widgetName, + durationMs: 0, + }, + data: { + value, + label: widgetDefinition?.name ?? "Failed", + }, + }; + } +} + diff --git a/src/services/dashboard-providers/mq-dashboard-inflight-messages-kpi-provider.service.ts b/src/services/dashboard-providers/mq-dashboard-inflight-messages-kpi-provider.service.ts new file mode 100644 index 00000000..90bf5cfd --- /dev/null +++ b/src/services/dashboard-providers/mq-dashboard-inflight-messages-kpi-provider.service.ts @@ -0,0 +1,45 @@ +import { Injectable } from "@nestjs/common"; +import { DashboardWidgetDataProvider } from "src/decorators/dashboard-widget-data-provider.decorator"; +import { MqMessageRepository } from "src/repository/mq-message.repository"; +import { IDashboardWidgetDataProvider, IDashboardWidgetDataProviderContext, IDashboardWidgetDataResponseEnvelope } from "src/interfaces"; +import { applyMqDashboardFilters, MQ_INFLIGHT_STAGES } from "src/services/dashboard-providers/mq-dashboard-provider-utils"; + +@DashboardWidgetDataProvider() +@Injectable() +export class MqDashboardInflightMessagesKpiProvider implements IDashboardWidgetDataProvider { + constructor( + private readonly mqMessageRepository: MqMessageRepository, + ) { } + + name(): string { + return "MqDashboardInflightMessagesKpiProvider"; + } + + help(): string { + return "Returns in-flight mq message count after applying dashboard variables."; + } + + async getData( + widgetDefinition: Record, + ctxt: IDashboardWidgetDataProviderContext, + ): Promise> { + const qb = await this.mqMessageRepository.createSecurityRuleAwareQueryBuilder("mqMessage"); + applyMqDashboardFilters(qb, ctxt.variables ?? {}, { ignoreStage: true }); + qb.andWhere("mqMessage.stage IN (:...stages)", { stages: MQ_INFLIGHT_STAGES }); + + const value = await qb.getCount(); + return { + meta: { + providerName: this.name(), + generatedAt: new Date().toISOString(), + widgetName: ctxt.widgetName, + durationMs: 0, + }, + data: { + value, + label: widgetDefinition?.name ?? "In Flight", + }, + }; + } +} + diff --git a/src/services/dashboard-providers/mq-dashboard-latency-trend-provider.service.ts b/src/services/dashboard-providers/mq-dashboard-latency-trend-provider.service.ts new file mode 100644 index 00000000..657f9158 --- /dev/null +++ b/src/services/dashboard-providers/mq-dashboard-latency-trend-provider.service.ts @@ -0,0 +1,58 @@ +import { Injectable } from "@nestjs/common"; +import { DashboardWidgetDataProvider } from "src/decorators/dashboard-widget-data-provider.decorator"; +import { MqMessageRepository } from "src/repository/mq-message.repository"; +import { IDashboardWidgetDataProvider, IDashboardWidgetDataProviderContext, IDashboardWidgetDataResponseEnvelope } from "src/interfaces"; +import { applyMqDashboardFilters, normalizeBucket, toNumber } from "src/services/dashboard-providers/mq-dashboard-provider-utils"; + +@DashboardWidgetDataProvider() +@Injectable() +export class MqDashboardLatencyTrendProvider implements IDashboardWidgetDataProvider { + constructor( + private readonly mqMessageRepository: MqMessageRepository, + ) { } + + name(): string { + return "MqDashboardLatencyTrendProvider"; + } + + help(): string { + return "Returns average elapsed millis trend over time buckets after applying dashboard variables."; + } + + async getData( + widgetDefinition: Record, + ctxt: IDashboardWidgetDataProviderContext, + ): Promise> { + const bucket = normalizeBucket(ctxt?.providerContext?.bucket); + + const qb = await this.mqMessageRepository.createSecurityRuleAwareQueryBuilder("mqMessage"); + applyMqDashboardFilters(qb, ctxt.variables ?? {}); + qb.andWhere("mqMessage.elapsedMillis IS NOT NULL"); + + const bucketExpr = `DATE_TRUNC('${bucket}', mqMessage.createdAt)`; + const rows = await qb + .select(bucketExpr, "bucket") + .addSelect("AVG(mqMessage.elapsedMillis)", "value") + .groupBy(bucketExpr) + .orderBy("bucket", "ASC") + .getRawMany<{ bucket: string; value: string | number }>(); + + const categories = rows.map((r) => new Date(r.bucket).toISOString()); + const values = rows.map((r) => Number(toNumber(r.value).toFixed(2))); + + return { + meta: { + providerName: this.name(), + generatedAt: new Date().toISOString(), + widgetName: ctxt.widgetName, + durationMs: 0, + }, + data: { + categories, + series: [{ name: "avg_elapsed_millis", data: values }], + bucket, + }, + }; + } +} + diff --git a/src/services/dashboard-providers/mq-dashboard-messages-over-time-provider.service.ts b/src/services/dashboard-providers/mq-dashboard-messages-over-time-provider.service.ts new file mode 100644 index 00000000..9a941a6f --- /dev/null +++ b/src/services/dashboard-providers/mq-dashboard-messages-over-time-provider.service.ts @@ -0,0 +1,79 @@ +import { Injectable } from "@nestjs/common"; +import { DashboardWidgetDataProvider } from "src/decorators/dashboard-widget-data-provider.decorator"; +import { MqMessageRepository } from "src/repository/mq-message.repository"; +import { IDashboardWidgetDataProvider, IDashboardWidgetDataProviderContext, IDashboardWidgetDataResponseEnvelope } from "src/interfaces"; +import { applyMqDashboardFilters, normalizeBucket, toNumber } from "src/services/dashboard-providers/mq-dashboard-provider-utils"; + +@DashboardWidgetDataProvider() +@Injectable() +export class MqDashboardMessagesOverTimeProvider implements IDashboardWidgetDataProvider { + constructor( + private readonly mqMessageRepository: MqMessageRepository, + ) { } + + name(): string { + return "MqDashboardMessagesOverTimeProvider"; + } + + help(): string { + return "Returns time-series message counts by stage (and total) after applying dashboard variables."; + } + + async getData( + widgetDefinition: Record, + ctxt: IDashboardWidgetDataProviderContext, + ): Promise> { + const bucket = normalizeBucket(ctxt?.providerContext?.bucket); + const requestedSeries: string[] = Array.isArray(ctxt?.providerContext?.series) && ctxt.providerContext.series.length > 0 + ? ctxt.providerContext.series + : ["total", "succeeded", "failed", "retrying"]; + + const qb = await this.mqMessageRepository.createSecurityRuleAwareQueryBuilder("mqMessage"); + applyMqDashboardFilters(qb, ctxt.variables ?? {}); + + const bucketExpr = `DATE_TRUNC('${bucket}', mqMessage.createdAt)`; + const rows = await qb + .select(bucketExpr, "bucket") + .addSelect("mqMessage.stage", "stage") + .addSelect("COUNT(mqMessage.id)", "count") + .groupBy(bucketExpr) + .addGroupBy("mqMessage.stage") + .orderBy("bucket", "ASC") + .getRawMany<{ bucket: string; stage: string; count: string | number }>(); + + const bucketToStageMap = new Map>(); + for (const row of rows) { + const key = new Date(row.bucket).toISOString(); + const entry = bucketToStageMap.get(key) ?? {}; + entry[row.stage] = (entry[row.stage] ?? 0) + toNumber(row.count, 0); + bucketToStageMap.set(key, entry); + } + + const categories = Array.from(bucketToStageMap.keys()).sort(); + const series = requestedSeries.map((seriesName) => { + const data = categories.map((category) => { + const stageMap = bucketToStageMap.get(category) ?? {}; + if (seriesName === "total") { + return Object.values(stageMap).reduce((sum, value) => sum + value, 0); + } + return stageMap[seriesName] ?? 0; + }); + return { name: seriesName, data }; + }); + + return { + meta: { + providerName: this.name(), + generatedAt: new Date().toISOString(), + widgetName: ctxt.widgetName, + durationMs: 0, + }, + data: { + categories, + series, + bucket, + }, + }; + } +} + diff --git a/src/services/dashboard-providers/mq-dashboard-provider-utils.ts b/src/services/dashboard-providers/mq-dashboard-provider-utils.ts new file mode 100644 index 00000000..d40ed9b6 --- /dev/null +++ b/src/services/dashboard-providers/mq-dashboard-provider-utils.ts @@ -0,0 +1,165 @@ +import { MqMessage } from "src/entities/mq-message.entity"; +import { SelectQueryBuilder } from "typeorm"; + +export const MQ_INFLIGHT_STAGES = ['pending', 'scheduled', 'started', 'retry', 'retrying']; + +export interface MqDashboardVariables { + date?: any; + queueName?: string | string[]; + stage?: string | string[]; + messageBroker?: string | string[]; +} + +export interface ApplyMqDashboardFiltersOptions { + ignoreStage?: boolean; + ensureQueueJoin?: boolean; +} + +export function applyMqDashboardFilters( + qb: SelectQueryBuilder, + variables: MqDashboardVariables = {}, + options: ApplyMqDashboardFiltersOptions = {}, +): SelectQueryBuilder { + applyDateFilter(qb, variables.date); + applyStringOrArrayFilter(qb, 'mqMessage.messageBroker', variables.messageBroker, 'messageBroker'); + + if (!options.ignoreStage) { + applyStringOrArrayFilter(qb, 'mqMessage.stage', variables.stage, 'stage'); + } + + if ( + options.ensureQueueJoin || + (variables.queueName !== undefined && variables.queueName !== null && variables.queueName !== '') + ) { + qb.leftJoin('mqMessage.mqMessageQueue', 'mqMessageQueue'); + } + + if (variables.queueName !== undefined && variables.queueName !== null && variables.queueName !== '') { + applyStringOrArrayFilter(qb, 'mqMessageQueue.name', variables.queueName, 'queueName'); + } + + return qb; +} + +export function normalizeBucket(value: string | undefined): 'hour' | 'day' | 'week' | 'month' { + const v = `${value ?? ''}`.toLowerCase(); + if (v === 'hour' || v === 'day' || v === 'week' || v === 'month') { + return v; + } + return 'hour'; +} + +export function toNumber(value: any, fallback = 0): number { + const n = Number(value); + return Number.isFinite(n) ? n : fallback; +} + + +function applyDateFilter(qb: SelectQueryBuilder, dateVariable: any): void { + const range = resolveDateRange(dateVariable); + if (range.from) { + qb.andWhere('mqMessage.createdAt >= :dashboardDateFrom', { dashboardDateFrom: range.from.toISOString() }); + } + if (range.to) { + qb.andWhere('mqMessage.createdAt <= :dashboardDateTo', { dashboardDateTo: range.to.toISOString() }); + } +} + +function applyStringOrArrayFilter( + qb: SelectQueryBuilder, + qualifiedColumn: string, + value: string | string[] | undefined, + paramKey: string, +) { + if (value === undefined || value === null || value === '') { + return; + } + + const values = Array.isArray(value) ? value.filter((v) => !!v) : [value]; + if (values.length === 0) { + return; + } + + if (values.length === 1) { + qb.andWhere(`${qualifiedColumn} = :${paramKey}`, { [paramKey]: values[0] }); + return; + } + + qb.andWhere(`${qualifiedColumn} IN (:...${paramKey})`, { [paramKey]: values }); +} + +function resolveDateRange(dateVariable: any): { from?: Date; to?: Date } { + if (!dateVariable) return {}; + + if (Array.isArray(dateVariable) && dateVariable.length >= 2) { + return { + from: parseDate(dateVariable[0]), + to: parseDate(dateVariable[1]), + }; + } + + if (typeof dateVariable === 'object') { + const presetRange = resolvePresetRange(dateVariable.preset); + const from = parseDate(dateVariable.from ?? dateVariable.start ?? dateVariable.startDate) ?? presetRange.from; + const to = parseDate(dateVariable.to ?? dateVariable.end ?? dateVariable.endDate) ?? presetRange.to; + return { from, to }; + } + + if (typeof dateVariable === 'string') { + const presetRange = resolvePresetRange(dateVariable); + if (presetRange.from || presetRange.to) { + return presetRange; + } + const parsed = parseDate(dateVariable); + if (!parsed) return {}; + return { from: parsed, to: parsed }; + } + + return {}; +} + +function parseDate(value: any): Date | undefined { + if (!value) return undefined; + const d = new Date(value); + if (Number.isNaN(d.getTime())) return undefined; + return d; +} + +function resolvePresetRange(preset: string | undefined): { from?: Date; to?: Date } { + if (!preset) return {}; + + const now = new Date(); + const to = new Date(now); + switch (preset) { + case 'today': { + const from = new Date(now); + from.setHours(0, 0, 0, 0); + return { from, to }; + } + case 'yesterday': { + const from = new Date(now); + from.setDate(from.getDate() - 1); + from.setHours(0, 0, 0, 0); + const end = new Date(from); + end.setHours(23, 59, 59, 999); + return { from, to: end }; + } + case 'last_24_hours': { + const from = new Date(now); + from.setHours(from.getHours() - 24); + return { from, to }; + } + case 'last_7_days': { + const from = new Date(now); + from.setDate(from.getDate() - 7); + return { from, to }; + } + case 'last_30_days': { + const from = new Date(now); + from.setDate(from.getDate() - 30); + return { from, to }; + } + default: + return {}; + } +} diff --git a/src/services/dashboard-providers/mq-dashboard-queue-sla-heatmap-provider.service.ts b/src/services/dashboard-providers/mq-dashboard-queue-sla-heatmap-provider.service.ts new file mode 100644 index 00000000..da5eaf27 --- /dev/null +++ b/src/services/dashboard-providers/mq-dashboard-queue-sla-heatmap-provider.service.ts @@ -0,0 +1,155 @@ +import { Injectable } from "@nestjs/common"; +import { DashboardWidgetDataProvider } from "src/decorators/dashboard-widget-data-provider.decorator"; +import { IDashboardWidgetDataProvider, IDashboardWidgetDataProviderContext, IDashboardWidgetDataResponseEnvelope } from "src/interfaces"; +import { MqMessageRepository } from "src/repository/mq-message.repository"; +import { applyMqDashboardFilters, normalizeBucket, toNumber } from "src/services/dashboard-providers/mq-dashboard-provider-utils"; + +type HeatmapLegendThreshold = { + label: string; + color: string; + lte?: number; + gte?: number; + gt?: number; +}; + +type HeatmapPointDetail = { + xIndex: number; + yIndex: number; + bucket: string; + queueName: string; + avgElapsedMillis: number; + messageCount: number; + peakElapsedMillis: number; +}; + +type QueueSlaHeatmapResponse = { + xCategories: string[]; + yCategories: string[]; + points: [number, number, number][]; + metric: string; + bucket: string; + tooltipFields?: string[]; + pointDetails?: HeatmapPointDetail[]; + legendThresholds?: HeatmapLegendThreshold[]; +}; + +const DEFAULT_LEGEND_THRESHOLDS: HeatmapLegendThreshold[] = [ + { label: "0-5s", color: "#22c55e", lte: 5000 }, + { label: "5-15s", color: "#f59e0b", gt: 5000, lte: 15000 }, + { label: "15-30s", color: "#f97316", gt: 15000, lte: 30000 }, + { label: "30s+", color: "#ef4444", gt: 30000 }, +]; + +@DashboardWidgetDataProvider() +@Injectable() +export class MqDashboardQueueSlaHeatmapProvider implements IDashboardWidgetDataProvider { + constructor( + private readonly mqMessageRepository: MqMessageRepository, + ) { } + + name(): string { + return "MqDashboardQueueSlaHeatmapProvider"; + } + + help(): string { + return "Returns queue-wise SLA heatmap data using average elapsed millis across time buckets."; + } + + async getData( + widgetDefinition: Record, + ctxt: IDashboardWidgetDataProviderContext, + ): Promise> { + const bucket = normalizeBucket(ctxt?.providerContext?.bucket); + const legendThresholds = Array.isArray(ctxt?.providerContext?.legendThresholds) && ctxt.providerContext.legendThresholds.length > 0 + ? ctxt.providerContext.legendThresholds + : DEFAULT_LEGEND_THRESHOLDS; + const tooltipFields = Array.isArray(ctxt?.providerContext?.tooltipFields) && ctxt.providerContext.tooltipFields.length > 0 + ? ctxt.providerContext.tooltipFields + : ["avgElapsedMillis", "messageCount", "peakElapsedMillis"]; + + const qb = await this.mqMessageRepository.createSecurityRuleAwareQueryBuilder("mqMessage"); + applyMqDashboardFilters(qb, ctxt.variables ?? {}, { ensureQueueJoin: true }); + qb.andWhere("mqMessage.elapsedMillis IS NOT NULL"); + + const bucketExpr = `DATE_TRUNC('${bucket}', mqMessage.createdAt)`; + const rows = await qb + .select(bucketExpr, "bucket") + .addSelect("mqMessageQueue.name", "queueName") + .addSelect("AVG(mqMessage.elapsedMillis)", "avgElapsedMillis") + .addSelect("COUNT(mqMessage.id)", "messageCount") + .addSelect("MAX(mqMessage.elapsedMillis)", "peakElapsedMillis") + .groupBy(bucketExpr) + .addGroupBy("mqMessageQueue.name") + .orderBy("bucket", "ASC") + .addOrderBy("mqMessageQueue.name", "ASC") + .getRawMany<{ + bucket: string; + queueName: string; + avgElapsedMillis: string | number; + messageCount: string | number; + peakElapsedMillis: string | number; + }>(); + + const xCategories = Array.from( + new Set( + rows + .map((row) => row?.bucket ? new Date(row.bucket).toISOString() : "") + .filter((bucketValue) => !!bucketValue) + ) + ).sort(); + const yCategories = Array.from( + new Set(rows.map((row) => `${row?.queueName ?? ""}`.trim()).filter((queueName) => !!queueName)) + ).sort((left, right) => left.localeCompare(right)); + + const xIndexMap = new Map(xCategories.map((value, index) => [value, index])); + const yIndexMap = new Map(yCategories.map((value, index) => [value, index])); + + const points: [number, number, number][] = []; + const pointDetails: HeatmapPointDetail[] = []; + + for (const row of rows) { + const bucketValue = row?.bucket ? new Date(row.bucket).toISOString() : ""; + const queueName = `${row?.queueName ?? ""}`.trim(); + const xIndex = xIndexMap.get(bucketValue); + const yIndex = yIndexMap.get(queueName); + + if (xIndex === undefined || yIndex === undefined) { + continue; + } + + const avgElapsedMillis = Number(toNumber(row.avgElapsedMillis).toFixed(2)); + const messageCount = toNumber(row.messageCount); + const peakElapsedMillis = Number(toNumber(row.peakElapsedMillis).toFixed(2)); + + points.push([xIndex, yIndex, avgElapsedMillis]); + pointDetails.push({ + xIndex, + yIndex, + bucket: bucketValue, + queueName, + avgElapsedMillis, + messageCount, + peakElapsedMillis, + }); + } + + return { + meta: { + providerName: this.name(), + generatedAt: new Date().toISOString(), + widgetName: ctxt.widgetName, + durationMs: 0, + }, + data: { + xCategories, + yCategories, + points, + metric: "avg_elapsed_millis", + bucket, + tooltipFields, + pointDetails, + legendThresholds, + }, + }; + } +} diff --git a/src/services/dashboard-providers/mq-dashboard-queue-wise-avg-elapsed-provider.service.ts b/src/services/dashboard-providers/mq-dashboard-queue-wise-avg-elapsed-provider.service.ts new file mode 100644 index 00000000..152cb5dc --- /dev/null +++ b/src/services/dashboard-providers/mq-dashboard-queue-wise-avg-elapsed-provider.service.ts @@ -0,0 +1,51 @@ +import { Injectable } from "@nestjs/common"; +import { DashboardWidgetDataProvider } from "src/decorators/dashboard-widget-data-provider.decorator"; +import { MqMessageRepository } from "src/repository/mq-message.repository"; +import { IDashboardWidgetDataProvider, IDashboardWidgetDataProviderContext, IDashboardWidgetDataResponseEnvelope } from "src/interfaces"; +import { applyMqDashboardFilters, toNumber } from "src/services/dashboard-providers/mq-dashboard-provider-utils"; + +@DashboardWidgetDataProvider() +@Injectable() +export class MqDashboardQueueWiseAvgElapsedProvider implements IDashboardWidgetDataProvider { + constructor( + private readonly mqMessageRepository: MqMessageRepository, + ) { } + + name(): string { + return "MqDashboardQueueWiseAvgElapsedProvider"; + } + + help(): string { + return "Returns queue-wise average elapsed millis after applying dashboard variables."; + } + + async getData( + widgetDefinition: Record, + ctxt: IDashboardWidgetDataProviderContext, + ): Promise> { + const qb = await this.mqMessageRepository.createSecurityRuleAwareQueryBuilder("mqMessage"); + applyMqDashboardFilters(qb, ctxt.variables ?? {}, { ensureQueueJoin: true }); + qb.andWhere("mqMessage.elapsedMillis IS NOT NULL"); + + const rows = await qb + .select("mqMessageQueue.name", "category") + .addSelect("AVG(mqMessage.elapsedMillis)", "value") + .groupBy("mqMessageQueue.name") + .orderBy("value", "DESC") + .getRawMany<{ category: string; value: string | number }>(); + + return { + meta: { + providerName: this.name(), + generatedAt: new Date().toISOString(), + widgetName: ctxt.widgetName, + durationMs: 0, + }, + data: { + categories: rows.map((r) => r.category), + series: [{ name: "avg_elapsed_millis", data: rows.map((r) => Number(toNumber(r.value).toFixed(2))) }], + }, + }; + } +} + diff --git a/src/services/dashboard-providers/mq-dashboard-queue-wise-failures-provider.service.ts b/src/services/dashboard-providers/mq-dashboard-queue-wise-failures-provider.service.ts new file mode 100644 index 00000000..df1b03a7 --- /dev/null +++ b/src/services/dashboard-providers/mq-dashboard-queue-wise-failures-provider.service.ts @@ -0,0 +1,51 @@ +import { Injectable } from "@nestjs/common"; +import { DashboardWidgetDataProvider } from "src/decorators/dashboard-widget-data-provider.decorator"; +import { MqMessageRepository } from "src/repository/mq-message.repository"; +import { IDashboardWidgetDataProvider, IDashboardWidgetDataProviderContext, IDashboardWidgetDataResponseEnvelope } from "src/interfaces"; +import { applyMqDashboardFilters, toNumber } from "src/services/dashboard-providers/mq-dashboard-provider-utils"; + +@DashboardWidgetDataProvider() +@Injectable() +export class MqDashboardQueueWiseFailuresProvider implements IDashboardWidgetDataProvider { + constructor( + private readonly mqMessageRepository: MqMessageRepository, + ) { } + + name(): string { + return "MqDashboardQueueWiseFailuresProvider"; + } + + help(): string { + return "Returns queue-wise failed message count after applying dashboard variables."; + } + + async getData( + widgetDefinition: Record, + ctxt: IDashboardWidgetDataProviderContext, + ): Promise> { + const qb = await this.mqMessageRepository.createSecurityRuleAwareQueryBuilder("mqMessage"); + applyMqDashboardFilters(qb, ctxt.variables ?? {}, { ignoreStage: true, ensureQueueJoin: true }); + qb.andWhere("mqMessage.stage = :failedStage", { failedStage: "failed" }); + + const rows = await qb + .select("mqMessageQueue.name", "category") + .addSelect("COUNT(mqMessage.id)", "value") + .groupBy("mqMessageQueue.name") + .orderBy("value", "DESC") + .getRawMany<{ category: string; value: string | number }>(); + + return { + meta: { + providerName: this.name(), + generatedAt: new Date().toISOString(), + widgetName: ctxt.widgetName, + durationMs: 0, + }, + data: { + categories: rows.map((r) => r.category), + series: [{ name: "failed_count", data: rows.map((r) => toNumber(r.value)) }], + }, + }; + } +} + diff --git a/src/services/dashboard-providers/mq-dashboard-recent-failures-provider.service.ts b/src/services/dashboard-providers/mq-dashboard-recent-failures-provider.service.ts new file mode 100644 index 00000000..8d1eb698 --- /dev/null +++ b/src/services/dashboard-providers/mq-dashboard-recent-failures-provider.service.ts @@ -0,0 +1,95 @@ +import { Injectable } from "@nestjs/common"; +import { DashboardWidgetDataProvider } from "src/decorators/dashboard-widget-data-provider.decorator"; +import { MqMessageRepository } from "src/repository/mq-message.repository"; +import { IDashboardWidgetDataProvider, IDashboardWidgetDataProviderContext, IDashboardWidgetDataResponseEnvelope } from "src/interfaces"; +import { applyMqDashboardFilters } from "src/services/dashboard-providers/mq-dashboard-provider-utils"; + +@DashboardWidgetDataProvider() +@Injectable() +export class MqDashboardRecentFailuresProvider implements IDashboardWidgetDataProvider { + constructor( + private readonly mqMessageRepository: MqMessageRepository, + ) { } + + name(): string { + return "MqDashboardRecentFailuresProvider"; + } + + help(): string { + return "Returns recent failed mq messages in a tabular payload after applying dashboard variables."; + } + + async getData( + widgetDefinition: Record, + ctxt: IDashboardWidgetDataProviderContext, + ): Promise> { + const limit = Math.min(Math.max(Number(ctxt?.providerContext?.limit ?? 25), 1), 200); + const errorMaxLength = Math.max(Number(ctxt?.providerContext?.errorMaxLength ?? 160), 0); + + const qb = await this.mqMessageRepository.createSecurityRuleAwareQueryBuilder("mqMessage"); + applyMqDashboardFilters(qb, ctxt.variables ?? {}, { ignoreStage: true, ensureQueueJoin: true }); + qb.andWhere("mqMessage.stage = :failedStage", { failedStage: "failed" }); + + const rows = await qb + .select([ + "mqMessage.id AS id", + "mqMessage.messageId AS messageId", + "mqMessageQueue.name AS queueName", + "mqMessage.stage AS stage", + "mqMessage.retryCount AS retryCount", + "mqMessage.elapsedMillis AS elapsedMillis", + "mqMessage.startedAt AS startedAt", + "mqMessage.finishedAt AS finishedAt", + "mqMessage.error AS error", + "mqMessage.createdAt AS createdAt", + ]) + .orderBy("mqMessage.createdAt", "DESC") + .take(limit) + .getRawMany>(); + + const columns = [ + "id", + "messageId", + "queueName", + "stage", + "retryCount", + "elapsedMillis", + "startedAt", + "finishedAt", + "error", + "createdAt", + ]; + + const records = rows.map((row) => ({ + id: row.id ?? null, + messageId: row.messageId ?? row.messageid ?? null, + queueName: row.queueName ?? row.queuename ?? null, + stage: row.stage ?? null, + retryCount: row.retryCount ?? row.retrycount ?? null, + elapsedMillis: row.elapsedMillis ?? row.elapsedmillis ?? null, + startedAt: row.startedAt ?? row.startedat ?? null, + finishedAt: row.finishedAt ?? row.finishedat ?? null, + error: truncateString(row.error ?? null, errorMaxLength), + createdAt: row.createdAt ?? row.createdat ?? null, + })); + + return { + meta: { + providerName: this.name(), + generatedAt: new Date().toISOString(), + widgetName: ctxt.widgetName, + durationMs: 0, + }, + data: { + columns, + records, + }, + }; + } +} + +function truncateString(value: any, maxLength: number): any { + if (!value || typeof value !== 'string') return value; + if (maxLength <= 0 || value.length <= maxLength) return value; + return `${value.slice(0, maxLength)}...`; +} diff --git a/src/services/dashboard-providers/mq-dashboard-stage-distribution-provider.service.ts b/src/services/dashboard-providers/mq-dashboard-stage-distribution-provider.service.ts new file mode 100644 index 00000000..c945ae40 --- /dev/null +++ b/src/services/dashboard-providers/mq-dashboard-stage-distribution-provider.service.ts @@ -0,0 +1,49 @@ +import { Injectable } from "@nestjs/common"; +import { DashboardWidgetDataProvider } from "src/decorators/dashboard-widget-data-provider.decorator"; +import { MqMessageRepository } from "src/repository/mq-message.repository"; +import { IDashboardWidgetDataProvider, IDashboardWidgetDataProviderContext, IDashboardWidgetDataResponseEnvelope } from "src/interfaces"; +import { applyMqDashboardFilters, toNumber } from "src/services/dashboard-providers/mq-dashboard-provider-utils"; + +@DashboardWidgetDataProvider() +@Injectable() +export class MqDashboardStageDistributionProvider implements IDashboardWidgetDataProvider { + constructor( + private readonly mqMessageRepository: MqMessageRepository, + ) { } + + name(): string { + return "MqDashboardStageDistributionProvider"; + } + + help(): string { + return "Returns grouped count by stage after applying dashboard variables."; + } + + async getData( + widgetDefinition: Record, + ctxt: IDashboardWidgetDataProviderContext, + ): Promise> { + const qb = await this.mqMessageRepository.createSecurityRuleAwareQueryBuilder("mqMessage"); + applyMqDashboardFilters(qb, ctxt.variables ?? {}); + + const rows = await qb + .select("mqMessage.stage", "name") + .addSelect("COUNT(mqMessage.id)", "value") + .groupBy("mqMessage.stage") + .orderBy("value", "DESC") + .getRawMany<{ name: string; value: string | number }>(); + + return { + meta: { + providerName: this.name(), + generatedAt: new Date().toISOString(), + widgetName: ctxt.widgetName, + durationMs: 0, + }, + data: { + items: rows.map((r) => ({ name: r.name, value: toNumber(r.value) })), + }, + }; + } +} + diff --git a/src/services/dashboard-providers/mq-dashboard-succeeded-messages-kpi-provider.service.ts b/src/services/dashboard-providers/mq-dashboard-succeeded-messages-kpi-provider.service.ts new file mode 100644 index 00000000..cbc84beb --- /dev/null +++ b/src/services/dashboard-providers/mq-dashboard-succeeded-messages-kpi-provider.service.ts @@ -0,0 +1,45 @@ +import { Injectable } from "@nestjs/common"; +import { DashboardWidgetDataProvider } from "src/decorators/dashboard-widget-data-provider.decorator"; +import { MqMessageRepository } from "src/repository/mq-message.repository"; +import { IDashboardWidgetDataProvider, IDashboardWidgetDataProviderContext, IDashboardWidgetDataResponseEnvelope } from "src/interfaces"; +import { applyMqDashboardFilters } from "src/services/dashboard-providers/mq-dashboard-provider-utils"; + +@DashboardWidgetDataProvider() +@Injectable() +export class MqDashboardSucceededMessagesKpiProvider implements IDashboardWidgetDataProvider { + constructor( + private readonly mqMessageRepository: MqMessageRepository, + ) { } + + name(): string { + return "MqDashboardSucceededMessagesKpiProvider"; + } + + help(): string { + return "Returns succeeded mq message count after applying dashboard variables."; + } + + async getData( + widgetDefinition: Record, + ctxt: IDashboardWidgetDataProviderContext, + ): Promise> { + const qb = await this.mqMessageRepository.createSecurityRuleAwareQueryBuilder("mqMessage"); + applyMqDashboardFilters(qb, ctxt.variables ?? {}, { ignoreStage: true }); + qb.andWhere("mqMessage.stage = :stage", { stage: "succeeded" }); + + const value = await qb.getCount(); + return { + meta: { + providerName: this.name(), + generatedAt: new Date().toISOString(), + widgetName: ctxt.widgetName, + durationMs: 0, + }, + data: { + value, + label: widgetDefinition?.name ?? "Succeeded", + }, + }; + } +} + diff --git a/src/services/dashboard-providers/mq-dashboard-success-rate-kpi-provider.service.ts b/src/services/dashboard-providers/mq-dashboard-success-rate-kpi-provider.service.ts new file mode 100644 index 00000000..c6206034 --- /dev/null +++ b/src/services/dashboard-providers/mq-dashboard-success-rate-kpi-provider.service.ts @@ -0,0 +1,55 @@ +import { Injectable } from "@nestjs/common"; +import { DashboardWidgetDataProvider } from "src/decorators/dashboard-widget-data-provider.decorator"; +import { MqMessageRepository } from "src/repository/mq-message.repository"; +import { IDashboardWidgetDataProvider, IDashboardWidgetDataProviderContext, IDashboardWidgetDataResponseEnvelope } from "src/interfaces"; +import { applyMqDashboardFilters } from "src/services/dashboard-providers/mq-dashboard-provider-utils"; + +@DashboardWidgetDataProvider() +@Injectable() +export class MqDashboardSuccessRateKpiProvider implements IDashboardWidgetDataProvider { + constructor( + private readonly mqMessageRepository: MqMessageRepository, + ) { } + + name(): string { + return "MqDashboardSuccessRateKpiProvider"; + } + + help(): string { + return "Returns success rate percentage after applying dashboard variables."; + } + + async getData( + widgetDefinition: Record, + ctxt: IDashboardWidgetDataProviderContext, + ): Promise> { + const totalQb = await this.mqMessageRepository.createSecurityRuleAwareQueryBuilder("mqMessage"); + applyMqDashboardFilters(totalQb, ctxt.variables ?? {}, { ignoreStage: true }); + + const successQb = await this.mqMessageRepository.createSecurityRuleAwareQueryBuilder("mqMessage"); + applyMqDashboardFilters(successQb, ctxt.variables ?? {}, { ignoreStage: true }); + successQb.andWhere("mqMessage.stage = :stage", { stage: "succeeded" }); + + const [total, succeeded] = await Promise.all([totalQb.getCount(), successQb.getCount()]); + const value = total > 0 ? Number(((succeeded / total) * 100).toFixed(2)) : 0; + + return { + meta: { + providerName: this.name(), + generatedAt: new Date().toISOString(), + widgetName: ctxt.widgetName, + durationMs: 0, + }, + data: { + value, + label: widgetDefinition?.name ?? "Success Rate", + numerator: succeeded, + denominator: total, + }, + uiHints: { + suffix: "%", + }, + }; + } +} + diff --git a/src/services/dashboard-providers/mq-dashboard-total-messages-kpi-provider.service.ts b/src/services/dashboard-providers/mq-dashboard-total-messages-kpi-provider.service.ts new file mode 100644 index 00000000..e986e0d0 --- /dev/null +++ b/src/services/dashboard-providers/mq-dashboard-total-messages-kpi-provider.service.ts @@ -0,0 +1,44 @@ +import { Injectable } from "@nestjs/common"; +import { DashboardWidgetDataProvider } from "src/decorators/dashboard-widget-data-provider.decorator"; +import { MqMessageRepository } from "src/repository/mq-message.repository"; +import { IDashboardWidgetDataProvider, IDashboardWidgetDataProviderContext, IDashboardWidgetDataResponseEnvelope } from "src/interfaces"; +import { applyMqDashboardFilters } from "src/services/dashboard-providers/mq-dashboard-provider-utils"; + +@DashboardWidgetDataProvider() +@Injectable() +export class MqDashboardTotalMessagesKpiProvider implements IDashboardWidgetDataProvider { + constructor( + private readonly mqMessageRepository: MqMessageRepository, + ) { } + + name(): string { + return "MqDashboardTotalMessagesKpiProvider"; + } + + help(): string { + return "Returns total mq message count after applying dashboard variables."; + } + + async getData( + widgetDefinition: Record, + ctxt: IDashboardWidgetDataProviderContext, + ): Promise> { + const qb = await this.mqMessageRepository.createSecurityRuleAwareQueryBuilder("mqMessage"); + applyMqDashboardFilters(qb, ctxt.variables ?? {}); + + const value = await qb.getCount(); + return { + meta: { + providerName: this.name(), + generatedAt: new Date().toISOString(), + widgetName: ctxt.widgetName, + durationMs: 0, + }, + data: { + value, + label: widgetDefinition?.name ?? "Total Messages", + }, + }; + } +} + diff --git a/src/services/dashboard-question-sql-dataset-config.service.ts b/src/services/dashboard-question-sql-dataset-config.service.ts deleted file mode 100644 index 5698f3a6..00000000 --- a/src/services/dashboard-question-sql-dataset-config.service.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { InjectEntityManager } from '@nestjs/typeorm'; -import { ModuleRef } from "@nestjs/core"; -import { EntityManager } from 'typeorm'; - -import { CRUDService } from 'src/services/crud.service'; - - -import { DashboardQuestionSqlDatasetConfig } from '../entities/dashboard-question-sql-dataset-config.entity'; -import { DashboardQuestionSqlDatasetConfigRepository } from 'src/repository/dashboard-question-sql-dataset-config.repository'; - -@Injectable() -export class DashboardQuestionSqlDatasetConfigService extends CRUDService{ - constructor( - @InjectEntityManager() - readonly entityManager: EntityManager, - // @InjectRepository(DashboardQuestionSqlDatasetConfig, 'default') - // readonly repo: Repository, - readonly repo: DashboardQuestionSqlDatasetConfigRepository, - readonly moduleRef: ModuleRef - - ) { - super(entityManager, repo, 'dashboardQuestionSqlDatasetConfig', 'solid-core', moduleRef); - } -} diff --git a/src/services/dashboard-question.service.ts b/src/services/dashboard-question.service.ts deleted file mode 100644 index a2c23d28..00000000 --- a/src/services/dashboard-question.service.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { BadRequestException, Injectable, Logger, NotImplementedException } from '@nestjs/common'; -import { ModuleRef } from "@nestjs/core"; -import { InjectEntityManager } from '@nestjs/typeorm'; -import { EntityManager } from 'typeorm'; - -import { CRUDService } from 'src/services/crud.service'; - - -import { DashboardVariable } from 'src/entities/dashboard-variable.entity'; -import { SolidRegistry } from 'src/helpers/solid-registry'; -import { DashboardQuestion } from '../entities/dashboard-question.entity'; -import { SqlExpression, SqlExpressionOperator } from './question-data-providers/chartjs-sql-data-provider.service'; -import { DashboardQuestionRepository } from 'src/repository/dashboard-question.repository'; -import { QuestionSqlDataProviderContext } from '../interfaces'; - -enum SOURCE_TYPE { - SQL = 'sql', - PROVIDER = 'provider', -} - -export const CHARTJS_SQL_DATA_PROVIDER_NAME = 'ChartJsSqlDataProvider'; -export const PRIME_REACT_METER_GROUP_SQL_DATA_PROVIDER_NAME = 'PrimeReactMeterGroupSqlDataProvider'; -export const PRIME_REACT_DATATABLE_SQL_DATA_PROVIDER_NAME = 'PrimeReactDatatableSqlDataProvider'; - -export const INBUILT_SQL_DATA_PROVIDERS = [CHARTJS_SQL_DATA_PROVIDER_NAME, PRIME_REACT_METER_GROUP_SQL_DATA_PROVIDER_NAME, PRIME_REACT_DATATABLE_SQL_DATA_PROVIDER_NAME]; - -@Injectable() -export class DashboardQuestionService extends CRUDService { - private readonly logger = new Logger(this.constructor.name); - constructor( - @InjectEntityManager() - readonly entityManager: EntityManager, - // @InjectRepository(DashboardQuestion, 'default') - // readonly repo: Repository, - readonly repo: DashboardQuestionRepository, - readonly moduleRef: ModuleRef, - readonly solidRegistry: SolidRegistry, // Assuming solidRegistry is injected for data providers - ) { - super(entityManager, repo, 'dashboardQuestion', 'solid-core', moduleRef); - } - - // Get the data for a specific question - async getData(id: number, inputExpressions: SqlExpression[] = [], isPreview = false): Promise { - // Load the question - const question = await this.loadQuestion(id); - if (!question) { - throw new BadRequestException(`Question with id ${id} not found`); - } - - // Get the dashbbard variables from the question - const dashboardVariables = question.dashboard?.dashboardVariables || []; - const expressions: SqlExpression[] = this.getExpressions(isPreview, dashboardVariables, inputExpressions); - - // Try to resolve the dataProvider based on a combination of sourceType and visualisedAs - let dataProvider = null; - let context = {}; - - // Decide which data provider to use based on the question visualisation type if sourceType is SQL. - if (question.sourceType === SOURCE_TYPE.SQL && ['bar', 'pie', 'line', 'donut'].includes(question.visualisedAs)) { - dataProvider = this.solidRegistry.getDashboardQuestionDataProviderInstance(CHARTJS_SQL_DATA_PROVIDER_NAME); - context = { - expressions, - } as QuestionSqlDataProviderContext; - } - if (question.sourceType === SOURCE_TYPE.SQL && ['prime-meter-group'].includes(question.visualisedAs)) { - dataProvider = this.solidRegistry.getDashboardQuestionDataProviderInstance(PRIME_REACT_METER_GROUP_SQL_DATA_PROVIDER_NAME); - context = { - expressions, - } as QuestionSqlDataProviderContext; - } - if (question.sourceType === SOURCE_TYPE.SQL && ['prime-datatable'].includes(question.visualisedAs)) { - dataProvider = this.solidRegistry.getDashboardQuestionDataProviderInstance(PRIME_REACT_DATATABLE_SQL_DATA_PROVIDER_NAME); - context = { - expressions, - } as QuestionSqlDataProviderContext; - } - - // If a custom provider is specified, use that one instead - if (question.sourceType === SOURCE_TYPE.PROVIDER) { - dataProvider = this.solidRegistry.getDashboardQuestionDataProviderInstance(question.providerName); - } - - if (!dataProvider) { - throw new NotImplementedException(`Invalid data source type ${question.sourceType}`); - } - - return await dataProvider.getData(question, context); - - } - - private getExpressions(isPreview: boolean, dashboardVariables: DashboardVariable[], inputExpressions: SqlExpression[]) { - const expressions: SqlExpression[] = []; - - if (isPreview) { - // Convert the dashboard variables into objects of interface type SqlExpression using the default value, default operator and the variable name - const expr: SqlExpression[] = dashboardVariables.map(variable => { - return { - variableName: variable.variableName, - operator: variable.defaultOperator as SqlExpressionOperator, // Assuming defaultOperator is a valid SqlExpressionOperator - value: JSON.parse(variable.defaultValue || '[]'), // Assuming defaultValue is a string or can be converted to a string array - }; - }); - expressions.push(...expr); - } - else { - // Loop through the dashboard variables and see if there is a matching input expression - // If there is, use that expression instead of the default value - for (const variable of dashboardVariables) { - const matchingInputExpression = inputExpressions.find(expr => expr.variableName === variable.variableName); - if (matchingInputExpression) { - expressions.push(matchingInputExpression); - } - else { - expressions.push({ - variableName: variable.variableName, - operator: variable.defaultOperator as SqlExpressionOperator, - value: JSON.parse(variable.defaultValue || '[]'), - }); - } - } - expressions.push(...expressions); - } - - // Remove duplicate expressions based on variableName in the expressions array - const deduplicatedExpressions: SqlExpression[] = []; - const variableNames = new Set(); - for (const expr of expressions) { - if (!variableNames.has(expr.variableName)) { - deduplicatedExpressions.push(expr); - variableNames.add(expr.variableName); - } - } - - return deduplicatedExpressions; - } - - private async loadQuestion(id: number) { - const repo = this.entityManager.getRepository(DashboardQuestion); - - // Load the dashboard record using the field - const question = await repo.findOne({ - where: { - id, - }, - relations: ['questionSqlDatasetConfigs', 'dashboard', 'dashboard.dashboardVariables'], - }); - return question; - } - -} diff --git a/src/services/dashboard-runtime.service.ts b/src/services/dashboard-runtime.service.ts new file mode 100644 index 00000000..8303cf6e --- /dev/null +++ b/src/services/dashboard-runtime.service.ts @@ -0,0 +1,488 @@ +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import qs from 'qs'; +import { ERROR_MESSAGES } from 'src/constants/error-messages'; +import { DashboardVariableOptionsQueryDto } from 'src/dtos/dashboard-variable-options-query.dto'; +import { DashboardBatchDataRequestDto, DashboardSaveLayoutDto, DashboardWidgetDataRequestDto } from 'src/dtos/dashboard-widget-data-request.dto'; +import { ModuleMetadata } from 'src/entities/module-metadata.entity'; +import { ModuleMetadataHelperService } from 'src/helpers/module-metadata-helper.service'; +import { SolidRegistry } from 'src/helpers/solid-registry'; +import { ActiveUserData } from 'src/interfaces/active-user-data.interface'; +import { IDashboardWidgetDataProviderContext } from 'src/interfaces'; +import { DashboardUserLayoutService } from './dashboard-user-layout.service'; + +@Injectable() +export class DashboardRuntimeService { + private readonly logger = new Logger(DashboardRuntimeService.name); + + constructor( + private readonly moduleMetadataHelperService: ModuleMetadataHelperService, + private readonly solidRegistry: SolidRegistry, + private readonly dashboardUserLayoutService: DashboardUserLayoutService, + ) { } + + async getDashboardDefinition(moduleName: string, dashboardName: string): Promise { + const moduleMetadata = await this.getModuleMetadata(moduleName); + const dashboards = this.getDashboards(moduleMetadata); + const dashboard = dashboards.find((d) => d?.name === dashboardName || d?.routeName === dashboardName); + + if (!dashboard) { + throw new NotFoundException(`Dashboard ${dashboardName} not found for module ${moduleName}.`); + } + + return dashboard; + } + + async getWidgetData( + moduleName: string, + dashboardName: string, + widgetName: string, + request: DashboardWidgetDataRequestDto = {}, + activeUser?: ActiveUserData, + ): Promise { + const dashboardDefinition = await this.getDashboardDefinition(moduleName, dashboardName); + return this.getWidgetDataFromDefinition(dashboardDefinition, moduleName, dashboardName, widgetName, request, activeUser); + } + + async getDashboardData( + moduleName: string, + dashboardName: string, + request: DashboardBatchDataRequestDto = {}, + activeUser?: ActiveUserData, + ): Promise { + const dashboardDefinition = await this.getDashboardDefinition(moduleName, dashboardName); + const allWidgets = Array.isArray(dashboardDefinition.widgets) ? dashboardDefinition.widgets : []; + + const targetWidgets = request.widgetNames?.length + ? allWidgets.filter((widget) => request.widgetNames.includes(widget.id) || request.widgetNames.includes(widget.name)) + : allWidgets; + + const widgets = await Promise.all( + targetWidgets.map((widget) => this.getWidgetDataFromDefinition( + dashboardDefinition, + moduleName, + dashboardName, + widget.id ?? widget.name, + request, + activeUser + )) + ); + + return { + moduleName, + dashboardName: dashboardDefinition.name, + variables: request.variables ?? {}, + widgets, + }; + } + + async getVariableOptions( + moduleName: string, + dashboardName: string, + variableName: string, + query: DashboardVariableOptionsQueryDto, + ): Promise { + const dashboardDefinition = await this.getDashboardDefinition(moduleName, dashboardName); + const variable = (dashboardDefinition.variables ?? []).find((v) => v.name === variableName); + + if (!variable) { + throw new NotFoundException(`Variable ${variableName} not found in dashboard ${dashboardName}.`); + } + + if (variable.type === 'selectionStatic') { + const values = Array.isArray(variable.selectionStaticValues) ? variable.selectionStaticValues : []; + return values.map((entry: string) => { + const [value, label] = `${entry}`.split(':'); + return { label: label ?? value, value }; + }); + } + + if (variable.type !== 'selectionDynamic') { + return []; + } + + const providerName = variable?.selectionConfig?.providerName; + if (!providerName) { + throw new NotFoundException(`Variable ${variableName} does not define a dynamic selection provider.`); + } + + const selectionProvider = this.solidRegistry.getSelectionProviderInstance(providerName); + if (!selectionProvider) { + throw new NotFoundException(ERROR_MESSAGES.PROVIDER_NOT_FOUND(providerName)); + } + + const providerContext = { + ...(variable?.selectionConfig?.providerContext ?? {}), + limit: query?.limit ?? 10, + offset: query?.offset ?? 0, + formValues: this.parseFormValues(query?.formValues), + }; + + if (query.optionValue) { + return selectionProvider.value(query.optionValue, providerContext as any); + } + + return selectionProvider.values(query?.query ?? '', providerContext as any); + } + + async getLayout(moduleName: string, dashboardName: string, activeUser?: ActiveUserData): Promise { + const dashboardDefinition = await this.getDashboardDefinition(moduleName, dashboardName); + const defaultLayout = dashboardDefinition.defaultLayout ?? {}; + const userId = activeUser?.sub ?? null; + let userLayoutRecord = null; + try { + userLayoutRecord = userId + ? await this.dashboardUserLayoutService.repo.findOne({ + where: { + user: { id: userId }, + module: { name: moduleName }, + dashboardName: dashboardDefinition.name, + } as any, + }) + : null; + } catch (err: any) { + const isEntityMetadataMissing = `${err?.message ?? ''}`.includes('No metadata for "DashboardUserLayout" was found'); + if (!isEntityMetadataMissing) throw err; + this.logger.warn('DashboardUserLayout entity metadata is not registered yet. Returning default layout fallback.'); + } + const userLayout = this.parseLayoutJson(userLayoutRecord?.layoutJson); + const effectiveLayout = this.mergeLayouts(defaultLayout, userLayout); + + return { + moduleName, + dashboardName: dashboardDefinition.name, + userId, + defaultLayout, + userLayout, + effectiveLayout, + persisted: !!userLayoutRecord, + }; + } + + async saveLayout( + moduleName: string, + dashboardName: string, + dto: DashboardSaveLayoutDto, + activeUser?: ActiveUserData, + ): Promise { + const dashboardDefinition = await this.getDashboardDefinition(moduleName, dashboardName); + const resolvedDashboardName = dashboardDefinition?.name ?? dashboardName; + const resolvedLayoutJson = this.resolveLayoutPayload(dto); + const userId = activeUser?.sub; + if (!userId) { + return { + moduleName, + dashboardName: resolvedDashboardName, + userId: null, + persisted: false, + message: 'Unable to persist layout without active user context.', + layoutJson: resolvedLayoutJson, + }; + } + const layoutJsonAsString = typeof resolvedLayoutJson === 'string' + ? resolvedLayoutJson + : JSON.stringify(resolvedLayoutJson ?? {}); + let savedLayout: any = null; + try { + const layoutRepo = this.dashboardUserLayoutService.repo; + const existing = await layoutRepo.findOne({ + where: { + user: { id: userId }, + module: { name: moduleName }, + dashboardName: resolvedDashboardName, + } as any, + }); + + const moduleRepo = this.dashboardUserLayoutService.entityManager.getRepository(ModuleMetadata); + const moduleEntity = await moduleRepo.findOne({ where: { name: moduleName } as any }); + + if (!moduleEntity) { + throw new NotFoundException(`Module ${moduleName} not found.`); + } + + const toSave = layoutRepo.create({ + ...(existing ? { id: existing.id } : {}), + user: { id: userId } as any, + module: { id: moduleEntity.id } as any, + dashboardName: resolvedDashboardName, + layoutJson: layoutJsonAsString, + version: (existing?.version ?? 0) + 1, + } as any); + + savedLayout = await layoutRepo.save(toSave); + } catch (err: any) { + const isEntityMetadataMissing = `${err?.message ?? ''}`.includes('No metadata for "DashboardUserLayout" was found'); + if (!isEntityMetadataMissing) throw err; + this.logger.warn('DashboardUserLayout entity metadata is not registered yet. Returning non-persistent save fallback.'); + return { + moduleName, + dashboardName: resolvedDashboardName, + userId, + persisted: false, + message: 'Layout model generated but entity is not registered in TypeORM yet. Please add DashboardUserLayout to module TypeOrm entities and restart.', + layoutJson: resolvedLayoutJson, + }; + } + + return { + moduleName, + dashboardName: resolvedDashboardName, + userId, + persisted: true, + message: 'Layout saved successfully.', + layoutJson: this.parseLayoutJson(savedLayout.layoutJson), + }; + } + + private async getModuleMetadata(moduleName: string): Promise { + const configPath = await this.moduleMetadataHelperService.getModuleMetadataFilePath(moduleName); + const moduleMetadata = await this.moduleMetadataHelperService.getModuleMetadataConfiguration(configPath); + + if (!moduleMetadata) { + throw new NotFoundException(ERROR_MESSAGES.MODULE_NOT_FOUND(moduleName)); + } + + return moduleMetadata; + } + + private getDashboards(moduleMetadata: any): any[] { + return Array.isArray(moduleMetadata?.dashboards) ? moduleMetadata.dashboards : []; + } + + private findWidgetDefinition(dashboardDefinition: any, widgetName: string): any { + const widgets = Array.isArray(dashboardDefinition.widgets) ? dashboardDefinition.widgets : []; + const widgetDefinition = widgets.find((widget) => widget?.id === widgetName || widget?.name === widgetName); + + if (!widgetDefinition) { + throw new NotFoundException(`Widget ${widgetName} not found in dashboard ${dashboardDefinition?.name ?? ''}.`); + } + + return widgetDefinition; + } + + private async getWidgetDataFromDefinition( + dashboardDefinition: any, + moduleName: string, + dashboardName: string, + widgetName: string, + request: DashboardWidgetDataRequestDto = {}, + activeUser?: ActiveUserData, + ): Promise { + const widgetDefinition = this.findWidgetDefinition(dashboardDefinition, widgetName); + const resolvedWidgetName = widgetDefinition.id ?? widgetDefinition.name ?? widgetName; + const providerName = widgetDefinition.dataProvider; + + if (!this.hasDashboardWidgetPermission(activeUser, dashboardDefinition?.name ?? dashboardName, resolvedWidgetName)) { + return this.buildUnauthorizedWidgetResponse( + dashboardDefinition?.name ?? dashboardName, + resolvedWidgetName, + providerName, + ); + } + + if (!providerName) { + throw new NotFoundException(`Widget ${widgetName} is missing dataProvider definition.`); + } + + const provider = this.solidRegistry.getDashboardWidgetDataProviderInstance(providerName); + if (!provider) { + throw new NotFoundException(ERROR_MESSAGES.PROVIDER_NOT_FOUND(providerName)); + } + + const providerContext = { + ...(widgetDefinition.providerContext ?? {}), + ...(request.providerContext ?? {}), + }; + + const runtimeContext: IDashboardWidgetDataProviderContext = { + moduleName, + dashboardName, + widgetName: resolvedWidgetName, + variables: request.variables ?? {}, + providerContext, + activeUser, + }; + + const startTime = Date.now(); + const providerResponse = await provider.getData(widgetDefinition, runtimeContext); + const durationMs = Date.now() - startTime; + + if (providerResponse && typeof providerResponse === 'object' && providerResponse.data !== undefined) { + const nextMeta = { + ...(providerResponse.meta ?? {}), + providerName, + widgetName: runtimeContext.widgetName, + durationMs: providerResponse?.meta?.durationMs ?? durationMs, + generatedAt: providerResponse?.meta?.generatedAt ?? new Date().toISOString(), + }; + + return { + ...providerResponse, + meta: nextMeta, + }; + } + + this.logger.debug(`Widget provider ${providerName} returned a non-envelope payload for ${runtimeContext.widgetName}. Normalizing response.`); + return { + meta: { + providerName, + widgetName: runtimeContext.widgetName, + durationMs, + generatedAt: new Date().toISOString(), + }, + data: providerResponse, + }; + } + + private parseFormValues(formValues?: Record | string): Record { + if (!formValues) { + return {}; + } + if (typeof formValues === 'object') { + return formValues; + } + + try { + const parsedFromQueryString = qs.parse(formValues); + if (parsedFromQueryString && typeof parsedFromQueryString === 'object') { + if (parsedFromQueryString['formValues'] && typeof parsedFromQueryString['formValues'] === 'object') { + return parsedFromQueryString['formValues'] as Record; + } + return parsedFromQueryString as Record; + } + } catch (err) { + // ignore parse errors and try JSON + } + + try { + return JSON.parse(formValues); + } catch (err) { + return {}; + } + } + + private hasDashboardWidgetPermission( + activeUser: ActiveUserData | undefined, + dashboardName: string, + widgetName: string, + ): boolean { + const permissions = Array.isArray(activeUser?.permissions) ? activeUser.permissions : []; + if (!dashboardName || !widgetName || permissions.length === 0) { + return false; + } + + return permissions.some((permission) => this.matchesDashboardWidgetPermission(permission, dashboardName, widgetName)); + } + + private matchesDashboardWidgetPermission(permission: string, dashboardName: string, widgetName: string): boolean { + const match = /^dashboard:([^:]+):(.+)$/.exec(`${permission ?? ''}`.trim()); + if (!match) { + return false; + } + + const [, permissionDashboardName, widgetPattern] = match; + if (permissionDashboardName !== dashboardName || !widgetPattern) { + return false; + } + + if (widgetPattern === '*') { + return true; + } + + if (widgetPattern === widgetName) { + return true; + } + + try { + return new RegExp(widgetPattern).test(widgetName); + } catch { + return false; + } + } + + private buildUnauthorizedWidgetResponse( + dashboardName: string, + widgetName: string, + providerName?: string, + ): any { + return { + meta: { + providerName: providerName ?? null, + widgetName, + durationMs: 0, + generatedAt: new Date().toISOString(), + unauthorized: true, + permissionExpression: `dashboard:${dashboardName}:${widgetName}`, + }, + data: null, + uiHints: { + state: 'unauthorized', + message: 'Unauthorized', + }, + }; + } + + private parseLayoutJson(layoutJson: any): any { + if (layoutJson === null || layoutJson === undefined) return null; + if (typeof layoutJson !== 'string') return layoutJson; + try { + return JSON.parse(layoutJson); + } catch { + return layoutJson; + } + } + + private resolveLayoutPayload(dto: DashboardSaveLayoutDto | Record | null | undefined): any { + if (!dto) return {}; + if (dto.layoutJson !== undefined) return dto.layoutJson; + if ((dto as any).layout !== undefined) return (dto as any).layout; + return dto; + } + + private mergeLayouts(defaultLayout: any, userLayout: any): any { + if (!defaultLayout && !userLayout) return {}; + if (!defaultLayout) return userLayout ?? {}; + if (!userLayout) return defaultLayout ?? {}; + + const defaultItems = Array.isArray(defaultLayout?.items) ? defaultLayout.items : []; + const userItems = Array.isArray(userLayout?.items) ? userLayout.items : []; + + if (!defaultItems.length) { + return { + ...defaultLayout, + ...userLayout, + items: userItems, + }; + } + + const userByWidget = new Map(); + userItems.forEach((item: any) => { + const key = item?.widgetId ?? item?.id; + if (key) userByWidget.set(key, item); + }); + + const mergedDefaultItems = defaultItems.map((baseItem: any) => { + const key = baseItem?.widgetId ?? baseItem?.id; + if (!key) return baseItem; + const override = userByWidget.get(key); + return override ? { ...baseItem, ...override, widgetId: key } : baseItem; + }); + + const defaultKeys = new Set( + defaultItems + .map((item: any) => item?.widgetId ?? item?.id) + .filter((value: any) => !!value) + ); + + const userOnlyItems = userItems.filter((item: any) => { + const key = item?.widgetId ?? item?.id; + return !!key && !defaultKeys.has(key); + }); + + return { + ...defaultLayout, + ...userLayout, + columns: userLayout?.columns ?? defaultLayout?.columns ?? 12, + items: [...mergedDefaultItems, ...userOnlyItems], + }; + } +} diff --git a/src/services/dashboard-selection-providers/dashboard-variable-sql-dynamic-provider.service.ts b/src/services/dashboard-selection-providers/dashboard-variable-sql-dynamic-provider.service.ts deleted file mode 100644 index 85d60a27..00000000 --- a/src/services/dashboard-selection-providers/dashboard-variable-sql-dynamic-provider.service.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { Injectable } from "@nestjs/common"; -import { IDashboardVariableSelectionProvider, ISelectionProviderContext, ISelectionProviderValues } from "../../interfaces"; -// import localeCodes from 'locale-codes'; -import { EntityManager } from "typeorm"; -import { DashboardVariableSelectionProvider } from "src/decorators/dashboard-selection-provider.decorator"; - -@DashboardVariableSelectionProvider() -@Injectable() -export class DashboardVariableSQLDynamicProvider implements IDashboardVariableSelectionProvider { - - constructor(private readonly entityManager: EntityManager) { - } - - help(): string { - return "# Get the dashboard variable after executing the SQL query configured for the dashboard variable.\n"; - } - - name(): string { - return 'DashboardVariableSQLDynamicProvider'; - } - - async value(optionValue: string, ctxt: ISelectionProviderContext): Promise { - throw new Error("DashboardVariableSQLDynamicProvider does not support value method. Use values method instead."); - } - - async values(query: string, ctxt: ISelectionProviderContext): Promise { - const { sql, limit, offset } = ctxt as unknown as { sql: string, limit?: number, offset?: number }; - if (!sql) { - throw new Error("DashboardVariableSQLDynamicProvider requires a SQL query to be provided in the context."); - } - - // Here you would execute the SQL query against your database - // For demonstration, let's assume we have a mock database function that executes the SQL query - const results = await this.entityManager.query(this.appendLimitOffset(sql), [limit, offset]); - - // Transform the results into the expected format - return results.map((result: any) => { - const transformedResult: ISelectionProviderValues = { - value: result.value, // Assuming the result has a 'value' field - label: result.label, // Assuming the result has a 'label' field - // Add any other fields you need to transform here - }; - return transformedResult; - }); - } - - appendLimitOffset(sql: string): string { - // Strip trailing semicolon if present - const trimmedSql = sql.trim().replace(/;$/, ''); - - // Append the LIMIT/OFFSET - const finalSql = `${trimmedSql} LIMIT $1 OFFSET $2`; // FIXME This works with PostgreSQL. for mysql use ?. For this we will need to identify the datasource using the model for the particular dashboard variable - - return finalSql; - } -} \ No newline at end of file diff --git a/src/services/dashboard-selection-providers/dashboard-variable-test-dynamic-provider.service.ts b/src/services/dashboard-selection-providers/dashboard-variable-test-dynamic-provider.service.ts deleted file mode 100644 index b9df0d4d..00000000 --- a/src/services/dashboard-selection-providers/dashboard-variable-test-dynamic-provider.service.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { Injectable } from "@nestjs/common"; -import { IDashboardVariableSelectionProvider, ISelectionProviderContext, ISelectionProviderValues } from "../../interfaces"; -// import localeCodes from 'locale-codes'; -import { EntityManager } from "typeorm"; -import { DashboardVariableSelectionProvider } from "src/decorators/dashboard-selection-provider.decorator"; - -@DashboardVariableSelectionProvider() -@Injectable() -export class DasbhoardVariableTestDynamicProvider implements IDashboardVariableSelectionProvider { - - constructor(private readonly entityManager: EntityManager) { - } - - help(): string { - return "# Get the dashboard variable values.\n"; - } - - name(): string { - return 'DasbhoardVariableTestDynamicProvider'; - } - - async value(optionValue: string, ctxt: ISelectionProviderContext): Promise { - throw new Error("DasbhoardVariableTestDynamicProvider does not support value method. Use values method instead."); - } - - async values(query: string, ctxt: ISelectionProviderContext): Promise { - // Return some dummy data for testing - const { sql, limit, offset } = ctxt as unknown as { sql: string, limit?: number, offset?: number }; - return [ - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - ]; - } - -} \ No newline at end of file diff --git a/src/services/dashboard-user-layout.service.ts b/src/services/dashboard-user-layout.service.ts new file mode 100644 index 00000000..4744eda3 --- /dev/null +++ b/src/services/dashboard-user-layout.service.ts @@ -0,0 +1,20 @@ +import { Injectable } from '@nestjs/common'; +import { InjectEntityManager } from '@nestjs/typeorm'; +import { ModuleRef } from "@nestjs/core"; +import { EntityManager } from 'typeorm'; +import { CRUDService } from 'src/services/crud.service'; +import { DashboardUserLayout } from '../entities/dashboard-user-layout.entity'; +import { DashboardUserLayoutRepository } from '../repositories/dashboard-user-layout.repository'; + +@Injectable() +export class DashboardUserLayoutService extends CRUDService{ + constructor( + @InjectEntityManager("default") + readonly entityManager: EntityManager, + readonly repo: DashboardUserLayoutRepository, + readonly moduleRef: ModuleRef, + + ) { + super(entityManager, repo, 'dashboardUserLayout', 'solid-core', moduleRef); + } +} \ No newline at end of file diff --git a/src/services/dashboard-variable.service.ts b/src/services/dashboard-variable.service.ts deleted file mode 100644 index 15fca3f0..00000000 --- a/src/services/dashboard-variable.service.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ModuleRef } from "@nestjs/core"; -import { InjectEntityManager } from '@nestjs/typeorm'; -import { EntityManager } from 'typeorm'; - -import { CRUDService } from 'src/services/crud.service'; - - -import { DashboardVariable } from '../entities/dashboard-variable.entity'; -import { DashboardVariableRepository } from 'src/repository/dashboard-variable.repository'; - -@Injectable() -export class DashboardVariableService extends CRUDService { - private readonly logger = new Logger(this.constructor.name); - constructor( - @InjectEntityManager() - readonly entityManager: EntityManager, - // @InjectRepository(DashboardVariable, 'default') - // readonly repo: Repository, - readonly repo: DashboardVariableRepository, - readonly moduleRef: ModuleRef, - ) { - super(entityManager, repo, 'dashboardVariable', 'solid-core', moduleRef); - } - - -} diff --git a/src/services/dashboard.service.ts b/src/services/dashboard.service.ts deleted file mode 100644 index 63a3cd49..00000000 --- a/src/services/dashboard.service.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { Injectable, Logger, NotFoundException } from '@nestjs/common'; -import { ModuleRef } from "@nestjs/core"; -import { InjectEntityManager } from '@nestjs/typeorm'; -import { EntityManager } from 'typeorm'; - -import { CRUDService } from 'src/services/crud.service'; - - -import * as fs from 'fs/promises'; // Use the Promise-based version of fs for async/await -import { SelectionDynamicSourceType } from 'src/dtos/create-dashboard-variable.dto'; -import { DashboardVariableSelectionDynamicQueryDto } from 'src/dtos/dashboard-variable-selection-dynamic-query.dto'; -import { DashboardVariable } from 'src/entities/dashboard-variable.entity'; -import { ModuleMetadataHelperService } from 'src/helpers/module-metadata-helper.service'; -import { SolidRegistry } from 'src/helpers/solid-registry'; -import { DashboardMapper } from 'src/mappers/dashboard-mapper'; -import { DashboardRepository } from 'src/repository/dashboard.repository'; -import { Dashboard } from '../entities/dashboard.entity'; -import { CreateDashboardDto } from 'src/dtos/create-dashboard.dto'; - - -export const SQL_DYNAMIC_PROVIDER_NAME = 'DashboardVariableSQLDynamicProvider'; -@Injectable() -export class DashboardService extends CRUDService { - private readonly logger = new Logger(this.constructor.name); - constructor( - @InjectEntityManager() - readonly entityManager: EntityManager, - readonly repo: DashboardRepository, // Assuming you have a DashboardRepository for custom queries - readonly moduleRef: ModuleRef, - readonly solidRegistry: SolidRegistry, // Assuming solidRegistry is injected for selection providers - readonly moduleMetadataHelperService: ModuleMetadataHelperService, - readonly dashboardMapper: DashboardMapper, - ) { - super(entityManager, repo, 'dashboard', 'solid-core', moduleRef); - } - - - async create(createDto: CreateDashboardDto, files: Express.Multer.File[]) { - createDto.name = createDto.name.trim().replace(/\s+/g, '-').toLowerCase(); - return super.create(createDto, files); - } - - async getSelectionDynamicValues(query: DashboardVariableSelectionDynamicQueryDto) { - // Get the dashboard variable repo - const dashboardVariable = await this.loadDashboardVariable(query.variableId); - - // Get the providerName and context for the dashboard variable - const [providerName, context] = this.getProviderNameAndContext(dashboardVariable, query); - - // Get hold of the provider instance from the SolidRegistry - const selectionProviderInstance = this.solidRegistry.getDashboardVariableSelectionProviderInstance(providerName); - if (!selectionProviderInstance) { - throw new NotFoundException(`Field incorrectly configured. No provider with name ${providerName} registered in backend.`); - } - - // 4. Call the provider's getSelectionDynamicValues method - return selectionProviderInstance.values(query.query, context); - } - - - private getProviderNameAndContext(dashboardVariable: DashboardVariable, query: DashboardVariableSelectionDynamicQueryDto): [string, any] { - const sourceType = dashboardVariable.selectionDynamicSourceType; - - // Get the appropriate provide name based on the source type - let providerName: string; - const context = { limit: query.limit, offset: query.offset }; - switch (sourceType) { - case SelectionDynamicSourceType.SQL: - providerName = SQL_DYNAMIC_PROVIDER_NAME; - context['sql'] = dashboardVariable.selectionDynamicSQL; - break; - case SelectionDynamicSourceType.PROVIDER: - providerName = dashboardVariable.selectionDynamicProviderName; - break; - default: - throw new Error(`Unsupported selection dynamic source type: ${sourceType}`); - } - return [providerName, context]; - } - - private async loadDashboardVariable(variableId: number) { - const dashboardVariableRepo = this.entityManager.getRepository(DashboardVariable); - - // Load the dashboard record using the field - const dashboardVariable = await dashboardVariableRepo.findOne({ - where: { - id: variableId, - }, - }); - return dashboardVariable; - } - - async saveDashboardToConfig(entity: Dashboard) { - if (!entity) { - this.logger.debug('No entity found in the DashboardSubscriber saveDashboardToConfig method'); - return; - } - - // Validate dashboard details - const dashboard = entity as Dashboard; - const moduleMetadata = entity.module; - if (!moduleMetadata) { - throw new Error(`Module metadata not found for dashboard id ${entity.id}`); - } - - // Get config file details - const { filePath, metaData } = await this.getConfigFileDetails(moduleMetadata.name); - if (!filePath || !metaData) { - throw new Error(`Configuration details not found for module: ${moduleMetadata.name}`); - } - - // Write the dashboard to the config file - await this.writeToConfig(metaData, dashboard, filePath); - } - - private async getConfigFileDetails(moduleName: string): Promise<{ filePath: string; metaData: any }> { - const filePath = await this.moduleMetadataHelperService.getModuleMetadataFilePath(moduleName); - try { - await fs.access(filePath); - } catch (error) { - throw new Error(`Configuration file not found for module: ${moduleName}`); - } - const metaData = await this.moduleMetadataHelperService.getModuleMetadataConfiguration(filePath); - return { filePath, metaData }; - } - - private async writeToConfig(metaData: any, dashboard: Dashboard, filePath: string) { - if (metaData.dashboards && Array.isArray(metaData.dashboards)) { - const dashboardIndex = metaData.dashboards?.findIndex((dashboardFromFile: { name: string; }) => dashboardFromFile.name === dashboard.name); - const dto = await this.dashboardMapper.toDto(dashboard); - if (dashboardIndex !== -1) { - metaData.dashboards[dashboardIndex] = dto; - } - else { - metaData.dashboards.push(dto); - } - } - else { - const dashboards = []; - const dto = await this.dashboardMapper.toDto(dashboard); - dashboards.push(dto); - metaData.dashboards = dashboards; - } - // Write the updated object back to the file - const updatedContent = JSON.stringify(metaData, null, 2); - await fs.writeFile(filePath, updatedContent); - } -} \ No newline at end of file diff --git a/src/services/database/database-bootstrap.service.ts b/src/services/database/database-bootstrap.service.ts deleted file mode 100644 index 6b5dd3dc..00000000 --- a/src/services/database/database-bootstrap.service.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; -import { DataSource } from 'typeorm'; -import { readdir, readFile } from 'fs/promises'; -import * as path from 'path'; -import { InjectDataSource } from '@nestjs/typeorm'; - -@Injectable() -export class DatabaseBootstrapService implements OnModuleInit { - private readonly logger = new Logger(DatabaseBootstrapService.name); - - constructor( - @InjectDataSource() - private readonly dataSource: DataSource, - ) { } - - async onModuleInit() { - if (!this.dataSource.isInitialized) { - this.logger.warn(`[${this.dataSource.name}] DataSource not initialized. Skipping SQL bootstrap.`); - return; - } - - this.logger.debug(`[${this.dataSource.name}] Bootstrapping stored procedures...`); - - await this.applyAllSqlFiles(); - - this.logger.debug(`[${this.dataSource.name}] SQL bootstrap completed`); - } - - private resolveSqlDirectory(): string { - const datasourceName = this.dataSource.name || 'default'; - const dbType = this.dataSource.options.type; - - const sqlFilePath = path.resolve(__dirname, `../../../sql/${datasourceName}/${dbType}`); - - return sqlFilePath - } - - private async applyAllSqlFiles() { - const sqlDir = this.resolveSqlDirectory(); - - this.logger.debug(`[${this.dataSource.name}] SQL directory: ${sqlDir}`); - - let files: string[]; - try { - files = await readdir(sqlDir); - } catch { - this.logger.warn( - `[${this.dataSource.name}] No SQL directory found. Skipping.`, - ); - return; - } - - const sqlFiles = files - .filter(file => file.endsWith('.sql')) - .sort(); - - if (!sqlFiles.length) { - this.logger.warn( - `[${this.dataSource.name}] No SQL files found`, - ); - return; - } - - for (const file of sqlFiles) { - await this.applySqlFileSafely( - path.join(sqlDir, file), - file, - ); - } - } - - private async applySqlFileSafely( - filePath: string, - fileName: string, - ) { - this.logger.debug(`[${this.dataSource.name}] Applying ${fileName}`); - - try { - const sql = await readFile(filePath, 'utf8'); - await this.dataSource.query(sql); - - this.logger.debug(`[${this.dataSource.name}] Applied ${fileName}`); - } catch (error) { - // DO NOT THROW — continue with next file - this.logger.error( - `[${this.dataSource.name}] Failed ${fileName}`, - error instanceof Error ? error.stack : String(error), - ); - } - } -} diff --git a/src/services/excel.service.ts b/src/services/excel.service.ts index 5b66e011..f357418e 100644 --- a/src/services/excel.service.ts +++ b/src/services/excel.service.ts @@ -93,7 +93,7 @@ export class ExcelService { workbook.commit(); // passThrough.end(); // ✅ Properly close the stream - } catch (error) { + } catch (error: any) { this.logger.error(`❌ Error writing Excel: ${error.message}`); passThrough.destroy(error); // Destroy stream throw error; diff --git a/src/services/export-transaction.service.ts b/src/services/export-transaction.service.ts index 64a6834d..2cf498eb 100644 --- a/src/services/export-transaction.service.ts +++ b/src/services/export-transaction.service.ts @@ -90,7 +90,7 @@ export class ExportTransactionService extends CRUDService { const fileName = this.getFileName(templateName, uuid, templateFormat); const mimeType = this.getMimeType(templateFormat); return { exportStream, fileName, mimeType, exportTransaction }; - } catch (error) { + } catch (error: any) { this.updateExportTransaction(id, ExportStatus.FAILED, error.message); throw error; } @@ -114,7 +114,7 @@ export class ExportTransactionService extends CRUDService { // Store the file using the appropriate storage provider await this.storeExportStream(exportStream, exportTransaction, this.getFileName(templateName, uuid, templateFormat)); this.updateExportTransaction(id, ExportStatus.COMPLETED); - } catch (error) { + } catch (error: any) { this.updateExportTransaction(id, ExportStatus.FAILED, error.message); throw error; diff --git a/src/services/field-metadata.service.ts b/src/services/field-metadata.service.ts index 6a71dfab..668b13c8 100755 --- a/src/services/field-metadata.service.ts +++ b/src/services/field-metadata.service.ts @@ -745,6 +745,8 @@ export class FieldMetadataService implements OnApplicationBootstrap { "type", "ormType", "isSystem", + "regexPattern", + "regexPatternNotMatchingErrorMsg", "defaultValue", "min", "max", @@ -772,8 +774,6 @@ export class FieldMetadataService implements OnApplicationBootstrap { "regexPattern", "regexPatternNotMatchingErrorMsg", "defaultValue", - "min", - "max", "required", "unique", "index", @@ -1256,7 +1256,7 @@ export class FieldMetadataService implements OnApplicationBootstrap { // Write the updated object back to the file const updatedContent = JSON.stringify(metaData, null, 2); await fs.writeFile(filePath, updatedContent); - } catch (error) { + } catch (error: any) { this.logger.error('File creation failed:', error); throw new Error(ERROR_MESSAGES.FILE_WRITE_FAILED); // Trigger rollback } diff --git a/src/services/fixtures.service.ts b/src/services/fixtures.service.ts index 39ae7023..38a98b93 100644 --- a/src/services/fixtures.service.ts +++ b/src/services/fixtures.service.ts @@ -35,7 +35,7 @@ export class FixturesService { // Create the model instance in the database const createdInstance = await modelServiceInstance.create(modelFixture.data); this.logger.log(`Successfully created fixture for model: ${modelFixture.singularName} with ID: ${createdInstance.id}`); - } catch (error) { + } catch (error: any) { this.logger.error(`Error creating fixture for model: ${modelFixture.singularName} - ${error.message}`); } } @@ -59,7 +59,7 @@ export class FixturesService { const deleteCriteria = modelFixture.data; // This should be adjusted based on actual criteria await modelServiceInstance.delete(deleteCriteria); this.logger.log(`Successfully deleted fixture for model: ${modelFixture.singularName}`); - } catch (error) { + } catch (error: any) { this.logger.error(`Error deleting fixture for model: ${modelFixture.singularName} - ${error.message}`); } } diff --git a/src/services/genai/ingest-metadata.service.ts b/src/services/genai/ingest-metadata.service.ts deleted file mode 100644 index 3266f2a8..00000000 --- a/src/services/genai/ingest-metadata.service.ts +++ /dev/null @@ -1,798 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import * as crypto from 'crypto'; -import * as fs from 'fs'; -import * as path from 'path'; - -import { getDynamicModuleNames, getDynamicModuleNamesBasedOnMetadata } from 'src/helpers/module.helper'; -import { CreateModuleMetadataDto } from 'src/dtos/create-module-metadata.dto'; -import { R2RHelperService } from './r2r-helper.service'; -import { r2rClient } from 'r2r-js'; -import { CreateModelMetadataDto } from 'src/dtos/create-model-metadata.dto'; -import { CreateFieldMetadataDto } from 'src/dtos/create-field-metadata.dto'; - -export type FieldIngestionInfo = { - fieldName: string; - fieldChunkId?: string; - fieldHash?: string; -}; - -export type ModelIngestionInfo = { - modelName: string; - modelChunkId?: string; - modelHash?: string; - fields: FieldIngestionInfo[]; -}; - -export type ModuleRAGIngestionInfo = { - moduleName?: string; - collectionId?: string; - - // Full json document is also uploaded so we track references... - documentId?: string; - documentHash?: string; - - // module references - moduleChunkId?: string; - // track a hash of module metadata to skip unchanged - moduleHash?: string; - - // model references... - models: ModelIngestionInfo[]; -}; - -@Injectable() -export class IngestMetadataService { - private readonly logger = new Logger(IngestMetadataService.name); - private ragClient: r2rClient; - - constructor( - private readonly r2rService: R2RHelperService, - ) { } - - // Stable stringify so hashes/ids don't flap - // private stableStringify(obj: any): string { - // return JSON.stringify(obj, Object.keys(obj).sort(), 2); - // } - // private sha256(input: string): string { - // return crypto.createHash('sha256').update(input).digest('hex'); - // } - // private hashSchema(obj: any): string { - // return this.sha256(this.stableStringify(obj)); - // } - private _sha256OfJson(obj: any): string { - const s = JSON.stringify(obj); - return crypto.createHash('sha256').update(s).digest('hex'); - } - // // Small natural-language one-liners for relations - // private relationSig(model: any): string[] { - // const rels: string[] = []; - // for (const f of model.fields ?? []) { - // if (f.relation?.targetModel) { - // rels.push(`${model.singularName}.${f.name} -> ${f.relation.targetModel}(${f.relation.targetField ?? 'id'})`); - // } - // } - // return rels; - // } - - private _oneLineBool(b?: boolean): 'yes' | 'no' { - return b ? 'yes' : 'no'; - } - - private _shortList(arr?: string[] | null, max = 10): string { - if (!Array.isArray(arr) || arr.length === 0) return 'none'; - const a = arr.slice(0, max); - return `${a.join(', ')}${arr.length > max ? ', …' : ''}`; - } - - async ingest() { - // Create a new ragClient... - this.ragClient = await this.r2rService.getClient(); - - // const allModuleMetadataJson = []; - this.logger.debug(`getting dynamics modules`); - // const enabledModules = getDynamicModuleNames(); - const enabledModules = getDynamicModuleNamesBasedOnMetadata(); - this.logger.log(`ingesting metadata`); - - for (let i = 0; i < enabledModules.length; i++) { - const enabledModule = enabledModules[i]; - const fileName = `${enabledModule}-metadata.json`; - const enabledModuleSeedFile = `module-metadata/${enabledModule}/${fileName}`; - const fullPath = path.join(process.cwd(), enabledModuleSeedFile); - const overallMetadata: any = JSON.parse(fs.readFileSync(fullPath, 'utf-8').toString()); - - const moduleMetadata: CreateModuleMetadataDto = overallMetadata.moduleMetadata; - - // Manage all the ingestion info file paths... - const enabledModulIngestionInfoFile = `module-metadata/${enabledModule}/genai/${enabledModule}-ingested-info.json`; - const enabledModulIngestionInfoFullPath = path.join(process.cwd(), enabledModulIngestionInfoFile); - const ingestionInfo: ModuleRAGIngestionInfo = fs.existsSync(enabledModulIngestionInfoFullPath) ? JSON.parse(fs.readFileSync(enabledModulIngestionInfoFullPath, 'utf-8').toString()) : {}; - const enabledModulIngestionInfoDir = path.dirname(enabledModulIngestionInfoFullPath); - if (!fs.existsSync(enabledModulIngestionInfoDir)) { - fs.mkdirSync(enabledModulIngestionInfoDir, { recursive: true }); - } - - ingestionInfo.moduleName = enabledModule - - // Process module metadata first. - this.logger.log(`[Start] Processing module metadata for ${moduleMetadata.name}`) - - // Create or use an existing collection... - const collectionId = await this.resolveRagCollectionForModule(ingestionInfo, enabledModule) - ingestionInfo.collectionId = collectionId; - - // Delete and re-insert a document representing the full json... - // await this.deleteInsertRagDocumentForModuleMetadataJsonFile(ingestionInfo, fullPath, fileName) - - // Delete all metadata chunks. - // const collectionName = `${enabledModule}-rag-collection`; - // const deleteResult = await this.ragClient.documents.deleteByFilter({ - // filters: { - // // AND semantics across fields: - // "$and": [ - // { 'metadata.kind': { $eq: 'solidx-metadata' } }, - // { 'metadata.collectionName': { $eq: collectionName } }, - // { 'title': { $like: '%Ingested Chunks%' } }, - // ] - // }, - // }); - // this.logger.log(`Deleted existing documents for collectionName: ${collectionName}. Got response:`, deleteResult); - - // Delete and re-insert a chunk representing the module. - await this.deleteInsertRagChunkForModule(ingestionInfo, moduleMetadata); - - // Delete and re-insert chunks representing each model. - for (const model of moduleMetadata.models) { - await this.deleteInsertRagChunkForModel(ingestionInfo, enabledModule, model); - - // Disabling this for now... - // for (const field of model.fields) { - // await this.deleteInsertRagChunkForField(ingestionInfo, enabledModule, model.singularName, field); - // } - } - - // TODO: Delete and re-insert chunks representing roles - - // TODO: Delete and re-insert chunks representing menus - - // TODO: Delete and re-insert chunks representing actions - - // TODO: Delete and re-insert chunks representing list views - - // TODO: Delete and re-insert chunks representing kanban views - - // TODO: Delete and re-insert chunks representing form views - - // TODO: Delete and re-insert chunks representing security rules - - // TODO: Delete and re-insert chunks representing scheduled jobs - - // Save ingestion info to disk... - fs.writeFileSync(enabledModulIngestionInfoFullPath, JSON.stringify({ ...ingestionInfo }, null, 2), 'utf8'); - } - } - - private async resolveRagCollectionForModule(ingestionInfo: ModuleRAGIngestionInfo, moduleName: string): Promise { - this.logger.debug(`Resolving RAG collection for module: ${moduleName}`); - - const collectionName = `${moduleName}-rag-collection`; - - // delete and recreate - // if (alwaysRecreate === true) { - const existingCollection = await this.ragClient.collections.retrieveByName({ - name: collectionName - }); - - if (existingCollection) { - try { - await this.ragClient.collections.delete({ id: existingCollection.results.id }); - } catch (e) { - this.logger.warn(`[Warn] Failed deleteByFilter for collection': ${String(e)}`); - } - } - // } - - // let existingCollection: CollectionResponse = null; - // if (ingestionInfo.collectionId) { - // // See if collection already exists... - // const r = await this.ragClient.collections.list({ - // ids: [ - // ingestionInfo.collectionId - // ] - // }); - - // if (r) { - // if (r.results.length === 1) { - // existingCollection = r.results[0]; - // } - // if (r.results.length > 1) { - // // TODO: do something that will print a meaningful error on the console... - // } - // } - // } - - // if (!existingCollection) { - const r = await this.ragClient.collections.create({ - name: collectionName, - description: `Collection created to group all documents, chunks related to module: ${moduleName}` - }); - - // TODO: for some reason if we are unable to create a collection then fail with a visible error message in the console... - return r.results.id; - // } - // return existingCollection.id; - } - - private async deleteInsertRagDocumentForModuleMetadataJsonFile(ingestionInfo: ModuleRAGIngestionInfo, fullPath: string, fileName: string): Promise { - this.logger.debug(`Ingesting file: ${fullPath}`); - - // 1) Compute hash of the entire JSON string (as-is) - const jsonStr = fs.readFileSync(fullPath, 'utf-8'); - const contentHash = this._sha256OfJson(JSON.parse(jsonStr)); - - // 2) Short-circuit if unchanged and we still have a documentId - if (ingestionInfo.documentHash === contentHash && ingestionInfo.documentId) { - this.logger.log(`[Skip] Unchanged: ${fileName} (hash=${contentHash.slice(0, 8)}…)`); - return; - // return ingestionInfo.documentId; - } - - // 3) Delete the previous doc if present - if (ingestionInfo.documentId) { - try { - await this.ragClient.documents.delete({ id: ingestionInfo.documentId }); - } catch (e) { - this.logger.warn( - `[Warn] Failed deleting prior document ${ingestionInfo.documentId}: ${String(e)}` - ); - } - } - - // 4) Create a fresh document; attach the hash into metadata for traceability - const ingestResult = await this.ragClient.documents.create({ - file: { - path: fullPath, - name: fileName - }, - collectionIds: [ingestionInfo.collectionId], - metadata: { - contentHash, - fileName - }, - }); - // console.log("file ingest result:", JSON.stringify(ingestResult, null, 2)); - - const newId = ingestResult?.results?.documentId; - if (!newId) { - throw new Error(`R2R did not return a documentId for ${fileName}`); - } - - // 5) Persist identifiers + hash on our side - ingestionInfo.documentId = newId; - ingestionInfo.documentHash = contentHash; - - this.logger.log(`[OK] Ingested ${fileName} → id=${newId}, hash=${contentHash.slice(0, 8)}…`); - // return newId; - - } - - private async deleteInsertRagChunkForModule(ingestionInfo: ModuleRAGIngestionInfo, moduleMetadata: CreateModuleMetadataDto): Promise { - const moduleName: string = moduleMetadata?.name; - - // Hash the meaningful parts of the module to detect changes and skip re-ingest. - const schemaHash = this._sha256OfJson({ - name: moduleMetadata?.name ?? null, - description: moduleMetadata?.description ?? null, - - // Keep model names + brief shape so module-level hash changes when models change. - models: (moduleMetadata?.models ?? []).map((m: any) => ({ - singularName: m?.singularName ?? null, - description: m?.description ?? null, - - // Include field names to detect field-level changes at module granularity - maybe remove this later? - // fields: Array.isArray(m?.fields) ? m.fields.map((f: any) => f?.name ?? null) : [], - })), - }); - - // Skip unchanged module - if (ingestionInfo.moduleHash === schemaHash && ingestionInfo.moduleChunkId) { - this.logger.log(`[Skip] Module unchanged: ${moduleName}`); - return; - // return ingestionInfo.moduleChunkId; - } - - const models: any[] = moduleMetadata?.models ?? []; - const modelLines = models.map((m) => { - const name = m?.singularName; - const desc = m?.description ? `: ${m.description}` : ''; - return `- ${name}${desc}`; - }); - - const text = `SolidX Module: ${moduleName} -Purpose: ${moduleMetadata?.description ?? 'N/A'} - -Models (${models.length}): -${modelLines.join('\n')} - -Usage: Use this chunk to choose the correct model/field chunks for code generation or metadata edits.`; - - // metadata has to be concise and queryable - const metadata = { - collectionName: `${moduleName}-rag-collection`, - kind: 'solidx-metadata', - type: 'module', - moduleName, - modelCount: models.length, - schemaHash, - models: models.map((m) => m?.singularName).filter(Boolean), - }; - - // Delete previous chunk if we have one - // if (ingestionInfo.moduleChunkId) { - // try { - // await this.ragClient.documents.delete({ id: ingestionInfo.moduleChunkId }); - // } catch (e) { - // this.logger.warn(`[Warn] Failed deleting old module chunk (${ingestionInfo.moduleChunkId}): ${String(e)}`); - // } - // } - - // We changed the approach to delete and re-create using metadata. - // delete any existing module records by metadata (idempotent) - try { - const deleteResult = await this.ragClient.documents.deleteByFilter({ - filters: { - // AND semantics across fields: - "$and": [ - { 'metadata.kind': { $eq: 'solidx-metadata' } }, - { 'metadata.type': { $eq: 'module' } }, - { 'metadata.moduleName': { $eq: moduleName } }, - // (optional but nice to constrain tightly if you always set it) - { 'metadata.collectionName': { $eq: `${moduleName}-rag-collection` } }, - ] - }, - }); - - this.logger.log(`Deleted existing module document for moduleName: ${moduleName}. Got response:`, deleteResult); - - } catch (e) { - this.logger.warn(`[Warn] Failed deleteByFilter for module '${moduleName}': ${String(e)}`); - // Non-fatal: we can still proceed to create; caller’s auth will limit scope anyway - } - - const r = await this.ragClient.documents.create({ - chunks: [text], - // raw_text: text, - metadata: metadata, - collectionIds: [ingestionInfo.collectionId], - }); - - const newId = r?.results?.documentId; - if (!newId) { - throw new Error(`R2R did not return a documentId while creating module chunk for module name ${moduleName}`); - } - - // Update ingestion info for persistence by the caller - ingestionInfo.moduleChunkId = r.results?.documentId; - ingestionInfo.moduleHash = schemaHash; - - this.logger.log(`[OK] Ingested module ${moduleName} → id=${newId}, hash=${schemaHash.slice(0, 8)}…`); - - } - - private async deleteInsertRagChunkForModel(ingestionInfo: ModuleRAGIngestionInfo, moduleName: string, model: CreateModelMetadataDto): Promise { - const modelName: string = model?.singularName; - - // 1) Hash full JSON (as-is) to detect changes and skip re-ingest - const schemaHash = this._sha256OfJson(model); - - // Ensure ingestionInfo.models[] has an entry for this model - const modelsArr = ingestionInfo.models ?? (ingestionInfo.models = []); - let modelEntry = modelsArr.find(m => m.modelName === modelName); - if (!modelEntry) { - modelEntry = { modelName, fields: [] }; - modelsArr.push(modelEntry); - } - - // 2) Short-circuit if unchanged - if (modelEntry.modelHash === schemaHash && modelEntry.modelChunkId) { - this.logger.log(`[Skip] Model unchanged: ${moduleName}.${modelName}`); - return; - // return modelEntry.modelChunkId; - } - - // 3) Build retrieval-friendly text (concise) - const fields: CreateFieldMetadataDto[] = Array.isArray(model?.fields) ? model.fields : []; - const userkey = fields.find((f: CreateFieldMetadataDto) => f?.isUserKey)?.name ?? 'id'; - const uniques = fields.filter((f: CreateFieldMetadataDto) => f?.unique).map((f: any) => f.name); - const required = fields.filter((f: CreateFieldMetadataDto) => f?.required).map((f: any) => f.name); - const rels = fields - .filter((f: CreateFieldMetadataDto) => f.type === 'relation') - .map((f: CreateFieldMetadataDto) => `${modelName}.${f.name} -> ${f.relationCoModelSingularName}.${f.relationCoModelColumnName ?? 'id'}`); - - const fieldSummaryLines = fields.slice(0, 30).map((f: CreateFieldMetadataDto) => { - const bits = [ - `${f.name}:${f.type}`, - f.required ? 'req' : '', - f.unique ? 'unique' : '', - f.isUserKey ? 'userkey' : '', - f.relationCoModelSingularName ? `rel->${f.relationCoModelSingularName}` : '', - ].filter(Boolean).join('|'); - return `- ${bits}`; - }); - - const text = - `SolidX Model: ${modelName} -Module: ${moduleName} -Purpose: ${model?.description ?? 'N/A'} - -Signature: -- Primary: ${userkey} -- Unique: ${uniques.length ? uniques.join(', ') : 'none'} -- Required (${required.length}): ${required.slice(0, 12).join(', ')}${required.length > 12 ? '…' : ''} - -Relations (${rels.length}): -${rels.length ? `- ${rels.join('\n- ')}` : 'None'} - -Fields (${fields.length}) [name:type|flags]: -${fieldSummaryLines.join('\n')} - -Usage: Use this chunk to generate DTOs, subscribers, custom service methods, and CRUD handlers for ${modelName}. - -Full model metadata json: -${JSON.stringify(model)} - -`; - - // 4) Metadata (concise & queryable) - const metadata = { - collectionName: `${moduleName}-rag-collection`, - kind: 'solidx-metadata', - type: 'model', - moduleName, - modelName, - fieldCount: fields.length, - requiredCount: required.length, - relationCount: rels.length, - userkey: userkey, - uniqueFields: uniques, - hasTimestamps: !!fields.find((f: CreateFieldMetadataDto) => ['time', 'date', 'datetime'].includes(f.type)), - schemaHash, - }; - - // 5) Delete previous chunk if present - // if (modelEntry.modelChunkId) { - // try { - // await this.ragClient.documents.delete({ id: modelEntry.modelChunkId }); - // } catch (e) { - // this.logger.warn(`[Warn] Failed deleting old model chunk (${modelEntry.modelChunkId}): ${String(e)}`); - // } - // } - - // We changed the approach to delete and re-create using metadata. - // delete any existing module records by metadata (idempotent) - try { - const deleteResult = await this.ragClient.documents.deleteByFilter({ - filters: { - // AND semantics across fields: - "$and": [ - { 'metadata.kind': { $eq: 'solidx-metadata' } }, - { 'metadata.type': { $eq: 'model' } }, - { 'metadata.moduleName': { $eq: moduleName } }, - { 'metadata.modelName': { $eq: modelName } }, - // (optional but nice to constrain tightly if you always set it) - { 'metadata.collectionName': { $eq: `${moduleName}-rag-collection` } }, - ] - }, - }); - - this.logger.log(`Deleted existing model document for modelName: ${modelName}. Got response:`, deleteResult); - - } catch (e) { - this.logger.warn(`[Warn] Failed deleteByFilter for module '${moduleName}': ${String(e)}`); - // Non-fatal: we can still proceed to create; caller’s auth will limit scope anyway - } - - // 6) Create new document (R2R auto-generates the ID) - const r = await this.ragClient.documents.create({ - chunks: [text], - metadata, - collectionIds: [ingestionInfo.collectionId], - }); - - const newId = r?.results?.documentId; - if (!newId) { - throw new Error(`R2R did not return a documentId while creating model chunk for ${moduleName}.${modelName}`); - } - - // 7) Update ingestionInfo for persistence - modelEntry.modelChunkId = newId; - modelEntry.modelHash = schemaHash; - - this.logger.log(`[OK] Ingested model ${moduleName}.${modelName} → id=${newId}, hash=${schemaHash.slice(0, 8)}…`); - // return newId; - } - - private _buildFieldTextAndMetadata(moduleName: string, modelName: string, f: CreateFieldMetadataDto) { - // Identity - const name = f?.name; - const displayName = f?.displayName ?? name; - const description = f?.description ?? 'N/A'; - - // Types - const type = f?.type; - const ormType = f?.ormType ?? null; - - // Constraints / validation - const required = !!f?.required; - const unique = !!f?.unique; - const index = !!f?.index; - const length = f?.length ?? null; - const min = f?.min ?? null; - const max = f?.max ?? null; - const defaultValue = f?.defaultValue ?? null; - const regex = f?.regexPattern ?? null; - const regexErr = f?.regexPatternNotMatchingErrorMsg ?? null; - - // Relation - const relationType = f?.relationType ?? null; // e.g., many-to-one, many-to-many, one-to-many - const relModule = f?.relationModelModuleName ?? null; - const relModel = f?.relationCoModelSingularName ?? null; - const relField = f?.relationCoModelFieldName ?? 'id'; - // const relOwner = f?.isRelationManyToManyOwner ?? null; - // const relJoinTable = f?.relationJoinTableName ?? null; - // const relCreateInverse = !!f?.relationCreateInverse; - const relCascade = f?.relationCascade ?? null; - const relFixedFilter = f?.relationFieldFixedFilter ?? null; - - // Selection (dropdowns) - const selectionDynProvider = f?.selectionDynamicProvider ?? null; - const selectionDynCtxt = f?.selectionDynamicProviderCtxt ?? null; - const selectionStatic = Array.isArray(f?.selectionStaticValues) ? f.selectionStaticValues : null; - const selectionValueType = f?.selectionValueType ?? null; - const isMultiSelect = !!f?.isMultiSelect; - - // Media (uploads) - const mediaTypes = Array.isArray(f?.mediaTypes) ? f.mediaTypes : null; - const mediaMaxSizeKb = f?.mediaMaxSizeKb ?? null; - const mediaStorageProvider = f?.mediaStorageProviderUserKey ?? null; // likely object/id in your JSON - - // Computed fields - const computedProvider = f?.computedFieldValueProvider ?? null; - const computedProviderCtxt = f?.computedFieldValueProviderCtxt ?? null; - const computedValueType = f?.computedFieldValueType ?? null; - const computedTriggerCfg = f?.computedFieldTriggerConfig ?? null; - - // Security / privacy / audit - // const encrypt = !!f?.encrypt; - // const encryptionType = f?.encryptionType ?? null; - // const decryptWhen = f?.decryptWhen ?? null; - const isPrivate = !!f?.private; - const enableAuditTracking = !!f?.enableAuditTracking; - - // Keys / system flags / mapping - const isUserKey = !!f?.isUserKey; - const isSystem = !!f?.isSystem; - const isMarkedForRemoval = !!f?.isMarkedForRemoval; - const columnName = f?.columnName ?? null; - const relCoModelColumn = f?.relationCoModelColumnName ?? null; - const uuid = f?.uuid ?? null; - - const relationSummary = (() => { - if (!relationType || !relModel) return 'none'; - const parts = [ - `type=${relationType}`, - relModule ? `module=${relModule}` : null, - `model=${relModel}`, - relField ? `field=${relField}` : null, - // relOwner !== null ? `m2mOwner=${relOwner}` : null, - // relCreateInverse ? 'createInverse=yes' : null, - relCascade ? `cascade=${relCascade}` : null, - // relJoinTable ? `joinTable=${relJoinTable}` : null, - relFixedFilter ? `fixedFilter=${relFixedFilter}` : null, - ].filter(Boolean); - return parts.join(', '); - })(); - - const selectionSummary = (() => { - const parts: string[] = []; - if (selectionDynProvider) parts.push(`dynamicProvider=${selectionDynProvider}`); - if (selectionDynCtxt) parts.push(`dynamicCtxt=${selectionDynCtxt}`); - if (selectionStatic?.length) parts.push(`static=[${this._shortList(selectionStatic, 12)}]`); - if (selectionValueType) parts.push(`valueType=${selectionValueType}`); - parts.push(`multiSelect=${this._oneLineBool(isMultiSelect)}`); - return parts.length ? parts.join(', ') : 'none'; - })(); - - const mediaSummary = (() => { - const parts: string[] = []; - if (mediaTypes?.length) parts.push(`types=[${this._shortList(mediaTypes, 12)}]`); - if (mediaMaxSizeKb) parts.push(`maxSizeKb=${mediaMaxSizeKb}`); - if (mediaStorageProvider) parts.push(`storageProvider=${typeof mediaStorageProvider === 'string' ? mediaStorageProvider : 'set'}`); - return parts.length ? parts.join(', ') : 'none'; - })(); - - const computedSummary = (() => { - const parts: string[] = []; - if (computedProvider) parts.push(`provider=${computedProvider}`); - if (computedProviderCtxt) parts.push(`providerCtxt=${computedProviderCtxt}`); - if (computedValueType) parts.push(`valueType=${computedValueType}`); - if (computedTriggerCfg?.length) parts.push(`triggers=${computedTriggerCfg.length}`); - return parts.length ? parts.join(', ') : 'none'; - })(); - - const securitySummary = [ - // `encrypt=${this.oneLineBool(encrypt)}`, - // encryptionType ? `encType=${encryptionType}` : null, - // decryptWhen ? `decryptWhen=${decryptWhen}` : null, - `private=${this._oneLineBool(isPrivate)}`, - `auditTracking=${this._oneLineBool(enableAuditTracking)}` - ].filter(Boolean).join(', '); - - const constraintSummary = [ - `required=${this._oneLineBool(required)}`, - `unique=${this._oneLineBool(unique)}`, - `index=${this._oneLineBool(index)}`, - length !== null ? `length=${length}` : null, - min !== null ? `min=${min}` : null, - max !== null ? `max=${max}` : null, - defaultValue !== null ? `default=${defaultValue}` : null, - regex ? `regex=${regex}${regexErr ? ` (${regexErr})` : ''}` : null - ].filter(Boolean).join(', '); - - const mappingSummary = [ - columnName ? `column=${columnName}` : null, - relCoModelColumn ? `relColumn=${relCoModelColumn}` : null, - uuid ? `uuid=${uuid}` : null, - `userKey=${this._oneLineBool(isUserKey)}`, - `system=${this._oneLineBool(isSystem)}`, - `markedForRemoval=${this._oneLineBool(isMarkedForRemoval)}` - ].filter(Boolean).join(', '); - - const text = [ - `SolidX Field: ${name} (${displayName})`, - `Model: ${modelName}`, - `Module: ${moduleName}`, - ``, - `Type: ${type}${ormType ? ` (orm=${ormType})` : ''}`, - `Description: ${description}`, - ``, - `Constraints: ${constraintSummary || 'none'}`, - `Relation: ${relationSummary}`, - `Selection: ${selectionSummary}`, - `Media: ${mediaSummary}`, - `Computed: ${computedSummary}`, - `Security/Privacy/Audit: ${securitySummary}`, - `Mapping/Flags: ${mappingSummary || 'none'}`, - ``, - `Usage: Use this chunk to generate exact field contracts (DTO, form control, DB column), `, - `validation rules, relation wiring, and UI widgets (selection/media/computed).`, - ].join('\n'); - - const metadata = { - kind: 'solidx-metadata', - type: 'field', - moduleName, - modelName, - fieldName: name, - displayName, - description, - dataType: type, - ormType, - required, - unique, - index, - defaultValue, - length, - min, - max, - regexPattern: regex, - regexPatternNotMatchingErrorMsg: regexErr, - - // relation - relationType, - relationModelModuleName: relModule, - relationCoModelSingularName: relModel, - relationCoModelFieldName: relField, - // isRelationManyToManyOwner: relOwner, - // relationJoinTableName: relJoinTable, - // relationCreateInverse: relCreateInverse, - relationCascade: relCascade, - relationFieldFixedFilter: relFixedFilter, - - // selection - selectionDynProvider, - selectionDynCtxt, - selectionStaticValues: selectionStatic, - selectionValueType, - isMultiSelect, - - // media - mediaTypes, - mediaMaxSizeKb, - mediaStorageProvider: mediaStorageProvider ? (typeof mediaStorageProvider === 'string' ? mediaStorageProvider : 'set') : null, - - // computed - computedFieldValueProvider: computedProvider, - computedFieldValueProviderCtxt: computedProviderCtxt, - computedFieldValueType: computedValueType, - computedFieldTriggerConfigCount: Array.isArray(computedTriggerCfg) ? computedTriggerCfg.length : 0, - - // security/privacy/audit - // encrypt, - // encryptionType, - // decryptWhen, - private: isPrivate, - enableAuditTracking, - - // mapping/flags - columnName, - relationCoModelColumnName: relCoModelColumn, - isUserKey, - isSystem, - isMarkedForRemoval, - }; - - return { text, metadata }; - } - - private async deleteInsertRagChunkForField(ingestionInfo: ModuleRAGIngestionInfo, moduleName: string, modelName: string, field: CreateFieldMetadataDto,): Promise { - const fieldName: string = field?.name ?? 'unknown_field'; - - // 1) Full JSON hash (as-is) - const schemaHash = this._sha256OfJson(field); - - // 2) Ensure ingestionInfo entry for the model & field - const modelsArr = ingestionInfo.models ?? (ingestionInfo.models = []); - let modelEntry = modelsArr.find(m => m.modelName === modelName); - if (!modelEntry) { - modelEntry = { modelName, fields: [] }; - modelsArr.push(modelEntry); - } - const fieldsArr = modelEntry.fields ?? (modelEntry.fields = []); - let fieldEntry = fieldsArr.find(f => f.fieldName === fieldName); - if (!fieldEntry) { - fieldEntry = { fieldName }; - fieldsArr.push(fieldEntry); - } - - // 3) Skip if unchanged - if (fieldEntry.fieldHash === schemaHash && fieldEntry.fieldChunkId) { - this.logger.log(`[Skip] Field unchanged: ${moduleName}.${modelName}.${fieldName}`); - return fieldEntry.fieldChunkId; - } - - // 4) Build text + metadata tailored to FieldMetadata - const { text, metadata } = this._buildFieldTextAndMetadata(moduleName, modelName, field); - - // also keep the hash in metadata for audit/debug - (metadata as any).schemaHash = schemaHash; - - // 5) Delete previous chunk if present - if (fieldEntry.fieldChunkId) { - try { - await this.ragClient.documents.delete({ id: fieldEntry.fieldChunkId }); - } catch (e) { - this.logger.warn(`[Warn] Failed deleting old field chunk (${fieldEntry.fieldChunkId}): ${String(e)}`); - } - } - - // 6) Create new document (R2R auto-generates the ID) - const r = await this.ragClient.documents.create({ - raw_text: text, - metadata, - collectionIds: [ingestionInfo.collectionId], - }); - - const newId = r?.results?.documentId; - if (!newId) { - throw new Error(`R2R did not return a documentId while creating field chunk for ${moduleName}.${modelName}.${fieldName}`); - } - - // 7) Update ingestion info - fieldEntry.fieldChunkId = newId; - fieldEntry.fieldHash = schemaHash; - - this.logger.log(`[OK] Ingested field ${moduleName}.${modelName}.${fieldName} → id=${newId}, hash=${schemaHash.slice(0, 8)}…`); - return newId; - } -} \ No newline at end of file diff --git a/src/services/genai/mcp-handlers/mcp-handler-factory.service.ts b/src/services/genai/mcp-handlers/mcp-handler-factory.service.ts deleted file mode 100644 index 11a001f8..00000000 --- a/src/services/genai/mcp-handlers/mcp-handler-factory.service.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; - -import { classify } from '../../../helpers/string.helper'; -import { IMcpToolResponseHandler } from 'src/interfaces'; -import { SolidIntrospectService } from '../../solid-introspect.service'; - -@Injectable() -export class McpHandlerFactory { - private readonly logger = new Logger(McpHandlerFactory.name); - - constructor( - private readonly solidIntrospectionService: SolidIntrospectService - ) { - } - - getInstance(toolInvoked: string): IMcpToolResponseHandler { - toolInvoked = classify(toolInvoked); - - let resolvedHandlerName = `${toolInvoked}McpHandler`; - - // Get hold of the tool response handler instance using the tool name used. - let actualHandler = this.solidIntrospectionService.getProvider(resolvedHandlerName); - if (!actualHandler) { - throw new Error(`Unable to locate mcp tool handler with name ${resolvedHandlerName}`); - } - - // type safe - const actualHandlerInstance: IMcpToolResponseHandler = actualHandler.instance; - this.logger.error(`Resolved mcp tool response handler with name ${actualHandler.name}`); - - return actualHandlerInstance; - } -} diff --git a/src/services/genai/mcp-handlers/solid-add-question-to-dashboard-mcp-handler.service.ts b/src/services/genai/mcp-handlers/solid-add-question-to-dashboard-mcp-handler.service.ts deleted file mode 100644 index 9f7d021c..00000000 --- a/src/services/genai/mcp-handlers/solid-add-question-to-dashboard-mcp-handler.service.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Injectable } from "@nestjs/common"; -import { plainToInstance } from "class-transformer"; -import { CreateDashboardQuestionDto } from "src/dtos/create-dashboard-question.dto"; -import { AiInteraction } from "src/entities/ai-interaction.entity"; -import { IMcpToolResponseHandler } from "../../../interfaces"; -import { DashboardQuestionService } from "../../dashboard-question.service"; - -@Injectable() -export class SolidAddQuestionToDashboardMcpHandler implements IMcpToolResponseHandler { - - constructor( - private readonly dashboardQuestionService: DashboardQuestionService, - ) { - } - - async apply(aiInteraction: AiInteraction) { - const escapedMessage = aiInteraction.message.replace(/\\'/g, "'"); - const aiResponseMessage = JSON.parse(escapedMessage); - - const {data} = aiResponseMessage; - const { dashboardUserKey, schema} = data; - - //FIXME: Replace \' with ' in the response, since the AI response seems to contain \' which is invalid JSON. - // This is a workaround for now, until we find a better solution. - // const aiResponseMessageReplaced = aiResponseMessage['message'].replace(/\\'/g, "'"); - // const dashboardUserKey = aiResponseMessageReplaced['dashboardUserKey']; - // if (!dashboardUserKey) { - // throw new Error("Dashboard User Key is required to create a Dashboard Question."); - // } - const dashboardQuestionDto = plainToInstance(CreateDashboardQuestionDto, schema); - dashboardQuestionDto['questionSqlDatasetConfigsCommand'] = "update"; - dashboardQuestionDto['dashboardUserKey'] = dashboardUserKey; - - const dashboardQuestion = await this.dashboardQuestionService.create(dashboardQuestionDto, []); - - // TODO: decide on some shape to return hre... - return { - seedingRequired: false, - serverRebooting: false, - } - } - -} \ No newline at end of file diff --git a/src/services/genai/mcp-handlers/solid-add-variable-to-dashboard-mcp-handler.service.ts b/src/services/genai/mcp-handlers/solid-add-variable-to-dashboard-mcp-handler.service.ts deleted file mode 100644 index 95a899f9..00000000 --- a/src/services/genai/mcp-handlers/solid-add-variable-to-dashboard-mcp-handler.service.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { Injectable } from "@nestjs/common"; -import { plainToInstance } from "class-transformer"; -import { CreateDashboardVariableDto } from "src/dtos/create-dashboard-variable.dto"; -import { AiInteraction } from "src/entities/ai-interaction.entity"; -import { DashboardVariableService } from "src/services/dashboard-variable.service"; -import { IMcpToolResponseHandler } from "../../../interfaces"; - -@Injectable() -export class SolidAddVariableToDashboardMcpHandler implements IMcpToolResponseHandler { - - constructor( - private readonly dashboardVariableService: DashboardVariableService, - ) { - } - - async apply(aiInteraction: AiInteraction) { - const escapedMessage = aiInteraction.message.replace(/\\'/g, "'"); - const aiResponseMessage = JSON.parse(escapedMessage); - - const {data} = aiResponseMessage; - const { dashboardUserKey, schema} = data; - - //FIXME: Replace \' with ' in the response, since the AI response seems to contain \' which is invalid JSON. - // This is a workaround for now, until we find a better solution. - // const aiResponseMessageReplaced = aiResponseMessage['message'].replace(/\\'/g, "'"); - // const dashboardUserKey = aiResponseMessageReplaced['dashboardUserKey']; - // if (!dashboardUserKey) { - // throw new Error("Dashboard User Key is required to create a Dashboard Question."); - // } - const dashboardVariableDto = plainToInstance(CreateDashboardVariableDto, schema); - dashboardVariableDto['selectionStaticValues'] = JSON.stringify(dashboardVariableDto['selectionStaticValues'] || []); - dashboardVariableDto['defaultValue'] = JSON.stringify(dashboardVariableDto['defaultValue'] || []); - dashboardVariableDto['dashboardUserKey'] = dashboardUserKey; - - const dashboardVariable = await this.dashboardVariableService.create(dashboardVariableDto, []); - - // TODO: decide on some shape to return hre... - return { - seedingRequired: false, - serverRebooting: false, - } - } - -} \ No newline at end of file diff --git a/src/services/genai/mcp-handlers/solid-create-dashboard-mcp-handler.service.ts b/src/services/genai/mcp-handlers/solid-create-dashboard-mcp-handler.service.ts deleted file mode 100644 index 4eaf3dcd..00000000 --- a/src/services/genai/mcp-handlers/solid-create-dashboard-mcp-handler.service.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { Injectable } from "@nestjs/common"; -import { plainToInstance } from "class-transformer"; -import { CreateDashboardDto } from "src/dtos/create-dashboard.dto"; -import { UpdateMenuItemMetadataDto } from "src/dtos/update-menu-item-metadata.dto"; -import { AiInteraction } from "src/entities/ai-interaction.entity"; -import { Dashboard } from "src/entities/dashboard.entity"; -import { IMcpToolResponseHandler } from "../../../interfaces"; -import { ActionMetadataService } from "../../action-metadata.service"; -import { DashboardService } from "../../dashboard.service"; -import { MenuItemMetadataService } from "../../menu-item-metadata.service"; -import { ModelMetadataService } from "../../model-metadata.service"; -import { ModuleMetadataService } from "../../module-metadata.service"; -import { RoleMetadataService } from "../../role-metadata.service"; - -@Injectable() -export class SolidCreateDashboardWithWidgetsMcpHandler implements IMcpToolResponseHandler { - - constructor( - private readonly dashboardService: DashboardService, - private readonly actionMetadataService: ActionMetadataService, - private readonly menuItemMetadataService: MenuItemMetadataService, - private readonly moduleMetadataService: ModuleMetadataService, - private readonly modelMetadataService: ModelMetadataService, - private readonly roleService: RoleMetadataService, // Assuming roleService is a Mongoose model, adjust as necessary - ) { - } - - async apply(aiInteraction: AiInteraction) { - const escapedMessage = aiInteraction.message.replace(/\\'/g, "'"); - const aiResponseMessage = JSON.parse(escapedMessage); - const { data } = aiResponseMessage; - const { schema } = data; - const { dashboardDto, dashboard } = await this.createDashboard(schema); - - const { moduleUserKey, actionMetadataEntity } = await this.createActionMetadataEntry(dashboardDto, dashboard); - - await this.createMenuItemEntry(dashboard, moduleUserKey, actionMetadataEntity); - - // TODO: decide on some shape to return hre... - return { - seedingRequired: false, - serverRebooting: false, - } - } - - - private async createMenuItemEntry(dashboard: Dashboard, moduleUserKey: string, actionMetadataEntity: any) { - const menuData = { - displayName: dashboard.displayName || dashboard.name, - name: `${moduleUserKey}-${dashboard.name}-dashboard-menu-item`, - sequenceNumber: 1, - actionUserKey: actionMetadataEntity.name, - moduleUserKey: moduleUserKey, - parentMenuItemUserKey: '', - }; - - const adminRole = await this.roleService.findRoleByName('Admin'); - const specifiedRoles = menuData['roles']; - - // If the developer has specified roles, then resolve them, making sure that admin role is also given. - const specifiedRoleObjects = [adminRole]; - if (specifiedRoles) { - for (let i = 0; i < specifiedRoles.length; i++) { - const specifiedRole = specifiedRoles[i]; - const specifiedRoleObject = await this.roleService.findRoleByName(specifiedRole); - if (!specifiedRoleObject) { - throw new Error(`Invalid role: (${specifiedRole}) specified against menu with display name ${menuData.displayName}.`); - } - specifiedRoleObjects.push(specifiedRoleObject); - } - } - - menuData['roles'] = specifiedRoleObjects; - menuData['action'] = await this.actionMetadataService.findOneByUserKey(menuData.actionUserKey); - menuData['module'] = await this.moduleMetadataService.findOneByUserKey(menuData.moduleUserKey); - - if (menuData.parentMenuItemUserKey) { - menuData['parentMenuItem'] = await this.menuItemMetadataService.findOneByUserKey(menuData.parentMenuItemUserKey); - } else { - menuData['parentMenuItem'] = null; - } - await this.menuItemMetadataService.upsert(menuData as unknown as UpdateMenuItemMetadataDto); - } - - private async createActionMetadataEntry(dashboardDto: CreateDashboardDto, dashboard: Dashboard) { - const moduleUserKey = dashboardDto.moduleUserKey; - const actionMetadata = { - name: `${dashboard.name}-view`, - displayName: dashboard.displayName || dashboard.name, - type: 'custom', - domain: "{}", - context: "{}", - customComponent: `/admin/core/${moduleUserKey}/dashboards?dashboardName=${dashboard.name}`, - customIsModal: true, - serverEndpoint: '', - moduleUserKey: moduleUserKey, - modelUserKey: '', - viewUserKey: `${dashboard.name}-view`, - }; - actionMetadata['module'] = await this.moduleMetadataService.findOneByUserKey(actionMetadata.moduleUserKey); - if (actionMetadata.modelUserKey) { - actionMetadata['model'] = await this.modelMetadataService.findOneByUserKey(actionMetadata.modelUserKey); - } - const actionMetadataEntity = await this.actionMetadataService.upsert(actionMetadata); - return { moduleUserKey, actionMetadataEntity }; - } - - private async createDashboard(aiResponseMessage: any) { - const dashboardDto = plainToInstance(CreateDashboardDto, aiResponseMessage); - dashboardDto['layoutJson'] = JSON.stringify(dashboardDto['layoutJson']); - const dashboard = await this.dashboardService.create(dashboardDto, []); - return { dashboardDto, dashboard }; - } -} \ No newline at end of file diff --git a/src/services/genai/mcp-handlers/solid-create-dashboard-question-mcp-handler.service.ts b/src/services/genai/mcp-handlers/solid-create-dashboard-question-mcp-handler.service.ts deleted file mode 100644 index e8836bba..00000000 --- a/src/services/genai/mcp-handlers/solid-create-dashboard-question-mcp-handler.service.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { Injectable } from "@nestjs/common"; -import { plainToInstance } from "class-transformer"; -import { CreateDashboardQuestionDto } from "src/dtos/create-dashboard-question.dto"; -import { AiInteraction } from "src/entities/ai-interaction.entity"; -import { IMcpToolResponseHandler } from "../../../interfaces"; -import { DashboardQuestionService } from "../../dashboard-question.service"; - -@Injectable() -export class SolidCreateDashboardQuestionMcpHandler implements IMcpToolResponseHandler { - - constructor( - private readonly dashboardQuestionService: DashboardQuestionService, - ) { - } - - async apply(aiInteraction: AiInteraction) { - const escapedMessage = aiInteraction.message.replace(/\\'/g, "'"); - const aiResponseMessage = JSON.parse(escapedMessage); - - //FIXME: Replace \' with ' in the response, since the AI response seems to contain \' which is invalid JSON. - // This is a workaround for now, until we find a better solution. - // const aiResponseMessageReplaced = aiResponseMessage['message'].replace(/\\'/g, "'"); - // const dashboardUserKey = aiResponseMessageReplaced['dashboardUserKey']; - // if (!dashboardUserKey) { - // throw new Error("Dashboard User Key is required to create a Dashboard Question."); - // } - const dashboardQuestionDto = plainToInstance(CreateDashboardQuestionDto, aiResponseMessage); - - const dashboardQuestion = await this.dashboardQuestionService.create(dashboardQuestionDto, []); - - // TODO: decide on some shape to return hre... - return { - seedingRequired: false, - serverRebooting: false, - } - } - -} \ No newline at end of file diff --git a/src/services/genai/mcp-handlers/solid-create-dashboard-question-sql-dataset-config-mcp-handler.service.ts b/src/services/genai/mcp-handlers/solid-create-dashboard-question-sql-dataset-config-mcp-handler.service.ts deleted file mode 100644 index dd8d3d03..00000000 --- a/src/services/genai/mcp-handlers/solid-create-dashboard-question-sql-dataset-config-mcp-handler.service.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Injectable } from "@nestjs/common"; -import { plainToInstance } from "class-transformer"; -import { AiInteraction } from "src/entities/ai-interaction.entity"; -import { IMcpToolResponseHandler } from "../../../interfaces"; -import { DashboardQuestionSqlDatasetConfigService } from "../../dashboard-question-sql-dataset-config.service"; -import { CreateDashboardQuestionSqlDatasetConfigDto } from "src/dtos/create-dashboard-question-sql-dataset-config.dto"; - -@Injectable() -export class SolidCreateDashboardQuestionSqlDatasetConfigMcpHandler implements IMcpToolResponseHandler { - - constructor( - private readonly dashboardQuestionSqlDatasetConfigService: DashboardQuestionSqlDatasetConfigService, - ) { - } - - async apply(aiInteraction: AiInteraction) { - // const aiResponse = JSON.parse(aiInteraction.message); - const escapedMessage = aiInteraction.message.replace(/\\'/g, "'"); - const aiResponseMessage = JSON.parse(escapedMessage); - - // FIXME: Replace \' with ' in the response, since the AI response seems to contain \' which is invalid JSON. - // This is a workaround for now, until we find a better solution. - // const aiResponseMessageReplaced = aiResponse['message'].replace(/\\'/g, "'"); - // const dashboardUserKey = aiResponseMessageReplaced['dashboardUserKey']; - // if (!dashboardUserKey) { - // throw new Error("Dashboard User Key is required to create a Dashboard Question."); - // } - const dto = plainToInstance(CreateDashboardQuestionSqlDatasetConfigDto, aiResponseMessage); - dto['options'] = JSON.stringify(dto['options']); - - const dashboardQuestion = await this.dashboardQuestionSqlDatasetConfigService.create(dto, []); - - // TODO: decide on some shape to return hre... - return { - seedingRequired: false, - serverRebooting: false, - } - } - -} \ No newline at end of file diff --git a/src/services/genai/mcp-handlers/solid-create-dashboard-widget-mcp-handler.service.ts b/src/services/genai/mcp-handlers/solid-create-dashboard-widget-mcp-handler.service.ts deleted file mode 100644 index 25362a0d..00000000 --- a/src/services/genai/mcp-handlers/solid-create-dashboard-widget-mcp-handler.service.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Injectable } from "@nestjs/common"; -import { plainToInstance } from "class-transformer"; -import { CreateDashboardQuestionDto } from "src/dtos/create-dashboard-question.dto"; -import { AiInteraction } from "src/entities/ai-interaction.entity"; -import { IMcpToolResponseHandler } from "../../../interfaces"; -import { DashboardQuestionService } from "../../dashboard-question.service"; - -@Injectable() -export class SolidCreateDashboardWidgetMcpHandler implements IMcpToolResponseHandler { - - constructor( - private readonly dashboardQuestionService: DashboardQuestionService, - ) { - } - - async apply(aiInteraction: AiInteraction) { - const escapedMessage = aiInteraction.message.replace(/\\'/g, "'"); - const aiResponseMessage = JSON.parse(escapedMessage); - - //FIXME: Replace \' with ' in the response, since the AI response seems to contain \' which is invalid JSON. - // This is a workaround for now, until we find a better solution. - // const aiResponseMessageReplaced = aiResponseMessage['message'].replace(/\\'/g, "'"); - // const dashboardUserKey = aiResponseMessageReplaced['dashboardUserKey']; - // if (!dashboardUserKey) { - // throw new Error("Dashboard User Key is required to create a Dashboard Question."); - // } - const dashboardQuestionDto = plainToInstance(CreateDashboardQuestionDto, aiResponseMessage); - dashboardQuestionDto['questionSqlDatasetConfigsCommand'] = "update"; - - const dashboardQuestion = await this.dashboardQuestionService.create(dashboardQuestionDto, []); - - // TODO: decide on some shape to return hre... - return { - seedingRequired: false, - serverRebooting: false, - } - } - -} \ No newline at end of file diff --git a/src/services/genai/r2r-helper.service.ts b/src/services/genai/r2r-helper.service.ts deleted file mode 100644 index 4323bdad..00000000 --- a/src/services/genai/r2r-helper.service.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import type { SolidCoreSetting } from "src/services/settings/default-settings-provider.service"; - -import { r2rClient } from 'r2r-js'; -import { SettingService } from '../setting.service'; - - - -@Injectable() -export class R2RHelperService { - private readonly logger = new Logger(R2RHelperService.name); - - constructor(private readonly settingService: SettingService) { } - - async getClient() { - const ragServerUrl = this.settingService.getConfigValue('ragServerUrl'); - this.logger.debug(`Attempting to create RAG client with url: ${ragServerUrl}`); - const client = new r2rClient(ragServerUrl); - - const ragServerLogin = this.settingService.getConfigValue('ragServerLogin'); - // @ts-ignore - this.logger.debug(`Attempting to login to our RAG server with user ${ragServerLogin}`) - await client.users.login({ - email: ragServerLogin, - password: this.settingService.getConfigValue('ragServerPassword') - }); - - return client; - } - - // async checkHealth() { - // const client = new r2rClient('http://localhost:7272'); - - // return await client.health(); - // } - -} \ No newline at end of file diff --git a/src/services/import-transaction.service.ts b/src/services/import-transaction.service.ts index dc2b341b..0eaf3cd9 100644 --- a/src/services/import-transaction.service.ts +++ b/src/services/import-transaction.service.ts @@ -25,8 +25,9 @@ import { SolidIntrospectService } from './solid-introspect.service'; import { ModelMetadataHelperService } from 'src/helpers/model-metadata-helper.service'; import { getUserExcludedFields } from 'src/helpers/user-helper'; import { ActiveUserData } from 'src/interfaces/active-user-data.interface'; -import {upperFirst, camelCase} from 'lodash'; +import { upperFirst, camelCase } from 'lodash'; import { classify } from '../helpers/string.helper'; +import type { SolidCoreSetting } from './settings/default-settings-provider.service'; interface ImportTemplateFileInfo { stream: NodeJS.ReadableStream; @@ -180,11 +181,20 @@ export class ImportTransactionService extends CRUDService { // Replace modelMetadata.fields with combined (child + parent) fields // modelMetadata.fields = allFields; + const dateFieldFormat = + (this.settingService.getConfigValue('dateFormat') as string | null) ?? + 'YYYY-MM-DD'; + const dateTimeFieldFormat = + (this.settingService.getConfigValue('dateTimeFormat') as string | null) ?? + 'YYYY-MM-DD HH:mm:ss'; + // Create the standard import instructions const standardInstructions: StandardImportInstructionsResponseDto = { requiredFields: [], dateFields: [], + dateFieldFormat, dateTimeFields: [], + dateTimeFieldFormat, numberFields: [], emailFields: [], regexFields: [], @@ -557,7 +567,7 @@ export class ImportTransactionService extends CRUDService { const createdRecord = await this.insertRecord(record, JSON.parse(importTransaction.mapping) as ImportMapping[], importTransaction.modelMetadata, modelService); ids.push(createdRecord.id); // Add the ID of the created record to the ids array } - catch (error) { + catch (error: any) { this.logger.debug(`Error inserting record: ${JSON.stringify(record)}. Error: ${error.message}`); // Get the Import transaction error log repo const errorLog = await this.createErrorLogEntry(importTransaction, record, error); @@ -792,4 +802,4 @@ export class ImportTransactionService extends CRUDService { const relatedRecordsIds = relatedRecordsResult.records.map(record => record.id); return relatedRecordsIds; } -} \ No newline at end of file +} diff --git a/src/services/list-of-values.service.ts b/src/services/list-of-values.service.ts index 44c56972..b21eb9f1 100644 --- a/src/services/list-of-values.service.ts +++ b/src/services/list-of-values.service.ts @@ -140,7 +140,7 @@ export class ListOfValuesService extends CRUDService { const filePath = await this.moduleMetadataHelperService.getModuleMetadataFilePath(moduleName); try { await fs.access(filePath); - } catch (error) { + } catch (error: any) { throw new Error(`Configuration file not found for module: ${moduleName}`); } const metaData = await this.moduleMetadataHelperService.getModuleMetadataConfiguration(filePath); diff --git a/src/services/mcp-audit-log.service.ts b/src/services/mcp-audit-log.service.ts new file mode 100644 index 00000000..ca7651b0 --- /dev/null +++ b/src/services/mcp-audit-log.service.ts @@ -0,0 +1,19 @@ +import { Injectable } from '@nestjs/common'; +import { ModuleRef } from '@nestjs/core'; +import { InjectEntityManager } from '@nestjs/typeorm'; +import { McpAuditLog } from 'src/entities/mcp-audit-log.entity'; +import { McpAuditLogRepository } from 'src/repository/mcp-audit-log.repository'; +import { EntityManager } from 'typeorm'; +import { CRUDService } from './crud.service'; + +@Injectable() +export class McpAuditLogService extends CRUDService { + constructor( + @InjectEntityManager() + readonly entityManager: EntityManager, + readonly repo: McpAuditLogRepository, + readonly moduleRef: ModuleRef, + ) { + super(entityManager, repo, 'mcpAuditLog', 'solid-core', moduleRef); + } +} diff --git a/src/services/mediaStorageProviders/file-s3-storage-provider.ts b/src/services/mediaStorageProviders/file-s3-storage-provider.ts index 8b2257ce..fe3ebd43 100755 --- a/src/services/mediaStorageProviders/file-s3-storage-provider.ts +++ b/src/services/mediaStorageProviders/file-s3-storage-provider.ts @@ -2,8 +2,8 @@ import { Injectable, Logger } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { CommonEntity } from "src/entities/common.entity"; import { FieldMetadata } from "src/entities/field-metadata.entity"; -import { LegacyCommonEntity } from "src/entities/legacy-common.entity"; -import { LegacyCommonWithIdEntity } from "src/entities/legacy-common-with-id.entity"; +import { LegacyCommonEntityWithExistingId } from "src/entities/legacy-common.entity"; +import { LegacyCommonEntityWithGeneratedId } from "src/entities/legacy-common-with-id.entity"; import { Media } from "src/entities/media.entity"; import { MediaStorageProvider } from "src/interfaces"; import { DiskFileService, S3FileService } from "src/services/file"; @@ -21,16 +21,51 @@ export class FileS3StorageProvider implements MediaStorageProvider { readonly mediaRepository: MediaRepository, ) { } - storeStreams(streamPairs: [Readable, string][], entity: T, mediaFieldMetadata: FieldMetadata): Promise { - throw new Error("Method not implemented."); + async storeStreams(streamPairs: [Readable, string][], entity: T, mediaFieldMetadata: FieldMetadata): Promise { + const isSupportedEntity = entity instanceof CommonEntity + || entity instanceof LegacyCommonEntityWithExistingId + || entity instanceof LegacyCommonEntityWithGeneratedId; + if (!isSupportedEntity) { + throw new Error("Entity must be an instance of CommonEntity, LegacyCommonEntityWithExistingId or LegacyCommonEntityWithGeneratedId"); + } + const result: Media[] = []; + const storageProvider = mediaFieldMetadata.mediaStorageProvider; + const region = this.getEffectiveRegion(storageProvider.region); + + for (const [stream, fileName] of streamPairs) { + const bucketName = storageProvider.bucketName; + + // Buffer the stream so we can get the byte count and upload in one pass + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + const fileData = Buffer.concat(chunks); + const fileSize = fileData.length; + + await this.s3FileService.write(`${bucketName}:${fileName}`, fileData, { region }); + + const mediaEntity = await this.mediaRepository.createMedia({ + // @ts-ignore + entityId: entity.id, + modelMetadataId: mediaFieldMetadata.model.id, + relativeUri: fileName, + fileSize, + mediaStorageProviderMetadataId: mediaFieldMetadata.mediaStorageProvider.id, + fieldMetadataId: mediaFieldMetadata.id + }) as unknown as Media; + result.push(mediaEntity); + this.logger.debug(`Stored media with`, mediaEntity); + } + return result; } async retrieve(entity: T, mediaFieldMetadata: FieldMetadata): Promise { const isSupportedEntity = entity instanceof CommonEntity - || entity instanceof LegacyCommonEntity - || entity instanceof LegacyCommonWithIdEntity; + || entity instanceof LegacyCommonEntityWithExistingId + || entity instanceof LegacyCommonEntityWithGeneratedId; if (!isSupportedEntity) { - throw new Error("Entity must be an instance of CommonEntity, LegacyCommonEntity or LegacyCommonWithIdEntity"); // FIXME This needs to be handled through generics. e.g T extends CommonEntity + throw new Error("Entity must be an instance of CommonEntity, LegacyCommonEntityWithExistingId or LegacyCommonEntityWithGeneratedId"); // FIXME This needs to be handled through generics. e.g T extends CommonEntity } // @ts-ignore const media = await this.mediaRepository.findByEntityIdAndFieldIdAndModelMetadataId(entity.id, mediaFieldMetadata.id, mediaFieldMetadata.model.id, ['mediaStorageProviderMetadata']); @@ -60,10 +95,10 @@ export class FileS3StorageProvider implements MediaStorageProvider { async store(files: Express.Multer.File[], entity: T, mediaFieldMetadata: FieldMetadata): Promise { const isSupportedEntity = entity instanceof CommonEntity - || entity instanceof LegacyCommonEntity - || entity instanceof LegacyCommonWithIdEntity; + || entity instanceof LegacyCommonEntityWithExistingId + || entity instanceof LegacyCommonEntityWithGeneratedId; if (!isSupportedEntity) { - throw new Error("Entity must be an instance of CommonEntity, LegacyCommonEntity or LegacyCommonWithIdEntity"); // FIXME This needs to be handled through generics. e.g T extends CommonEntity + throw new Error("Entity must be an instance of CommonEntity, LegacyCommonEntityWithExistingId or LegacyCommonEntityWithGeneratedId"); // FIXME This needs to be handled through generics. e.g T extends CommonEntity } const result: Media[] = []; const storageProvider = mediaFieldMetadata.mediaStorageProvider; @@ -102,10 +137,10 @@ export class FileS3StorageProvider implements MediaStorageProvider { async delete(entity: T, mediaFieldMetadata: FieldMetadata): Promise { const isSupportedEntity = entity instanceof CommonEntity - || entity instanceof LegacyCommonEntity - || entity instanceof LegacyCommonWithIdEntity; + || entity instanceof LegacyCommonEntityWithExistingId + || entity instanceof LegacyCommonEntityWithGeneratedId; if (!isSupportedEntity) { - throw new Error("Entity must be an instance of CommonEntity, LegacyCommonEntity or LegacyCommonWithIdEntity"); // FIXME This needs to be handled through generics. e.g T extends CommonEntity + throw new Error("Entity must be an instance of CommonEntity, LegacyCommonEntityWithExistingId or LegacyCommonEntityWithGeneratedId"); // FIXME This needs to be handled through generics. e.g T extends CommonEntity } const storageProvider = mediaFieldMetadata.mediaStorageProvider; const region = this.getEffectiveRegion(storageProvider.region); @@ -120,6 +155,18 @@ export class FileS3StorageProvider implements MediaStorageProvider { } } + async deleteByMediaRecord(media: Media): Promise { + const storageProvider = media?.mediaStorageProviderMetadata; + if (!storageProvider) { + throw new Error(`mediaStorageProviderMetadata is not populated for media id ${media?.id ?? 'unknown'}`); + } + if (!storageProvider?.bucketName || !media?.relativeUri) { + return; + } + const region = this.getEffectiveRegion(storageProvider.region); + await this.s3FileService.delete(`${storageProvider.bucketName}:${media.relativeUri}`, { region }); + } + /** * Get the effective region to use for S3 operations. * Uses the provider-specific region if configured, otherwise falls back to env variable. diff --git a/src/services/mediaStorageProviders/file-storage-provider.ts b/src/services/mediaStorageProviders/file-storage-provider.ts index dd91d9ec..53f9b053 100755 --- a/src/services/mediaStorageProviders/file-storage-provider.ts +++ b/src/services/mediaStorageProviders/file-storage-provider.ts @@ -8,6 +8,7 @@ import { MediaRepository } from "src/repository/media.repository"; import { DiskFileService } from "src/services/file"; import { Readable } from "stream"; import * as path from "path"; +import * as fs from "fs"; import { SettingService } from "../setting.service"; import { DEFAULT_MEDIA_FILE_STORAGE_DIR } from "src/services/settings/default-settings-provider.service"; import type { SolidCoreSetting } from "src/services/settings/default-settings-provider.service"; @@ -80,12 +81,15 @@ export class FileStorageProvider implements MediaStorageProvider { for (const pair of streamPairs) { const stream = pair[0]; const fileName = pair[1]; - await this.fileService.writeStream(this.getFullFilePath(fileName), stream); + const fullPath = this.getFullFilePath(fileName); + await this.fileService.writeStream(fullPath, stream); + const { size: fileSize } = await fs.promises.stat(fullPath); const mediaEntity = await this.mediaRepository.createMedia({ //@ts-ignore entityId: entity.id, modelMetadataId: mediaFieldMetadata.model.id, relativeUri: fileName, + fileSize, mediaStorageProviderMetadataId: mediaFieldMetadata.mediaStorageProvider.id, fieldMetadataId: mediaFieldMetadata.id }) as unknown as Media; @@ -110,6 +114,13 @@ export class FileStorageProvider implements MediaStorageProvider { // }); } + async deleteByMediaRecord(media: Media): Promise { + if (!media?.relativeUri) { + return; + } + await this.fileService.delete(this.getFullFilePath(media.relativeUri)); + } + private getFullFilePath(fileName: string): string { const base = this.settingService.getConfigValue("fileStorageDir") || DEFAULT_MEDIA_FILE_STORAGE_DIR; @@ -122,4 +133,4 @@ export class FileStorageProvider implements MediaStorageProvider { private getFileName(file: Express.Multer.File): string { return `${file.filename}-${file.originalname}`; } -} \ No newline at end of file +} diff --git a/src/services/model-metadata.service.ts b/src/services/model-metadata.service.ts index fab35282..90d8379e 100755 --- a/src/services/model-metadata.service.ts +++ b/src/services/model-metadata.service.ts @@ -8,11 +8,10 @@ import { ModelMetadata } from '../entities/model-metadata.entity'; import { ModuleMetadata } from '../entities/module-metadata.entity'; import { kebabCase } from 'lodash'; -import { classify } from '../helpers/string.helper'; import { ERROR_MESSAGES } from 'src/constants/error-messages'; import { DisallowInProduction } from 'src/decorators/disallow-in-production.decorator'; import { SolidFieldType } from 'src/dtos/create-field-metadata.dto'; -import { PermissionMetadata } from 'src/entities/permission-metadata.entity'; +import { NavigationDto } from 'src/dtos/navigation.dto'; import { ModuleMetadataHelperService } from 'src/helpers/module-metadata-helper.service'; import { FieldMetadataRepository } from 'src/repository/field-metadata.repository'; import { ModelMetadataRepository } from 'src/repository/model-metadata.repository'; @@ -20,21 +19,26 @@ import { BasicFilterDto } from '../dtos/basic-filters.dto'; import { UpdateModelMetaDataDto } from '../dtos/update-model-metadata.dto'; import { ActionMetadata } from '../entities/action-metadata.entity'; import { FieldMetadata } from '../entities/field-metadata.entity'; +import { ImportTransactionErrorLog } from '../entities/import-transaction-error-log.entity'; +import { ImportTransaction } from '../entities/import-transaction.entity'; import { MenuItemMetadata } from '../entities/menu-item-metadata.entity'; +import { UserViewMetadata } from '../entities/user-view-metadata.entity'; +import { PermissionMetadata } from '../entities/permission-metadata.entity'; import { ViewMetadata } from '../entities/view-metadata.entity'; +import { CommandService } from '../helpers/command.service'; import { REFRESH_MODEL_COMMAND, REMOVE_FIELDS_COMMAND, SchematicService } from '../helpers/schematic.service'; +import { classify } from '../helpers/string.helper'; import { CodeGenerationOptions } from '../interfaces'; -import { CrudHelperService } from './crud-helper.service'; +import { CrudHelperService, FilterCombinator } from './crud-helper.service'; +import { CRUDService } from './crud.service'; import { FieldMetadataService } from './field-metadata.service'; import { MediaStorageProviderMetadataService } from './media-storage-provider-metadata.service'; import { RoleMetadataService } from './role-metadata.service'; -import { NavigationDto } from 'src/dtos/navigation.dto'; import { SolidIntrospectService } from './solid-introspect.service'; -import { CRUDService } from './crud.service'; import { SolidTsMorphService } from './solid-ts-morph.service'; @Injectable() @@ -49,6 +53,7 @@ export class ModelMetadataService { private readonly modelMetadataRepo: ModelMetadataRepository, private readonly fieldMetadataRepo: FieldMetadataRepository, private readonly schematicService: SchematicService, + private readonly commandService: CommandService, @InjectDataSource() private readonly dataSource: DataSource, private readonly crudHelperService: CrudHelperService, @@ -67,36 +72,26 @@ export class ModelMetadataService { return this.findMany(basicFilterDto); } - async findMany(basicFilterDto: BasicFilterDto) { + async findMany(basicFilterDto: BasicFilterDto): Promise { const alias = 'modelMetadata'; - // Extract the required keys from the input query - let { limit, offset } = basicFilterDto; - - // Create above query on pincode table using query builder - var qb: SelectQueryBuilder = await this.modelMetadataRepo.createSecurityRuleAwareQueryBuilder(alias) - qb = await this.crudHelperService.buildFilterQuery(qb, basicFilterDto, alias); + const { limit, offset } = basicFilterDto; - // Get the records and the count - const [entities, count] = await qb.getManyAndCount(); + const qb: SelectQueryBuilder = await this.modelMetadataRepo.createSecurityRuleAwareQueryBuilder(alias); - const currentPage = Math.floor(offset / limit) + 1; - const totalPages = Math.ceil(count / limit); - - const nextPage = currentPage < totalPages ? currentPage + 1 : null; - const prevPage = currentPage > 1 ? currentPage - 1 : null; + if (basicFilterDto.groupBy?.length) { + const groupFilterQb = this.crudHelperService.buildFilterQuery( + qb, basicFilterDto, alias, undefined, undefined, undefined, + FilterCombinator.AND, false, false + ); + return this.crudHelperService.executeGroupPipeline( + groupFilterQb, basicFilterDto, alias, + () => this.modelMetadataRepo.createSecurityRuleAwareQueryBuilder(alias) + ); + } - const r = { - meta: { - totalRecords: count, - currentPage: currentPage, - nextPage: nextPage, - prevPage: prevPage, - totalPages: totalPages, - perPage: +limit, - }, - records: entities - }; - return r + const filteredQb = this.crudHelperService.buildFilterQuery(qb, basicFilterDto, alias); + const [entities, count] = await filteredQb.getManyAndCount(); + return this.crudHelperService.pagedResponse(offset, limit, count, entities); } async findOne(id: any, query?: any) { @@ -154,7 +149,7 @@ export class ModelMetadataService { return model }); - } catch (error) { + } catch (error: any) { // console.error('Transaction failed:', error); this.logger.error('Transaction failed:', error); throw error; @@ -189,7 +184,7 @@ export class ModelMetadataService { // return model }); - } catch (error) { + } catch (error: any) { // console.error('Transaction failed:', error); this.logger.error('Transaction failed:', error); throw error; @@ -286,8 +281,7 @@ export class ModelMetadataService { tableName: model.tableName, userKeyFieldUserKey: model.fields.find(field => field.isUserKey)?.name, isChild: model?.isChild, - isLegacyTable: model?.isLegacyTable, - isLegacyTableWithId: model?.isLegacyTableWithId, + legacyTableType: model?.legacyTableType, parentModelUserKey: model?.parentModel?.singularName, enableAuditTracking: model?.enableAuditTracking, enableSoftDelete: model?.enableSoftDelete, @@ -309,7 +303,7 @@ export class ModelMetadataService { const updatedContent = JSON.stringify(metaData, null, 2); await fs.writeFile(filePath, updatedContent); - } catch (error) { + } catch (error: any) { // console.error('File creation failed:', error); this.logger.error('File creation failed:', error); throw new Error(ERROR_MESSAGES.FILE_WRITE_FAILED); // Trigger rollback @@ -490,7 +484,7 @@ export class ModelMetadataService { const updatedContent = JSON.stringify(metaData, null, 2); await fs.writeFile(filePath, updatedContent); - } catch (error) { + } catch (error: any) { // console.error('File creation failed:', error); this.logger.error('File creation failed:', error); throw new Error(ERROR_MESSAGES.FILE_WRITE_FAILED); // Trigger rollback @@ -524,7 +518,7 @@ export class ModelMetadataService { await this.cleanupOnDelete(entity.id); const r = await this.modelMetadataRepo.remove(entity); return r; - } catch (error) { + } catch (error: any) { } } @@ -566,7 +560,7 @@ export class ModelMetadataService { // @ts-ignore id: modelEntityId, }, - relations: ['module'] + relations: ['module', 'fields'] }); if (!modelEntity) { @@ -621,73 +615,30 @@ export class ModelMetadataService { try { await fs.unlink(fileToDelete); this.logger.log(`Deleted file: ${fileToDelete}`); - } catch (error) { + } catch (error: any) { + if (error?.code === 'ENOENT') { + this.logger.warn(`File already absent, skipping delete: ${fileToDelete}`); + continue; + } this.logger.error(`Error deleting file: ${fileToDelete}`, error); } } } - await this.dataSource.query( - `CALL cleanup_model_metadata($1, $2)`, - [modelEntity.singularName, true], - ); - - // Delete the permissions, menu, actions & views related to this model. - // const controllerName = `${classify(modelEntity.singularName)}Controller`; - // const permissionNames = [ - // `${controllerName}.delete`, - // `${controllerName}.deleteMany`, - // `${controllerName}.findOne`, - // `${controllerName}.findMany`, - // `${controllerName}.recover`, - // `${controllerName}.recoverMany`, - // `${controllerName}.partialUpdate`, - // `${controllerName}.update`, - // `${controllerName}.insertMany`, - // `${controllerName}.create`, - // ]; - // const permissionsRepo = this.dataSource.getRepository(PermissionMetadata); - // const permissionsToDelete = await permissionsRepo.find({ - // where: { name: In(permissionNames) }, - // relations: ['roles'], - // }); - - // Remove role associations first - // for (const permission of permissionsToDelete) { - // if (permission.roles?.length) { - // await this.dataSource - // .createQueryBuilder() - // .relation(PermissionMetadata, 'roles') - // .of(permission) // permission instance or its ID - // .remove(permission.roles); // remove all linked roles - // } - // } - - // await permissionsRepo.remove(permissionsToDelete); - - // Delete actions - // const actionRepo = this.dataSource.getRepository(ActionMetadata); - // const action = await actionRepo.findOne({ where: { model: { id: modelEntity.id } } }); - // await actionRepo.delete({ model: { id: modelEntity.id } }); - - // // Delete menu items - // const menuItemRepo = this.dataSource.getRepository(MenuItemMetadata); - // if (action) { - // const menuItems = await menuItemRepo.find({ where: { action: { id: action.id } } }); - // for (let i = 0; i < menuItems.length; i++) { - // const menuItem = menuItems[i]; - // await menuItemRepo.remove(menuItem); - // } - // } - - // Delete view - // const viewRepo = this.dataSource.getRepository(ViewMetadata); - // await viewRepo.delete({ model: { id: modelEntity.id } }) + const { removedActionNames, removedMenuNames, removedViewNames } = await this.cleanupAssociatedViewsActionsAndMenus(modelEntity.id); + await this.cleanupAssociatedImports(modelEntity.id); + await this.cleanupAssociatedPermissions(modelEntity.singularName); + await this.clearModelReferencesBeforeDelete(modelEntity); // -metadata.json | Remove references to this model in the model metadata, menu, action & view sections. | Automatic const filePath = await this.moduleMetadataHelperService.getModuleMetadataFilePath(modelEntity.module?.name); const metaData = await this.moduleMetadataHelperService.getModuleMetadataConfiguration(filePath); if (metaData) { + const moduleMetadata = metaData?.moduleMetadata ?? metaData; + const removedActionNameSet = new Set(removedActionNames); + const removedMenuNameSet = new Set(removedMenuNames); + const removedViewNameSet = new Set(removedViewNames); + const existingModelIndex = metaData.moduleMetadata.models.findIndex( (existingModel: any) => existingModel.singularName === modelEntity.singularName ); @@ -698,15 +649,51 @@ export class ModelMetadataService { } // Remove references to this model in the menu, action & view sections. - metaData.moduleMetadata.menus = metaData.moduleMetadata?.menus?.filter( - (menu: any) => menu.modelUserKey !== modelEntity.singularName - ); - metaData.moduleMetadata.actions = metaData.moduleMetadata?.actions?.filter( - (action: any) => action.modelUserKey !== modelEntity.singularName - ); - metaData.moduleMetadata.views = metaData.moduleMetadata?.views?.filter( - (view: any) => view.modelUserKey !== modelEntity.singularName - ); + const existingViews = Array.isArray(moduleMetadata?.views) ? moduleMetadata.views : []; + moduleMetadata.views = existingViews.filter((view: any) => { + const shouldRemove = view?.modelUserKey === modelEntity.singularName || removedViewNameSet.has(view?.name); + if (shouldRemove && view?.name) { + removedViewNameSet.add(view.name); + } + return !shouldRemove; + }); + + const existingActions = Array.isArray(moduleMetadata?.actions) ? moduleMetadata.actions : []; + moduleMetadata.actions = existingActions.filter((action: any) => { + const shouldRemove = + action?.modelUserKey === modelEntity.singularName || + removedActionNameSet.has(action?.name) || + removedViewNameSet.has(action?.viewUserKey); + + if (shouldRemove && action?.name) { + removedActionNameSet.add(action.name); + } + return !shouldRemove; + }); + + let pendingMenus = Array.isArray(moduleMetadata?.menus) ? [...moduleMetadata.menus] : []; + let menuRemovedOnPass = true; + while (menuRemovedOnPass) { + menuRemovedOnPass = false; + pendingMenus = pendingMenus.filter((menu: any) => { + const shouldRemove = + menu?.modelUserKey === modelEntity.singularName || + removedMenuNameSet.has(menu?.name) || + removedActionNameSet.has(menu?.actionUserKey) || + removedMenuNameSet.has(menu?.parentMenuItemUserKey); + + if (shouldRemove) { + if (menu?.name) { + removedMenuNameSet.add(menu.name); + } + menuRemovedOnPass = true; + return false; + } + + return true; + }); + } + moduleMetadata.menus = pendingMenus; const updatedContent = JSON.stringify(metaData, null, 2); await fs.writeFile(filePath, updatedContent); @@ -725,7 +712,7 @@ export class ModelMetadataService { ); this.solidTsMorphService.removeModuleMembers(moduleFilePath, removedIdentifiers); await this.solidTsMorphService.commit(); - } catch (error) { + } catch (error: any) { this.solidTsMorphService.rollback(); this.logger.error(`Failed to clean up module file for model '${modelEntity.singularName}':`, error); } @@ -735,6 +722,233 @@ export class ModelMetadataService { } + private async cleanupAssociatedViewsActionsAndMenus(modelId: number) { + const viewRepo = this.dataSource.getRepository(ViewMetadata); + const actionRepo = this.dataSource.getRepository(ActionMetadata); + const menuRepo = this.dataSource.getRepository(MenuItemMetadata); + const userViewRepo = this.dataSource.getRepository(UserViewMetadata); + + const views = await viewRepo.find({ + where: { + model: { id: modelId }, + }, + }); + const viewIds = views.map((view) => view.id); + const removedViewNames = views.map((view) => view.name).filter(Boolean); + + const actions = await actionRepo.find({ + where: [ + { + model: { id: modelId }, + }, + ...(viewIds.length > 0 + ? [ + { + view: { id: In(viewIds) }, + }, + ] + : []), + ], + relations: ['view'], + }); + + const uniqueActions = Array.from( + new Map(actions.map((action) => [action.id, action])).values(), + ); + const actionIds = uniqueActions.map((action) => action.id); + const removedActionNames = uniqueActions.map((action) => action.name).filter(Boolean); + + const menus = await this.findMenusForActionIds(actionIds); + const removedMenuNames = menus.map((menu) => menu.name).filter(Boolean); + + if (menus.length > 0) { + const menuIds = menus.map((menu) => menu.id).filter(Boolean); + for (const menu of menus) { + if (menu.roles?.length) { + await this.dataSource + .createQueryBuilder() + .relation(MenuItemMetadata, 'roles') + .of(menu.id) + .remove(menu.roles.map((role) => role.id)); + } + } + + if (menuIds.length > 0) { + await menuRepo + .createQueryBuilder() + .update(MenuItemMetadata) + .set({ parentMenuItem: null as any }) + .where('id IN (:...menuIds)', { menuIds }) + .execute(); + + await menuRepo + .createQueryBuilder() + .delete() + .from(MenuItemMetadata) + .where('id IN (:...menuIds)', { menuIds }) + .execute(); + } + + this.logger.log(`Deleted ${menus.length} menu metadata record(s) for model id ${modelId}`); + } + + if (uniqueActions.length > 0) { + await actionRepo.remove(uniqueActions); + this.logger.log(`Deleted ${uniqueActions.length} action metadata record(s) for model id ${modelId}`); + } + + if (viewIds.length > 0) { + const userViews = await userViewRepo.find({ + where: { + viewMetadata: { id: In(viewIds) }, + }, + relations: ['viewMetadata'], + }); + if (userViews.length > 0) { + await userViewRepo.remove(userViews); + this.logger.log(`Deleted ${userViews.length} user view metadata record(s) for model id ${modelId}`); + } + + await viewRepo.remove(views); + this.logger.log(`Deleted ${views.length} view metadata record(s) for model id ${modelId}`); + } + + return { + removedActionNames, + removedMenuNames, + removedViewNames, + }; + } + + private async cleanupAssociatedImports(modelId: number) { + const importTransactionRepo = this.dataSource.getRepository(ImportTransaction); + const importTransactionErrorLogRepo = this.dataSource.getRepository(ImportTransactionErrorLog); + + const importTransactions = await importTransactionRepo.find({ + where: { + modelMetadata: { id: modelId }, + }, + }); + + if (importTransactions.length === 0) { + return; + } + + const importTransactionIds = importTransactions.map((transaction) => transaction.id); + const importTransactionErrorLogs = await importTransactionErrorLogRepo.find({ + where: { + importTransaction: { id: In(importTransactionIds) }, + }, + relations: ['importTransaction'], + }); + + if (importTransactionErrorLogs.length > 0) { + await importTransactionErrorLogRepo.remove(importTransactionErrorLogs); + this.logger.log(`Deleted ${importTransactionErrorLogs.length} import transaction error log record(s) for model id ${modelId}`); + } + + await importTransactionRepo.remove(importTransactions); + this.logger.log(`Deleted ${importTransactions.length} import transaction record(s) for model id ${modelId}`); + } + + private async cleanupAssociatedPermissions(modelSingularName: string) { + const permissionsRepo = this.dataSource.getRepository(PermissionMetadata); + const controllerName = `${classify(modelSingularName)}Controller`; + const permissions = await permissionsRepo + .createQueryBuilder('permission') + .leftJoinAndSelect('permission.roles', 'role') + .where('permission.name LIKE :pattern', { pattern: `${controllerName}.%` }) + .getMany(); + + if (permissions.length === 0) { + return; + } + + for (const permission of permissions) { + if (permission.roles?.length) { + await this.dataSource + .createQueryBuilder() + .relation(PermissionMetadata, 'roles') + .of(permission.id) + .remove(permission.roles.map((role) => role.id)); + } + } + + await permissionsRepo.remove(permissions); + this.logger.log(`Deleted ${permissions.length} permission metadata record(s) for model '${modelSingularName}'`); + } + + private async clearModelReferencesBeforeDelete(modelEntity: ModelMetadata) { + const modelRepo = this.dataSource.getRepository(ModelMetadata); + const fieldIds = (modelEntity.fields ?? []).map((field) => field.id).filter(Boolean); + + if (fieldIds.length > 0) { + await modelRepo + .createQueryBuilder() + .update(ModelMetadata) + .set({ userKeyField: null as any }) + .where('user_key_field_id IN (:...fieldIds)', { fieldIds }) + .execute(); + } + + await modelRepo + .createQueryBuilder() + .update(ModelMetadata) + .set({ parentModel: null as any }) + .where('parent_model_id = :modelId', { modelId: modelEntity.id }) + .execute(); + } + + private async findMenusForActionIds(actionIds: number[]) { + if (!actionIds?.length) { + return []; + } + + const menuRepo = this.dataSource.getRepository(MenuItemMetadata); + const menusById = new Map(); + + const rootMenus = await menuRepo.find({ + where: { + action: { id: In(actionIds) }, + }, + relations: ['roles', 'action', 'parentMenuItem'], + }); + + rootMenus.forEach((menu) => menusById.set(menu.id, menu)); + + let parentIds = rootMenus.map((menu) => menu.id); + while (parentIds.length > 0) { + const childMenus = await menuRepo.find({ + where: { + parentMenuItem: { id: In(parentIds) }, + }, + relations: ['roles', 'action', 'parentMenuItem'], + }); + + const nextParentIds: number[] = []; + for (const childMenu of childMenus) { + if (!menusById.has(childMenu.id)) { + menusById.set(childMenu.id, childMenu); + nextParentIds.push(childMenu.id); + } + } + + parentIds = nextParentIds; + } + + return Array.from(menusById.values()); + } + + @DisallowInProduction() + async generateCodeViaCtl(modelId: number): Promise { + const model = await this.findOne(modelId); + return this.commandService.executeCommandWithArgs({ + command: 'npx', + args: ['@solidxai/solidctl@latest', 'generate', 'model', `--name=${model.singularName}`], + cwd: path.join(process.cwd(), '..'), + }); + } + @DisallowInProduction() async handleGenerateCode(options: CodeGenerationOptions): Promise { const affectedModelIds = [], refreshModelCodeOutputLines = [], removeFieldCodeOutputLines = []; @@ -789,7 +1003,7 @@ export class ModelMetadataService { await this.populateVAMConfigInDb(model); await this.populateVAMConfigInFile(model); }); - } catch (error) { + } catch (error: any) { this.logger.error('generateVAMConfig Transaction failed:', error); throw error; } @@ -816,7 +1030,7 @@ export class ModelMetadataService { const updatedContent = JSON.stringify(metaData, null, 2); await fs.writeFile(filePath, updatedContent); - } catch (error) { + } catch (error: any) { // console.error('File creation failed:', error); this.logger.error('File updation failed for View, action, menus config:', error); throw new Error('File updation failed for View, action, menus config'); // Trigger rollback @@ -826,15 +1040,15 @@ export class ModelMetadataService { // Populate the View, Actions and Menus in the config file private populateVAMConfigInFileInternal(formViewLayoutFields: any[], model: ModelMetadata, listViewLayoutFields: { type: string; attrs: { name: string; }; }[], treeViewLayoutFields: { type: string; attrs: { name: string; }; }[], metaData: any) { const column1Fields = []; - const column2Fields = []; + // const column2Fields = []; // Distribute fields between two columns for (let i = 0; i < formViewLayoutFields.length; i++) { - if (i % 2 === 0) { - column1Fields.push(formViewLayoutFields[i]); - } else { - column2Fields.push(formViewLayoutFields[i]); - } + // if (i % 2 === 0) { + column1Fields.push(formViewLayoutFields[i]); + // } else { + // column2Fields.push(formViewLayoutFields[i]); + // } } const actionName = `${model.singularName}-list-action`; const treeViewActionName = `${model.singularName}-tree-action`; @@ -842,6 +1056,7 @@ export class ModelMetadataService { const treeViewName = `${model.singularName}-tree-view`; const formViewName = `${model.singularName}-form-view`; const menuName = `${model.singularName}-menu-item`; + const nextMenuSequenceNumber = (metaData.menus?.length ?? 0) + 1; const action = { displayName: `${model.displayName} List Action`, @@ -874,7 +1089,7 @@ export class ModelMetadataService { const menu = { displayName: `${model.displayName}`, name: menuName, - sequenceNumber: 1, + sequenceNumber: nextMenuSequenceNumber, actionUserKey: actionName, moduleUserKey: `${model.module.name}`, parentMenuItemUserKey: "", @@ -955,11 +1170,11 @@ export class ModelMetadataService { attrs: { name: "group-1", label: "", className: "col-12 sm:col-12 md:col-6 lg:col-6" }, children: column1Fields }, - { - type: "column", - attrs: { name: "group-2", label: "", className: "col-12 sm:col-12 md:col-6 lg:col-6" }, - children: column2Fields - } + // { + // type: "column", + // attrs: { name: "group-2", label: "", className: "col-12 sm:col-12 md:col-6 lg:col-6" }, + // children: column2Fields + // } ] }, ] @@ -1228,11 +1443,11 @@ export class ModelMetadataService { const removeOutput = await this.executeRemoveFieldsCommand(model, fieldsForRemoval, options.dryRun); // Remove the fields from the database as well. This also checks, if the field is marked for removal - fieldsForRemoval.forEach((field: FieldMetadata) => { + for (const field of fieldsForRemoval) { if (field.isMarkedForRemoval) { - this.fieldMetadataService.delete(field.id); + await this.fieldMetadataService.delete(field.id); } - }); + } // Remove the fields from metadata json file @@ -1274,24 +1489,13 @@ export class ModelMetadataService { }; const model = options.modelId ? await this.findOne(options.modelId, query) : await this.findOneByUserKey(options.modelUserKey, query.populate); - let fieldsForRefresh = model.fields.filter((field) => !field.isMarkedForRemoval); - - // If a list of field ids or field names is passed for refresh, use these fields only - if (options.fieldIdsForRefresh && options.fieldIdsForRefresh.length > 0) { - fieldsForRefresh = fieldsForRefresh.filter((field) => options.fieldIdsForRefresh.includes(+field.id)); - } else if (options.fieldNamesForRefresh && options.fieldNamesForRefresh.length > 0) { - fieldsForRefresh = fieldsForRefresh.filter((field) => options.fieldNamesForRefresh.includes(field.name)); - } - // const fieldsForRefresh = model.fields.filter((field) => !field.isMarkedForRemoval); - //Execute the schematic command to refresh the model - const refreshOuput = await this.executeRefreshModelCommand(model, fieldsForRefresh, options.dryRun); + const refreshOuput = await this.executeRefreshModelCommand(model, options.dryRun); return `${refreshOuput}`; } - private async executeRefreshModelCommand(model: ModelMetadata, fieldsForRefresh: FieldMetadata[], dryRun: boolean = false): Promise { - // const fieldsForRefresh = model.fields.filter((field) => !field.isMarkedForRemoval); + private async executeRefreshModelCommand(model: ModelMetadata, dryRun: boolean = false): Promise { const output = await this.schematicService.executeSchematicCommand( REFRESH_MODEL_COMMAND, { diff --git a/src/services/module-metadata-explorer.service.ts b/src/services/module-metadata-explorer.service.ts new file mode 100644 index 00000000..1966ac21 --- /dev/null +++ b/src/services/module-metadata-explorer.service.ts @@ -0,0 +1,670 @@ +import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { DisallowInProduction } from 'src/decorators/disallow-in-production.decorator'; +import { ModuleMetadataHelperService } from 'src/helpers/module-metadata-helper.service'; +import { MetadataExplorerReferencesQueryDto } from '../dtos/metadata-explorer-references-query.dto'; +import { MetadataExplorerSearchQueryDto } from '../dtos/metadata-explorer-search-query.dto'; +import { MetadataExplorerWriteDto } from '../dtos/metadata-explorer-write.dto'; + +type MetadataExplorerSectionType = 'object' | 'array'; + +type MetadataExplorerSectionDefinition = { + key: string; + title: string; + jsonPath: string; + type: MetadataExplorerSectionType; + description: string; +}; + +type MetadataValidationIssue = { + path: string; + message: string; + severity: 'error' | 'warning'; +}; + +type MetadataSearchMatch = { + path: string; + sectionKey: string | null; + matchType: 'key' | 'value'; + valueType: string; + preview: string; +}; + +const SECTION_DEFINITIONS: MetadataExplorerSectionDefinition[] = [ + { key: 'module', title: 'Module', jsonPath: 'moduleMetadata', type: 'object', description: 'Top-level module metadata excluding nested models.' }, + { key: 'models', title: 'Models', jsonPath: 'moduleMetadata.models', type: 'array', description: 'Model metadata definitions for the module.' }, + { key: 'roles', title: 'Roles', jsonPath: 'roles', type: 'array', description: 'Role metadata definitions.' }, + { key: 'users', title: 'Users', jsonPath: 'users', type: 'array', description: 'Seed users and user-role assignments.' }, + { key: 'permissions', title: 'Permissions', jsonPath: 'permissions', type: 'array', description: 'Permission declarations and explicit additions.' }, + { key: 'actions', title: 'Actions', jsonPath: 'actions', type: 'array', description: 'Action metadata definitions.' }, + { key: 'menus', title: 'Menus', jsonPath: 'menus', type: 'array', description: 'Menu item metadata definitions.' }, + { key: 'views', title: 'Views', jsonPath: 'views', type: 'array', description: 'List, form, kanban, tree, and related view metadata.' }, + { key: 'emailTemplates', title: 'Email Templates', jsonPath: 'emailTemplates', type: 'array', description: 'Email template metadata.' }, + { key: 'smsTemplates', title: 'SMS Templates', jsonPath: 'smsTemplates', type: 'array', description: 'SMS template metadata.' }, + { key: 'mediaStorageProviders', title: 'Media Storage Providers', jsonPath: 'mediaStorageProviders', type: 'array', description: 'Configured media storage providers.' }, + { key: 'savedFilters', title: 'Saved Filters', jsonPath: 'savedFilters', type: 'array', description: 'Saved filter definitions.' }, + { key: 'securityRules', title: 'Security Rules', jsonPath: 'securityRules', type: 'array', description: 'Security rule definitions.' }, + { key: 'listOfValues', title: 'List Of Values', jsonPath: 'listOfValues', type: 'array', description: 'List of values entries.' }, + { key: 'dashboards', title: 'Dashboards', jsonPath: 'dashboards', type: 'array', description: 'Dashboard metadata definitions.' }, + { key: 'testing', title: 'Testing', jsonPath: 'testing', type: 'object', description: 'Testing roles, users, specs, and scenarios.' }, + { key: 'scheduledJobs', title: 'Scheduled Jobs', jsonPath: 'scheduledJobs', type: 'array', description: 'Scheduled job metadata definitions.' }, + { key: 'modelSequences', title: 'Model Sequences', jsonPath: 'modelSequences', type: 'array', description: 'Model sequence definitions.' }, +]; + +@Injectable() +export class ModuleMetadataExplorerService { + private readonly logger = new Logger(ModuleMetadataExplorerService.name); + + constructor( + private readonly moduleMetadataHelperService: ModuleMetadataHelperService, + ) { } + + /** + * Input: module name string. + * Output: explorer manifest containing file details, section summaries, and document validation state. + * Logic: loads the module metadata JSON, computes section-level summary information, and returns + * the high-level descriptor the explorer UI can use to render navigation and status badges. + */ + async getManifest(moduleName: string) { + const { document, filePath, stats } = await this.loadMetadataDocument(moduleName); + const validation = this.validateDocumentValue(document); + + const sections = SECTION_DEFINITIONS.map((definition) => { + const value = this.getValueAtPath(document, definition.jsonPath); + return { + ...definition, + exists: value !== undefined, + itemCount: Array.isArray(value) ? value.length : undefined, + nodeCount: this.countNodes(value), + preview: this.buildPreview(value, 96), + }; + }); + + return { + moduleName, + filePath, + lastModifiedAt: stats.mtime.toISOString(), + sections, + validation, + }; + } + + /** + * Input: module name string. + * Output: full metadata document payload with file metadata and validation results. + * Logic: reads the resolved module metadata JSON from disk and returns it as the canonical + * document payload for full-document editing scenarios. + */ + async getDocument(moduleName: string) { + const { document, filePath, stats } = await this.loadMetadataDocument(moduleName); + return { + moduleName, + filePath, + lastModifiedAt: stats.mtime.toISOString(), + value: document, + validation: this.validateDocumentValue(document), + }; + } + + /** + * Input: module name string and a DTO containing the replacement JSON document in `value`. + * Output: refreshed full document payload after a successful write. + * Logic: validates the incoming JSON document at a coarse structural level, writes it back to + * the resolved metadata file, and then reloads the saved document to return the persisted state. + */ + @DisallowInProduction() + async updateDocument(moduleName: string, dto: MetadataExplorerWriteDto) { + this.assertValueProvided(dto?.value); + const validation = this.validateDocumentValue(dto.value); + if (!validation.valid) { + throw new BadRequestException(validation); + } + + const filePath = await this.moduleMetadataHelperService.getModuleMetadataFilePath(moduleName); + await this.writeMetadataDocument(filePath, dto.value); + return this.getDocument(moduleName); + } + + /** + * Input: module name string and an optional DTO containing a document candidate in `value`. + * Output: validation result object with `valid` flag and issue list. + * Logic: validates either the supplied document candidate or the currently persisted metadata + * file without mutating anything, so the UI can perform pre-save checks. + */ + async validateDocument(moduleName: string, dto?: MetadataExplorerWriteDto) { + const filePath = await this.moduleMetadataHelperService.getModuleMetadataFilePath(moduleName); + const value = dto && Object.prototype.hasOwnProperty.call(dto, 'value') + ? dto.value + : (await this.loadMetadataDocument(moduleName)).document; + return { + moduleName, + filePath, + ...this.validateDocumentValue(value), + }; + } + + /** + * Input: module name string and a section key such as `models` or `views`. + * Output: section payload containing metadata about the section, its JSON value, and validation. + * Logic: resolves the section definition, extracts that fragment from the full metadata document, + * and returns the section in a UI-friendly envelope. + */ + async getSection(moduleName: string, sectionKey: string) { + const definition = this.getSectionDefinition(sectionKey); + const { document, filePath, stats } = await this.loadMetadataDocument(moduleName); + const value = this.getValueAtPath(document, definition.jsonPath); + + return { + moduleName, + filePath, + lastModifiedAt: stats.mtime.toISOString(), + section: this.buildSectionResponse(definition, value), + validation: this.validateSectionValue(definition, value), + }; + } + + /** + * Input: module name string, section key, and a DTO containing the replacement section JSON in `value`. + * Output: refreshed section payload after the section is written back into the document. + * Logic: validates the incoming section shape, merges it into the current document at the + * configured JSON path, validates the resulting document, writes the file, and reloads the section. + */ + @DisallowInProduction() + async updateSection(moduleName: string, sectionKey: string, dto: MetadataExplorerWriteDto) { + this.assertValueProvided(dto?.value); + const definition = this.getSectionDefinition(sectionKey); + const sectionValidation = this.validateSectionValue(definition, dto.value); + if (!sectionValidation.valid) { + throw new BadRequestException(sectionValidation); + } + + const { document, filePath } = await this.loadMetadataDocument(moduleName); + this.setValueAtPath(document, definition.jsonPath, dto.value); + + const documentValidation = this.validateDocumentValue(document); + if (!documentValidation.valid) { + throw new BadRequestException(documentValidation); + } + + await this.writeMetadataDocument(filePath, document); + return this.getSection(moduleName, sectionKey); + } + + /** + * Input: module name string, section key, and an optional DTO containing a section candidate in `value`. + * Output: validation result scoped to that section. + * Logic: validates either the supplied section candidate or the persisted section value against + * the configured section definition without performing any writes. + */ + async validateSection(moduleName: string, sectionKey: string, dto?: MetadataExplorerWriteDto) { + const definition = this.getSectionDefinition(sectionKey); + const value = dto && Object.prototype.hasOwnProperty.call(dto, 'value') + ? dto.value + : this.getValueAtPath((await this.loadMetadataDocument(moduleName)).document, definition.jsonPath); + + return { + moduleName, + sectionKey, + section: { + key: definition.key, + title: definition.title, + jsonPath: definition.jsonPath, + type: definition.type, + }, + ...this.validateSectionValue(definition, value), + }; + } + + /** + * Input: module name string plus search query DTO with query text, optional section scope, + * exact-match toggle, preview length, limit, and offset. + * Output: paginated list of key/value matches with JSON paths and section attribution. + * Logic: loads the document, optionally narrows the search root to a specific section, and then + * recursively scans keys and primitive values for textual matches. + */ + async search(moduleName: string, query: MetadataExplorerSearchQueryDto) { + const needle = `${query?.query ?? ''}`.trim(); + if (!needle) { + throw new BadRequestException('Search query is required.'); + } + + const { document } = await this.loadMetadataDocument(moduleName); + const targetSection = query?.sectionKey ? this.getSectionDefinition(query.sectionKey) : null; + const searchRoot = targetSection ? this.getValueAtPath(document, targetSection.jsonPath) : document; + const rootPath = targetSection ? targetSection.jsonPath : ''; + const limit = query?.limit ?? 10; + const offset = query?.offset ?? 0; + const matches = this.collectMatches(searchRoot, rootPath, { + needle, + exact: query?.exact ?? false, + limit, + offset, + previewLength: query?.previewLength ?? 120, + matchKeys: true, + matchValues: true, + }); + + return { + moduleName, + query: needle, + sectionKey: targetSection?.key ?? null, + meta: { + totalMatches: matches.total, + returned: matches.items.length, + limit, + offset, + }, + matches: matches.items, + }; + } + + /** + * Input: module name string plus reference-query DTO with needle, optional section scope, + * match strategy flags, exclusion path, limit, and offset. + * Output: paginated list of reference-like matches across keys and/or values. + * Logic: performs the same recursive traversal engine as search, but with defaults tuned for + * exact reference lookups and optional exclusion of the source path being inspected. + */ + async findReferences(moduleName: string, query: MetadataExplorerReferencesQueryDto) { + const needle = `${query?.needle ?? ''}`.trim(); + if (!needle) { + throw new BadRequestException('Reference needle is required.'); + } + + const { document } = await this.loadMetadataDocument(moduleName); + const targetSection = query?.sectionKey ? this.getSectionDefinition(query.sectionKey) : null; + const searchRoot = targetSection ? this.getValueAtPath(document, targetSection.jsonPath) : document; + const rootPath = targetSection ? targetSection.jsonPath : ''; + const limit = query?.limit ?? 10; + const offset = query?.offset ?? 0; + const matches = this.collectMatches(searchRoot, rootPath, { + needle, + exact: query?.exact ?? true, + limit, + offset, + previewLength: 140, + matchKeys: query?.matchKeys ?? true, + matchValues: query?.matchValues ?? true, + excludePath: query?.excludePath, + }); + + return { + moduleName, + needle, + sectionKey: targetSection?.key ?? null, + meta: { + totalMatches: matches.total, + returned: matches.items.length, + limit, + offset, + }, + matches: matches.items, + }; + } + + /** + * Input: module name string. + * Output: parsed JSON document, resolved file path, and file stats. + * Logic: resolves the module metadata file location, reads it from disk, parses JSON, and maps + * common file and syntax errors into explorer-specific HTTP exceptions. + */ + private async loadMetadataDocument(moduleName: string) { + const filePath = await this.moduleMetadataHelperService.getModuleMetadataFilePath(moduleName); + if (!filePath) { + throw new NotFoundException(`Unable to resolve metadata file for module: ${moduleName}`); + } + + try { + const [raw, stats] = await Promise.all([ + fs.readFile(filePath, 'utf-8'), + fs.stat(filePath), + ]); + const document = JSON.parse(raw); + return { document, filePath, stats }; + } catch (error: any) { + if (error?.code === 'ENOENT') { + throw new NotFoundException(`Metadata file not found for module: ${moduleName}`); + } + + if (error instanceof SyntaxError) { + throw new BadRequestException(`Metadata file for module ${moduleName} contains invalid JSON.`); + } + + this.logger.error(`Failed to load metadata for module ${moduleName}`, error); + throw error; + } + } + + /** + * Input: absolute metadata file path and the JSON document value to persist. + * Output: none. + * Logic: ensures the target directory exists and writes a formatted JSON file with a trailing + * newline so the explorer remains the canonical persistence path for metadata edits. + */ + private async writeMetadataDocument(filePath: string, document: any) { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, `${JSON.stringify(document, null, 2)}\n`, 'utf-8'); + } + + /** + * Input: section key string. + * Output: matching section definition object. + * Logic: looks up the fixed explorer section registry and throws a 404 when the caller asks for + * a section the explorer does not expose. + */ + private getSectionDefinition(sectionKey: string): MetadataExplorerSectionDefinition { + const definition = SECTION_DEFINITIONS.find((item) => item.key === sectionKey); + if (!definition) { + throw new NotFoundException(`Metadata explorer section not found: ${sectionKey}`); + } + return definition; + } + + /** + * Input: section definition and extracted section JSON value. + * Output: normalized section response payload for the API. + * Logic: enriches the raw section value with descriptive metadata, existence flags, counts, and + * the original JSON so the UI can render a section panel without extra computation. + */ + private buildSectionResponse(definition: MetadataExplorerSectionDefinition, value: any) { + return { + key: definition.key, + title: definition.title, + jsonPath: definition.jsonPath, + type: definition.type, + description: definition.description, + exists: value !== undefined, + itemCount: Array.isArray(value) ? value.length : undefined, + nodeCount: this.countNodes(value), + value, + }; + } + + /** + * Input: candidate full metadata document. + * Output: validation result containing a boolean validity flag and issue list. + * Logic: performs coarse structural checks for the overall document contract and also validates + * each known explorer section against its expected container type. + */ + private validateDocumentValue(document: any) { + const issues: MetadataValidationIssue[] = []; + + if (!document || typeof document !== 'object' || Array.isArray(document)) { + issues.push({ + path: '', + message: 'Metadata document must be a JSON object.', + severity: 'error', + }); + } + + const moduleMetadata = document?.moduleMetadata; + if (!moduleMetadata || typeof moduleMetadata !== 'object' || Array.isArray(moduleMetadata)) { + issues.push({ + path: 'moduleMetadata', + message: 'moduleMetadata must be present as an object.', + severity: 'error', + }); + } + + if (moduleMetadata && !Array.isArray(moduleMetadata.models)) { + issues.push({ + path: 'moduleMetadata.models', + message: 'moduleMetadata.models must be an array.', + severity: 'error', + }); + } + + for (const definition of SECTION_DEFINITIONS) { + const sectionValue = this.getValueAtPath(document, definition.jsonPath); + const sectionValidation = this.validateSectionValue(definition, sectionValue, true); + issues.push(...sectionValidation.issues); + } + + return { + valid: issues.every((issue) => issue.severity !== 'error'), + issues, + }; + } + + /** + * Input: section definition, candidate section value, and optional allow-undefined toggle. + * Output: validation result for that section. + * Logic: checks whether a section is present when required and whether it matches the configured + * array or object shape expected by the explorer contract. + */ + private validateSectionValue( + definition: MetadataExplorerSectionDefinition, + value: any, + allowUndefined = false, + ) { + const issues: MetadataValidationIssue[] = []; + + if (value === undefined) { + if (!allowUndefined) { + issues.push({ + path: definition.jsonPath, + message: `${definition.title} is missing from the metadata document.`, + severity: 'error', + }); + } + return { + valid: issues.every((issue) => issue.severity !== 'error'), + issues, + }; + } + + const isArray = Array.isArray(value); + if (definition.type === 'array' && !isArray) { + issues.push({ + path: definition.jsonPath, + message: `${definition.title} must be an array.`, + severity: 'error', + }); + } + + if (definition.type === 'object' && (value === null || typeof value !== 'object' || isArray)) { + issues.push({ + path: definition.jsonPath, + message: `${definition.title} must be an object.`, + severity: 'error', + }); + } + + return { + valid: issues.every((issue) => issue.severity !== 'error'), + issues, + }; + } + + /** + * Input: source object and dot-delimited JSON path. + * Output: value found at that path, or undefined if any segment is missing. + * Logic: walks the object graph one segment at a time and safely stops when an intermediate node + * does not exist. + */ + private getValueAtPath(source: any, jsonPath: string): any { + if (!jsonPath) return source; + return jsonPath.split('.').reduce((acc, key) => (acc == null ? undefined : acc[key]), source); + } + + /** + * Input: target object, dot-delimited JSON path, and replacement value. + * Output: none. + * Logic: creates missing object containers along the path as needed and then assigns the final + * value at the terminal segment. + */ + private setValueAtPath(target: any, jsonPath: string, value: any) { + const parts = jsonPath.split('.'); + let current = target; + + for (let index = 0; index < parts.length - 1; index++) { + const part = parts[index]; + if (!current[part] || typeof current[part] !== 'object' || Array.isArray(current[part])) { + current[part] = {}; + } + current = current[part]; + } + + current[parts[parts.length - 1]] = value; + } + + /** + * Input: any JSON value. + * Output: integer node count representing the size of that subtree. + * Logic: recursively counts container nodes and primitive leaves so the explorer can show rough + * section complexity metrics in manifests and section responses. + */ + private countNodes(value: any): number { + if (value === undefined || value === null) return 0; + if (Array.isArray(value)) { + return value.reduce((sum, item) => sum + this.countNodes(item), 1); + } + if (typeof value === 'object') { + return Object.values(value).reduce((sum, item) => sum + this.countNodes(item), 1); + } + return 1; + } + + /** + * Input: any JSON value and a maximum preview length. + * Output: string preview suitable for manifest/search display. + * Logic: converts the value to a string representation and truncates it to a bounded length for + * compact display in explorer summaries. + */ + private buildPreview(value: any, maxLength: number): string { + if (value === undefined) return ''; + + const normalized = typeof value === 'string' + ? value + : JSON.stringify(value); + + if (!normalized) return ''; + return normalized.length > maxLength ? `${normalized.slice(0, maxLength)}...` : normalized; + } + + /** + * Input: search root value, its root JSON path, and traversal options. + * Output: total match count plus the paginated slice of matches requested by the caller. + * Logic: recursively traverses arrays, objects, and primitives, recording key and/or value + * matches together with their JSON paths and preview text. + */ + private collectMatches( + root: any, + rootPath: string, + opts: { + needle: string; + exact: boolean; + limit: number; + offset: number; + previewLength: number; + matchKeys: boolean; + matchValues: boolean; + excludePath?: string; + }, + ) { + const results: MetadataSearchMatch[] = []; + const normalizedNeedle = opts.exact ? opts.needle : opts.needle.toLowerCase(); + + const visit = (value: any, currentPath: string) => { + if (value === undefined) return; + + if (Array.isArray(value)) { + value.forEach((item, index) => { + visit(item, this.joinPath(currentPath, `${index}`)); + }); + return; + } + + if (value && typeof value === 'object') { + Object.entries(value).forEach(([key, child]) => { + const childPath = this.joinPath(currentPath, key); + + if (opts.matchKeys && this.matchesNeedle(key, normalizedNeedle, opts.exact) && childPath !== opts.excludePath) { + results.push({ + path: childPath, + sectionKey: this.resolveSectionKey(childPath), + matchType: 'key', + valueType: 'key', + preview: this.buildPreview(child, opts.previewLength), + }); + } + + visit(child, childPath); + }); + return; + } + + if (!opts.matchValues) return; + const comparable = value == null ? '' : `${value}`; + if (this.matchesNeedle(comparable, normalizedNeedle, opts.exact) && currentPath !== opts.excludePath) { + results.push({ + path: currentPath, + sectionKey: this.resolveSectionKey(currentPath), + matchType: 'value', + valueType: typeof value, + preview: this.buildPreview(value, opts.previewLength), + }); + } + }; + + visit(root, rootPath); + + return { + total: results.length, + items: results.slice(opts.offset, opts.offset + opts.limit), + }; + } + + /** + * Input: candidate string value, normalized search needle, and exact-match flag. + * Output: boolean indicating whether the value matches the search. + * Logic: performs either exact comparison or case-insensitive containment matching, depending on + * the caller's intent. + */ + private matchesNeedle(value: string, normalizedNeedle: string, exact: boolean): boolean { + if (exact) { + return value === normalizedNeedle; + } + return value.toLowerCase().includes(normalizedNeedle); + } + + /** + * Input: JSON path string from a match result. + * Output: matching explorer section key or null when the path is outside known sections. + * Logic: finds the deepest section definition whose JSON path prefixes the supplied match path so + * search results can be attributed back to a sidebar section. + */ + private resolveSectionKey(jsonPath: string): string | null { + const sorted = [...SECTION_DEFINITIONS].sort((a, b) => b.jsonPath.length - a.jsonPath.length); + const matched = sorted.find((section) => + jsonPath === section.jsonPath + || jsonPath.startsWith(`${section.jsonPath}.`) + || jsonPath.startsWith(`${section.jsonPath}[`) + ); + return matched?.key ?? null; + } + + /** + * Input: current base JSON path and next property/index segment. + * Output: composed JSON path string. + * Logic: appends either dot notation or array index notation so traversal results use a stable, + * readable path format. + */ + private joinPath(base: string, next: string): string { + if (!base) return /^\d+$/.test(next) ? `[${next}]` : next; + return /^\d+$/.test(next) ? `${base}[${next}]` : `${base}.${next}`; + } + + /** + * Input: arbitrary candidate value. + * Output: none; throws when the value is missing. + * Logic: enforces that write and validation endpoints receive an explicit JSON payload instead of + * silently treating an omitted body as intentional input. + */ + private assertValueProvided(value: any) { + if (value === undefined) { + throw new BadRequestException('A JSON value payload is required.'); + } + } +} diff --git a/src/services/module-metadata.service.ts b/src/services/module-metadata.service.ts index 1b61a048..fb2c0485 100755 --- a/src/services/module-metadata.service.ts +++ b/src/services/module-metadata.service.ts @@ -2,8 +2,10 @@ import { BadRequestException, forwardRef, Inject, Injectable, Logger, NotFoundEx import { InjectDataSource } from '@nestjs/typeorm'; import { DEFAULT_MEDIA_FILE_STORAGE_DIR } from "src/services/settings/default-settings-provider.service"; import type { SolidCoreSetting } from "src/services/settings/default-settings-provider.service"; -import { DataSource, EntityManager, SelectQueryBuilder } from 'typeorm'; +import { DataSource, EntityManager, In, SelectQueryBuilder } from 'typeorm'; +import { ActionMetadata } from '../entities/action-metadata.entity'; import { CreateModuleMetadataDto } from '../dtos/create-module-metadata.dto'; +import { MenuItemMetadata } from '../entities/menu-item-metadata.entity'; import { ModuleMetadata } from '../entities/module-metadata.entity'; import { classify } from '../helpers/string.helper'; @@ -22,6 +24,7 @@ import { ADD_MODULE_COMMAND, SchematicService, } from '../helpers/schematic.service'; +import { CommandService } from '../helpers/command.service'; import { SolidRegistry } from '../helpers/solid-registry'; import { CodeGenerationOptions, ModuleMetadataConfiguration } from '../interfaces'; import { CrudHelperService } from './crud-helper.service'; @@ -40,6 +43,7 @@ export class ModuleMetadataService { private readonly moduleMetadataRepo: ModuleMetadataRepository, private readonly crudHelperService: CrudHelperService, private readonly schematicService: SchematicService, + private readonly commandService: CommandService, private readonly fileService: DiskFileService, private readonly settingService: SettingService, @@ -123,7 +127,7 @@ export class ModuleMetadataService { await this.createInFile(module); return module }); - } catch (error) { + } catch (error: any) { // console.error('Transaction failed:', error); this.logger.error('Transaction failed:', error); throw error; @@ -181,7 +185,7 @@ export class ModuleMetadataService { actionUserKey: `${module?.name}-home-action`, moduleUserKey: module?.name, parentMenuItemUserKey: "", - iconName : "home" + iconName: "home" } ], views: [], @@ -194,9 +198,8 @@ export class ModuleMetadataService { // Convert the object to JSON string const metadataJson = JSON.stringify(moduleMetaDataJson, null, 2); - // Create the folder path inside 'module-metadata' - const folderPath = path.resolve(process.cwd(), 'module-metadata', module.name); const filePath = await this.moduleMetadataHelperService.getModuleMetadataFilePath(module.name); + const folderPath = path.dirname(filePath); // Ensure the folder exists await fs.mkdir(folderPath, { recursive: true }); @@ -204,7 +207,7 @@ export class ModuleMetadataService { // Write the JSON to the file await fs.writeFile(filePath, metadataJson); - } catch (error) { + } catch (error: any) { // console.error('File creation failed:', error); this.logger.error('File creation failed:', error); throw new Error(ERROR_MESSAGES.FILE_WRITE_FAILED); // Trigger rollback @@ -219,7 +222,7 @@ export class ModuleMetadataService { await this.updateInFile(module); return module }); - } catch (error) { + } catch (error: any) { // console.error('Transaction failed:', error); this.logger.error('Transaction failed:', error); throw error; @@ -255,7 +258,7 @@ export class ModuleMetadataService { try { metaData = await this.moduleMetadataHelperService.getModuleMetadataConfiguration(filePath); - } catch (error) { + } catch (error: any) { metaData = { moduleMetadata: { name: null, @@ -290,7 +293,7 @@ export class ModuleMetadataService { const updatedContent = JSON.stringify(metaData, null, 2); await fs.writeFile(filePath, updatedContent); - } catch (error) { + } catch (error: any) { // console.error('File creation failed:', error); this.logger.error('File creation failed:', error); throw new Error(ERROR_MESSAGES.FILE_WRITE_FAILED); // Trigger rollback @@ -353,9 +356,11 @@ export class ModuleMetadataService { this.logger.log(`Cleaning up for module: ${moduleEntity.name}.`); const modulePath = await this.moduleMetadataHelperService.getModulePath(moduleEntity.name); + const solidUiModulePath = await this.moduleMetadataHelperService.getSolidUiModulePath(moduleEntity.name); if (modulePath) { this.logger.log(`Module path: ${modulePath}`); + this.logger.log(`Solid UI module path: ${solidUiModulePath}`); // Metadata file to be deleted const moduleMetadataPAth = await this.moduleMetadataHelperService.getModuleMetadataFolderPath(moduleEntity.name) @@ -363,17 +368,112 @@ export class ModuleMetadataService { try { await fs.rm(modulePath, { recursive: true, force: true }); + if (solidUiModulePath) { + await fs.rm(solidUiModulePath, { recursive: true, force: true }); + this.logger.log(`Deleted solid-ui module path: ${solidUiModulePath}`); + } await fs.rm(moduleMetadataPAth, { recursive: true, force: true }); this.logger.log(`Deleted file: ${moduleMetadataPAth}`); - } catch (error) { + } catch (error: any) { this.logger.error(`Error deleting file: ${moduleMetadataPAth}`, error); throw new Error(ERROR_MESSAGES.FILE_DELETE_FAILED); // Trigger rollback } } - await this.dataSource.query( - `CALL cleanup_module_metadata($1, $2)`, - [moduleEntity.name, true], - ); + await this.cleanupAssociatedMenusAndActions(moduleEntity.id); + } + + private async cleanupAssociatedMenusAndActions(moduleId: number) { + const actionRepo = this.dataSource.getRepository(ActionMetadata); + const menuRepo = this.dataSource.getRepository(MenuItemMetadata); + + const actions = await actionRepo.find({ + where: { + module: { id: moduleId }, + }, + }); + const actionIds = actions.map((action) => action.id); + + const menus = await this.findMenusForModuleCleanup(moduleId, actionIds); + if (menus.length > 0) { + const menuIds = menus.map((menu) => menu.id).filter(Boolean); + for (const menu of menus) { + if (menu.roles?.length) { + await this.dataSource + .createQueryBuilder() + .relation(MenuItemMetadata, 'roles') + .of(menu.id) + .remove(menu.roles.map((role) => role.id)); + } + } + + if (menuIds.length > 0) { + await menuRepo + .createQueryBuilder() + .update(MenuItemMetadata) + .set({ parentMenuItem: null as any }) + .where('id IN (:...menuIds)', { menuIds }) + .execute(); + + await menuRepo + .createQueryBuilder() + .delete() + .from(MenuItemMetadata) + .where('id IN (:...menuIds)', { menuIds }) + .execute(); + } + + this.logger.log(`Deleted ${menus.length} menu metadata record(s) for module id ${moduleId}`); + } + + if (actions.length > 0) { + await actionRepo.remove(actions); + this.logger.log(`Deleted ${actions.length} action metadata record(s) for module id ${moduleId}`); + } + } + + private async findMenusForModuleCleanup(moduleId: number, actionIds: number[]) { + const menuRepo = this.dataSource.getRepository(MenuItemMetadata); + const menusById = new Map(); + + const rootMenus = await menuRepo.find({ + where: [ + { + module: { id: moduleId }, + }, + ...(actionIds.length > 0 + ? [ + { + action: { id: In(actionIds) }, + }, + ] + : []), + ], + relations: ['roles', 'action', 'parentMenuItem'], + }); + + rootMenus.forEach((menu) => menusById.set(menu.id, menu)); + + let parentIds = rootMenus.map((menu) => menu.id); + while (parentIds.length > 0) { + const childMenus = await menuRepo.find({ + where: { + parentMenuItem: { id: In(parentIds) }, + }, + relations: ['roles', 'action', 'parentMenuItem'], + }); + + const nextParentIds: number[] = []; + for (const childMenu of childMenus) { + if (!menusById.has(childMenu.id)) { + menusById.set(childMenu.id, childMenu); + nextParentIds.push(childMenu.id); + } + } + + parentIds = nextParentIds; + } + + return Array.from(menusById.values()); } async deleteMany(ids: number[]): Promise { @@ -389,9 +489,12 @@ export class ModuleMetadataService { id: id, } }); - // if (!entity) { - // throw new Error(`Entity with id ${id} not found`); - // } + if (!entity) { + this.logger.warn(`Module metadata with id ${id} not found. Skipping deleteMany cleanup for this id.`); + continue; + } + + await this.cleanupOnDelete(entity.id); removedEntities.push(await this.moduleMetadataRepo.remove(entity)); } @@ -403,6 +506,41 @@ export class ModuleMetadataService { return true } + @DisallowInProduction() + async seedModuleFromMetadata(moduleId: number) { + const module = await this.findOne(moduleId); + const seeder = this.solidRegistry + .getSeeders() + .filter((registeredSeeder) => registeredSeeder.name === 'ModuleMetadataSeederService') + .map((registeredSeeder) => registeredSeeder.instance) + .pop(); + + if (!seeder || typeof seeder.seed !== 'function') { + throw new NotFoundException('ModuleMetadataSeederService not found. Cannot seed module metadata.'); + } + + await seeder.seed({ + modulesToSeed: [module.name], + seedGlobalMetadata: false, + }); + + return { + success: true, + moduleName: module.name, + message: `Seeded metadata for module ${module.name}.`, + }; + } + + @DisallowInProduction() + async generateCodeViaCtl(moduleId: number): Promise { + const module = await this.findOne(moduleId); + return this.commandService.executeCommandWithArgs({ + command: 'npx', + args: ['@solidxai/solidctl@latest', 'generate', 'module', `--name=${module.name}`], + cwd: path.join(process.cwd(), '..'), + }); + } + @DisallowInProduction() async generateCode(options: CodeGenerationOptions): Promise { if (!options.moduleId && !options.moduleUserKey) { diff --git a/src/services/module-package.service.ts b/src/services/module-package.service.ts new file mode 100644 index 00000000..2faf14cf --- /dev/null +++ b/src/services/module-package.service.ts @@ -0,0 +1,1171 @@ +import { + BadRequestException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { createHash } from 'crypto'; +import archiver from 'archiver'; +import { createWriteStream } from 'fs'; +import { DisallowInProduction } from 'src/decorators/disallow-in-production.decorator'; +import { ConfirmModulePackageImportDto } from 'src/dtos/confirm-module-package-import.dto'; +import { RunModulePackageBuildDto } from 'src/dtos/run-module-package-build.dto'; +import { RunModulePackageSeedDto } from 'src/dtos/run-module-package-seed.dto'; +import { CommandService } from 'src/helpers/command.service'; +import { ModuleMetadataHelperService } from 'src/helpers/module-metadata-helper.service'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { v4 as uuidv4 } from 'uuid'; + +type ModulePackageManifest = { + schemaVersion: string; + packageType: string; + exportedAt?: string; + generatedBy?: { + name: string; + version?: string; + }; + module: { + name: string; + displayName?: string; + description?: string; + }; + contents: { + metadataPath: string; + apiModulePath: string; + uiModulePath: string; + }; + compatibility?: Record; + postImport?: Record; + checksums?: Record; +}; + +type ValidationPayload = { + valid: boolean; + errors: string[]; + warnings: string[]; +}; + +type PreviewPayload = { + module: { + name: string; + displayName?: string; + description?: string; + }; + manifest: ModulePackageManifest; + contentsSummary: { + totalEntries: number; + solidApiEntries: number; + solidUiEntries: number; + }; + requiredPaths: { + metadataPath: string; + apiModulePath: string; + uiModulePath: string; + }; + conflicts: string[]; + fileTree: string[]; + nextActions: string[]; +}; + +type ParsedArchive = { + entries: string[]; + archiveRootPrefix: string | null; + manifest: ModulePackageManifest; + metadataDocument: any; + validation: ValidationPayload; + preview: PreviewPayload; +}; + +type ModulePackageStatusFile = { + transactionKey: string; + status: string; + currentStep: string; + moduleName: string | null; + moduleDisplayName: string | null; + archiveFileName: string | null; + archiveFilePath: string | null; + extractDirPath: string | null; + archiveRootPrefix: string | null; + manifest: ModulePackageManifest | null; + metadataDocument: any; + preview: PreviewPayload | null; + validation: ValidationPayload | null; + conflicts: string[]; + outputs: { + import: string | null; + build: string | null; + seed: string | null; + }; + errorMessage: string | null; + createdAt: string; + updatedAt: string; +}; + +type ModulePackageExportFile = { + fileName: string; + filePath: string; + mimeType: string; +}; + +type ModulePackageRuntimeCleanupResult = { + runtimeRoot: string; + removedImportTransactions: number; + removedExportTransactions: number; + removedImportLooseEntries: number; + removedExportLooseEntries: number; + clearedActiveTransactionPointer: boolean; + clearedAt: string; +}; + +type ModulePackageActiveTransactionFile = { + transactionKey: string; + updatedAt: string; +}; + +enum ModulePackageStatus { + uploaded = 'uploaded', + validated = 'validated', + awaiting_confirmation = 'awaiting_confirmation', + import_running = 'import_running', + awaiting_restart = 'awaiting_restart', + build_running = 'build_running', + build_failed = 'build_failed', + build_succeeded = 'build_succeeded', + seed_running = 'seed_running', + seed_failed = 'seed_failed', + completed = 'completed', + failed = 'failed', +} + +@Injectable() +export class ModulePackageService { + private readonly logger = new Logger(ModulePackageService.name); + private static readonly SUPPORTED_SCHEMA_VERSION = '1.0'; + private static readonly SUPPORTED_PACKAGE_TYPE = 'solidx-module'; + + constructor( + private readonly commandService: CommandService, + private readonly moduleMetadataHelperService: ModuleMetadataHelperService, + ) { } + + @DisallowInProduction() + async validateUpload(file: Express.Multer.File) { + if (!file) { + throw new BadRequestException('A .sldx archive file is required.'); + } + + await this.cleanupModulePackageTransactions(); + + const transactionKey = uuidv4(); + const workingDir = await this.createWorkingDir(transactionKey); + const archiveFileName = file.originalname || path.basename(file.path); + const archiveFilePath = path.join(workingDir, archiveFileName); + const extractDirPath = path.join(workingDir, 'extract'); + + await fs.mkdir(extractDirPath, { recursive: true }); + await fs.rename(file.path, archiveFilePath); + + const parsed = await this.parseArchive(archiveFilePath); + await this.extractArchive(archiveFilePath, extractDirPath); + + const transaction: ModulePackageStatusFile = { + transactionKey, + status: parsed.validation.valid ? ModulePackageStatus.awaiting_confirmation : ModulePackageStatus.failed, + currentStep: 'preview', + moduleName: parsed.manifest.module.name, + moduleDisplayName: parsed.manifest.module.displayName ?? null, + archiveFileName, + archiveFilePath, + extractDirPath, + archiveRootPrefix: parsed.archiveRootPrefix, + manifest: parsed.manifest, + metadataDocument: parsed.metadataDocument, + preview: parsed.preview, + validation: parsed.validation, + conflicts: parsed.preview.conflicts, + outputs: { + import: null, + build: null, + seed: null, + }, + errorMessage: parsed.validation.valid ? null : parsed.validation.errors.join('\n'), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + await this.writeStatusFile(transactionKey, transaction); + return this.toStatusResponse(transaction); + } + + async getStatus(transactionKey: string) { + const transaction = await this.loadTransaction(transactionKey); + return this.toStatusResponse(transaction); + } + + @DisallowInProduction() + async getLatestResumableImport() { + await this.cleanupModulePackageTransactions(); + const transaction = await this.resolveLatestResumableTransaction(); + return transaction ? this.toStatusResponse(transaction) : null; + } + + @DisallowInProduction() + async dismissImport(transactionKey: string) { + const transaction = await this.loadTransaction(transactionKey); + await this.clearActiveTransactionIfMatches(transaction.transactionKey); + return this.toStatusResponse(transaction); + } + + @DisallowInProduction() + async clearPackageRuntime(): Promise { + const runtimeRoot = this.getModulePackageRuntimeRoot(); + const importsRoot = this.getModulePackageImportsRoot(); + const exportsRoot = this.getModulePackageExportsRoot(); + const snapshot = await this.collectRuntimeCleanupSnapshot(); + + await fs.rm(runtimeRoot, { recursive: true, force: true }); + await fs.mkdir(importsRoot, { recursive: true }); + await fs.mkdir(exportsRoot, { recursive: true }); + + return { + runtimeRoot, + ...snapshot, + clearedAt: new Date().toISOString(), + }; + } + + @DisallowInProduction() + async exportModulePackage(moduleNameInput: string): Promise { + const moduleName = (moduleNameInput ?? '').trim(); + if (!moduleName) { + throw new BadRequestException('A module name is required to export a module package.'); + } + + const solidApiModulePath = this.getSolidApiModuleTargetPath(moduleName); + const solidUiModulePath = this.getSolidUiModuleTargetPath(moduleName); + const metadataFilePath = await this.moduleMetadataHelperService.getModuleMetadataFilePath(moduleName); + + await this.assertPathExists(solidApiModulePath, 'solid-api module path'); + await this.assertPathExists(solidUiModulePath, 'solid-ui module path'); + await this.assertPathExists(metadataFilePath, 'module metadata file'); + + const metadataDocument = this.parseMetadataDocument( + await fs.readFile(metadataFilePath, 'utf-8'), + [], + ); + + const transactionKey = uuidv4(); + const exportRoot = path.join(this.getModulePackageExportsRoot(), transactionKey); + const stagingDir = path.join(exportRoot, 'staging'); + const archiveFileName = `${moduleName}.sldx`; + const archiveFilePath = path.join(exportRoot, archiveFileName); + + await fs.mkdir(path.join(stagingDir, 'solid-api', 'src'), { recursive: true }); + await fs.mkdir(path.join(stagingDir, 'solid-ui', 'src'), { recursive: true }); + + await fs.cp(solidApiModulePath, path.join(stagingDir, 'solid-api', 'src', moduleName), { recursive: true }); + await fs.cp(solidUiModulePath, path.join(stagingDir, 'solid-ui', 'src', moduleName), { recursive: true }); + + const manifest = await this.buildExportManifest(moduleName, metadataDocument, stagingDir); + await fs.writeFile( + path.join(stagingDir, 'manifest.json'), + JSON.stringify(manifest, null, 2), + 'utf-8', + ); + + await this.createArchiveFromDirectory(stagingDir, archiveFilePath); + + return { + fileName: archiveFileName, + filePath: archiveFilePath, + mimeType: 'application/zip', + }; + } + + @DisallowInProduction() + async confirmImport(transactionKey: string, dto: ConfirmModulePackageImportDto = {}) { + const transaction = await this.loadTransaction(transactionKey); + const preview = transaction.preview; + const validation = transaction.validation; + + if (!preview || !validation) { + throw new BadRequestException('The module package transaction is missing preview or validation data.'); + } + + if (!validation.valid) { + throw new BadRequestException({ + message: 'The uploaded module package is invalid and cannot be imported.', + validation, + }); + } + + const overwriteExisting = dto.overwriteExisting ?? false; + if (preview.conflicts.length > 0 && !overwriteExisting) { + throw new BadRequestException({ + message: 'Conflicts were detected. Re-submit with overwriteExisting=true to continue.', + conflicts: preview.conflicts, + }); + } + + transaction.status = ModulePackageStatus.import_running; + transaction.currentStep = 'import'; + transaction.errorMessage = null; + await this.writeStatusFile(transactionKey, transaction); + + try { + const moduleName = preview.module.name; + const solidApiTarget = this.getSolidApiModuleTargetPath(moduleName); + const solidUiTarget = this.getSolidUiModuleTargetPath(moduleName); + const extractRoot = transaction.archiveRootPrefix + ? path.join(transaction.extractDirPath as string, transaction.archiveRootPrefix.replace(/\/$/, '')) + : transaction.extractDirPath as string; + const solidApiSource = path.join(extractRoot, 'solid-api', 'src', moduleName); + const solidUiSource = path.join(extractRoot, 'solid-ui', 'src', moduleName); + + await this.assertPathExists(solidApiSource, 'solid-api module source'); + await this.assertPathExists(solidUiSource, 'solid-ui module source'); + + if (overwriteExisting) { + await fs.rm(solidApiTarget, { recursive: true, force: true }); + await fs.rm(solidUiTarget, { recursive: true, force: true }); + } + + await fs.mkdir(path.dirname(solidApiTarget), { recursive: true }); + await fs.mkdir(path.dirname(solidUiTarget), { recursive: true }); + + await fs.cp(solidApiSource, solidApiTarget, { recursive: true, force: overwriteExisting }); + await fs.cp(solidUiSource, solidUiTarget, { recursive: true, force: overwriteExisting }); + + if (transaction.extractDirPath) { + await fs.rm(transaction.extractDirPath, { recursive: true, force: true }); + transaction.extractDirPath = null; + } + + transaction.status = ModulePackageStatus.awaiting_restart; + transaction.currentStep = 'restart'; + transaction.outputs.import = [ + `Placed solid-api module at ${solidApiTarget}`, + `Placed solid-ui module at ${solidUiTarget}`, + `Metadata file placed at ${preview.requiredPaths.metadataPath}`, + ].join('\n'); + await this.writeStatusFile(transactionKey, transaction); + await this.syncActiveTransactionPointer(transaction); + + return this.toStatusResponse(transaction); + } catch (error: any) { + transaction.status = ModulePackageStatus.failed; + transaction.currentStep = 'import'; + transaction.errorMessage = error.message ?? 'Failed to import the module package.'; + await this.writeStatusFile(transactionKey, transaction); + await this.syncActiveTransactionPointer(transaction); + throw error; + } + } + + @DisallowInProduction() + async runBuild(transactionKey: string, dto: RunModulePackageBuildDto = {}) { + const transaction = await this.loadTransaction(transactionKey); + transaction.status = ModulePackageStatus.build_running; + transaction.currentStep = 'build'; + transaction.errorMessage = null; + await this.writeStatusFile(transactionKey, transaction); + + try { + const buildSolidApi = dto.buildSolidApi ?? true; + const buildSolidUi = dto.buildSolidUi ?? true; + const outputs: string[] = []; + const failedTargets: string[] = []; + const projectRoot = this.getProjectRoot(); + + if (buildSolidApi) { + try { + const output = await this.commandService.executeCommandWithArgs({ + command: 'npx', + args: ['-y', '@solidxai/solidctl@latest', 'build'], + cwd: projectRoot, + }); + outputs.push(`solidctl build [success]\n${output}`.trim()); + } catch (error: any) { + failedTargets.push('solidctl build'); + outputs.push(`solidctl build [failed]\n${error?.message ?? 'Build failed.'}`.trim()); + } + } + + if (buildSolidUi) { + try { + const output = await this.commandService.executeCommandWithArgs({ + command: 'npx', + args: ['-y', '@solidxai/solidctl@latest', 'build', '--ui-only'], + cwd: projectRoot, + }); + outputs.push(`solidctl build --ui-only [success]\n${output}`.trim()); + } catch (error: any) { + failedTargets.push('solidctl build --ui-only'); + outputs.push(`solidctl build --ui-only [failed]\n${error?.message ?? 'Build failed.'}`.trim()); + } + } + + transaction.status = failedTargets.length > 0 + ? ModulePackageStatus.build_failed + : ModulePackageStatus.build_succeeded; + transaction.currentStep = 'build'; + transaction.outputs.build = outputs.join('\n\n'); + transaction.errorMessage = failedTargets.length > 0 + ? `Build completed with errors in: ${failedTargets.join(', ')}` + : null; + await this.writeStatusFile(transactionKey, transaction); + await this.syncActiveTransactionPointer(transaction); + + return this.toStatusResponse(transaction); + } catch (error: any) { + transaction.status = ModulePackageStatus.build_failed; + transaction.currentStep = 'build'; + transaction.errorMessage = error.message ?? 'Build failed.'; + transaction.outputs.build = error.message ?? ''; + await this.writeStatusFile(transactionKey, transaction); + await this.syncActiveTransactionPointer(transaction); + throw error; + } + } + + @DisallowInProduction() + async runSeed(transactionKey: string, dto: RunModulePackageSeedDto = {}) { + const transaction = await this.loadTransaction(transactionKey); + const moduleName = transaction.moduleName; + + if (!moduleName) { + throw new BadRequestException('Unable to seed because the transaction is missing moduleName.'); + } + + transaction.status = ModulePackageStatus.seed_running; + transaction.currentStep = 'seed'; + transaction.errorMessage = null; + await this.writeStatusFile(transactionKey, transaction); + + try { + const output = await this.commandService.executeCommandWithArgs({ + command: 'npx', + args: ['-y', '@solidxai/solidctl@latest', 'seed', '--modules-to-seed', moduleName], + cwd: this.getProjectRoot(), + }); + transaction.status = ModulePackageStatus.completed; + transaction.currentStep = 'done'; + transaction.outputs.seed = [ + `solidctl seed --modules-to-seed ${moduleName} [success]`, + output, + ].filter(Boolean).join('\n'); + await this.writeStatusFile(transactionKey, transaction); + await this.syncActiveTransactionPointer(transaction); + + return this.toStatusResponse(transaction); + } catch (error: any) { + transaction.status = ModulePackageStatus.seed_failed; + transaction.currentStep = 'seed'; + transaction.errorMessage = error.message ?? 'Seed failed.'; + transaction.outputs.seed = error.message ?? ''; + await this.writeStatusFile(transactionKey, transaction); + await this.syncActiveTransactionPointer(transaction); + return this.toStatusResponse(transaction); + } + } + + private async parseArchive(archiveFilePath: string): Promise { + const entries = await this.listArchiveEntries(archiveFilePath); + const archiveRootPrefix = this.detectArchiveRootPrefix(entries); + const normalizedEntries = this.normalizeArchiveEntries(entries, archiveRootPrefix); + const validationErrors: string[] = []; + const validationWarnings: string[] = []; + + if (path.extname(archiveFilePath).toLowerCase() !== '.sldx') { + validationErrors.push('Only .sldx archives are supported.'); + } + + const unsafeEntries = normalizedEntries.filter((entry) => this.isUnsafeArchivePath(entry)); + if (unsafeEntries.length > 0) { + validationErrors.push(`Archive contains unsafe paths: ${unsafeEntries.join(', ')}`); + } + + if (!normalizedEntries.includes('manifest.json')) { + validationErrors.push('Archive is missing manifest.json at the root.'); + } + + const manifestArchivePath = this.toArchiveEntryPath(archiveRootPrefix, 'manifest.json'); + const manifestText = normalizedEntries.includes('manifest.json') + ? await this.readArchiveEntry(archiveFilePath, manifestArchivePath) + : ''; + const manifest = this.parseManifest(manifestText, validationErrors); + + if (!manifest) { + return { + entries: normalizedEntries, + archiveRootPrefix, + manifest: this.getFallbackManifest(), + metadataDocument: null, + validation: { + valid: false, + errors: validationErrors, + warnings: validationWarnings, + }, + preview: { + module: { name: '', displayName: '' }, + manifest: this.getFallbackManifest(), + contentsSummary: { + totalEntries: normalizedEntries.length, + solidApiEntries: normalizedEntries.filter((entry) => entry.startsWith('solid-api/')).length, + solidUiEntries: normalizedEntries.filter((entry) => entry.startsWith('solid-ui/')).length, + }, + requiredPaths: { + metadataPath: '', + apiModulePath: '', + uiModulePath: '', + }, + conflicts: [], + fileTree: normalizedEntries, + nextActions: ['Fix archive structure and upload again.'], + }, + }; + } + + if (manifest.packageType !== ModulePackageService.SUPPORTED_PACKAGE_TYPE) { + validationErrors.push(`manifest.json packageType must be "${ModulePackageService.SUPPORTED_PACKAGE_TYPE}".`); + } + if (manifest.schemaVersion !== ModulePackageService.SUPPORTED_SCHEMA_VERSION) { + validationErrors.push(`manifest.json schemaVersion must be "${ModulePackageService.SUPPORTED_SCHEMA_VERSION}".`); + } + if (!manifest.module?.name) { + validationErrors.push('manifest.json module.name is required.'); + } + + const moduleName = manifest.module?.name ?? ''; + const expectedPaths = this.buildExpectedPaths(moduleName); + + if (manifest.contents?.metadataPath !== expectedPaths.metadataPath) { + validationErrors.push(`manifest.json contents.metadataPath must be "${expectedPaths.metadataPath}".`); + } + if (manifest.contents?.apiModulePath !== expectedPaths.apiModulePath) { + validationErrors.push(`manifest.json contents.apiModulePath must be "${expectedPaths.apiModulePath}".`); + } + if (manifest.contents?.uiModulePath !== expectedPaths.uiModulePath) { + validationErrors.push(`manifest.json contents.uiModulePath must be "${expectedPaths.uiModulePath}".`); + } + + if (!normalizedEntries.includes(expectedPaths.metadataPath)) { + validationErrors.push(`Archive is missing ${expectedPaths.metadataPath}.`); + } + if (!normalizedEntries.includes(expectedPaths.apiModulePath)) { + validationErrors.push(`Archive is missing ${expectedPaths.apiModulePath}.`); + } + if (!normalizedEntries.includes(expectedPaths.uiModulePath)) { + validationErrors.push(`Archive is missing ${expectedPaths.uiModulePath}.`); + } + + const metadataDocument = normalizedEntries.includes(expectedPaths.metadataPath) + ? this.parseMetadataDocument( + await this.readArchiveEntry(archiveFilePath, this.toArchiveEntryPath(archiveRootPrefix, expectedPaths.metadataPath)), + validationErrors, + ) + : null; + + const metadataModuleName = metadataDocument?.moduleMetadata?.name; + if (metadataModuleName && metadataModuleName !== moduleName) { + validationErrors.push(`Metadata module name "${metadataModuleName}" does not match manifest module name "${moduleName}".`); + } + + if (!manifest.checksums || Object.keys(manifest.checksums).length === 0) { + validationWarnings.push('manifest.json does not include checksums. Checksums are recommended.'); + } + + const conflicts = await this.detectConflicts(moduleName); + if (conflicts.length > 0) { + validationWarnings.push('The target module already exists locally and will require overwrite confirmation.'); + } + + const validation: ValidationPayload = { + valid: validationErrors.length === 0, + errors: validationErrors, + warnings: validationWarnings, + }; + + const preview: PreviewPayload = { + module: manifest.module, + manifest, + contentsSummary: { + totalEntries: normalizedEntries.length, + solidApiEntries: normalizedEntries.filter((entry) => entry.startsWith('solid-api/')).length, + solidUiEntries: normalizedEntries.filter((entry) => entry.startsWith('solid-ui/')).length, + }, + requiredPaths: expectedPaths, + conflicts, + fileTree: normalizedEntries, + nextActions: [ + 'Review archive preview.', + 'Confirm import to place files locally.', + 'Wait for service restart, then run build and seed.', + ], + }; + + return { + entries: normalizedEntries, + archiveRootPrefix, + manifest, + metadataDocument, + validation, + preview, + }; + } + + private parseManifest(manifestText: string, validationErrors: string[]): ModulePackageManifest | null { + if (!manifestText) { + return null; + } + + try { + return JSON.parse(manifestText) as ModulePackageManifest; + } catch (error) { + validationErrors.push('manifest.json is not valid JSON.'); + return null; + } + } + + private parseMetadataDocument(documentText: string, validationErrors: string[]) { + try { + return JSON.parse(documentText); + } catch (error) { + validationErrors.push('Module metadata JSON is not valid JSON.'); + return null; + } + } + + private async listArchiveEntries(archiveFilePath: string): Promise { + const output = await this.commandService.executeCommandWithArgs({ + command: 'unzip', + args: ['-Z1', archiveFilePath], + }); + + return output + .split(/\r?\n/) + .map((entry) => entry.trim()) + .filter(Boolean); + } + + private async readArchiveEntry(archiveFilePath: string, entryPath: string): Promise { + return this.commandService.executeCommandWithArgs({ + command: 'unzip', + args: ['-p', archiveFilePath, entryPath], + }); + } + + private async extractArchive(archiveFilePath: string, extractDirPath: string) { + await this.commandService.executeCommandWithArgs({ + command: 'unzip', + args: ['-o', archiveFilePath, '-d', extractDirPath], + }); + } + + private async detectConflicts(moduleName: string): Promise { + const conflicts: string[] = []; + const solidApiTarget = this.getSolidApiModuleTargetPath(moduleName); + const solidUiTarget = this.getSolidUiModuleTargetPath(moduleName); + const metadataFilePath = await this.moduleMetadataHelperService.getModuleMetadataFilePath(moduleName); + + if (await this.pathExists(solidApiTarget)) { + conflicts.push(`solid-api module already exists at ${solidApiTarget}`); + } + if (await this.pathExists(solidUiTarget)) { + conflicts.push(`solid-ui module already exists at ${solidUiTarget}`); + } + if (await this.pathExists(metadataFilePath)) { + conflicts.push(`module metadata already exists at ${metadataFilePath}`); + } + + return conflicts; + } + + private buildExpectedPaths(moduleName: string) { + return { + metadataPath: `solid-api/src/${moduleName}/metadata/${moduleName}-metadata.json`, + apiModulePath: `solid-api/src/${moduleName}/${moduleName}.module.ts`, + uiModulePath: `solid-ui/src/${moduleName}/${moduleName}.ui-module.ts`, + }; + } + + private detectArchiveRootPrefix(entries: string[]): string | null { + const normalized = entries + .map((entry) => entry.trim()) + .filter(Boolean) + .filter((entry) => !this.isMacOsMetadataEntry(entry)); + + if (normalized.length === 0) { + return null; + } + + const topLevelSegments = normalized + .map((entry) => entry.split('/')[0]) + .filter(Boolean); + + const uniqueTopLevelSegments = Array.from(new Set(topLevelSegments)); + if (uniqueTopLevelSegments.length !== 1) { + return null; + } + + const onlySegment = uniqueTopLevelSegments[0]; + const hasNestedEntries = normalized.some((entry) => entry.startsWith(`${onlySegment}/`)); + return hasNestedEntries ? `${onlySegment}/` : null; + } + + private normalizeArchiveEntries(entries: string[], archiveRootPrefix: string | null) { + const filteredEntries = entries.filter((entry) => !this.isMacOsMetadataEntry(entry)); + + if (!archiveRootPrefix) { + return filteredEntries; + } + + return filteredEntries + .map((entry) => entry.startsWith(archiveRootPrefix) ? entry.slice(archiveRootPrefix.length) : entry) + .filter(Boolean); + } + + private toArchiveEntryPath(archiveRootPrefix: string | null, normalizedEntryPath: string) { + return archiveRootPrefix ? `${archiveRootPrefix}${normalizedEntryPath}` : normalizedEntryPath; + } + + private isUnsafeArchivePath(entry: string) { + return entry.startsWith('/') || entry.includes('..') || /^[A-Za-z]:/.test(entry); + } + + private isMacOsMetadataEntry(entry: string) { + const normalizedEntry = entry.trim(); + if (!normalizedEntry) { + return false; + } + + const segments = normalizedEntry.split('/').filter(Boolean); + return segments.some((segment) => segment === '__MACOSX' || segment.startsWith('._')); + } + + private getFallbackManifest(): ModulePackageManifest { + return { + schemaVersion: ModulePackageService.SUPPORTED_SCHEMA_VERSION, + packageType: ModulePackageService.SUPPORTED_PACKAGE_TYPE, + module: { name: '', displayName: '' }, + contents: { + metadataPath: '', + apiModulePath: '', + uiModulePath: '', + }, + }; + } + + private async loadTransaction(transactionKey: string) { + this.assertSafeTransactionKey(transactionKey); + const statusFilePath = this.getStatusFilePath(transactionKey); + + if (!(await this.pathExists(statusFilePath))) { + throw new NotFoundException(`Module package transaction ${transactionKey} was not found.`); + } + + return JSON.parse(await fs.readFile(statusFilePath, 'utf-8')) as ModulePackageStatusFile; + } + + private isResumableStatus(status: string | null | undefined) { + return [ + ModulePackageStatus.import_running, + ModulePackageStatus.awaiting_restart, + ModulePackageStatus.build_running, + ModulePackageStatus.build_failed, + ModulePackageStatus.build_succeeded, + ModulePackageStatus.seed_running, + ModulePackageStatus.seed_failed, + ].includes(status as ModulePackageStatus); + } + + private async createWorkingDir(transactionKey: string) { + const baseDir = path.join( + this.getModulePackageImportsRoot(), + transactionKey, + ); + + await fs.mkdir(baseDir, { recursive: true }); + return baseDir; + } + + private getSolidApiRoot() { + return process.cwd(); + } + + private getProjectRoot() { + return path.resolve(this.getSolidApiRoot(), '..'); + } + + private getSolidUiRoot() { + return path.join(this.getProjectRoot(), 'solid-ui'); + } + + private getModulePackageImportsRoot() { + return path.join( + this.getModulePackageRuntimeRoot(), + 'module-package-imports', + ); + } + + private getModulePackageExportsRoot() { + return path.join( + this.getModulePackageRuntimeRoot(), + 'module-package-exports', + ); + } + + private getModulePackageRuntimeRoot() { + const configuredRuntimeRoot = process.env.SOLIDX_MODULE_PACKAGE_RUNTIME_DIR?.trim(); + + if (configuredRuntimeRoot) { + return path.resolve(configuredRuntimeRoot); + } + + return path.join( + this.getProjectRoot(), + '.solidx-runtime', + ); + } + + private getSolidApiModuleTargetPath(moduleName: string) { + return path.join(this.getSolidApiRoot(), 'src', moduleName); + } + + private getSolidUiModuleTargetPath(moduleName: string) { + return path.join(this.getSolidUiRoot(), 'src', moduleName); + } + + private async pathExists(targetPath: string) { + try { + await fs.access(targetPath); + return true; + } catch (error) { + return false; + } + } + + private async assertPathExists(targetPath: string, label: string) { + if (!(await this.pathExists(targetPath))) { + throw new BadRequestException(`Expected ${label} at ${targetPath}, but it was not found in the extracted archive.`); + } + } + + private async readDirSafe(targetPath: string) { + try { + return await fs.readdir(targetPath, { withFileTypes: true }); + } catch (error) { + return []; + } + } + + private assertSafeTransactionKey(transactionKey: string) { + if (!/^[a-zA-Z0-9-]+$/.test(transactionKey)) { + throw new BadRequestException('Invalid module package transaction key.'); + } + } + + private getStatusFilePath(transactionKey: string) { + this.assertSafeTransactionKey(transactionKey); + return path.join(this.getModulePackageImportsRoot(), transactionKey, 'status.json'); + } + + private getActiveTransactionFilePath() { + return path.join(this.getModulePackageImportsRoot(), 'active-transaction.json'); + } + + private async writeStatusFile(transactionKey: string, payload: ModulePackageStatusFile) { + payload.updatedAt = new Date().toISOString(); + const statusFilePath = this.getStatusFilePath(transactionKey); + await fs.mkdir(path.dirname(statusFilePath), { recursive: true }); + await fs.writeFile(statusFilePath, JSON.stringify(payload, null, 2)); + } + + private async markActiveTransaction(transactionKey: string) { + const activeTransactionFilePath = this.getActiveTransactionFilePath(); + await fs.mkdir(path.dirname(activeTransactionFilePath), { recursive: true }); + const payload: ModulePackageActiveTransactionFile = { + transactionKey, + updatedAt: new Date().toISOString(), + }; + await fs.writeFile(activeTransactionFilePath, JSON.stringify(payload, null, 2)); + } + + private async clearActiveTransactionIfMatches(transactionKey: string) { + const activeTransaction = await this.loadActiveTransactionPointer(); + if (!activeTransaction || activeTransaction.transactionKey !== transactionKey) { + return; + } + + await fs.rm(this.getActiveTransactionFilePath(), { force: true }); + } + + private async loadActiveTransactionPointer(): Promise { + try { + return JSON.parse(await fs.readFile(this.getActiveTransactionFilePath(), 'utf-8')) as ModulePackageActiveTransactionFile; + } catch (error) { + return null; + } + } + + private async syncActiveTransactionPointer(transaction: ModulePackageStatusFile) { + if (this.isResumableStatus(transaction.status)) { + await this.markActiveTransaction(transaction.transactionKey); + return; + } + + await this.clearActiveTransactionIfMatches(transaction.transactionKey); + } + + private async collectRuntimeCleanupSnapshot() { + const importEntries = await this.readDirSafe(this.getModulePackageImportsRoot()); + const exportEntries = await this.readDirSafe(this.getModulePackageExportsRoot()); + + const importTransactionEntries = importEntries.filter( + (entry) => entry.isDirectory() && /^[a-zA-Z0-9-]+$/.test(entry.name), + ); + const exportTransactionEntries = exportEntries.filter( + (entry) => entry.isDirectory() && /^[a-zA-Z0-9-]+$/.test(entry.name), + ); + + const importLooseEntries = importEntries.filter( + (entry) => !(entry.isDirectory() && /^[a-zA-Z0-9-]+$/.test(entry.name)), + ); + const exportLooseEntries = exportEntries.filter( + (entry) => !(entry.isDirectory() && /^[a-zA-Z0-9-]+$/.test(entry.name)), + ); + + return { + removedImportTransactions: importTransactionEntries.length, + removedExportTransactions: exportTransactionEntries.length, + removedImportLooseEntries: importLooseEntries.length, + removedExportLooseEntries: exportLooseEntries.length, + clearedActiveTransactionPointer: importEntries.some((entry) => entry.name === 'active-transaction.json'), + }; + } + + private getCompletedTransactionRetentionMs() { + const retentionDays = Number(process.env.SOLIDX_MODULE_PACKAGE_RETENTION_DAYS ?? 7); + const safeDays = Number.isFinite(retentionDays) && retentionDays > 0 ? retentionDays : 7; + return safeDays * 24 * 60 * 60 * 1000; + } + + private getPendingTransactionRetentionMs() { + const retentionHours = Number(process.env.SOLIDX_MODULE_PACKAGE_PENDING_RETENTION_HOURS ?? 24); + const safeHours = Number.isFinite(retentionHours) && retentionHours > 0 ? retentionHours : 24; + return safeHours * 60 * 60 * 1000; + } + + private async cleanupModulePackageTransactions() { + const importsRoot = this.getModulePackageImportsRoot(); + const activePointer = await this.loadActiveTransactionPointer(); + const now = Date.now(); + + try { + await fs.mkdir(importsRoot, { recursive: true }); + const entries = await fs.readdir(importsRoot, { withFileTypes: true }); + + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + + const transactionKey = entry.name; + if (!/^[a-zA-Z0-9-]+$/.test(transactionKey)) { + continue; + } + + const statusFilePath = this.getStatusFilePath(transactionKey); + let transaction: ModulePackageStatusFile | null = null; + + try { + transaction = JSON.parse(await fs.readFile(statusFilePath, 'utf-8')) as ModulePackageStatusFile; + } catch (error) { + continue; + } + + const updatedAtMs = transaction.updatedAt ? new Date(transaction.updatedAt).getTime() : 0; + const ageMs = updatedAtMs > 0 ? now - updatedAtMs : Number.MAX_SAFE_INTEGER; + const isResumable = this.isResumableStatus(transaction.status); + const maxAgeMs = isResumable + ? this.getPendingTransactionRetentionMs() + : this.getCompletedTransactionRetentionMs(); + const shouldDelete = ageMs > maxAgeMs; + + if (!shouldDelete) { + continue; + } + + await fs.rm(path.join(importsRoot, transactionKey), { recursive: true, force: true }); + + if (activePointer?.transactionKey === transactionKey) { + await fs.rm(this.getActiveTransactionFilePath(), { force: true }); + } + } + } catch (error) { + this.logger.warn(`Failed to clean up module package transactions: ${String(error)}`); + } + } + + private async resolveLatestResumableTransaction(): Promise { + const activePointer = await this.loadActiveTransactionPointer(); + if (activePointer?.transactionKey) { + try { + const activeTransaction = await this.loadTransaction(activePointer.transactionKey); + if (this.isResumableStatus(activeTransaction.status)) { + return activeTransaction; + } + + await this.clearActiveTransactionIfMatches(activePointer.transactionKey); + } catch (error) { + await this.clearActiveTransactionIfMatches(activePointer.transactionKey); + } + } + + const importsRoot = this.getModulePackageImportsRoot(); + const candidates: ModulePackageStatusFile[] = []; + + try { + const entries = await fs.readdir(importsRoot, { withFileTypes: true }); + + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + + const transactionKey = entry.name; + if (!/^[a-zA-Z0-9-]+$/.test(transactionKey)) { + continue; + } + + try { + const transaction = await this.loadTransaction(transactionKey); + if (this.isResumableStatus(transaction.status)) { + candidates.push(transaction); + } + } catch (error) { + continue; + } + } + } catch (error) { + return null; + } + + candidates.sort((left, right) => { + const leftUpdatedAt = new Date(left.updatedAt ?? left.createdAt ?? 0).getTime(); + const rightUpdatedAt = new Date(right.updatedAt ?? right.createdAt ?? 0).getTime(); + return rightUpdatedAt - leftUpdatedAt; + }); + + const latest = candidates[0] ?? null; + if (latest) { + await this.markActiveTransaction(latest.transactionKey); + } + return latest; + } + + private toStatusResponse(transaction: ModulePackageStatusFile) { + return { + transactionKey: transaction.transactionKey, + status: transaction.status, + currentStep: transaction.currentStep, + moduleName: transaction.moduleName, + moduleDisplayName: transaction.moduleDisplayName, + archiveFileName: transaction.archiveFileName, + manifest: transaction.manifest, + preview: transaction.preview, + validation: transaction.validation, + conflicts: transaction.conflicts, + outputs: transaction.outputs, + errorMessage: transaction.errorMessage, + createdAt: transaction.createdAt, + updatedAt: transaction.updatedAt, + }; + } + + private async buildExportManifest(moduleName: string, metadataDocument: any, stagingDir: string): Promise { + const expectedPaths = this.buildExpectedPaths(moduleName); + const checksums = await this.computeDirectoryChecksums(stagingDir, ['manifest.json']); + + return { + schemaVersion: ModulePackageService.SUPPORTED_SCHEMA_VERSION, + packageType: ModulePackageService.SUPPORTED_PACKAGE_TYPE, + exportedAt: new Date().toISOString(), + generatedBy: { + name: '@solidxai/core', + }, + module: { + name: moduleName, + displayName: metadataDocument?.moduleMetadata?.displayName, + description: metadataDocument?.moduleMetadata?.description, + }, + contents: expectedPaths, + postImport: { + recommendedSteps: ['restart', 'build', 'seed'], + }, + checksums, + }; + } + + private async computeDirectoryChecksums(rootDir: string, excludedRelativePaths: string[] = []): Promise> { + const excludedPaths = new Set(excludedRelativePaths.map((entry) => entry.replace(/\\/g, '/'))); + const filePaths = await this.collectFilePathsRecursively(rootDir); + const checksums: Record = {}; + + for (const filePath of filePaths) { + const relativePath = path.relative(rootDir, filePath).replace(/\\/g, '/'); + if (excludedPaths.has(relativePath)) { + continue; + } + + checksums[relativePath] = await this.computeSha256(filePath); + } + + return checksums; + } + + private async collectFilePathsRecursively(rootDir: string): Promise { + const entries = await fs.readdir(rootDir, { withFileTypes: true }); + const nestedPaths = await Promise.all(entries.map(async (entry) => { + const entryPath = path.join(rootDir, entry.name); + if (entry.isDirectory()) { + return this.collectFilePathsRecursively(entryPath); + } + + return [entryPath]; + })); + + return nestedPaths.flat(); + } + + private async createArchiveFromDirectory(sourceDir: string, archiveFilePath: string): Promise { + await fs.mkdir(path.dirname(archiveFilePath), { recursive: true }); + + await new Promise((resolve, reject) => { + const output = createWriteStream(archiveFilePath); + const archive = archiver('zip', { + zlib: { level: 9 }, + }); + + const rejectOnce = (error: Error) => { + output.destroy(); + reject(error); + }; + + output.on('close', () => resolve()); + output.on('error', rejectOnce); + archive.on('warning', rejectOnce); + archive.on('error', rejectOnce); + + archive.pipe(output); + archive.directory(sourceDir, false); + void archive.finalize(); + }); + } + + private async computeSha256(filePath: string): Promise { + const fileBuffer = await fs.readFile(filePath); + return createHash('sha256').update(fileBuffer).digest('hex'); + } +} diff --git a/src/services/question-data-providers/chartjs-sql-data-provider.service.ts b/src/services/question-data-providers/chartjs-sql-data-provider.service.ts deleted file mode 100644 index c6322deb..00000000 --- a/src/services/question-data-providers/chartjs-sql-data-provider.service.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { Injectable, Logger } from "@nestjs/common"; -import { DashboardQuestionDataProvider } from "src/decorators/dashboard-question-data-provider.decorator"; -import { DashboardQuestion } from "src/entities/dashboard-question.entity"; -import { IDashboardQuestionDataProvider, QuestionSqlDataProviderContext } from "src/interfaces"; -import { EntityManager } from "typeorm"; -import { SqlExpressionResolverService } from "../sql-expression-resolver.service"; -import { getKpi, getLabels } from "./helpers"; - - -export enum SqlExpressionOperator { - EQUALS = '$equals', - NOT_EQUALS = '$notEquals', - CONTAINS = '$contains', - NOT_CONTAINS = '$notContains', - STARTS_WITH = '$startsWith', - ENDS_WITH = '$endsWith', - IN = '$in', - NOT_IN = '$notIn', - BETWEEN = '$between', - LT = '$lt', - LTE = '$lte', - GT = '$gt', - GTE = '$gte' -} - -export interface SqlExpression { - variableName: string; // The name of the variable in the SQL query - operator: SqlExpressionOperator; // The operator to use for the replacement (e.g., '=', '>', '<', etc.) - value: string[]; // The value to replace the variable with -} - -@DashboardQuestionDataProvider() -@Injectable() -export class ChartJsSqlDataProvider implements IDashboardQuestionDataProvider { - private readonly logger = new Logger(ChartJsSqlDataProvider.name); - - constructor(private readonly entityManager: EntityManager, private readonly sqlExpressionResolver: SqlExpressionResolverService) { } - - help(): string { - return "Provides data for dashboard questions using a SQL dataset configuration. Configure your SQL dataset in the admin panel, then reference it in your dashboard question to fetch data."; - } - - name(): string { - return "ChartJsSqlDataProvider"; - } - - async getData(question: DashboardQuestion, context?: QuestionSqlDataProviderContext): Promise { - const expressions: SqlExpression[] = context?.expressions || []; - // TODO: put some validation to check if the results of each SQL in each dataset returns the same number of rows - - // This is what we have to return. - // const data = { - // labels: ['January', 'February', 'March', 'April', 'May', 'June', 'July'], - // datasets: [ - // { - // label: 'Dataset 1', - // data: labels.map(() => faker.number.int({ min: 0, max: 1000 })), - // backgroundColor: 'rgba(255, 99, 132, 0.5)', - // }, - // { - // label: 'Dataset 2', - // data: labels.map(() => faker.number.int({ min: 0, max: 1000 })), - // backgroundColor: 'rgba(53, 162, 235, 0.5)', - // }, - // { - // label: 'Dataset 3', - // data: labels.map(() => faker.number.int({ min: 0, max: 1000 })), - // backgroundColor: 'rgba(53, 235, 162, 0.5)', - // }, - // ], - // }; - - // TODO: Load the set of labels by using a separate field on the question entity. - - let datasetIdx = 0; - const datasets = []; - - const labels: string[] = await getLabels(question, expressions, this.entityManager, this.sqlExpressionResolver); - const kpi: string = await getKpi(question, expressions, this.entityManager, this.sqlExpressionResolver); - - // const question = context.question; - for (const questionSqlDatasetConfig of question.questionSqlDatasetConfigs) { - - const sql = questionSqlDatasetConfig.sql; - if (!sql) { - throw new Error(`SQL dataset ${questionSqlDatasetConfig.datasetName} configuration does not contain a valid SQL query.`); - } - - const sqlReplacementResult = this.sqlExpressionResolver.resolveSqlWithExpressions(sql, expressions || []); - this.logger.debug(`Final Sql query for dataset [${questionSqlDatasetConfig.datasetName}] is query=[${sqlReplacementResult.rawSql}]`); - this.logger.debug(`Final Sql query for dataset [${questionSqlDatasetConfig.datasetName}] is parameters=[${JSON.stringify(sqlReplacementResult.parameters)}]`); - const results = await this.entityManager.query(sqlReplacementResult.rawSql, sqlReplacementResult.parameters); - - // Also for each data set we create the dataset object as is expected by ChartJs. - const data = []; - for (let i = 0; i < results.length; i++) { - const result = results[i]; - data.push(result[questionSqlDatasetConfig.valueColumnName]); - } - datasets.push({ - label: questionSqlDatasetConfig.datasetDisplayName, - data: data, - ...JSON.parse(questionSqlDatasetConfig.options || '{}'), - }); - - datasetIdx++; - } - - return { - kpi, - visualizedAs: question.visualisedAs, - visualizationData: { - labels, - datasets - } - }; - - } - - -} \ No newline at end of file diff --git a/src/services/question-data-providers/helpers.ts b/src/services/question-data-providers/helpers.ts deleted file mode 100644 index 39b369ac..00000000 --- a/src/services/question-data-providers/helpers.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { DashboardQuestion } from "src/entities/dashboard-question.entity"; -import { EntityManager } from "typeorm"; -import { SqlExpression } from "./chartjs-sql-data-provider.service"; -import { SqlExpressionResolverService } from "../sql-expression-resolver.service"; - -export async function getLabels(question: DashboardQuestion, expressions: SqlExpression[], entityManager: EntityManager, sqlExpressionResolver: SqlExpressionResolverService): Promise { - const sql = question.labelSql; - if (!sql) { - return []; - } - const sqlReplacementResult = sqlExpressionResolver.resolveSqlWithExpressions(sql, expressions || []); - - const labelResults = await entityManager.query(sqlReplacementResult.rawSql, sqlReplacementResult.parameters); - - // Assuming labelResults has a single row with a 'label' field - // Map the label results to the labels array - const labels: string[] = labelResults.map((result: { [x: string]: string; }) => result['label']); - return labels; -} - -export async function getKpi(question: DashboardQuestion, expressions: SqlExpression[], entityManager: EntityManager, sqlExpressionResolver: SqlExpressionResolverService): Promise { - const sql = question.kpiSql; - if (!sql) { - return ""; - } - const sqlReplacementResult = sqlExpressionResolver.resolveSqlWithExpressions(sql, expressions || []); - const result = await entityManager.query(sqlReplacementResult.rawSql, sqlReplacementResult.parameters); - const kpiResult = result.pop(); - return kpiResult?.kpi || ""; -} \ No newline at end of file diff --git a/src/services/question-data-providers/prime-react-datatable-sql-data-provider.service.ts b/src/services/question-data-providers/prime-react-datatable-sql-data-provider.service.ts deleted file mode 100644 index 07aae620..00000000 --- a/src/services/question-data-providers/prime-react-datatable-sql-data-provider.service.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { Injectable } from "@nestjs/common"; -import { DashboardQuestionDataProvider } from "src/decorators/dashboard-question-data-provider.decorator"; -import { DashboardQuestion } from "src/entities/dashboard-question.entity"; -import { IDashboardQuestionDataProvider, QuestionSqlDataProviderContext } from "src/interfaces"; -import { EntityManager } from "typeorm"; -import { SqlExpressionResolverService } from "../sql-expression-resolver.service"; -import { Logger } from '@nestjs/common'; -import { SqlExpression } from "./chartjs-sql-data-provider.service"; -import { getKpi } from "./helpers"; - -@DashboardQuestionDataProvider() -@Injectable() -export class PrimeReactDatatableSqlDataProvider implements IDashboardQuestionDataProvider { - private readonly logger = new Logger(PrimeReactDatatableSqlDataProvider.name); - - constructor(private readonly entityManager: EntityManager, private readonly sqlExpressionResolver: SqlExpressionResolverService) { } - - help(): string { - return "Provides data for dashboard questions using a SQL dataset configuration. Configure your SQL dataset in the admin panel, then reference it in your dashboard question to fetch data."; - } - - name(): string { - return "PrimeReactDatatableSqlDataProvider"; - } - - async getData(question: DashboardQuestion, context?: QuestionSqlDataProviderContext): Promise { - const expressions: SqlExpression[] = context?.expressions || []; - - // TODO: put some validation to check if the results of each SQL in each dataset returns the same number of rows - - // Check the expected response for prime react data tables to understand what is going on here... - - const kpi: string = await getKpi(question, expressions, this.entityManager, this.sqlExpressionResolver); - // TODO: Load the set of labels by using a separate field on the question entity. - const labelSql = question.labelSql; - const labelResults = await this.entityManager.query(labelSql); - const columns = []; - for (let i = 0; i < labelResults.length; i++) { - const labelResult = labelResults[i]; - columns.push({ - field: labelResult['field'], - header: labelResult['header'], - }); - } - - // Load the chart options as a JSON - // const chartOptions = JSON.parse(question.barChartLabelOptions || '{}'); - - const values = [] - - // For meter group we can assume that we only have one sql dataset config. - const questionSqlDatasetConfig = question.questionSqlDatasetConfigs[0]; - - const sql = questionSqlDatasetConfig.sql; - if (!sql) { - throw new Error(`SQL dataset ${questionSqlDatasetConfig.datasetName} configuration does not contain a valid SQL query.`); - } - - const sqlReplacementResult = this.sqlExpressionResolver.resolveSqlWithExpressions(sql, expressions || []); - this.logger.debug(`Final Sql query for dataset [${questionSqlDatasetConfig.datasetName}] is query=[${sqlReplacementResult.rawSql}]`); - this.logger.debug(`Final Sql query for dataset [${questionSqlDatasetConfig.datasetName}] is parameters=[${JSON.stringify(sqlReplacementResult.parameters)}]`); - const results = await this.entityManager.query(sqlReplacementResult.rawSql, sqlReplacementResult.parameters); - - return { - kpi, - visualisedAs: question.visualisedAs, - visualizationData: { - columns, - rows: results, - } - }; - - } -} \ No newline at end of file diff --git a/src/services/question-data-providers/prime-react-meter-group-sql-data-provider.service.ts b/src/services/question-data-providers/prime-react-meter-group-sql-data-provider.service.ts deleted file mode 100644 index a7af5e74..00000000 --- a/src/services/question-data-providers/prime-react-meter-group-sql-data-provider.service.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { Injectable } from "@nestjs/common"; -import { DashboardQuestionDataProvider } from "src/decorators/dashboard-question-data-provider.decorator"; -import { DashboardQuestion } from "src/entities/dashboard-question.entity"; -import { IDashboardQuestionDataProvider, QuestionSqlDataProviderContext } from "src/interfaces"; -import { EntityManager } from "typeorm"; -import { SqlExpressionResolverService } from "../sql-expression-resolver.service"; -import { Logger } from '@nestjs/common'; -import { SqlExpression } from "./chartjs-sql-data-provider.service"; -import { getKpi } from "./helpers"; - -@DashboardQuestionDataProvider() -@Injectable() -export class PrimeReactMeterGroupSqlDataProvider implements IDashboardQuestionDataProvider { - private readonly logger = new Logger(PrimeReactMeterGroupSqlDataProvider.name); - - constructor(private readonly entityManager: EntityManager, private readonly sqlExpressionResolver: SqlExpressionResolverService) { } - - help(): string { - return "Provides data for dashboard questions using a SQL dataset configuration. Configure your SQL dataset in the admin panel, then reference it in your dashboard question to fetch data."; - } - - name(): string { - return "PrimeReactMeterGroupSqlDataProvider"; - } - - hslToHex(h: number, s: number, l: number): string { - l /= 100; - s /= 100; - - const k = (n: number) => (n + h / 30) % 12; - const a = s * Math.min(l, 1 - l); - const f = (n: number) => - Math.round(255 * (l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1))))); - - return `#${f(0).toString(16).padStart(2, '0')}${f(8).toString(16).padStart(2, '0')}${f(4) - .toString(16) - .padStart(2, '0')}`; - } - - generateDistinctColors(count: number): string[] { - const colors: string[] = []; - - const hueStep = 360 / count; - const saturation = 65; // keep it vibrant - const lightness = 55; // balanced for both light/dark themes - - for (let i = 0; i < count; i++) { - const hue = Math.round(i * hueStep); - colors.push(this.hslToHex(hue, saturation, lightness)); - } - - return colors; - } - - async getData(question: DashboardQuestion, context?: QuestionSqlDataProviderContext): Promise { - const expressions: SqlExpression[] = context?.expressions || []; - - // TODO: put some validation to check if the results of each SQL in each dataset returns the same number of rows - - // This is what we have to return. - // const values = [ - // { label: 'Apps', color: '#34d399', value: 16 }, - // { label: 'Messages', color: '#fbbf24', value: 8 }, - // { label: 'Media', color: '#60a5fa', value: 24 }, - // { label: 'System', color: '#c084fc', value: 10 } - // ]; - - // TODO: Load the set of labels by using a separate field on the question entity. - - const kpi: string = await getKpi(question, expressions, this.entityManager, this.sqlExpressionResolver); - - // Load the chart options as a JSON - const chartOptions = JSON.parse(question.chartOptions || '{}'); - - const values = [] - - // For meter group we can assume that we only have one sql dataset config. - const questionSqlDatasetConfig = question.questionSqlDatasetConfigs[0]; - - const sql = questionSqlDatasetConfig.sql; - if (!sql) { - throw new Error(`SQL dataset ${questionSqlDatasetConfig.datasetName} configuration does not contain a valid SQL query.`); - } - - const sqlReplacementResult = this.sqlExpressionResolver.resolveSqlWithExpressions(sql, expressions || []); - this.logger.debug(`Final Sql query for dataset [${questionSqlDatasetConfig.datasetName}] is query=[${sqlReplacementResult.rawSql}]`); - this.logger.debug(`Final Sql query for dataset [${questionSqlDatasetConfig.datasetName}] is parameters=[${JSON.stringify(sqlReplacementResult.parameters)}]`); - const results = await this.entityManager.query(sqlReplacementResult.rawSql, sqlReplacementResult.parameters); - - const colors = this.generateDistinctColors(results.length); - - // Also for each data set we create the dataset object as is expected by ChartJs. - for (let i = 0; i < results.length; i++) { - const result = results[i]; - - const colorFromChartOptions = chartOptions?.colors?.[result[questionSqlDatasetConfig.labelColumnName]]; - const color = typeof colorFromChartOptions === 'string' ? colorFromChartOptions : colors[i]; - - values.push({ - label: result[questionSqlDatasetConfig.labelColumnName], - color: color, - value: result[questionSqlDatasetConfig.valueColumnName] - }) - } - - return { - kpi, - visualizedAs: question.visualisedAs, - visualizationData: { - dataset: values - }, - }; - - } -} \ No newline at end of file diff --git a/src/services/queues/database-publisher.service.ts b/src/services/queues/database-publisher.service.ts index 2fa441e7..3dd24c86 100644 --- a/src/services/queues/database-publisher.service.ts +++ b/src/services/queues/database-publisher.service.ts @@ -15,9 +15,9 @@ export abstract class DatabasePublisher implements QueuePublisher { protected readonly mqMessageService: MqMessageService, protected readonly mqMessageQueueService: MqMessageQueueService, ) { - this.serviceRole = process.env.QUEUES_SERVICE_ROLE; - if (!this.serviceRole) { - this.logger.debug('Queue service Role is not defined in the environment variables'); + this.serviceRole = process.env.QUEUES_SERVICE_ROLE || 'both'; + if (!process.env.QUEUES_SERVICE_ROLE) { + this.logger.debug('QUEUES_SERVICE_ROLE is not defined. Defaulting DatabasePublisher service role to "both".'); } // this.logger.debug(`DatabasePublisher instance created with options: ${JSON.stringify(this.options())}`); } @@ -74,7 +74,7 @@ export abstract class DatabasePublisher implements QueuePublisher { mqMessageQueueId: mqMessageQueue.id, }); } - catch (error) { + catch (error: any) { this.logger.error(error.message, error.stack); } diff --git a/src/services/queues/database-subscriber.service.ts b/src/services/queues/database-subscriber.service.ts index 31c89340..8f07b5fe 100644 --- a/src/services/queues/database-subscriber.service.ts +++ b/src/services/queues/database-subscriber.service.ts @@ -16,9 +16,9 @@ export abstract class DatabaseSubscriber implements OnModuleInit, QueueSubscr protected readonly mqMessageQueueService: MqMessageQueueService, protected readonly poller: PollerService, ) { - this.serviceRole = process.env.QUEUES_SERVICE_ROLE; - if (!this.serviceRole) { - this.logger.debug('Queue service Role is not defined in the environment variables'); + this.serviceRole = process.env.QUEUES_SERVICE_ROLE || 'both'; + if (!process.env.QUEUES_SERVICE_ROLE) { + this.logger.debug('QUEUES_SERVICE_ROLE is not defined. Defaulting DatabaseSubscriber service role to "both".'); } // this.logger.debug(`DatabaseSubscriber instance created with options: ${JSON.stringify(this.options())}`); } @@ -60,7 +60,7 @@ export abstract class DatabaseSubscriber implements OnModuleInit, QueueSubscr await this.processMessage(message); } - catch (error) { + catch (error: any) { this.logger.error(`Error processing message: ${error.message}`); // if an error occurs then if retryCount is set we start retrying. @@ -108,7 +108,7 @@ export abstract class DatabaseSubscriber implements OnModuleInit, QueueSubscr this.logger.log(`DatabaseSubscriber for queue ${queueName} is disabled because it does not match QUEUES_QUEUE_NAME_REGEX_TO_ENABLE=${queueNameRegex}`); return; } - } catch (error) { + } catch (error: any) { this.logger.error(`Invalid QUEUES_QUEUE_NAME_REGEX_TO_ENABLE regex "${queueNameRegex}". Subscriber for queue ${queueName} will not start.`); return; } @@ -152,7 +152,7 @@ export abstract class DatabaseSubscriber implements OnModuleInit, QueueSubscr private async retryMessage(message: QueueMessage) { try { await this.processMessage(message); - } catch (error) { + } catch (error: any) { if (message.currentRetry < message.retryCount) { await this.updateStatusInDatabase('retrying', message); @@ -203,7 +203,7 @@ export abstract class DatabaseSubscriber implements OnModuleInit, QueueSubscr this.logger.debug(`Message status updated to ${stage} for messageId: ${mqMessage.id}`); } } - catch (error) { + catch (error: any) { this.logger.error(error.message, error.stack); } } diff --git a/src/services/queues/rabbitmq-publisher.service.ts b/src/services/queues/rabbitmq-publisher.service.ts index af98c245..061a4be0 100755 --- a/src/services/queues/rabbitmq-publisher.service.ts +++ b/src/services/queues/rabbitmq-publisher.service.ts @@ -22,12 +22,12 @@ export abstract class RabbitMqPublisher implements OnModuleDestroy, QueuePubl protected readonly mqMessageQueueService: MqMessageQueueService, ) { this.url = process.env.QUEUES_RABBIT_MQ_URL; - this.serviceRole = process.env.QUEUES_SERVICE_ROLE; + this.serviceRole = process.env.QUEUES_SERVICE_ROLE || 'both'; if (!this.url) { this.logger.debug('RabbitMqPublisher url is not defined in the environment variables'); } - if (!this.serviceRole) { - this.logger.debug('Queue service Role is not defined in the environment variables'); + if (!process.env.QUEUES_SERVICE_ROLE) { + this.logger.debug('QUEUES_SERVICE_ROLE is not defined. Defaulting RabbitMqPublisher service role to "both".'); } // this.logger.debug(`RabbitMqPublisher instance created with options: ${JSON.stringify(this.options())} and url: ${this.url}`); } @@ -118,7 +118,7 @@ export abstract class RabbitMqPublisher implements OnModuleDestroy, QueuePubl if (this.channel) { try { await this.channel.close(); - } catch (err) { + } catch (err: any) { this.logger.warn( `RabbitMqPublisher error closing channel: ${(err as Error).message}`, ); @@ -130,7 +130,7 @@ export abstract class RabbitMqPublisher implements OnModuleDestroy, QueuePubl if (this.connection) { try { await this.connection.close(); - } catch (err) { + } catch (err: any) { this.logger.warn( `RabbitMqPublisher error closing connection: ${(err as Error).message}`, ); @@ -189,7 +189,7 @@ export abstract class RabbitMqPublisher implements OnModuleDestroy, QueuePubl // } // await channel.waitForConfirms(); // this.logger.debug('RabbitMqPublisher Message published successfully'); - } catch (err) { + } catch (err: any) { this.logger.error(`RabbitMqPublisher Message publish failed: ${JSON.stringify(err)}`); if (err instanceof Error) { this.logger.error(`RabbitMqPublisher Error stack: ${err.stack}`); @@ -224,7 +224,7 @@ export abstract class RabbitMqPublisher implements OnModuleDestroy, QueuePubl mqMessageQueueId: mqMessageQueue.id, }); } - catch (error) { + catch (error: any) { this.logger.error(error.message, error.stack); } diff --git a/src/services/queues/rabbitmq-subscriber.service.ts b/src/services/queues/rabbitmq-subscriber.service.ts index 69998cbf..776d8d64 100755 --- a/src/services/queues/rabbitmq-subscriber.service.ts +++ b/src/services/queues/rabbitmq-subscriber.service.ts @@ -31,12 +31,12 @@ export abstract class RabbitMqSubscriber implements OnModuleInit, QueueSubscr constructor(protected readonly mqMessageService: MqMessageService, protected readonly mqMessageQueueService: MqMessageQueueService) { this.url = process.env.QUEUES_RABBIT_MQ_URL; - this.serviceRole = process.env.QUEUES_SERVICE_ROLE; + this.serviceRole = process.env.QUEUES_SERVICE_ROLE || 'both'; if (!this.url) { this.logger.debug('RabbitMqPublisher url is not defined in the environment variables'); } - if (!this.serviceRole) { - this.logger.debug('Queue service Role is not defined in the environment variables'); + if (!process.env.QUEUES_SERVICE_ROLE) { + this.logger.debug('QUEUES_SERVICE_ROLE is not defined. Defaulting RabbitMqSubscriber service role to "both".'); } // this.logger.debug(`RabbitMqSubscriber instance created with options: ${JSON.stringify(this.options())} and url: ${this.url}`); } @@ -85,7 +85,7 @@ export abstract class RabbitMqSubscriber implements OnModuleInit, QueueSubscr async onModuleInit(): Promise { // Not using SettingService here as that will necessitate all implementors of RabbitMqSubscriber to also inject SettingService which is not ideal. // Instead we directly read the environment variables here. - const defaultBroker = process.env.QUEUES_DEFAULT_BROKER || 'rabbitmq'; + const defaultBroker = process.env.QUEUES_DEFAULT_BROKER || 'database'; const solidCliRunning = process.env.SOLID_CLI_RUNNING || "false"; const queueNameRegex = (process.env.QUEUES_QUEUE_NAME_REGEX_TO_ENABLE || '').trim(); const roleAllowed = ['both', 'subscriber'].includes(this.serviceRole); @@ -107,7 +107,7 @@ export abstract class RabbitMqSubscriber implements OnModuleInit, QueueSubscr this.logger.log(`RabbitMqSubscriber for queue ${queueName} is disabled because it does not match QUEUES_QUEUE_NAME_REGEX_TO_ENABLE=${queueNameRegex}`); return; } - } catch (error) { + } catch (error: any) { this.logger.error(`Invalid QUEUES_QUEUE_NAME_REGEX_TO_ENABLE regex "${queueNameRegex}". Subscriber for queue ${queueName} will not start.`); return; } @@ -116,7 +116,7 @@ export abstract class RabbitMqSubscriber implements OnModuleInit, QueueSubscr const namespacedQueueName = buildNamespacedQueueName(queueName); try { await this.connectAndConsume(namespacedQueueName); - } catch (err) { + } catch (err: any) { this.logger.error(`Failed to connect to RabbitMQ for queue ${namespacedQueueName}: ${(err as Error).message}`, (err as Error).stack); this.triggerReconnect(namespacedQueueName, 'initial connection failure'); } @@ -142,7 +142,7 @@ export abstract class RabbitMqSubscriber implements OnModuleInit, QueueSubscr let connection: amqp.Connection; try { connection = await this.establishConnection(); - } catch (err) { + } catch (err: any) { this.logger.error(`Failed to connect to RabbitMQ for queue ${queueName}: ${(err as Error).message}`, (err as Error).stack); throw err; } @@ -210,7 +210,7 @@ export abstract class RabbitMqSubscriber implements OnModuleInit, QueueSubscr const messageContentString = rawMessage.content.toString(); message = JSON.parse(messageContentString) as QueueMessage; this.logger.debug(`rabbitmq subscriber received message with id: ${message.messageId} for queue ${queueName}`); - } catch (error) { + } catch (error: any) { this.logger.error(`Invalid JSON message on queue ${queueName}: ${(error as Error).message}`); await this.publishToFailedQueue(queueName, rawMessage.content, channel, error); channel.ack(rawMessage); @@ -223,7 +223,7 @@ export abstract class RabbitMqSubscriber implements OnModuleInit, QueueSubscr try { await this.processMessage(message, rawMessage, channel, queueName); - } catch (error) { + } catch (error: any) { await this.handleProcessingError(message, rawMessage, channel, error, queueName); } }, @@ -277,7 +277,7 @@ export abstract class RabbitMqSubscriber implements OnModuleInit, QueueSubscr channel.sendToQueue(failedQueue, body, errorMessage ? { headers: { 'x-error': errorMessage } } : undefined); - } catch (err) { + } catch (err: any) { this.logger.error(`Failed to publish to failed queue ${failedQueue}: ${(err as Error).message}`); } } @@ -302,7 +302,7 @@ export abstract class RabbitMqSubscriber implements OnModuleInit, QueueSubscr this.reconnectAttempt = 0; this.logger.log(`RabbitMqSubscriber reconnected for queue ${queueName}`); return; - } catch (err) { + } catch (err: any) { this.reconnectAttempt += 1; const delay = this.backoff(); this.logger.warn(`RabbitMqSubscriber reconnect failed for queue ${queueName}; retrying in ${delay}ms`); @@ -407,7 +407,7 @@ export abstract class RabbitMqSubscriber implements OnModuleInit, QueueSubscr await this.mqMessageService.repo.update(mqMessage.id, updatedFields); } } - catch (error) { + catch (error: any) { this.logger.error(error.message, error.stack); } @@ -485,7 +485,7 @@ export abstract class RabbitMqSubscriber implements OnModuleInit, QueueSubscr // - If timeoutPromise rejects first, we fail fast with timeout error. // This ensures we mark DB status via normal error handling before broker ack-timeout. return await Promise.race([subscribePromise, timeoutPromise]); - } catch (error) { + } catch (error: any) { const errorMessage = (error as Error)?.message || String(error); this.logger.error( `Subscriber execution failed for queue ${queueName} and messageId ${messageId}: ${errorMessage}`, diff --git a/src/services/queues/redis-publisher.service.ts b/src/services/queues/redis-publisher.service.ts index 10dd7469..a8ea343a 100644 --- a/src/services/queues/redis-publisher.service.ts +++ b/src/services/queues/redis-publisher.service.ts @@ -16,7 +16,10 @@ export abstract class RedisPublisher implements OnModuleDestroy, QueuePublish protected readonly mqMessageService: MqMessageService, protected readonly mqMessageQueueService: MqMessageQueueService, ) { - this.serviceRole = process.env.QUEUES_SERVICE_ROLE; + this.serviceRole = process.env.QUEUES_SERVICE_ROLE || 'both'; + if (!process.env.QUEUES_SERVICE_ROLE) { + this.logger.debug('QUEUES_SERVICE_ROLE is not defined. Defaulting RedisPublisher service role to "both".'); + } if (!process.env.QUEUES_REDIS_URL) { this.logger.debug('RedisPublisher: QUEUES_REDIS_URL is not defined in the environment variables'); } @@ -65,7 +68,7 @@ export abstract class RedisPublisher implements OnModuleDestroy, QueuePublish const client = this.getClient(); await client.publish(namespacedQueueName, JSON.stringify(message)); this.logger.debug(`RedisPublisher published message ${message.messageId} to channel ${namespacedQueueName}`); - } catch (err) { + } catch (err: any) { this.logger.error(`RedisPublisher failed to publish message: ${(err as Error).message}`, (err as Error).stack); } @@ -87,8 +90,8 @@ export abstract class RedisPublisher implements OnModuleDestroy, QueuePublish parentEntity: message.parentEntity, mqMessageQueueId: mqMessageQueue.id, }); - } catch (error) { + } catch (error: any) { this.logger.error(error.message, error.stack); } } -} \ No newline at end of file +} diff --git a/src/services/queues/redis-subscriber.service.ts b/src/services/queues/redis-subscriber.service.ts index d707959e..7f77bd23 100644 --- a/src/services/queues/redis-subscriber.service.ts +++ b/src/services/queues/redis-subscriber.service.ts @@ -17,7 +17,10 @@ export abstract class RedisSubscriber implements OnModuleInit, OnModuleDestro protected readonly mqMessageService: MqMessageService, protected readonly mqMessageQueueService: MqMessageQueueService, ) { - this.serviceRole = process.env.QUEUES_SERVICE_ROLE; + this.serviceRole = process.env.QUEUES_SERVICE_ROLE || 'both'; + if (!process.env.QUEUES_SERVICE_ROLE) { + this.logger.debug('QUEUES_SERVICE_ROLE is not defined. Defaulting RedisSubscriber service role to "both".'); + } if (!process.env.QUEUES_REDIS_URL) { this.logger.debug('RedisSubscriber: QUEUES_REDIS_URL is not defined in the environment variables'); } @@ -50,7 +53,7 @@ export abstract class RedisSubscriber implements OnModuleInit, OnModuleDestro ); return; } - } catch (error) { + } catch (error: any) { this.logger.error( `Invalid QUEUES_QUEUE_NAME_REGEX_TO_ENABLE regex "${queueNameRegex}". Subscriber for queue ${queueName} will not start.`, ); @@ -92,7 +95,7 @@ export abstract class RedisSubscriber implements OnModuleInit, OnModuleDestro let message: QueueMessage = null; try { message = JSON.parse(rawMessage) as QueueMessage; - } catch (error) { + } catch (error: any) { this.logger.error(`RedisSubscriber invalid JSON on channel ${channel}: ${(error as Error).message}`); return; } @@ -103,7 +106,7 @@ export abstract class RedisSubscriber implements OnModuleInit, OnModuleDestro try { await this.processMessage(message); - } catch (error) { + } catch (error: any) { await this.handleProcessingError(message, error, channel); } }); @@ -147,7 +150,7 @@ export abstract class RedisSubscriber implements OnModuleInit, OnModuleDestro try { await this.connectAndSubscribe(channel); this.logger.log(`RedisSubscriber reconnected for channel ${channel}`); - } catch (err) { + } catch (err: any) { this.triggerReconnect(channel, `reconnect failed: ${(err as Error).message}`); } }, delay); @@ -201,7 +204,7 @@ export abstract class RedisSubscriber implements OnModuleInit, OnModuleDestro if (stage === 'failed') updatedFields['error'] = error; await this.mqMessageService.repo.update(mqMessage.id, updatedFields); } - } catch (err) { + } catch (err: any) { this.logger.error(err.message, err.stack); } } diff --git a/src/services/role-metadata.service.ts b/src/services/role-metadata.service.ts index d778d0d9..2842587e 100755 --- a/src/services/role-metadata.service.ts +++ b/src/services/role-metadata.service.ts @@ -91,7 +91,7 @@ export class RoleMetadataService extends CRUDService { } */ } - } catch (error) { + } catch (error: any) { this.logger.error(error); } } diff --git a/src/services/scheduled-jobs/scheduler.service.ts b/src/services/scheduled-jobs/scheduler.service.ts index 37deee16..1681cca0 100644 --- a/src/services/scheduled-jobs/scheduler.service.ts +++ b/src/services/scheduled-jobs/scheduler.service.ts @@ -36,7 +36,7 @@ export class SchedulerServiceImpl implements ISchedulerService { if (jobsRegexToEnable && jobsRegexToEnable !== "all") { try { jobsRegex = new RegExp(jobsRegexToEnable); - } catch (error) { + } catch (error: any) { this.logger.error(`Invalid SOLID_SCHEDULER_JOBS_REGEX_TO_ENABLE regex "${jobsRegexToEnable}". Scheduler loop will skip this run.`); return; } @@ -101,7 +101,7 @@ export class SchedulerServiceImpl implements ISchedulerService { await this.scheduledJobRepo.save(job); this.logger.log(`[${now.getTime()}]: scheduler service finished running job: ${job.job}`); - } catch (err) { + } catch (err: any) { this.logger.error(`[${now.getTime()}]: scheduler service failed to run job ${job.job}`, err.stack); } finally { this.runningJobs.delete(jobKey); @@ -153,7 +153,7 @@ export class SchedulerServiceImpl implements ISchedulerService { try { const parsed = JSON.parse(dayOfWeek); return Array.isArray(parsed) ? parsed : []; - } catch (error) { + } catch (error: any) { this.logger.warn(`Invalid dayOfWeek JSON '${dayOfWeek}'`, error as any); return []; } @@ -217,10 +217,10 @@ export class SchedulerServiceImpl implements ISchedulerService { if (runAfterNext.getTime() - nextRun.getTime() < 60000) { throw new Error('Cron expression interval must be at least 1 minute'); } - + this.logger.log(`Custom cron '${job.cronExpression}' next run: ${nextRun}`); return nextRun; - } catch (error) { + } catch (error: any) { this.logger.error(`Invalid cron expression for job ${job.scheduleName}: ${job.cronExpression}. Reason: ${(error as Error).message}`); // Fallback to daily if cron parsing fails return new Date(base.getTime() + 24 * 60 * 60 * 1000); diff --git a/src/services/selection-providers/list-of-dashboard-question-providers-selection-provider.service.ts b/src/services/selection-providers/list-of-dashboard-question-providers-selection-provider.service.ts deleted file mode 100644 index 463a80fd..00000000 --- a/src/services/selection-providers/list-of-dashboard-question-providers-selection-provider.service.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { Injectable } from "@nestjs/common"; -import { SelectionProvider } from "src/decorators/selection-provider.decorator"; -import { SolidRegistry } from "src/helpers/solid-registry"; -import { IDashboardQuestionDataProvider, IDashboardVariableSelectionProvider, ISelectionProvider, ISelectionProviderContext, ISelectionProviderValues } from "../../interfaces"; -import { SQL_DYNAMIC_PROVIDER_NAME } from "../dashboard.service"; -import { CHARTJS_SQL_DATA_PROVIDER_NAME, INBUILT_SQL_DATA_PROVIDERS, PRIME_REACT_DATATABLE_SQL_DATA_PROVIDER_NAME, PRIME_REACT_METER_GROUP_SQL_DATA_PROVIDER_NAME } from "../dashboard-question.service"; - - -@SelectionProvider() -@Injectable() -export class ListOfDashboardQuestionProvidersSelectionProvider implements ISelectionProvider { - - constructor( - private readonly solidRegistry: SolidRegistry, - ) { - } - - help(): string { - return "# Allows one to dynamically fetch all the dashboard providers that are registered in the system. "; - } - - name(): string { - return 'ListOfDashboardQuestionProvidersSelectionProvider'; - } - - async value(optionValue: string, ctxt: ISelectionProviderContext): Promise { - const dashboardSelectionProvider: IDashboardQuestionDataProvider | undefined = this.solidRegistry.getDashboardQuestionDataProviderInstance(optionValue); - if (!dashboardSelectionProvider) { - return null; - } - - return { label: dashboardSelectionProvider.name(), value: dashboardSelectionProvider.name() }; - } - - async values(query: string, ctxt: ISelectionProviderContext): Promise { - const dashboardSelectionProviders = this.solidRegistry.getDashboardQuestionDataProviders() - //Exclude the SQL dynamic provider from the list, (since although it is a dashboard selection provider, it is not a valid option for the user to select) - return dashboardSelectionProviders - .filter(provider => !INBUILT_SQL_DATA_PROVIDERS.includes(provider.name())) - .map(i => { - return { label: i.name, value: i.name }; - }); - } -} \ No newline at end of file diff --git a/src/services/selection-providers/list-of-dashboard-variable-providers-selection-provider.service.ts b/src/services/selection-providers/list-of-dashboard-variable-providers-selection-provider.service.ts deleted file mode 100644 index cc910360..00000000 --- a/src/services/selection-providers/list-of-dashboard-variable-providers-selection-provider.service.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Injectable } from "@nestjs/common"; -import { SelectionProvider } from "src/decorators/selection-provider.decorator"; -import { SolidRegistry } from "src/helpers/solid-registry"; -import { IDashboardVariableSelectionProvider, ISelectionProvider, ISelectionProviderContext, ISelectionProviderValues } from "../../interfaces"; -import { SQL_DYNAMIC_PROVIDER_NAME } from "../dashboard.service"; - - -@SelectionProvider() -@Injectable() -export class ListOfDashboardVariableProvidersSelectionProvider implements ISelectionProvider { - - constructor( - private readonly solidRegistry: SolidRegistry, - ) { - } - - help(): string { - return "# Allows one to dynamically fetch all the dashboard providers that are registered in the system. "; - } - - name(): string { - return 'ListOfDashboardVariableProvidersSelectionProvider'; - } - - async value(optionValue: string, ctxt: ISelectionProviderContext): Promise { - const dashboardSelectionProvider: IDashboardVariableSelectionProvider | undefined = this.solidRegistry.getDashboardVariableSelectionProviderInstance(optionValue); - if (!dashboardSelectionProvider) { - return null; - } - - return { label: dashboardSelectionProvider.name(), value: dashboardSelectionProvider.name() }; - } - - async values(query: string, ctxt: ISelectionProviderContext): Promise { - const dashboardSelectionProviders = this.solidRegistry.getDashboardVariableSelectionProviders() - //Exclude the SQL dynamic provider from the list, (since although it is a dashboard selection provider, it is not a valid option for the user to select) - return dashboardSelectionProviders.filter(i => (i.name !== SQL_DYNAMIC_PROVIDER_NAME)).map(i => { - return { label: i.name, value: i.name }; - }); - } -} \ No newline at end of file diff --git a/src/services/selection-providers/mq-dashboard-message-broker-variable-options-provider.service.ts b/src/services/selection-providers/mq-dashboard-message-broker-variable-options-provider.service.ts new file mode 100644 index 00000000..60814e84 --- /dev/null +++ b/src/services/selection-providers/mq-dashboard-message-broker-variable-options-provider.service.ts @@ -0,0 +1,52 @@ +import { Injectable } from "@nestjs/common"; +import { SelectionProvider } from "src/decorators/selection-provider.decorator"; +import { MqMessageRepository } from "src/repository/mq-message.repository"; +import { ISelectionProvider, ISelectionProviderContext, ISelectionProviderValues } from "src/interfaces"; + +@SelectionProvider() +@Injectable() +export class MqDashboardMessageBrokerVariableOptionsProvider implements ISelectionProvider { + constructor( + private readonly mqMessageRepository: MqMessageRepository, + ) { } + + name(): string { + return "MqDashboardMessageBrokerVariableOptionsProvider"; + } + + help(): string { + return "Dynamic options provider for dashboard messageBroker variable."; + } + + async value(optionValue: string): Promise { + if (!optionValue) return null; + return { label: optionValue, value: optionValue }; + } + + async values(query: string, ctxt: ISelectionProviderContext): Promise { + const qb = await this.mqMessageRepository.createSecurityRuleAwareQueryBuilder("mqMessage"); + const limit = Math.min(Math.max(ctxt?.limit ?? 25, 1), 200); + const offset = Math.max(ctxt?.offset ?? 0, 0); + + qb.select("DISTINCT mqMessage.messageBroker", "value") + .where("mqMessage.messageBroker IS NOT NULL"); + + if (query) { + qb.andWhere("mqMessage.messageBroker ILIKE :query", { query: `%${query}%` }); + } + + const rows = await qb + .orderBy("value", "ASC") + .take(limit) + .skip(offset) + .getRawMany<{ value: string }>(); + + return rows + .filter((r) => !!r?.value) + .map((r) => ({ + label: r.value, + value: r.value, + })); + } +} + diff --git a/src/services/selection-providers/mq-dashboard-queue-name-variable-options-provider.service.ts b/src/services/selection-providers/mq-dashboard-queue-name-variable-options-provider.service.ts new file mode 100644 index 00000000..dc5cbf08 --- /dev/null +++ b/src/services/selection-providers/mq-dashboard-queue-name-variable-options-provider.service.ts @@ -0,0 +1,51 @@ +import { Injectable } from "@nestjs/common"; +import { SelectionProvider } from "src/decorators/selection-provider.decorator"; +import { MqMessageQueueRepository } from "src/repository/mq-message-queue.repository"; +import { ISelectionProvider, ISelectionProviderContext, ISelectionProviderValues } from "src/interfaces"; + +@SelectionProvider() +@Injectable() +export class MqDashboardQueueNameVariableOptionsProvider implements ISelectionProvider { + constructor( + private readonly mqMessageQueueRepository: MqMessageQueueRepository, + ) { } + + name(): string { + return "MqDashboardQueueNameVariableOptionsProvider"; + } + + help(): string { + return "Dynamic options provider for dashboard queueName variable."; + } + + async value(optionValue: string): Promise { + if (!optionValue) return null; + const queue = await this.mqMessageQueueRepository.findOne({ + where: { name: optionValue }, + }); + if (!queue?.name) return null; + return { label: queue.name, value: queue.name }; + } + + async values(query: string, ctxt: ISelectionProviderContext): Promise { + const qb = await this.mqMessageQueueRepository.createSecurityRuleAwareQueryBuilder("mqMessageQueue"); + const limit = Math.min(Math.max(ctxt?.limit ?? 25, 1), 200); + const offset = Math.max(ctxt?.offset ?? 0, 0); + + if (query) { + qb.andWhere("mqMessageQueue.name ILIKE :query", { query: `%${query}%` }); + } + + const records = await qb + .select(["mqMessageQueue.name"]) + .orderBy("mqMessageQueue.name", "ASC") + .take(limit) + .skip(offset) + .getMany(); + + return records + .filter((r) => !!r?.name) + .map((r) => ({ label: r.name, value: r.name })); + } +} + diff --git a/src/services/settings/default-settings-provider.service.ts b/src/services/settings/default-settings-provider.service.ts index cf8bcdd9..728bfe6f 100644 --- a/src/services/settings/default-settings-provider.service.ts +++ b/src/services/settings/default-settings-provider.service.ts @@ -43,6 +43,16 @@ const getSolidCoreSettings = (isProd: boolean) => sortOrder: 50, controlType: "boolean", }, + { + moduleName: "solid-core", + key: "iamMicrosoftActiveDirectoryOAuthEnabled", + value: false, + level: SettingLevel.SystemAdminEditable, + label: "Allow Login / Signup With Microsoft Active Directory", + group: "authentication-settings", + sortOrder: 50, + controlType: "boolean", + }, { moduleName: "solid-core", key: "authPagesLayout", @@ -363,6 +373,16 @@ const getSolidCoreSettings = (isProd: boolean) => group: "system-settings", sortOrder: 30, controlType: "shortText", + }, + { + moduleName: "solid-core", + key: "frontendAdminBaseUrl", + value: process.env.FRONTEND_BASE_URL, + level: SettingLevel.SystemAdminReadonly, + label: "Frontend Admin Base URL", + group: "system-settings", + sortOrder: 35, + controlType: "shortText", }, { moduleName: "solid-core", @@ -693,6 +713,55 @@ const getSolidCoreSettings = (isProd: boolean) => level: SettingLevel.SystemAdminReadonly, }, + // microsoft-active-directory-oauth-settings-provider.service.ts + { + moduleName: "solid-core", + key: "MICROSOFT_ACTIVE_DIRECTORY_CLIENT_ID", + value: process.env.IAM_MICROSOFT_ACTIVE_DIRECTORY_OAUTH_CLIENT_ID, + level: SettingLevel.SystemAdminReadonly, + label: "Microsoft Active Directory OAuth Client ID", + group: "oauth-settings", + sortOrder: 70, + controlType: "shortText", + }, + { + moduleName: "solid-core", + key: "MICROSOFT_ACTIVE_DIRECTORY_CLIENT_SECRET", + value: process.env.IAM_MICROSOFT_ACTIVE_DIRECTORY_OAUTH_CLIENT_SECRET, + level: SettingLevel.SystemEnv, + }, + { + moduleName: "solid-core", + key: "MICROSOFT_ACTIVE_DIRECTORY_TENANT_ID", + value: + process.env.IAM_MICROSOFT_ACTIVE_DIRECTORY_OAUTH_TENANT_ID || "common", + level: SettingLevel.SystemAdminReadonly, + label: "Microsoft Active Directory OAuth Tenant ID", + group: "oauth-settings", + sortOrder: 80, + controlType: "shortText", + }, + { + moduleName: "solid-core", + key: "MICROSOFT_ACTIVE_DIRECTORY_CALLBACK_URL", + value: process.env.IAM_MICROSOFT_ACTIVE_DIRECTORY_OAUTH_CALLBACK_URL, + level: SettingLevel.SystemAdminReadonly, + label: "Microsoft Active Directory OAuth Callback URL", + group: "oauth-settings", + sortOrder: 90, + controlType: "shortText", + }, + { + moduleName: "solid-core", + key: "MICROSOFT_ACTIVE_DIRECTORY_REDIRECT_URL", + value: process.env.IAM_MICROSOFT_ACTIVE_DIRECTORY_OAUTH_REDIRECT_URL, + level: SettingLevel.SystemAdminReadonly, + label: "Microsoft Active Directory OAuth Redirect URL", + group: "oauth-settings", + sortOrder: 100, + controlType: "shortText", + }, + // iam-settings-provider.service.ts { moduleName: "solid-core", @@ -724,7 +793,11 @@ const getSolidCoreSettings = (isProd: boolean) => label: "Registration Validation Type", group: "authentication-settings", sortOrder: 30, - controlType: "shortText", + controlType: "selectionStatic", + options: [ + { label: "Email", value: "email" }, + { label: "Mobile", value: "mobile" }, + ], }, { moduleName: "solid-core", @@ -734,7 +807,12 @@ const getSolidCoreSettings = (isProd: boolean) => label: "Login Validation Type", group: "authentication-settings", sortOrder: 40, - controlType: "shortText", + controlType: "selectionStatic", + options: [ + { label: "Email", value: "email" }, + { label: "Mobile", value: "mobile" }, + { label: "Selectable", value: "selectable" }, + ], }, { moduleName: "solid-core", @@ -770,6 +848,46 @@ const getSolidCoreSettings = (isProd: boolean) => value: parseInt(process.env.IAM_OTP_EXPIRY ?? "10"), level: SettingLevel.SystemEnv, }, + { + moduleName: "solid-core", + key: "otpWhatsappFallbackEnabled", + value: (process.env.IAM_OTP_WHATSAPP_FALLBACK_ENABLED ?? "true") === "true", + level: SettingLevel.SystemAdminEditable, + label: "OTP WhatsApp Fallback Enabled", + group: "authentication-settings", + sortOrder: 85, + controlType: "boolean", + }, + { + moduleName: "solid-core", + key: "otpWhatsappTemplateId", + value: process.env.IAM_OTP_WHATSAPP_TEMPLATE_ID ?? "common_otp", + level: SettingLevel.SystemAdminEditable, + label: "OTP WhatsApp Template ID", + group: "authentication-settings", + sortOrder: 86, + controlType: "shortText", + }, + { + moduleName: "solid-core", + key: "otpWhatsappIndependentEnabled", + value: (process.env.IAM_OTP_WHATSAPP_INDEPENDENT_ENABLED ?? "true") === "true", + level: SettingLevel.SystemAdminEditable, + label: "OTP WhatsApp Independent Enabled", + group: "authentication-settings", + sortOrder: 87, + controlType: "boolean", + }, + { + moduleName: "solid-core", + key: "otpDefaultCountryDialCode", + value: process.env.IAM_OTP_DEFAULT_COUNTRY_DIAL_CODE ?? "", + level: SettingLevel.SystemAdminEditable, + label: "OTP Default Country Dial Code", + group: "authentication-settings", + sortOrder: 88, + controlType: "shortText", + }, { moduleName: "solid-core", key: "forgotPasswordVerificationTokenExpiry", @@ -899,7 +1017,11 @@ const getSolidCoreSettings = (isProd: boolean) => ( process.env.IAM_SEND_WELCOME_EMAIL_ON_SIGNUP ?? "false" ).toLowerCase() === "true", - level: SettingLevel.SystemEnv, + level: SettingLevel.SystemAdminEditable, + label: "Send Welcome Email On Signup", + group: "authentication-settings", + sortOrder: 180, + controlType: "boolean", }, { moduleName: "solid-core", @@ -908,7 +1030,11 @@ const getSolidCoreSettings = (isProd: boolean) => ( process.env.IAM_SEND_WELCOME_SMS_ON_SIGNUP ?? "false" ).toLowerCase() === "true", - level: SettingLevel.SystemEnv, + level: SettingLevel.SystemAdminEditable, + label: "Send Welcome SMS On Signup", + group: "authentication-settings", + sortOrder: 190, + controlType: "boolean", }, { moduleName: "solid-core", @@ -1161,6 +1287,64 @@ const getSolidCoreSettings = (isProd: boolean) => sortOrder: 30, controlType: "shortText", }, + { + moduleName: "solid-core", + key: "metaWhatsappApiUrl", + value: process.env.COMMON_META_WHATSAPP_API_URL || "https://graph.facebook.com", + level: SettingLevel.SystemAdminReadonly, + label: "Meta WhatsApp API URL", + group: "whatsapp-settings", + sortOrder: 40, + controlType: "shortText", + }, + { + moduleName: "solid-core", + key: "metaWhatsappApiVersion", + value: process.env.COMMON_META_WHATSAPP_API_VERSION || "v23.0", + level: SettingLevel.SystemAdminReadonly, + label: "Meta WhatsApp API Version", + group: "whatsapp-settings", + sortOrder: 50, + controlType: "shortText", + }, + { + moduleName: "solid-core", + key: "metaWhatsappPhoneNumberId", + value: process.env.COMMON_META_WHATSAPP_PHONE_NUMBER_ID, + level: SettingLevel.SystemAdminReadonly, + label: "Meta WhatsApp Phone Number ID", + group: "whatsapp-settings", + sortOrder: 60, + controlType: "shortText", + }, + { + moduleName: "solid-core", + key: "metaWhatsappBusinessAccountId", + value: process.env.COMMON_META_WHATSAPP_BUSINESS_ACCOUNT_ID, + level: SettingLevel.SystemAdminReadonly, + label: "Meta WhatsApp Business Account ID", + group: "whatsapp-settings", + sortOrder: 70, + controlType: "shortText", + }, + { + moduleName: "solid-core", + key: "metaWhatsappAccessToken", + value: process.env.COMMON_META_WHATSAPP_ACCESS_TOKEN, + level: SettingLevel.SystemEnv, + }, + { + moduleName: "solid-core", + key: "metaWhatsappWebhookVerifyToken", + value: process.env.COMMON_META_WHATSAPP_WEBHOOK_VERIFY_TOKEN, + level: SettingLevel.SystemEnv, + }, + { + moduleName: "solid-core", + key: "metaWhatsappAppSecret", + value: process.env.COMMON_META_WHATSAPP_APP_SECRET, + level: SettingLevel.SystemEnv, + }, ] as const satisfies SettingDefinition[]; // 2. diff --git a/src/services/sms/TwilioSMSService.ts b/src/services/sms/TwilioSMSService.ts index 8d9700ee..5a8703b5 100644 --- a/src/services/sms/TwilioSMSService.ts +++ b/src/services/sms/TwilioSMSService.ts @@ -69,7 +69,7 @@ export class TwilioSMSService implements ISMS { try { const bodyTemplate = Handlebars.compile(smsTemplate.body); body = bodyTemplate(templateParams); - } catch (error) { + } catch (error: any) { throw new Error('Unable to compile sms template body'); } // Finally send the email. @@ -110,7 +110,7 @@ export class TwilioSMSService implements ISMS { } return r; - } catch (error) { + } catch (error: any) { throw new Error(error); } } diff --git a/src/services/solid-introspect.service.ts b/src/services/solid-introspect.service.ts index 119f3e12..c0f67aa7 100755 --- a/src/services/solid-introspect.service.ts +++ b/src/services/solid-introspect.service.ts @@ -6,13 +6,12 @@ import { ModelMetadataRepository } from 'src/repository/model-metadata.repositor import { InstanceWrapper } from '@nestjs/core/injector/instance-wrapper'; import { getDataSourceToken } from '@nestjs/typeorm'; import { IS_COMPUTED_FIELD_PROVIDER } from 'src/decorators/computed-field-provider.decorator'; -import { IS_DASHBOARD_QUESTION_DATA_PROVIDER } from 'src/decorators/dashboard-question-data-provider.decorator'; -import { IS_DASHBOARD_VARIABLE_SELECTION_PROVIDER } from 'src/decorators/dashboard-selection-provider.decorator'; import { IS_ERROR_CODE_PROVIDER } from 'src/decorators/error-codes-provider.decorator'; import { IS_MAIL_PROVIDER } from 'src/decorators/mail-provider.decorator'; import { IS_SCHEDULED_JOB_PROVIDER } from 'src/decorators/scheduled-job-provider.decorator'; import { IS_SECURITY_RULE_CONFIG_PROVIDER } from 'src/decorators/security-rule-config-provider.decorator'; import { IS_SELECTION_PROVIDER } from 'src/decorators/selection-provider.decorator'; +import { IS_DASHBOARD_WIDGET_DATA_PROVIDER } from 'src/decorators/dashboard-widget-data-provider.decorator'; import { IS_EXTENSION_USER_CREATION_PROVIDER } from 'src/decorators/extension-user-creation-provider.decorator'; import { IS_SOLID_DATABASE_MODULE } from 'src/decorators/solid-database-module.decorator'; import { IS_WA_PROVIDER } from 'src/decorators/whatsapp-provider.decorator'; @@ -70,24 +69,18 @@ export class SolidIntrospectService implements OnApplicationBootstrap { this.solidRegistry.registerSelectionProvider(selectionProvider); }); + // Register all IDashboardWidgetDataProvider implementations + const dashboardWidgetDataProviders = this.discoveryService.getProviders().filter((provider) => this.isDashboardWidgetDataProvider(provider)); + dashboardWidgetDataProviders.forEach((provider) => { + this.solidRegistry.registerDashboardWidgetDataProvider(provider); + }); + // Register all ISettingsProvider implementations const settingsProviders = this.discoveryService.getProviders().filter((provider) => this.isSettingsProvider(provider)); settingsProviders.forEach((settingsProvider) => { this.solidRegistry.registerSettingsProvider(settingsProvider); }); - // Register all IDashboardSelectionProvider implementations - const dashboardVariableSelectionProviders = this.discoveryService.getProviders().filter((provider) => this.isDashboardVariableSelectionProvider(provider)); - dashboardVariableSelectionProviders.forEach((dashboardSelectionProvider) => { - this.solidRegistry.registerDashboardVariableSelectionProvider(dashboardSelectionProvider); - }); - - // Register all IDashboardSelectionProvider implementations - const dashboardQuestionDataProviders = this.discoveryService.getProviders().filter((provider) => this.isDashboardQuestionDataProvider(provider)); - dashboardQuestionDataProviders.forEach((provider) => { - this.solidRegistry.registerDashboardQuestionDataProvider(provider); - }); - // Register all IComputedProvider implementations const computedFieldProviders = this.discoveryService.getProviders().filter((provider) => this.isComputedFieldProvider(provider)); computedFieldProviders.forEach((computedFieldProvider) => { @@ -197,7 +190,7 @@ export class SolidIntrospectService implements OnApplicationBootstrap { let ds: DataSource | undefined; try { ds = this.moduleRef.get(token, { strict: false }); - } catch (err) { + } catch (err: any) { this.logger.warn(`DataSource token for "${dsName ?? 'default'}" not found: ${err?.message ?? err}`); } if (!ds) { @@ -209,7 +202,7 @@ export class SolidIntrospectService implements OnApplicationBootstrap { if (!ds.isInitialized) { try { await ds.initialize(); // only if you need to initialize here; in many apps datasources are created earlier - } catch (err) { + } catch (err: any) { this.logger.error(`Failed to initialize DataSource "${dsName}": ${err}`); continue; } @@ -250,16 +243,6 @@ export class SolidIntrospectService implements OnApplicationBootstrap { } } - isDashboardQuestionDataProvider(providerWrapper: InstanceWrapper) { - const { instance } = providerWrapper; - if (!instance) return false; - const provider = this.reflector.get( - IS_DASHBOARD_QUESTION_DATA_PROVIDER, - instance.constructor, - ); - return !!provider; - } - // This method identifies a provider as a seeder if it has a seed method i.e duck typing private isSeeder(provider: InstanceWrapper) { const { instance } = provider; @@ -294,6 +277,18 @@ export class SolidIntrospectService implements OnApplicationBootstrap { return !!isSelectionProvider; } + private isDashboardWidgetDataProvider(provider: InstanceWrapper) { + const { instance } = provider; + if (!instance) return false; + + const isDashboardWidgetDataProvider = this.reflector.get( + IS_DASHBOARD_WIDGET_DATA_PROVIDER, + instance.constructor, + ); + + return !!isDashboardWidgetDataProvider; + } + private isExtensionUserCreationProvider(provider: InstanceWrapper): boolean { const { instance } = provider; if (!instance) return false; @@ -312,16 +307,6 @@ export class SolidIntrospectService implements OnApplicationBootstrap { return !!isSettingsProvider; } - private isDashboardVariableSelectionProvider(provider: InstanceWrapper) { - const { instance } = provider; - if (!instance) return false; - const isDashboardSelectionProvider = this.reflector.get( - IS_DASHBOARD_VARIABLE_SELECTION_PROVIDER, - instance.constructor, - ); - return !!isDashboardSelectionProvider; - } - private isComputedFieldProvider(provider: InstanceWrapper) { const { instance } = provider; if (!instance) return false; diff --git a/src/services/sql-expression-resolver.service.ts b/src/services/sql-expression-resolver.service.ts deleted file mode 100644 index 2a9ca298..00000000 --- a/src/services/sql-expression-resolver.service.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { Injectable } from "@nestjs/common"; -import { SqlExpression, SqlExpressionOperator } from "./question-data-providers/chartjs-sql-data-provider.service"; -import { RequestContextService } from "./request-context.service"; -import { ERROR_MESSAGES } from "src/constants/error-messages"; - -export interface SqlReplacementResult { - rawSql: string; - parameters: any[]; // Positional parameters -} - -@Injectable() -export class SqlExpressionResolverService { - constructor(private readonly requestContextService: RequestContextService) { } - resolveSqlWithExpressions(sql: string, expressions: SqlExpression[]): SqlReplacementResult { - const variableToColumnMap: Record = {}; - const variablePattern = /{{\s*(\w+)\s*\[\s*([\w.]+)\s*\]\s*}}/g; - - let paramIndex = 1; - const parameters: any[] = []; - - // Handle sql expression tokens like {{$activeUserId}} in the SQL string - if (sql.includes('{{$activeUserId}}')) { - const activeUser = this.requestContextService.getActiveUser(); - if (activeUser && activeUser.sub) { - // Replace custom placeholder with parameter placeholder ($1) - sql = sql.replace(/\{\{\$activeUserId\}\}/g, `$${paramIndex++}`); - // Add the active user ID to parameters - parameters.push(activeUser.sub); - } - } - - // --- Pass 1: extract variable -> column mappings --- - let simplifiedSql = sql.replace(variablePattern, (_, variableName, columnName) => { - variableToColumnMap[variableName] = columnName; - return `{{${variableName}}}`; - }); - - // --- Pass 2: Replace each variable with positional fragment --- - - for (const expr of expressions) { - const column = variableToColumnMap[expr.variableName]; - if (!column) continue; - - let sqlFragment = ''; - const placeholder = `{{${expr.variableName}}}`; - - switch (expr.operator) { - case SqlExpressionOperator.EQUALS: - sqlFragment = `${column} = $${paramIndex++}`; - parameters.push(expr.value[0]); - break; - - case SqlExpressionOperator.NOT_EQUALS: - sqlFragment = `${column} != $${paramIndex++}`; - parameters.push(expr.value[0]); - break; - - case SqlExpressionOperator.CONTAINS: - sqlFragment = `${column} LIKE $${paramIndex++}`; - parameters.push(`%${expr.value[0]}%`); - break; - - case SqlExpressionOperator.NOT_CONTAINS: - sqlFragment = `${column} NOT LIKE $${paramIndex++}`; - parameters.push(`%${expr.value[0]}%`); - break; - - case SqlExpressionOperator.STARTS_WITH: - sqlFragment = `${column} LIKE $${paramIndex++}`; - parameters.push(`${expr.value[0]}%`); - break; - - case SqlExpressionOperator.ENDS_WITH: - sqlFragment = `${column} LIKE $${paramIndex++}`; - parameters.push(`%${expr.value[0]}`); - break; - - case SqlExpressionOperator.IN: - const inParams = expr.value.map(val => { - parameters.push(val); - return `$${paramIndex++}`; - }); - sqlFragment = `${column} IN (${inParams.join(", ")})`; - break; - - case SqlExpressionOperator.NOT_IN: - const notInParams = expr.value.map(val => { - parameters.push(val); - return `$${paramIndex++}`; - }); - sqlFragment = `${column} NOT IN (${notInParams.join(", ")})`; - break; - - case SqlExpressionOperator.BETWEEN: - sqlFragment = `${column} BETWEEN $${paramIndex} AND $${paramIndex + 1}`; - parameters.push(expr.value[0], expr.value[1]); - paramIndex += 2; - break; - - case SqlExpressionOperator.LT: - sqlFragment = `${column} < $${paramIndex++}`; - parameters.push(expr.value[0]); - break; - - case SqlExpressionOperator.LTE: - sqlFragment = `${column} <= $${paramIndex++}`; - parameters.push(expr.value[0]); - break; - - case SqlExpressionOperator.GT: - sqlFragment = `${column} > $${paramIndex++}`; - parameters.push(expr.value[0]); - break; - - case SqlExpressionOperator.GTE: - sqlFragment = `${column} >= $${paramIndex++}`; - parameters.push(expr.value[0]); - break; - - default: - throw new Error(ERROR_MESSAGES.UNSUPPORTED_SQL_OPERATOR(expr.operator)); - } - simplifiedSql = simplifiedSql.replace(placeholder, sqlFragment); - } - - // --- Final cleanup: remove any remaining placeholders --- - simplifiedSql = simplifiedSql.replace(/{{\s*\w+\s*}}/g, ''); - - // Remove dangling where clause if no expressions were applied - simplifiedSql = simplifiedSql.replace(/\bwhere\b\s*$/i, '').trim(); - - // Need to handle scenarios of complex expression i.e with and / or clauses. probably need to have this logic in the sql expression object itself - - - return { - rawSql: simplifiedSql, - parameters - }; - } -} \ No newline at end of file diff --git a/src/services/user-activity-history.service.ts b/src/services/user-activity-history.service.ts index 36ae9117..44dcc21c 100644 --- a/src/services/user-activity-history.service.ts +++ b/src/services/user-activity-history.service.ts @@ -38,7 +38,7 @@ export class UserActivityHistoryService extends CRUDService ipAddress: ip, userAgent, }); - } catch (err) { + } catch (err: any) { this._logger.warn(`Failed to log event "${event}" for user ${user?.id}: ${err}`); } } diff --git a/src/services/user.service.ts b/src/services/user.service.ts index d13e96d3..e1ca9c07 100755 --- a/src/services/user.service.ts +++ b/src/services/user.service.ts @@ -30,6 +30,23 @@ export class UserService extends CRUDService { return normalized || "facebook_user"; } + private buildMicrosoftActiveDirectoryUsernameBase( + email?: string, + providerId?: string, + name?: string, + ): string { + const source = + email || + name || + (providerId ? `microsoft_active_directory_${providerId}` : ""); + const normalized = source + .trim() + .toLowerCase() + .replace(/[^a-z0-9@._-]+/g, "_") + .replace(/^_+|_+$/g, ""); + return normalized || "microsoft_active_directory_user"; + } + private async resolveUniqueUsername( preferredUsername: string, // fallbackUsername: string, @@ -285,7 +302,7 @@ export class UserService extends CRUDService { } async resolveUserOnOauthFacebook(oauthUserDto: OauthUserDto): Promise { - const normalizedEmail = oauthUserDto.email?.trim().toLowerCase() || null; + const normalizedEmail = oauthUserDto.email?.trim().toLowerCase(); let user: User | null = null; if (oauthUserDto.providerId) { @@ -397,6 +414,50 @@ export class UserService extends CRUDService { return user; } + async resolveUserOnOauthMicrosoftActiveDirectory( + oauthUserDto: OauthUserDto, + ): Promise { + const user = await this.repo.findOne({ + where: { + email: oauthUserDto.email, + }, + relations: { + roles: true, + }, + }); + + if (!user) { + const newUser = new User(); + newUser.username = oauthUserDto.email; + newUser.email = oauthUserDto.email; + newUser.fullName = oauthUserDto.name; + newUser.lastLoginProvider = oauthUserDto.provider; + newUser.accessCode = oauthUserDto.accessCode; + newUser.microsoftActiveDirectoryAccessToken = oauthUserDto.accessToken; + newUser.microsoftActiveDirectoryId = oauthUserDto.providerId; + newUser.microsoftActiveDirectoryProfilePicture = oauthUserDto.picture; + + const savedUser = await this.repo.save(newUser); + + await this.initializeRolesForNewUser( + [this.settingService.getConfigValue("defaultRole")], + savedUser, + ); + } else { + const entity = await this.repo.preload({ + id: user.id, + lastLoginProvider: oauthUserDto.provider, + accessCode: oauthUserDto.accessCode, + microsoftActiveDirectoryAccessToken: oauthUserDto.accessToken, + microsoftActiveDirectoryId: oauthUserDto.providerId, + microsoftActiveDirectoryProfilePicture: oauthUserDto.picture, + }); + + await this.repo.save(entity); + } + return user; + } + async findUsersByRole( roleName: string, relations: any = {}, diff --git a/src/services/view-metadata.service.ts b/src/services/view-metadata.service.ts index ed171fcb..ad7bfc8b 100755 --- a/src/services/view-metadata.service.ts +++ b/src/services/view-metadata.service.ts @@ -134,6 +134,7 @@ export class ViewMetadataService extends CRUDService { let viewModes = []; const menuItemModelId = menuItem?.action?.model?.id; const menuItemModuleId = menuItem?.module?.id; + const collectionViewTypes = ['card', 'list', 'kanban', 'tree']; if (menuItemModelId && menuItemModuleId) { const actionQb = await this.actionMetadataService.repo.createSecurityRuleAwareQueryBuilder('action'); const actionsForViewModes = await actionQb @@ -142,16 +143,32 @@ export class ViewMetadataService extends CRUDService { .leftJoinAndSelect('action.view', 'view') .where('model.id = :modelId', { modelId: menuItemModelId }) .andWhere('module.id = :moduleId', { moduleId: menuItemModuleId }) - .andWhere('view.type IN (:...viewTypes)', { viewTypes: ['card', 'list', 'kanban', 'tree'] }) + .andWhere('view.type IN (:...viewTypes)', { viewTypes: collectionViewTypes }) .getMany(); - viewModes = actionsForViewModes.map(actionItem => ({ - type: actionItem.view?.type ?? '', - menuItemId: menuItem.id, - menuItemName: menuItem.displayName, - actionId: actionItem.id ?? '', - actionName: actionItem.displayName ?? '', - })); + const canonicalActionsByViewType = new Map(); + for (const actionItem of actionsForViewModes) { + const resolvedViewType = actionItem.view?.type; + if (!resolvedViewType || canonicalActionsByViewType.has(resolvedViewType)) { + continue; + } + canonicalActionsByViewType.set(resolvedViewType, actionItem); + } + + if (action?.view?.type && collectionViewTypes.includes(action.view.type) && action?.id) { + canonicalActionsByViewType.set(action.view.type, action); + } + + viewModes = collectionViewTypes + .map((resolvedViewType) => canonicalActionsByViewType.get(resolvedViewType)) + .filter(Boolean) + .map(actionItem => ({ + type: actionItem.view?.type ?? '', + menuItemId: menuItem.id, + menuItemName: menuItem.displayName, + actionId: actionItem.id ?? '', + actionName: actionItem.displayName ?? '', + })); } const viewId = action?.view?.id diff --git a/src/services/whatsapp/GupshupOtpWhatsappService.ts b/src/services/whatsapp/GupshupOtpWhatsappService.ts new file mode 100644 index 00000000..fccfc12b --- /dev/null +++ b/src/services/whatsapp/GupshupOtpWhatsappService.ts @@ -0,0 +1,174 @@ +import { HttpService } from "@nestjs/axios"; +import { Injectable, Logger } from "@nestjs/common"; +import { AxiosError } from "axios"; +import { WhatsAppProvider } from "src/decorators/whatsapp-provider.decorator"; +import { IWhatsAppTransport } from "src/interfaces"; + +@Injectable() +@WhatsAppProvider() +export class GupshupOtpWhatsappService implements IWhatsAppTransport { + private readonly logger = new Logger(GupshupOtpWhatsappService.name); + + constructor(private readonly httpService: HttpService) {} + + async sendWhatsAppMessage( + to: string, + templateId: string, + parameters: any, + parentEntity?: any, + parentEntityId?: any, + ): Promise { + if (!to) { + throw new Error("WhatsApp destination number is required"); + } + + const payload = parameters?.payload; + if (payload?.message?.type === "text" && payload?.message?.text) { + await this.sendTextMessage( + payload.destination || to, + payload.message.text, + payload.source, + payload["src.name"], + ); + return { to, templateId, parameters, parentEntity, parentEntityId }; + } + + if (parameters?.type === "text" && parameters?.text) { + await this.sendTextMessage(to, parameters.text); + return { to, templateId, parameters, parentEntity, parentEntityId }; + } + + if (!templateId) { + throw new Error("WhatsApp templateId is required for template message"); + } + + const bodyParams = Array.isArray(parameters) + ? parameters + : Array.isArray(parameters?.body) + ? parameters.body + : []; + + await this.sendOtpTemplate(to, templateId, bodyParams.map((x) => String(x))); + return { to, templateId, parameters, parentEntity, parentEntityId }; + } + + private async sendTextMessage( + destination: string, + text: string, + sourceOverride?: string, + appNameOverride?: string, + ): Promise { + const apiKey = + process.env.COMMON_GUPSHUP_WHATSAPP_API_KEY || + process.env.GUPSHUP_API_KEY; + const msgUrl = + process.env.COMMON_GUPSHUP_WHATSAPP_API_URL || + process.env.GUPSHUP_API_URL || + "https://api.gupshup.io/wa/api/v1/msg"; + const source = + sourceOverride || + process.env.COMMON_GUPSHUP_WHATSAPP_SOURCE || + process.env.GUPSHUP_SOURCE_NUMBER; + const appName = + appNameOverride || process.env.COMMON_GUPSHUP_APP_NAME || "solidx"; + + if (!apiKey || !msgUrl || !source) { + throw new Error("Missing Gupshup WhatsApp configuration for text message"); + } + + const rawDestination = destination.replace(/\D/g, ""); + const params = new URLSearchParams(); + params.append("channel", "whatsapp"); + params.append("source", source); + params.append("destination", rawDestination); + params.append("src.name", appName); + params.append("message", JSON.stringify({ type: "text", text })); + + await this.httpService.axiosRef.post(msgUrl, params.toString(), { + headers: { + apikey: apiKey, + "Content-Type": "application/x-www-form-urlencoded", + }, + }); + } + + async sendOtpTemplate( + destination: string, + templateId: string, + bodyParams: string[], + ): Promise { + const apiKey = + process.env.COMMON_GUPSHUP_WHATSAPP_API_KEY || + process.env.GUPSHUP_API_KEY; + const baseUrl = + process.env.COMMON_GUPSHUP_WHATSAPP_API_URL || + process.env.GUPSHUP_API_URL || + "https://api.gupshup.io/wa/api/v1/msg"; + const templateUrl = + process.env.COMMON_GUPSHUP_WHATSAPP_TEMPLATE_API_URL || + baseUrl.replace(/\/msg$/, "/template/msg"); + const source = + process.env.COMMON_GUPSHUP_WHATSAPP_SOURCE || + process.env.GUPSHUP_SOURCE_NUMBER; + const appName = process.env.COMMON_GUPSHUP_APP_NAME || "solidx"; + + if (!apiKey || !templateUrl || !source) { + throw new Error("Missing Gupshup OTP WhatsApp configuration"); + } + + if (!destination || !String(destination).trim()) { + throw new Error("WhatsApp destination is empty"); + } + + try { + const rawDestination = destination.replace(/\D/g, ""); + const params = new URLSearchParams(); + params.append("channel", "whatsapp"); + params.append("source", source); + params.append("destination", rawDestination); + params.append( + "to", + rawDestination.startsWith("+") + ? rawDestination + : `+${rawDestination}`, + ); + params.append("src.name", appName); + params.append( + "template", + JSON.stringify({ + id: templateId, + params: bodyParams, + }), + ); + + this.logger.debug( + `Gupshup OTP outbound (template): endpoint=${templateUrl}, source=${source}, destination=${rawDestination}, to=${rawDestination.startsWith("+") ? rawDestination : `+${rawDestination}`}, templateId=${templateId}, params=${JSON.stringify(bodyParams)}`, + ); + + const response = await this.httpService.axiosRef.post( + templateUrl, + params.toString(), + { + headers: { + apikey: apiKey, + "Content-Type": "application/x-www-form-urlencoded", + }, + }, + ); + + this.logger.debug( + `Gupshup response: ${response.status}, data=${JSON.stringify(response.data)}`, + ); + + this.logger.debug( + `Independent OTP WhatsApp sent to ${rawDestination} template=${templateId} via template endpoint`, + ); + } catch (error) { + const axiosError = error as AxiosError; + this.logger.error( + `Independent OTP WhatsApp failed: destination=${destination}, templateId=${templateId}, status=${axiosError.response?.status ?? "unknown"}, response=${typeof axiosError.response?.data === "object" ? JSON.stringify(axiosError.response?.data) : axiosError.response?.data}, url=${templateUrl}`, + ); + throw error; + } + } +} diff --git a/src/services/whatsapp/MetaCloudWhatsappService.ts b/src/services/whatsapp/MetaCloudWhatsappService.ts new file mode 100644 index 00000000..c709766b --- /dev/null +++ b/src/services/whatsapp/MetaCloudWhatsappService.ts @@ -0,0 +1,253 @@ +import { HttpService } from "@nestjs/axios"; +import { Injectable, Logger } from "@nestjs/common"; +import { AxiosError } from "axios"; +import { WhatsAppProvider } from "src/decorators/whatsapp-provider.decorator"; +import { IWhatsAppTransport } from "src/interfaces"; +import { QueueMessage } from "src/interfaces/mq"; +import { SettingService } from "src/services/setting.service"; +import type { SolidCoreSetting } from "src/services/settings/default-settings-provider.service"; + +type MetaTemplatePayload = { + type?: "template"; + templateName?: string; + templateId?: string; + languageCode?: string; + body?: string[]; + headerText?: string; + imageLink?: string; +}; + +type MetaTextPayload = { + type?: "text"; + text?: string; + previewUrl?: boolean; +}; + +@Injectable() +@WhatsAppProvider() +export class MetaCloudWhatsappService implements IWhatsAppTransport { + readonly logger = new Logger(MetaCloudWhatsappService.name); + + constructor( + private readonly httpService: HttpService, + private readonly settingService: SettingService, + ) {} + + async sendWhatsAppMessage( + to: string, + templateId: string, + parameters: any, + parentEntity?: any, + parentEntityId?: any, + ): Promise { + const message: QueueMessage = { + payload: { + to, + templateId, + ...parameters, + }, + parentEntity, + parentEntityId, + }; + + await this.sendWhatsAppMessageSynchronously(message); + return message; + } + + async sendWhatsAppMessageSynchronously(message: QueueMessage): Promise { + const phoneNumberId = this.settingService.getConfigValue( + "metaWhatsappPhoneNumberId", + ); + const apiVersion = this.settingService.getConfigValue( + "metaWhatsappApiVersion", + ); + const apiBaseUrl = this.settingService.getConfigValue( + "metaWhatsappApiUrl", + ); + const accessToken = this.settingService.getConfigValue( + "metaWhatsappAccessToken", + ); + + if (!phoneNumberId || !apiVersion || !apiBaseUrl || !accessToken) { + throw new Error( + "Missing Meta WhatsApp configuration. Set metaWhatsappApiUrl, metaWhatsappApiVersion, metaWhatsappPhoneNumberId, metaWhatsappAccessToken.", + ); + } + + const requestBody = this.createWhatsappRequest(message); + const url = `${String(apiBaseUrl).replace(/\/$/, "")}/${apiVersion}/${phoneNumberId}/messages`; + + try { + await this.httpService.axiosRef.post(url, requestBody, { + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); + this.logger.debug( + `Sent Meta WhatsApp message to ${message.payload?.to} with type ${requestBody.type}`, + ); + } catch (error) { + const axiosError = error as AxiosError; + const status = axiosError.response?.status; + const data = axiosError.response?.data; + this.logger.error( + `Meta WhatsApp send failed: status=${status ?? "unknown"}, url=${url}, response=${typeof data === "object" ? JSON.stringify(data) : data}`, + ); + throw error; + } + } + + private createWhatsappRequest(message: QueueMessage): any { + const payload = message?.payload ?? {}; + + if (payload?.payload) { + const normalizedFromWrappedPayload = this.normalizeWrappedPayload(payload.payload); + if (normalizedFromWrappedPayload) { + return normalizedFromWrappedPayload; + } + return payload.payload; + } + + const to = this.normalizePhone(payload.to); + const templatePayload: MetaTemplatePayload = payload; + const textPayload: MetaTextPayload = payload; + + if (textPayload.type === "text" || textPayload.text) { + return { + messaging_product: "whatsapp", + recipient_type: "individual", + to, + type: "text", + text: { + body: textPayload.text ?? "", + preview_url: Boolean(textPayload.previewUrl), + }, + }; + } + + const templateName = payload.templateId || templatePayload.templateId || templatePayload.templateName; + if (!templateName) { + throw new Error( + "Meta WhatsApp template name is missing. Provide templateId or parameters.templateName.", + ); + } + + const components: any[] = []; + if (templatePayload.headerText) { + components.push({ + type: "header", + parameters: [ + { + type: "text", + text: templatePayload.headerText, + }, + ], + }); + } else if (templatePayload.imageLink) { + components.push({ + type: "header", + parameters: [ + { + type: "image", + image: { link: templatePayload.imageLink }, + }, + ], + }); + } + + if (Array.isArray(templatePayload.body) && templatePayload.body.length > 0) { + components.push({ + type: "body", + parameters: templatePayload.body.map((entry) => ({ + type: "text", + text: String(entry), + })), + }); + } + + return { + messaging_product: "whatsapp", + recipient_type: "individual", + to, + type: "template", + template: { + name: templateName, + language: { + code: templatePayload.languageCode || "en", + }, + ...(components.length > 0 ? { components } : {}), + }, + }; + } + + private normalizeWrappedPayload(payload: any): any | null { + if (!payload || typeof payload !== "object") { + return null; + } + + if (payload.messaging_product && payload.to && payload.type) { + return payload; + } + + const destination = payload.destination || payload.to; + const message = payload.message || {}; + if (!destination || !message.type) { + return null; + } + + if (message.type === "text" && message.text) { + return { + messaging_product: "whatsapp", + recipient_type: "individual", + to: this.normalizePhone(destination), + type: "text", + text: { + body: String(message.text), + preview_url: false, + }, + }; + } + + if (message.type === "template" && message.template?.id) { + const params = Array.isArray(message.template?.params) + ? message.template.params + : []; + return { + messaging_product: "whatsapp", + recipient_type: "individual", + to: this.normalizePhone(destination), + type: "template", + template: { + name: String(message.template.id), + language: { + code: "en", + }, + ...(params.length > 0 + ? { + components: [ + { + type: "body", + parameters: params.map((entry: any) => ({ + type: "text", + text: String(entry), + })), + }, + ], + } + : {}), + }, + }; + } + + return null; + } + + private normalizePhone(phone: string): string { + const digits = String(phone || "").replace(/\D/g, ""); + if (!digits) { + throw new Error("Destination phone number is required for WhatsApp message."); + } + return digits; + } +} diff --git a/src/solid-core.module.ts b/src/solid-core.module.ts old mode 100755 new mode 100644 index 8ba290ae..a3c94f53 --- a/src/solid-core.module.ts +++ b/src/solid-core.module.ts @@ -1,6 +1,7 @@ -import { Global, MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; -import * as express from 'express'; -import { ConfigModule, ConfigService } from '@nestjs/config'; +import "multer"; +import { Global, MiddlewareConsumer, Module, NestModule } from "@nestjs/common"; +import * as express from "express"; +import { ConfigModule, ConfigService } from "@nestjs/config"; import { APP_FILTER, APP_GUARD, @@ -8,136 +9,149 @@ import { DiscoveryService, MetadataScanner, Reflector, -} from '@nestjs/core'; -import { MulterModule } from '@nestjs/platform-express'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { RemoveFieldsCommand } from './commands/remove-fields.command'; -import { FieldMetadataController } from './controllers/field-metadata.controller'; -import { MediaStorageProviderMetadataController } from './controllers/media-storage-provider-metadata.controller'; -import { ModelMetadataController } from './controllers/model-metadata.controller'; -import { ModuleMetadataController } from './controllers/module-metadata.controller'; -import { TestController } from './controllers/test.controller'; -import { FieldMetadata } from './entities/field-metadata.entity'; -import { ListOfValues } from './entities/list-of-values.entity'; -import { MediaStorageProviderMetadata } from './entities/media-storage-provider-metadata.entity'; -import { Media } from './entities/media.entity'; -import { ModelMetadata } from './entities/model-metadata.entity'; -import { ModuleMetadata } from './entities/module-metadata.entity'; -import { CommandService } from './helpers/command.service'; -import { SchematicService } from './helpers/schematic.service'; -import { ListOfValuesSelectionProvider } from './services/selection-providers/list-of-values-selection-providers.service'; -import { PseudoForeignKeySelectionProvider } from './services/selection-providers/pseudo-foreign-key-selection-provider.service'; -import { ModuleMetadataSeederService } from './seeders/module-metadata-seeder.service'; -import { ModuleTestDataService } from './seeders/module-test-data.service'; -import { CrudHelperService } from './services/crud-helper.service'; -import { FieldMetadataService } from './services/field-metadata.service'; -import { ListOfValuesService } from './services/list-of-values.service'; +} from "@nestjs/core"; +import { MulterModule } from "@nestjs/platform-express"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { RemoveFieldsCommand } from "./commands/remove-fields.command"; +import { FieldMetadataController } from "./controllers/field-metadata.controller"; +import { DashboardController } from "./controllers/dashboard.controller"; +import { MediaStorageProviderMetadataController } from "./controllers/media-storage-provider-metadata.controller"; +import { ModelMetadataController } from "./controllers/model-metadata.controller"; +import { ModuleMetadataExplorerController } from "./controllers/module-metadata-explorer.controller"; +import { ModuleMetadataController } from "./controllers/module-metadata.controller"; +import { ModulePackageController } from "./controllers/module-package.controller"; +import { TestController } from "./controllers/test.controller"; +import { FieldMetadata } from "./entities/field-metadata.entity"; +import { ListOfValues } from "./entities/list-of-values.entity"; +import { MediaStorageProviderMetadata } from "./entities/media-storage-provider-metadata.entity"; +import { Media } from "./entities/media.entity"; +import { ModelMetadata } from "./entities/model-metadata.entity"; +import { ModuleMetadata } from "./entities/module-metadata.entity"; +import { CommandService } from "./helpers/command.service"; +import { SchematicService } from "./helpers/schematic.service"; +import { ListOfValuesSelectionProvider } from "./services/selection-providers/list-of-values-selection-providers.service"; +import { MqDashboardMessageBrokerVariableOptionsProvider } from "./services/selection-providers/mq-dashboard-message-broker-variable-options-provider.service"; +import { MqDashboardQueueNameVariableOptionsProvider } from "./services/selection-providers/mq-dashboard-queue-name-variable-options-provider.service"; +import { PseudoForeignKeySelectionProvider } from "./services/selection-providers/pseudo-foreign-key-selection-provider.service"; +import { ModuleMetadataSeederService } from "./seeders/module-metadata-seeder.service"; +import { ModuleTestDataService } from "./seeders/module-test-data.service"; +import { CrudHelperService } from "./services/crud-helper.service"; +import { FieldMetadataService } from "./services/field-metadata.service"; +import { DashboardRuntimeService } from "./services/dashboard-runtime.service"; +import { ListOfValuesService } from "./services/list-of-values.service"; // import { MediaStorageProviderMetadataSeederService } from './services/media-storage-provider-metadata-seeder.service'; -import { MediaStorageProviderMetadataService } from './services/media-storage-provider-metadata.service'; -import { MediaService } from './services/media.service'; -import { ModelMetadataService } from './services/model-metadata.service'; -import { ModuleMetadataService } from './services/module-metadata.service'; -import { SolidIntrospectService } from './services/solid-introspect.service'; +import { MediaStorageProviderMetadataService } from "./services/media-storage-provider-metadata.service"; +import { MediaService } from "./services/media.service"; +import { ModelMetadataService } from "./services/model-metadata.service"; +import { ModuleMetadataExplorerService } from "./services/module-metadata-explorer.service"; +import { ModuleMetadataService } from "./services/module-metadata.service"; +import { ModulePackageService } from "./services/module-package.service"; +import { SolidIntrospectService } from "./services/solid-introspect.service"; // import { ListOfComputedFieldProvider } from './providers/list-of-computed-field-provider.service'; -import { ServeStaticModule } from '@nestjs/serve-static'; -import { join } from 'path'; -import { RefreshModelCommand } from './commands/refresh-model.command'; -import { MediaController } from './controllers/media.controller'; +import { ServeStaticModule } from "@nestjs/serve-static"; +import { join } from "path"; +import { RefreshModelCommand } from "./commands/refresh-model.command"; +import { MediaController } from "./controllers/media.controller"; -import { RefreshModuleCommand } from './commands/refresh-module.command'; -import { ModelMetadataSubscriber } from './subscribers/model-metadata.subscriber'; +import { RefreshModuleCommand } from "./commands/refresh-module.command"; +import { ModelMetadataSubscriber } from "./subscribers/model-metadata.subscriber"; -import { ViewMetadataController } from './controllers/view-metadata.controller'; -import { ViewMetadata } from './entities/view-metadata.entity'; -import { ViewMetadataService } from './services/view-metadata.service'; +import { ViewMetadataController } from "./controllers/view-metadata.controller"; +import { ViewMetadata } from "./entities/view-metadata.entity"; +import { ViewMetadataService } from "./services/view-metadata.service"; -import { ActionMetadataController } from './controllers/action-metadata.controller'; -import { ActionMetadata } from './entities/action-metadata.entity'; -import { ActionMetadataService } from './services/action-metadata.service'; +import { ActionMetadataController } from "./controllers/action-metadata.controller"; +import { ActionMetadata } from "./entities/action-metadata.entity"; +import { ActionMetadataService } from "./services/action-metadata.service"; -import { FacebookAuthenticationController } from './controllers/facebook-authentication.controller'; -import { MicrosoftAuthenticationController } from './controllers/microsoft-authentication.controller'; -import { FacebookOAuthStrategy } from './passport-strategies/facebook-oauth.strategy'; -import { MicrosoftOAuthStrategy } from './passport-strategies/microsoft-oauth.strategy'; +import { FacebookAuthenticationController } from "./controllers/facebook-authentication.controller"; +import { MicrosoftAuthenticationController } from "./controllers/microsoft-authentication.controller"; +import { MicrosoftActiveDirectoryAuthenticationController } from "./controllers/microsoft-active-directory-authentication.controller"; +import { FacebookOAuthStrategy } from "./passport-strategies/facebook-oauth.strategy"; +import { MicrosoftOAuthStrategy } from "./passport-strategies/microsoft-oauth.strategy"; +import { MicrosoftActiveDirectoryOAuthStrategy } from "./passport-strategies/microsoft-active-directory-oauth.strategy"; -import { HttpModule } from '@nestjs/axios'; -import { JwtModule } from '@nestjs/jwt'; -import { SeedCommand } from './commands/seed.command'; -import { TestDataCommand } from './commands/test-data.command'; -import { TestRunCommand } from './commands/run-tests.command'; -import { TestCommand } from './commands/test.command'; -import { AuthenticationController } from './controllers/authentication.controller'; -import { EmailTemplateController } from './controllers/email-template.controller'; -import { GoogleAuthenticationController } from './controllers/google-authentication.controller'; -import { MenuItemMetadataController } from './controllers/menu-item-metadata.controller'; -import { MqMessageQueueController } from './controllers/mq-message-queue.controller'; -import { MqMessageController } from './controllers/mq-message.controller'; -import { OTPAuthenticationController } from './controllers/otp-authentication.controller'; -import { ServiceController } from './controllers/service.controller'; -import { SmsTemplateController } from './controllers/sms-template.controller'; -import { TestQueueController } from './controllers/test-queue.controller'; -import { EmailAttachment } from './entities/email-attachment.entity'; -import { EmailTemplate } from './entities/email-template.entity'; -import { MenuItemMetadata } from './entities/menu-item-metadata.entity'; -import { MqMessageQueue } from './entities/mq-message-queue.entity'; -import { MqMessage } from './entities/mq-message.entity'; -import { SmsTemplate } from './entities/sms-template.entity'; -import { AccessTokenGuard } from './guards/access-token.guard'; -import { ApiKeyGuard } from './guards/api-key.guard'; -import { AuthenticationGuard } from './guards/authentication.guard'; -import { PermissionsGuard } from './guards/permissions.guard'; -import { SolidRegistry } from './helpers/solid-registry'; -import { LoggingInterceptor } from './interceptors/logging.interceptor'; -import { ApiEmailQueuePublisher } from './jobs/rabbitmq/api-email-publisher.service'; -import { ApiEmailQueueSubscriber } from './jobs/rabbitmq/api-email-subscriber.service'; -import { TestQueuePublisherDatabase } from './jobs/database/test-queue-publisher-database.service'; -import { TestQueueSubscriberDatabase } from './jobs/database/test-queue-subscriber-database.service'; -import { TestQueuePublisherRedis } from './jobs/redis/test-queue-publisher-redis.service'; -import { TestQueueSubscriberRedis } from './jobs/redis/test-queue-subscriber-redis.service'; -import { Msg91WhatsappQueuePublisher } from './jobs/rabbitmq/msg91-whatsapp-publisher.service'; -import { Msg91WhatsappQueueSubscriber } from './jobs/rabbitmq/msg91-whatsapp-subscriber.service'; -import { Msg91OTPQueuePublisher } from './jobs/rabbitmq/msg91-otp-publisher.service'; -import { Msg91OTPQueueSubscriber } from './jobs/rabbitmq/msg91-otp-subscriber.service'; -import { Msg91SmsQueuePublisher } from './jobs/rabbitmq/msg91-sms-publisher.service'; -import { Msg91SmsQueueSubscriber } from './jobs/rabbitmq/msg91-sms-subscriber.service'; -import { SmtpEmailQueuePublisherRabbitmq } from './jobs/rabbitmq/smtp-email-publisher.service'; -import { SmtpEmailQueueSubscriberRabbitmq } from './jobs/rabbitmq/smtp-email-subscriber.service'; -import { TestQueuePublisher } from './jobs/rabbitmq/test-queue-publisher.service'; -import { TestQueueSubscriber } from './jobs/rabbitmq/test-queue-subscriber.service'; -import { ChatterQueuePublisherRabbitmq } from './jobs/rabbitmq/chatter-queue-publisher.service'; -import { ChatterQueueSubscriberRabbitmq } from './jobs/rabbitmq/chatter-queue-subscriber.service'; -import { ChatterQueuePublisherDatabase } from './jobs/database/chatter-queue-publisher-database.service'; -import { ChatterQueueSubscriberDatabase } from './jobs/database/chatter-queue-subscriber-database.service'; -import { ApiEmailQueuePublisherRedis } from './jobs/redis/api-email-publisher-redis.service'; -import { ApiEmailQueueSubscriberRedis } from './jobs/redis/api-email-subscriber-redis.service'; -import { ChatterQueuePublisherRedis } from './jobs/redis/chatter-queue-publisher-redis.service'; -import { ChatterQueueSubscriberRedis } from './jobs/redis/chatter-queue-subscriber-redis.service'; -import { ComputedFieldEvaluationPublisherRedis } from './jobs/redis/computed-field-evaluation-publisher-redis.service'; -import { ComputedFieldEvaluationSubscriberRedis } from './jobs/redis/computed-field-evaluation-subscriber-redis.service'; -import { GenerateCodePublisherRedis } from './jobs/redis/generate-code-publisher-redis.service'; -import { GenerateCodeSubscriberRedis } from './jobs/redis/generate-code-subscriber-redis.service'; -import { Msg91OTPQueuePublisherRedis } from './jobs/redis/msg91-otp-publisher-redis.service'; -import { Msg91OTPQueueSubscriberRedis } from './jobs/redis/msg91-otp-subscriber-redis.service'; -import { Msg91SmsQueuePublisherRedis } from './jobs/redis/msg91-sms-publisher-redis.service'; -import { Msg91SmsQueueSubscriberRedis } from './jobs/redis/msg91-sms-subscriber-redis.service'; -import { Msg91WhatsappQueuePublisherRedis } from './jobs/redis/msg91-whatsapp-publisher-redis.service'; -import { Msg91WhatsappQueueSubscriberRedis } from './jobs/redis/msg91-whatsapp-subscriber-redis.service'; -import { SmtpEmailQueuePublisherRedis } from './jobs/redis/smtp-email-publisher-redis.service'; -import { SmtpEmailQueueSubscriberRedis } from './jobs/redis/smtp-email-subscriber-redis.service'; -import { Three60WhatsappQueuePublisherRedis } from './jobs/redis/three60-whatsapp-publisher-redis.service'; -import { Three60WhatsappQueueSubscriberRedis } from './jobs/redis/three60-whatsapp-subscriber-redis.service'; -import { TriggerMcpClientPublisherRedis } from './jobs/redis/trigger-mcp-client-publisher-redis.service'; -import { TriggerMcpClientSubscriberRedis } from './jobs/redis/trigger-mcp-client-subscriber-redis.service'; -import { TwilioSmsQueuePublisherRedis } from './jobs/redis/twilio-sms-publisher-redis.service'; -import { TwilioSmsQueueSubscriberRedis } from './jobs/redis/twilio-sms-subscriber-redis.service'; -import { UserRegistrationListener } from './listeners/user-registration.listener'; -import { GoogleOauthStrategy } from './passport-strategies/google-oauth.strategy'; -import { ApiKeyService } from './services/api-key.service'; -import { AuthenticationService } from './services/authentication.service'; -import { BcryptService } from './services/bcrypt.service'; -import { UuidExternalIdEntityComputedFieldProvider } from './services/computed-fields/entity/uuid-externalid-entity-computed-field-provider.service'; -import { UuidExternalIdComputedFieldProvider } from './services/computed-fields/uuid-external-id-computed-field-provider.service'; -import { EmailTemplateService } from './services/email-template.service'; +import { GupshupOtpWhatsappService } from "./services/whatsapp/GupshupOtpWhatsappService"; +import { MetaCloudWhatsappService } from "./services/whatsapp/MetaCloudWhatsappService"; +import { GupshupWebhookController } from "./controllers/gupshup-webhook.controller"; +import { MetaCloudWhatsappWebhookController } from "./controllers/meta-cloud-whatsapp-webhook.controller"; + +import { HttpModule } from "@nestjs/axios"; +import { JwtModule } from "@nestjs/jwt"; +import { SeedCommand } from "./commands/seed.command"; +import { TestDataCommand } from "./commands/test-data.command"; +import { TestRunCommand } from "./commands/run-tests.command"; +import { TestCommand } from "./commands/test.command"; +import { AuthenticationController } from "./controllers/authentication.controller"; +import { EmailTemplateController } from "./controllers/email-template.controller"; +import { GoogleAuthenticationController } from "./controllers/google-authentication.controller"; +import { MenuItemMetadataController } from "./controllers/menu-item-metadata.controller"; +import { MqMessageQueueController } from "./controllers/mq-message-queue.controller"; +import { MqMessageController } from "./controllers/mq-message.controller"; +import { OTPAuthenticationController } from "./controllers/otp-authentication.controller"; +import { ServiceController } from "./controllers/service.controller"; +import { SmsTemplateController } from "./controllers/sms-template.controller"; +import { TestQueueController } from "./controllers/test-queue.controller"; +import { EmailAttachment } from "./entities/email-attachment.entity"; +import { EmailTemplate } from "./entities/email-template.entity"; +import { MenuItemMetadata } from "./entities/menu-item-metadata.entity"; +import { MqMessageQueue } from "./entities/mq-message-queue.entity"; +import { MqMessage } from "./entities/mq-message.entity"; +import { SmsTemplate } from "./entities/sms-template.entity"; +import { AccessTokenGuard } from "./guards/access-token.guard"; +import { ApiKeyGuard } from "./guards/api-key.guard"; +import { AuthenticationGuard } from "./guards/authentication.guard"; +import { PermissionsGuard } from "./guards/permissions.guard"; +import { SolidRegistry } from "./helpers/solid-registry"; +import { LoggingInterceptor } from "./interceptors/logging.interceptor"; +import { ApiEmailQueuePublisher } from "./jobs/rabbitmq/api-email-publisher.service"; +import { ApiEmailQueueSubscriber } from "./jobs/rabbitmq/api-email-subscriber.service"; +import { TestQueuePublisherDatabase } from "./jobs/database/test-queue-publisher-database.service"; +import { TestQueueSubscriberDatabase } from "./jobs/database/test-queue-subscriber-database.service"; +import { TestQueuePublisherRedis } from "./jobs/redis/test-queue-publisher-redis.service"; +import { TestQueueSubscriberRedis } from "./jobs/redis/test-queue-subscriber-redis.service"; +import { Msg91WhatsappQueuePublisher } from "./jobs/rabbitmq/msg91-whatsapp-publisher.service"; +import { Msg91WhatsappQueueSubscriber } from "./jobs/rabbitmq/msg91-whatsapp-subscriber.service"; +import { Msg91OTPQueuePublisher } from "./jobs/rabbitmq/msg91-otp-publisher.service"; +import { Msg91OTPQueueSubscriber } from "./jobs/rabbitmq/msg91-otp-subscriber.service"; +import { Msg91SmsQueuePublisher } from "./jobs/rabbitmq/msg91-sms-publisher.service"; +import { Msg91SmsQueueSubscriber } from "./jobs/rabbitmq/msg91-sms-subscriber.service"; +import { SmtpEmailQueuePublisherRabbitmq } from "./jobs/rabbitmq/smtp-email-publisher.service"; +import { SmtpEmailQueueSubscriberRabbitmq } from "./jobs/rabbitmq/smtp-email-subscriber.service"; +import { TestQueuePublisher } from "./jobs/rabbitmq/test-queue-publisher.service"; +import { TestQueueSubscriber } from "./jobs/rabbitmq/test-queue-subscriber.service"; +import { ChatterQueuePublisherRabbitmq } from "./jobs/rabbitmq/chatter-queue-publisher.service"; +import { ChatterQueueSubscriberRabbitmq } from "./jobs/rabbitmq/chatter-queue-subscriber.service"; +import { ChatterQueuePublisherDatabase } from "./jobs/database/chatter-queue-publisher-database.service"; +import { ChatterQueueSubscriberDatabase } from "./jobs/database/chatter-queue-subscriber-database.service"; +import { ApiEmailQueuePublisherRedis } from "./jobs/redis/api-email-publisher-redis.service"; +import { ApiEmailQueueSubscriberRedis } from "./jobs/redis/api-email-subscriber-redis.service"; +import { ChatterQueuePublisherRedis } from "./jobs/redis/chatter-queue-publisher-redis.service"; +import { ChatterQueueSubscriberRedis } from "./jobs/redis/chatter-queue-subscriber-redis.service"; +import { ComputedFieldEvaluationPublisherRedis } from "./jobs/redis/computed-field-evaluation-publisher-redis.service"; +import { ComputedFieldEvaluationSubscriberRedis } from "./jobs/redis/computed-field-evaluation-subscriber-redis.service"; +import { GenerateCodePublisherRedis } from "./jobs/redis/generate-code-publisher-redis.service"; +import { GenerateCodeSubscriberRedis } from "./jobs/redis/generate-code-subscriber-redis.service"; +import { Msg91OTPQueuePublisherRedis } from "./jobs/redis/msg91-otp-publisher-redis.service"; +import { Msg91OTPQueueSubscriberRedis } from "./jobs/redis/msg91-otp-subscriber-redis.service"; +import { Msg91SmsQueuePublisherRedis } from "./jobs/redis/msg91-sms-publisher-redis.service"; +import { Msg91SmsQueueSubscriberRedis } from "./jobs/redis/msg91-sms-subscriber-redis.service"; +import { Msg91WhatsappQueuePublisherRedis } from "./jobs/redis/msg91-whatsapp-publisher-redis.service"; +import { Msg91WhatsappQueueSubscriberRedis } from "./jobs/redis/msg91-whatsapp-subscriber-redis.service"; +import { SmtpEmailQueuePublisherRedis } from "./jobs/redis/smtp-email-publisher-redis.service"; +import { SmtpEmailQueueSubscriberRedis } from "./jobs/redis/smtp-email-subscriber-redis.service"; +import { Three60WhatsappQueuePublisherRedis } from "./jobs/redis/three60-whatsapp-publisher-redis.service"; +import { Three60WhatsappQueueSubscriberRedis } from "./jobs/redis/three60-whatsapp-subscriber-redis.service"; +import { TwilioSmsQueuePublisherRedis } from "./jobs/redis/twilio-sms-publisher-redis.service"; +import { TwilioSmsQueueSubscriberRedis } from "./jobs/redis/twilio-sms-subscriber-redis.service"; +import { UserRegistrationListener } from "./listeners/user-registration.listener"; +import { GoogleOauthStrategy } from "./passport-strategies/google-oauth.strategy"; +import { ApiKeyService } from "./services/api-key.service"; +import { AuthenticationService } from "./services/authentication.service"; +import { BcryptService } from "./services/bcrypt.service"; +import { UuidExternalIdEntityComputedFieldProvider } from "./services/computed-fields/entity/uuid-externalid-entity-computed-field-provider.service"; +import { UuidExternalIdComputedFieldProvider } from "./services/computed-fields/uuid-external-id-computed-field-provider.service"; +import { EmailTemplateService } from "./services/email-template.service"; import { DiskFileService, S3FileService, @@ -145,40 +159,34 @@ import { DiskStoragePathBuilder, S3StoragePathBuilder, StoragePathBuilderFactory, -} from './services/file'; -import { HashingService } from './services/hashing.service'; -import { ElasticEmailService } from './services/mail/elastic-email.service'; -import { SMTPEMailService } from './services/mail/smtp-email.service'; -import { MenuItemMetadataService } from './services/menu-item-metadata.service'; -import { MqMessageQueueService } from './services/mq-message-queue.service'; -import { MqMessageService } from './services/mq-message.service'; -import { PdfService } from './services/pdf.service'; -import { RefreshTokenIdsStorageService } from './services/refresh-token-ids-storage.service'; -import { SsoCodeStorageService } from './services/sso-code-storage.service'; -import { ListOfModelsSelectionProvider } from './services/selection-providers/list-of-models-selection-provider.service'; -import { TinyUrlService } from './services/short-url/tiny-url.service'; -import { SmsTemplateService } from './services/sms-template.service'; -import { Msg91OTPService } from './services/sms/Msg91OTPService'; -import { Msg91SMSService } from './services/sms/Msg91SMSService'; +} from "./services/file"; +import { HashingService } from "./services/hashing.service"; +import { ElasticEmailService } from "./services/mail/elastic-email.service"; +import { SMTPEMailService } from "./services/mail/smtp-email.service"; +import { MenuItemMetadataService } from "./services/menu-item-metadata.service"; +import { MqMessageQueueService } from "./services/mq-message-queue.service"; +import { MqMessageService } from "./services/mq-message.service"; +import { PdfService } from "./services/pdf.service"; +import { RefreshTokenIdsStorageService } from "./services/refresh-token-ids-storage.service"; +import { SsoCodeStorageService } from "./services/sso-code-storage.service"; +import { ListOfModelsSelectionProvider } from "./services/selection-providers/list-of-models-selection-provider.service"; +import { TinyUrlService } from "./services/short-url/tiny-url.service"; +import { SmsTemplateService } from "./services/sms-template.service"; +import { Msg91OTPService } from "./services/sms/Msg91OTPService"; +import { Msg91SMSService } from "./services/sms/Msg91SMSService"; // import { UserService } from './services/user.service'; -import { Msg91WhatsappService } from './services/whatsapp/Msg91WhatsappService'; -import { SoftDeleteAwareEventSubscriber } from './subscribers/soft-delete-aware-event.subscriber'; +import { Msg91WhatsappService } from "./services/whatsapp/Msg91WhatsappService"; +import { SoftDeleteAwareEventSubscriber } from "./subscribers/soft-delete-aware-event.subscriber"; -import { PermissionMetadataController } from './controllers/permission-metadata.controller'; -import { PermissionMetadata } from './entities/permission-metadata.entity'; -import { PermissionMetadataService } from './services/permission-metadata.service'; +import { PermissionMetadataController } from "./controllers/permission-metadata.controller"; +import { PermissionMetadata } from "./entities/permission-metadata.entity"; +import { PermissionMetadataService } from "./services/permission-metadata.service"; -import { ScheduleModule } from '@nestjs/schedule'; -import { ClsModule } from 'nestjs-cls'; -import { AiInteractionController } from './controllers/ai-interaction.controller'; -import { ChatterMessageDetailsController } from './controllers/chatter-message-details.controller'; -import { ChatterMessageController } from './controllers/chatter-message.controller'; -import { DashboardQuestionSqlDatasetConfigController } from './controllers/dashboard-question-sql-dataset-config.controller'; -import { DashboardQuestionController } from './controllers/dashboard-question.controller'; -import { DashboardVariableController } from './controllers/dashboard-variable.controller'; -import { DashboardLayoutController } from './controllers/dashboard-layout.controller'; +import { ScheduleModule } from "@nestjs/schedule"; +import { ClsModule } from "nestjs-cls"; +import { ChatterMessageDetailsController } from "./controllers/chatter-message-details.controller"; +import { ChatterMessageController } from "./controllers/chatter-message.controller"; -import { DashboardController } from './controllers/dashboard.controller'; import { ExportTemplateController } from './controllers/export-template.controller'; import { ExportTransactionController } from './controllers/export-transaction.controller'; import { ImportTransactionErrorLogController } from './controllers/import-transaction-error-log.controller'; @@ -190,6 +198,7 @@ import { SavedFiltersController } from './controllers/saved-filters.controller'; import { ScheduledJobController } from './controllers/scheduled-job.controller'; import { AgentSessionController } from './controllers/agent-session.controller'; import { AgentEventController } from './controllers/agent-event.controller'; +import { McpAuditLogController } from './controllers/mcp-audit-log.controller'; import { SecurityRuleController } from './controllers/security-rule.controller'; import { SettingController } from './controllers/setting.controller'; import { InfoController } from './controllers/info.controller'; @@ -197,15 +206,9 @@ import { InfoService } from './services/info.service'; import { UserActivityHistoryController } from './controllers/user-activity-history.controller'; import { UserViewMetadataController } from './controllers/user-view-metadata.controller'; import { UserController } from './controllers/user.controller'; -import { AiInteraction } from './entities/ai-interaction.entity'; import { ChatterMessageDetails } from './entities/chatter-message-details.entity'; import { ChatterMessage } from './entities/chatter-message.entity'; -import { DashboardQuestionSqlDatasetConfig } from './entities/dashboard-question-sql-dataset-config.entity'; -import { DashboardQuestion } from './entities/dashboard-question.entity'; -import { DashboardVariable } from './entities/dashboard-variable.entity'; -import { DashboardLayout } from './entities/dashboard-layout.entity'; -import { Dashboard } from './entities/dashboard.entity'; import { ExportTemplate } from './entities/export-template.entity'; import { ExportTransaction } from './entities/export-transaction.entity'; import { ImportTransactionErrorLog } from './entities/import-transaction-error-log.entity'; @@ -216,6 +219,7 @@ import { SavedFilters } from './entities/saved-filters.entity'; import { ScheduledJob } from './entities/scheduled-job.entity'; import { AgentSession } from './entities/agent-session.entity'; import { AgentEvent } from './entities/agent-event.entity'; +import { McpAuditLog } from './entities/mcp-audit-log.entity'; import { SecurityRule } from './entities/security-rule.entity'; import { Setting } from './entities/setting.entity'; import { UserActivityHistory } from './entities/user-activity-history.entity'; @@ -238,41 +242,29 @@ import { Msg91SmsQueueSubscriberDatabase } from './jobs/database/msg91-sms-subsc import { SmtpEmailQueuePublisherDatabase } from './jobs/database/smtp-email-publisher-database.service'; import { SmtpEmailQueueSubscriberDatabase } from './jobs/database/smtp-email-subscriber-database.service'; -import { TwilioSmsQueuePublisherDatabase } from './jobs/database/twilio-sms-publisher-database.service'; -import { TwilioSmsQueueSubscriberDatabase } from './jobs/database/twilio-sms-subscriber-database.service'; +import { TwilioSmsQueuePublisherDatabase } from "./jobs/database/twilio-sms-publisher-database.service"; +import { TwilioSmsQueueSubscriberDatabase } from "./jobs/database/twilio-sms-subscriber-database.service"; // import { ThrottlerModule } from '@nestjs/throttler'; -import { IngestCommand } from './commands/ingest.command'; -import { MailFactory } from './factories/mail.factory'; -import { ErrorMapperService } from './helpers/error-mapper.service'; -import { SolidCoreErrorCodesProvider } from './helpers/solid-core-error-codes-provider.service'; -import { ComputedFieldEvaluationPublisherRabbitmq } from './jobs/rabbitmq/computed-field-evaluation-publisher.service'; -import { ComputedFieldEvaluationSubscriberRabbitmq } from './jobs/rabbitmq/computed-field-evaluation-subscriber.service'; -import { Msg91WhatsappQueuePublisherDatabase } from './jobs/database/msg91-whatsapp-publisher-database.service'; -import { Msg91WhatsappQueueSubscriberDatabase } from './jobs/database/msg91-whatsapp-subscriber-database.service'; -import { Three60WhatsappQueuePublisherDatabase } from './jobs/database/three60-whatsapp-publisher-database.service'; -import { Three60WhatsappQueueSubscriberDatabase } from './jobs/database/three60-whatsapp-subscriber-database.service'; -import { TriggerMcpClientPublisherDatabase } from './jobs/database/trigger-mcp-client-publisher-database.service'; -import { TriggerMcpClientSubscriberDatabase } from './jobs/database/trigger-mcp-client-subscriber-database.service'; -import { GenerateCodePublisherRabbitmq } from './jobs/rabbitmq/generate-code-publisher.service'; -import { GenerateCodeSubscriberRabbitmq } from './jobs/rabbitmq/generate-code-subscriber.service'; -import { Three60WhatsappQueuePublisher } from './jobs/rabbitmq/three60-whatsapp-publisher.service'; -import { Three60WhatsappQueueSubscriber } from './jobs/rabbitmq/three60-whatsapp-subscriber.service'; -import { TriggerMcpClientPublisherRabbitmq } from './jobs/rabbitmq/trigger-mcp-client-publisher.service'; -import { TriggerMcpClientSubscriberRabbitmq } from './jobs/rabbitmq/trigger-mcp-client-subscriber.service'; -import { TwilioSmsQueuePublisherRabbitmq } from './jobs/rabbitmq/twilio-sms-publisher.service'; -import { TwilioSmsQueueSubscriberRabbitmq } from './jobs/rabbitmq/twilio-sms-subscriber.service'; -import { DashboardMapper } from './mappers/dashboard-mapper'; -import { ListOfValuesMapper } from './mappers/list-of-values-mapper'; -import { ActionMetadataRepository } from './repository/action-metadata.repository'; -import { AiInteractionRepository } from './repository/ai-interaction.repository'; -import { ChatterMessageDetailsRepository } from './repository/chatter-message-details.repository'; -import { ChatterMessageRepository } from './repository/chatter-message.repository'; -import { DashboardQuestionSqlDatasetConfigRepository } from './repository/dashboard-question-sql-dataset-config.repository'; -import { DashboardQuestionRepository } from './repository/dashboard-question.repository'; -import { DashboardVariableRepository } from './repository/dashboard-variable.repository'; -import { DashboardRepository } from './repository/dashboard.repository'; -import { DashboardLayoutRepository } from './repository/dashboard-layout.repository'; +import { MailFactory } from "./factories/mail.factory"; +import { ErrorMapperService } from "./helpers/error-mapper.service"; +import { SolidCoreErrorCodesProvider } from "./helpers/solid-core-error-codes-provider.service"; +import { ComputedFieldEvaluationPublisherRabbitmq } from "./jobs/rabbitmq/computed-field-evaluation-publisher.service"; +import { ComputedFieldEvaluationSubscriberRabbitmq } from "./jobs/rabbitmq/computed-field-evaluation-subscriber.service"; +import { Msg91WhatsappQueuePublisherDatabase } from "./jobs/database/msg91-whatsapp-publisher-database.service"; +import { Msg91WhatsappQueueSubscriberDatabase } from "./jobs/database/msg91-whatsapp-subscriber-database.service"; +import { Three60WhatsappQueuePublisherDatabase } from "./jobs/database/three60-whatsapp-publisher-database.service"; +import { Three60WhatsappQueueSubscriberDatabase } from "./jobs/database/three60-whatsapp-subscriber-database.service"; +import { GenerateCodePublisherRabbitmq } from "./jobs/rabbitmq/generate-code-publisher.service"; +import { GenerateCodeSubscriberRabbitmq } from "./jobs/rabbitmq/generate-code-subscriber.service"; +import { Three60WhatsappQueuePublisher } from "./jobs/rabbitmq/three60-whatsapp-publisher.service"; +import { Three60WhatsappQueueSubscriber } from "./jobs/rabbitmq/three60-whatsapp-subscriber.service"; +import { TwilioSmsQueuePublisherRabbitmq } from "./jobs/rabbitmq/twilio-sms-publisher.service"; +import { TwilioSmsQueueSubscriberRabbitmq } from "./jobs/rabbitmq/twilio-sms-subscriber.service"; +import { ListOfValuesMapper } from "./mappers/list-of-values-mapper"; +import { ActionMetadataRepository } from "./repository/action-metadata.repository"; +import { ChatterMessageDetailsRepository } from "./repository/chatter-message-details.repository"; +import { ChatterMessageRepository } from "./repository/chatter-message.repository"; import { EmailTemplateRepository } from './repository/email-template.repository'; import { ExportTemplateRepository } from './repository/export-template.repository'; @@ -294,6 +286,7 @@ import { SavedFiltersRepository } from './repository/saved-filters.repository'; import { ScheduledJobRepository } from './repository/scheduled-job.repository'; import { AgentSessionRepository } from './repository/agent-session.repository'; import { AgentEventRepository } from './repository/agent-event.repository'; +import { McpAuditLogRepository } from './repository/mcp-audit-log.repository'; import { SecurityRuleRepository } from './repository/security-rule.repository'; import { SettingRepository } from './repository/setting.repository'; import { SmsTemplateRepository } from './repository/sms-template.repository'; @@ -304,38 +297,37 @@ import { UserRepository } from './repository/user.repository'; import { ViewMetadataRepository } from './repository/view-metadata.repository'; import { PermissionMetadataSeederService } from './seeders/permission-metadata-seeder.service'; import { SystemFieldsSeederService } from './seeders/system-fields-seeder.service'; -import { AiInteractionService } from './services/ai-interaction.service'; import { ChatterMessageDetailsService } from './services/chatter-message-details.service'; import { ChatterMessageService } from './services/chatter-message.service'; import { ConcatComputedFieldProvider } from './services/computed-fields/concat-computed-field-provider.service'; import { AlphaNumExternalIdComputationProvider } from './services/computed-fields/entity/alpha-num-external-id-computed-field-provider'; import { ConcatEntityComputedFieldProvider } from './services/computed-fields/entity/concat-entity-computed-field-provider.service'; +import { MqDashboardFailedMessagesKpiProvider } from './services/dashboard-providers/mq-dashboard-failed-messages-kpi-provider.service'; +import { MqDashboardInflightMessagesKpiProvider } from './services/dashboard-providers/mq-dashboard-inflight-messages-kpi-provider.service'; +import { MqDashboardLatencyTrendProvider } from './services/dashboard-providers/mq-dashboard-latency-trend-provider.service'; +import { MqDashboardMessagesOverTimeProvider } from './services/dashboard-providers/mq-dashboard-messages-over-time-provider.service'; +import { MqDashboardQueueWiseAvgElapsedProvider } from './services/dashboard-providers/mq-dashboard-queue-wise-avg-elapsed-provider.service'; +import { MqDashboardQueueWiseFailuresProvider } from './services/dashboard-providers/mq-dashboard-queue-wise-failures-provider.service'; +import { MqDashboardQueueSlaHeatmapProvider } from './services/dashboard-providers/mq-dashboard-queue-sla-heatmap-provider.service'; +import { MqDashboardRecentFailuresProvider } from './services/dashboard-providers/mq-dashboard-recent-failures-provider.service'; +import { MqDashboardStageDistributionProvider } from './services/dashboard-providers/mq-dashboard-stage-distribution-provider.service'; +import { MqDashboardSucceededMessagesKpiProvider } from './services/dashboard-providers/mq-dashboard-succeeded-messages-kpi-provider.service'; +import { MqDashboardSuccessRateKpiProvider } from './services/dashboard-providers/mq-dashboard-success-rate-kpi-provider.service'; +import { MqDashboardTotalMessagesKpiProvider } from './services/dashboard-providers/mq-dashboard-total-messages-kpi-provider.service'; +import { MqDashboardAvgElapsedKpiProvider } from './services/dashboard-providers/mq-dashboard-avg-elapsed-kpi-provider.service'; import { NoopsEntityComputedFieldProviderService } from './services/computed-fields/entity/noops-entity-computed-field-provider.service'; import { CRUDService } from './services/crud.service'; import { CsvService } from './services/csv.service'; -import { DashboardQuestionSqlDatasetConfigService } from './services/dashboard-question-sql-dataset-config.service'; -import { DashboardQuestionService } from './services/dashboard-question.service'; -import { DashboardVariableSQLDynamicProvider } from './services/dashboard-selection-providers/dashboard-variable-sql-dynamic-provider.service'; -import { DasbhoardVariableTestDynamicProvider } from './services/dashboard-selection-providers/dashboard-variable-test-dynamic-provider.service'; -import { DashboardVariableService } from './services/dashboard-variable.service'; -import { DashboardService } from './services/dashboard.service'; -import { DashboardLayoutService } from './services/dashboard-layout.service'; import { ExcelService } from './services/excel.service'; import { ExportTemplateService } from './services/export-template.service'; import { ExportTransactionService } from './services/export-transaction.service'; -import { IngestMetadataService } from './services/genai/ingest-metadata.service'; -import { McpHandlerFactory } from './services/genai/mcp-handlers/mcp-handler-factory.service'; -import { R2RHelperService } from './services/genai/r2r-helper.service'; import { ImportTransactionErrorLogService } from './services/import-transaction-error-log.service'; import { ImportTransactionService } from './services/import-transaction.service'; import { LocaleService } from './services/locale.service'; import { FileS3StorageProvider } from './services/mediaStorageProviders/file-s3-storage-provider'; import { FileStorageProvider } from './services/mediaStorageProviders/file-storage-provider'; import { PollerService } from './services/poller.service'; -import { ChartJsSqlDataProvider } from './services/question-data-providers/chartjs-sql-data-provider.service'; -import { PrimeReactDatatableSqlDataProvider } from './services/question-data-providers/prime-react-datatable-sql-data-provider.service'; -import { PrimeReactMeterGroupSqlDataProvider } from './services/question-data-providers/prime-react-meter-group-sql-data-provider.service'; import { PublisherFactory } from './services/queues/publisher-factory.service'; import { RequestContextService } from './services/request-context.service'; import { RoleMetadataService } from './services/role-metadata.service'; @@ -343,16 +335,14 @@ import { SavedFiltersService } from './services/saved-filters.service'; import { ScheduledJobService } from './services/scheduled-job.service'; import { AgentSessionService } from './services/agent-session.service'; import { AgentEventService } from './services/agent-event.service'; +import { McpAuditLogService } from './services/mcp-audit-log.service'; import { SchedulerServiceImpl } from './services/scheduled-jobs/scheduler.service'; import { SecurityRuleService } from './services/security-rule.service'; -import { ListOfDashboardQuestionProvidersSelectionProvider } from './services/selection-providers/list-of-dashboard-question-providers-selection-provider.service'; -import { ListOfDashboardVariableProvidersSelectionProvider } from './services/selection-providers/list-of-dashboard-variable-providers-selection-provider.service'; import { ListOfScheduledJobsSelectionProvider } from './services/selection-providers/list-of-scheduled-jobs-selection-provider.service'; import { LocaleListSelectionProvider } from './services/selection-providers/locale-list-selection-provider.service'; import { SettingService } from './services/setting.service'; import { TwilioSMSService } from './services/sms/TwilioSMSService'; import { SolidTsMorphService } from './services/solid-ts-morph.service'; -import { SqlExpressionResolverService } from './services/sql-expression-resolver.service'; import { TextractService } from './services/textract.service'; import { UserActivityHistoryService } from './services/user-activity-history.service'; import { UserViewMetadataService } from './services/user-view-metadata.service'; @@ -361,10 +351,6 @@ import { Three60WhatsappService } from './services/whatsapp/Three60WhatsappServi import { AuditSubscriber } from './subscribers/audit.subscriber'; import { ComputedEntityFieldSubscriber } from './subscribers/computed-entity-field.subscriber'; import { CreatedByUpdatedBySubscriber } from './subscribers/created-by-updated-by.subscriber'; -import { DashboardQuestionSqlDatasetConfigSubscriber } from './subscribers/dashboard-question-sql-dataset-config.subscriber'; -import { DashboardQuestionSubscriber } from './subscribers/dashboard-question.subscriber'; -import { DashboardVariableSubscriber } from './subscribers/dashboard-variable.subscriber'; -import { DashboardSubscriber } from './subscribers/dashboard.subscriber'; import { ListOfValuesSubscriber } from './subscribers/list-of-values.subscriber'; import { ScheduledJobSubscriber } from './subscribers/scheduled-job.subscriber'; import { SecurityRuleSubscriber } from './subscribers/security-rule.subscriber'; @@ -374,7 +360,6 @@ import { McpCommand } from './commands/mcp.command'; import { FixturesService } from './services/fixtures.service'; import { FixturesSetupCommand } from './commands/fixtures/fixtures-setup.command'; import { FixturesTearDownCommand } from './commands/fixtures/fixtures-tear-down.command'; -import { DatabaseBootstrapService } from './services/database/database-bootstrap.service'; import { SequenceNumComputedFieldProvider } from './services/computed-fields/entity/sequence-num-computed-field-provider'; import { ModelSequence } from './entities/model-sequence.entity'; import { ModelSequenceService } from './services/model-sequence.service'; @@ -390,20 +375,18 @@ import { SolidMicroserviceAdapter } from './helpers/solid-microservice-adapter.s import { InfoCommand } from './commands/info.command'; import { ListOfRolesSelectionProvider } from './services/selection-providers/list-of-roles-selectionproviders.service'; import { Entity } from 'typeorm'; +import { DashboardUserLayout } from './entities/dashboard-user-layout.entity'; +import { DashboardUserLayoutService } from './services/dashboard-user-layout.service'; +import { DashboardUserLayoutController } from './controllers/dashboard-user-layout.controller'; +import { DashboardUserLayoutRepository } from './repositories/dashboard-user-layout.repository'; @Global() @Module({ imports: [ TypeOrmModule.forFeature([ ActionMetadata, - AiInteraction, ChatterMessage, ChatterMessageDetails, - Dashboard, - DashboardQuestion, - DashboardQuestionSqlDatasetConfig, - DashboardVariable, - DashboardLayout, EmailAttachment, EmailTemplate, ExportTemplate, @@ -426,6 +409,7 @@ import { Entity } from 'typeorm'; ScheduledJob, AgentSession, AgentEvent, + McpAuditLog, SecurityRule, Setting, SmsTemplate, @@ -440,13 +424,13 @@ import { Entity } from 'typeorm'; CacheModule.registerAsync(CacheManagerOptions), ScheduleModule.forRoot(), ServeStaticModule.forRoot({ - rootPath: join(process.cwd(), 'media-files-storage'), - serveRoot: '/media-files-storage', + rootPath: join(process.cwd(), "media-files-storage"), + serveRoot: "/media-files-storage", serveStaticOptions: { setHeaders: (res /*, path, stat*/) => { // Allow use of these files from a different origin (e.g., :3000 UI) // Use 'same-site' if both origins are on the same site (localhost:* counts as same-site) - res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'); // or 'same-site' + res.setHeader("Cross-Origin-Resource-Policy", "cross-origin"); // or 'same-site' // If you need to load into without tainting or fetch images via XHR, // you can also expose CORS here (not needed for simple ): @@ -457,7 +441,7 @@ import { Entity } from 'typeorm'; MulterModule.registerAsync({ imports: [ConfigModule], useFactory: async (configService: ConfigService) => ({ - dest: process.env.AB_MEDIA_UPLOAD_DIR ?? 'media-uploads', + dest: process.env.AB_MEDIA_UPLOAD_DIR ?? "media-uploads", }), inject: [ConfigService], }), @@ -467,25 +451,22 @@ import { Entity } from 'typeorm'; JwtModule.register({ global: true, }), + TypeOrmModule.forFeature([DashboardUserLayout]), ], controllers: [ ActionMetadataController, - AiInteractionController, AuthenticationController, ChatterMessageController, ChatterMessageDetailsController, - DashboardController, - DashboardQuestionController, - DashboardQuestionSqlDatasetConfigController, - DashboardVariableController, - DashboardLayoutController, EmailTemplateController, ExportTemplateController, ExportTransactionController, FieldMetadataController, + DashboardController, GoogleAuthenticationController, FacebookAuthenticationController, MicrosoftAuthenticationController, + MicrosoftActiveDirectoryAuthenticationController, ImportTransactionController, ImportTransactionErrorLogController, ListOfValuesController, @@ -494,9 +475,13 @@ import { Entity } from 'typeorm'; MediaStorageProviderMetadataController, MenuItemMetadataController, ModelMetadataController, + ModuleMetadataExplorerController, ModuleMetadataController, + ModulePackageController, MqMessageController, MqMessageQueueController, + GupshupWebhookController, + MetaCloudWhatsappWebhookController, OTPAuthenticationController, PermissionMetadataController, RoleMetadataController, @@ -504,6 +489,7 @@ import { Entity } from 'typeorm'; ScheduledJobController, AgentSessionController, AgentEventController, + McpAuditLogController, SecurityRuleController, ServiceController, SettingController, @@ -516,6 +502,7 @@ import { Entity } from 'typeorm'; UserViewMetadataController, ViewMetadataController, ModelSequenceController, + DashboardUserLayoutController, ], providers: [ { @@ -539,10 +526,13 @@ import { Entity } from 'typeorm'; useClass: HttpExceptionFilter, }, ModuleMetadataService, + ModuleMetadataExplorerService, ModuleMetadataHelperService, + ModulePackageService, ModelMetadataService, ModelMetadataHelperService, FieldMetadataService, + DashboardRuntimeService, RemoveFieldsCommand, RefreshModelCommand, RefreshModuleCommand, @@ -550,7 +540,6 @@ import { Entity } from 'typeorm'; InfoService, SolidIntrospectService, DiscoveryService, - R2RHelperService, CrudHelperService, CRUDService, Reflector, @@ -564,6 +553,8 @@ import { Entity } from 'typeorm'; ModuleTestDataService, ListOfValuesService, ListOfValuesSelectionProvider, + MqDashboardQueueNameVariableOptionsProvider, + MqDashboardMessageBrokerVariableOptionsProvider, PseudoForeignKeySelectionProvider, ModelMetadataSubscriber, ViewMetadataService, @@ -585,13 +576,13 @@ import { Entity } from 'typeorm'; TestDataCommand, TestRunCommand, McpCommand, - IngestCommand, - IngestMetadataService, SMTPEMailService, ElasticEmailService, Msg91SMSService, Msg91OTPService, Msg91WhatsappService, + MetaCloudWhatsappService, + GupshupOtpWhatsappService, TwilioSMSService, SmsTemplateService, EmailTemplateService, @@ -600,11 +591,6 @@ import { Entity } from 'typeorm'; ErrorMapperService, SolidCoreErrorCodesProvider, - TriggerMcpClientPublisherDatabase, - TriggerMcpClientSubscriberDatabase, - TriggerMcpClientPublisherRabbitmq, - TriggerMcpClientSubscriberRabbitmq, - SmtpEmailQueuePublisherRabbitmq, SmtpEmailQueueSubscriberRabbitmq, SmtpEmailQueuePublisherDatabase, @@ -654,6 +640,7 @@ import { Entity } from 'typeorm'; GoogleOauthStrategy, FacebookOAuthStrategy, MicrosoftOAuthStrategy, + MicrosoftActiveDirectoryOAuthStrategy, UserRegistrationListener, TestQueuePublisher, TestQueueSubscriber, @@ -685,8 +672,6 @@ import { Entity } from 'typeorm'; SmtpEmailQueueSubscriberRedis, Three60WhatsappQueuePublisherRedis, Three60WhatsappQueueSubscriberRedis, - TriggerMcpClientPublisherRedis, - TriggerMcpClientSubscriberRedis, TwilioSmsQueuePublisherRedis, TwilioSmsQueueSubscriberRedis, GenerateCodePublisherDatabase, @@ -706,6 +691,19 @@ import { Entity } from 'typeorm'; UserRepository, SettingService, ConcatComputedFieldProvider, + MqDashboardTotalMessagesKpiProvider, + MqDashboardSucceededMessagesKpiProvider, + MqDashboardFailedMessagesKpiProvider, + MqDashboardInflightMessagesKpiProvider, + MqDashboardSuccessRateKpiProvider, + MqDashboardAvgElapsedKpiProvider, + MqDashboardMessagesOverTimeProvider, + MqDashboardStageDistributionProvider, + MqDashboardQueueWiseFailuresProvider, + MqDashboardQueueWiseAvgElapsedProvider, + MqDashboardQueueSlaHeatmapProvider, + MqDashboardLatencyTrendProvider, + MqDashboardRecentFailuresProvider, FileStorageProvider, FileS3StorageProvider, MediaRepository, @@ -725,6 +723,7 @@ import { Entity } from 'typeorm'; ExportTransactionService, ExcelService, CsvService, + DashboardRuntimeService, ImportTransactionService, ImportTransactionErrorLogService, CreatedByUpdatedBySubscriber, @@ -737,44 +736,18 @@ import { Entity } from 'typeorm'; ComputedFieldEvaluationSubscriberRabbitmq, ConcatEntityComputedFieldProvider, UserActivityHistoryService, - DashboardService, - DashboardVariableService, - DashboardLayoutService, - DashboardQuestionService, - DashboardVariableSQLDynamicProvider, - DasbhoardVariableTestDynamicProvider, - ListOfDashboardVariableProvidersSelectionProvider, - ListOfDashboardQuestionProvidersSelectionProvider, - DashboardQuestionSqlDatasetConfigService, - ChartJsSqlDataProvider, - PrimeReactMeterGroupSqlDataProvider, - PrimeReactDatatableSqlDataProvider, - SqlExpressionResolverService, - AiInteractionService, - DashboardMapper, - DashboardRepository, - DashboardSubscriber, - DashboardVariableSubscriber, - DashboardQuestionSubscriber, - DashboardQuestionSqlDatasetConfigSubscriber, NoopsEntityComputedFieldProviderService, - McpHandlerFactory, - // SolidCreateDashboardWithWidgetsMcpHandler, - // SolidCreateDashboardQuestionMcpHandler, - // SolidCreateDashboardQuestionSqlDatasetConfigMcpHandler, - // SolidCreateDashboardWidgetMcpHandler, - // SolidAddVariableToDashboardMcpHandler, - // SolidAddQuestionToDashboardMcpHandler, - SolidTsMorphService, ViewMetadataRepository, ScheduledJobRepository, AgentSessionRepository, AgentEventRepository, + McpAuditLogRepository, AgentSessionService, AgentEventService, + McpAuditLogService, ScheduledJobSubscriber, AlphaNumExternalIdComputationProvider, ListOfValuesSubscriber, @@ -784,11 +757,6 @@ import { Entity } from 'typeorm'; SmsFactory, ChatterMessageRepository, ChatterMessageDetailsRepository, - AiInteractionRepository, - DashboardQuestionSqlDatasetConfigRepository, - DashboardQuestionRepository, - DashboardVariableRepository, - DashboardLayoutRepository, EmailTemplateRepository, ExportTemplateRepository, ExportTransactionRepository, @@ -813,7 +781,6 @@ import { Entity } from 'typeorm'; FixturesService, FixturesSetupCommand, FixturesTearDownCommand, - DatabaseBootstrapService, SequenceNumComputedFieldProvider, ModelSequenceService, ModelSequenceRepository, @@ -821,9 +788,10 @@ import { Entity } from 'typeorm'; ImageEncodingService, SolidMicroserviceAdapter, ListOfRolesSelectionProvider, + DashboardUserLayoutService, + DashboardUserLayoutRepository, ], exports: [ - AiInteractionService, AuthenticationService, ChatterMessageDetailsRepository, ChatterMessageDetailsService, @@ -855,6 +823,8 @@ import { Entity } from 'typeorm'; ModelMetadataHelperService, ModelMetadataService, ModuleMetadataService, + ModuleMetadataExplorerService, + ModulePackageService, MqMessageQueueService, MqMessageService, Msg91OTPService, @@ -888,9 +858,9 @@ export class SolidCoreModule implements NestModule { configure(consumer: MiddlewareConsumer) { consumer .apply( - express.json({ limit: '10mb' }), - express.urlencoded({ limit: '10mb', extended: true }), + express.json({ limit: "10mb" }), + express.urlencoded({ limit: "10mb", extended: true }), ) - .forRoutes('*'); + .forRoutes("*"); } } diff --git a/src/subscribers/computed-entity-field.subscriber.ts b/src/subscribers/computed-entity-field.subscriber.ts index 431c97c3..c4f52028 100644 --- a/src/subscribers/computed-entity-field.subscriber.ts +++ b/src/subscribers/computed-entity-field.subscriber.ts @@ -142,7 +142,7 @@ export class ComputedEntityFieldSubscriber implements EntitySubscriberInterface const providerInstance = provider.instance as IEntityPreComputeFieldProvider; // IEntityComputedFieldProvider const computedValue = await providerInstance.preComputeValue(entity, computedFieldMetadata); //FIXME There should some way to check/assert if the provider actually has a postComputeAndSaveValue return computedValue; //TODO: This line here is just for backward compatibility, once the pre compute interface is change to return void, we will get rid of it. - } catch (error) { + } catch (error: any) { throw new InternalServerErrorException(`Error evaluating computed field ${computedFieldMetadata.fieldName} for model ${computedFieldMetadata.modelName} for triggered entity ${entity.constructor.name}: ${error.message}`); } } diff --git a/src/subscribers/dashboard-question-sql-dataset-config.subscriber.ts b/src/subscribers/dashboard-question-sql-dataset-config.subscriber.ts deleted file mode 100644 index 2f738e94..00000000 --- a/src/subscribers/dashboard-question-sql-dataset-config.subscriber.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { Injectable, Logger } from "@nestjs/common"; -import { InjectDataSource } from "@nestjs/typeorm"; -import { DashboardQuestionSqlDatasetConfig } from "src/entities/dashboard-question-sql-dataset-config.entity"; -import { DashboardQuestion } from "src/entities/dashboard-question.entity"; -import { ModuleMetadataHelperService } from "src/helpers/module-metadata-helper.service"; -import { DashboardService } from "src/services/dashboard.service"; -import { DataSource, EntityManager, EntitySubscriberInterface, InsertEvent, UpdateEvent } from "typeorm"; - -@Injectable() -export class DashboardQuestionSqlDatasetConfigSubscriber implements EntitySubscriberInterface { - private readonly logger = new Logger(this.constructor.name); - constructor( - @InjectDataSource() - private readonly dataSource: DataSource, - readonly moduleMetadataHelperService: ModuleMetadataHelperService, - readonly dashboardService: DashboardService, // Assuming you have a DashboardService for custom queries - ) { - this.dataSource.subscribers.push(this); - } - - listenTo() { - return DashboardQuestionSqlDatasetConfig; - } - - async afterInsert(event: InsertEvent) { - const question = event.entity.question; - if (!question) { - this.logger.debug('No question found in the QuestionSqlDatasetConfigSubscriber afterInsert method'); - return; - } - await this.saveQuestionToConfig(question, event.queryRunner.manager); - } - - async afterUpdate(event: UpdateEvent) { - const question = event.databaseEntity.question; - if (!question) { - this.logger.debug('No question found in the QuestionSqlDatasetConfigSubscriber afterUpdate method'); - return; - } - await this.saveQuestionToConfig(question, event.queryRunner.manager); - } - - private async saveQuestionToConfig(question: DashboardQuestion, entityManager: EntityManager): Promise { - // Populate the dashboard for the question - const populatedQuestion = await entityManager.findOne(DashboardQuestion, { - where: { - id: question.id, - }, - relations: ['dashboard', 'dashboard.module', 'dashboard.dashboardVariables', 'dashboard.questions', 'dashboard.questions.questionSqlDatasetConfigs'], - }); - const dashboard = populatedQuestion?.dashboard; - - if (!dashboard) { - throw new Error(`Dashboard not found for question id ${question.id}`); - } - - // Call the saveDashboardToConfig method from the DashboardService - await this.dashboardService.saveDashboardToConfig(dashboard); - } - -} \ No newline at end of file diff --git a/src/subscribers/dashboard-question.subscriber.ts b/src/subscribers/dashboard-question.subscriber.ts deleted file mode 100644 index e768e948..00000000 --- a/src/subscribers/dashboard-question.subscriber.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { Injectable, Logger } from "@nestjs/common"; -import { InjectDataSource } from "@nestjs/typeorm"; -import { DashboardQuestion } from "src/entities/dashboard-question.entity"; -import { Dashboard } from "src/entities/dashboard.entity"; -import { ModuleMetadataHelperService } from "src/helpers/module-metadata-helper.service"; -import { DashboardService } from "src/services/dashboard.service"; -import { DataSource, EntityManager, EntitySubscriberInterface, InsertEvent, UpdateEvent } from "typeorm"; - -@Injectable() -export class DashboardQuestionSubscriber implements EntitySubscriberInterface { - private readonly logger = new Logger(this.constructor.name); - constructor( - @InjectDataSource() - private readonly dataSource: DataSource, - readonly moduleMetadataHelperService: ModuleMetadataHelperService, - readonly dashboardService: DashboardService, - ) { - this.dataSource.subscribers.push(this); - } - - listenTo() { - return DashboardQuestion; - } - - async afterInsert(event: InsertEvent) { - if (!event.entity) { - this.logger.debug('No question entity found in the QuestionSubscriber afterInsert method'); - return; - } - await this.saveDashboardToConfig(event.entity, event.queryRunner.manager); - } - - async afterUpdate(event: UpdateEvent) { - if (!event.databaseEntity) { - this.logger.debug('No question entity found in the QuestionSubscriber afterUpdate method'); - return; - } - await this.saveDashboardToConfig(event.databaseEntity, event.queryRunner.manager); - } - - private async saveDashboardToConfig(question: DashboardQuestion, entityManager: EntityManager): Promise { - const dashboard = question.dashboard; - // Get the dashboard from the question & call the saveDashboardToConfig method - if (!dashboard) { - this.logger.debug(`Dashboard is undefined for question id ${question.id}`); - return; - } - - // populate the dashboard with its variables - const populatedDashboard = await entityManager.findOne(Dashboard, { - where: { id: dashboard.id }, - relations: ['module','dashboardVariables', 'questions', 'questions.questionSqlDatasetConfigs'], - }); - - if (!populatedDashboard) { - throw new Error(`Dashboard not found for question id ${populatedDashboard.id}`); - } - - // Call the saveDashboardToConfig method from the DashboardService - await this.dashboardService.saveDashboardToConfig(populatedDashboard); - } -} \ No newline at end of file diff --git a/src/subscribers/dashboard-variable.subscriber.ts b/src/subscribers/dashboard-variable.subscriber.ts deleted file mode 100644 index de803b0f..00000000 --- a/src/subscribers/dashboard-variable.subscriber.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { Injectable, Logger } from "@nestjs/common"; -import { InjectDataSource } from "@nestjs/typeorm"; -import { DashboardVariable } from "src/entities/dashboard-variable.entity"; -import { Dashboard } from "src/entities/dashboard.entity"; -import { ModuleMetadataHelperService } from "src/helpers/module-metadata-helper.service"; -import { DashboardVariableService } from "src/services/dashboard-variable.service"; -import { DashboardService } from "src/services/dashboard.service"; -import { EntitySubscriberInterface, DataSource, InsertEvent, UpdateEvent, EntityManager } from "typeorm"; - -@Injectable() -export class DashboardVariableSubscriber implements EntitySubscriberInterface { - private readonly logger = new Logger(this.constructor.name); - constructor( - @InjectDataSource() - private readonly dataSource: DataSource, - readonly moduleMetadataHelperService: ModuleMetadataHelperService, - readonly dashboardService: DashboardService, // Assuming you have a DashboardService for custom queries - ) { - this.dataSource.subscribers.push(this); - } - - listenTo() { - return DashboardVariable; - } - - async afterInsert(event: InsertEvent) { - if (!event.entity) { - this.logger.debug('No dashboard variable entity found in the DashboardVariableSubscriber afterInsert method'); - return; - } - await this.saveDashboardToConfig(event.entity, event.queryRunner.manager); - } - - async afterUpdate(event: UpdateEvent) { - if (!event.databaseEntity) { - this.logger.debug('No dashboard variable entity found in the DashboardVariableSubscriber afterUpdate method'); - return; - } - await this.saveDashboardToConfig(event.databaseEntity, event.queryRunner.manager); - } - - private async saveDashboardToConfig(dashboardVariable: DashboardVariable, entityManager: EntityManager): Promise { - const dashboard = dashboardVariable.dashboard; - // Get the dashboard from the question & call the saveDashboardToConfig method - if (!dashboard) { - this.logger.debug(`Dashboard is undefined for dashboard variable id ${dashboardVariable.id}`); - return; - } - - // populate the dashboard with its variables - const populatedDashboard = await entityManager.findOne(Dashboard, { - where: { id: dashboard.id }, - relations: ['module','dashboardVariables', 'questions', 'questions.questionSqlDatasetConfigs'], - }); - - if (!populatedDashboard) { - throw new Error(`Dashboard not found for question id ${populatedDashboard.id}`); - } - - // Call the saveDashboardToConfig method from the DashboardService - await this.dashboardService.saveDashboardToConfig(populatedDashboard); - } -} \ No newline at end of file diff --git a/src/subscribers/dashboard.subscriber.ts b/src/subscribers/dashboard.subscriber.ts deleted file mode 100644 index 425d6539..00000000 --- a/src/subscribers/dashboard.subscriber.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { InjectDataSource } from "@nestjs/typeorm"; -import { Dashboard } from 'src/entities/dashboard.entity'; -import { ModuleMetadataHelperService } from "src/helpers/module-metadata-helper.service"; -import { DashboardService } from 'src/services/dashboard.service'; -import { DataSource, EntityManager, EntitySubscriberInterface, InsertEvent, UpdateEvent } from "typeorm"; - -@Injectable() -export class DashboardSubscriber implements EntitySubscriberInterface { - private readonly logger = new Logger(this.constructor.name); - constructor( - @InjectDataSource() - private readonly dataSource: DataSource, - readonly moduleMetadataHelperService: ModuleMetadataHelperService, - readonly dashboardService: DashboardService, // Assuming you have a DashboardService for custom queries - ) { - this.dataSource.subscribers.push(this); - } - - listenTo() { - return Dashboard; - } - - async afterInsert(event: InsertEvent) { - if (!event.entity) { - this.logger.debug('No dashboard entity found in the DashboardSubscriber afterInsert method'); - return; - } - await this.saveDashboardToConfig(event.entity, event.queryRunner.manager); - } - - async afterUpdate(event: UpdateEvent) { - if (!event.entity) { - this.logger.debug('No dashboard entity found in the DashboardSubscriber afterInsert method'); - return; - } - - await this.saveDashboardToConfig(event.databaseEntity, event.queryRunner.manager); - } - - private async saveDashboardToConfig(dashboard: Dashboard, entityManager: EntityManager): Promise { - if (!dashboard || !dashboard.id) { - this.logger.debug('Dashboard or dashboard id is undefined'); - return; - } - - // Load the dashboard with module relation populated - const populatedDashboard = await entityManager.findOne(Dashboard, { - where: { id: dashboard.id }, - relations: ['module','dashboardVariables', 'questions', 'questions.questionSqlDatasetConfigs'], - }); - - if (!populatedDashboard) { - this.logger.error(`Dashboard not found for id ${dashboard.id}`); - return; - } - - // Call the saveDashboardToConfig method from the DashboardService - await this.dashboardService.saveDashboardToConfig(populatedDashboard); - } - -} \ No newline at end of file diff --git a/src/subscribers/model-metadata.subscriber.ts b/src/subscribers/model-metadata.subscriber.ts index 2cf6f092..3c45717b 100755 --- a/src/subscribers/model-metadata.subscriber.ts +++ b/src/subscribers/model-metadata.subscriber.ts @@ -35,9 +35,7 @@ export class ModelMetadataSubscriber implements EntitySubscriberInterface) { - const isLegacyTable = event.entity.isLegacyTable; - const isLegacyTableWithId = event.entity.isLegacyTableWithId; - const systemFieldsDefaultMetadata = this.modelHelperService.getSystemFieldsMetadata(isLegacyTable, isLegacyTableWithId); + const systemFieldsDefaultMetadata = this.modelHelperService.getSystemFieldsMetadata(event.entity.legacyTableType); // map and add the model as event.entity for the above metadata const systemFieldsMetadata = systemFieldsDefaultMetadata.map(field => ({ ...field, diff --git a/src/subscribers/scheduled-job.subscriber.ts b/src/subscribers/scheduled-job.subscriber.ts index 320f7dc0..c294d293 100644 --- a/src/subscribers/scheduled-job.subscriber.ts +++ b/src/subscribers/scheduled-job.subscriber.ts @@ -83,7 +83,7 @@ export class ScheduledJobSubscriber return; } - const moduleMetadataRepo = this.dataSource.getRepository(ModuleMetadata); + const moduleMetadataRepo = event.queryRunner.manager.getRepository(ModuleMetadata); const populatedModuleMetadata = await moduleMetadataRepo.findOne({ where: { id: moduleMetadata.id }, }); diff --git a/src/subscribers/security-rule.subscriber.ts b/src/subscribers/security-rule.subscriber.ts index 88d094e8..e780c37c 100644 --- a/src/subscribers/security-rule.subscriber.ts +++ b/src/subscribers/security-rule.subscriber.ts @@ -27,20 +27,20 @@ export class SecurityRuleSubscriber implements EntitySubscriberInterface) { await this.saveSecurityRules(event); } - + async afterUpdate(event: UpdateEvent) { await this.saveSecurityRules(event); } - async saveSecurityRules(event: UpdateEvent| InsertEvent) { + async saveSecurityRules(event: UpdateEvent | InsertEvent) { const securityRule = event.entity as SecurityRule; const modelMetadata = event.entity.modelMetadata; if (!modelMetadata) { this.logger.error(`Model metadata not found for security rule with id ${event.entity.id}`); return; } - - const modelMetadataRepo = this.dataSource.getRepository(ModelMetadata); + + const modelMetadataRepo = event.manager.getRepository(ModelMetadata); const populatedModelMetadata = await modelMetadataRepo.findOne({ where: { id: modelMetadata.id @@ -58,7 +58,7 @@ export class SecurityRuleSubscriber implements EntitySubscriberInterface ruleFromFile.name === securityRule.name); - const {id, roleId, modelMetadataId, ...requiredDto} = await this.securityRuleRepo.toDto(securityRule) - metaData.securityRules[securityRuleIndex] = {...requiredDto, securityRuleConfig: JSON.parse(securityRule.securityRuleConfig)} + const { id, roleId, modelMetadataId, ...requiredDto } = await this.securityRuleRepo.toDto(securityRule) + metaData.securityRules[securityRuleIndex] = { ...requiredDto, securityRuleConfig: JSON.parse(securityRule.securityRuleConfig) } } else { const securityRules = [] - const {id, roleId, modelMetadataId, ...requiredDto} = await this.securityRuleRepo.toDto(securityRule) - securityRules.push({...requiredDto, securityRuleConfig: JSON.parse(securityRule.securityRuleConfig)}) + const { id, roleId, modelMetadataId, ...requiredDto } = await this.securityRuleRepo.toDto(securityRule) + securityRules.push({ ...requiredDto, securityRuleConfig: JSON.parse(securityRule.securityRuleConfig) }) metaData.securityRules = securityRules } // Write the updated object back to the file diff --git a/src/subscribers/view-metadata.subscriber.ts b/src/subscribers/view-metadata.subscriber.ts index e86117bd..fa6de800 100644 --- a/src/subscribers/view-metadata.subscriber.ts +++ b/src/subscribers/view-metadata.subscriber.ts @@ -43,7 +43,7 @@ export class ViewMetadataSubsciber implements EntitySubscriberInterface` +5. `npx @solidxai/solidctl@latest test data --teardown` + +Lightweight existing-database workflow: +1. `npx @solidxai/solidctl@latest test data --load` +2. `npx @solidxai/solidctl@latest test run --module --headless false` +3. `npx @solidxai/solidctl@latest test data --unlink` + +`test data --unlink` deletes records declared in `testing.data` in reverse order using each model's actual `userKeyFieldUserKey` value from `testing.data[*].data`. This assumes `testing.data` is authored in dependency order, with parent records appearing before dependent records. + +For human-assisted OTP or third-party verification flows, use `ui.waitForManual` in a headed run so the browser remains interactive while the scenario is paused. + ## Add A New Step (SOP) 1. Create a new `*.step.ts` in the right domain folder. 2. Implement a `registerXSteps(registry)` function and `registry.register("op.name", handler)`. 3. Validate required `step.with` fields and throw clear errors. 4. Use adapters via `ctx.api` / `ctx.ui` and update `ctx.last` when helpful. -5. Export and register it from the domain `index.ts`. +5. Export and register it from the domain `index.ts`. \ No newline at end of file diff --git a/src/testing/adapters/ui/playwright-adapter.ts b/src/testing/adapters/ui/playwright-adapter.ts index da104661..1f44d3c1 100644 --- a/src/testing/adapters/ui/playwright-adapter.ts +++ b/src/testing/adapters/ui/playwright-adapter.ts @@ -18,6 +18,10 @@ export class PlaywrightAdapter { this.headless = opts?.headless ?? true; } + isHeadless(): boolean { + return this.headless; + } + async start(): Promise { const { chromium } = await import('playwright'); this.browser = await chromium.launch({ headless: this.headless }); diff --git a/src/testing/core/testing-engine.ts b/src/testing/core/testing-engine.ts index f1dc7f1a..a7e4a3cc 100644 --- a/src/testing/core/testing-engine.ts +++ b/src/testing/core/testing-engine.ts @@ -57,7 +57,7 @@ export class TestingEngine { } else { await execute(); } - } catch (err) { + } catch (err: any) { scenarioError = err; } finally { const durationMs = Date.now() - scenarioStart; @@ -108,7 +108,7 @@ export class TestingEngine { // console.log(`Step ${resolvedStep.name} attempting to saveAs ${resolvedStep.saveAs}`, JSON.stringify(result)); ctx.resources.set(resolvedStep.saveAs, result); } - } catch (err) { + } catch (err: any) { stepError = err; throw err; } finally { diff --git a/src/testing/reporter/console-reporter.ts b/src/testing/reporter/console-reporter.ts index d4b0ae12..4de22e86 100644 --- a/src/testing/reporter/console-reporter.ts +++ b/src/testing/reporter/console-reporter.ts @@ -146,6 +146,10 @@ function formatStepLabel(step: OpStep): string { } export class ConsoleReporter implements Reporter { + private totalScenarios = 0; + private passedScenarios = 0; + private failedScenarios = 0; + onScenarioStart(scenario: { id: string; name?: string }): void { const label = scenario.name ? `${scenario.id} (${scenario.name})` : scenario.id; console.log(`\n▶ Scenario: ${label}`); @@ -157,6 +161,12 @@ export class ConsoleReporter implements Reporter { ): void { const label = scenario.name ? `${scenario.id} (${scenario.name})` : scenario.id; const status = result.ok ? "✔" : "✖"; + this.totalScenarios += 1; + if (result.ok) { + this.passedScenarios += 1; + } else { + this.failedScenarios += 1; + } console.log(`${status} Scenario: ${label} (${result.durationMs}ms)`); } @@ -226,4 +236,21 @@ export class ConsoleReporter implements Reporter { if (!dataText.length) return; console.log(indentLines(dataText, `${STEP_INDENT}${INDENT}${INDENT}`)); } + + onRunEnd(args: { + ok: boolean; + total: number; + passed: number; + failed: number; + durationMs: number; + }): void { + const durationSeconds = (args.durationMs / 1000).toFixed(2); + const finalStatus = args.ok ? "PASSED" : "FAILED"; + + console.log("\n════════ Test Run Summary ════════"); + console.log(`Result: Test run ${finalStatus}`); + console.log(`Cases: total=${args.total}, passed=${args.passed}, failed=${args.failed}`); + console.log(`Duration: ${durationSeconds}s`); + console.log("══════════════════════════════════"); + } } diff --git a/src/testing/reporter/reporter.types.ts b/src/testing/reporter/reporter.types.ts index afbf9840..34bd4481 100644 --- a/src/testing/reporter/reporter.types.ts +++ b/src/testing/reporter/reporter.types.ts @@ -33,4 +33,11 @@ export interface Reporter { contentType: string; data: Buffer | string; }): void; + onRunEnd?(args: { + ok: boolean; + total: number; + passed: number; + failed: number; + durationMs: number; + }): void; } diff --git a/src/testing/reporter/webhook-reporter.ts b/src/testing/reporter/webhook-reporter.ts new file mode 100644 index 00000000..48b879b3 --- /dev/null +++ b/src/testing/reporter/webhook-reporter.ts @@ -0,0 +1,116 @@ +import { ConsoleReporter } from "./console-reporter"; +import type { OpStep, ScenarioSpec } from "../contracts/testing-metadata.types"; +import type { StepPhase } from "../contracts/runtime-context.types"; + +interface TestStepResult { + phase: string; + operation: string; + name?: string; + ok: boolean; + durationMs: number; + error?: string; +} + +interface TestScenarioResult { + id: string; + name?: string; + ok: boolean; + durationMs: number; + error?: string; + steps: TestStepResult[]; +} + +export interface TestRunPayload { + runName: string; + startedAt: string; + completedAt: string; + durationMs: number; + exitCode: number; + total: number; + passed: number; + failed: number; + scenarios: TestScenarioResult[]; +} + +function formatError(err: unknown): string { + if (!err) return ""; + if (err instanceof Error) return err.stack || err.message; + return String(err); +} + +export class WebhookReporter extends ConsoleReporter { + private readonly startedAt = new Date(); + private readonly accumulated: TestScenarioResult[] = []; + private currentSteps: TestStepResult[] = []; + + constructor( + private readonly webhookUrl: string, + private readonly runName: string, + ) { + super(); + } + + override onStepEnd(args: { + scenarioId: string; + phase: StepPhase; + step: OpStep; + ok: boolean; + error?: unknown; + durationMs: number; + }): void { + super.onStepEnd(args); + this.currentSteps.push({ + phase: String(args.phase), + operation: args.step.op, + name: args.step.name, + ok: args.ok, + durationMs: args.durationMs, + error: args.error ? formatError(args.error) : undefined, + }); + } + + override onScenarioEnd( + scenario: ScenarioSpec, + result: { ok: boolean; error?: unknown; durationMs: number }, + ): void { + super.onScenarioEnd(scenario, result); + this.accumulated.push({ + id: scenario.id, + name: scenario.name, + ok: result.ok, + durationMs: result.durationMs, + error: result.error ? formatError(result.error) : undefined, + steps: [...this.currentSteps], + }); + this.currentSteps = []; + } + + async flush(exitCode: number): Promise { + const completedAt = new Date(); + const payload: TestRunPayload = { + runName: this.runName, + startedAt: this.startedAt.toISOString(), + completedAt: completedAt.toISOString(), + durationMs: completedAt.getTime() - this.startedAt.getTime(), + exitCode, + total: this.accumulated.length, + passed: this.accumulated.filter((s) => s.ok).length, + failed: this.accumulated.filter((s) => !s.ok).length, + scenarios: this.accumulated, + }; + + try { + const response = await fetch(this.webhookUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + signal: AbortSignal.timeout(10_000), + }); + if (!response.ok) { + console.warn(`[WebhookReporter] Webhook returned ${response.status}`); + } + } catch (err: any) { + console.warn(`[WebhookReporter] Failed to deliver test results: ${err}`); + } + } +} diff --git a/src/testing/runner/run-from-metadata.ts b/src/testing/runner/run-from-metadata.ts index f271047d..72f1003f 100644 --- a/src/testing/runner/run-from-metadata.ts +++ b/src/testing/runner/run-from-metadata.ts @@ -44,6 +44,7 @@ export type RunnerOptions = { }; export async function runFromMetadata(opts: RunnerOptions): Promise { + const startedAt = Date.now(); const registry = new StepRegistry(); registerApiSteps(registry); registerUiSteps(registry); @@ -71,15 +72,32 @@ export async function runFromMetadata(opts: RunnerOptions): Promise { const ui = new PlaywrightAdapter(opts.ui); const ctxBase = { resources, reporter, api, ui, specRegistry, testData, options: opts.options }; const uiStarted = { value: false }; + let passed = 0; + let failed = 0; + let runError: unknown; try { for (const scenario of scenarios) { if (scenarioNeedsUi(scenario)) { await ensureUiStarted(ctxBase, uiStarted); } - await engine.runScenario(scenario, ctxBase); + try { + await engine.runScenario(scenario, ctxBase); + passed += 1; + } catch (error) { + failed += 1; + runError = error; + throw error; + } } } finally { + reporter.onRunEnd?.({ + ok: !runError, + total: scenarios.length, + passed, + failed, + durationMs: Date.now() - startedAt, + }); if (uiStarted.value) { await ui.stop(); } diff --git a/src/testing/steps/ui/index.ts b/src/testing/steps/ui/index.ts index cd9a23d2..b4bdfe37 100644 --- a/src/testing/steps/ui/index.ts +++ b/src/testing/steps/ui/index.ts @@ -3,10 +3,12 @@ import { registerNavigationSteps } from "./navigation.step"; import { registerFormSteps } from "./form.step"; import { registerActionSteps } from "./actions.step"; import { registerAssertionSteps } from "./assertions.step"; +import { registerManualSteps } from "./manual.step"; export function registerUiSteps(registry: StepRegistry): void { registerNavigationSteps(registry); registerFormSteps(registry); registerActionSteps(registry); registerAssertionSteps(registry); + registerManualSteps(registry); } diff --git a/src/testing/steps/ui/manual.step.ts b/src/testing/steps/ui/manual.step.ts new file mode 100644 index 00000000..1f363052 --- /dev/null +++ b/src/testing/steps/ui/manual.step.ts @@ -0,0 +1,80 @@ +import { createInterface } from "node:readline/promises"; +import { stdin as input, stdout as output } from "node:process"; + +import type { TestContext } from "../../contracts/runtime-context.types"; +import type { OpStep } from "../../contracts/testing-metadata.types"; +import { StepRegistry } from "../../core/step-registry"; + +type WaitForManualInput = { + message?: string; + prompt?: string; + waitForSelector?: string; + waitForUrlContains?: string; + waitForUrlEquals?: string; + timeoutMs?: number; + bringToFront?: boolean; +}; + +function requirePage(ctx: TestContext, op: string) { + if (!ctx.ui || !ctx.ui.page) { + throw new Error(`Missing UI page on context for op "${op}"`); + } + return ctx.ui.page; +} + +export function registerManualSteps(registry: StepRegistry): void { + registry.register("ui.waitForManual", async (ctx: TestContext, step: OpStep) => { + const page = requirePage(ctx, "ui.waitForManual"); + const inputConfig = (step.with ?? {}) as WaitForManualInput; + const message = inputConfig.message?.trim() || "Manual interaction required."; + const prompt = inputConfig.prompt?.trim() || "Press Enter to continue..."; + const timeoutMs = inputConfig.timeoutMs; + const bringToFront = inputConfig.bringToFront ?? true; + + if (ctx.ui?.isHeadless()) { + throw new Error('Op "ui.waitForManual" requires headed mode. Re-run with --headless false.'); + } + if (!input.isTTY || !output.isTTY) { + throw new Error('Op "ui.waitForManual" requires an interactive terminal (TTY).'); + } + + if (bringToFront) { + await page.bringToFront(); + } + + console.log(""); + console.log("════════ Manual Interaction Required ════════"); + console.log(message); + console.log(`Scenario: ${ctx.scenarioId}`); + console.log(`Current URL: ${page.url()}`); + console.log(prompt); + + const rl = createInterface({ input, output }); + try { + await rl.question(""); + } finally { + rl.close(); + } + + if (inputConfig.waitForSelector) { + await page.waitForSelector(inputConfig.waitForSelector, { + state: "visible", + timeout: timeoutMs, + }); + } + + if (inputConfig.waitForUrlEquals) { + await page.waitForURL( + (url) => url.toString() === inputConfig.waitForUrlEquals, + { timeout: timeoutMs }, + ); + } + + if (inputConfig.waitForUrlContains) { + await page.waitForURL( + (url) => url.toString().includes(String(inputConfig.waitForUrlContains)), + { timeout: timeoutMs }, + ); + } + }); +} diff --git a/src/winston.logger.ts b/src/winston.logger.ts index f9adc378..53c4fb55 100644 --- a/src/winston.logger.ts +++ b/src/winston.logger.ts @@ -6,8 +6,8 @@ import { WINSTON_MODULE_PROVIDER } from 'nest-winston'; import * as winston from 'winston'; import { Environment } from './decorators/disallow-in-production.decorator'; -export const WinstonLoggerConfig = { - level: process.env.LOG_LEVEL || (process.env.ENV === Environment.Production ? 'info' : 'debug'), +export const createWinstonLoggerConfig = () => ({ + level: process.env.LOG_LEVEL || (process.env.ENV === Environment.Production ? 'warn' : 'info'), format: winston.format.combine( winston.format.timestamp(), winston.format.errors({ stack: true }), @@ -21,8 +21,6 @@ export const WinstonLoggerConfig = { transports: [ new winston.transports.Console({ format: winston.format.combine( - // winston.format.colorize(), - // winston.format.timestamp(), winston.format.printf(({ level, message, timestamp }) => { return `[${timestamp}] ${level.toUpperCase()}: ${message}`; }), @@ -30,15 +28,15 @@ export const WinstonLoggerConfig = { }), new winston.transports.File({ filename: 'logs/application.log', - // format: winston.format.json(), }), new winston.transports.File({ filename: 'logs/error.log', level: 'error', - // format: winston.format.json(), }), ], -}; +}); + +export const WinstonLoggerConfig = createWinstonLoggerConfig(); export class WinstonTypeORMLogger implements TypeORMLogger { constructor(@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger) { }