Compare commits

..

48 Commits

Author SHA1 Message Date
fbf62fcd66 Enhances CDK deployment process and documentation
Improves the deployment script to use the UAE  region and adds context for the CDK stack.
2025-07-07 09:37:10 +03:00
374fb69804 fix the super user seeded to accept terms and add certificate arn 2025-06-30 03:58:47 -04:00
d4d1ec817d a functioning backend stack bypassing firebase and using an existing domain 2025-06-29 20:45:38 -04:00
90ab291d83 add curtain module device (#440) 2025-06-29 10:10:19 +03:00
5381a949bc task: delete used & its relations (#437) 2025-06-25 15:32:46 +03:00
6973e8b195 task: sort communities by creation date (#416) 2025-06-19 11:13:24 +03:00
92d102d08f Merge pull request #413 from SyncrowIOT/fix-staging-insirt-logs-data
Fix-staging-insirt-logs-data
2025-06-18 07:35:30 -06:00
7dc28d0cb3 fix: enable AQI sensor historical data update in device status processing 2025-06-18 07:32:39 -06:00
d9ad431a23 fix: correct procedure names in energy consumption updates 2025-06-18 05:33:49 -06:00
4bf43dab2b feat: enhance device status DTO and service with optional properties and environment checks 2025-06-18 05:33:43 -06:00
7520b8d9c7 fix: power clamp historical API (#408) 2025-06-17 15:17:49 +03:00
72753b6dfb merge dev to main 2025-06-14 15:18:20 -06:00
568eef8119 Merge branch 'dev' 2025-06-14 15:04:48 -06:00
a40560a0b1 Merge pull request #380 from SyncrowIOT/revert-378-daily-aqi-sensor
Revert "SQL model for aqi score and processing air data"
2025-05-21 20:04:43 -04:00
7d6f1bb944 Revert "SQL model for aqi score and processing air data" 2025-05-21 20:01:05 -04:00
434316fe51 Merge pull request #378 from SyncrowIOT/daily-aqi-sensor
SQL model for aqi score and processing air data
2025-05-21 16:54:19 -04:00
287bb4c5e4 SQL model for aqi score and processing air data 2025-05-21 16:49:44 -04:00
85602fa952 check deployment 2025-05-08 13:15:31 +03:00
25a4d3e91b Merge pull request #364 from SyncrowIOT/revert-363-DATA-date-param-filtering
Revert "DATA-date-param-moved"
2025-05-08 13:08:58 +03:00
d3a560d18f Revert "DATA-date-param-moved" 2025-05-08 13:08:41 +03:00
ab99bb5afc Merge pull request #363 from SyncrowIOT/DATA-date-param-filtering
DATA-date-param-moved
2025-05-08 13:07:51 +03:00
67911d5ff1 moved param 2025-05-08 13:06:39 +03:00
13e3f3e213 Merge branch 'dev' 2025-04-29 09:58:05 +03:00
327d678656 Enhance TuyaWebSocketService to handle environment-specific message extraction 2025-03-28 03:40:09 +03:00
dd5447fc5f Merge pull request #311 from SyncrowIOT/dev 2025-03-13 13:56:50 +04:00
7df5b9ab08 Merge branch 'main' of https://github.com/SyncrowIOT/backend 2025-03-13 11:06:06 +03:00
06b4407b85 Merge branch 'dev' 2025-03-13 11:05:11 +03:00
1d6f3b8e65 Merge pull request #309 from SyncrowIOT:dev
propagating of space model to space
2025-03-13 00:27:23 +04:00
80659f7a48 Merge branch 'dev' 2025-03-12 02:22:33 +03:00
4a5f2f3b9f Merge branch 'dev' 2025-03-11 20:27:22 +03:00
a57f4e1c65 Merge branch 'dev' 2025-03-11 15:33:52 +03:00
b2d52c7622 Merge branch 'dev' 2025-02-20 03:46:08 -06:00
c9cbb2e085 Merge pull request #262 from SyncrowIOT/dev
change subspace tag movement
2025-02-19 13:11:46 +04:00
8aa3de5fdc config 2025-02-18 16:59:38 +04:00
bc1ee9a53b test deploy 2 2025-02-18 05:39:55 -06:00
19356c3833 test deploy 2025-02-18 05:35:06 -06:00
8737ee992b Update GitHub Actions workflow for Node.js app deployment to Azure 2025-02-18 05:08:51 -06:00
e98a99be73 Update GitHub Actions workflow for containerized deployment to Azure Web App 2025-02-18 05:03:05 -06:00
93efa15f3c Empty commit 2025-02-18 04:50:54 -06:00
c305e39ff2 Add or update the Azure App Service build and deployment workflow config 2025-02-18 04:34:31 -06:00
61e4d220dc test deploy 2025-02-18 04:15:24 -06:00
cd4bbe1788 Empty commit 2025-02-18 00:10:22 -06:00
d770a0c732 Remove robots.txt request handling middleware 2025-02-17 18:51:16 -06:00
030e6ae902 Add middleware to ignore requests for robots*.txt files 2025-02-17 18:43:43 -06:00
9d8287b82b Remove trailing whitespace in GitHub workflow file 2025-02-17 18:05:20 -06:00
d741a6c1f3 Empty commit 2025-02-17 17:50:51 -06:00
6d55704dd4 Merge branch 'dev' 2025-02-17 17:35:45 -06:00
d8ad9e55ea Merge pull request #253 from SyncrowIOT/dev
Dev
2025-02-06 09:26:54 +04:00
89 changed files with 3038 additions and 1730 deletions

View File

@ -21,7 +21,6 @@ module.exports = {
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'off',
"@typescript-eslint/no-unused-vars": 'warn',
},
settings: {
'import/resolver': {

View File

@ -1,4 +1,7 @@
name: Backend deployment to Azure App Service
# Docs for the Azure Web Apps Deploy action: https://github.com/Azure/webapps-deploy
# More GitHub Actions for Azure: https://github.com/Azure/actions
name: Build and deploy container app to Azure Web App - syncrow(staging)
on:
push:
@ -6,50 +9,43 @@ on:
- main
workflow_dispatch:
env:
AZURE_WEB_APP_NAME: 'syncrow'
AZURE_WEB_APP_SLOT_NAME: 'staging'
ACR_REGISTRY: 'syncrow.azurecr.io'
IMAGE_NAME: 'backend'
IMAGE_TAG: 'latest'
jobs:
build_and_deploy:
runs-on: ubuntu-latest
build:
runs-on: 'ubuntu-latest'
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v2
- name: Set up Node.js
uses: actions/setup-node@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Log in to registry
uses: docker/login-action@v2
with:
node-version: '20'
registry: https://syncrow.azurecr.io/
username: ${{ secrets.AzureAppService_ContainerUsername_47395803300340b49931ea82f6d80be3 }}
password: ${{ secrets.AzureAppService_ContainerPassword_e7b0ff54f54d44cba04a970a22384848 }}
- name: Install dependencies and build project
run: |
npm install
npm run build
- name: Log in to Azure
uses: azure/login@v1
- name: Build and push container image to registry
uses: docker/build-push-action@v3
with:
creds: ${{ secrets.AZURE_CREDENTIALS }}
push: true
tags: syncrow.azurecr.io/${{ secrets.AzureAppService_ContainerUsername_47395803300340b49931ea82f6d80be3 }}/syncrow/backend:${{ github.sha }}
file: ./Dockerfile
- name: Log in to Azure Container Registry
run: az acr login --name ${{ env.ACR_REGISTRY }}
deploy:
runs-on: ubuntu-latest
needs: build
environment:
name: 'staging'
url: ${{ steps.deploy-to-webapp.outputs.webapp-url }}
- name: List build output
run: ls -R dist/
- name: Build and push Docker image
run: |
docker build . -t ${{ env.ACR_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }}
docker push ${{ env.ACR_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }}
- name: Set Web App with Docker container
run: |
az webapp config container set \
--name ${{ env.AZURE_WEB_APP_NAME }} \
--resource-group backend \
--docker-custom-image-name ${{ env.ACR_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }} \
--docker-registry-server-url https://${{ env.ACR_REGISTRY }}
steps:
- name: Deploy to Azure Web App
id: deploy-to-webapp
uses: azure/webapps-deploy@v2
with:
app-name: 'syncrow'
slot-name: 'staging'
publish-profile: ${{ secrets.AzureAppService_PublishProfile_44f7766441ec4796b74789e9761ef589 }}
images: 'syncrow.azurecr.io/${{ secrets.AzureAppService_ContainerUsername_47395803300340b49931ea82f6d80be3 }}/syncrow/backend:${{ github.sha }}'

73
.github/workflows/main_syncrow(stg).yml vendored Normal file
View File

@ -0,0 +1,73 @@
# Docs for the Azure Web Apps Deploy action: https://github.com/Azure/webapps-deploy
# More GitHub Actions for Azure: https://github.com/Azure/actions
name: Build and deploy Node.js app to Azure Web App - syncrow
on:
push:
branches:
- main
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read #This is required for actions/checkout
steps:
- uses: actions/checkout@v4
- name: Set up Node.js version
uses: actions/setup-node@v3
with:
node-version: '20.x'
- name: npm install, build, and test
run: |
npm install
npm run build --if-present
npm run test --if-present
- name: Zip artifact for deployment
run: zip release.zip ./* -r
- name: Upload artifact for deployment job
uses: actions/upload-artifact@v4
with:
name: node-app
path: release.zip
deploy:
runs-on: ubuntu-latest
needs: build
environment:
name: 'stg'
url: ${{ steps.deploy-to-webapp.outputs.webapp-url }}
permissions:
id-token: write #This is required for requesting the JWT
contents: read #This is required for actions/checkout
steps:
- name: Download artifact from build job
uses: actions/download-artifact@v4
with:
name: node-app
- name: Unzip artifact for deployment
run: unzip release.zip
- name: Login to Azure
uses: azure/login@v2
with:
client-id: ${{ secrets.AZUREAPPSERVICE_CLIENTID_515C8E782CFF431AB20448C85CA0FE58 }}
tenant-id: ${{ secrets.AZUREAPPSERVICE_TENANTID_2AEFE5534424490387C08FAE41573CC2 }}
subscription-id: ${{ secrets.AZUREAPPSERVICE_SUBSCRIPTIONID_00623C33023749FEA5F6BC36884F9C8A }}
- name: 'Deploy to Azure Web App'
id: deploy-to-webapp
uses: azure/webapps-deploy@v3
with:
app-name: 'syncrow'
slot-name: 'stg'
package: .

7
.gitignore vendored
View File

@ -58,4 +58,9 @@ pids
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
config.dev
config.dev
cdk.out
backend-cdk-new.out
web-cdk.out
backend-cdk.out
backend-cdk-final.out

View File

@ -1,16 +1,28 @@
FROM node:20-alpine
FROM --platform=linux/amd64 node:20-alpine
# curl for health checks
RUN apk add --no-cache curl
WORKDIR /app
COPY package*.json ./
RUN npm install
RUN npm install -g @nestjs/cli
RUN npm install --production --ignore-scripts
COPY . .
RUN npm run build
EXPOSE 4000
RUN addgroup -g 1001 -S nodejs
RUN adduser -S nestjs -u 1001
CMD ["npm", "run", "start"]
RUN chown -R nestjs:nodejs /app
USER nestjs
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost:3000/health || exit 1
CMD ["npm", "run", "start:prod"]

119
GITHUB_SETUP.md Normal file
View File

@ -0,0 +1,119 @@
# GitHub Actions Setup Guide
## Required GitHub Secrets
Add these secrets to your GitHub repository (Settings > Secrets and variables > Actions):
### AWS Credentials
```
AWS_ACCESS_KEY_ID=your-aws-access-key
AWS_SECRET_ACCESS_KEY=your-aws-secret-key
```
### JWT Configuration (CRITICAL - Generate secure random strings)
```
JWT_SECRET=your-super-secure-jwt-secret-key-here
JWT_SECRET_REFRESH=your-super-secure-refresh-secret-key-here
SECRET_KEY=your-general-encryption-secret-key-here
```
### Admin Configuration
```
SUPER_ADMIN_EMAIL=admin@syncrow.ae
SUPER_ADMIN_PASSWORD=YourSecureAdminPassword123!
```
### Tuya IoT Configuration
```
TUYA_ACCESS_ID=your-tuya-access-id
TUYA_ACCESS_KEY=your-tuya-access-key
TRUN_ON_TUYA_SOCKET=true-or-false
```
### Firebase Configuration
```
FIREBASE_API_KEY=your-firebase-api-key
FIREBASE_AUTH_DOMAIN=your-project.firebaseapp.com
FIREBASE_PROJECT_ID=your-project-id
FIREBASE_STORAGE_BUCKET=your-project.appspot.com
FIREBASE_MESSAGING_SENDER_ID=your-sender-id
FIREBASE_APP_ID=your-app-id
FIREBASE_MEASUREMENT_ID=your-measurement-id
FIREBASE_DATABASE_URL=https://your-project.firebaseio.com
```
### Google OAuth
```
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
```
### OneSignal Push Notifications
```
ONESIGNAL_APP_ID=your-onesignal-app-id
ONESIGNAL_API_KEY=your-onesignal-api-key
```
### Email Configuration (SMTP)
```
SMTP_HOST=your-smtp-host
SMTP_USER=your-smtp-username
SMTP_PASSWORD=your-smtp-password
```
### Mailtrap Configuration
```
MAILTRAP_API_TOKEN=your-mailtrap-api-token
MAILTRAP_ENABLE_TEMPLATE_UUID=template-uuid
MAILTRAP_DISABLE_TEMPLATE_UUID=template-uuid
MAILTRAP_INVITATION_TEMPLATE_UUID=template-uuid
MAILTRAP_DELETE_USER_TEMPLATE_UUID=template-uuid
MAILTRAP_EDIT_USER_TEMPLATE_UUID=template-uuid
```
### Optional Services (leave empty if not used)
```
AZURE_REDIS_CONNECTIONSTRING=your-redis-connection-string
DOPPLER_PROJECT=your-doppler-project
DOPPLER_CONFIG=your-doppler-config
DOPPLER_ENVIRONMENT=your-doppler-environment
ACCESS_KEY=your-access-key
DOCKER_REGISTRY_SERVER_URL=your-registry-url
DOCKER_REGISTRY_SERVER_USERNAME=your-registry-username
DOCKER_REGISTRY_SERVER_PASSWORD=your-registry-password
```
## Setup Steps
1. **Add AWS Credentials**
- Create IAM user with ECR, ECS, CloudFormation permissions
- Add AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY to GitHub Secrets
2. **Generate JWT Secrets**
- Use a secure random string generator
- Make JWT_SECRET and JWT_SECRET_REFRESH different values
- Keep these values secure and never share them
3. **Configure Services**
- Add secrets for each service you're using
- Leave unused services empty (they'll default to empty strings)
4. **Test Deployment**
- Push to master/main branch
- Check GitHub Actions tab for deployment status
- Verify API is accessible at https://api.syncrow.me
## Security Notes
- Never commit secrets to the repository
- Use GitHub Secrets for all sensitive values
- Rotate secrets regularly
- Monitor GitHub Actions logs for any exposed values
- Database password is automatically managed by AWS Secrets Manager
## Troubleshooting
- Check GitHub Actions logs for deployment errors
- Verify all required secrets are set
- Ensure AWS credentials have sufficient permissions
- Check ECS service logs in CloudWatch for runtime errors

View File

@ -107,3 +107,29 @@ $ npm run test:cov
| | Standby Node | | |
| +------------------+----------------+ |
+-----------------------------------------------------------------+
## CDK Deployment
• Bootstrap CDK (first time only): npx cdk bootstrap aws://482311766496/me-central-1
• List available stacks: npx cdk list
• Deploy infrastructure: npx cdk deploy --require-approval never
• View changes before deploy: npx cdk diff
• Generate CloudFormation template: npx cdk synth
• Destroy infrastructure: npx cdk destroy
• Environment variables are configured in infrastructure/stack.ts
• After code changes: build Docker image, push to ECR, force ECS deployment
• Database seeding happens automatically on first deployment with DB_SYNC=true
• Admin credentials: admin@syncrow.ae / YourSecureAdminPassword123!
• Production API: https://api.syncrow.me
• Health check: https://api.syncrow.me/health
## GitHub Actions Deployment
• Automatic deployment on push to master/main branch
• Configure GitHub Secrets (see GITHUB_SETUP.md for complete list)
• Required secrets: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, JWT_SECRET, JWT_SECRET_REFRESH
• Workflow builds Docker image, pushes to ECR, and deploys CDK stack
• Environment variables are passed securely via GitHub Secrets
• Manual deployment: Go to Actions tab and run "Deploy Backend to AWS" workflow
• Check deployment status in GitHub Actions tab
• Logs available in CloudWatch under /ecs/syncrow-backend log group

25
cdk.context.json Normal file
View File

@ -0,0 +1,25 @@
{
"availability-zones:account=426265406140:region=us-east-2": [
"us-east-2a",
"us-east-2b",
"us-east-2c"
],
"availability-zones:account=482311766496:region=us-east-2": [
"us-east-2a",
"us-east-2b",
"us-east-2c"
],
"hosted-zone:account=482311766496:domainName=syncrow.me:region=us-east-2": {
"Id": "/hostedzone/Z02085662NLJECF4DGJV3",
"Name": "syncrow.me."
},
"availability-zones:account=482311766496:region=me-central-1": [
"me-central-1a",
"me-central-1b",
"me-central-1c"
],
"hosted-zone:account=482311766496:domainName=syncrow.me:region=me-central-1": {
"Id": "/hostedzone/Z02085662NLJECF4DGJV3",
"Name": "syncrow.me."
}
}

58
cdk.json Normal file
View File

@ -0,0 +1,58 @@
{
"app": "npx ts-node --prefer-ts-exts infrastructure/app.ts",
"watch": {
"include": [
"**"
],
"exclude": [
"README.md",
"cdk*.json",
"**/*.d.ts",
"**/*.js",
"tsconfig.json",
"package*.json",
"yarn.lock",
"node_modules",
"test"
]
},
"context": {
"@aws-cdk/aws-lambda:recognizeLayerVersion": true,
"@aws-cdk/core:checkSecretUsage": true,
"@aws-cdk/core:target-partitions": [
"aws",
"aws-cn"
],
"@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true,
"@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true,
"@aws-cdk/aws-ecs:arnFormatIncludesClusterName": true,
"@aws-cdk/aws-iam:minimizePolicies": true,
"@aws-cdk/core:validateSnapshotRemovalPolicy": true,
"@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName": true,
"@aws-cdk/aws-s3:createDefaultLoggingPolicy": true,
"@aws-cdk/aws-sns-subscriptions:restrictSqsDescryption": true,
"@aws-cdk/aws-apigateway:disableCloudWatchRole": true,
"@aws-cdk/core:enablePartitionLiterals": true,
"@aws-cdk/aws-events:eventsTargetQueueSameAccount": true,
"@aws-cdk/aws-iam:standardizedServicePrincipals": true,
"@aws-cdk/aws-ecs:disableExplicitDeploymentControllerForCircuitBreaker": true,
"@aws-cdk/aws-iam:importedRoleStackSafeDefaultPolicyName": true,
"@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": true,
"@aws-cdk/aws-route53-patters:useCertificate": true,
"@aws-cdk/customresources:installLatestAwsSdkDefault": false,
"@aws-cdk/aws-rds:databaseProxyUniqueResourceName": true,
"@aws-cdk/aws-codedeploy:removeAlarmsFromDeploymentGroup": true,
"@aws-cdk/aws-apigateway:authorizerChangeDeploymentLogicalId": true,
"@aws-cdk/aws-ec2:launchTemplateDefaultUserData": true,
"@aws-cdk/aws-secretsmanager:useAttachedSecretResourcePolicyForSecretTargetAttachments": true,
"@aws-cdk/aws-redshift:columnId": true,
"@aws-cdk/aws-stepfunctions-tasks:enableLogging": true,
"@aws-cdk/aws-ec2:restrictDefaultSecurityGroup": true,
"@aws-cdk/aws-apigateway:requestValidatorUniqueId": true,
"@aws-cdk/aws-kms:aliasNameRef": true,
"@aws-cdk/aws-autoscaling:generateLaunchTemplateInsteadOfLaunchConfig": true,
"@aws-cdk/aws-ecs:removeDefaultDeploymentAlarm": true,
"@aws-cdk/aws-rds:preventRenderingDeprecatedCredentials": true,
"@aws-cdk/aws-codepipeline-actions:useNewDefaultBranchForSourceAction": true
}
}

22
deploy.sh Executable file
View File

@ -0,0 +1,22 @@
#!/bin/bash
set -e
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
REGION=${AWS_DEFAULT_REGION:-me-central-1}
npx cdk deploy SyncrowBackendStack --context certificateArn=arn:aws:acm:me-central-1:482311766496:certificate/bea1e2ae-84a1-414e-8dbf-4599397e7ed0 --require-approval never
aws ecr get-login-password --region $REGION | docker login --username AWS --password-stdin $ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com
docker build --platform=linux/amd64 -t syncrow-backend .
docker tag syncrow-backend:latest $ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com/syncrow-backend:latest
docker push $ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com/syncrow-backend:latest
SERVICE_ARN=$(aws ecs list-services --cluster syncrow-backend-cluster --query 'serviceArns[0]' --output text --region $REGION 2>/dev/null || echo "")
if [ "$SERVICE_ARN" != "" ] && [ "$SERVICE_ARN" != "None" ]; then
SERVICE_NAME=$(echo $SERVICE_ARN | cut -d'/' -f3)
aws ecs update-service --cluster syncrow-backend-cluster --service $SERVICE_NAME --force-new-deployment --region $REGION
else
npx cdk deploy SyncrowBackendStack --context certificateArn=arn:aws:acm:me-central-1:482311766496:certificate/bea1e2ae-84a1-414e-8dbf-4599397e7ed0 --require-approval never
fi

15
infrastructure/app.ts Normal file
View File

@ -0,0 +1,15 @@
#!/usr/bin/env node
import 'source-map-support/register';
import * as cdk from 'aws-cdk-lib';
import { BackendStack } from './stack';
const app = new cdk.App();
new BackendStack(app, 'SyncrowBackendStack', {
env: {
account: process.env.CDK_DEFAULT_ACCOUNT,
region: 'me-central-1',
},
databaseName: 'syncrow',
certificateArn: 'arn:aws:acm:me-central-1:482311766496:certificate/bea1e2ae-84a1-414e-8dbf-4599397e7ed0',
});

342
infrastructure/stack.ts Normal file
View File

@ -0,0 +1,342 @@
import * as cdk from 'aws-cdk-lib';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as ecs from 'aws-cdk-lib/aws-ecs';
import * as ecsPatterns from 'aws-cdk-lib/aws-ecs-patterns';
import * as rds from 'aws-cdk-lib/aws-rds';
import * as ecr from 'aws-cdk-lib/aws-ecr';
import * as logs from 'aws-cdk-lib/aws-logs';
import * as elbv2 from 'aws-cdk-lib/aws-elasticloadbalancingv2';
import * as acm from 'aws-cdk-lib/aws-certificatemanager';
import * as route53 from 'aws-cdk-lib/aws-route53';
import { Construct } from 'constructs';
import * as dotenv from 'dotenv';
export interface BackendStackProps extends cdk.StackProps {
vpcId?: string;
databaseName?: string;
certificateArn?: string;
}
export class BackendStack extends cdk.Stack {
public readonly apiUrl: string;
public readonly databaseEndpoint: string;
public readonly vpc: ec2.IVpc;
constructor(scope: Construct, id: string, props?: BackendStackProps) {
super(scope, id, props);
// Load environment variables from .env file
dotenv.config({ path: '.env' });
// VPC - either use existing or create new
this.vpc = props?.vpcId
? ec2.Vpc.fromLookup(this, 'ExistingVpc', { vpcId: props.vpcId })
: new ec2.Vpc(this, 'SyncrowVpc', {
maxAzs: 2,
natGateways: 1,
subnetConfiguration: [
{
cidrMask: 24,
name: 'public',
subnetType: ec2.SubnetType.PUBLIC,
},
{
cidrMask: 24,
name: 'private',
subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
},
],
});
// Security Groups
const dbSecurityGroup = new ec2.SecurityGroup(this, 'DatabaseSecurityGroup', {
vpc: this.vpc,
description: 'Security group for RDS PostgreSQL',
allowAllOutbound: false,
});
const ecsSecurityGroup = new ec2.SecurityGroup(this, 'EcsSecurityGroup', {
vpc: this.vpc,
description: 'Security group for ECS Fargate service',
allowAllOutbound: true,
});
const albSecurityGroup = new ec2.SecurityGroup(this, 'AlbSecurityGroup', {
vpc: this.vpc,
description: 'Security group for Application Load Balancer',
allowAllOutbound: true,
});
// Allow ALB to connect to ECS
ecsSecurityGroup.addIngressRule(
albSecurityGroup,
ec2.Port.tcp(3000),
'Allow ALB to connect to ECS service'
);
// Allow ECS to connect to RDS
dbSecurityGroup.addIngressRule(
ecsSecurityGroup,
ec2.Port.tcp(5432),
'Allow ECS to connect to PostgreSQL'
);
// Temporary access for admin IP
dbSecurityGroup.addIngressRule(
ec2.Peer.ipv4('216.126.231.231/32'),
ec2.Port.tcp(5432),
'Temporary access from admin IP'
);
// Allow HTTP/HTTPS traffic to ALB
albSecurityGroup.addIngressRule(
ec2.Peer.anyIpv4(),
ec2.Port.tcp(80),
'Allow HTTP traffic'
);
albSecurityGroup.addIngressRule(
ec2.Peer.anyIpv4(),
ec2.Port.tcp(443),
'Allow HTTPS traffic'
);
// RDS Aurora Serverless v2 PostgreSQL
const dbCluster = new rds.DatabaseCluster(this, 'SyncrowDatabase', {
engine: rds.DatabaseClusterEngine.auroraPostgres({
version: rds.AuroraPostgresEngineVersion.VER_15_4,
}),
vpc: this.vpc,
securityGroups: [dbSecurityGroup],
serverlessV2MinCapacity: 0.5,
serverlessV2MaxCapacity: 4,
writer: rds.ClusterInstance.serverlessV2('writer'),
defaultDatabaseName: props?.databaseName || 'syncrow',
credentials: rds.Credentials.fromGeneratedSecret('syncrowadmin', {
secretName: 'syncrow-db-credentials',
}),
removalPolicy: cdk.RemovalPolicy.DESTROY,
});
// ECR Repository for Docker images - ensure it's in the correct region
const ecrRepository = new ecr.Repository(this, 'SyncrowBackendRepo', {
repositoryName: 'syncrow-backend',
removalPolicy: cdk.RemovalPolicy.DESTROY,
emptyOnDelete: true,
});
// Output the correct ECR URI for this region
new cdk.CfnOutput(this, 'EcrRepositoryUriRegional', {
value: ecrRepository.repositoryUri,
description: `ECR Repository URI in region ${this.region}`,
exportName: `${this.stackName}-EcrRepositoryUriRegional`,
});
// ECS Cluster
const cluster = new ecs.Cluster(this, 'SyncrowCluster', {
vpc: this.vpc,
clusterName: 'syncrow-backend-cluster',
});
// CloudWatch Log Group
const logGroup = new logs.LogGroup(this, 'SyncrowBackendLogs', {
logGroupName: '/ecs/syncrow-backend',
retention: logs.RetentionDays.ONE_WEEK,
removalPolicy: cdk.RemovalPolicy.DESTROY,
});
// Use existing wildcard certificate or create new one
const apiCertificate = props?.certificateArn
? acm.Certificate.fromCertificateArn(this, 'ApiCertificate', props.certificateArn)
: new acm.Certificate(this, 'ApiCertificate', {
domainName: 'api.syncrow.me',
validation: acm.CertificateValidation.fromDns(),
});
// ECS Fargate Service with Application Load Balancer
const fargateService = new ecsPatterns.ApplicationLoadBalancedFargateService(this, 'SyncrowBackendService', {
cluster,
memoryLimitMiB: 1024,
cpu: 512,
desiredCount: 1,
domainName: 'api.syncrow.me',
domainZone: route53.HostedZone.fromLookup(this, 'SyncrowZone', {
domainName: 'syncrow.me',
}),
certificate: apiCertificate,
protocol: elbv2.ApplicationProtocol.HTTPS,
redirectHTTP: true,
taskImageOptions: {
image: ecs.ContainerImage.fromEcrRepository(ecrRepository, 'latest'),
containerPort: 3000,
enableLogging: true,
environment: {
// App settings
NODE_ENV: process.env.NODE_ENV || 'production',
PORT: process.env.PORT || '3000',
BASE_URL: process.env.BASE_URL || '',
// Database connection (CDK provides these automatically)
AZURE_POSTGRESQL_HOST: dbCluster.clusterEndpoint.hostname,
AZURE_POSTGRESQL_PORT: '5432',
AZURE_POSTGRESQL_DATABASE: props?.databaseName || 'syncrow',
AZURE_POSTGRESQL_USER: 'syncrowadmin',
AZURE_POSTGRESQL_SSL: process.env.AZURE_POSTGRESQL_SSL || 'false',
AZURE_POSTGRESQL_SYNC: process.env.AZURE_POSTGRESQL_SYNC || 'false',
// JWT Configuration - CRITICAL: These must be set
JWT_SECRET: process.env.JWT_SECRET || 'syncrow-jwt-secret-key-2025-production-environment-very-secure-random-string',
JWT_SECRET_REFRESH: process.env.JWT_SECRET_REFRESH || 'syncrow-refresh-secret-key-2025-production-environment-different-secure-string',
JWT_EXPIRE_TIME: process.env.JWT_EXPIRE_TIME || '1h',
JWT_EXPIRE_TIME_REFRESH: process.env.JWT_EXPIRE_TIME_REFRESH || '7d',
// Firebase Configuration
FIREBASE_API_KEY: process.env.FIREBASE_API_KEY || '',
FIREBASE_AUTH_DOMAIN: process.env.FIREBASE_AUTH_DOMAIN || '',
FIREBASE_PROJECT_ID: process.env.FIREBASE_PROJECT_ID || '',
FIREBASE_STORAGE_BUCKET: process.env.FIREBASE_STORAGE_BUCKET || '',
FIREBASE_MESSAGING_SENDER_ID: process.env.FIREBASE_MESSAGING_SENDER_ID || '',
FIREBASE_APP_ID: process.env.FIREBASE_APP_ID || '',
FIREBASE_MEASUREMENT_ID: process.env.FIREBASE_MEASUREMENT_ID || '',
FIREBASE_DATABASE_URL: process.env.FIREBASE_DATABASE_URL || '',
// Tuya IoT Configuration
TUYA_EU_URL: process.env.TUYA_EU_URL || 'https://openapi.tuyaeu.com',
TUYA_ACCESS_ID: process.env.TUYA_ACCESS_ID || '',
TUYA_ACCESS_KEY: process.env.TUYA_ACCESS_KEY || '',
TRUN_ON_TUYA_SOCKET: process.env.TRUN_ON_TUYA_SOCKET || '',
// Email Configuration
SMTP_HOST: process.env.SMTP_HOST || '',
SMTP_PORT: process.env.SMTP_PORT || '587',
SMTP_SECURE: process.env.SMTP_SECURE || 'true',
SMTP_USER: process.env.SMTP_USER || '',
SMTP_PASSWORD: process.env.SMTP_PASSWORD || '',
// Mailtrap Configuration
MAILTRAP_API_TOKEN: process.env.MAILTRAP_API_TOKEN || '',
MAILTRAP_INVITATION_TEMPLATE_UUID: process.env.MAILTRAP_INVITATION_TEMPLATE_UUID || '',
MAILTRAP_EDIT_USER_TEMPLATE_UUID: process.env.MAILTRAP_EDIT_USER_TEMPLATE_UUID || '',
MAILTRAP_DISABLE_TEMPLATE_UUID: process.env.MAILTRAP_DISABLE_TEMPLATE_UUID || '',
MAILTRAP_ENABLE_TEMPLATE_UUID: process.env.MAILTRAP_ENABLE_TEMPLATE_UUID || '',
MAILTRAP_DELETE_USER_TEMPLATE_UUID: process.env.MAILTRAP_DELETE_USER_TEMPLATE_UUID || '',
// OneSignal Push Notifications
ONESIGNAL_APP_ID: process.env.ONESIGNAL_APP_ID || '',
ONESIGNAL_API_KEY: process.env.ONESIGNAL_API_KEY || '',
// Admin Configuration
SUPER_ADMIN_EMAIL: process.env.SUPER_ADMIN_EMAIL || 'admin@yourdomain.com',
SUPER_ADMIN_PASSWORD: process.env.SUPER_ADMIN_PASSWORD || 'YourSecureAdminPassword123!',
// Google OAuth
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID || '',
GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET || '',
// Other Configuration
OTP_LIMITER: process.env.OTP_LIMITER || '5',
SECRET_KEY: process.env.SECRET_KEY || 'another-random-secret-key-for-general-encryption',
ACCESS_KEY: process.env.ACCESS_KEY || '',
DB_SYNC: process.env.DB_SYNC || 'txsrue',
// Redis (used?)
AZURE_REDIS_CONNECTIONSTRING: process.env.AZURE_REDIS_CONNECTIONSTRING || '',
// Docker Registry (for deployment)
DOCKER_REGISTRY_SERVER_URL: process.env.DOCKER_REGISTRY_SERVER_URL || '',
DOCKER_REGISTRY_SERVER_USERNAME: process.env.DOCKER_REGISTRY_SERVER_USERNAME || '',
DOCKER_REGISTRY_SERVER_PASSWORD: process.env.DOCKER_REGISTRY_SERVER_PASSWORD || '',
// Doppler (if used for secrets management)
DOPPLER_PROJECT: process.env.DOPPLER_PROJECT || '',
DOPPLER_CONFIG: process.env.DOPPLER_CONFIG || '',
DOPPLER_ENVIRONMENT: process.env.DOPPLER_ENVIRONMENT || '',
// Azure specific
WEBSITES_ENABLE_APP_SERVICE_STORAGE: process.env.WEBSITES_ENABLE_APP_SERVICE_STORAGE || 'false',
},
secrets: {
AZURE_POSTGRESQL_PASSWORD: ecs.Secret.fromSecretsManager(
dbCluster.secret!,
'password'
),
},
logDriver: ecs.LogDrivers.awsLogs({
streamPrefix: 'syncrow-backend',
logGroup,
}),
},
publicLoadBalancer: true,
securityGroups: [ecsSecurityGroup],
});
// Add security group to load balancer after creation
fargateService.loadBalancer.addSecurityGroup(albSecurityGroup);
// Configure health check
fargateService.targetGroup.configureHealthCheck({
path: '/health',
healthyHttpCodes: '200',
interval: cdk.Duration.seconds(30),
timeout: cdk.Duration.seconds(5),
healthyThresholdCount: 2,
unhealthyThresholdCount: 3,
});
// Auto Scaling
const scalableTarget = fargateService.service.autoScaleTaskCount({
minCapacity: 1,
maxCapacity: 10,
});
scalableTarget.scaleOnCpuUtilization('CpuScaling', {
targetUtilizationPercent: 70,
scaleInCooldown: cdk.Duration.minutes(5),
scaleOutCooldown: cdk.Duration.minutes(2),
});
scalableTarget.scaleOnMemoryUtilization('MemoryScaling', {
targetUtilizationPercent: 80,
scaleInCooldown: cdk.Duration.minutes(5),
scaleOutCooldown: cdk.Duration.minutes(2),
});
// Grant ECS task access to RDS credentials
if (dbCluster.secret) {
dbCluster.secret.grantRead(fargateService.taskDefinition.taskRole);
}
this.apiUrl = 'https://api.syncrow.me';
this.databaseEndpoint = dbCluster.clusterEndpoint.hostname;
// Outputs
new cdk.CfnOutput(this, 'ApiUrl', {
value: this.apiUrl,
description: 'Application Load Balancer URL',
exportName: `${this.stackName}-ApiUrl`,
});
new cdk.CfnOutput(this, 'DatabaseEndpoint', {
value: this.databaseEndpoint,
description: 'RDS Cluster Endpoint',
exportName: `${this.stackName}-DatabaseEndpoint`,
});
new cdk.CfnOutput(this, 'EcrRepositoryUri', {
value: ecrRepository.repositoryUri,
description: 'ECR Repository URI',
exportName: `${this.stackName}-EcrRepositoryUri`,
});
new cdk.CfnOutput(this, 'ClusterName', {
value: cluster.clusterName,
description: 'ECS Cluster Name',
exportName: `${this.stackName}-ClusterName`,
});
new cdk.CfnOutput(this, 'ServiceName', {
value: fargateService.service.serviceName,
description: 'ECS Service Name',
exportName: `${this.stackName}-ServiceName`,
});
}
}

View File

@ -397,6 +397,11 @@ export class ControllerRoute {
public static readonly DELETE_USER_SUMMARY = 'Delete user by UUID';
public static readonly DELETE_USER_DESCRIPTION =
'This endpoint deletes a user identified by their UUID. Accessible only by users with the Super Admin role.';
public static readonly DELETE_USER_PROFILE_SUMMARY =
'Delete user profile by UUID';
public static readonly DELETE_USER_PROFILE_DESCRIPTION =
'This endpoint deletes a user profile identified by their UUID. Accessible only by users with the Super Admin role.';
public static readonly UPDATE_USER_WEB_AGREEMENT_SUMMARY =
'Update user web agreement by user UUID';
public static readonly UPDATE_USER_WEB_AGREEMENT_DESCRIPTION =
@ -501,7 +506,6 @@ export class ControllerRoute {
};
static PowerClamp = class {
public static readonly ROUTE = 'power-clamp';
static ACTIONS = class {
public static readonly GET_ENERGY_SUMMARY =
'Get power clamp historical data';

View File

@ -25,7 +25,6 @@ import {
InviteUserEntity,
InviteUserSpaceEntity,
} from '../modules/Invite-user/entities';
import { SpaceDailyOccupancyDurationEntity } from '../modules/occupancy/entities';
import {
PowerClampDailyEntity,
PowerClampHourlyEntity,
@ -47,6 +46,7 @@ import {
SubspaceModelProductAllocationEntity,
} from '../modules/space-model/entities';
import { InviteSpaceEntity } from '../modules/space/entities/invite-space.entity';
import { SpaceLinkEntity } from '../modules/space/entities/space-link.entity';
import { SpaceProductAllocationEntity } from '../modules/space/entities/space-product-allocation.entity';
import { SpaceEntity } from '../modules/space/entities/space.entity';
import { SubspaceProductAllocationEntity } from '../modules/space/entities/subspace/subspace-product-allocation.entity';
@ -58,6 +58,7 @@ import {
UserSpaceEntity,
} from '../modules/user/entities';
import { VisitorPasswordEntity } from '../modules/visitor-password/entities';
import { SpaceDailyOccupancyDurationEntity } from '../modules/occupancy/entities';
@Module({
imports: [
TypeOrmModule.forRootAsync({
@ -86,6 +87,7 @@ import { VisitorPasswordEntity } from '../modules/visitor-password/entities';
PermissionTypeEntity,
CommunityEntity,
SpaceEntity,
SpaceLinkEntity,
SubspaceEntity,
UserSpaceEntity,
DeviceUserPermissionEntity,
@ -125,8 +127,8 @@ import { VisitorPasswordEntity } from '../modules/visitor-password/entities';
logger: typeOrmLogger,
extra: {
charset: 'utf8mb4',
max: 100, // set pool max size
idleTimeoutMillis: 3000, // close idle clients after 5 second
max: 20, // set pool max size
idleTimeoutMillis: 5000, // close idle clients after 5 second
connectionTimeoutMillis: 12_000, // return an error after 11 second if connection could not be established
maxUses: 7500, // close (and replace) a connection after it has been used 7500 times (see below for discussion)
},

View File

@ -1,9 +1,10 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsBoolean, IsOptional } from 'class-validator';
import { BooleanValues } from '../constants/boolean-values.enum';
import { IsBoolean, IsDate, IsOptional } from 'class-validator';
import { IsPageRequestParam } from '../validators/is-page-request-param.validator';
import { ApiProperty } from '@nestjs/swagger';
import { IsSizeRequestParam } from '../validators/is-size-request-param.validator';
import { Transform } from 'class-transformer';
import { parseToDate } from '../util/parseToDate';
import { BooleanValues } from '../constants/boolean-values.enum';
export class PaginationRequestGetListDto {
@ApiProperty({
@ -18,7 +19,6 @@ export class PaginationRequestGetListDto {
return value.obj.includeSpaces === BooleanValues.TRUE;
})
public includeSpaces?: boolean = false;
@IsOptional()
@IsPageRequestParam({
message: 'Page must be bigger than 0',
@ -40,4 +40,40 @@ export class PaginationRequestGetListDto {
description: 'Size request',
})
size?: number;
@IsOptional()
@ApiProperty({
name: 'name',
required: false,
description: 'Name to be filtered',
})
name?: string;
@ApiProperty({
name: 'from',
required: false,
type: Number,
description: `Start time in UNIX timestamp format to filter`,
example: 1674172800000,
})
@IsOptional()
@Transform(({ value }) => parseToDate(value))
@IsDate({
message: `From must be in UNIX timestamp format in order to parse to Date instance`,
})
from?: Date;
@ApiProperty({
name: 'to',
required: false,
type: Number,
description: `End time in UNIX timestamp format to filter`,
example: 1674259200000,
})
@IsOptional()
@Transform(({ value }) => parseToDate(value))
@IsDate({
message: `To must be in UNIX timestamp format in order to parse to Date instance`,
})
to?: Date;
}

View File

@ -3,12 +3,28 @@ import { DeviceStatusFirebaseController } from './controllers/devices-status.con
import { DeviceStatusFirebaseService } from './services/devices-status.service';
import { DeviceRepository } from '@app/common/modules/device/repositories';
import { DeviceStatusLogRepository } from '@app/common/modules/device-status-log/repositories/device-status.repository';
import { PowerClampService } from '@app/common/helper/services/power.clamp.service';
import {
PowerClampHourlyRepository,
PowerClampDailyRepository,
PowerClampMonthlyRepository,
} from '@app/common/modules/power-clamp/repositories';
import { SqlLoaderService } from '@app/common/helper/services/sql-loader.service';
import { OccupancyService } from '@app/common/helper/services/occupancy.service';
import { AqiDataService } from '@app/common/helper/services/aqi.data.service';
@Module({
providers: [
DeviceStatusFirebaseService,
DeviceRepository,
DeviceStatusLogRepository,
PowerClampService,
PowerClampHourlyRepository,
PowerClampDailyRepository,
PowerClampMonthlyRepository,
SqlLoaderService,
OccupancyService,
AqiDataService,
],
controllers: [DeviceStatusFirebaseController],
exports: [DeviceStatusFirebaseService, DeviceStatusLogRepository],

View File

@ -13,6 +13,7 @@ class StatusDto {
@IsNotEmpty()
value: any;
t?: string | number | Date;
}
export class AddDeviceStatusDto {

View File

@ -18,13 +18,24 @@ import {
runTransaction,
} from 'firebase/database';
import { DeviceStatusLogRepository } from '@app/common/modules/device-status-log/repositories';
import { ProductType } from '@app/common/constants/product-type.enum';
import { PowerClampService } from '@app/common/helper/services/power.clamp.service';
import { PowerClampEnergyEnum } from '@app/common/constants/power.clamp.enargy.enum';
import { PresenceSensorEnum } from '@app/common/constants/presence.sensor.enum';
import { OccupancyService } from '@app/common/helper/services/occupancy.service';
import { AqiDataService } from '@app/common/helper/services/aqi.data.service';
@Injectable()
export class DeviceStatusFirebaseService {
private tuya: TuyaContext;
private firebaseDb: Database;
private readonly isDevEnv: boolean;
constructor(
private readonly configService: ConfigService,
private readonly deviceRepository: DeviceRepository,
private readonly powerClampService: PowerClampService,
private readonly occupancyService: OccupancyService,
private readonly aqiDataService: AqiDataService,
private deviceStatusLogRepository: DeviceStatusLogRepository,
) {
const accessKey = this.configService.get<string>('auth-config.ACCESS_KEY');
@ -37,7 +48,14 @@ export class DeviceStatusFirebaseService {
});
// Initialize firebaseDb using firebaseDataBase function
this.firebaseDb = firebaseDataBase(this.configService);
try {
this.firebaseDb = firebaseDataBase(this.configService);
} catch (error) {
console.warn('Firebase initialization failed, continuing without Firebase:', error.message);
this.firebaseDb = null;
}
this.isDevEnv =
this.configService.get<string>('NODE_ENV') === 'development';
}
async addDeviceStatusByDeviceUuid(
deviceTuyaUuid: string,
@ -52,7 +70,7 @@ export class DeviceStatusFirebaseService {
const deviceStatusSaved = await this.createDeviceStatusFirebase({
deviceUuid: device.uuid,
deviceTuyaUuid: deviceTuyaUuid,
status: deviceStatus.status,
status: deviceStatus?.status,
productUuid: deviceStatus.productUuid,
productType: deviceStatus.productType,
});
@ -67,94 +85,25 @@ export class DeviceStatusFirebaseService {
);
}
}
async addBatchDeviceStatusToOurDb(
batch: {
deviceTuyaUuid: string;
status: any;
log: any;
device: any;
}[],
): Promise<void> {
const allLogs = [];
console.log(`🔁 Preparing logs from batch of ${batch.length} items...`);
for (const item of batch) {
const device = item.device;
if (!device?.uuid) {
console.log(`⛔ Skipped unknown device: ${item.deviceTuyaUuid}`);
continue;
}
const logs = item.log.properties.map((property) =>
this.deviceStatusLogRepository.create({
deviceId: device.uuid,
deviceTuyaId: item.deviceTuyaUuid,
productId: item.log.productId,
log: item.log,
code: property.code,
value: property.value,
eventId: item.log.dataId,
eventTime: new Date(property.time).toISOString(),
}),
);
allLogs.push(...logs);
}
console.log(`📝 Total logs to insert: ${allLogs.length}`);
const insertLogsPromise = (async () => {
const chunkSize = 300;
let insertedCount = 0;
for (let i = 0; i < allLogs.length; i += chunkSize) {
const chunk = allLogs.slice(i, i + chunkSize);
try {
const result = await this.deviceStatusLogRepository
.createQueryBuilder()
.insert()
.into('device-status-log') // or use DeviceStatusLogEntity
.values(chunk)
.orIgnore() // skip duplicates
.execute();
insertedCount += result.identifiers.length;
console.log(
`✅ Inserted ${result.identifiers.length} / ${chunk.length} logs (chunk)`,
);
} catch (error) {
console.error('❌ Insert error (skipped chunk):', error.message);
}
}
console.log(
`✅ Total logs inserted: ${insertedCount} / ${allLogs.length}`,
);
})();
await insertLogsPromise;
}
async addDeviceStatusToFirebase(
addDeviceStatusDto: AddDeviceStatusDto & { device?: any },
addDeviceStatusDto: AddDeviceStatusDto,
): Promise<AddDeviceStatusDto | null> {
try {
let device = addDeviceStatusDto.device;
if (!device) {
device = await this.getDeviceByDeviceTuyaUuid(
addDeviceStatusDto.deviceTuyaUuid,
);
}
const device = await this.getDeviceByDeviceTuyaUuid(
addDeviceStatusDto.deviceTuyaUuid,
);
if (device?.uuid) {
return await this.createDeviceStatusFirebase({
deviceUuid: device.uuid,
...addDeviceStatusDto,
productType: device.productDevice?.prodType,
productType: device.productDevice.prodType,
});
}
// Return null if device not found or no UUID
return null;
} catch (error) {
// Handle the error silently, perhaps log it internally or ignore it
return null;
}
}
@ -168,15 +117,6 @@ export class DeviceStatusFirebaseService {
relations: ['productDevice'],
});
}
async getAllDevices() {
return await this.deviceRepository.find({
where: {
isActive: true,
},
relations: ['productDevice'],
});
}
async getDevicesInstructionStatus(deviceUuid: string) {
try {
const deviceDetails = await this.getDeviceByDeviceUuid(deviceUuid);
@ -191,7 +131,7 @@ export class DeviceStatusFirebaseService {
return {
productUuid: deviceDetails.productDevice.uuid,
productType: deviceDetails.productDevice.prodType,
status: deviceStatus.result[0].status,
status: deviceStatus.result[0]?.status,
};
} catch (error) {
throw new HttpException(
@ -235,6 +175,14 @@ export class DeviceStatusFirebaseService {
async createDeviceStatusFirebase(
addDeviceStatusDto: AddDeviceStatusDto,
): Promise<any> {
// Check if Firebase is available
if (!this.firebaseDb) {
console.warn('Firebase not available, skipping Firebase operations');
// Still process the database logs but skip Firebase operations
await this.processDeviceStatusLogs(addDeviceStatusDto);
return { message: 'Device status processed without Firebase' };
}
const dataRef = ref(
this.firebaseDb,
`device-status/${addDeviceStatusDto.deviceUuid}`,
@ -256,18 +204,18 @@ export class DeviceStatusFirebaseService {
if (!existingData.productType) {
existingData.productType = addDeviceStatusDto.productType;
}
if (!existingData.status) {
if (!existingData?.status) {
existingData.status = [];
}
// Create a map to track existing status codes
const statusMap = new Map(
existingData.status.map((item) => [item.code, item.value]),
existingData?.status.map((item) => [item.code, item.value]),
);
// Update or add status codes
for (const statusItem of addDeviceStatusDto.status) {
for (const statusItem of addDeviceStatusDto?.status) {
statusMap.set(statusItem.code, statusItem.value);
}
@ -280,8 +228,251 @@ export class DeviceStatusFirebaseService {
return existingData;
});
if (this.isDevEnv) {
// Save logs to your repository
const newLogs = addDeviceStatusDto.log.properties.map((property) => {
return this.deviceStatusLogRepository.create({
deviceId: addDeviceStatusDto.deviceUuid,
deviceTuyaId: addDeviceStatusDto.deviceTuyaUuid,
productId: addDeviceStatusDto.log.productId,
log: addDeviceStatusDto.log,
code: property.code,
value: property.value,
eventId: addDeviceStatusDto.log.dataId,
eventTime: new Date(property.time).toISOString(),
});
});
await this.deviceStatusLogRepository.save(newLogs);
if (addDeviceStatusDto.productType === ProductType.PC) {
const energyCodes = new Set([
PowerClampEnergyEnum.ENERGY_CONSUMED,
PowerClampEnergyEnum.ENERGY_CONSUMED_A,
PowerClampEnergyEnum.ENERGY_CONSUMED_B,
PowerClampEnergyEnum.ENERGY_CONSUMED_C,
]);
const energyStatus = addDeviceStatusDto?.log?.properties?.find(
(status) => energyCodes.has(status.code),
);
if (energyStatus) {
await this.powerClampService.updateEnergyConsumedHistoricalData(
addDeviceStatusDto.deviceUuid,
);
}
}
if (
addDeviceStatusDto.productType === ProductType.CPS ||
addDeviceStatusDto.productType === ProductType.WPS
) {
const occupancyCodes = new Set([PresenceSensorEnum.PRESENCE_STATE]);
const occupancyStatus = addDeviceStatusDto?.log?.properties?.find(
(status) => occupancyCodes.has(status.code),
);
if (occupancyStatus) {
await this.occupancyService.updateOccupancySensorHistoricalData(
addDeviceStatusDto.deviceUuid,
);
await this.occupancyService.updateOccupancySensorHistoricalDurationData(
addDeviceStatusDto.deviceUuid,
);
}
}
if (addDeviceStatusDto.productType === ProductType.AQI) {
await this.aqiDataService.updateAQISensorHistoricalData(
addDeviceStatusDto.deviceUuid,
);
}
} else {
// Save logs to your repository
const newLogs = addDeviceStatusDto?.status.map((property) => {
return this.deviceStatusLogRepository.create({
deviceId: addDeviceStatusDto.deviceUuid,
deviceTuyaId: addDeviceStatusDto.deviceTuyaUuid,
productId: addDeviceStatusDto.log.productKey,
log: addDeviceStatusDto.log,
code: property.code,
value: property.value,
eventId: addDeviceStatusDto.log.dataId,
eventTime: new Date(property.t).toISOString(),
});
});
await this.deviceStatusLogRepository.save(newLogs);
if (addDeviceStatusDto.productType === ProductType.PC) {
const energyCodes = new Set([
PowerClampEnergyEnum.ENERGY_CONSUMED,
PowerClampEnergyEnum.ENERGY_CONSUMED_A,
PowerClampEnergyEnum.ENERGY_CONSUMED_B,
PowerClampEnergyEnum.ENERGY_CONSUMED_C,
]);
const energyStatus = addDeviceStatusDto?.status?.find((status) => {
return energyCodes.has(status.code as PowerClampEnergyEnum);
});
if (energyStatus) {
await this.powerClampService.updateEnergyConsumedHistoricalData(
addDeviceStatusDto.deviceUuid,
);
}
}
if (
addDeviceStatusDto.productType === ProductType.CPS ||
addDeviceStatusDto.productType === ProductType.WPS
) {
const occupancyCodes = new Set([PresenceSensorEnum.PRESENCE_STATE]);
const occupancyStatus = addDeviceStatusDto?.status?.find((status) => {
return occupancyCodes.has(status.code as PresenceSensorEnum);
});
if (occupancyStatus) {
await this.occupancyService.updateOccupancySensorHistoricalData(
addDeviceStatusDto.deviceUuid,
);
await this.occupancyService.updateOccupancySensorHistoricalDurationData(
addDeviceStatusDto.deviceUuid,
);
}
}
if (addDeviceStatusDto.productType === ProductType.AQI) {
await this.aqiDataService.updateAQISensorHistoricalData(
addDeviceStatusDto.deviceUuid,
);
}
}
// Return the updated data
const snapshot: DataSnapshot = await get(dataRef);
return snapshot.val();
}
private async processDeviceStatusLogs(addDeviceStatusDto: AddDeviceStatusDto): Promise<void> {
if (this.isDevEnv) {
// Save logs to your repository
const newLogs = addDeviceStatusDto.log.properties.map((property) => {
return this.deviceStatusLogRepository.create({
deviceId: addDeviceStatusDto.deviceUuid,
deviceTuyaId: addDeviceStatusDto.deviceTuyaUuid,
productId: addDeviceStatusDto.log.productId,
log: addDeviceStatusDto.log,
code: property.code,
value: property.value,
eventId: addDeviceStatusDto.log.dataId,
eventTime: new Date(property.time).toISOString(),
});
});
await this.deviceStatusLogRepository.save(newLogs);
if (addDeviceStatusDto.productType === ProductType.PC) {
const energyCodes = new Set([
PowerClampEnergyEnum.ENERGY_CONSUMED,
PowerClampEnergyEnum.ENERGY_CONSUMED_A,
PowerClampEnergyEnum.ENERGY_CONSUMED_B,
PowerClampEnergyEnum.ENERGY_CONSUMED_C,
]);
const energyStatus = addDeviceStatusDto?.log?.properties?.find(
(status) => energyCodes.has(status.code),
);
if (energyStatus) {
await this.powerClampService.updateEnergyConsumedHistoricalData(
addDeviceStatusDto.deviceUuid,
);
}
}
if (
addDeviceStatusDto.productType === ProductType.CPS ||
addDeviceStatusDto.productType === ProductType.WPS
) {
const occupancyCodes = new Set([PresenceSensorEnum.PRESENCE_STATE]);
const occupancyStatus = addDeviceStatusDto?.log?.properties?.find(
(status) => occupancyCodes.has(status.code),
);
if (occupancyStatus) {
await this.occupancyService.updateOccupancySensorHistoricalData(
addDeviceStatusDto.deviceUuid,
);
await this.occupancyService.updateOccupancySensorHistoricalDurationData(
addDeviceStatusDto.deviceUuid,
);
}
}
if (addDeviceStatusDto.productType === ProductType.AQI) {
await this.aqiDataService.updateAQISensorHistoricalData(
addDeviceStatusDto.deviceUuid,
);
}
} else {
// Save logs to your repository
const newLogs = addDeviceStatusDto?.status.map((property) => {
return this.deviceStatusLogRepository.create({
deviceId: addDeviceStatusDto.deviceUuid,
deviceTuyaId: addDeviceStatusDto.deviceTuyaUuid,
productId: addDeviceStatusDto.log.productKey,
log: addDeviceStatusDto.log,
code: property.code,
value: property.value,
eventId: addDeviceStatusDto.log.dataId,
eventTime: new Date(property.t).toISOString(),
});
});
await this.deviceStatusLogRepository.save(newLogs);
if (addDeviceStatusDto.productType === ProductType.PC) {
const energyCodes = new Set([
PowerClampEnergyEnum.ENERGY_CONSUMED,
PowerClampEnergyEnum.ENERGY_CONSUMED_A,
PowerClampEnergyEnum.ENERGY_CONSUMED_B,
PowerClampEnergyEnum.ENERGY_CONSUMED_C,
]);
const energyStatus = addDeviceStatusDto?.status?.find((status) => {
return energyCodes.has(status.code as PowerClampEnergyEnum);
});
if (energyStatus) {
await this.powerClampService.updateEnergyConsumedHistoricalData(
addDeviceStatusDto.deviceUuid,
);
}
}
if (
addDeviceStatusDto.productType === ProductType.CPS ||
addDeviceStatusDto.productType === ProductType.WPS
) {
const occupancyCodes = new Set([PresenceSensorEnum.PRESENCE_STATE]);
const occupancyStatus = addDeviceStatusDto?.status?.find((status) => {
return occupancyCodes.has(status.code as PresenceSensorEnum);
});
if (occupancyStatus) {
await this.occupancyService.updateOccupancySensorHistoricalData(
addDeviceStatusDto.deviceUuid,
);
await this.occupancyService.updateOccupancySensorHistoricalDurationData(
addDeviceStatusDto.deviceUuid,
);
}
}
if (addDeviceStatusDto.productType === ProductType.AQI) {
await this.aqiDataService.updateAQISensorHistoricalData(
addDeviceStatusDto.deviceUuid,
);
}
}
}
}

View File

@ -3,21 +3,32 @@ import { getDatabase } from 'firebase/database';
import { ConfigService } from '@nestjs/config';
export const initializeFirebaseApp = (configService: ConfigService) => {
const firebaseConfig = {
apiKey: configService.get<string>('FIREBASE_API_KEY'),
authDomain: configService.get<string>('FIREBASE_AUTH_DOMAIN'),
projectId: configService.get<string>('FIREBASE_PROJECT_ID'),
storageBucket: configService.get<string>('FIREBASE_STORAGE_BUCKET'),
messagingSenderId: configService.get<string>(
'FIREBASE_MESSAGING_SENDER_ID',
),
appId: configService.get<string>('FIREBASE_APP_ID'),
measurementId: configService.get<string>('FIREBASE_MEASUREMENT_ID'),
databaseURL: configService.get<string>('FIREBASE_DATABASE_URL'),
};
try {
const firebaseConfig = {
apiKey: configService.get<string>('FIREBASE_API_KEY'),
authDomain: configService.get<string>('FIREBASE_AUTH_DOMAIN'),
projectId: configService.get<string>('FIREBASE_PROJECT_ID'),
storageBucket: configService.get<string>('FIREBASE_STORAGE_BUCKET'),
messagingSenderId: configService.get<string>(
'FIREBASE_MESSAGING_SENDER_ID',
),
appId: configService.get<string>('FIREBASE_APP_ID'),
measurementId: configService.get<string>('FIREBASE_MEASUREMENT_ID'),
databaseURL: configService.get<string>('FIREBASE_DATABASE_URL'),
};
const app = initializeApp(firebaseConfig);
return getDatabase(app);
// Check if required Firebase config is available
if (!firebaseConfig.projectId || firebaseConfig.projectId === 'placeholder-project') {
console.warn('Firebase configuration not available, Firebase features will be disabled');
return null;
}
const app = initializeApp(firebaseConfig);
return getDatabase(app);
} catch (error) {
console.warn('Firebase initialization failed, Firebase features will be disabled:', error.message);
return null;
}
};
export const firebaseDataBase = (configService: ConfigService) =>

View File

@ -8,10 +8,7 @@ import { TuyaWebSocketService } from './services/tuya.web.socket.service';
import { OneSignalService } from './services/onesignal.service';
import { DeviceMessagesService } from './services/device.messages.service';
import { DeviceRepositoryModule } from '../modules/device/device.repository.module';
import {
DeviceNotificationRepository,
DeviceRepository,
} from '../modules/device/repositories';
import { DeviceNotificationRepository } from '../modules/device/repositories';
import { DeviceStatusFirebaseModule } from '../firebase/devices-status/devices-status.module';
import { CommunityPermissionService } from './services/community.permission.service';
import { CommunityRepository } from '../modules/community/repositories';
@ -30,7 +27,6 @@ import { SosHandlerService } from './services/sos.handler.service';
DeviceNotificationRepository,
CommunityRepository,
SosHandlerService,
DeviceRepository,
],
exports: [
HelperHashService,

View File

@ -1,63 +1,44 @@
import { DeviceRepository } from '@app/common/modules/device/repositories';
import { Injectable } from '@nestjs/common';
import { SqlLoaderService } from './sql-loader.service';
import { DataSource } from 'typeorm';
import { SQL_PROCEDURES_PATH } from '@app/common/constants/sql-query-path';
import { SqlLoaderService } from './sql-loader.service';
@Injectable()
export class AqiDataService {
constructor(
private readonly sqlLoader: SqlLoaderService,
private readonly dataSource: DataSource,
private readonly deviceRepository: DeviceRepository,
) {}
async updateAQISensorHistoricalData(): Promise<void> {
async updateAQISensorHistoricalData(deviceUuid: string): Promise<void> {
try {
const { dateStr } = this.getFormattedDates();
const now = new Date();
const dateStr = now.toLocaleDateString('en-CA'); // YYYY-MM-DD
const device = await this.deviceRepository.findOne({
where: { uuid: deviceUuid },
relations: ['spaceDevice'],
});
// Execute all procedures in parallel
await Promise.all([
this.executeProcedureWithRetry(
'proceduce_update_daily_space_aqi',
[dateStr],
'fact_daily_space_aqi',
),
]);
await this.executeProcedure(
'fact_daily_space_aqi',
'proceduce_update_daily_space_aqi',
[dateStr, device.spaceDevice?.uuid],
);
} catch (err) {
console.error('Failed to update AQI sensor historical data:', err);
console.error('Failed to insert or update aqi data:', err);
throw err;
}
}
private getFormattedDates(): { dateStr: string } {
const now = new Date();
return {
dateStr: now.toLocaleDateString('en-CA'), // YYYY-MM-DD
};
}
private async executeProcedureWithRetry(
private async executeProcedure(
procedureFolderName: string,
procedureFileName: string,
params: (string | number | null)[],
folderName: string,
retries = 3,
): Promise<void> {
try {
const query = this.loadQuery(folderName, procedureFileName);
await this.dataSource.query(query, params);
console.log(`Procedure ${procedureFileName} executed successfully.`);
} catch (err) {
if (retries > 0) {
const delayMs = 1000 * (4 - retries); // Exponential backoff
console.warn(`Retrying ${procedureFileName} (${retries} retries left)`);
await new Promise((resolve) => setTimeout(resolve, delayMs));
return this.executeProcedureWithRetry(
procedureFileName,
params,
folderName,
retries - 1,
);
}
console.error(`Failed to execute ${procedureFileName}:`, err);
throw err;
}
const query = this.loadQuery(procedureFolderName, procedureFileName);
await this.dataSource.query(query, params);
console.log(`Procedure ${procedureFileName} executed successfully.`);
}
private loadQuery(folderName: string, fileName: string): string {

View File

@ -1,69 +1,66 @@
import { DeviceRepository } from '@app/common/modules/device/repositories';
import { Injectable } from '@nestjs/common';
import { SqlLoaderService } from './sql-loader.service';
import { DataSource } from 'typeorm';
import { SQL_PROCEDURES_PATH } from '@app/common/constants/sql-query-path';
import { SqlLoaderService } from './sql-loader.service';
@Injectable()
export class OccupancyService {
constructor(
private readonly sqlLoader: SqlLoaderService,
private readonly dataSource: DataSource,
private readonly deviceRepository: DeviceRepository,
) {}
async updateOccupancyDataProcedures(): Promise<void> {
try {
const { dateStr } = this.getFormattedDates();
// Execute all procedures in parallel
await Promise.all([
this.executeProcedureWithRetry(
'procedure_update_fact_space_occupancy',
[dateStr],
'fact_space_occupancy_count',
),
this.executeProcedureWithRetry(
'procedure_update_daily_space_occupancy_duration',
[dateStr],
'fact_daily_space_occupancy_duration',
),
]);
} catch (err) {
console.error('Failed to update occupancy data:', err);
throw err;
}
}
private getFormattedDates(): { dateStr: string } {
const now = new Date();
return {
dateStr: now.toLocaleDateString('en-CA'), // YYYY-MM-DD
};
}
private async executeProcedureWithRetry(
procedureFileName: string,
params: (string | number | null)[],
folderName: string,
retries = 3,
async updateOccupancySensorHistoricalDurationData(
deviceUuid: string,
): Promise<void> {
try {
const query = this.loadQuery(folderName, procedureFileName);
await this.dataSource.query(query, params);
console.log(`Procedure ${procedureFileName} executed successfully.`);
const now = new Date();
const dateStr = now.toLocaleDateString('en-CA'); // YYYY-MM-DD
const device = await this.deviceRepository.findOne({
where: { uuid: deviceUuid },
relations: ['spaceDevice'],
});
await this.executeProcedure(
'fact_daily_space_occupancy_duration',
'procedure_update_daily_space_occupancy_duration',
[dateStr, device.spaceDevice?.uuid],
);
} catch (err) {
if (retries > 0) {
const delayMs = 1000 * (4 - retries); // Exponential backoff
console.warn(`Retrying ${procedureFileName} (${retries} retries left)`);
await new Promise((resolve) => setTimeout(resolve, delayMs));
return this.executeProcedureWithRetry(
procedureFileName,
params,
folderName,
retries - 1,
);
}
console.error(`Failed to execute ${procedureFileName}:`, err);
console.error('Failed to insert or update occupancy duration data:', err);
throw err;
}
}
async updateOccupancySensorHistoricalData(deviceUuid: string): Promise<void> {
try {
const now = new Date();
const dateStr = now.toLocaleDateString('en-CA'); // YYYY-MM-DD
const device = await this.deviceRepository.findOne({
where: { uuid: deviceUuid },
relations: ['spaceDevice'],
});
await this.executeProcedure(
'fact_space_occupancy_count',
'procedure_update_fact_space_occupancy',
[dateStr, device.spaceDevice?.uuid],
);
} catch (err) {
console.error('Failed to insert or update occupancy data:', err);
throw err;
}
}
private async executeProcedure(
procedureFolderName: string,
procedureFileName: string,
params: (string | number | null)[],
): Promise<void> {
const query = this.loadQuery(procedureFolderName, procedureFileName);
await this.dataSource.query(query, params);
console.log(`Procedure ${procedureFileName} executed successfully.`);
}
private loadQuery(folderName: string, fileName: string): string {
return this.sqlLoader.loadQuery(folderName, fileName, SQL_PROCEDURES_PATH);

View File

@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { SqlLoaderService } from './sql-loader.service';
import { DataSource } from 'typeorm';
import { SQL_PROCEDURES_PATH } from '@app/common/constants/sql-query-path';
import { SqlLoaderService } from './sql-loader.service';
@Injectable()
export class PowerClampService {
@ -10,74 +10,50 @@ export class PowerClampService {
private readonly dataSource: DataSource,
) {}
async updateEnergyConsumedHistoricalData(): Promise<void> {
async updateEnergyConsumedHistoricalData(deviceUuid: string): Promise<void> {
try {
const { dateStr, monthYear } = this.getFormattedDates();
// Execute all procedures in parallel
await Promise.all([
this.executeProcedureWithRetry(
'fact_hourly_device_energy_consumed_procedure',
[dateStr],
'fact_device_energy_consumed',
),
this.executeProcedureWithRetry(
'fact_daily_device_energy_consumed_procedure',
[dateStr],
'fact_device_energy_consumed',
),
this.executeProcedureWithRetry(
'fact_monthly_device_energy_consumed_procedure',
[monthYear],
'fact_device_energy_consumed',
),
]);
} catch (err) {
console.error('Failed to update energy consumption data:', err);
throw err;
}
}
private getFormattedDates(): { dateStr: string; monthYear: string } {
const now = new Date();
return {
dateStr: now.toLocaleDateString('en-CA'), // YYYY-MM-DD
monthYear: now
const now = new Date();
const dateStr = now.toLocaleDateString('en-CA'); // YYYY-MM-DD
const hour = now.getHours();
const monthYear = now
.toLocaleDateString('en-US', {
month: '2-digit',
year: 'numeric',
})
.replace('/', '-'), // MM-YYYY
};
}
.replace('/', '-'); // MM-YYYY
private async executeProcedureWithRetry(
procedureFileName: string,
params: (string | number | null)[],
folderName: string,
retries = 3,
): Promise<void> {
try {
const query = this.loadQuery(folderName, procedureFileName);
await this.dataSource.query(query, params);
console.log(`Procedure ${procedureFileName} executed successfully.`);
await this.executeProcedure(
'fact_hourly_device_energy_consumed_procedure',
[deviceUuid, dateStr, hour],
);
await this.executeProcedure(
'fact_daily_device_energy_consumed_procedure',
[deviceUuid, dateStr],
);
await this.executeProcedure(
'fact_monthly_device_energy_consumed_procedure',
[deviceUuid, monthYear],
);
} catch (err) {
if (retries > 0) {
const delayMs = 1000 * (4 - retries); // Exponential backoff
console.warn(`Retrying ${procedureFileName} (${retries} retries left)`);
await new Promise((resolve) => setTimeout(resolve, delayMs));
return this.executeProcedureWithRetry(
procedureFileName,
params,
folderName,
retries - 1,
);
}
console.error(`Failed to execute ${procedureFileName}:`, err);
console.error('Failed to insert or update energy data:', err);
throw err;
}
}
private async executeProcedure(
procedureFileName: string,
params: (string | number | null)[],
): Promise<void> {
const query = this.loadQuery(
'fact_device_energy_consumed',
procedureFileName,
);
await this.dataSource.query(query, params);
console.log(`Procedure ${procedureFileName} executed successfully.`);
}
private loadQuery(folderName: string, fileName: string): string {
return this.sqlLoader.loadQuery(folderName, fileName, SQL_PROCEDURES_PATH);
}

View File

@ -16,46 +16,21 @@ export class SosHandlerService {
);
}
async handleSosEventFirebase(device: any, logData: any): Promise<void> {
const sosTrueStatus = [{ code: 'sos', value: true }];
const sosFalseStatus = [{ code: 'sos', value: false }];
async handleSosEvent(devId: string, logData: any): Promise<void> {
try {
// ✅ Send true status
await this.deviceStatusFirebaseService.addDeviceStatusToFirebase({
deviceTuyaUuid: device.deviceTuyaUuid,
status: sosTrueStatus,
deviceTuyaUuid: devId,
status: [{ code: 'sos', value: true }],
log: logData,
device,
});
await this.deviceStatusFirebaseService.addBatchDeviceStatusToOurDb([
{
deviceTuyaUuid: device.deviceTuyaUuid,
status: sosTrueStatus,
log: logData,
device,
},
]);
// ✅ Schedule false status
setTimeout(async () => {
try {
await this.deviceStatusFirebaseService.addDeviceStatusToFirebase({
deviceTuyaUuid: device.deviceTuyaUuid,
status: sosFalseStatus,
deviceTuyaUuid: devId,
status: [{ code: 'sos', value: false }],
log: logData,
device,
});
await this.deviceStatusFirebaseService.addBatchDeviceStatusToOurDb([
{
deviceTuyaUuid: device.deviceTuyaUuid,
status: sosFalseStatus,
log: logData,
device,
},
]);
} catch (err) {
this.logger.error('Failed to send SOS false value', err);
}

View File

@ -1,24 +1,13 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import TuyaWebsocket from '../../config/tuya-web-socket-config';
import { ConfigService } from '@nestjs/config';
import { DeviceStatusFirebaseService } from '@app/common/firebase/devices-status/services/devices-status.service';
import { SosHandlerService } from './sos.handler.service';
import * as NodeCache from 'node-cache';
@Injectable()
export class TuyaWebSocketService implements OnModuleInit {
export class TuyaWebSocketService {
private client: any;
private readonly isDevEnv: boolean;
private readonly deviceCache = new NodeCache({ stdTTL: 7200 }); // TTL = 2 hour
private messageQueue: {
devId: string;
status: any;
logData: any;
device: any;
}[] = [];
private isProcessing = false;
constructor(
private readonly configService: ConfigService,
@ -37,36 +26,16 @@ export class TuyaWebSocketService implements OnModuleInit {
});
if (this.configService.get<string>('tuya-config.TRUN_ON_TUYA_SOCKET')) {
// Set up event handlers
this.setupEventHandlers();
// Start receiving messages
this.client.start();
}
// Run the queue processor every 15 seconds
setInterval(() => this.processQueue(), 15000);
// Refresh the cache every 1 hour
setInterval(() => this.initializeDeviceCache(), 30 * 60 * 1000); // 30 minutes
}
async onModuleInit() {
await this.initializeDeviceCache();
}
private async initializeDeviceCache() {
try {
const allDevices = await this.deviceStatusFirebaseService.getAllDevices();
allDevices.forEach((device) => {
if (device.deviceTuyaUuid) {
this.deviceCache.set(device.deviceTuyaUuid, device);
}
});
console.log(`✅ Refreshed cache with ${allDevices.length} devices.`);
} catch (error) {
console.error('❌ Failed to initialize device cache:', error);
}
}
private setupEventHandlers() {
// Event handlers
this.client.open(() => {
console.log('open');
});
@ -74,38 +43,23 @@ export class TuyaWebSocketService implements OnModuleInit {
this.client.message(async (ws: WebSocket, message: any) => {
try {
const { devId, status, logData } = this.extractMessageData(message);
if (!Array.isArray(logData?.properties)) {
this.client.ackMessage(message.messageId);
return;
}
const device = this.deviceCache.get(devId);
if (!device) {
// console.log(⛔ Unknown device: ${devId}, message ignored.);
this.client.ackMessage(message.messageId);
return;
}
if (this.sosHandlerService.isSosTriggered(status)) {
await this.sosHandlerService.handleSosEventFirebase(devId, logData);
await this.sosHandlerService.handleSosEvent(devId, logData);
} else {
await this.deviceStatusFirebaseService.addDeviceStatusToFirebase({
deviceTuyaUuid: devId,
status,
status: status,
log: logData,
device,
});
}
// Push to internal queue
this.messageQueue.push({ devId, status, logData, device });
// Acknowledge the message
this.client.ackMessage(message.messageId);
} catch (error) {
console.error('Error receiving message:', error);
console.error('Error processing message:', error);
}
});
this.client.reconnect(() => {
console.log('reconnect');
});
@ -126,37 +80,6 @@ export class TuyaWebSocketService implements OnModuleInit {
console.error('WebSocket error:', error);
});
}
private async processQueue() {
if (this.isProcessing) {
console.log('⏳ Skipping: still processing previous batch');
return;
}
if (this.messageQueue.length === 0) return;
this.isProcessing = true;
const batch = [...this.messageQueue];
this.messageQueue = [];
console.log(`🔁 Processing batch of size: ${batch.length}`);
try {
await this.deviceStatusFirebaseService.addBatchDeviceStatusToOurDb(
batch.map((item) => ({
deviceTuyaUuid: item.devId,
status: item.status,
log: item.logData,
device: item.device,
})),
);
} catch (error) {
console.error('❌ Error processing batch:', error);
this.messageQueue.unshift(...batch); // retry
} finally {
this.isProcessing = false;
}
}
private extractMessageData(message: any): {
devId: string;
status: any;

View File

@ -1,43 +1,26 @@
import { utilities as nestWinstonModuleUtilities } from 'nest-winston';
import * as winston from 'winston';
const environment = process.env.NODE_ENV || 'local';
export const winstonLoggerOptions: winston.LoggerOptions = {
level:
environment === 'local'
? 'debug'
: environment === 'development'
? 'warn'
: 'error',
process.env.AZURE_POSTGRESQL_DATABASE === 'development' ? 'debug' : 'error',
transports: [
new winston.transports.Console({
level:
environment === 'local'
? 'debug'
: environment === 'development'
? 'warn'
: 'error',
format: winston.format.combine(
winston.format.timestamp(),
nestWinstonModuleUtilities.format.nestLike('MyApp', {
prettyPrint: environment === 'local',
prettyPrint: true,
}),
),
}),
// Only create file logs if NOT local
...(environment !== 'local'
? [
new winston.transports.File({
filename: 'logs/error.log',
level: 'error',
format: winston.format.json(),
}),
new winston.transports.File({
filename: 'logs/combined.log',
level: 'info',
format: winston.format.json(),
}),
]
: []),
new winston.transports.File({
filename: 'logs/error.log',
level: 'error',
format: winston.format.json(),
}),
new winston.transports.File({
filename: 'logs/combined.log',
format: winston.format.json(),
}),
],
};

View File

@ -8,14 +8,14 @@ import {
Unique,
} from 'typeorm';
import { AbstractEntity } from '../../abstract/entities/abstract.entity';
import { RoleTypeEntity } from '../../role-type/entities';
import { UserStatusEnum } from '@app/common/constants/user-status.enum';
import { UserEntity } from '../../user/entities';
import { RoleType } from '@app/common/constants/role.type.enum';
import { InviteUserDto, InviteUserSpaceDto } from '../dtos';
import { UserStatusEnum } from '@app/common/constants/user-status.enum';
import { AbstractEntity } from '../../abstract/entities/abstract.entity';
import { ProjectEntity } from '../../project/entities';
import { RoleTypeEntity } from '../../role-type/entities';
import { SpaceEntity } from '../../space/entities/space.entity';
import { UserEntity } from '../../user/entities';
import { InviteUserDto, InviteUserSpaceDto } from '../dtos';
@Entity({ name: 'invite-user' })
@Unique(['email', 'project'])
@ -82,7 +82,10 @@ export class InviteUserEntity extends AbstractEntity<InviteUserDto> {
onDelete: 'CASCADE',
})
public roleType: RoleTypeEntity;
@OneToOne(() => UserEntity, (user) => user.inviteUser, { nullable: true })
@OneToOne(() => UserEntity, (user) => user.inviteUser, {
nullable: true,
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'user_uuid' })
user: UserEntity;
@OneToMany(
@ -112,7 +115,9 @@ export class InviteUserSpaceEntity extends AbstractEntity<InviteUserSpaceDto> {
})
public uuid: string;
@ManyToOne(() => InviteUserEntity, (inviteUser) => inviteUser.spaces)
@ManyToOne(() => InviteUserEntity, (inviteUser) => inviteUser.spaces, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'invite_user_uuid' })
public inviteUser: InviteUserEntity;

View File

@ -1,24 +1,24 @@
import {
Column,
Entity,
Index,
JoinColumn,
ManyToOne,
OneToMany,
Unique,
Index,
JoinColumn,
} from 'typeorm';
import { AbstractEntity } from '../../abstract/entities/abstract.entity';
import { DeviceDto, DeviceUserPermissionDto } from '../dtos/device.dto';
import { ProductEntity } from '../../product/entities';
import { UserEntity } from '../../user/entities';
import { DeviceNotificationDto } from '../dtos';
import { PermissionTypeEntity } from '../../permission/entities';
import { PowerClampHourlyEntity } from '../../power-clamp/entities/power-clamp.entity';
import { PresenceSensorDailyDeviceEntity } from '../../presence-sensor/entities';
import { ProductEntity } from '../../product/entities';
import { SceneDeviceEntity } from '../../scene-device/entities';
import { SpaceEntity } from '../../space/entities/space.entity';
import { SubspaceEntity } from '../../space/entities/subspace/subspace.entity';
import { NewTagEntity } from '../../tag';
import { PowerClampHourlyEntity } from '../../power-clamp/entities/power-clamp.entity';
import { PresenceSensorDailyDeviceEntity } from '../../presence-sensor/entities';
import { UserEntity } from '../../user/entities';
import { DeviceNotificationDto } from '../dtos';
import { DeviceDto, DeviceUserPermissionDto } from '../dtos/device.dto';
@Entity({ name: 'device' })
@Unique(['deviceTuyaUuid'])
@ -111,6 +111,7 @@ export class DeviceNotificationEntity extends AbstractEntity<DeviceNotificationD
@ManyToOne(() => UserEntity, (user) => user.userPermission, {
nullable: false,
onDelete: 'CASCADE',
})
user: UserEntity;
@ -149,6 +150,7 @@ export class DeviceUserPermissionEntity extends AbstractEntity<DeviceUserPermiss
@ManyToOne(() => UserEntity, (user) => user.userPermission, {
nullable: false,
onDelete: 'CASCADE',
})
user: UserEntity;
constructor(partial: Partial<DeviceUserPermissionEntity>) {

View File

@ -1,3 +1,32 @@
import { Column, Entity, JoinColumn, ManyToOne } from 'typeorm';
import { AbstractEntity } from '../../abstract/entities/abstract.entity';
import { SpaceEntity } from './space.entity';
import { Direction } from '@app/common/constants/direction.enum';
export class SpaceLinkEntity extends AbstractEntity {}
@Entity({ name: 'space-link' })
export class SpaceLinkEntity extends AbstractEntity {
@ManyToOne(() => SpaceEntity, { nullable: false, onDelete: 'CASCADE' })
@JoinColumn({ name: 'start_space_id' })
public startSpace: SpaceEntity;
@ManyToOne(() => SpaceEntity, { nullable: false, onDelete: 'CASCADE' })
@JoinColumn({ name: 'end_space_id' })
public endSpace: SpaceEntity;
@Column({
nullable: false,
default: false,
})
public disabled: boolean;
@Column({
nullable: false,
enum: Object.values(Direction),
})
direction: string;
constructor(partial: Partial<SpaceLinkEntity>) {
super();
Object.assign(this, partial);
}
}

View File

@ -1,17 +1,18 @@
import { Column, Entity, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
import { SpaceDto } from '../dtos';
import { AbstractEntity } from '../../abstract/entities/abstract.entity';
import { AqiSpaceDailyPollutantStatsEntity } from '../../aqi/entities';
import { CommunityEntity } from '../../community/entities';
import { UserSpaceEntity } from '../../user/entities';
import { DeviceEntity } from '../../device/entities';
import { InviteUserSpaceEntity } from '../../Invite-user/entities';
import { SpaceDailyOccupancyDurationEntity } from '../../occupancy/entities';
import { PresenceSensorDailySpaceEntity } from '../../presence-sensor/entities';
import { CommunityEntity } from '../../community/entities';
import { SpaceLinkEntity } from './space-link.entity';
import { SceneEntity } from '../../scene/entities';
import { SpaceModelEntity } from '../../space-model';
import { UserSpaceEntity } from '../../user/entities';
import { SpaceDto } from '../dtos';
import { InviteUserSpaceEntity } from '../../Invite-user/entities';
import { SpaceProductAllocationEntity } from './space-product-allocation.entity';
import { SubspaceEntity } from './subspace/subspace.entity';
import { PresenceSensorDailySpaceEntity } from '../../presence-sensor/entities';
import { AqiSpaceDailyPollutantStatsEntity } from '../../aqi/entities';
import { SpaceDailyOccupancyDurationEntity } from '../../occupancy/entities';
@Entity({ name: 'space' })
export class SpaceEntity extends AbstractEntity<SpaceDto> {
@ -74,6 +75,16 @@ export class SpaceEntity extends AbstractEntity<SpaceDto> {
)
devices: DeviceEntity[];
@OneToMany(() => SpaceLinkEntity, (connection) => connection.startSpace, {
nullable: true,
})
public outgoingConnections: SpaceLinkEntity[];
@OneToMany(() => SpaceLinkEntity, (connection) => connection.endSpace, {
nullable: true,
})
public incomingConnections: SpaceLinkEntity[];
@Column({
nullable: true,
type: 'text',

View File

@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import { DataSource, Repository } from 'typeorm';
import { InviteSpaceEntity } from '../entities/invite-space.entity';
import { SpaceLinkEntity } from '../entities/space-link.entity';
import { SpaceProductAllocationEntity } from '../entities/space-product-allocation.entity';
import { SpaceEntity } from '../entities/space.entity';
@ -12,7 +13,11 @@ export class SpaceRepository extends Repository<SpaceEntity> {
}
@Injectable()
export class SpaceLinkRepository {}
export class SpaceLinkRepository extends Repository<SpaceLinkEntity> {
constructor(private dataSource: DataSource) {
super(SpaceLinkEntity, dataSource.createEntityManager());
}
}
@Injectable()
export class InviteSpaceRepository extends Repository<InviteSpaceEntity> {

View File

@ -1,3 +1,4 @@
import { defaultProfilePicture } from '@app/common/constants/default.profile.picture';
import {
Column,
DeleteDateColumn,
@ -8,27 +9,26 @@ import {
OneToOne,
Unique,
} from 'typeorm';
import { OtpType } from '../../../../src/constants/otp-type.enum';
import { AbstractEntity } from '../../abstract/entities/abstract.entity';
import { ClientEntity } from '../../client/entities';
import {
DeviceNotificationEntity,
DeviceUserPermissionEntity,
} from '../../device/entities';
import { InviteUserEntity } from '../../Invite-user/entities';
import { ProjectEntity } from '../../project/entities';
import { RegionEntity } from '../../region/entities';
import { RoleTypeEntity } from '../../role-type/entities';
import { SpaceEntity } from '../../space/entities/space.entity';
import { TimeZoneEntity } from '../../timezone/entities';
import { VisitorPasswordEntity } from '../../visitor-password/entities';
import {
UserDto,
UserNotificationDto,
UserOtpDto,
UserSpaceDto,
} from '../dtos';
import { AbstractEntity } from '../../abstract/entities/abstract.entity';
import {
DeviceNotificationEntity,
DeviceUserPermissionEntity,
} from '../../device/entities';
import { defaultProfilePicture } from '@app/common/constants/default.profile.picture';
import { RegionEntity } from '../../region/entities';
import { TimeZoneEntity } from '../../timezone/entities';
import { OtpType } from '../../../../src/constants/otp-type.enum';
import { RoleTypeEntity } from '../../role-type/entities';
import { VisitorPasswordEntity } from '../../visitor-password/entities';
import { InviteUserEntity } from '../../Invite-user/entities';
import { ProjectEntity } from '../../project/entities';
import { SpaceEntity } from '../../space/entities/space.entity';
import { ClientEntity } from '../../client/entities';
@Entity({ name: 'user' })
export class UserEntity extends AbstractEntity<UserDto> {
@ -94,7 +94,9 @@ export class UserEntity extends AbstractEntity<UserDto> {
@Column({ type: 'timestamp', nullable: true })
appAgreementAcceptedAt: Date;
@OneToMany(() => UserSpaceEntity, (userSpace) => userSpace.user)
@OneToMany(() => UserSpaceEntity, (userSpace) => userSpace.user, {
onDelete: 'CASCADE',
})
userSpaces: UserSpaceEntity[];
@OneToMany(
@ -158,6 +160,7 @@ export class UserEntity extends AbstractEntity<UserDto> {
export class UserNotificationEntity extends AbstractEntity<UserNotificationDto> {
@ManyToOne(() => UserEntity, (user) => user.roleType, {
nullable: false,
onDelete: 'CASCADE',
})
user: UserEntity;
@Column({
@ -219,7 +222,10 @@ export class UserSpaceEntity extends AbstractEntity<UserSpaceDto> {
})
public uuid: string;
@ManyToOne(() => UserEntity, (user) => user.userSpaces, { nullable: false })
@ManyToOne(() => UserEntity, (user) => user.userSpaces, {
nullable: false,
onDelete: 'CASCADE',
})
user: UserEntity;
@ManyToOne(() => SpaceEntity, (space) => space.userSpaces, {

View File

@ -1,7 +1,7 @@
import { Column, Entity, ManyToOne, JoinColumn, Index } from 'typeorm';
import { VisitorPasswordDto } from '../dtos';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { AbstractEntity } from '../../abstract/entities/abstract.entity';
import { UserEntity } from '../../user/entities/user.entity';
import { VisitorPasswordDto } from '../dtos';
@Entity({ name: 'visitor-password' })
@Index('IDX_PASSWORD_TUYA_UUID', ['passwordTuyaUuid'])
@ -14,6 +14,7 @@ export class VisitorPasswordEntity extends AbstractEntity<VisitorPasswordDto> {
@ManyToOne(() => UserEntity, (user) => user.visitorPasswords, {
nullable: false,
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'authorizer_uuid' })
public user: UserEntity;

View File

@ -61,6 +61,10 @@ export class SuperAdminSeeder {
lastName: 'Admin',
isUserVerified: true,
isActive: true,
hasAcceptedAppAgreement: true,
hasAcceptedWebAgreement: true,
appAgreementAcceptedAt: new Date(),
webAgreementAcceptedAt: new Date(),
roleType: { uuid: defaultUserRoleUuid },
});
} catch (err) {

View File

@ -1,6 +1,7 @@
WITH params AS (
SELECT
TO_DATE(NULLIF($1, ''), 'YYYY-MM-DD') AS event_date
TO_DATE(NULLIF($1, ''), 'YYYY-MM-DD') AS event_date,
$2::uuid AS space_id
),
-- Query Pipeline Starts Here
@ -276,10 +277,7 @@ SELECT
a.daily_avg_ch2o,a.daily_max_ch2o, a.daily_min_ch2o
FROM daily_percentages p
LEFT JOIN daily_averages a
ON p.space_id = a.space_id
AND p.event_date = a.event_date
JOIN params
ON params.event_date = a.event_date
ON p.space_id = a.space_id AND p.event_date = a.event_date
ORDER BY p.space_id, p.event_date)

View File

@ -1,6 +1,7 @@
WITH params AS (
SELECT
TO_DATE(NULLIF($1, ''), 'YYYY-MM-DD') AS event_date
TO_DATE(NULLIF($1, ''), 'YYYY-MM-DD') AS event_date,
$2::uuid AS space_id
),
presence_logs AS (
@ -85,7 +86,8 @@ final_data AS (
ROUND(LEAST(raw_occupied_seconds, 86400) / 86400.0 * 100, 2) AS occupancy_percentage
FROM summed_intervals s
JOIN params p
ON p.event_date = s.event_date
ON p.space_id = s.space_id
AND p.event_date = s.event_date
)
INSERT INTO public."space-daily-occupancy-duration" (

View File

@ -1,6 +1,7 @@
WITH params AS (
SELECT
$1::date AS target_date
$1::uuid AS device_id,
$2::date AS target_date
),
total_energy AS (
SELECT
@ -13,7 +14,8 @@ total_energy AS (
MAX(log.value)::integer AS max_value
FROM "device-status-log" log, params
WHERE log.code = 'EnergyConsumed'
AND log.event_time::date = params.target_date
AND log.device_id = params.device_id
AND log.event_time::date = params.target_date
GROUP BY 1,2,3,4,5
),
energy_phase_A AS (
@ -27,7 +29,8 @@ energy_phase_A AS (
MAX(log.value)::integer AS max_value
FROM "device-status-log" log, params
WHERE log.code = 'EnergyConsumedA'
AND log.event_time::date = params.target_date
AND log.device_id = params.device_id
AND log.event_time::date = params.target_date
GROUP BY 1,2,3,4,5
),
energy_phase_B AS (
@ -41,7 +44,8 @@ energy_phase_B AS (
MAX(log.value)::integer AS max_value
FROM "device-status-log" log, params
WHERE log.code = 'EnergyConsumedB'
AND log.event_time::date = params.target_date
AND log.device_id = params.device_id
AND log.event_time::date = params.target_date
GROUP BY 1,2,3,4,5
),
energy_phase_C AS (
@ -55,7 +59,8 @@ energy_phase_C AS (
MAX(log.value)::integer AS max_value
FROM "device-status-log" log, params
WHERE log.code = 'EnergyConsumedC'
AND log.event_time::date = params.target_date
AND log.device_id = params.device_id
AND log.event_time::date = params.target_date
GROUP BY 1,2,3,4,5
),
final_data AS (

View File

@ -1,6 +1,8 @@
WITH params AS (
SELECT
$1::date AS target_date
$1::uuid AS device_id,
$2::date AS target_date,
$3::text AS target_hour
),
total_energy AS (
SELECT
@ -13,7 +15,9 @@ total_energy AS (
MAX(log.value)::integer AS max_value
FROM "device-status-log" log, params
WHERE log.code = 'EnergyConsumed'
AND log.device_id = params.device_id
AND log.event_time::date = params.target_date
AND EXTRACT(HOUR FROM log.event_time)::text = params.target_hour
GROUP BY 1,2,3,4,5
),
energy_phase_A AS (
@ -27,7 +31,9 @@ energy_phase_A AS (
MAX(log.value)::integer AS max_value
FROM "device-status-log" log, params
WHERE log.code = 'EnergyConsumedA'
AND log.device_id = params.device_id
AND log.event_time::date = params.target_date
AND EXTRACT(HOUR FROM log.event_time)::text = params.target_hour
GROUP BY 1,2,3,4,5
),
energy_phase_B AS (
@ -41,7 +47,9 @@ energy_phase_B AS (
MAX(log.value)::integer AS max_value
FROM "device-status-log" log, params
WHERE log.code = 'EnergyConsumedB'
AND log.device_id = params.device_id
AND log.event_time::date = params.target_date
AND EXTRACT(HOUR FROM log.event_time)::text = params.target_hour
GROUP BY 1,2,3,4,5
),
energy_phase_C AS (
@ -55,7 +63,9 @@ energy_phase_C AS (
MAX(log.value)::integer AS max_value
FROM "device-status-log" log, params
WHERE log.code = 'EnergyConsumedC'
AND log.device_id = params.device_id
AND log.event_time::date = params.target_date
AND EXTRACT(HOUR FROM log.event_time)::text = params.target_hour
GROUP BY 1,2,3,4,5
),
final_data AS (

View File

@ -1,6 +1,7 @@
WITH params AS (
SELECT
$1::text AS target_month -- Format should match 'MM-YYYY'
$1::uuid AS device_id,
$2::text AS target_month -- Format should match 'MM-YYYY'
),
total_energy AS (
SELECT
@ -13,6 +14,7 @@ total_energy AS (
MAX(log.value)::integer AS max_value
FROM "device-status-log" log, params
WHERE log.code = 'EnergyConsumed'
AND log.device_id = params.device_id
AND TO_CHAR(log.event_time, 'MM-YYYY') = params.target_month
GROUP BY 1,2,3,4,5
),
@ -27,6 +29,7 @@ energy_phase_A AS (
MAX(log.value)::integer AS max_value
FROM "device-status-log" log, params
WHERE log.code = 'EnergyConsumedA'
AND log.device_id = params.device_id
AND TO_CHAR(log.event_time, 'MM-YYYY') = params.target_month
GROUP BY 1,2,3,4,5
),
@ -41,6 +44,7 @@ energy_phase_B AS (
MAX(log.value)::integer AS max_value
FROM "device-status-log" log, params
WHERE log.code = 'EnergyConsumedB'
AND log.device_id = params.device_id
AND TO_CHAR(log.event_time, 'MM-YYYY') = params.target_month
GROUP BY 1,2,3,4,5
),
@ -55,6 +59,7 @@ energy_phase_C AS (
MAX(log.value)::integer AS max_value
FROM "device-status-log" log, params
WHERE log.code = 'EnergyConsumedC'
AND log.device_id = params.device_id
AND TO_CHAR(log.event_time, 'MM-YYYY') = params.target_month
GROUP BY 1,2,3,4,5
),

View File

@ -1,6 +1,7 @@
WITH params AS (
SELECT
TO_DATE(NULLIF($1, ''), 'YYYY-MM-DD') AS event_date
TO_DATE(NULLIF($1, ''), 'YYYY-MM-DD') AS event_date,
$2::uuid AS space_id
),
device_logs AS (
@ -86,7 +87,8 @@ SELECT summary.space_id,
count_total_presence_detected
FROM summary
JOIN params P ON true
where (P.event_date IS NULL or summary.event_date::date = P.event_date)
where summary.space_id = P.space_id
and (P.event_date IS NULL or summary.event_date::date = P.event_date)
ORDER BY space_id, event_date)

724
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -6,19 +6,24 @@
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "npm run test && npx nest build",
"build": "npx nest build",
"build:lambda": "npx nest build && cp package*.json dist/",
"format": "prettier --write \"apps/**/*.ts\" \"libs/**/*.ts\"",
"start": "npm run test && node dist/main",
"start:dev": "npm run test && npx nest start --watch",
"start": "node dist/main",
"start:dev": "npx nest start --watch",
"dev": "npx nest start --watch",
"start:debug": "npm run test && npx nest start --debug --watch",
"start:prod": "npm run test && node dist/main",
"start:debug": "npx nest start --debug --watch",
"start:prod": "node dist/main",
"start:lambda": "node dist/lambda",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest --config jest.config.js",
"test:watch": "jest --watch --config jest.config.js",
"test:cov": "jest --coverage --config jest.config.js",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./apps/backend/test/jest-e2e.json"
"test:e2e": "jest --config ./apps/backend/test/jest-e2e.json",
"deploy": "./deploy.sh",
"infra:deploy": "cdk deploy SyncrowBackendStack",
"infra:destroy": "cdk destroy SyncrowBackendStack"
},
"dependencies": {
"@fast-csv/format": "^5.0.2",
@ -30,20 +35,22 @@
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.0.0",
"@nestjs/schedule": "^6.0.0",
"@nestjs/swagger": "^7.3.0",
"@nestjs/terminus": "^11.0.0",
"@nestjs/throttler": "^6.4.0",
"@nestjs/typeorm": "^10.0.2",
"@nestjs/websockets": "^10.3.8",
"@tuya/tuya-connector-nodejs": "^2.1.2",
"@types/aws-lambda": "^8.10.150",
"argon2": "^0.40.1",
"aws-serverless-express": "^3.4.0",
"axios": "^1.7.7",
"bcryptjs": "^2.4.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"crypto-js": "^4.2.0",
"csv-parser": "^3.2.0",
"dotenv": "^17.0.0",
"express-rate-limit": "^7.1.5",
"firebase": "^10.12.5",
"google-auth-library": "^9.14.1",
@ -51,14 +58,15 @@
"ioredis": "^5.3.2",
"morgan": "^1.10.0",
"nest-winston": "^1.10.2",
"node-cache": "^5.1.2",
"nodemailer": "^6.9.10",
"onesignal-node": "^3.4.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"pg": "^8.11.3",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1",
"typeorm": "^0.3.20",
"webpack": "^5.99.9",
"winston": "^3.17.0",
"ws": "^8.17.0"
},
@ -75,7 +83,9 @@
"@types/supertest": "^6.0.0",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"aws-cdk-lib": "^2.202.0",
"concurrently": "^8.2.2",
"constructs": "^10.4.2",
"eslint": "^8.42.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-import": "^2.31.0",
@ -89,5 +99,9 @@
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.1.3"
},
"engines": {
"node": "20.x",
"npm": "10.x"
}
}

View File

@ -1,7 +1,7 @@
import { SeederModule } from '@app/common/seed/seeder.module';
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { WinstonModule } from 'nest-winston';
import { AuthenticationModule } from './auth/auth.module';
import { AutomationModule } from './automation/automation.module';
@ -35,32 +35,18 @@ import { UserNotificationModule } from './user-notification/user-notification.mo
import { UserModule } from './users/user.module';
import { VisitorPasswordModule } from './vistor-password/visitor-password.module';
import { ThrottlerGuard } from '@nestjs/throttler';
import { ThrottlerModule } from '@nestjs/throttler/dist/throttler.module';
import { isArray } from 'class-validator';
import { winstonLoggerOptions } from '../libs/common/src/logger/services/winston.logger';
import { AqiModule } from './aqi/aqi.module';
import { OccupancyModule } from './occupancy/occupancy.module';
import { WeatherModule } from './weather/weather.module';
import { ScheduleModule as NestScheduleModule } from '@nestjs/schedule';
import { SchedulerModule } from './scheduler/scheduler.module';
@Module({
imports: [
ConfigModule.forRoot({
load: config,
}),
ThrottlerModule.forRoot({
throttlers: [{ ttl: 60000, limit: 100 }],
generateKey: (context) => {
const req = context.switchToHttp().getRequest();
console.log('Real IP:', req.headers['x-forwarded-for']);
return req.headers['x-forwarded-for']
? isArray(req.headers['x-forwarded-for'])
? req.headers['x-forwarded-for'][0].split(':')[0]
: req.headers['x-forwarded-for'].split(':')[0]
: req.ip;
},
}),
/* ThrottlerModule.forRoot({
throttlers: [{ ttl: 100000, limit: 30 }],
}), */
WinstonModule.forRoot(winstonLoggerOptions),
ClientModule,
AuthenticationModule,
@ -96,18 +82,16 @@ import { SchedulerModule } from './scheduler/scheduler.module';
OccupancyModule,
WeatherModule,
AqiModule,
SchedulerModule,
NestScheduleModule.forRoot(),
],
providers: [
{
provide: APP_INTERCEPTOR,
useClass: LoggingInterceptor,
},
{
/* {
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
}, */
],
})
export class AppModule {}

View File

@ -30,8 +30,6 @@ import { PowerClampService } from '@app/common/helper/services/power.clamp.servi
import { SqlLoaderService } from '@app/common/helper/services/sql-loader.service';
import { OccupancyService } from '@app/common/helper/services/occupancy.service';
import { AqiDataService } from '@app/common/helper/services/aqi.data.service';
import { PresenceSensorDailySpaceRepository } from '@app/common/modules/presence-sensor/repositories';
import { AqiSpaceDailyPollutantStatsRepository } from '@app/common/modules/aqi/repositories';
@Module({
imports: [ConfigModule, SpaceRepositoryModule],
@ -61,8 +59,6 @@ import { AqiSpaceDailyPollutantStatsRepository } from '@app/common/modules/aqi/r
SqlLoaderService,
OccupancyService,
AqiDataService,
PresenceSensorDailySpaceRepository,
AqiSpaceDailyPollutantStatsRepository,
],
exports: [],
})

View File

@ -64,8 +64,6 @@ import {
import { SqlLoaderService } from '@app/common/helper/services/sql-loader.service';
import { OccupancyService } from '@app/common/helper/services/occupancy.service';
import { AqiDataService } from '@app/common/helper/services/aqi.data.service';
import { PresenceSensorDailySpaceRepository } from '@app/common/modules/presence-sensor/repositories';
import { AqiSpaceDailyPollutantStatsRepository } from '@app/common/modules/aqi/repositories';
@Module({
imports: [ConfigModule, SpaceRepositoryModule, UserRepositoryModule],
@ -80,7 +78,6 @@ import { AqiSpaceDailyPollutantStatsRepository } from '@app/common/modules/aqi/r
ProjectRepository,
SpaceService,
InviteSpaceRepository,
// Todo: find out why this is needed
SpaceLinkService,
SubSpaceService,
ValidationService,
@ -120,8 +117,6 @@ import { AqiSpaceDailyPollutantStatsRepository } from '@app/common/modules/aqi/r
SqlLoaderService,
OccupancyService,
AqiDataService,
PresenceSensorDailySpaceRepository,
AqiSpaceDailyPollutantStatsRepository,
],
exports: [CommunityService, SpacePermissionService],
})

View File

@ -1,3 +1,4 @@
import { CommunityService } from '../services/community.service';
import {
Body,
Controller,
@ -9,18 +10,17 @@ import {
Query,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { AddCommunityDto } from '../dtos/add.community.dto';
import { GetCommunityParams } from '../dtos/get.community.dto';
import { UpdateCommunityNameDto } from '../dtos/update.community.dto';
import { CommunityService } from '../services/community.service';
// import { CheckUserCommunityGuard } from 'src/guards/user.community.guard';
import { ControllerRoute } from '@app/common/constants/controller-route';
import { BaseResponseDto } from '@app/common/dto/base.response.dto';
import { PaginationRequestWithSearchGetListDto } from '@app/common/dto/pagination-with-search.request.dto';
import { Permissions } from 'src/decorators/permissions.decorator';
import { PermissionsGuard } from 'src/guards/permissions.guard';
import { ProjectParam } from '../dtos';
import { PermissionsGuard } from 'src/guards/permissions.guard';
import { Permissions } from 'src/decorators/permissions.decorator';
import { PaginationRequestWithSearchGetListDto } from '@app/common/dto/pagination-with-search.request.dto';
@ApiTags('Community Module')
@Controller({
@ -45,21 +45,6 @@ export class CommunityController {
return await this.communityService.createCommunity(param, addCommunityDto);
}
@ApiBearerAuth()
@UseGuards(PermissionsGuard)
@Permissions('COMMUNITY_VIEW')
@ApiOperation({
summary: ControllerRoute.COMMUNITY.ACTIONS.LIST_COMMUNITY_SUMMARY,
description: ControllerRoute.COMMUNITY.ACTIONS.LIST_COMMUNITY_DESCRIPTION,
})
@Get('v2')
async getCommunitiesV2(
@Param() param: ProjectParam,
@Query() query: PaginationRequestWithSearchGetListDto,
): Promise<any> {
return this.communityService.getCommunitiesV2(param, query);
}
@ApiBearerAuth()
@UseGuards(PermissionsGuard)
@Permissions('COMMUNITY_VIEW')

View File

@ -1,7 +1,4 @@
import {
ORPHAN_COMMUNITY_NAME,
ORPHAN_SPACE_NAME,
} from '@app/common/constants/orphan-constant';
import { ORPHAN_COMMUNITY_NAME } from '@app/common/constants/orphan-constant';
import { BaseResponseDto } from '@app/common/dto/base.response.dto';
import { PageResponse } from '@app/common/dto/pagination.response.dto';
import { SuccessResponseDto } from '@app/common/dto/success.response.dto';
@ -25,7 +22,7 @@ import {
NotFoundException,
} from '@nestjs/common';
import { SpaceService } from 'src/space/services';
import { QueryRunner, SelectQueryBuilder } from 'typeorm';
import { SelectQueryBuilder } from 'typeorm';
import { AddCommunityDto, GetCommunityParams, ProjectParam } from '../dtos';
import { UpdateCommunityNameDto } from '../dtos/update.community.dto';
@ -72,18 +69,12 @@ export class CommunityService {
}
}
async getCommunityById(
params: GetCommunityParams,
queryRunner?: QueryRunner,
): Promise<BaseResponseDto> {
async getCommunityById(params: GetCommunityParams): Promise<BaseResponseDto> {
const { communityUuid, projectUuid } = params;
await this.validateProject(projectUuid);
const communityRepository =
queryRunner?.manager.getRepository(CommunityEntity) ||
this.communityRepository;
const community = await communityRepository.findOneBy({
const community = await this.communityRepository.findOneBy({
uuid: communityUuid,
});
@ -120,6 +111,7 @@ export class CommunityService {
.leftJoin('c.spaces', 's', 's.disabled = false')
.where('c.project = :projectUuid', { projectUuid })
.andWhere(`c.name != '${ORPHAN_COMMUNITY_NAME}-${project.name}'`)
.orderBy('c.createdAt', 'DESC')
.distinct(true);
if (pageable.search) {
qb.andWhere(
@ -170,75 +162,6 @@ export class CommunityService {
}
}
async getCommunitiesV2(
{ projectUuid }: ProjectParam,
{
search,
includeSpaces,
...pageable
}: Partial<ExtendedTypeORMCustomModelFindAllQuery>,
) {
try {
const project = await this.validateProject(projectUuid);
let qb: undefined | SelectQueryBuilder<CommunityEntity> = undefined;
qb = this.communityRepository
.createQueryBuilder('c')
.where('c.project = :projectUuid', { projectUuid })
.andWhere(`c.name != '${ORPHAN_COMMUNITY_NAME}-${project.name}'`)
.distinct(true);
if (includeSpaces) {
qb.leftJoinAndSelect(
'c.spaces',
'space',
'space.disabled = :disabled AND space.spaceName != :orphanSpaceName',
{ disabled: false, orphanSpaceName: ORPHAN_SPACE_NAME },
)
.leftJoinAndSelect('space.parent', 'parent')
.leftJoinAndSelect(
'space.children',
'children',
'children.disabled = :disabled',
{ disabled: false },
);
// .leftJoinAndSelect('space.spaceModel', 'spaceModel')
}
if (search) {
qb.andWhere(
`c.name ILIKE :search ${includeSpaces ? 'OR space.space_name ILIKE :search' : ''}`,
{ search },
);
}
const customModel = TypeORMCustomModel(this.communityRepository);
const { baseResponseDto, paginationResponseDto } =
await customModel.findAll({ ...pageable, modelName: 'community' }, qb);
if (includeSpaces) {
baseResponseDto.data = baseResponseDto.data.map((community) => ({
...community,
spaces: this.spaceService.buildSpaceHierarchy(community.spaces || []),
}));
}
return new PageResponse<CommunityDto>(
baseResponseDto,
paginationResponseDto,
);
} catch (error) {
// Generic error handling
if (error instanceof HttpException) {
throw error;
}
throw new HttpException(
error.message || 'An error occurred while fetching communities.',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
async updateCommunity(
params: GetCommunityParams,
updateCommunityDto: UpdateCommunityNameDto,

View File

@ -1,28 +0,0 @@
interface BaseCommand {
code: string;
value: any;
}
export interface ControlCur2Command extends BaseCommand {
code: 'control';
value: 'open' | 'close' | 'stop';
}
export interface ControlCur2PercentCommand extends BaseCommand {
code: 'percent_control';
value: 10 | 20 | 30 | 40 | 50 | 60 | 70 | 80 | 90 | 100;
}
export interface ControlCur2AccurateCalibrationCommand extends BaseCommand {
code: 'accurate_calibration';
value: 'start' | 'end'; // Assuming this is a numeric value for calibration
}
export interface ControlCur2TDirectionConCommand extends BaseCommand {
code: 'control_t_direction_con';
value: 'forward' | 'back';
}
export interface ControlCur2QuickCalibrationCommand extends BaseCommand {
code: 'tr_timecon';
value: number; // between 10 and 120
}
export interface ControlCur2MotorModeCommand extends BaseCommand {
code: 'elec_machinery_mode';
value: 'strong_power' | 'dry_contact';
}

View File

@ -1,11 +1,11 @@
import { ControllerRoute } from '@app/common/constants/controller-route';
import { EnableDisableStatusEnum } from '@app/common/constants/days.enum';
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Permissions } from 'src/decorators/permissions.decorator';
import { PermissionsGuard } from 'src/guards/permissions.guard';
import { GetDevicesFilterDto, ProjectParam } from '../dtos';
import { DeviceService } from '../services/device.service';
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { EnableDisableStatusEnum } from '@app/common/constants/days.enum';
import { ControllerRoute } from '@app/common/constants/controller-route';
import { PermissionsGuard } from 'src/guards/permissions.guard';
import { Permissions } from 'src/decorators/permissions.decorator';
import { GetDoorLockDevices, ProjectParam } from '../dtos';
@ApiTags('Device Module')
@Controller({
@ -25,7 +25,7 @@ export class DeviceProjectController {
})
async getAllDevices(
@Param() param: ProjectParam,
@Query() query: GetDevicesFilterDto,
@Query() query: GetDoorLockDevices,
) {
return await this.deviceService.getAllDevices(param, query);
}

View File

@ -1,7 +1,6 @@
import { DeviceTypeEnum } from '@app/common/constants/device-type.enum';
import { ApiProperty } from '@nestjs/swagger';
import {
IsArray,
IsEnum,
IsNotEmpty,
IsOptional,
@ -42,7 +41,16 @@ export class GetDeviceLogsDto {
@IsOptional()
public endTime: string;
}
export class GetDoorLockDevices {
@ApiProperty({
description: 'Device Type',
enum: DeviceTypeEnum,
required: false,
})
@IsEnum(DeviceTypeEnum)
@IsOptional()
public deviceType: DeviceTypeEnum;
}
export class GetDevicesBySpaceOrCommunityDto {
@ApiProperty({
description: 'Device Product Type',
@ -64,23 +72,3 @@ export class GetDevicesBySpaceOrCommunityDto {
@IsNotEmpty({ message: 'Either spaceUuid or communityUuid must be provided' })
requireEither?: never; // This ensures at least one of them is provided
}
export class GetDevicesFilterDto {
@ApiProperty({
description: 'Device Type',
enum: DeviceTypeEnum,
required: false,
})
@IsEnum(DeviceTypeEnum)
@IsOptional()
public deviceType: DeviceTypeEnum;
@ApiProperty({
description: 'List of Space IDs to filter devices',
required: false,
example: ['60d21b4667d0d8992e610c85', '60d21b4967d0d8992e610c86'],
})
@IsOptional()
@IsArray()
@IsUUID('4', { each: true })
public spaces?: string[];
}

View File

@ -53,7 +53,7 @@ import { DeviceSceneParamDto } from '../dtos/device.param.dto';
import {
GetDeviceLogsDto,
GetDevicesBySpaceOrCommunityDto,
GetDevicesFilterDto,
GetDoorLockDevices,
} from '../dtos/get.device.dto';
import {
controlDeviceInterface,
@ -955,20 +955,19 @@ export class DeviceService {
async getAllDevices(
param: ProjectParam,
{ deviceType, spaces }: GetDevicesFilterDto,
query: GetDoorLockDevices,
): Promise<BaseResponseDto> {
try {
await this.validateProject(param.projectUuid);
if (deviceType === DeviceTypeEnum.DOOR_LOCK) {
return await this.getDoorLockDevices(param.projectUuid, spaces);
} else if (!deviceType) {
if (query.deviceType === DeviceTypeEnum.DOOR_LOCK) {
return await this.getDoorLockDevices(param.projectUuid);
} else if (!query.deviceType) {
const devices = await this.deviceRepository.find({
where: {
isActive: true,
spaceDevice: {
uuid: spaces && spaces.length ? In(spaces) : undefined,
spaceName: Not(ORPHAN_SPACE_NAME),
community: { project: { uuid: param.projectUuid } },
spaceName: Not(ORPHAN_SPACE_NAME),
},
},
relations: [
@ -1564,7 +1563,7 @@ export class DeviceService {
}
}
async getDoorLockDevices(projectUuid: string, spaces?: string[]) {
async getDoorLockDevices(projectUuid: string) {
await this.validateProject(projectUuid);
const devices = await this.deviceRepository.find({
@ -1574,7 +1573,6 @@ export class DeviceService {
},
spaceDevice: {
spaceName: Not(ORPHAN_SPACE_NAME),
uuid: spaces && spaces.length ? In(spaces) : undefined,
community: {
project: {
uuid: projectUuid,

View File

@ -30,8 +30,6 @@ import { SqlLoaderService } from '@app/common/helper/services/sql-loader.service
import { OccupancyService } from '@app/common/helper/services/occupancy.service';
import { CommunityRepository } from '@app/common/modules/community/repositories';
import { AqiDataService } from '@app/common/helper/services/aqi.data.service';
import { PresenceSensorDailySpaceRepository } from '@app/common/modules/presence-sensor/repositories';
import { AqiSpaceDailyPollutantStatsRepository } from '@app/common/modules/aqi/repositories';
@Module({
imports: [ConfigModule, DeviceRepositoryModule],
controllers: [DoorLockController],
@ -60,8 +58,6 @@ import { AqiSpaceDailyPollutantStatsRepository } from '@app/common/modules/aqi/r
OccupancyService,
CommunityRepository,
AqiDataService,
PresenceSensorDailySpaceRepository,
AqiSpaceDailyPollutantStatsRepository,
],
exports: [DoorLockService],
})

View File

@ -28,8 +28,6 @@ import { SqlLoaderService } from '@app/common/helper/services/sql-loader.service
import { OccupancyService } from '@app/common/helper/services/occupancy.service';
import { CommunityRepository } from '@app/common/modules/community/repositories';
import { AqiDataService } from '@app/common/helper/services/aqi.data.service';
import { PresenceSensorDailySpaceRepository } from '@app/common/modules/presence-sensor/repositories';
import { AqiSpaceDailyPollutantStatsRepository } from '@app/common/modules/aqi/repositories';
@Module({
imports: [ConfigModule, DeviceRepositoryModule],
controllers: [GroupController],
@ -57,8 +55,6 @@ import { AqiSpaceDailyPollutantStatsRepository } from '@app/common/modules/aqi/r
OccupancyService,
CommunityRepository,
AqiDataService,
PresenceSensorDailySpaceRepository,
AqiSpaceDailyPollutantStatsRepository,
],
exports: [GroupService],
})

View File

@ -71,6 +71,7 @@ import {
import { SpaceModelProductAllocationService } from 'src/space-model/services/space-model-product-allocation.service';
import { SubspaceModelProductAllocationService } from 'src/space-model/services/subspace/subspace-model-product-allocation.service';
import {
SpaceLinkService,
SpaceService,
SpaceUserService,
SubspaceDeviceService,
@ -82,8 +83,6 @@ import { SubspaceProductAllocationService } from 'src/space/services/subspace/su
import { TagService as NewTagService } from 'src/tags/services';
import { UserDevicePermissionService } from 'src/user-device-permission/services';
import { UserService, UserSpaceService } from 'src/users/services';
import { PresenceSensorDailySpaceRepository } from '@app/common/modules/presence-sensor/repositories';
import { AqiSpaceDailyPollutantStatsRepository } from '@app/common/modules/aqi/repositories';
@Module({
imports: [ConfigModule, InviteUserRepositoryModule, CommunityModule],
@ -116,6 +115,7 @@ import { AqiSpaceDailyPollutantStatsRepository } from '@app/common/modules/aqi/r
TimeZoneRepository,
SpaceService,
InviteSpaceRepository,
SpaceLinkService,
SubSpaceService,
ValidationService,
NewTagService,
@ -152,8 +152,6 @@ import { AqiSpaceDailyPollutantStatsRepository } from '@app/common/modules/aqi/r
SqlLoaderService,
OccupancyService,
AqiDataService,
PresenceSensorDailySpaceRepository,
AqiSpaceDailyPollutantStatsRepository,
],
exports: [InviteUserService],
})

View File

@ -1,13 +1,15 @@
import { RequestContextMiddleware } from '@app/common/middleware/request-context.middleware';
import { SeederService } from '@app/common/seed/services/seeder.service';
import { Logger, ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { json, urlencoded } from 'body-parser';
import helmet from 'helmet';
import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston';
import { setupSwaggerAuthentication } from '../libs/common/src/util/user-auth.swagger.utils';
import { AppModule } from './app.module';
import rateLimit from 'express-rate-limit';
import helmet from 'helmet';
import { setupSwaggerAuthentication } from '../libs/common/src/util/user-auth.swagger.utils';
import { ValidationPipe } from '@nestjs/common';
import { json, urlencoded } from 'body-parser';
import { SeederService } from '@app/common/seed/services/seeder.service';
import { HttpExceptionFilter } from './common/filters/http-exception/http-exception.filter';
import { Logger } from '@nestjs/common';
import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston';
import { RequestContextMiddleware } from '@app/common/middleware/request-context.middleware';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
@ -21,6 +23,13 @@ async function bootstrap() {
app.use(new RequestContextMiddleware().use);
app.use(
rateLimit({
windowMs: 5 * 60 * 1000,
max: 500,
}),
);
app.use(
helmet({
contentSecurityPolicy: false,
@ -48,7 +57,8 @@ async function bootstrap() {
logger.error('Seeding failed!', error.stack || error);
}
logger.log('Starting auth at port ...', process.env.PORT || 4000);
await app.listen(process.env.PORT || 4000);
const port = process.env.PORT || 3000;
logger.log(`Starting application on port ${port}...`);
await app.listen(port, '0.0.0.0');
}
bootstrap();

View File

@ -1,25 +1,24 @@
import { ControllerRoute } from '@app/common/constants/controller-route';
import { EnableDisableStatusEnum } from '@app/common/constants/days.enum';
import { BaseResponseDto } from '@app/common/dto/base.response.dto';
import { JwtAuthGuard } from '@app/common/guards/jwt.auth.guard';
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
import {
ApiTags,
ApiBearerAuth,
ApiOperation,
ApiParam,
ApiQuery,
ApiTags,
} from '@nestjs/swagger';
import { EnableDisableStatusEnum } from '@app/common/constants/days.enum';
import { ControllerRoute } from '@app/common/constants/controller-route';
import { JwtAuthGuard } from '@app/common/guards/jwt.auth.guard';
import { PowerClampService } from '../services/power-clamp.service';
import {
GetPowerClampBySpaceDto,
GetPowerClampDto,
} from '../dto/get-power-clamp.dto';
import { BaseResponseDto } from '@app/common/dto/base.response.dto';
import {
PowerClampParamsDto,
ResourceParamsDto,
} from '../dto/power-clamp-params.dto';
import { PowerClampService } from '../services/power-clamp.service';
@ApiTags('Power Clamp Module')
@Controller({
version: EnableDisableStatusEnum.ENABLED,
@ -27,7 +26,6 @@ import {
})
export class PowerClampController {
constructor(private readonly powerClampService: PowerClampService) {}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Get(':powerClampUuid/historical')

View File

@ -1,5 +1,4 @@
import { DeviceStatusFirebaseService } from '@app/common/firebase/devices-status/services/devices-status.service';
import { AqiDataService } from '@app/common/helper/services/aqi.data.service';
import { OccupancyService } from '@app/common/helper/services/occupancy.service';
import { PowerClampService } from '@app/common/helper/services/power.clamp.service';
import { SqlLoaderService } from '@app/common/helper/services/sql-loader.service';
@ -50,6 +49,7 @@ import { SpaceModelProductAllocationService } from 'src/space-model/services/spa
import { SubspaceModelProductAllocationService } from 'src/space-model/services/subspace/subspace-model-product-allocation.service';
import {
SpaceDeviceService,
SpaceLinkService,
SpaceService,
SubspaceDeviceService,
SubSpaceService,
@ -60,8 +60,7 @@ import { SubspaceProductAllocationService } from 'src/space/services/subspace/su
import { TagService } from 'src/tags/services';
import { PowerClampController } from './controllers';
import { PowerClampService as PowerClamp } from './services/power-clamp.service';
import { PresenceSensorDailySpaceRepository } from '@app/common/modules/presence-sensor/repositories';
import { AqiSpaceDailyPollutantStatsRepository } from '@app/common/modules/aqi/repositories';
import { AqiDataService } from '@app/common/helper/services/aqi.data.service';
@Module({
imports: [ConfigModule],
controllers: [PowerClampController],
@ -91,6 +90,7 @@ import { AqiSpaceDailyPollutantStatsRepository } from '@app/common/modules/aqi/r
SceneRepository,
AutomationRepository,
InviteSpaceRepository,
SpaceLinkService,
SubSpaceService,
TagService,
SpaceModelService,
@ -111,8 +111,6 @@ import { AqiSpaceDailyPollutantStatsRepository } from '@app/common/modules/aqi/r
SubspaceModelProductAllocationRepoitory,
OccupancyService,
AqiDataService,
PresenceSensorDailySpaceRepository,
AqiSpaceDailyPollutantStatsRepository,
],
exports: [PowerClamp],
})

View File

@ -23,10 +23,10 @@ import { SpaceDeviceService } from 'src/space/services';
import { SqlLoaderService } from '@app/common/helper/services/sql-loader.service';
import { DataSource } from 'typeorm';
import { SQL_PROCEDURES_PATH } from '@app/common/constants/sql-query-path';
import { filterByMonth, toMMYYYY } from '@app/common/helper/date-format';
import { ProductType } from '@app/common/constants/product-type.enum';
import { CommunityService } from 'src/community/services';
import { BaseResponseDto } from '@app/common/dto/base.response.dto';
import { filterByMonth, toMMYYYY } from '@app/common/helper/date-format';
@Injectable()
export class PowerClampService {

View File

@ -54,6 +54,7 @@ import {
import { SpaceModelProductAllocationService } from 'src/space-model/services/space-model-product-allocation.service';
import { SubspaceModelProductAllocationService } from 'src/space-model/services/subspace/subspace-model-product-allocation.service';
import {
SpaceLinkService,
SpaceService,
SubspaceDeviceService,
SubSpaceService,
@ -67,8 +68,6 @@ import { ProjectUserController } from './controllers/project-user.controller';
import { CreateOrphanSpaceHandler } from './handler';
import { ProjectService } from './services';
import { ProjectUserService } from './services/project-user.service';
import { PresenceSensorDailySpaceRepository } from '@app/common/modules/presence-sensor/repositories';
import { AqiSpaceDailyPollutantStatsRepository } from '@app/common/modules/aqi/repositories';
const CommandHandlers = [CreateOrphanSpaceHandler];
@ -88,6 +87,7 @@ const CommandHandlers = [CreateOrphanSpaceHandler];
UserRepository,
SpaceService,
InviteSpaceRepository,
SpaceLinkService,
SubSpaceService,
ValidationService,
TagService,
@ -126,8 +126,6 @@ const CommandHandlers = [CreateOrphanSpaceHandler];
SqlLoaderService,
OccupancyService,
AqiDataService,
PresenceSensorDailySpaceRepository,
AqiSpaceDailyPollutantStatsRepository,
],
exports: [ProjectService, CqrsModule],
})

View File

@ -24,7 +24,6 @@ import { SpaceService } from 'src/space/services';
import { PassThrough } from 'stream';
import { CreateOrphanSpaceCommand } from '../command/create-orphan-space-command';
import { CreateProjectDto, GetProjectParam } from '../dto';
import { QueryRunner } from 'typeorm';
@Injectable()
export class ProjectService {
@ -213,14 +212,8 @@ export class ProjectService {
}
}
async findOne(
uuid: string,
queryRunner?: QueryRunner,
): Promise<ProjectEntity> {
const projectRepository = queryRunner
? queryRunner.manager.getRepository(ProjectEntity)
: this.projectRepository;
const project = await projectRepository.findOne({ where: { uuid } });
async findOne(uuid: string): Promise<ProjectEntity> {
const project = await this.projectRepository.findOne({ where: { uuid } });
if (!project) {
throw new HttpException(
`Invalid project with uuid ${uuid}`,

View File

@ -1,32 +0,0 @@
import {
ControlCur2AccurateCalibrationCommand,
ControlCur2Command,
ControlCur2PercentCommand,
ControlCur2QuickCalibrationCommand,
ControlCur2TDirectionConCommand,
} from 'src/device/commands/cur2-commands';
export enum ScheduleProductType {
CUR_2 = 'CUR_2',
}
export const DeviceFunctionMap: {
[T in ScheduleProductType]: (body: DeviceFunction[T]) => any;
} = {
[ScheduleProductType.CUR_2]: ({ code, value }) => {
return [
{
code,
value,
},
];
},
};
type DeviceFunction = {
[ScheduleProductType.CUR_2]:
| ControlCur2Command
| ControlCur2PercentCommand
| ControlCur2AccurateCalibrationCommand
| ControlCur2TDirectionConCommand
| ControlCur2QuickCalibrationCommand;
};

View File

@ -49,11 +49,23 @@ export class ScheduleService {
}
// Corrected condition for supported device types
this.ensureProductTypeSupportedForSchedule(
deviceDetails.productDevice.prodType as ProductType,
);
return this.enableScheduleDeviceInTuya(
if (
deviceDetails.productDevice.prodType !== ProductType.THREE_G &&
deviceDetails.productDevice.prodType !== ProductType.ONE_G &&
deviceDetails.productDevice.prodType !== ProductType.TWO_G &&
deviceDetails.productDevice.prodType !== ProductType.WH &&
deviceDetails.productDevice.prodType !== ProductType.ONE_1TG &&
deviceDetails.productDevice.prodType !== ProductType.TWO_2TG &&
deviceDetails.productDevice.prodType !== ProductType.THREE_3TG &&
deviceDetails.productDevice.prodType !== ProductType.GD &&
deviceDetails.productDevice.prodType !== ProductType.CUR_2
) {
throw new HttpException(
'This device is not supported for schedule',
HttpStatus.BAD_REQUEST,
);
}
return await this.enableScheduleDeviceInTuya(
deviceDetails.deviceTuyaUuid,
enableScheduleDto,
);
@ -64,6 +76,29 @@ export class ScheduleService {
);
}
}
async enableScheduleDeviceInTuya(
deviceId: string,
enableScheduleDto: EnableScheduleDto,
): Promise<addScheduleDeviceInterface> {
try {
const path = `/v2.0/cloud/timer/device/${deviceId}/state`;
const response = await this.tuya.request({
method: 'PUT',
path,
body: {
enable: enableScheduleDto.enable,
timer_id: enableScheduleDto.scheduleId,
},
});
return response as addScheduleDeviceInterface;
} catch (error) {
throw new HttpException(
'Error while updating schedule from Tuya',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
async deleteDeviceSchedule(deviceUuid: string, scheduleId: string) {
try {
const deviceDetails = await this.getDeviceByDeviceUuid(deviceUuid);
@ -73,10 +108,22 @@ export class ScheduleService {
}
// Corrected condition for supported device types
this.ensureProductTypeSupportedForSchedule(
deviceDetails.productDevice.prodType as ProductType,
);
if (
deviceDetails.productDevice.prodType !== ProductType.THREE_G &&
deviceDetails.productDevice.prodType !== ProductType.ONE_G &&
deviceDetails.productDevice.prodType !== ProductType.TWO_G &&
deviceDetails.productDevice.prodType !== ProductType.WH &&
deviceDetails.productDevice.prodType !== ProductType.ONE_1TG &&
deviceDetails.productDevice.prodType !== ProductType.TWO_2TG &&
deviceDetails.productDevice.prodType !== ProductType.THREE_3TG &&
deviceDetails.productDevice.prodType !== ProductType.GD &&
deviceDetails.productDevice.prodType !== ProductType.CUR_2
) {
throw new HttpException(
'This device is not supported for schedule',
HttpStatus.BAD_REQUEST,
);
}
return await this.deleteScheduleDeviceInTuya(
deviceDetails.deviceTuyaUuid,
scheduleId,
@ -88,6 +135,25 @@ export class ScheduleService {
);
}
}
async deleteScheduleDeviceInTuya(
deviceId: string,
scheduleId: string,
): Promise<addScheduleDeviceInterface> {
try {
const path = `/v2.0/cloud/timer/device/${deviceId}/batch?timer_ids=${scheduleId}`;
const response = await this.tuya.request({
method: 'DELETE',
path,
});
return response as addScheduleDeviceInterface;
} catch (error) {
throw new HttpException(
'Error while deleting schedule from Tuya',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
async addDeviceSchedule(deviceUuid: string, addScheduleDto: AddScheduleDto) {
try {
const deviceDetails = await this.getDeviceByDeviceUuid(deviceUuid);
@ -96,10 +162,23 @@ export class ScheduleService {
throw new HttpException('Device Not Found', HttpStatus.NOT_FOUND);
}
this.ensureProductTypeSupportedForSchedule(
deviceDetails.productDevice.prodType as ProductType,
);
// Corrected condition for supported device types
if (
deviceDetails.productDevice.prodType !== ProductType.THREE_G &&
deviceDetails.productDevice.prodType !== ProductType.ONE_G &&
deviceDetails.productDevice.prodType !== ProductType.TWO_G &&
deviceDetails.productDevice.prodType !== ProductType.WH &&
deviceDetails.productDevice.prodType !== ProductType.ONE_1TG &&
deviceDetails.productDevice.prodType !== ProductType.TWO_2TG &&
deviceDetails.productDevice.prodType !== ProductType.THREE_3TG &&
deviceDetails.productDevice.prodType !== ProductType.GD &&
deviceDetails.productDevice.prodType !== ProductType.CUR_2
) {
throw new HttpException(
'This device is not supported for schedule',
HttpStatus.BAD_REQUEST,
);
}
await this.addScheduleDeviceInTuya(
deviceDetails.deviceTuyaUuid,
addScheduleDto,
@ -111,6 +190,40 @@ export class ScheduleService {
);
}
}
async addScheduleDeviceInTuya(
deviceId: string,
addScheduleDto: AddScheduleDto,
): Promise<addScheduleDeviceInterface> {
try {
const convertedTime = convertTimestampToDubaiTime(addScheduleDto.time);
const loops = getScheduleStatus(addScheduleDto.days);
const path = `/v2.0/cloud/timer/device/${deviceId}`;
const response = await this.tuya.request({
method: 'POST',
path,
body: {
time: convertedTime.time,
timezone_id: 'Asia/Dubai',
loops: `${loops}`,
functions: [
{
code: addScheduleDto.function.code,
value: addScheduleDto.function.value,
},
],
category: `category_${addScheduleDto.category}`,
},
});
return response as addScheduleDeviceInterface;
} catch (error) {
throw new HttpException(
'Error adding schedule from Tuya',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
async getDeviceScheduleByCategory(deviceUuid: string, category: string) {
try {
const deviceDetails = await this.getDeviceByDeviceUuid(deviceUuid);
@ -119,9 +232,22 @@ export class ScheduleService {
throw new HttpException('Device Not Found', HttpStatus.NOT_FOUND);
}
// Corrected condition for supported device types
this.ensureProductTypeSupportedForSchedule(
deviceDetails.productDevice.prodType as ProductType,
);
if (
deviceDetails.productDevice.prodType !== ProductType.THREE_G &&
deviceDetails.productDevice.prodType !== ProductType.ONE_G &&
deviceDetails.productDevice.prodType !== ProductType.TWO_G &&
deviceDetails.productDevice.prodType !== ProductType.WH &&
deviceDetails.productDevice.prodType !== ProductType.ONE_1TG &&
deviceDetails.productDevice.prodType !== ProductType.TWO_2TG &&
deviceDetails.productDevice.prodType !== ProductType.THREE_3TG &&
deviceDetails.productDevice.prodType !== ProductType.GD &&
deviceDetails.productDevice.prodType !== ProductType.CUR_2
) {
throw new HttpException(
'This device is not supported for schedule',
HttpStatus.BAD_REQUEST,
);
}
const schedules = await this.getScheduleDeviceInTuya(
deviceDetails.deviceTuyaUuid,
category,
@ -148,82 +274,7 @@ export class ScheduleService {
);
}
}
async updateDeviceSchedule(
deviceUuid: string,
updateScheduleDto: UpdateScheduleDto,
) {
try {
const deviceDetails = await this.getDeviceByDeviceUuid(deviceUuid);
if (!deviceDetails || !deviceDetails.deviceTuyaUuid) {
throw new HttpException('Device Not Found', HttpStatus.NOT_FOUND);
}
// Corrected condition for supported device types
this.ensureProductTypeSupportedForSchedule(
deviceDetails.productDevice.prodType as ProductType,
);
await this.updateScheduleDeviceInTuya(
deviceDetails.deviceTuyaUuid,
updateScheduleDto,
);
} catch (error) {
throw new HttpException(
error.message || 'Error While Updating Schedule',
error.status || HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
private getDeviceByDeviceUuid(
deviceUuid: string,
withProductDevice: boolean = true,
) {
return this.deviceRepository.findOne({
where: {
uuid: deviceUuid,
isActive: true,
},
...(withProductDevice && { relations: ['productDevice'] }),
});
}
private async addScheduleDeviceInTuya(
deviceId: string,
addScheduleDto: AddScheduleDto,
): Promise<addScheduleDeviceInterface> {
try {
const convertedTime = convertTimestampToDubaiTime(addScheduleDto.time);
const loops = getScheduleStatus(addScheduleDto.days);
const path = `/v2.0/cloud/timer/device/${deviceId}`;
const response = await this.tuya.request({
method: 'POST',
path,
body: {
time: convertedTime.time,
timezone_id: 'Asia/Dubai',
loops: `${loops}`,
functions: [
{
...addScheduleDto.function,
},
],
category: `category_${addScheduleDto.category}`,
},
});
return response as addScheduleDeviceInterface;
} catch (error) {
throw new HttpException(
'Error adding schedule from Tuya',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
private async getScheduleDeviceInTuya(
async getScheduleDeviceInTuya(
deviceId: string,
category: string,
): Promise<getDeviceScheduleInterface> {
@ -244,8 +295,58 @@ export class ScheduleService {
);
}
}
async getDeviceByDeviceUuid(
deviceUuid: string,
withProductDevice: boolean = true,
) {
return await this.deviceRepository.findOne({
where: {
uuid: deviceUuid,
isActive: true,
},
...(withProductDevice && { relations: ['productDevice'] }),
});
}
async updateDeviceSchedule(
deviceUuid: string,
updateScheduleDto: UpdateScheduleDto,
) {
try {
const deviceDetails = await this.getDeviceByDeviceUuid(deviceUuid);
private async updateScheduleDeviceInTuya(
if (!deviceDetails || !deviceDetails.deviceTuyaUuid) {
throw new HttpException('Device Not Found', HttpStatus.NOT_FOUND);
}
// Corrected condition for supported device types
if (
deviceDetails.productDevice.prodType !== ProductType.THREE_G &&
deviceDetails.productDevice.prodType !== ProductType.ONE_G &&
deviceDetails.productDevice.prodType !== ProductType.TWO_G &&
deviceDetails.productDevice.prodType !== ProductType.WH &&
deviceDetails.productDevice.prodType !== ProductType.ONE_1TG &&
deviceDetails.productDevice.prodType !== ProductType.TWO_2TG &&
deviceDetails.productDevice.prodType !== ProductType.THREE_3TG &&
deviceDetails.productDevice.prodType !== ProductType.GD &&
deviceDetails.productDevice.prodType !== ProductType.CUR_2
) {
throw new HttpException(
'This device is not supported for schedule',
HttpStatus.BAD_REQUEST,
);
}
await this.updateScheduleDeviceInTuya(
deviceDetails.deviceTuyaUuid,
updateScheduleDto,
);
} catch (error) {
throw new HttpException(
error.message || 'Error While Updating Schedule',
error.status || HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
async updateScheduleDeviceInTuya(
deviceId: string,
updateScheduleDto: UpdateScheduleDto,
): Promise<addScheduleDeviceInterface> {
@ -280,69 +381,4 @@ export class ScheduleService {
);
}
}
private async enableScheduleDeviceInTuya(
deviceId: string,
enableScheduleDto: EnableScheduleDto,
): Promise<addScheduleDeviceInterface> {
try {
const path = `/v2.0/cloud/timer/device/${deviceId}/state`;
const response = await this.tuya.request({
method: 'PUT',
path,
body: {
enable: enableScheduleDto.enable,
timer_id: enableScheduleDto.scheduleId,
},
});
return response as addScheduleDeviceInterface;
} catch (error) {
throw new HttpException(
'Error while updating schedule from Tuya',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
private async deleteScheduleDeviceInTuya(
deviceId: string,
scheduleId: string,
): Promise<addScheduleDeviceInterface> {
try {
const path = `/v2.0/cloud/timer/device/${deviceId}/batch?timer_ids=${scheduleId}`;
const response = await this.tuya.request({
method: 'DELETE',
path,
});
return response as addScheduleDeviceInterface;
} catch (error) {
throw new HttpException(
'Error while deleting schedule from Tuya',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
private ensureProductTypeSupportedForSchedule(deviceType: ProductType): void {
if (
![
ProductType.THREE_G,
ProductType.ONE_G,
ProductType.TWO_G,
ProductType.WH,
ProductType.ONE_1TG,
ProductType.TWO_2TG,
ProductType.THREE_3TG,
ProductType.GD,
ProductType.CUR_2,
].includes(deviceType)
) {
throw new HttpException(
'This device is not supported for schedule',
HttpStatus.BAD_REQUEST,
);
}
}
}

View File

@ -1,25 +0,0 @@
import { DatabaseModule } from '@app/common/database/database.module';
import { SqlLoaderService } from '@app/common/helper/services/sql-loader.service';
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { SchedulerService } from './scheduler.service';
import { ScheduleModule as NestScheduleModule } from '@nestjs/schedule';
import { AqiDataService } from '@app/common/helper/services/aqi.data.service';
import { OccupancyService } from '@app/common/helper/services/occupancy.service';
import { PowerClampService } from '@app/common/helper/services/power.clamp.service';
@Module({
imports: [
NestScheduleModule.forRoot(),
TypeOrmModule.forFeature([]),
DatabaseModule,
],
providers: [
SchedulerService,
SqlLoaderService,
PowerClampService,
OccupancyService,
AqiDataService,
],
})
export class SchedulerModule {}

View File

@ -1,92 +0,0 @@
import { AqiDataService } from '@app/common/helper/services/aqi.data.service';
import { Injectable } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { OccupancyService } from '@app/common/helper/services/occupancy.service';
import { PowerClampService } from '@app/common/helper/services/power.clamp.service';
@Injectable()
export class SchedulerService {
constructor(
private readonly powerClampService: PowerClampService,
private readonly occupancyService: OccupancyService,
private readonly aqiDataService: AqiDataService,
) {
console.log('SchedulerService initialized!');
}
@Cron(CronExpression.EVERY_HOUR)
async runHourlyProcedures() {
console.log('\n======== Starting Procedures ========');
console.log(new Date().toISOString(), 'Scheduler running...');
try {
const results = await Promise.allSettled([
this.executeTask(
() => this.powerClampService.updateEnergyConsumedHistoricalData(),
'Energy Consumption',
),
this.executeTask(
() => this.occupancyService.updateOccupancyDataProcedures(),
'Occupancy Data',
),
this.executeTask(
() => this.aqiDataService.updateAQISensorHistoricalData(),
'AQI Data',
),
]);
this.logResults(results);
} catch (error) {
console.error('MAIN SCHEDULER ERROR:', error);
if (error.stack) {
console.error('Error stack:', error.stack);
}
}
}
private async executeTask(
task: () => Promise<void>,
name: string,
): Promise<{ name: string; status: string }> {
try {
console.log(`[${new Date().toISOString()}] Starting ${name} task...`);
await task();
console.log(
`[${new Date().toISOString()}] ${name} task completed successfully`,
);
return { name, status: 'success' };
} catch (error) {
console.error(
`[${new Date().toISOString()}] ${name} task failed:`,
error.message,
);
if (error.stack) {
console.error('Task error stack:', error.stack);
}
return { name, status: 'failed' };
}
}
private logResults(results: PromiseSettledResult<any>[]) {
const successCount = results.filter((r) => r.status === 'fulfilled').length;
const failedCount = results.length - successCount;
console.log('\n======== Task Results ========');
console.log(`Successful tasks: ${successCount}`);
console.log(`Failed tasks: ${failedCount}`);
if (failedCount > 0) {
console.log('\n======== Failed Tasks Details ========');
results.forEach((result, index) => {
if (result.status === 'rejected') {
console.error(`Task ${index + 1} failed:`, result.reason);
if (result.reason.stack) {
console.error('Error stack:', result.reason.stack);
}
}
});
}
console.log('\n======== Scheduler Completed ========\n');
}
}

View File

@ -51,12 +51,8 @@ export class SubSpaceModelService {
for (const [index, dto] of dtos.entries()) {
const subspaceModel = savedSubspaces[index];
const processedTags = await this.tagService.upsertTags(
dto.tags.map((tag) => ({
tagName: tag.name,
productUuid: tag.productUuid,
tagUuid: tag.uuid,
})),
const processedTags = await this.tagService.processTags(
dto.tags,
spaceModel.project.uuid,
queryRunner,
);

View File

@ -46,6 +46,7 @@ import { CommunityService } from 'src/community/services';
import { DeviceService } from 'src/device/services';
import { SceneService } from 'src/scene/services';
import {
SpaceLinkService,
SpaceService,
SubspaceDeviceService,
SubSpaceService,
@ -63,8 +64,6 @@ import {
import { SpaceModelService, SubSpaceModelService } from './services';
import { SpaceModelProductAllocationService } from './services/space-model-product-allocation.service';
import { SubspaceModelProductAllocationService } from './services/subspace/subspace-model-product-allocation.service';
import { PresenceSensorDailySpaceRepository } from '@app/common/modules/presence-sensor/repositories';
import { AqiSpaceDailyPollutantStatsRepository } from '@app/common/modules/aqi/repositories';
const CommandHandlers = [
PropogateUpdateSpaceModelHandler,
@ -93,6 +92,7 @@ const CommandHandlers = [
DeviceRepository,
TuyaService,
CommunityRepository,
SpaceLinkService,
SpaceLinkRepository,
InviteSpaceRepository,
NewTagService,
@ -122,8 +122,6 @@ const CommandHandlers = [
SqlLoaderService,
OccupancyService,
AqiDataService,
PresenceSensorDailySpaceRepository,
AqiSpaceDailyPollutantStatsRepository,
],
exports: [CqrsModule, SpaceModelService],
})

View File

@ -3,8 +3,7 @@ import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
ArrayUnique,
IsArray,
IsMongoId,
IsBoolean,
IsNotEmpty,
IsNumber,
IsOptional,
@ -13,7 +12,7 @@ import {
NotEquals,
ValidateNested,
} from 'class-validator';
import { CreateProductAllocationDto } from './create-product-allocation.dto';
import { ProcessTagDto } from 'src/tags/dtos';
import { AddSubspaceDto } from './subspace';
export class AddSpaceDto {
@ -48,6 +47,14 @@ export class AddSpaceDto {
@IsOptional()
public icon?: string;
@ApiProperty({
description: 'Indicates whether the space is private or public',
example: false,
default: false,
})
@IsBoolean()
isPrivate: boolean;
@ApiProperty({ description: 'X position on canvas', example: 120 })
@IsNumber()
x: number;
@ -57,19 +64,23 @@ export class AddSpaceDto {
y: number;
@ApiProperty({
description: 'UUID of the Space Model',
description: 'UUID of the Space',
example: 'd290f1ee-6c54-4b01-90e6-d701748f0851',
})
@IsMongoId()
@IsString()
@IsOptional()
spaceModelUuid?: string;
@ApiProperty({ description: 'Y position on canvas', example: 200 })
@IsString()
@IsOptional()
direction?: string;
@ApiProperty({
description: 'List of subspaces included in the model',
type: [AddSubspaceDto],
})
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@ArrayUnique((subspace) => subspace.subspaceName, {
message(validationArguments) {
@ -89,21 +100,51 @@ export class AddSpaceDto {
subspaces?: AddSubspaceDto[];
@ApiProperty({
description: 'List of allocations associated with the space',
type: [CreateProductAllocationDto],
description: 'List of tags associated with the space model',
type: [ProcessTagDto],
})
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => CreateProductAllocationDto)
productAllocations?: CreateProductAllocationDto[];
@ApiProperty({
description: 'List of children spaces associated with the space',
type: [AddSpaceDto],
})
@IsOptional()
@ValidateNested({ each: true })
@Type(() => AddSpaceDto)
children?: AddSpaceDto[];
@Type(() => ProcessTagDto)
tags?: ProcessTagDto[];
}
export class AddUserSpaceDto {
@ApiProperty({
description: 'spaceUuid',
required: true,
})
@IsString()
@IsNotEmpty()
public spaceUuid: string;
@ApiProperty({
description: 'userUuid',
required: true,
})
@IsString()
@IsNotEmpty()
public userUuid: string;
constructor(dto: Partial<AddUserSpaceDto>) {
Object.assign(this, dto);
}
}
export class AddUserSpaceUsingCodeDto {
@ApiProperty({
description: 'userUuid',
required: true,
})
@IsString()
@IsNotEmpty()
public userUuid: string;
@ApiProperty({
description: 'inviteCode',
required: true,
})
@IsString()
@IsNotEmpty()
public inviteCode: string;
constructor(dto: Partial<AddUserSpaceDto>) {
Object.assign(this, dto);
}
}

View File

@ -1,14 +1,14 @@
import { SpaceEntity } from '@app/common/modules/space/entities/space.entity';
import { SubspaceEntity } from '@app/common/modules/space/entities/subspace/subspace.entity';
import { ProcessTagDto } from 'src/tags/dtos';
import { QueryRunner } from 'typeorm';
import { CreateProductAllocationDto } from './create-product-allocation.dto';
export enum AllocationsOwnerType {
SPACE = 'space',
SUBSPACE = 'subspace',
}
export class BaseCreateAllocationsDto {
productAllocations: CreateProductAllocationDto[];
tags: ProcessTagDto[];
projectUuid: string;
queryRunner: QueryRunner;
type: AllocationsOwnerType;

View File

@ -1,36 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import {
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
ValidateIf,
} from 'class-validator';
export class CreateProductAllocationDto {
@ApiProperty({
description: 'The name of the tag (if creating a new tag)',
example: 'New Tag',
})
@IsString()
@IsNotEmpty()
@ValidateIf((o) => !o.tagUuid)
tagName: string;
@ApiProperty({
description: 'UUID of the tag (if selecting an existing tag)',
example: '123e4567-e89b-12d3-a456-426614174000',
})
@IsUUID()
@IsNotEmpty()
@ValidateIf((o) => !o.tagName)
tagUuid: string;
@ApiProperty({
description: 'UUID of the product',
example: '550e8400-e29b-41d4-a716-446655440000',
})
@IsUUID()
@IsOptional()
productUuid: string;
}

View File

@ -1,10 +1,8 @@
export * from './add.space.dto';
export * from './community-space.param';
export * from './create-allocations.dto';
export * from './create-product-allocation.dto';
export * from './get.space.param';
export * from './project.param.dto';
export * from './subspace';
export * from './tag';
export * from './update.space.dto';
export * from './user-space.param';
export * from './subspace';
export * from './project.param.dto';
export * from './update.space.dto';
export * from './tag';

View File

@ -1,5 +1,4 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsArray,
IsNotEmpty,
@ -7,8 +6,8 @@ import {
IsString,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
import { ProcessTagDto } from 'src/tags/dtos';
import { CreateProductAllocationDto } from '../create-product-allocation.dto';
export class AddSubspaceDto {
@ApiProperty({
@ -25,7 +24,7 @@ export class AddSubspaceDto {
})
@IsArray()
@ValidateNested({ each: true })
@Type(() => CreateProductAllocationDto)
@Type(() => ProcessTagDto)
@IsOptional()
productAllocations?: CreateProductAllocationDto[];
tags?: ProcessTagDto[];
}

View File

@ -1,5 +1,6 @@
export * from './add.subspace-device.param';
export * from './add.subspace.dto';
export * from './delete.subspace.dto';
export * from './get.subspace.param';
export * from './add.subspace-device.param';
export * from './update.subspace.dto';
export * from './delete.subspace.dto';
export * from './modify.subspace.dto';

View File

@ -0,0 +1,14 @@
import { ApiPropertyOptional, PartialType } from '@nestjs/swagger';
import { IsOptional, IsUUID } from 'class-validator';
import { AddSubspaceDto } from './add.subspace.dto';
export class ModifySubspaceDto extends PartialType(AddSubspaceDto) {
@ApiPropertyOptional({
description:
'UUID of the subspace (will present if updating an existing subspace)',
example: '123e4567-e89b-12d3-a456-426614174000',
})
@IsOptional()
@IsUUID()
uuid?: string;
}

View File

@ -1,14 +1,16 @@
import { ApiPropertyOptional, PartialType } from '@nestjs/swagger';
import { IsOptional, IsUUID } from 'class-validator';
import { AddSubspaceDto } from './add.subspace.dto';
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';
export class UpdateSubspaceDto extends PartialType(AddSubspaceDto) {
@ApiPropertyOptional({
description:
'UUID of the subspace (will present if updating an existing subspace)',
example: '123e4567-e89b-12d3-a456-426614174000',
export class UpdateSubspaceDto {
@ApiProperty({
description: 'Name of the subspace',
example: 'Living Room',
})
@IsOptional()
@IsUUID()
uuid?: string;
@IsNotEmpty()
@IsString()
subspaceName?: string;
@IsNotEmpty()
@IsString()
subspaceUuid: string;
}

View File

@ -9,8 +9,8 @@ import {
NotEquals,
ValidateNested,
} from 'class-validator';
import { CreateProductAllocationDto } from './create-product-allocation.dto';
import { UpdateSubspaceDto } from './subspace';
import { ModifySubspaceDto } from './subspace';
import { ModifyTagDto } from './tag/modify-tag.dto';
export class UpdateSpaceDto {
@ApiProperty({
@ -46,24 +46,25 @@ export class UpdateSpaceDto {
y?: number;
@ApiPropertyOptional({
description: 'List of subspace modifications',
type: [UpdateSubspaceDto],
description: 'List of subspace modifications (add/update/delete)',
type: [ModifySubspaceDto],
})
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => UpdateSubspaceDto)
subspaces?: UpdateSubspaceDto[];
@Type(() => ModifySubspaceDto)
subspaces?: ModifySubspaceDto[];
@ApiPropertyOptional({
description: 'List of allocations modifications',
type: [CreateProductAllocationDto],
description:
'List of tag modifications (add/update/delete) for the space model',
type: [ModifyTagDto],
})
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => CreateProductAllocationDto)
productAllocations?: CreateProductAllocationDto[];
@Type(() => ModifyTagDto)
tags?: ModifyTagDto[];
@ApiProperty({
description: 'UUID of the Space',

View File

@ -5,7 +5,11 @@ import { DeviceService } from 'src/device/services';
import { UserSpaceService } from 'src/users/services';
import { DataSource } from 'typeorm';
import { DisableSpaceCommand } from '../commands';
import { SpaceSceneService, SubSpaceService } from '../services';
import {
SpaceLinkService,
SpaceSceneService,
SubSpaceService,
} from '../services';
@CommandHandler(DisableSpaceCommand)
export class DisableSpaceHandler
@ -15,6 +19,7 @@ export class DisableSpaceHandler
private readonly subSpaceService: SubSpaceService,
private readonly userService: UserSpaceService,
private readonly deviceService: DeviceService,
private readonly spaceLinkService: SpaceLinkService,
private readonly sceneService: SpaceSceneService,
private readonly dataSource: DataSource,
) {}
@ -34,6 +39,8 @@ export class DisableSpaceHandler
'subspaces',
'parent',
'devices',
'outgoingConnections',
'incomingConnections',
'scenes',
'children',
'userSpaces',
@ -72,6 +79,7 @@ export class DisableSpaceHandler
orphanSpace,
queryRunner,
),
this.spaceLinkService.deleteSpaceLink(space, queryRunner),
this.sceneService.deleteScenes(space, queryRunner),
];

View File

@ -16,10 +16,10 @@ export class ProductAllocationService {
) {}
async createAllocations(dto: CreateAllocationsDto): Promise<void> {
const { projectUuid, queryRunner, productAllocations, type } = dto;
const { projectUuid, queryRunner, tags, type } = dto;
const allocationsData = await this.tagService.upsertTags(
productAllocations,
const allocationsData = await this.tagService.processTags(
tags,
projectUuid,
queryRunner,
);
@ -29,17 +29,15 @@ export class ProductAllocationService {
const createdTagsByName = new Map(allocationsData.map((t) => [t.name, t]));
// Create the product-tag mapping based on the processed tags
const productTagMapping = productAllocations.map(
({ tagUuid, tagName, productUuid }) => {
const inputTag = tagUuid
? createdTagsByUUID.get(tagUuid)
: createdTagsByName.get(tagName);
return {
tag: inputTag?.uuid,
product: productUuid,
};
},
);
const productTagMapping = tags.map(({ uuid, name, productUuid }) => {
const inputTag = uuid
? createdTagsByUUID.get(uuid)
: createdTagsByName.get(name);
return {
tag: inputTag?.uuid,
product: productUuid,
};
});
switch (type) {
case AllocationsOwnerType.SPACE: {

View File

@ -1,6 +1,121 @@
import { Injectable } from '@nestjs/common';
import { SpaceLinkEntity } from '@app/common/modules/space/entities/space-link.entity';
import { SpaceEntity } from '@app/common/modules/space/entities/space.entity';
import { SpaceLinkRepository } from '@app/common/modules/space/repositories';
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { QueryRunner } from 'typeorm';
// todo: find out why we need to import this
// in community module in order for the whole system to work
@Injectable()
export class SpaceLinkService {}
export class SpaceLinkService {
constructor(private readonly spaceLinkRepository: SpaceLinkRepository) {}
async saveSpaceLink(
startSpaceId: string,
endSpaceId: string,
direction: string,
queryRunner: QueryRunner,
): Promise<void> {
try {
// Check if a link between the startSpace and endSpace already exists
const existingLink = await queryRunner.manager.findOne(SpaceLinkEntity, {
where: {
startSpace: { uuid: startSpaceId },
endSpace: { uuid: endSpaceId },
disabled: false,
},
});
if (existingLink) {
// Update the direction if the link exists
existingLink.direction = direction;
await queryRunner.manager.save(SpaceLinkEntity, existingLink);
return;
}
const existingEndSpaceLink = await queryRunner.manager.findOne(
SpaceLinkEntity,
{
where: { endSpace: { uuid: endSpaceId } },
},
);
if (
existingEndSpaceLink &&
existingEndSpaceLink.startSpace.uuid !== startSpaceId
) {
throw new Error(
`Space with ID ${endSpaceId} is already an endSpace in another link and cannot be reused.`,
);
}
// Find start space
const startSpace = await queryRunner.manager.findOne(SpaceEntity, {
where: { uuid: startSpaceId },
});
if (!startSpace) {
throw new HttpException(
`Start space with ID ${startSpaceId} not found.`,
HttpStatus.NOT_FOUND,
);
}
// Find end space
const endSpace = await queryRunner.manager.findOne(SpaceEntity, {
where: { uuid: endSpaceId },
});
if (!endSpace) {
throw new HttpException(
`End space with ID ${endSpaceId} not found.`,
HttpStatus.NOT_FOUND,
);
}
// Create and save the space link
const spaceLink = this.spaceLinkRepository.create({
startSpace,
endSpace,
direction,
});
await queryRunner.manager.save(SpaceLinkEntity, spaceLink);
} catch (error) {
throw new HttpException(
error.message ||
`Failed to save space link. Internal Server Error: ${error.message}`,
error.status || HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
async deleteSpaceLink(
space: SpaceEntity,
queryRunner: QueryRunner,
): Promise<void> {
try {
const spaceLinks = await queryRunner.manager.find(SpaceLinkEntity, {
where: [
{ startSpace: space, disabled: false },
{ endSpace: space, disabled: false },
],
});
if (spaceLinks.length === 0) {
return;
}
const linkIds = spaceLinks.map((link) => link.uuid);
await queryRunner.manager
.createQueryBuilder()
.update(SpaceLinkEntity)
.set({ disabled: true })
.whereInIds(linkIds)
.execute();
} catch (error) {
throw new HttpException(
`Failed to disable space links for the given space: ${error.message}`,
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
}

View File

@ -16,7 +16,7 @@ import {
Inject,
Injectable,
} from '@nestjs/common';
import { In, QueryRunner } from 'typeorm';
import { In } from 'typeorm';
import { CommunityService } from '../../community/services';
import { ProjectService } from '../../project/services';
import { ProjectParam } from '../dtos';
@ -69,17 +69,12 @@ export class ValidationService {
async validateCommunityAndProject(
communityUuid: string,
projectUuid: string,
queryRunner?: QueryRunner,
) {
const project = await this.projectService.findOne(projectUuid, queryRunner);
const community = await this.communityService.getCommunityById(
{
communityUuid,
projectUuid,
},
queryRunner,
);
const project = await this.projectService.findOne(projectUuid);
const community = await this.communityService.getCommunityById({
communityUuid,
projectUuid,
});
return { community: community.data, project: project };
}
@ -175,14 +170,8 @@ export class ValidationService {
return space;
}
async validateSpaceModel(
spaceModelUuid: string,
queryRunner?: QueryRunner,
): Promise<SpaceModelEntity> {
const queryBuilder = (
queryRunner.manager.getRepository(SpaceModelEntity) ||
this.spaceModelRepository
)
async validateSpaceModel(spaceModelUuid: string): Promise<SpaceModelEntity> {
const queryBuilder = this.spaceModelRepository
.createQueryBuilder('spaceModel')
.leftJoinAndSelect(
'spaceModel.subspaceModels',

View File

@ -22,6 +22,7 @@ import {
import { CommandBus } from '@nestjs/cqrs';
import { DeviceService } from 'src/device/services';
import { SpaceModelService } from 'src/space-model/services';
import { ProcessTagDto } from 'src/tags/dtos';
import { TagService } from 'src/tags/services/tags.service';
import { DataSource, In, Not, QueryRunner } from 'typeorm';
import { DisableSpaceCommand } from '../commands';
@ -31,9 +32,9 @@ import {
GetSpaceParam,
UpdateSpaceDto,
} from '../dtos';
import { CreateProductAllocationDto } from '../dtos/create-product-allocation.dto';
import { GetSpaceDto } from '../dtos/get.space.dto';
import { SpaceWithParentsDto } from '../dtos/space.parents.dto';
import { SpaceLinkService } from './space-link';
import { SpaceProductAllocationService } from './space-product-allocation.service';
import { ValidationService } from './space-validation.service';
import { SubSpaceService } from './subspace';
@ -43,6 +44,7 @@ export class SpaceService {
private readonly dataSource: DataSource,
private readonly spaceRepository: SpaceRepository,
private readonly inviteSpaceRepository: InviteSpaceRepository,
private readonly spaceLinkService: SpaceLinkService,
private readonly subSpaceService: SubSpaceService,
private readonly validationService: ValidationService,
private readonly tagService: TagService,
@ -55,72 +57,50 @@ export class SpaceService {
async createSpace(
addSpaceDto: AddSpaceDto,
params: CommunitySpaceParam,
queryRunner?: QueryRunner,
recursiveCallParentEntity?: SpaceEntity,
): Promise<BaseResponseDto> {
const isRecursiveCall = !!queryRunner;
const {
parentUuid,
spaceModelUuid,
subspaces,
productAllocations,
children,
} = addSpaceDto;
const { parentUuid, direction, spaceModelUuid, subspaces, tags } =
addSpaceDto;
const { communityUuid, projectUuid } = params;
if (!queryRunner) {
queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
}
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
const { community } =
await this.validationService.validateCommunityAndProject(
communityUuid,
projectUuid,
queryRunner,
);
this.validateSpaceCreationCriteria({
spaceModelUuid,
subspaces,
productAllocations,
});
this.validateSpaceCreationCriteria({ spaceModelUuid, subspaces, tags });
const parent =
parentUuid && !isRecursiveCall
? await this.validationService.validateSpace(parentUuid)
: null;
const parent = parentUuid
? await this.validationService.validateSpace(parentUuid)
: null;
const spaceModel = spaceModelUuid
? await this.validationService.validateSpaceModel(spaceModelUuid)
: null;
try {
const space = queryRunner.manager.create(SpaceEntity, {
// todo: find a better way to handle this instead of naming every key
spaceName: addSpaceDto.spaceName,
icon: addSpaceDto.icon,
x: addSpaceDto.x,
y: addSpaceDto.y,
...addSpaceDto,
spaceModel,
parent: isRecursiveCall
? recursiveCallParentEntity
: parentUuid
? parent
: null,
parent: parentUuid ? parent : null,
community,
});
const newSpace = await queryRunner.manager.save(space);
this.checkDuplicateTags([
...(productAllocations || []),
...(subspaces?.flatMap(
(subspace) => subspace.productAllocations || [],
) || []),
]);
const subspaceTags =
subspaces?.flatMap((subspace) => subspace.tags || []) || [];
this.checkDuplicateTags([...tags, ...subspaceTags]);
if (spaceModelUuid) {
// no need to check for existing dependencies here as validateSpaceCreationCriteria
// ensures no tags or subspaces are present along with spaceModelUuid
await this.spaceModelService.linkToSpace(
newSpace,
spaceModel,
@ -129,6 +109,15 @@ export class SpaceService {
}
await Promise.all([
// todo: remove this logic as we are not using space links anymore
direction && parent
? this.spaceLinkService.saveSpaceLink(
parent.uuid,
newSpace.uuid,
direction,
queryRunner,
)
: Promise.resolve(),
subspaces?.length
? this.subSpaceService.createSubspacesFromDto(
subspaces,
@ -137,32 +126,12 @@ export class SpaceService {
projectUuid,
)
: Promise.resolve(),
productAllocations?.length
? this.createAllocations(
productAllocations,
projectUuid,
queryRunner,
newSpace,
)
tags?.length
? this.createAllocations(tags, projectUuid, queryRunner, newSpace)
: Promise.resolve(),
]);
if (children?.length) {
await Promise.all(
children.map((child) =>
this.createSpace(
{ ...child, parentUuid: newSpace.uuid },
{ communityUuid, projectUuid },
queryRunner,
newSpace,
),
),
);
}
if (!isRecursiveCall) {
await queryRunner.commitTransaction();
}
await queryRunner.commitTransaction();
return new SuccessResponseDto({
statusCode: HttpStatus.CREATED,
@ -170,34 +139,34 @@ export class SpaceService {
message: 'Space created successfully',
});
} catch (error) {
!isRecursiveCall ? await queryRunner.rollbackTransaction() : null;
await queryRunner.rollbackTransaction();
if (error instanceof HttpException) {
throw error;
}
throw new HttpException(error.message, HttpStatus.INTERNAL_SERVER_ERROR);
} finally {
!isRecursiveCall ? await queryRunner.release() : null;
await queryRunner.release();
}
}
private checkDuplicateTags(allocations: CreateProductAllocationDto[]) {
private checkDuplicateTags(allTags: ProcessTagDto[]) {
const tagUuidSet = new Set<string>();
const tagNameProductSet = new Set<string>();
for (const allocation of allocations) {
if (allocation.tagUuid) {
if (tagUuidSet.has(allocation.tagUuid)) {
for (const tag of allTags) {
if (tag.uuid) {
if (tagUuidSet.has(tag.uuid)) {
throw new HttpException(
`Duplicate tag UUID found: ${allocation.tagUuid}`,
`Duplicate tag UUID found: ${tag.uuid}`,
HttpStatus.BAD_REQUEST,
);
}
tagUuidSet.add(allocation.tagUuid);
tagUuidSet.add(tag.uuid);
} else {
const tagKey = `${allocation.tagName}-${allocation.productUuid}`;
const tagKey = `${tag.name}-${tag.productUuid}`;
if (tagNameProductSet.has(tagKey)) {
throw new HttpException(
`Duplicate tag found with name "${allocation.tagName}" and product "${allocation.productUuid}".`,
`Duplicate tag found with name "${tag.name}" and product "${tag.productUuid}".`,
HttpStatus.BAD_REQUEST,
);
}
@ -226,7 +195,12 @@ export class SpaceService {
'children.disabled = :disabled',
{ disabled: false },
)
.leftJoinAndSelect(
'space.incomingConnections',
'incomingConnections',
'incomingConnections.disabled = :incomingConnectionDisabled',
{ incomingConnectionDisabled: false },
)
.leftJoinAndSelect('space.productAllocations', 'productAllocations')
.leftJoinAndSelect('productAllocations.tag', 'tag')
.leftJoinAndSelect('productAllocations.product', 'product')
@ -297,6 +271,7 @@ export class SpaceService {
}
}
// todo refactor this method to eliminate wrong use of tags
async findOne(params: GetSpaceParam): Promise<BaseResponseDto> {
const { communityUuid, spaceUuid, projectUuid } = params;
try {
@ -307,6 +282,19 @@ export class SpaceService {
const queryBuilder = this.spaceRepository
.createQueryBuilder('space')
.leftJoinAndSelect('space.parent', 'parent')
.leftJoinAndSelect(
'space.children',
'children',
'children.disabled = :disabled',
{ disabled: false },
)
.leftJoinAndSelect(
'space.incomingConnections',
'incomingConnections',
'incomingConnections.disabled = :incomingConnectionDisabled',
{ incomingConnectionDisabled: false },
)
.leftJoinAndSelect('space.productAllocations', 'productAllocations')
.leftJoinAndSelect('productAllocations.tag', 'spaceTag')
.leftJoinAndSelect('productAllocations.product', 'spaceProduct')
@ -333,12 +321,7 @@ export class SpaceService {
.andWhere('space.disabled = :disabled', { disabled: false });
const space = await queryBuilder.getOne();
if (!space) {
throw new HttpException(
`Space with ID ${spaceUuid} not found`,
HttpStatus.NOT_FOUND,
);
}
return new SuccessResponseDto({
message: `Space with ID ${spaceUuid} successfully fetched`,
data: space,
@ -348,7 +331,7 @@ export class SpaceService {
throw error; // If it's an HttpException, rethrow it
} else {
throw new HttpException(
'An error occurred while fetching the space',
'An error occurred while deleting the community',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
@ -440,7 +423,7 @@ export class SpaceService {
const queryRunner = this.dataSource.createQueryRunner();
const hasSubspace = updateSpaceDto.subspaces?.length > 0;
const hasAllocations = updateSpaceDto.productAllocations?.length > 0;
const hasTags = updateSpaceDto.tags?.length > 0;
try {
await queryRunner.connect();
@ -465,7 +448,7 @@ export class SpaceService {
await this.updateSpaceProperties(space, updateSpaceDto, queryRunner);
if (hasSubspace || hasAllocations) {
if (hasSubspace || hasTags) {
await queryRunner.manager.update(SpaceEntity, space.uuid, {
spaceModel: null,
});
@ -509,7 +492,7 @@ export class SpaceService {
await this.subSpaceService.unlinkModels(space.subspaces, queryRunner);
}
if (hasAllocations && space.productAllocations && space.spaceModel) {
if (hasTags && space.productAllocations && space.spaceModel) {
await this.spaceProductAllocationService.unlinkModels(
space,
queryRunner,
@ -525,13 +508,13 @@ export class SpaceService {
);
}
if (updateSpaceDto.productAllocations) {
if (updateSpaceDto.tags) {
await queryRunner.manager.delete(SpaceProductAllocationEntity, {
space: { uuid: space.uuid },
tag: {
uuid: Not(
In(
updateSpaceDto.productAllocations
updateSpaceDto.tags
.filter((tag) => tag.tagUuid)
.map((tag) => tag.tagUuid),
),
@ -539,7 +522,11 @@ export class SpaceService {
},
});
await this.createAllocations(
updateSpaceDto.productAllocations,
updateSpaceDto.tags.map((tag) => ({
name: tag.name,
uuid: tag.tagUuid,
productUuid: tag.productUuid,
})),
projectUuid,
queryRunner,
space,
@ -686,7 +673,7 @@ export class SpaceService {
}
}
buildSpaceHierarchy(spaces: SpaceEntity[]): SpaceEntity[] {
private buildSpaceHierarchy(spaces: SpaceEntity[]): SpaceEntity[] {
const map = new Map<string, SpaceEntity>();
// Step 1: Create a map of spaces by UUID
@ -715,17 +702,13 @@ export class SpaceService {
private validateSpaceCreationCriteria({
spaceModelUuid,
productAllocations,
tags,
subspaces,
}: Pick<
AddSpaceDto,
'spaceModelUuid' | 'productAllocations' | 'subspaces'
>): void {
const hasProductsOrSubspaces =
(productAllocations && productAllocations.length > 0) ||
(subspaces && subspaces.length > 0);
}: Pick<AddSpaceDto, 'spaceModelUuid' | 'tags' | 'subspaces'>): void {
const hasTagsOrSubspaces =
(tags && tags.length > 0) || (subspaces && subspaces.length > 0);
if (spaceModelUuid && hasProductsOrSubspaces) {
if (spaceModelUuid && hasTagsOrSubspaces) {
throw new HttpException(
'For space creation choose either space model or products and subspace',
HttpStatus.CONFLICT,
@ -734,13 +717,13 @@ export class SpaceService {
}
private async createAllocations(
productAllocations: CreateProductAllocationDto[],
tags: ProcessTagDto[],
projectUuid: string,
queryRunner: QueryRunner,
space: SpaceEntity,
): Promise<void> {
const allocationsData = await this.tagService.upsertTags(
productAllocations,
const allocationsData = await this.tagService.processTags(
tags,
projectUuid,
queryRunner,
);
@ -750,17 +733,15 @@ export class SpaceService {
const createdTagsByName = new Map(allocationsData.map((t) => [t.name, t]));
// Create the product-tag mapping based on the processed tags
const productTagMapping = productAllocations.map(
({ tagUuid, tagName, productUuid }) => {
const inputTag = tagUuid
? createdTagsByUUID.get(tagUuid)
: createdTagsByName.get(tagName);
return {
tag: inputTag?.uuid,
product: productUuid,
};
},
);
const productTagMapping = tags.map(({ uuid, name, productUuid }) => {
const inputTag = uuid
? createdTagsByUUID.get(uuid)
: createdTagsByName.get(name);
return {
tag: inputTag?.uuid,
product: productUuid,
};
});
await this.spaceProductAllocationService.createProductAllocations(
space,

View File

@ -3,7 +3,7 @@ import { SubspaceProductAllocationEntity } from '@app/common/modules/space/entit
import { SubspaceEntity } from '@app/common/modules/space/entities/subspace/subspace.entity';
import { SubspaceProductAllocationRepository } from '@app/common/modules/space/repositories/subspace.repository';
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { UpdateSubspaceDto } from 'src/space/dtos';
import { UpdateSpaceAllocationDto } from 'src/space/interfaces/update-subspace-allocation.dto';
import { TagService as NewTagService } from 'src/tags/services';
import { In, Not, QueryRunner } from 'typeorm';
@ -60,46 +60,31 @@ export class SubspaceProductAllocationService {
}
async updateSubspaceProductAllocationsV2(
subSpaces: UpdateSubspaceDto[],
subSpaces: UpdateSpaceAllocationDto[],
projectUuid: string,
queryRunner: QueryRunner,
) {
await Promise.all(
subSpaces.map(async (subspace) => {
await queryRunner.manager.delete(SubspaceProductAllocationEntity, {
subspace: subspace.uuid ? { uuid: subspace.uuid } : undefined,
tag: subspace.productAllocations
? {
uuid: Not(
In(
subspace.productAllocations
.filter((allocation) => allocation.tagUuid)
.map((allocation) => allocation.tagUuid),
),
),
}
: undefined,
product: subspace.productAllocations
? {
uuid: Not(
In(
subspace.productAllocations
.filter((allocation) => allocation.productUuid)
.map((allocation) => allocation.productUuid),
),
),
}
: undefined,
subspace: { uuid: subspace.uuid },
tag: {
uuid: Not(
In(
subspace.tags.filter((tag) => tag.uuid).map((tag) => tag.uuid),
),
),
},
});
const subspaceEntity = await queryRunner.manager.findOne(
SubspaceEntity,
{
where: { uuid: subspace.uuid },
},
);
const processedTags = await this.tagService.upsertTags(
subspace.productAllocations,
const processedTags = await this.tagService.processTags(
subspace.tags,
projectUuid,
queryRunner,
);
@ -112,11 +97,11 @@ export class SubspaceProductAllocationService {
);
// Create the product-tag mapping based on the processed tags
const productTagMapping = subspace.productAllocations.map(
({ tagUuid, tagName, productUuid }) => {
const inputTag = tagUuid
? createdTagsByUUID.get(tagUuid)
: createdTagsByName.get(tagName);
const productTagMapping = subspace.tags.map(
({ uuid, name, productUuid }) => {
const inputTag = uuid
? createdTagsByUUID.get(uuid)
: createdTagsByName.get(name);
return {
tag: inputTag?.uuid,
product: productUuid,
@ -133,6 +118,71 @@ export class SubspaceProductAllocationService {
);
}
// async processDeleteActions(dtos: ModifyTagDto[], queryRunner: QueryRunner) {
// // : Promise<SubspaceProductAllocationEntity[]>
// try {
// // if (!dtos || dtos.length === 0) {
// // throw new Error('No DTOs provided for deletion.');
// // }
// // const tagUuidsToDelete = dtos
// // .filter((dto) => dto.action === ModifyAction.DELETE && dto.tagUuid)
// // .map((dto) => dto.tagUuid);
// // if (tagUuidsToDelete.length === 0) return [];
// // const allocationsToUpdate = await queryRunner.manager.find(
// // SubspaceProductAllocationEntity,
// // {
// // where: { tag: In(tagUuidsToDelete) },
// // },
// // );
// // if (!allocationsToUpdate || allocationsToUpdate.length === 0) return [];
// // const deletedAllocations: SubspaceProductAllocationEntity[] = [];
// // const allocationUpdates: SubspaceProductAllocationEntity[] = [];
// // for (const allocation of allocationsToUpdate) {
// // const updatedTags = allocation.tags.filter(
// // (tag) => !tagUuidsToDelete.includes(tag.uuid),
// // );
// // if (updatedTags.length === allocation.tags.length) {
// // continue;
// // }
// // if (updatedTags.length === 0) {
// // deletedAllocations.push(allocation);
// // } else {
// // allocation.tags = updatedTags;
// // allocationUpdates.push(allocation);
// // }
// // }
// // if (allocationUpdates.length > 0) {
// // await queryRunner.manager.save(
// // SubspaceProductAllocationEntity,
// // allocationUpdates,
// // );
// // }
// // if (deletedAllocations.length > 0) {
// // await queryRunner.manager.remove(
// // SubspaceProductAllocationEntity,
// // deletedAllocations,
// // );
// // }
// // await queryRunner.manager
// // .createQueryBuilder()
// // .delete()
// // .from('subspace_product_tags')
// // .where(
// // 'subspace_product_allocation_uuid NOT IN ' +
// // queryRunner.manager
// // .createQueryBuilder()
// // .select('allocation.uuid')
// // .from(SubspaceProductAllocationEntity, 'allocation')
// // .getQuery() +
// // ')',
// // )
// // .execute();
// // return deletedAllocations;
// } catch (error) {
// throw this.handleError(error, `Failed to delete tags in subspace`);
// }
// }
async unlinkModels(
allocations: SubspaceProductAllocationEntity[],
queryRunner: QueryRunner,
@ -155,6 +205,67 @@ export class SubspaceProductAllocationService {
}
}
// private async validateTagWithinSubspace(
// queryRunner: QueryRunner | undefined,
// tag: NewTagEntity & { product: string },
// subspace: SubspaceEntity,
// spaceAllocationsToExclude?: SpaceProductAllocationEntity[],
// ): Promise<void> {
// // const existingTagInSpace = await (queryRunner
// // ? queryRunner.manager.findOne(SpaceProductAllocationEntity, {
// // where: {
// // product: { uuid: tag.product },
// // space: { uuid: subspace.space.uuid },
// // tag: { uuid: tag.uuid },
// // },
// // })
// // : this.spaceProductAllocationRepository.findOne({
// // where: {
// // product: { uuid: tag.product },
// // space: { uuid: subspace.space.uuid },
// // tag: { uuid: tag.uuid },
// // },
// // }));
// // const isExcluded = spaceAllocationsToExclude?.some(
// // (excludedAllocation) =>
// // excludedAllocation.product.uuid === tag.product &&
// // excludedAllocation.tags.some((t) => t.uuid === tag.uuid),
// // );
// // if (!isExcluded && existingTagInSpace) {
// // throw new HttpException(
// // `Tag ${tag.uuid} (Product: ${tag.product}) is already allocated at the space level (${subspace.space.uuid}). Cannot allocate the same tag in a subspace.`,
// // HttpStatus.BAD_REQUEST,
// // );
// // }
// // // ?: Check if the tag is already allocated in another "subspace" within the same space
// // const existingTagInSameSpace = await (queryRunner
// // ? queryRunner.manager.findOne(SubspaceProductAllocationEntity, {
// // where: {
// // product: { uuid: tag.product },
// // subspace: { space: subspace.space },
// // tag: { uuid: tag.uuid },
// // },
// // relations: ['subspace'],
// // })
// // : this.subspaceProductAllocationRepository.findOne({
// // where: {
// // product: { uuid: tag.product },
// // subspace: { space: subspace.space },
// // tag: { uuid: tag.uuid },
// // },
// // relations: ['subspace'],
// // }));
// // if (
// // existingTagInSameSpace &&
// // existingTagInSameSpace.subspace.uuid !== subspace.uuid
// // ) {
// // throw new HttpException(
// // `Tag ${tag.uuid} (Product: ${tag.product}) is already allocated in another subspace (${existingTagInSameSpace.subspace.uuid}) within the same space (${subspace.space.uuid}).`,
// // HttpStatus.BAD_REQUEST,
// // );
// // }
// }
private createNewSubspaceAllocation(
subspace: SubspaceEntity,
allocationData: { product: string; tag: string },

View File

@ -12,7 +12,7 @@ import {
AddSubspaceDto,
GetSpaceParam,
GetSubSpaceParam,
UpdateSubspaceDto,
ModifySubspaceDto,
} from '../../dtos';
import { SubspaceModelEntity } from '@app/common/modules/space-model';
@ -103,13 +103,13 @@ export class SubSpaceService {
queryRunner,
);
await Promise.all(
addSubspaceDtos.map(async ({ productAllocations }, index) => {
addSubspaceDtos.map(async ({ tags }, index) => {
// map the dto to the corresponding subspace
const subspace = createdSubspaces[index];
await this.createAllocations({
projectUuid,
queryRunner,
productAllocations,
tags,
type: AllocationsOwnerType.SUBSPACE,
subspace,
});
@ -145,7 +145,7 @@ export class SubSpaceService {
space,
);
const newSubspace = this.subspaceRepository.create({
subspaceName: addSubspaceDto.subspaceName,
...addSubspaceDto,
space,
});
@ -305,7 +305,7 @@ export class SubSpaceService {
} */
async updateSubspaceInSpace(
subspaceDtos: UpdateSubspaceDto[],
subspaceDtos: ModifySubspaceDto[],
queryRunner: QueryRunner,
space: SpaceEntity,
projectUuid: string,
@ -324,52 +324,42 @@ export class SubSpaceService {
disabled: true,
},
);
await queryRunner.manager.delete(SubspaceProductAllocationEntity, {
subspace: {
uuid: Not(
In(subspaceDtos.filter(({ uuid }) => uuid).map(({ uuid }) => uuid)),
),
},
subspace: { uuid: Not(In(subspaceDtos.map((dto) => dto.uuid))) },
});
// create or update subspaces provided in the list
const newSubspaces = this.subspaceRepository.create(
subspaceDtos
.filter((dto) => !dto.uuid)
.map((dto) => ({
subspaceName: dto.subspaceName,
space,
})),
subspaceDtos.filter((dto) => !dto.uuid),
);
const updatedSubspaces: SubspaceEntity[] = await queryRunner.manager.save(
SubspaceEntity,
[
...newSubspaces,
...subspaceDtos
.filter((dto) => dto.uuid)
.map((dto) => ({
subspaceName: dto.subspaceName,
space,
})),
],
[...newSubspaces, ...subspaceDtos.filter((dto) => dto.uuid)].map(
(subspace) => ({ ...subspace, space }),
),
);
// create or update allocations for the subspaces
if (updatedSubspaces.length > 0) {
await this.subspaceProductAllocationService.updateSubspaceProductAllocationsV2(
subspaceDtos.map((dto) => ({
...dto,
uuid:
dto.uuid ||
updatedSubspaces.find((s) => s.subspaceName === dto.subspaceName)
?.uuid,
})),
subspaceDtos.map((dto) => {
if (!dto.uuid) {
dto.uuid = updatedSubspaces.find(
(subspace) => subspace.subspaceName === dto.subspaceName,
)?.uuid;
}
return {
tags: dto.tags || [],
uuid: dto.uuid,
};
}),
projectUuid,
queryRunner,
);
}
} catch (error) {
console.log(error);
throw new HttpException(
`An error occurred while modifying subspaces: ${error.message}`,
HttpStatus.INTERNAL_SERVER_ERROR,
@ -488,10 +478,10 @@ export class SubSpaceService {
}
async createAllocations(dto: CreateAllocationsDto): Promise<void> {
const { projectUuid, queryRunner, productAllocations, type } = dto;
if (!productAllocations) return;
const allocationsData = await this.newTagService.upsertTags(
productAllocations,
const { projectUuid, queryRunner, tags, type } = dto;
const allocationsData = await this.newTagService.processTags(
tags,
projectUuid,
queryRunner,
);
@ -501,17 +491,15 @@ export class SubSpaceService {
const createdTagsByName = new Map(allocationsData.map((t) => [t.name, t]));
// Create the product-tag mapping based on the processed tags
const productTagMapping = productAllocations.map(
({ tagUuid, tagName, productUuid }) => {
const inputTag = tagUuid
? createdTagsByUUID.get(tagUuid)
: createdTagsByName.get(tagName);
return {
tag: inputTag?.uuid,
product: productUuid,
};
},
);
const productTagMapping = tags.map(({ uuid, name, productUuid }) => {
const inputTag = uuid
? createdTagsByUUID.get(uuid)
: createdTagsByName.get(name);
return {
tag: inputTag?.uuid,
product: productUuid,
};
});
switch (type) {
case AllocationsOwnerType.SUBSPACE: {

View File

@ -79,6 +79,7 @@ import { SpaceValidationController } from './controllers/space-validation.contro
import { DisableSpaceHandler } from './handlers';
import {
SpaceDeviceService,
SpaceLinkService,
SpaceSceneService,
SpaceService,
SpaceUserService,
@ -88,8 +89,6 @@ import {
} from './services';
import { SpaceProductAllocationService } from './services/space-product-allocation.service';
import { SubspaceProductAllocationService } from './services/subspace/subspace-product-allocation.service';
import { PresenceSensorDailySpaceRepository } from '@app/common/modules/presence-sensor/repositories';
import { AqiSpaceDailyPollutantStatsRepository } from '@app/common/modules/aqi/repositories';
export const CommandHandlers = [DisableSpaceHandler];
@ -111,6 +110,7 @@ export const CommandHandlers = [DisableSpaceHandler];
ProductRepository,
SubSpaceService,
SpaceDeviceService,
SpaceLinkService,
SubspaceDeviceService,
SpaceRepository,
SubspaceRepository,
@ -163,8 +163,6 @@ export const CommandHandlers = [DisableSpaceHandler];
SqlLoaderService,
OccupancyService,
AqiDataService,
PresenceSensorDailySpaceRepository,
AqiSpaceDailyPollutantStatsRepository,
],
exports: [SpaceService],
})

View File

@ -14,8 +14,8 @@ import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import { CreateProductAllocationDto } from 'src/space/dtos';
import { In, QueryRunner } from 'typeorm';
import { ProcessTagDto } from '../dtos';
import { CreateTagDto } from '../dtos/tags.dto';
@Injectable()
@ -68,13 +68,13 @@ export class TagService {
/**
* Processes an array of tag DTOs, creating or updating tags in the database.
* @param allocationDtos - The array of allocations DTOs to process.
* @param tagDtos - The array of tag DTOs to process.
* @param projectUuid - The UUID of the project to associate the tags with.
* @param queryRunner - Optional TypeORM query runner for transaction management.
* @returns An array of the processed tag entities.
*/
async upsertTags(
allocationDtos: CreateProductAllocationDto[],
async processTags(
tagDtos: ProcessTagDto[],
projectUuid: string,
queryRunner?: QueryRunner,
): Promise<NewTagEntity[]> {
@ -82,22 +82,20 @@ export class TagService {
const dbManager = queryRunner
? queryRunner.manager
: this.tagRepository.manager;
if (!allocationDtos || allocationDtos.length === 0) {
if (!tagDtos || tagDtos.length === 0) {
return [];
}
const [allocationsWithTagUuid, allocationsWithoutTagUuid]: [
Pick<CreateProductAllocationDto, 'tagUuid' | 'productUuid'>[],
Omit<CreateProductAllocationDto, 'tagUuid'>[],
] = this.splitTagsByUuid(allocationDtos);
const [tagsWithUuid, tagsWithoutUuid]: [
Pick<ProcessTagDto, 'uuid' | 'productUuid'>[],
Omit<ProcessTagDto, 'uuid'>[],
] = this.splitTagsByUuid(tagDtos);
// create a set of unique existing tag names for the project
const upsertedTagsByNameResult = await dbManager.upsert(
NewTagEntity,
Array.from(
new Set<string>(
allocationsWithoutTagUuid.map((allocation) => allocation.tagName),
).values(),
new Set<string>(tagsWithoutUuid.map((tag) => tag.name)).values(),
).map((name) => ({
name,
project: { uuid: projectUuid },
@ -113,22 +111,20 @@ export class TagService {
let foundByUuidTags: NewTagEntity[] = [];
// Fetch existing tags using UUIDs
if (allocationsWithTagUuid.length) {
if (tagsWithUuid.length) {
foundByUuidTags = await dbManager.find(NewTagEntity, {
where: {
uuid: In([
...allocationsWithTagUuid.map((allocation) => allocation.tagUuid),
]),
uuid: In([...tagsWithUuid.map((tag) => tag.uuid)]),
project: { uuid: projectUuid },
},
});
}
// Ensure all provided UUIDs exist in the database
if (foundByUuidTags.length !== allocationsWithTagUuid.length) {
if (foundByUuidTags.length !== tagsWithUuid.length) {
const foundUuids = new Set(foundByUuidTags.map((tag) => tag.uuid));
const missingUuids = allocationsWithTagUuid.filter(
({ tagUuid }) => !foundUuids.has(tagUuid),
const missingUuids = tagsWithUuid.filter(
({ uuid }) => !foundUuids.has(uuid),
);
throw new HttpException(
@ -183,22 +179,20 @@ export class TagService {
}
private splitTagsByUuid(
allocationsDtos: CreateProductAllocationDto[],
): [CreateProductAllocationDto[], CreateProductAllocationDto[]] {
return allocationsDtos.reduce<
[CreateProductAllocationDto[], CreateProductAllocationDto[]]
>(
([withUuid, withoutUuid], allocation) => {
if (allocation.tagUuid) {
withUuid.push(allocation);
tagDtos: ProcessTagDto[],
): [ProcessTagDto[], ProcessTagDto[]] {
return tagDtos.reduce<[ProcessTagDto[], ProcessTagDto[]]>(
([withUuid, withoutUuid], tag) => {
if (tag.uuid) {
withUuid.push(tag);
} else {
if (!allocation.tagName || !allocation.productUuid) {
if (!tag.name || !tag.productUuid) {
throw new HttpException(
`Tag name or product UUID is missing`,
HttpStatus.BAD_REQUEST,
);
}
withoutUuid.push(allocation);
withoutUuid.push(tag);
}
return [withUuid, withoutUuid];
},

View File

@ -1,3 +1,7 @@
import { ControllerRoute } from '@app/common/constants/controller-route';
import { EnableDisableStatusEnum } from '@app/common/constants/days.enum';
import { RoleType } from '@app/common/constants/role.type.enum';
import { BaseResponseDto } from '@app/common/dto/base.response.dto';
import {
Body,
Controller,
@ -7,10 +11,12 @@ import {
Param,
Patch,
Put,
Req,
UseGuards,
} from '@nestjs/common';
import { UserService } from '../services/user.service';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CheckProfilePictureGuard } from 'src/guards/profile.picture.guard';
import { SuperAdminRoleGuard } from 'src/guards/super.admin.role.guard';
import { JwtAuthGuard } from '../../../libs/common/src/guards/jwt.auth.guard';
import {
UpdateNameDto,
@ -18,11 +24,7 @@ import {
UpdateRegionDataDto,
UpdateTimezoneDataDto,
} from '../dtos';
import { CheckProfilePictureGuard } from 'src/guards/profile.picture.guard';
import { SuperAdminRoleGuard } from 'src/guards/super.admin.role.guard';
import { EnableDisableStatusEnum } from '@app/common/constants/days.enum';
import { ControllerRoute } from '@app/common/constants/controller-route';
import { BaseResponseDto } from '@app/common/dto/base.response.dto';
import { UserService } from '../services/user.service';
@ApiTags('User Module')
@Controller({
@ -154,6 +156,32 @@ export class UserController {
};
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Delete('')
@ApiOperation({
summary: ControllerRoute.USER.ACTIONS.DELETE_USER_PROFILE_SUMMARY,
description: ControllerRoute.USER.ACTIONS.DELETE_USER_PROFILE_DESCRIPTION,
})
async deleteUserProfile(@Req() req: Request) {
const userUuid = req['user']?.userUuid;
const userRole = req['user']?.role;
if (!userUuid || (userRole && userRole == RoleType.SUPER_ADMIN)) {
throw {
statusCode: HttpStatus.UNAUTHORIZED,
message: 'Unauthorized',
};
}
await this.userService.deleteUserProfile(userUuid);
return {
statusCode: HttpStatus.OK,
data: {
userId: userUuid,
},
message: 'User deleted successfully',
};
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Patch('agreements/web/:userUuid')

View File

@ -1,5 +1,38 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';
import {
IsBoolean,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
export class AddSpaceDto {
@ApiProperty({
description: 'Name of the space (e.g., Floor 1, Unit 101)',
example: 'Unit 101',
})
@IsString()
@IsNotEmpty()
spaceName: string;
@ApiProperty({
description: 'UUID of the parent space (if any, for hierarchical spaces)',
example: 'f5d7e9c3-44bc-4b12-88f1-1b3cda84752e',
required: false,
})
@IsUUID()
@IsOptional()
parentUuid?: string;
@ApiProperty({
description: 'Indicates whether the space is private or public',
example: false,
default: false,
})
@IsBoolean()
isPrivate: boolean;
}
export class AddUserSpaceDto {
@ApiProperty({

View File

@ -1,21 +1,21 @@
import {
UpdateNameDto,
UpdateProfilePictureDataDto,
UpdateRegionDataDto,
UpdateTimezoneDataDto,
} from './../dtos/update.user.dto';
import { SuccessResponseDto } from '@app/common/dto/success.response.dto';
import { removeBase64Prefix } from '@app/common/helper/removeBase64Prefix';
import { RegionRepository } from '@app/common/modules/region/repositories';
import { TimeZoneRepository } from '@app/common/modules/timezone/repositories';
import { UserEntity } from '@app/common/modules/user/entities';
import { UserRepository } from '@app/common/modules/user/repositories';
import {
BadRequestException,
HttpException,
HttpStatus,
Injectable,
} from '@nestjs/common';
import { UserRepository } from '@app/common/modules/user/repositories';
import { RegionRepository } from '@app/common/modules/region/repositories';
import { TimeZoneRepository } from '@app/common/modules/timezone/repositories';
import { removeBase64Prefix } from '@app/common/helper/removeBase64Prefix';
import { UserEntity } from '@app/common/modules/user/entities';
import { SuccessResponseDto } from '@app/common/dto/success.response.dto';
import {
UpdateNameDto,
UpdateProfilePictureDataDto,
UpdateRegionDataDto,
UpdateTimezoneDataDto,
} from './../dtos/update.user.dto';
@Injectable()
export class UserService {
@ -269,4 +269,12 @@ export class UserService {
}
return await this.userRepository.update({ uuid }, { isActive: false });
}
async deleteUserProfile(uuid: string) {
const user = await this.findOneById(uuid);
if (!user) {
throw new BadRequestException('User not found');
}
return this.userRepository.delete({ uuid });
}
}

View File

@ -1,39 +1,39 @@
import { ProductType } from '@app/common/constants/product-type.enum';
import { DeviceRepository } from '@app/common/modules/device/repositories';
import { VisitorPasswordRepository } from './../../../libs/common/src/modules/visitor-password/repositories/visitor-password.repository';
import {
BadRequestException,
Injectable,
HttpException,
HttpStatus,
Injectable,
BadRequestException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { TuyaContext } from '@tuya/tuya-connector-nodejs';
import { ConfigService } from '@nestjs/config';
import {
addDeviceObjectInterface,
createTickInterface,
} from '../interfaces/visitor-password.interface';
import { VisitorPasswordRepository } from './../../../libs/common/src/modules/visitor-password/repositories/visitor-password.repository';
import { DeviceRepository } from '@app/common/modules/device/repositories';
import { ProductType } from '@app/common/constants/product-type.enum';
import { AddDoorLockTemporaryPasswordDto } from '../dtos';
import { EmailService } from '@app/common/util/email.service';
import { PasswordEncryptionService } from 'src/door-lock/services/encryption.services';
import { DoorLockService } from 'src/door-lock/services';
import { DeviceService } from 'src/device/services';
import { DeviceStatuses } from '@app/common/constants/device-status.enum';
import {
DaysEnum,
EnableDisableStatusEnum,
} from '@app/common/constants/days.enum';
import { DeviceStatuses } from '@app/common/constants/device-status.enum';
import { PasswordType } from '@app/common/constants/password-type.enum';
import {
CommonHourMinutes,
CommonHours,
} from '@app/common/constants/hours-minutes.enum';
import { ORPHAN_SPACE_NAME } from '@app/common/constants/orphan-constant';
import { PasswordType } from '@app/common/constants/password-type.enum';
import { ProjectRepository } from '@app/common/modules/project/repositiories';
import { VisitorPasswordEnum } from '@app/common/constants/visitor-password.enum';
import { SuccessResponseDto } from '@app/common/dto/success.response.dto';
import { ProjectRepository } from '@app/common/modules/project/repositiories';
import { EmailService } from '@app/common/util/email.service';
import { DeviceService } from 'src/device/services';
import { DoorLockService } from 'src/door-lock/services';
import { PasswordEncryptionService } from 'src/door-lock/services/encryption.services';
import { Not } from 'typeorm';
import { AddDoorLockTemporaryPasswordDto } from '../dtos';
import { ORPHAN_SPACE_NAME } from '@app/common/constants/orphan-constant';
@Injectable()
export class VisitorPasswordService {
@ -57,67 +57,6 @@ export class VisitorPasswordService {
secretKey,
});
}
async getPasswords(projectUuid: string) {
await this.validateProject(projectUuid);
const deviceIds = await this.deviceRepository.find({
where: {
productDevice: {
prodType: ProductType.DL,
},
spaceDevice: {
spaceName: Not(ORPHAN_SPACE_NAME),
community: {
project: {
uuid: projectUuid,
},
},
},
isActive: true,
},
});
const data = [];
deviceIds.forEach((deviceId) => {
data.push(
this.doorLockService
.getOnlineTemporaryPasswordsOneTime(deviceId.uuid, true, false)
.catch(() => {}),
this.doorLockService
.getOnlineTemporaryPasswordsOneTime(deviceId.uuid, true, true)
.catch(() => {}),
this.doorLockService
.getOnlineTemporaryPasswordsMultiple(deviceId.uuid, true, false)
.catch(() => {}),
this.doorLockService
.getOnlineTemporaryPasswordsMultiple(deviceId.uuid, true, true)
.catch(() => {}),
this.doorLockService
.getOfflineOneTimeTemporaryPasswords(deviceId.uuid, true, false)
.catch(() => {}),
this.doorLockService
.getOfflineOneTimeTemporaryPasswords(deviceId.uuid, true, true)
.catch(() => {}),
this.doorLockService
.getOfflineMultipleTimeTemporaryPasswords(deviceId.uuid, true, false)
.catch(() => {}),
this.doorLockService
.getOfflineMultipleTimeTemporaryPasswords(deviceId.uuid, true, true)
.catch(() => {}),
);
});
const result = (await Promise.all(data)).flat().filter((datum) => {
return datum != null;
});
return new SuccessResponseDto({
message: 'Successfully retrieved temporary passwords',
data: result,
statusCode: HttpStatus.OK,
});
}
async handleTemporaryPassword(
addDoorLockTemporaryPasswordDto: AddDoorLockTemporaryPasswordDto,
userUuid: string,
@ -166,7 +105,7 @@ export class VisitorPasswordService {
statusCode: HttpStatus.CREATED,
});
}
private async addOfflineMultipleTimeTemporaryPassword(
async addOfflineMultipleTimeTemporaryPassword(
addDoorLockOfflineMultipleDto: AddDoorLockTemporaryPasswordDto,
userUuid: string,
projectUuid: string,
@ -230,7 +169,6 @@ export class VisitorPasswordService {
success: true,
result: createMultipleOfflinePass.result,
deviceUuid,
deviceName: deviceDetails.name,
};
} catch (error) {
return {
@ -293,7 +231,7 @@ export class VisitorPasswordService {
}
}
private async addOfflineOneTimeTemporaryPassword(
async addOfflineOneTimeTemporaryPassword(
addDoorLockOfflineOneTimeDto: AddDoorLockTemporaryPasswordDto,
userUuid: string,
projectUuid: string,
@ -357,7 +295,6 @@ export class VisitorPasswordService {
success: true,
result: createOnceOfflinePass.result,
deviceUuid,
deviceName: deviceDetails.name,
};
} catch (error) {
return {
@ -420,7 +357,7 @@ export class VisitorPasswordService {
}
}
private async addOfflineTemporaryPasswordTuya(
async addOfflineTemporaryPasswordTuya(
doorLockUuid: string,
type: string,
addDoorLockOfflineMultipleDto: AddDoorLockTemporaryPasswordDto,
@ -450,7 +387,7 @@ export class VisitorPasswordService {
);
}
}
private async addOnlineTemporaryPasswordMultipleTime(
async addOnlineTemporaryPasswordMultipleTime(
addDoorLockOnlineMultipleDto: AddDoorLockTemporaryPasswordDto,
userUuid: string,
projectUuid: string,
@ -511,7 +448,6 @@ export class VisitorPasswordService {
success: true,
id: createPass.result.id,
deviceUuid,
deviceName: passwordData.deviceName,
};
} catch (error) {
return {
@ -572,8 +508,67 @@ export class VisitorPasswordService {
);
}
}
async getPasswords(projectUuid: string) {
await this.validateProject(projectUuid);
private async addOnlineTemporaryPasswordOneTime(
const deviceIds = await this.deviceRepository.find({
where: {
productDevice: {
prodType: ProductType.DL,
},
spaceDevice: {
spaceName: Not(ORPHAN_SPACE_NAME),
community: {
project: {
uuid: projectUuid,
},
},
},
isActive: true,
},
});
const data = [];
deviceIds.forEach((deviceId) => {
data.push(
this.doorLockService
.getOnlineTemporaryPasswordsOneTime(deviceId.uuid, true, false)
.catch(() => {}),
this.doorLockService
.getOnlineTemporaryPasswordsOneTime(deviceId.uuid, true, true)
.catch(() => {}),
this.doorLockService
.getOnlineTemporaryPasswordsMultiple(deviceId.uuid, true, false)
.catch(() => {}),
this.doorLockService
.getOnlineTemporaryPasswordsMultiple(deviceId.uuid, true, true)
.catch(() => {}),
this.doorLockService
.getOfflineOneTimeTemporaryPasswords(deviceId.uuid, true, false)
.catch(() => {}),
this.doorLockService
.getOfflineOneTimeTemporaryPasswords(deviceId.uuid, true, true)
.catch(() => {}),
this.doorLockService
.getOfflineMultipleTimeTemporaryPasswords(deviceId.uuid, true, false)
.catch(() => {}),
this.doorLockService
.getOfflineMultipleTimeTemporaryPasswords(deviceId.uuid, true, true)
.catch(() => {}),
);
});
const result = (await Promise.all(data)).flat().filter((datum) => {
return datum != null;
});
return new SuccessResponseDto({
message: 'Successfully retrieved temporary passwords',
data: result,
statusCode: HttpStatus.OK,
});
}
async addOnlineTemporaryPasswordOneTime(
addDoorLockOnlineOneTimeDto: AddDoorLockTemporaryPasswordDto,
userUuid: string,
projectUuid: string,
@ -632,7 +627,6 @@ export class VisitorPasswordService {
return {
success: true,
id: createPass.result.id,
deviceName: passwordData.deviceName,
deviceUuid,
};
} catch (error) {
@ -694,7 +688,7 @@ export class VisitorPasswordService {
);
}
}
private async getTicketAndEncryptedPassword(
async getTicketAndEncryptedPassword(
doorLockUuid: string,
passwordPlan: string,
projectUuid: string,
@ -731,7 +725,6 @@ export class VisitorPasswordService {
ticketKey: ticketDetails.result.ticket_key,
encryptedPassword: decrypted,
deviceTuyaUuid: deviceDetails.deviceTuyaUuid,
deviceName: deviceDetails.name,
};
} catch (error) {
throw new HttpException(
@ -741,7 +734,7 @@ export class VisitorPasswordService {
}
}
private async createDoorLockTicketTuya(
async createDoorLockTicketTuya(
deviceUuid: string,
): Promise<createTickInterface> {
try {
@ -760,7 +753,7 @@ export class VisitorPasswordService {
}
}
private async addOnlineTemporaryPasswordMultipleTuya(
async addOnlineTemporaryPasswordMultipleTuya(
addDeviceObj: addDeviceObjectInterface,
doorLockUuid: string,
): Promise<createTickInterface> {
@ -802,7 +795,7 @@ export class VisitorPasswordService {
}
}
private getWorkingDayValue(days) {
getWorkingDayValue(days) {
// Array representing the days of the week
const weekDays = [
DaysEnum.SAT,
@ -834,7 +827,36 @@ export class VisitorPasswordService {
return workingDayValue;
}
private timeToMinutes(timeStr) {
getDaysFromWorkingDayValue(workingDayValue) {
// Array representing the days of the week
const weekDays = [
DaysEnum.SAT,
DaysEnum.FRI,
DaysEnum.THU,
DaysEnum.WED,
DaysEnum.TUE,
DaysEnum.MON,
DaysEnum.SUN,
];
// Convert the integer to a binary string and pad with leading zeros to ensure 7 bits
const binaryString = workingDayValue
.toString(2)
.padStart(7, EnableDisableStatusEnum.DISABLED);
// Initialize an array to hold the days of the week
const days = [];
// Iterate through the binary string and weekDays array
for (let i = 0; i < binaryString.length; i++) {
if (binaryString[i] === EnableDisableStatusEnum.ENABLED) {
days.push(weekDays[i]);
}
}
return days;
}
timeToMinutes(timeStr) {
try {
// Special case for "24:00"
if (timeStr === CommonHours.TWENTY_FOUR) {
@ -861,7 +883,38 @@ export class VisitorPasswordService {
throw new HttpException(error.message, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
private async getDeviceByDeviceUuid(
minutesToTime(totalMinutes) {
try {
if (
typeof totalMinutes !== 'number' ||
totalMinutes < 0 ||
totalMinutes > CommonHourMinutes.TWENTY_FOUR
) {
throw new Error('Invalid minutes value');
}
if (totalMinutes === CommonHourMinutes.TWENTY_FOUR) {
return CommonHours.TWENTY_FOUR;
}
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
const formattedHours = String(hours).padStart(
2,
EnableDisableStatusEnum.DISABLED,
);
const formattedMinutes = String(minutes).padStart(
2,
EnableDisableStatusEnum.DISABLED,
);
return `${formattedHours}:${formattedMinutes}`;
} catch (error) {
throw new HttpException(error.message, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
async getDeviceByDeviceUuid(
deviceUuid: string,
withProductDevice: boolean = true,
projectUuid: string,
@ -886,7 +939,7 @@ export class VisitorPasswordService {
throw new HttpException('Device Not Found', HttpStatus.NOT_FOUND);
}
}
private async addOnlineTemporaryPasswordOneTimeTuya(
async addOnlineTemporaryPasswordOneTimeTuya(
addDeviceObj: addDeviceObjectInterface,
doorLockUuid: string,
): Promise<createTickInterface> {

View File

@ -32,8 +32,6 @@ import { SqlLoaderService } from '@app/common/helper/services/sql-loader.service
import { OccupancyService } from '@app/common/helper/services/occupancy.service';
import { CommunityRepository } from '@app/common/modules/community/repositories';
import { AqiDataService } from '@app/common/helper/services/aqi.data.service';
import { PresenceSensorDailySpaceRepository } from '@app/common/modules/presence-sensor/repositories';
import { AqiSpaceDailyPollutantStatsRepository } from '@app/common/modules/aqi/repositories';
@Module({
imports: [ConfigModule, DeviceRepositoryModule, DoorLockModule],
controllers: [VisitorPasswordController],
@ -63,8 +61,6 @@ import { AqiSpaceDailyPollutantStatsRepository } from '@app/common/modules/aqi/r
OccupancyService,
CommunityRepository,
AqiDataService,
PresenceSensorDailySpaceRepository,
AqiSpaceDailyPollutantStatsRepository,
],
exports: [VisitorPasswordService],
})