Compare commits

..

10 Commits

Author SHA1 Message Date
4aacbd8000 fix: add space condition to the join operator instead of general query 2025-06-23 12:00:33 +03:00
3160773c2a fix: spaces structure in communities (#420) 2025-06-23 10:21:55 +03:00
110ed4157a task: add spaces filter to get devices by project (#422) 2025-06-23 09:34:59 +03:00
aa9e90bf08 Test/prevent server block on rate limit (#419)
* increase DB max connection to 50
2025-06-19 14:34:23 +03:00
c5dd5e28fd Test/prevent server block on rate limit (#418) 2025-06-19 13:54:22 +03:00
603e74af09 Test/prevent server block on rate limit (#417)
* task: add trust proxy header

* add logging

* task: test rate limits on sever

* task: increase rate limit timeout

* fix: merge conflicts
2025-06-19 12:54:59 +03:00
0e36f32ed6 Test/prevent server block on rate limit (#415)
* task: increase rate limit timeout
2025-06-19 10:15:29 +03:00
705ceeba29 Test/prevent server block on rate limit (#414)
* task: test rate limits on sever
2025-06-19 09:45:09 +03:00
a37d5bb299 task: add trust proxy header (#411)
* task: add trust proxy header

* add logging
2025-06-18 12:05:53 +03:00
689a38ee0c Revamp/space management (#409)
* task: add getCommunitiesV2

* task: update getOneSpace API to match revamp structure

* refactor: implement modifications to pace management APIs

* refactor: remove space link
2025-06-18 10:34:29 +03:00
69 changed files with 842 additions and 2637 deletions

View File

@ -21,6 +21,7 @@ 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,7 +1,4 @@
# 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)
name: Backend deployment to Azure App Service
on:
push:
@ -9,43 +6,50 @@ 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:
runs-on: 'ubuntu-latest'
steps:
- uses: actions/checkout@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Log in to registry
uses: docker/login-action@v2
with:
registry: https://syncrow.azurecr.io/
username: ${{ secrets.AzureAppService_ContainerUsername_47395803300340b49931ea82f6d80be3 }}
password: ${{ secrets.AzureAppService_ContainerPassword_e7b0ff54f54d44cba04a970a22384848 }}
- name: Build and push container image to registry
uses: docker/build-push-action@v3
with:
push: true
tags: syncrow.azurecr.io/${{ secrets.AzureAppService_ContainerUsername_47395803300340b49931ea82f6d80be3 }}/syncrow/backend:${{ github.sha }}
file: ./Dockerfile
deploy:
build_and_deploy:
runs-on: ubuntu-latest
needs: build
environment:
name: 'staging'
url: ${{ steps.deploy-to-webapp.outputs.webapp-url }}
steps:
- name: Deploy to Azure Web App
id: deploy-to-webapp
uses: azure/webapps-deploy@v2
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v3
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 }}'
node-version: '20'
- name: Install dependencies and build project
run: |
npm install
npm run build
- name: Log in to Azure
uses: azure/login@v1
with:
creds: ${{ secrets.AZURE_CREDENTIALS }}
- name: Log in to Azure Container Registry
run: az acr login --name ${{ env.ACR_REGISTRY }}
- 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 }}

View File

@ -1,73 +0,0 @@
# 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: .

View File

@ -1,40 +0,0 @@
name: 🚀 Production Deployment
on:
push:
branches:
- master
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: ⬇️ Checkout Code
uses: actions/checkout@v4
- name: 🐢 Set up Node.js 20.x
uses: actions/setup-node@v4
with:
node-version: '20'
- name: 🐳 Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: 🔐 Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: me-central-1
- name: 🗂️ Write .env file from ENV_FILE Secret
run: echo "${{ secrets.ENV_FILE }}" > .env
- name: 📦 Install Dependencies
run: npm install
- name: 🛠️ Run Production Build & Deploy Script
run: |
chmod +x ./build.sh
./build.sh

5
.gitignore vendored
View File

@ -59,8 +59,3 @@ pids
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
config.dev
cdk.out
backend-cdk-new.out
web-cdk.out
backend-cdk.out
backend-cdk-final.out

View File

@ -1,28 +1,16 @@
FROM --platform=linux/amd64 node:20-alpine
# curl for health checks
RUN apk add --no-cache curl
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install --production --ignore-scripts
RUN npm install
RUN npm install -g @nestjs/cli
COPY . .
RUN npm run build
RUN addgroup -g 1001 -S nodejs
RUN adduser -S nestjs -u 1001
EXPOSE 4000
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"]
CMD ["npm", "run", "start"]

View File

@ -1,129 +0,0 @@
# 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.syncos.syncrow.ae
## 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

@ -1,12 +1,10 @@
# Backend
## Overview
This is the backend for an IoT application built using NestJS. It interfaces with the Tuya IoT cloud platform to manage homes, rooms, devices, ...etc.
This is the backend APIs project, developed with NestJS for Syncrow IOT Project.
## Database Model
The database uses PostgreSQL and TypeORM. Below is an entity relationship diagram:
The main entities are:
@ -21,11 +19,10 @@ Other Entities - sessions, OTPs, etc.
The entities have a one-to-many relationship - a user has multiple homes, a home has multiple rooms, and a room has multiple devices.
## Architecture
The application is deployed on Azure App Service using Docker containers. There are separate deployment slots for development, staging, and production environments.
## Installation
## Installation
First, ensure that you have Node.js `v20.11` or newer (LTS ONLY) installed on your system.
To install the project dependencies, run the following command in the project root directory:
@ -64,8 +61,8 @@ $ npm run test:cov
![Syncrow ERD Digram](https://github.com/SyncrowIOT/backend/assets/83524184/94273a2b-625c-4a34-9cd4-fb14415ce884)
## Architecture
## Architecture
+----------------------------------+
| |
| Applications |
@ -110,29 +107,3 @@ $ 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.syncos.syncrow.ae
• Health check: https://api.syncos.syncrow.ae/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

View File

@ -1,46 +0,0 @@
#!/bin/bash
set -e
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
REGION=${AWS_DEFAULT_REGION:-me-central-1}
REPO_NAME=syncrow-backend
IMAGE_TAG=latest
CLUSTER_NAME=syncrow-backend-cluster
STACK_NAME=SyncrowBackendStack
CERTIFICATE_ARN="arn:aws:acm:$REGION:$ACCOUNT_ID:certificate/bea1e2ae-84a1-414e-8dbf-4599397e7ed0"
echo "🔐 Logging into ECR..."
aws ecr get-login-password --region $REGION | docker login --username AWS --password-stdin "$ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com"
echo "🐳 Building Docker image..."
docker build --platform=linux/amd64 -t $REPO_NAME .
echo "🏷️ Tagging image..."
docker tag $REPO_NAME:$IMAGE_TAG "$ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com/$REPO_NAME:$IMAGE_TAG"
echo "📤 Pushing image to ECR..."
docker push "$ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com/$REPO_NAME:$IMAGE_TAG"
echo "🔍 Checking if ECS service exists..."
SERVICE_ARN=$(aws ecs list-services \
--cluster $CLUSTER_NAME \
--query 'serviceArns[0]' \
--output text \
--region $REGION 2>/dev/null || echo "")
echo "📦 Deploying CDK Stack..."
npx cdk deploy $STACK_NAME \
--context certificateArn=$CERTIFICATE_ARN \
--require-approval never
if [[ "$SERVICE_ARN" != "" && "$SERVICE_ARN" != "None" ]]; then
SERVICE_NAME=$(basename "$SERVICE_ARN")
echo "🚀 Redeploying ECS Service: $SERVICE_NAME"
aws ecs update-service \
--cluster $CLUSTER_NAME \
--service $SERVICE_NAME \
--force-new-deployment \
--region $REGION
fi
echo "✅ All done."

View File

@ -1,29 +0,0 @@
{
"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."
},
"hosted-zone:account=482311766496:domainName=syncrow.ae:region=me-central-1": {
"Id": "/hostedzone/Z01153152LRHQTA1370P4",
"Name": "syncrow.ae."
}
}

View File

@ -1,58 +0,0 @@
{
"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
}
}

View File

@ -1,22 +0,0 @@
#!/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

View File

@ -1,16 +0,0 @@
#!/usr/bin/env node
import * as cdk from 'aws-cdk-lib';
import 'source-map-support/register';
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: 'postgres',
certificateArn:
'arn:aws:acm:me-central-1:482311766496:certificate/423b343e-402b-4978-89bd-cda25f7a8873',
});

View File

@ -1,393 +0,0 @@
import * as cdk from 'aws-cdk-lib';
import * as acm from 'aws-cdk-lib/aws-certificatemanager';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as ecr from 'aws-cdk-lib/aws-ecr';
import * as ecs from 'aws-cdk-lib/aws-ecs';
import * as ecsPatterns from 'aws-cdk-lib/aws-ecs-patterns';
import * as elbv2 from 'aws-cdk-lib/aws-elasticloadbalancingv2';
import * as logs from 'aws-cdk-lib/aws-logs';
import * as rds from 'aws-cdk-lib/aws-rds';
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',
);
const dbCluster = rds.DatabaseCluster.fromDatabaseClusterAttributes(
this,
'SyncrowDatabase',
{
clusterIdentifier: 'syncrow-backend',
instanceIdentifiers: ['syncrowdatabase-instance-1'],
engine: rds.DatabaseClusterEngine.auroraPostgres({
version: rds.AuroraPostgresEngineVersion.VER_16_6,
}),
port: 5432,
securityGroups: [
ec2.SecurityGroup.fromSecurityGroupId(
this,
'ImportedDbSecurityGroup',
'sg-07e163f588b2bac25',
),
],
clusterEndpointAddress:
'syncrow-backend.cluster-criskv1sdkq4.me-central-1.rds.amazonaws.com',
},
);
// Import the existing database secret separately
const dbSecret = rds.DatabaseSecret.fromSecretCompleteArn(
this,
'ImportedDbSecret',
'arn:aws:secretsmanager:me-central-1:482311766496:secret:rds!cluster-43ec14cd-9301-43e2-aa79-d330a429a126-v0JDQN',
);
// ECR Repository for Docker images - import existing repository
const ecrRepository = ecr.Repository.fromRepositoryName(
this,
'SyncrowBackendRepo',
'syncrow-backend',
);
// 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.syncos.syncrow.ae',
validation: acm.CertificateValidation.fromDns(),
});
// ECS Fargate Service with Application Load Balancer
const fargateService =
new ecsPatterns.ApplicationLoadBalancedFargateService(
this,
'SyncrowBackendService',
{
cluster,
memoryLimitMiB: 1024,
cpu: 512,
desiredCount: 2,
domainName: 'api.syncos.syncrow.ae',
domainZone: route53.HostedZone.fromLookup(this, 'SyncrowZone', {
domainName: 'syncrow.ae',
}),
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 || 'postgres',
AZURE_POSTGRESQL_USER: 'postgres',
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(
dbSecret,
'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
dbSecret.grantRead(fargateService.taskDefinition.taskRole);
this.apiUrl = 'https://api.syncos.syncrow.ae';
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,11 +397,6 @@ 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 =
@ -506,6 +501,7 @@ 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

@ -15,7 +15,6 @@ export enum ProductType {
WL = 'WL',
GD = 'GD',
CUR = 'CUR',
CUR_2 = 'CUR_2',
PC = 'PC',
FOUR_S = '4S',
SIX_S = '6S',

View File

@ -25,6 +25,7 @@ import {
InviteUserEntity,
InviteUserSpaceEntity,
} from '../modules/Invite-user/entities';
import { SpaceDailyOccupancyDurationEntity } from '../modules/occupancy/entities';
import {
PowerClampDailyEntity,
PowerClampHourlyEntity,
@ -46,7 +47,6 @@ 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,7 +58,6 @@ import {
UserSpaceEntity,
} from '../modules/user/entities';
import { VisitorPasswordEntity } from '../modules/visitor-password/entities';
import { SpaceDailyOccupancyDurationEntity } from '../modules/occupancy/entities';
@Module({
imports: [
TypeOrmModule.forRootAsync({
@ -87,7 +86,6 @@ import { SpaceDailyOccupancyDurationEntity } from '../modules/occupancy/entities
PermissionTypeEntity,
CommunityEntity,
SpaceEntity,
SpaceLinkEntity,
SubspaceEntity,
UserSpaceEntity,
DeviceUserPermissionEntity,
@ -127,7 +125,7 @@ import { SpaceDailyOccupancyDurationEntity } from '../modules/occupancy/entities
logger: typeOrmLogger,
extra: {
charset: 'utf8mb4',
max: 20, // set pool max size
max: 50, // 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,10 +1,9 @@
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 { IsBoolean, IsOptional } from 'class-validator';
import { BooleanValues } from '../constants/boolean-values.enum';
import { IsPageRequestParam } from '../validators/is-page-request-param.validator';
import { IsSizeRequestParam } from '../validators/is-size-request-param.validator';
export class PaginationRequestGetListDto {
@ApiProperty({
@ -19,6 +18,7 @@ export class PaginationRequestGetListDto {
return value.obj.includeSpaces === BooleanValues.TRUE;
})
public includeSpaces?: boolean = false;
@IsOptional()
@IsPageRequestParam({
message: 'Page must be bigger than 0',
@ -40,40 +40,4 @@ 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

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

View File

@ -28,8 +28,6 @@ import { AqiDataService } from '@app/common/helper/services/aqi.data.service';
export class DeviceStatusFirebaseService {
private tuya: TuyaContext;
private firebaseDb: Database;
private readonly isDevEnv: boolean;
constructor(
private readonly configService: ConfigService,
private readonly deviceRepository: DeviceRepository,
@ -48,14 +46,7 @@ export class DeviceStatusFirebaseService {
});
// Initialize firebaseDb using firebaseDataBase function
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,
@ -70,7 +61,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,
});
@ -131,7 +122,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(
@ -175,14 +166,6 @@ 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}`,
@ -204,18 +187,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);
}
@ -228,7 +211,6 @@ export class DeviceStatusFirebaseService {
return existingData;
});
if (this.isDevEnv) {
// Save logs to your repository
const newLogs = addDeviceStatusDto.log.properties.map((property) => {
return this.deviceStatusLogRepository.create({
@ -252,8 +234,8 @@ export class DeviceStatusFirebaseService {
PowerClampEnergyEnum.ENERGY_CONSUMED_C,
]);
const energyStatus = addDeviceStatusDto?.log?.properties?.find(
(status) => energyCodes.has(status.code),
const energyStatus = addDeviceStatusDto?.log?.properties?.find((status) =>
energyCodes.has(status.code),
);
if (energyStatus) {
@ -287,192 +269,8 @@ export class DeviceStatusFirebaseService {
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,7 +3,6 @@ import { getDatabase } from 'firebase/database';
import { ConfigService } from '@nestjs/config';
export const initializeFirebaseApp = (configService: ConfigService) => {
try {
const firebaseConfig = {
apiKey: configService.get<string>('FIREBASE_API_KEY'),
authDomain: configService.get<string>('FIREBASE_AUTH_DOMAIN'),
@ -17,18 +16,8 @@ export const initializeFirebaseApp = (configService: ConfigService) => {
databaseURL: configService.get<string>('FIREBASE_DATABASE_URL'),
};
// 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

@ -1,18 +1,32 @@
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:
process.env.AZURE_POSTGRESQL_DATABASE === 'development' ? 'debug' : 'error',
environment === 'local'
? 'debug'
: environment === 'development'
? 'warn'
: '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: true,
prettyPrint: environment === 'local',
}),
),
}),
// Only create file logs if NOT local
...(environment !== 'local'
? [
new winston.transports.File({
filename: 'logs/error.log',
level: 'error',
@ -20,7 +34,10 @@ export const winstonLoggerOptions: winston.LoggerOptions = {
}),
new winston.transports.File({
filename: 'logs/combined.log',
level: 'info',
format: winston.format.json(),
}),
]
: []),
],
};

View File

@ -8,14 +8,14 @@ import {
Unique,
} from 'typeorm';
import { RoleType } from '@app/common/constants/role.type.enum';
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 { 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 { ProjectEntity } from '../../project/entities';
import { SpaceEntity } from '../../space/entities/space.entity';
@Entity({ name: 'invite-user' })
@Unique(['email', 'project'])
@ -82,10 +82,7 @@ export class InviteUserEntity extends AbstractEntity<InviteUserDto> {
onDelete: 'CASCADE',
})
public roleType: RoleTypeEntity;
@OneToOne(() => UserEntity, (user) => user.inviteUser, {
nullable: true,
onDelete: 'CASCADE',
})
@OneToOne(() => UserEntity, (user) => user.inviteUser, { nullable: true })
@JoinColumn({ name: 'user_uuid' })
user: UserEntity;
@OneToMany(
@ -115,9 +112,7 @@ export class InviteUserSpaceEntity extends AbstractEntity<InviteUserSpaceDto> {
})
public uuid: string;
@ManyToOne(() => InviteUserEntity, (inviteUser) => inviteUser.spaces, {
onDelete: 'CASCADE',
})
@ManyToOne(() => InviteUserEntity, (inviteUser) => inviteUser.spaces)
@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 { PermissionTypeEntity } from '../../permission/entities';
import { PowerClampHourlyEntity } from '../../power-clamp/entities/power-clamp.entity';
import { PresenceSensorDailyDeviceEntity } from '../../presence-sensor/entities';
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 { 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 { UserEntity } from '../../user/entities';
import { DeviceNotificationDto } from '../dtos';
import { DeviceDto, DeviceUserPermissionDto } from '../dtos/device.dto';
import { PowerClampHourlyEntity } from '../../power-clamp/entities/power-clamp.entity';
import { PresenceSensorDailyDeviceEntity } from '../../presence-sensor/entities';
@Entity({ name: 'device' })
@Unique(['deviceTuyaUuid'])
@ -111,7 +111,6 @@ export class DeviceNotificationEntity extends AbstractEntity<DeviceNotificationD
@ManyToOne(() => UserEntity, (user) => user.userPermission, {
nullable: false,
onDelete: 'CASCADE',
})
user: UserEntity;
@ -150,7 +149,6 @@ export class DeviceUserPermissionEntity extends AbstractEntity<DeviceUserPermiss
@ManyToOne(() => UserEntity, (user) => user.userPermission, {
nullable: false,
onDelete: 'CASCADE',
})
user: UserEntity;
constructor(partial: Partial<DeviceUserPermissionEntity>) {

View File

@ -1,32 +1,3 @@
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';
@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);
}
}
export class SpaceLinkEntity extends AbstractEntity {}

View File

@ -1,18 +1,17 @@
import { Column, Entity, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
import { SpaceDto } from '../dtos';
import { AbstractEntity } from '../../abstract/entities/abstract.entity';
import { UserSpaceEntity } from '../../user/entities';
import { DeviceEntity } from '../../device/entities';
import { AqiSpaceDailyPollutantStatsEntity } from '../../aqi/entities';
import { CommunityEntity } from '../../community/entities';
import { SpaceLinkEntity } from './space-link.entity';
import { DeviceEntity } from '../../device/entities';
import { InviteUserSpaceEntity } from '../../Invite-user/entities';
import { SpaceDailyOccupancyDurationEntity } from '../../occupancy/entities';
import { PresenceSensorDailySpaceEntity } from '../../presence-sensor/entities';
import { SceneEntity } from '../../scene/entities';
import { SpaceModelEntity } from '../../space-model';
import { InviteUserSpaceEntity } from '../../Invite-user/entities';
import { UserSpaceEntity } from '../../user/entities';
import { SpaceDto } from '../dtos';
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> {
@ -75,16 +74,6 @@ 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,7 +1,6 @@
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';
@ -13,11 +12,7 @@ export class SpaceRepository extends Repository<SpaceEntity> {
}
@Injectable()
export class SpaceLinkRepository extends Repository<SpaceLinkEntity> {
constructor(private dataSource: DataSource) {
super(SpaceLinkEntity, dataSource.createEntityManager());
}
}
export class SpaceLinkRepository {}
@Injectable()
export class InviteSpaceRepository extends Repository<InviteSpaceEntity> {

View File

@ -1,4 +1,3 @@
import { defaultProfilePicture } from '@app/common/constants/default.profile.picture';
import {
Column,
DeleteDateColumn,
@ -9,26 +8,27 @@ 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,9 +94,7 @@ export class UserEntity extends AbstractEntity<UserDto> {
@Column({ type: 'timestamp', nullable: true })
appAgreementAcceptedAt: Date;
@OneToMany(() => UserSpaceEntity, (userSpace) => userSpace.user, {
onDelete: 'CASCADE',
})
@OneToMany(() => UserSpaceEntity, (userSpace) => userSpace.user)
userSpaces: UserSpaceEntity[];
@OneToMany(
@ -160,7 +158,6 @@ export class UserEntity extends AbstractEntity<UserDto> {
export class UserNotificationEntity extends AbstractEntity<UserNotificationDto> {
@ManyToOne(() => UserEntity, (user) => user.roleType, {
nullable: false,
onDelete: 'CASCADE',
})
user: UserEntity;
@Column({
@ -222,10 +219,7 @@ export class UserSpaceEntity extends AbstractEntity<UserSpaceDto> {
})
public uuid: string;
@ManyToOne(() => UserEntity, (user) => user.userSpaces, {
nullable: false,
onDelete: 'CASCADE',
})
@ManyToOne(() => UserEntity, (user) => user.userSpaces, { nullable: false })
user: UserEntity;
@ManyToOne(() => SpaceEntity, (space) => space.userSpaces, {

View File

@ -1,7 +1,7 @@
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Column, Entity, ManyToOne, JoinColumn, Index } from 'typeorm';
import { VisitorPasswordDto } from '../dtos';
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,7 +14,6 @@ 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,10 +61,6 @@ export class SuperAdminSeeder {
lastName: 'Admin',
isUserVerified: true,
isActive: true,
hasAcceptedAppAgreement: true,
hasAcceptedWebAgreement: true,
appAgreementAcceptedAt: new Date(),
webAgreementAcceptedAt: new Date(),
roleType: { uuid: defaultUserRoleUuid },
});
} catch (err) {

630
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -6,25 +6,19 @@
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "npx nest build",
"build:lambda": "npx nest build && cp package*.json dist/",
"build": "npm run test && npx nest build",
"format": "prettier --write \"apps/**/*.ts\" \"libs/**/*.ts\"",
"start": "node dist/main",
"start:dev": "npx nest start --watch",
"start": "npm run test && node dist/main",
"start:dev": "npm run test && npx nest start --watch",
"dev": "npx nest start --watch",
"start:debug": "npx nest start --debug --watch",
"start:prod": "node dist/main",
"start:lambda": "node dist/lambda",
"start:debug": "npm run test && npx nest start --debug --watch",
"start:prod": "npm run test && node dist/main",
"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",
"deploy": "./deploy.sh",
"infra:build": "bash build.sh",
"infra:deploy": "cdk deploy SyncrowBackendStack",
"infra:destroy": "cdk destroy SyncrowBackendStack"
"test:e2e": "jest --config ./apps/backend/test/jest-e2e.json"
},
"dependencies": {
"@fast-csv/format": "^5.0.2",
@ -42,16 +36,13 @@
"@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",
@ -61,13 +52,11 @@
"nest-winston": "^1.10.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"
},
@ -84,9 +73,7 @@
"@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",
@ -100,9 +87,5 @@
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.1.3"
},
"engines": {
"node": "20.x",
"npm": "10.x"
}
}

View File

@ -78,6 +78,7 @@ import { AqiDataService } from '@app/common/helper/services/aqi.data.service';
ProjectRepository,
SpaceService,
InviteSpaceRepository,
// Todo: find out why this is needed
SpaceLinkService,
SubSpaceService,
ValidationService,

View File

@ -1,4 +1,3 @@
import { CommunityService } from '../services/community.service';
import {
Body,
Controller,
@ -10,17 +9,18 @@ import {
Query,
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { ApiBearerAuth, ApiOperation, ApiTags } 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 { 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';
import { Permissions } from 'src/decorators/permissions.decorator';
import { PermissionsGuard } from 'src/guards/permissions.guard';
import { ProjectParam } from '../dtos';
@ApiTags('Community Module')
@Controller({
@ -45,6 +45,21 @@ 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,4 +1,7 @@
import { ORPHAN_COMMUNITY_NAME } from '@app/common/constants/orphan-constant';
import {
ORPHAN_COMMUNITY_NAME,
ORPHAN_SPACE_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';
@ -22,7 +25,7 @@ import {
NotFoundException,
} from '@nestjs/common';
import { SpaceService } from 'src/space/services';
import { SelectQueryBuilder } from 'typeorm';
import { QueryRunner, SelectQueryBuilder } from 'typeorm';
import { AddCommunityDto, GetCommunityParams, ProjectParam } from '../dtos';
import { UpdateCommunityNameDto } from '../dtos/update.community.dto';
@ -69,12 +72,18 @@ export class CommunityService {
}
}
async getCommunityById(params: GetCommunityParams): Promise<BaseResponseDto> {
async getCommunityById(
params: GetCommunityParams,
queryRunner?: QueryRunner,
): Promise<BaseResponseDto> {
const { communityUuid, projectUuid } = params;
await this.validateProject(projectUuid);
const community = await this.communityRepository.findOneBy({
const communityRepository =
queryRunner?.manager.getRepository(CommunityEntity) ||
this.communityRepository;
const community = await communityRepository.findOneBy({
uuid: communityUuid,
});
@ -111,7 +120,6 @@ 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(
@ -162,6 +170,75 @@ 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,11 +1,11 @@
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 { 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 { GetDoorLockDevices, ProjectParam } from '../dtos';
import { PermissionsGuard } from 'src/guards/permissions.guard';
import { GetDevicesFilterDto, ProjectParam } from '../dtos';
import { DeviceService } from '../services/device.service';
@ApiTags('Device Module')
@Controller({
@ -25,7 +25,7 @@ export class DeviceProjectController {
})
async getAllDevices(
@Param() param: ProjectParam,
@Query() query: GetDoorLockDevices,
@Query() query: GetDevicesFilterDto,
) {
return await this.deviceService.getAllDevices(param, query);
}

View File

@ -1,6 +1,7 @@
import { DeviceTypeEnum } from '@app/common/constants/device-type.enum';
import { ApiProperty } from '@nestjs/swagger';
import {
IsArray,
IsEnum,
IsNotEmpty,
IsOptional,
@ -41,16 +42,7 @@ 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',
@ -72,3 +64,23 @@ 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,
GetDoorLockDevices,
GetDevicesFilterDto,
} from '../dtos/get.device.dto';
import {
controlDeviceInterface,
@ -955,19 +955,20 @@ export class DeviceService {
async getAllDevices(
param: ProjectParam,
query: GetDoorLockDevices,
{ deviceType, spaces }: GetDevicesFilterDto,
): Promise<BaseResponseDto> {
try {
await this.validateProject(param.projectUuid);
if (query.deviceType === DeviceTypeEnum.DOOR_LOCK) {
return await this.getDoorLockDevices(param.projectUuid);
} else if (!query.deviceType) {
if (deviceType === DeviceTypeEnum.DOOR_LOCK) {
return await this.getDoorLockDevices(param.projectUuid, spaces);
} else if (!deviceType) {
const devices = await this.deviceRepository.find({
where: {
isActive: true,
spaceDevice: {
community: { project: { uuid: param.projectUuid } },
uuid: spaces && spaces.length ? In(spaces) : undefined,
spaceName: Not(ORPHAN_SPACE_NAME),
community: { project: { uuid: param.projectUuid } },
},
},
relations: [
@ -1563,7 +1564,7 @@ export class DeviceService {
}
}
async getDoorLockDevices(projectUuid: string) {
async getDoorLockDevices(projectUuid: string, spaces?: string[]) {
await this.validateProject(projectUuid);
const devices = await this.deviceRepository.find({
@ -1573,6 +1574,7 @@ export class DeviceService {
},
spaceDevice: {
spaceName: Not(ORPHAN_SPACE_NAME),
uuid: spaces && spaces.length ? In(spaces) : undefined,
community: {
project: {
uuid: projectUuid,

View File

@ -71,7 +71,6 @@ 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,
@ -115,7 +114,6 @@ import { UserService, UserSpaceService } from 'src/users/services';
TimeZoneRepository,
SpaceService,
InviteSpaceRepository,
SpaceLinkService,
SubSpaceService,
ValidationService,
NewTagService,

View File

@ -1,15 +1,14 @@
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 { AppModule } from './app.module';
import { json, urlencoded } from 'body-parser';
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';
import { setupSwaggerAuthentication } from '../libs/common/src/util/user-auth.swagger.utils';
import { AppModule } from './app.module';
import { HttpExceptionFilter } from './common/filters/http-exception/http-exception.filter';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
@ -30,6 +29,13 @@ async function bootstrap() {
}),
);
app.use((req, res, next) => {
console.log('Real IP:', req.ip);
next();
});
// app.getHttpAdapter().getInstance().set('trust proxy', 1);
app.use(
helmet({
contentSecurityPolicy: false,
@ -57,8 +63,7 @@ async function bootstrap() {
logger.error('Seeding failed!', error.stack || error);
}
const port = process.env.PORT || 3000;
logger.log(`Starting application on port ${port}...`);
await app.listen(port, '0.0.0.0');
logger.log('Starting auth at port ...', process.env.PORT || 4000);
await app.listen(process.env.PORT || 4000);
}
bootstrap();

View File

@ -1,24 +1,25 @@
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,
@ -26,6 +27,7 @@ import { PowerClampService } from '../services/power-clamp.service';
})
export class PowerClampController {
constructor(private readonly powerClampService: PowerClampService) {}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Get(':powerClampUuid/historical')

View File

@ -1,4 +1,5 @@
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';
@ -49,7 +50,6 @@ 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,7 +60,6 @@ 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 { AqiDataService } from '@app/common/helper/services/aqi.data.service';
@Module({
imports: [ConfigModule],
controllers: [PowerClampController],
@ -90,7 +89,6 @@ import { AqiDataService } from '@app/common/helper/services/aqi.data.service';
SceneRepository,
AutomationRepository,
InviteSpaceRepository,
SpaceLinkService,
SubSpaceService,
TagService,
SpaceModelService,

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,7 +54,6 @@ 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,
@ -87,7 +86,6 @@ const CommandHandlers = [CreateOrphanSpaceHandler];
UserRepository,
SpaceService,
InviteSpaceRepository,
SpaceLinkService,
SubSpaceService,
ValidationService,
TagService,

View File

@ -24,6 +24,7 @@ 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 {
@ -212,8 +213,14 @@ export class ProjectService {
}
}
async findOne(uuid: string): Promise<ProjectEntity> {
const project = await this.projectRepository.findOne({ where: { uuid } });
async findOne(
uuid: string,
queryRunner?: QueryRunner,
): Promise<ProjectEntity> {
const projectRepository = queryRunner
? queryRunner.manager.getRepository(ProjectEntity)
: this.projectRepository;
const project = await projectRepository.findOne({ where: { uuid } });
if (!project) {
throw new HttpException(
`Invalid project with uuid ${uuid}`,

View File

@ -1,6 +1,6 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Injectable, HttpException, HttpStatus } from '@nestjs/common';
import { TuyaContext } from '@tuya/tuya-connector-nodejs';
import { ConfigService } from '@nestjs/config';
import {
AddScheduleDto,
EnableScheduleDto,
@ -11,14 +11,14 @@ import {
getDeviceScheduleInterface,
} from '../interfaces/get.schedule.interface';
import { ProductType } from '@app/common/constants/product-type.enum';
import { convertKeysToCamelCase } from '@app/common/helper/camelCaseConverter';
import { DeviceRepository } from '@app/common/modules/device/repositories';
import { ProductType } from '@app/common/constants/product-type.enum';
import { convertTimestampToDubaiTime } from '@app/common/helper/convertTimestampToDubaiTime';
import {
getEnabledDays,
getScheduleStatus,
} from '@app/common/helper/getScheduleStatus';
import { DeviceRepository } from '@app/common/modules/device/repositories';
@Injectable()
export class ScheduleService {
@ -57,8 +57,7 @@ export class ScheduleService {
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
deviceDetails.productDevice.prodType !== ProductType.GD
) {
throw new HttpException(
'This device is not supported for schedule',
@ -116,8 +115,7 @@ export class ScheduleService {
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
deviceDetails.productDevice.prodType !== ProductType.GD
) {
throw new HttpException(
'This device is not supported for schedule',
@ -171,8 +169,7 @@ export class ScheduleService {
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
deviceDetails.productDevice.prodType !== ProductType.GD
) {
throw new HttpException(
'This device is not supported for schedule',
@ -240,8 +237,7 @@ export class ScheduleService {
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
deviceDetails.productDevice.prodType !== ProductType.GD
) {
throw new HttpException(
'This device is not supported for schedule',
@ -327,8 +323,7 @@ export class ScheduleService {
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
deviceDetails.productDevice.prodType !== ProductType.GD
) {
throw new HttpException(
'This device is not supported for schedule',

View File

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

View File

@ -46,7 +46,6 @@ import { CommunityService } from 'src/community/services';
import { DeviceService } from 'src/device/services';
import { SceneService } from 'src/scene/services';
import {
SpaceLinkService,
SpaceService,
SubspaceDeviceService,
SubSpaceService,
@ -92,7 +91,6 @@ const CommandHandlers = [
DeviceRepository,
TuyaService,
CommunityRepository,
SpaceLinkService,
SpaceLinkRepository,
InviteSpaceRepository,
NewTagService,

View File

@ -3,7 +3,8 @@ import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
ArrayUnique,
IsBoolean,
IsArray,
IsMongoId,
IsNotEmpty,
IsNumber,
IsOptional,
@ -12,7 +13,7 @@ import {
NotEquals,
ValidateNested,
} from 'class-validator';
import { ProcessTagDto } from 'src/tags/dtos';
import { CreateProductAllocationDto } from './create-product-allocation.dto';
import { AddSubspaceDto } from './subspace';
export class AddSpaceDto {
@ -47,14 +48,6 @@ 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;
@ -64,23 +57,19 @@ export class AddSpaceDto {
y: number;
@ApiProperty({
description: 'UUID of the Space',
description: 'UUID of the Space Model',
example: 'd290f1ee-6c54-4b01-90e6-d701748f0851',
})
@IsString()
@IsMongoId()
@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) {
@ -100,51 +89,21 @@ export class AddSpaceDto {
subspaces?: AddSubspaceDto[];
@ApiProperty({
description: 'List of tags associated with the space model',
type: [ProcessTagDto],
description: 'List of allocations associated with the space',
type: [CreateProductAllocationDto],
})
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => ProcessTagDto)
tags?: ProcessTagDto[];
}
@Type(() => CreateProductAllocationDto)
productAllocations?: CreateProductAllocationDto[];
export class AddUserSpaceDto {
@ApiProperty({
description: 'spaceUuid',
required: true,
description: 'List of children spaces associated with the space',
type: [AddSpaceDto],
})
@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);
}
@IsOptional()
@ValidateNested({ each: true })
@Type(() => AddSpaceDto)
children?: AddSpaceDto[];
}

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 {
tags: ProcessTagDto[];
productAllocations: CreateProductAllocationDto[];
projectUuid: string;
queryRunner: QueryRunner;
type: AllocationsOwnerType;

View File

@ -0,0 +1,36 @@
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,8 +1,10 @@
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 './user-space.param';
export * from './subspace';
export * from './project.param.dto';
export * from './update.space.dto';
export * from './subspace';
export * from './tag';
export * from './update.space.dto';
export * from './user-space.param';

View File

@ -1,4 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsArray,
IsNotEmpty,
@ -6,8 +7,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({
@ -24,7 +25,7 @@ export class AddSubspaceDto {
})
@IsArray()
@ValidateNested({ each: true })
@Type(() => ProcessTagDto)
@Type(() => CreateProductAllocationDto)
@IsOptional()
tags?: ProcessTagDto[];
productAllocations?: CreateProductAllocationDto[];
}

View File

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

View File

@ -1,14 +0,0 @@
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,16 +1,14 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';
import { ApiPropertyOptional, PartialType } from '@nestjs/swagger';
import { IsOptional, IsUUID } from 'class-validator';
import { AddSubspaceDto } from './add.subspace.dto';
export class UpdateSubspaceDto {
@ApiProperty({
description: 'Name of the subspace',
example: 'Living Room',
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',
})
@IsNotEmpty()
@IsString()
subspaceName?: string;
@IsNotEmpty()
@IsString()
subspaceUuid: string;
@IsOptional()
@IsUUID()
uuid?: string;
}

View File

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

View File

@ -5,11 +5,7 @@ import { DeviceService } from 'src/device/services';
import { UserSpaceService } from 'src/users/services';
import { DataSource } from 'typeorm';
import { DisableSpaceCommand } from '../commands';
import {
SpaceLinkService,
SpaceSceneService,
SubSpaceService,
} from '../services';
import { SpaceSceneService, SubSpaceService } from '../services';
@CommandHandler(DisableSpaceCommand)
export class DisableSpaceHandler
@ -19,7 +15,6 @@ 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,
) {}
@ -39,8 +34,6 @@ export class DisableSpaceHandler
'subspaces',
'parent',
'devices',
'outgoingConnections',
'incomingConnections',
'scenes',
'children',
'userSpaces',
@ -79,7 +72,6 @@ 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, tags, type } = dto;
const { projectUuid, queryRunner, productAllocations, type } = dto;
const allocationsData = await this.tagService.processTags(
tags,
const allocationsData = await this.tagService.upsertTags(
productAllocations,
projectUuid,
queryRunner,
);
@ -29,15 +29,17 @@ 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 = tags.map(({ uuid, name, productUuid }) => {
const inputTag = uuid
? createdTagsByUUID.get(uuid)
: createdTagsByName.get(name);
const productTagMapping = productAllocations.map(
({ tagUuid, tagName, productUuid }) => {
const inputTag = tagUuid
? createdTagsByUUID.get(tagUuid)
: createdTagsByName.get(tagName);
return {
tag: inputTag?.uuid,
product: productUuid,
};
});
},
);
switch (type) {
case AllocationsOwnerType.SPACE: {

View File

@ -1,121 +1,6 @@
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';
import { Injectable } from '@nestjs/common';
// todo: find out why we need to import this
// in community module in order for the whole system to work
@Injectable()
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,
);
}
}
}
export class SpaceLinkService {}

View File

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

View File

@ -22,7 +22,6 @@ 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';
@ -32,9 +31,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';
@ -44,7 +43,6 @@ 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,
@ -57,50 +55,72 @@ export class SpaceService {
async createSpace(
addSpaceDto: AddSpaceDto,
params: CommunitySpaceParam,
queryRunner?: QueryRunner,
recursiveCallParentEntity?: SpaceEntity,
): Promise<BaseResponseDto> {
const { parentUuid, direction, spaceModelUuid, subspaces, tags } =
addSpaceDto;
const isRecursiveCall = !!queryRunner;
const {
parentUuid,
spaceModelUuid,
subspaces,
productAllocations,
children,
} = addSpaceDto;
const { communityUuid, projectUuid } = params;
const queryRunner = this.dataSource.createQueryRunner();
if (!queryRunner) {
queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
}
const { community } =
await this.validationService.validateCommunityAndProject(
communityUuid,
projectUuid,
queryRunner,
);
this.validateSpaceCreationCriteria({ spaceModelUuid, subspaces, tags });
this.validateSpaceCreationCriteria({
spaceModelUuid,
subspaces,
productAllocations,
});
const parent = parentUuid
const parent =
parentUuid && !isRecursiveCall
? await this.validationService.validateSpace(parentUuid)
: null;
const spaceModel = spaceModelUuid
? await this.validationService.validateSpaceModel(spaceModelUuid)
: null;
try {
const space = queryRunner.manager.create(SpaceEntity, {
...addSpaceDto,
// 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,
spaceModel,
parent: parentUuid ? parent : null,
parent: isRecursiveCall
? recursiveCallParentEntity
: parentUuid
? parent
: null,
community,
});
const newSpace = await queryRunner.manager.save(space);
const subspaceTags =
subspaces?.flatMap((subspace) => subspace.tags || []) || [];
this.checkDuplicateTags([...tags, ...subspaceTags]);
this.checkDuplicateTags([
...(productAllocations || []),
...(subspaces?.flatMap(
(subspace) => subspace.productAllocations || [],
) || []),
]);
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,
@ -109,15 +129,6 @@ 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,
@ -126,12 +137,32 @@ export class SpaceService {
projectUuid,
)
: Promise.resolve(),
tags?.length
? this.createAllocations(tags, projectUuid, queryRunner, newSpace)
productAllocations?.length
? this.createAllocations(
productAllocations,
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();
}
return new SuccessResponseDto({
statusCode: HttpStatus.CREATED,
@ -139,34 +170,34 @@ export class SpaceService {
message: 'Space created successfully',
});
} catch (error) {
await queryRunner.rollbackTransaction();
!isRecursiveCall ? await queryRunner.rollbackTransaction() : null;
if (error instanceof HttpException) {
throw error;
}
throw new HttpException(error.message, HttpStatus.INTERNAL_SERVER_ERROR);
} finally {
await queryRunner.release();
!isRecursiveCall ? await queryRunner.release() : null;
}
}
private checkDuplicateTags(allTags: ProcessTagDto[]) {
private checkDuplicateTags(allocations: CreateProductAllocationDto[]) {
const tagUuidSet = new Set<string>();
const tagNameProductSet = new Set<string>();
for (const tag of allTags) {
if (tag.uuid) {
if (tagUuidSet.has(tag.uuid)) {
for (const allocation of allocations) {
if (allocation.tagUuid) {
if (tagUuidSet.has(allocation.tagUuid)) {
throw new HttpException(
`Duplicate tag UUID found: ${tag.uuid}`,
`Duplicate tag UUID found: ${allocation.tagUuid}`,
HttpStatus.BAD_REQUEST,
);
}
tagUuidSet.add(tag.uuid);
tagUuidSet.add(allocation.tagUuid);
} else {
const tagKey = `${tag.name}-${tag.productUuid}`;
const tagKey = `${allocation.tagName}-${allocation.productUuid}`;
if (tagNameProductSet.has(tagKey)) {
throw new HttpException(
`Duplicate tag found with name "${tag.name}" and product "${tag.productUuid}".`,
`Duplicate tag found with name "${allocation.tagName}" and product "${allocation.productUuid}".`,
HttpStatus.BAD_REQUEST,
);
}
@ -195,12 +226,7 @@ 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')
@ -271,7 +297,6 @@ 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 {
@ -282,19 +307,6 @@ 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')
@ -423,7 +435,7 @@ export class SpaceService {
const queryRunner = this.dataSource.createQueryRunner();
const hasSubspace = updateSpaceDto.subspaces?.length > 0;
const hasTags = updateSpaceDto.tags?.length > 0;
const hasAllocations = updateSpaceDto.productAllocations?.length > 0;
try {
await queryRunner.connect();
@ -448,7 +460,7 @@ export class SpaceService {
await this.updateSpaceProperties(space, updateSpaceDto, queryRunner);
if (hasSubspace || hasTags) {
if (hasSubspace || hasAllocations) {
await queryRunner.manager.update(SpaceEntity, space.uuid, {
spaceModel: null,
});
@ -492,7 +504,7 @@ export class SpaceService {
await this.subSpaceService.unlinkModels(space.subspaces, queryRunner);
}
if (hasTags && space.productAllocations && space.spaceModel) {
if (hasAllocations && space.productAllocations && space.spaceModel) {
await this.spaceProductAllocationService.unlinkModels(
space,
queryRunner,
@ -508,13 +520,13 @@ export class SpaceService {
);
}
if (updateSpaceDto.tags) {
if (updateSpaceDto.productAllocations) {
await queryRunner.manager.delete(SpaceProductAllocationEntity, {
space: { uuid: space.uuid },
tag: {
uuid: Not(
In(
updateSpaceDto.tags
updateSpaceDto.productAllocations
.filter((tag) => tag.tagUuid)
.map((tag) => tag.tagUuid),
),
@ -522,11 +534,7 @@ export class SpaceService {
},
});
await this.createAllocations(
updateSpaceDto.tags.map((tag) => ({
name: tag.name,
uuid: tag.tagUuid,
productUuid: tag.productUuid,
})),
updateSpaceDto.productAllocations,
projectUuid,
queryRunner,
space,
@ -673,7 +681,7 @@ export class SpaceService {
}
}
private buildSpaceHierarchy(spaces: SpaceEntity[]): SpaceEntity[] {
buildSpaceHierarchy(spaces: SpaceEntity[]): SpaceEntity[] {
const map = new Map<string, SpaceEntity>();
// Step 1: Create a map of spaces by UUID
@ -702,13 +710,17 @@ export class SpaceService {
private validateSpaceCreationCriteria({
spaceModelUuid,
tags,
productAllocations,
subspaces,
}: Pick<AddSpaceDto, 'spaceModelUuid' | 'tags' | 'subspaces'>): void {
const hasTagsOrSubspaces =
(tags && tags.length > 0) || (subspaces && subspaces.length > 0);
}: Pick<
AddSpaceDto,
'spaceModelUuid' | 'productAllocations' | 'subspaces'
>): void {
const hasProductsOrSubspaces =
(productAllocations && productAllocations.length > 0) ||
(subspaces && subspaces.length > 0);
if (spaceModelUuid && hasTagsOrSubspaces) {
if (spaceModelUuid && hasProductsOrSubspaces) {
throw new HttpException(
'For space creation choose either space model or products and subspace',
HttpStatus.CONFLICT,
@ -717,13 +729,13 @@ export class SpaceService {
}
private async createAllocations(
tags: ProcessTagDto[],
productAllocations: CreateProductAllocationDto[],
projectUuid: string,
queryRunner: QueryRunner,
space: SpaceEntity,
): Promise<void> {
const allocationsData = await this.tagService.processTags(
tags,
const allocationsData = await this.tagService.upsertTags(
productAllocations,
projectUuid,
queryRunner,
);
@ -733,15 +745,17 @@ 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 = tags.map(({ uuid, name, productUuid }) => {
const inputTag = uuid
? createdTagsByUUID.get(uuid)
: createdTagsByName.get(name);
const productTagMapping = productAllocations.map(
({ tagUuid, tagName, productUuid }) => {
const inputTag = tagUuid
? createdTagsByUUID.get(tagUuid)
: createdTagsByName.get(tagName);
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 { UpdateSpaceAllocationDto } from 'src/space/interfaces/update-subspace-allocation.dto';
import { UpdateSubspaceDto } from 'src/space/dtos';
import { TagService as NewTagService } from 'src/tags/services';
import { In, Not, QueryRunner } from 'typeorm';
@ -60,31 +60,46 @@ export class SubspaceProductAllocationService {
}
async updateSubspaceProductAllocationsV2(
subSpaces: UpdateSpaceAllocationDto[],
subSpaces: UpdateSubspaceDto[],
projectUuid: string,
queryRunner: QueryRunner,
) {
await Promise.all(
subSpaces.map(async (subspace) => {
await queryRunner.manager.delete(SubspaceProductAllocationEntity, {
subspace: { uuid: subspace.uuid },
tag: {
subspace: subspace.uuid ? { uuid: subspace.uuid } : undefined,
tag: subspace.productAllocations
? {
uuid: Not(
In(
subspace.tags.filter((tag) => tag.uuid).map((tag) => tag.uuid),
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,
});
const subspaceEntity = await queryRunner.manager.findOne(
SubspaceEntity,
{
where: { uuid: subspace.uuid },
},
);
const processedTags = await this.tagService.processTags(
subspace.tags,
const processedTags = await this.tagService.upsertTags(
subspace.productAllocations,
projectUuid,
queryRunner,
);
@ -97,11 +112,11 @@ export class SubspaceProductAllocationService {
);
// Create the product-tag mapping based on the processed tags
const productTagMapping = subspace.tags.map(
({ uuid, name, productUuid }) => {
const inputTag = uuid
? createdTagsByUUID.get(uuid)
: createdTagsByName.get(name);
const productTagMapping = subspace.productAllocations.map(
({ tagUuid, tagName, productUuid }) => {
const inputTag = tagUuid
? createdTagsByUUID.get(tagUuid)
: createdTagsByName.get(tagName);
return {
tag: inputTag?.uuid,
product: productUuid,
@ -118,71 +133,6 @@ 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,
@ -205,67 +155,6 @@ 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,
ModifySubspaceDto,
UpdateSubspaceDto,
} from '../../dtos';
import { SubspaceModelEntity } from '@app/common/modules/space-model';
@ -103,13 +103,13 @@ export class SubSpaceService {
queryRunner,
);
await Promise.all(
addSubspaceDtos.map(async ({ tags }, index) => {
addSubspaceDtos.map(async ({ productAllocations }, index) => {
// map the dto to the corresponding subspace
const subspace = createdSubspaces[index];
await this.createAllocations({
projectUuid,
queryRunner,
tags,
productAllocations,
type: AllocationsOwnerType.SUBSPACE,
subspace,
});
@ -145,7 +145,7 @@ export class SubSpaceService {
space,
);
const newSubspace = this.subspaceRepository.create({
...addSubspaceDto,
subspaceName: addSubspaceDto.subspaceName,
space,
});
@ -305,7 +305,7 @@ export class SubSpaceService {
} */
async updateSubspaceInSpace(
subspaceDtos: ModifySubspaceDto[],
subspaceDtos: UpdateSubspaceDto[],
queryRunner: QueryRunner,
space: SpaceEntity,
projectUuid: string,
@ -324,42 +324,52 @@ export class SubSpaceService {
disabled: true,
},
);
await queryRunner.manager.delete(SubspaceProductAllocationEntity, {
subspace: { uuid: Not(In(subspaceDtos.map((dto) => dto.uuid))) },
subspace: {
uuid: Not(
In(subspaceDtos.filter(({ uuid }) => uuid).map(({ uuid }) => uuid)),
),
},
});
// create or update subspaces provided in the list
const newSubspaces = this.subspaceRepository.create(
subspaceDtos.filter((dto) => !dto.uuid),
subspaceDtos
.filter((dto) => !dto.uuid)
.map((dto) => ({
subspaceName: dto.subspaceName,
space,
})),
);
const updatedSubspaces: SubspaceEntity[] = await queryRunner.manager.save(
SubspaceEntity,
[...newSubspaces, ...subspaceDtos.filter((dto) => dto.uuid)].map(
(subspace) => ({ ...subspace, space }),
),
[
...newSubspaces,
...subspaceDtos
.filter((dto) => dto.uuid)
.map((dto) => ({
subspaceName: dto.subspaceName,
space,
})),
],
);
// create or update allocations for the subspaces
if (updatedSubspaces.length > 0) {
await this.subspaceProductAllocationService.updateSubspaceProductAllocationsV2(
subspaceDtos.map((dto) => {
if (!dto.uuid) {
dto.uuid = updatedSubspaces.find(
(subspace) => subspace.subspaceName === dto.subspaceName,
)?.uuid;
}
return {
tags: dto.tags || [],
uuid: dto.uuid,
};
}),
subspaceDtos.map((dto) => ({
...dto,
uuid:
dto.uuid ||
updatedSubspaces.find((s) => s.subspaceName === dto.subspaceName)
?.uuid,
})),
projectUuid,
queryRunner,
);
}
} catch (error) {
console.log(error);
throw new HttpException(
`An error occurred while modifying subspaces: ${error.message}`,
HttpStatus.INTERNAL_SERVER_ERROR,
@ -478,10 +488,10 @@ export class SubSpaceService {
}
async createAllocations(dto: CreateAllocationsDto): Promise<void> {
const { projectUuid, queryRunner, tags, type } = dto;
const allocationsData = await this.newTagService.processTags(
tags,
const { projectUuid, queryRunner, productAllocations, type } = dto;
if (!productAllocations) return;
const allocationsData = await this.newTagService.upsertTags(
productAllocations,
projectUuid,
queryRunner,
);
@ -491,15 +501,17 @@ 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 = tags.map(({ uuid, name, productUuid }) => {
const inputTag = uuid
? createdTagsByUUID.get(uuid)
: createdTagsByName.get(name);
const productTagMapping = productAllocations.map(
({ tagUuid, tagName, productUuid }) => {
const inputTag = tagUuid
? createdTagsByUUID.get(tagUuid)
: createdTagsByName.get(tagName);
return {
tag: inputTag?.uuid,
product: productUuid,
};
});
},
);
switch (type) {
case AllocationsOwnerType.SUBSPACE: {

View File

@ -79,7 +79,6 @@ import { SpaceValidationController } from './controllers/space-validation.contro
import { DisableSpaceHandler } from './handlers';
import {
SpaceDeviceService,
SpaceLinkService,
SpaceSceneService,
SpaceService,
SpaceUserService,
@ -110,7 +109,6 @@ export const CommandHandlers = [DisableSpaceHandler];
ProductRepository,
SubSpaceService,
SpaceDeviceService,
SpaceLinkService,
SubspaceDeviceService,
SpaceRepository,
SubspaceRepository,

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 tagDtos - The array of tag DTOs to process.
* @param allocationDtos - The array of allocations 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 processTags(
tagDtos: ProcessTagDto[],
async upsertTags(
allocationDtos: CreateProductAllocationDto[],
projectUuid: string,
queryRunner?: QueryRunner,
): Promise<NewTagEntity[]> {
@ -82,20 +82,22 @@ export class TagService {
const dbManager = queryRunner
? queryRunner.manager
: this.tagRepository.manager;
if (!tagDtos || tagDtos.length === 0) {
if (!allocationDtos || allocationDtos.length === 0) {
return [];
}
const [tagsWithUuid, tagsWithoutUuid]: [
Pick<ProcessTagDto, 'uuid' | 'productUuid'>[],
Omit<ProcessTagDto, 'uuid'>[],
] = this.splitTagsByUuid(tagDtos);
const [allocationsWithTagUuid, allocationsWithoutTagUuid]: [
Pick<CreateProductAllocationDto, 'tagUuid' | 'productUuid'>[],
Omit<CreateProductAllocationDto, 'tagUuid'>[],
] = this.splitTagsByUuid(allocationDtos);
// create a set of unique existing tag names for the project
const upsertedTagsByNameResult = await dbManager.upsert(
NewTagEntity,
Array.from(
new Set<string>(tagsWithoutUuid.map((tag) => tag.name)).values(),
new Set<string>(
allocationsWithoutTagUuid.map((allocation) => allocation.tagName),
).values(),
).map((name) => ({
name,
project: { uuid: projectUuid },
@ -111,20 +113,22 @@ export class TagService {
let foundByUuidTags: NewTagEntity[] = [];
// Fetch existing tags using UUIDs
if (tagsWithUuid.length) {
if (allocationsWithTagUuid.length) {
foundByUuidTags = await dbManager.find(NewTagEntity, {
where: {
uuid: In([...tagsWithUuid.map((tag) => tag.uuid)]),
uuid: In([
...allocationsWithTagUuid.map((allocation) => allocation.tagUuid),
]),
project: { uuid: projectUuid },
},
});
}
// Ensure all provided UUIDs exist in the database
if (foundByUuidTags.length !== tagsWithUuid.length) {
if (foundByUuidTags.length !== allocationsWithTagUuid.length) {
const foundUuids = new Set(foundByUuidTags.map((tag) => tag.uuid));
const missingUuids = tagsWithUuid.filter(
({ uuid }) => !foundUuids.has(uuid),
const missingUuids = allocationsWithTagUuid.filter(
({ tagUuid }) => !foundUuids.has(tagUuid),
);
throw new HttpException(
@ -179,20 +183,22 @@ export class TagService {
}
private splitTagsByUuid(
tagDtos: ProcessTagDto[],
): [ProcessTagDto[], ProcessTagDto[]] {
return tagDtos.reduce<[ProcessTagDto[], ProcessTagDto[]]>(
([withUuid, withoutUuid], tag) => {
if (tag.uuid) {
withUuid.push(tag);
allocationsDtos: CreateProductAllocationDto[],
): [CreateProductAllocationDto[], CreateProductAllocationDto[]] {
return allocationsDtos.reduce<
[CreateProductAllocationDto[], CreateProductAllocationDto[]]
>(
([withUuid, withoutUuid], allocation) => {
if (allocation.tagUuid) {
withUuid.push(allocation);
} else {
if (!tag.name || !tag.productUuid) {
if (!allocation.tagName || !allocation.productUuid) {
throw new HttpException(
`Tag name or product UUID is missing`,
HttpStatus.BAD_REQUEST,
);
}
withoutUuid.push(tag);
withoutUuid.push(allocation);
}
return [withUuid, withoutUuid];
},

View File

@ -1,7 +1,3 @@
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,
@ -11,12 +7,10 @@ import {
Param,
Patch,
Put,
Req,
UseGuards,
} from '@nestjs/common';
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 { UserService } from '../services/user.service';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { JwtAuthGuard } from '../../../libs/common/src/guards/jwt.auth.guard';
import {
UpdateNameDto,
@ -24,7 +18,11 @@ import {
UpdateRegionDataDto,
UpdateTimezoneDataDto,
} from '../dtos';
import { UserService } from '../services/user.service';
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';
@ApiTags('User Module')
@Controller({
@ -156,32 +154,6 @@ 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,38 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
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;
}
import { IsNotEmpty, IsString } from 'class-validator';
export class AddUserSpaceDto {
@ApiProperty({

View File

@ -1,21 +1,21 @@
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 {
UpdateNameDto,
UpdateProfilePictureDataDto,
UpdateRegionDataDto,
UpdateTimezoneDataDto,
} from './../dtos/update.user.dto';
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';
@Injectable()
export class UserService {
@ -269,12 +269,4 @@ 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 });
}
}