Compare commits

..

21 Commits

Author SHA1 Message Date
e468818a26 chore: remove unused package-lock.json file 2025-07-20 14:30:55 -06:00
fd5fbf57d8 Merge branch 'main' 2025-07-20 14:30:27 -06:00
c0b95a8b4b Update configuration and documentation for syncrow.ae domain 2025-07-17 20:56:35 -06:00
efa53da8c5 Trigger deployment after env change 2025-07-16 23:41:01 -06:00
57cf110229 Increase desired count for ECS service from 1 to 2 2025-07-14 01:41:00 -06:00
2e959a6ef3 Trigger GitHub Action 2025-07-13 23:28:02 -06:00
a6151220e2 Add .env injection from ENV_FILE secret 2025-07-13 23:17:42 -06:00
f4ba9c9bda Trigger GitHub Action 2025-07-13 22:43:00 -06:00
e91966c8f8 Add production deployment GitHub Action 2025-07-13 22:37:27 -06:00
ab59a310d9 Refactor stack.ts: reorganize imports, enhance security group definitions, and improve Fargate service configuration 2025-07-09 04:46:53 -06:00
30166810ca Fix import order and standardize database name in app.ts 2025-07-09 04:46:42 -06:00
805c5dd180 Update infra:build script to use bash for improved compatibility 2025-07-09 04:46:28 -06:00
e4ba7d46bb Refactor build.sh to improve readability and maintainability by defining variables for configuration and adding descriptive echo statements. 2025-07-09 04:46:18 -06:00
ef21b589c0 rds 2025-07-08 13:46:04 +03:00
44f83ea54e Merge branch 'cdk-aq1' of https://github.com/SyncrowIOT/backend into cdk-aq1 2025-07-08 04:26:51 -06:00
e4694db79c add build.sh command 2025-07-08 04:26:19 -06:00
13064296a7 import db 2025-07-08 13:25:23 +03:00
a269f833bc Updates ECR repository handling to import existing repo 2025-07-08 12:44:10 +03:00
fbf62fcd66 Enhances CDK deployment process and documentation
Improves the deployment script to use the UAE  region and adds context for the CDK stack.
2025-07-07 09:37:10 +03:00
374fb69804 fix the super user seeded to accept terms and add certificate arn 2025-06-30 03:58:47 -04:00
d4d1ec817d a functioning backend stack bypassing firebase and using an existing domain 2025-06-29 20:45:38 -04:00
44 changed files with 1683 additions and 14655 deletions

40
.github/workflows/production.yml vendored Normal file
View File

@ -0,0 +1,40 @@
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

7
.gitignore vendored
View File

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

View File

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

129
GITHUB_SETUP.md Normal file
View File

@ -0,0 +1,129 @@
# 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,17 +1,19 @@
# 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.
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:
User - Stores user account information
Home - Represents a home/space
Room - Represents a room/sub-space
Home - Represents a home/space
Room - Represents a room/sub-space
Device - Represents a connected device
Product - Stores metadata about device products
Other Entities - sessions, OTPs, etc.
@ -19,10 +21,11 @@ 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
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:
@ -61,8 +64,8 @@ $ npm run test:cov
![Syncrow ERD Digram](https://github.com/SyncrowIOT/backend/assets/83524184/94273a2b-625c-4a34-9cd4-fb14415ce884)
## Architecture
+----------------------------------+
| |
| Applications |
@ -107,3 +110,29 @@ $ npm run test:cov
| | Standby Node | | |
| +------------------+----------------+ |
+-----------------------------------------------------------------+
## CDK Deployment
• Bootstrap CDK (first time only): npx cdk bootstrap aws://482311766496/me-central-1
• List available stacks: npx cdk list
• Deploy infrastructure: npx cdk deploy --require-approval never
• View changes before deploy: npx cdk diff
• Generate CloudFormation template: npx cdk synth
• Destroy infrastructure: npx cdk destroy
• Environment variables are configured in infrastructure/stack.ts
• After code changes: build Docker image, push to ECR, force ECS deployment
• Database seeding happens automatically on first deployment with DB_SYNC=true
• Admin credentials: admin@syncrow.ae / YourSecureAdminPassword123!
• Production API: https://api.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

46
build.sh Normal file
View File

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

29
cdk.context.json Normal file
View File

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

58
cdk.json Normal file
View File

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

22
deploy.sh Executable file
View File

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

16
infrastructure/app.ts Normal file
View File

@ -0,0 +1,16 @@
#!/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',
});

393
infrastructure/stack.ts Normal file
View File

@ -0,0 +1,393 @@
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

@ -188,11 +188,6 @@ export class ControllerRoute {
static SCENE = class {
public static readonly ROUTE = 'scene';
static ACTIONS = class {
public static readonly GET_TAP_TO_RUN_SCENES_SUMMARY =
'Get Tap-to-Run Scenes by spaces';
public static readonly GET_TAP_TO_RUN_SCENES_DESCRIPTION =
'Gets Tap-to-Run scenes by spaces';
public static readonly CREATE_TAP_TO_RUN_SCENE_SUMMARY =
'Create a Tap-to-Run Scene';
public static readonly CREATE_TAP_TO_RUN_SCENE_DESCRIPTION =
@ -228,10 +223,6 @@ export class ControllerRoute {
public static readonly CREATE_SPACE_DESCRIPTION =
'This endpoint allows you to create a space in a specified community. Optionally, you can specify a parent space to nest the new space under it.';
public static readonly DUPLICATE_SPACE_SUMMARY = 'Duplicate a space';
public static readonly DUPLICATE_SPACE_DESCRIPTION =
'This endpoint allows you to create a copy of an existing space in a specified community.';
public static readonly LIST_SPACE_SUMMARY = 'List spaces in community';
public static readonly LIST_SPACE_DESCRIPTION =
'List spaces in specified community by community id';
@ -424,10 +415,6 @@ export class ControllerRoute {
public static readonly ROUTE = '/user';
static ACTIONS = class {
public static readonly GET_USERS_WITH_BOOKABLE_SPACES_SUMMARY =
'Retrieve list of users that has bookable spaces';
public static readonly GET_USERS_WITH_BOOKABLE_SPACES_DESCRIPTION =
'This endpoint retrieves all the users that have access to bookable spaces, paginated & accepts sorting';
public static readonly GET_USER_DETAILS_SUMMARY =
'Retrieve user details by user UUID';
public static readonly GET_USER_DETAILS_DESCRIPTION =
@ -781,10 +768,6 @@ export class ControllerRoute {
public static readonly ADD_AUTOMATION_DESCRIPTION =
'This endpoint creates a new automation based on the provided details.';
public static readonly GET_AUTOMATION_SUMMARY = 'Get all automations';
public static readonly GET_AUTOMATION_DESCRIPTION =
'This endpoint retrieves automations data';
public static readonly GET_AUTOMATION_DETAILS_SUMMARY =
'Get automation details';
public static readonly GET_AUTOMATION_DETAILS_DESCRIPTION =

View File

@ -39,7 +39,12 @@ export class DeviceStatusFirebaseService {
});
// Initialize firebaseDb using firebaseDataBase function
this.firebaseDb = firebaseDataBase(this.configService);
try {
this.firebaseDb = firebaseDataBase(this.configService);
} catch (error) {
console.warn('Firebase initialization failed, continuing without Firebase:', error.message);
this.firebaseDb = null;
}
this.isDevEnv =
this.configService.get<string>('NODE_ENV') === 'development';
}
@ -252,6 +257,14 @@ export class DeviceStatusFirebaseService {
async createDeviceStatusFirebase(
addDeviceStatusDto: AddDeviceStatusDto,
): Promise<any> {
// Check if Firebase is available
if (!this.firebaseDb) {
console.warn('Firebase not available, skipping Firebase operations');
// Still process the database logs but skip Firebase operations
await this.processDeviceStatusLogs(addDeviceStatusDto);
return { message: 'Device status processed without Firebase' };
}
const dataRef = ref(
this.firebaseDb,
`device-status/${addDeviceStatusDto.deviceUuid}`,
@ -301,4 +314,127 @@ export class DeviceStatusFirebaseService {
const snapshot: DataSnapshot = await get(dataRef);
return snapshot.val();
}
private async processDeviceStatusLogs(addDeviceStatusDto: AddDeviceStatusDto): Promise<void> {
if (this.isDevEnv) {
// Save logs to your repository
const newLogs = addDeviceStatusDto.log.properties.map((property) => {
return this.deviceStatusLogRepository.create({
deviceId: addDeviceStatusDto.deviceUuid,
deviceTuyaId: addDeviceStatusDto.deviceTuyaUuid,
productId: addDeviceStatusDto.log.productId,
log: addDeviceStatusDto.log,
code: property.code,
value: property.value,
eventId: addDeviceStatusDto.log.dataId,
eventTime: new Date(property.time).toISOString(),
});
});
await this.deviceStatusLogRepository.save(newLogs);
if (addDeviceStatusDto.productType === ProductType.PC) {
const energyCodes = new Set([
PowerClampEnergyEnum.ENERGY_CONSUMED,
PowerClampEnergyEnum.ENERGY_CONSUMED_A,
PowerClampEnergyEnum.ENERGY_CONSUMED_B,
PowerClampEnergyEnum.ENERGY_CONSUMED_C,
]);
const energyStatus = addDeviceStatusDto?.log?.properties?.find(
(status) => energyCodes.has(status.code),
);
if (energyStatus) {
await this.powerClampService.updateEnergyConsumedHistoricalData(
addDeviceStatusDto.deviceUuid,
);
}
}
if (
addDeviceStatusDto.productType === ProductType.CPS ||
addDeviceStatusDto.productType === ProductType.WPS
) {
const occupancyCodes = new Set([PresenceSensorEnum.PRESENCE_STATE]);
const occupancyStatus = addDeviceStatusDto?.log?.properties?.find(
(status) => occupancyCodes.has(status.code),
);
if (occupancyStatus) {
await this.occupancyService.updateOccupancySensorHistoricalData(
addDeviceStatusDto.deviceUuid,
);
await this.occupancyService.updateOccupancySensorHistoricalDurationData(
addDeviceStatusDto.deviceUuid,
);
}
}
if (addDeviceStatusDto.productType === ProductType.AQI) {
await this.aqiDataService.updateAQISensorHistoricalData(
addDeviceStatusDto.deviceUuid,
);
}
} else {
// Save logs to your repository
const newLogs = addDeviceStatusDto?.status.map((property) => {
return this.deviceStatusLogRepository.create({
deviceId: addDeviceStatusDto.deviceUuid,
deviceTuyaId: addDeviceStatusDto.deviceTuyaUuid,
productId: addDeviceStatusDto.log.productKey,
log: addDeviceStatusDto.log,
code: property.code,
value: property.value,
eventId: addDeviceStatusDto.log.dataId,
eventTime: new Date(property.t).toISOString(),
});
});
await this.deviceStatusLogRepository.save(newLogs);
if (addDeviceStatusDto.productType === ProductType.PC) {
const energyCodes = new Set([
PowerClampEnergyEnum.ENERGY_CONSUMED,
PowerClampEnergyEnum.ENERGY_CONSUMED_A,
PowerClampEnergyEnum.ENERGY_CONSUMED_B,
PowerClampEnergyEnum.ENERGY_CONSUMED_C,
]);
const energyStatus = addDeviceStatusDto?.status?.find((status) => {
return energyCodes.has(status.code as PowerClampEnergyEnum);
});
if (energyStatus) {
await this.powerClampService.updateEnergyConsumedHistoricalData(
addDeviceStatusDto.deviceUuid,
);
}
}
if (
addDeviceStatusDto.productType === ProductType.CPS ||
addDeviceStatusDto.productType === ProductType.WPS
) {
const occupancyCodes = new Set([PresenceSensorEnum.PRESENCE_STATE]);
const occupancyStatus = addDeviceStatusDto?.status?.find((status) => {
return occupancyCodes.has(status.code as PresenceSensorEnum);
});
if (occupancyStatus) {
await this.occupancyService.updateOccupancySensorHistoricalData(
addDeviceStatusDto.deviceUuid,
);
await this.occupancyService.updateOccupancySensorHistoricalDurationData(
addDeviceStatusDto.deviceUuid,
);
}
}
if (addDeviceStatusDto.productType === ProductType.AQI) {
await this.aqiDataService.updateAQISensorHistoricalData(
addDeviceStatusDto.deviceUuid,
);
}
}
}
}

View File

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

View File

@ -22,11 +22,6 @@ export class SpaceProductAllocationEntity extends AbstractEntity<SpaceProductAll
})
public space: SpaceEntity;
@Column({
name: 'space_uuid',
})
public spaceUuid: string;
@ManyToOne(() => SpaceModelProductAllocationEntity, {
nullable: true,
onDelete: 'SET NULL',
@ -36,19 +31,9 @@ export class SpaceProductAllocationEntity extends AbstractEntity<SpaceProductAll
@ManyToOne(() => ProductEntity, { nullable: false, onDelete: 'CASCADE' })
public product: ProductEntity;
@Column({
name: 'product_uuid',
})
public productUuid: string;
@ManyToOne(() => NewTagEntity, { nullable: true, onDelete: 'CASCADE' })
public tag: NewTagEntity;
@Column({
name: 'tag_uuid',
})
public tagUuid: string;
constructor(partial: Partial<SpaceProductAllocationEntity>) {
super();
Object.assign(this, partial);

View File

@ -9,7 +9,6 @@ import {
import { AbstractEntity } from '../../abstract/entities/abstract.entity';
import { AqiSpaceDailyPollutantStatsEntity } from '../../aqi/entities';
import { BookableSpaceEntity } from '../../booking/entities/bookable-space.entity';
import { BookingEntity } from '../../booking/entities/booking.entity';
import { CommunityEntity } from '../../community/entities';
import { DeviceEntity } from '../../device/entities';
import { InviteUserSpaceEntity } from '../../Invite-user/entities';
@ -21,6 +20,7 @@ import { UserSpaceEntity } from '../../user/entities';
import { SpaceDto } from '../dtos';
import { SpaceProductAllocationEntity } from './space-product-allocation.entity';
import { SubspaceEntity } from './subspace/subspace.entity';
import { BookingEntity } from '../../booking/entities/booking.entity';
@Entity({ name: 'space' })
export class SpaceEntity extends AbstractEntity<SpaceDto> {
@ -47,26 +47,12 @@ export class SpaceEntity extends AbstractEntity<SpaceDto> {
@JoinColumn({ name: 'community_id' })
community: CommunityEntity;
@Column({
name: 'community_id',
})
communityId: string;
@ManyToOne(() => SpaceEntity, (space) => space.children, {
nullable: true,
})
@ManyToOne(() => SpaceEntity, (space) => space.children, { nullable: true })
parent: SpaceEntity;
@Column({
name: 'parent_uuid',
nullable: true,
})
public parentUuid: string;
@OneToMany(() => SpaceEntity, (space) => space.parent, {
nullable: false,
onDelete: 'CASCADE',
cascade: true,
})
children: SpaceEntity[];
@ -87,10 +73,16 @@ export class SpaceEntity extends AbstractEntity<SpaceDto> {
@OneToMany(() => SubspaceEntity, (subspace) => subspace.space, {
nullable: true,
cascade: true,
})
subspaces?: SubspaceEntity[];
// Position columns
@Column({ type: 'float', nullable: false, default: 0 })
public x: number; // X coordinate for position
@Column({ type: 'float', nullable: false, default: 0 })
public y: number; // Y coordinate for position
@OneToMany(
() => DeviceEntity,
(devicesSpaceEntity) => devicesSpaceEntity.spaceDevice,

View File

@ -22,11 +22,6 @@ export class SubspaceProductAllocationEntity extends AbstractEntity<SubspaceProd
})
public subspace: SubspaceEntity;
@Column({
name: 'subspace_uuid',
})
public subspaceUuid: string;
@ManyToOne(() => SubspaceModelProductAllocationEntity, {
nullable: true,
onDelete: 'SET NULL',
@ -36,19 +31,9 @@ export class SubspaceProductAllocationEntity extends AbstractEntity<SubspaceProd
@ManyToOne(() => ProductEntity, { nullable: false, onDelete: 'CASCADE' })
public product: ProductEntity;
@Column({
name: 'product_uuid',
})
public productUuid: string;
@ManyToOne(() => NewTagEntity, { nullable: true, onDelete: 'CASCADE' })
public tag: NewTagEntity;
@Column({
name: 'tag_uuid',
})
public tagUuid: string;
constructor(partial: Partial<SubspaceProductAllocationEntity>) {
super();
Object.assign(this, partial);

View File

@ -26,11 +26,6 @@ export class SubspaceEntity extends AbstractEntity<SubspaceDto> {
@JoinColumn({ name: 'space_uuid' })
space: SpaceEntity;
@Column({
name: 'space_uuid',
})
public spaceUuid: string;
@Column({
nullable: false,
default: false,

View File

@ -44,7 +44,6 @@ export class UserEntity extends AbstractEntity<UserDto> {
nullable: true,
type: 'text',
default: defaultProfilePicture,
select: false,
})
public profilePicture: string;

View File

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

13470
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -6,19 +6,25 @@
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "npm run test && npx nest build",
"build": "npx nest build",
"build:lambda": "npx nest build && cp package*.json dist/",
"format": "prettier --write \"apps/**/*.ts\" \"libs/**/*.ts\"",
"start": "npm run test && node dist/main",
"start:dev": "npm run test && npx nest start --watch",
"start": "node dist/main",
"start:dev": "npx nest start --watch",
"dev": "npx nest start --watch",
"start:debug": "npm run test && npx nest start --debug --watch",
"start:prod": "npm run test && node dist/main",
"start:debug": "npx nest start --debug --watch",
"start:prod": "node dist/main",
"start:lambda": "node dist/lambda",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest --config jest.config.js",
"test:watch": "jest --watch --config jest.config.js",
"test:cov": "jest --coverage --config jest.config.js",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./apps/backend/test/jest-e2e.json"
"test:e2e": "jest --config ./apps/backend/test/jest-e2e.json",
"deploy": "./deploy.sh",
"infra:build": "bash build.sh",
"infra:deploy": "cdk deploy SyncrowBackendStack",
"infra:destroy": "cdk destroy SyncrowBackendStack"
},
"dependencies": {
"@fast-csv/format": "^5.0.2",
@ -37,13 +43,16 @@
"@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",
"date-fns": "^4.1.0",
"express-rate-limit": "^7.1.5",
"firebase": "^10.12.5",
@ -55,12 +64,13 @@
"node-cache": "^5.1.2",
"nodemailer": "^7.0.5",
"onesignal-node": "^3.4.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"pg": "^8.11.3",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"typeorm": "^0.3.20",
"uuid": "^11.1.0",
"webpack": "^5.99.9",
"winston": "^3.17.0",
"ws": "^8.17.0"
},
@ -78,7 +88,9 @@
"@types/supertest": "^6.0.0",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"aws-cdk-lib": "^2.202.0",
"concurrently": "^8.2.2",
"constructs": "^10.4.2",
"eslint": "^8.42.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-import": "^2.31.0",

View File

@ -1,7 +1,4 @@
import { ControllerRoute } from '@app/common/constants/controller-route';
import { EnableDisableStatusEnum } from '@app/common/constants/days.enum';
import { ProjectParam } from '@app/common/dto/project-param.dto';
import { SuccessResponseDto } from '@app/common/dto/success.response.dto';
import { AutomationService } from '../services/automation.service';
import {
Body,
Controller,
@ -12,20 +9,20 @@ import {
Patch,
Post,
Put,
Query,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Permissions } from 'src/decorators/permissions.decorator';
import { PermissionsGuard } from 'src/guards/permissions.guard';
import { AutomationParamDto } from '../dtos';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import {
AddAutomationDto,
UpdateAutomationDto,
UpdateAutomationStatusDto,
} from '../dtos/automation.dto';
import { GetAutomationBySpacesDto } from '../dtos/get-automation-by-spaces.dto';
import { AutomationService } from '../services/automation.service';
import { EnableDisableStatusEnum } from '@app/common/constants/days.enum';
import { AutomationParamDto } from '../dtos';
import { ControllerRoute } from '@app/common/constants/controller-route';
import { PermissionsGuard } from 'src/guards/permissions.guard';
import { Permissions } from 'src/decorators/permissions.decorator';
import { ProjectParam } from '@app/common/dto/project-param.dto';
@ApiTags('Automation Module')
@Controller({
@ -59,28 +56,6 @@ export class AutomationController {
};
}
@ApiBearerAuth()
@UseGuards(PermissionsGuard)
@Permissions('AUTOMATION_VIEW')
@Get('')
@ApiOperation({
summary: ControllerRoute.AUTOMATION.ACTIONS.GET_AUTOMATION_SUMMARY,
description: ControllerRoute.AUTOMATION.ACTIONS.GET_AUTOMATION_DESCRIPTION,
})
async getAutomationBySpaces(
@Param() param: ProjectParam,
@Query() spaces: GetAutomationBySpacesDto,
) {
const automation = await this.automationService.getAutomationBySpaces(
spaces,
param.projectUuid,
);
return new SuccessResponseDto({
message: 'Automation retrieved Successfully',
data: automation,
});
}
@ApiBearerAuth()
@UseGuards(PermissionsGuard)
@Permissions('AUTOMATION_VIEW')

View File

@ -1,20 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsOptional, IsUUID } from 'class-validator';
export class GetAutomationBySpacesDto {
@ApiProperty({
description: 'List of Space IDs to filter automation',
required: false,
example: ['60d21b4667d0d8992e610c85', '60d21b4967d0d8992e610c86'],
})
@IsOptional()
@Transform(({ value }) => {
if (!Array.isArray(value)) {
return [value];
}
return value;
})
@IsUUID('4', { each: true })
public spaces?: string[];
}

View File

@ -1,42 +1,20 @@
import {
ActionExecutorEnum,
ActionTypeEnum,
AUTO_PREFIX,
AUTOMATION_TYPE,
EntityTypeEnum,
} from '@app/common/constants/automation.enum';
import { BaseResponseDto } from '@app/common/dto/base.response.dto';
import { GetSpaceParam } from '@app/common/dto/get.space.param';
import { ProjectParam } from '@app/common/dto/project-param.dto';
import { SuccessResponseDto } from '@app/common/dto/success.response.dto';
import { convertKeysToCamelCase } from '@app/common/helper/camelCaseConverter';
import { convertKeysToSnakeCase } from '@app/common/helper/snakeCaseConverter';
import { ConvertedAction } from '@app/common/integrations/tuya/interfaces';
import { TuyaService } from '@app/common/integrations/tuya/services/tuya.service';
import { AutomationEntity } from '@app/common/modules/automation/entities';
import { AutomationRepository } from '@app/common/modules/automation/repositories';
import { ProjectRepository } from '@app/common/modules/project/repositiories';
import { SceneDeviceRepository } from '@app/common/modules/scene-device/repositories';
import { SceneRepository } from '@app/common/modules/scene/repositories';
import { SpaceRepository } from '@app/common/modules/space/repositories';
import {
BadRequestException,
Injectable,
HttpException,
HttpStatus,
Injectable,
BadRequestException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { TuyaContext } from '@tuya/tuya-connector-nodejs';
import { DeviceService } from 'src/device/services';
import { DeleteTapToRunSceneInterface } from 'src/scene/interface/scene.interface';
import { In } from 'typeorm';
import { SpaceRepository } from '@app/common/modules/space/repositories';
import {
AddAutomationDto,
AutomationParamDto,
UpdateAutomationDto,
UpdateAutomationStatusDto,
} from '../dtos';
import { GetAutomationBySpacesDto } from '../dtos/get-automation-by-spaces.dto';
import { ConfigService } from '@nestjs/config';
import { TuyaContext } from '@tuya/tuya-connector-nodejs';
import { convertKeysToSnakeCase } from '@app/common/helper/snakeCaseConverter';
import { DeviceService } from 'src/device/services';
import {
Action,
AddAutomationParams,
@ -44,6 +22,26 @@ import {
AutomationResponseData,
Condition,
} from '../interface/automation.interface';
import { convertKeysToCamelCase } from '@app/common/helper/camelCaseConverter';
import {
ActionExecutorEnum,
ActionTypeEnum,
AUTO_PREFIX,
AUTOMATION_TYPE,
EntityTypeEnum,
} from '@app/common/constants/automation.enum';
import { TuyaService } from '@app/common/integrations/tuya/services/tuya.service';
import { ConvertedAction } from '@app/common/integrations/tuya/interfaces';
import { SceneDeviceRepository } from '@app/common/modules/scene-device/repositories';
import { SceneRepository } from '@app/common/modules/scene/repositories';
import { AutomationRepository } from '@app/common/modules/automation/repositories';
import { BaseResponseDto } from '@app/common/dto/base.response.dto';
import { SuccessResponseDto } from '@app/common/dto/success.response.dto';
import { AutomationEntity } from '@app/common/modules/automation/entities';
import { DeleteTapToRunSceneInterface } from 'src/scene/interface/scene.interface';
import { ProjectParam } from '@app/common/dto/project-param.dto';
import { ProjectRepository } from '@app/common/modules/project/repositiories';
import { GetSpaceParam } from '@app/common/dto/get.space.param';
@Injectable()
export class AutomationService {
@ -114,25 +112,128 @@ export class AutomationService {
);
}
}
async getAutomationBySpace({ projectUuid, spaceUuid }: GetSpaceParam) {
return this.getAutomationBySpaces({ spaces: [spaceUuid] }, projectUuid);
}
async getAutomationBySpaces(
{ spaces }: GetAutomationBySpacesDto,
async createAutomationExternalService(
params: AddAutomationParams,
projectUuid: string,
) {
try {
await this.validateProject(projectUuid);
const formattedActions = await this.prepareActions(
params.actions,
projectUuid,
);
const formattedCondition = await this.prepareConditions(
params.conditions,
projectUuid,
);
const response = await this.tuyaService.createAutomation(
params.spaceTuyaId,
params.automationName,
params.effectiveTime,
params.decisionExpr,
formattedCondition,
formattedActions,
);
if (!response.result?.id) {
throw new HttpException(
'Failed to create automation in Tuya',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
return response;
} catch (err) {
if (err instanceof HttpException) {
throw err;
} else if (err.message?.includes('tuya')) {
throw new HttpException(
'API error: Failed to create automation',
HttpStatus.BAD_GATEWAY,
);
} else {
throw new HttpException(
`An Internal error has been occured ${err}`,
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
}
async add(params: AddAutomationParams, projectUuid: string) {
try {
const response = await this.createAutomationExternalService(
params,
projectUuid,
);
const automation = await this.automationRepository.save({
automationTuyaUuid: response.result.id,
space: { uuid: params.spaceUuid },
});
return automation;
} catch (err) {
if (err instanceof HttpException) {
throw err;
} else if (err.message?.includes('tuya')) {
throw new HttpException(
'API error: Failed to create automation',
HttpStatus.BAD_GATEWAY,
);
} else {
throw new HttpException(
'Database error: Failed to save automation',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
}
async getSpaceByUuid(spaceUuid: string, projectUuid: string) {
try {
const space = await this.spaceRepository.findOne({
where: {
uuid: spaceUuid,
community: {
project: {
uuid: projectUuid,
},
},
},
relations: ['community'],
});
if (!space) {
throw new BadRequestException(`Invalid space UUID ${spaceUuid}`);
}
return {
uuid: space.uuid,
createdAt: space.createdAt,
updatedAt: space.updatedAt,
name: space.spaceName,
spaceTuyaUuid: space.community.externalId,
};
} catch (err) {
if (err instanceof BadRequestException) {
throw err; // Re-throw BadRequestException
} else {
throw new HttpException('Space not found', HttpStatus.NOT_FOUND);
}
}
}
async getAutomationBySpace(param: GetSpaceParam) {
try {
await this.validateProject(param.projectUuid);
// Fetch automation data from the repository
const automationData = await this.automationRepository.find({
where: {
space: {
uuid: In(spaces ?? []),
uuid: param.spaceUuid,
community: {
uuid: param.communityUuid,
project: {
uuid: projectUuid,
uuid: param.projectUuid,
},
},
},
@ -189,277 +290,46 @@ export class AutomationService {
}
}
async getAutomationDetails(param: AutomationParamDto) {
await this.validateProject(param.projectUuid);
async findAutomationBySpace(spaceUuid: string, projectUuid: string) {
try {
const automation = await this.findAutomationByUuid(
param.automationUuid,
param.projectUuid,
);
await this.getSpaceByUuid(spaceUuid, projectUuid);
const automationDetails = await this.getAutomation(automation);
return automationDetails;
} catch (error) {
console.error(
`Error fetching automation details for automationUuid ${param.automationUuid}:`,
error,
);
if (error instanceof HttpException) {
throw error;
} else {
throw new HttpException(
'An error occurred while retrieving automation details',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
}
async updateAutomation(
updateAutomationDto: UpdateAutomationDto,
param: AutomationParamDto,
) {
await this.validateProject(param.projectUuid);
try {
const automation = await this.findAutomationByUuid(
param.automationUuid,
param.projectUuid,
);
const space = await this.getSpaceByUuid(
automation.space.uuid,
param.projectUuid,
);
const updateTuyaAutomationResponse =
await this.updateAutomationExternalService(
space.spaceTuyaUuid,
automation.automationTuyaUuid,
updateAutomationDto,
param.projectUuid,
);
if (!updateTuyaAutomationResponse.success) {
throw new HttpException(
`Failed to update a external automation`,
HttpStatus.BAD_GATEWAY,
);
}
const updatedScene = await this.automationRepository.update(
{ uuid: param.automationUuid },
{
space: { uuid: automation.space.uuid },
},
);
return new SuccessResponseDto({
data: updatedScene,
message: `Automation with ID ${param.automationUuid} updated successfully`,
});
} catch (err) {
if (err instanceof BadRequestException) {
throw err; // Re-throw BadRequestException
} else {
throw new HttpException(
err.message || 'Automation not found',
err.status || HttpStatus.NOT_FOUND,
);
}
}
}
async updateAutomationStatus(
updateAutomationStatusDto: UpdateAutomationStatusDto,
param: AutomationParamDto,
) {
const { isEnable, spaceUuid } = updateAutomationStatusDto;
await this.validateProject(param.projectUuid);
try {
const automation = await this.findAutomationByUuid(
param.automationUuid,
param.projectUuid,
);
const space = await this.getSpaceByUuid(spaceUuid, param.projectUuid);
if (!space.spaceTuyaUuid) {
throw new HttpException(
`Invalid space UUID ${spaceUuid}`,
HttpStatus.NOT_FOUND,
);
}
const response = await this.tuyaService.updateAutomationState(
space.spaceTuyaUuid,
automation.automationTuyaUuid,
isEnable,
);
return response;
} catch (err) {
if (err instanceof BadRequestException) {
throw err; // Re-throw BadRequestException
} else {
throw new HttpException(
err.message || 'Automation not found',
err.status || HttpStatus.NOT_FOUND,
);
}
}
}
async deleteAutomation(param: AutomationParamDto) {
const { automationUuid } = param;
await this.validateProject(param.projectUuid);
try {
const automationData = await this.findAutomationByUuid(
automationUuid,
param.projectUuid,
);
const space = await this.getSpaceByUuid(
automationData.space.uuid,
param.projectUuid,
);
await this.delete(automationData.automationTuyaUuid, space.spaceTuyaUuid);
const existingSceneDevice = await this.sceneDeviceRepository.findOne({
where: { automationTuyaUuid: automationData.automationTuyaUuid },
});
if (existingSceneDevice) {
await this.sceneDeviceRepository.delete({
automationTuyaUuid: automationData.automationTuyaUuid,
});
}
await this.automationRepository.update(
{
uuid: automationUuid,
},
{ disabled: true },
);
return new SuccessResponseDto({
message: `Automation with ID ${automationUuid} deleted successfully`,
});
} catch (err) {
if (err instanceof HttpException) {
throw err;
} else {
throw new HttpException(
err.message || `Automation not found for id ${param.automationUuid}`,
err.status || HttpStatus.NOT_FOUND,
);
}
}
}
private async createAutomationExternalService(
params: AddAutomationParams,
projectUuid: string,
) {
try {
const formattedActions = await this.prepareActions(
params.actions,
projectUuid,
);
const formattedCondition = await this.prepareConditions(
params.conditions,
projectUuid,
);
const response = await this.tuyaService.createAutomation(
params.spaceTuyaId,
params.automationName,
params.effectiveTime,
params.decisionExpr,
formattedCondition,
formattedActions,
);
if (!response.result?.id) {
throw new HttpException(
'Failed to create automation in Tuya',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
return response;
} catch (err) {
if (err instanceof HttpException) {
throw err;
} else if (err.message?.includes('tuya')) {
throw new HttpException(
'API error: Failed to create automation',
HttpStatus.BAD_GATEWAY,
);
} else {
throw new HttpException(
`An Internal error has been occured ${err}`,
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
}
private async add(params: AddAutomationParams, projectUuid: string) {
try {
const response = await this.createAutomationExternalService(
params,
projectUuid,
);
const automation = await this.automationRepository.save({
automationTuyaUuid: response.result.id,
space: { uuid: params.spaceUuid },
});
return automation;
} catch (err) {
if (err instanceof HttpException) {
throw err;
} else if (err.message?.includes('tuya')) {
throw new HttpException(
'API error: Failed to create automation',
HttpStatus.BAD_GATEWAY,
);
} else {
throw new HttpException(
'Database error: Failed to save automation',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
}
private async getSpaceByUuid(spaceUuid: string, projectUuid: string) {
try {
const space = await this.spaceRepository.findOne({
const automationData = await this.automationRepository.find({
where: {
uuid: spaceUuid,
community: {
project: {
uuid: projectUuid,
},
},
space: { uuid: spaceUuid },
disabled: false,
},
relations: ['community'],
relations: ['space'],
});
if (!space) {
throw new BadRequestException(`Invalid space UUID ${spaceUuid}`);
}
return {
uuid: space.uuid,
createdAt: space.createdAt,
updatedAt: space.updatedAt,
name: space.spaceName,
spaceTuyaUuid: space.community.externalId,
};
const automations = await Promise.all(
automationData.map(async (automation) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { actions, ...automationDetails } =
await this.getAutomation(automation);
return automationDetails;
}),
);
return automations;
} catch (err) {
if (err instanceof BadRequestException) {
throw err; // Re-throw BadRequestException
console.error(
`Error fetching Tap-to-Run scenes for space UUID ${spaceUuid}:`,
err.message,
);
if (err instanceof HttpException) {
throw err;
} else {
throw new HttpException('Space not found', HttpStatus.NOT_FOUND);
throw new HttpException(
'An error occurred while retrieving scenes',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
}
private async getTapToRunSceneDetailsTuya(
async getTapToRunSceneDetailsTuya(
sceneUuid: string,
): Promise<AutomationDetailsResult> {
try {
@ -491,8 +361,35 @@ export class AutomationService {
}
}
}
async getAutomationDetails(param: AutomationParamDto) {
await this.validateProject(param.projectUuid);
private async getAutomation(automation: AutomationEntity) {
try {
const automation = await this.findAutomation(
param.automationUuid,
param.projectUuid,
);
const automationDetails = await this.getAutomation(automation);
return automationDetails;
} catch (error) {
console.error(
`Error fetching automation details for automationUuid ${param.automationUuid}:`,
error,
);
if (error instanceof HttpException) {
throw error;
} else {
throw new HttpException(
'An error occurred while retrieving automation details',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
}
async getAutomation(automation: AutomationEntity) {
try {
const response = await this.tuyaService.getSceneRule(
automation.automationTuyaUuid,
@ -599,13 +496,13 @@ export class AutomationService {
}
}
}
private async findAutomationByUuid(
uuid: string,
async findAutomation(
sceneUuid: string,
projectUuid: string,
): Promise<AutomationEntity> {
const automation = await this.automationRepository.findOne({
where: {
uuid: uuid,
uuid: sceneUuid,
space: { community: { project: { uuid: projectUuid } } },
},
relations: ['space'],
@ -613,14 +510,57 @@ export class AutomationService {
if (!automation) {
throw new HttpException(
`Invalid automation with id ${uuid}`,
`Invalid automation with id ${sceneUuid}`,
HttpStatus.NOT_FOUND,
);
}
return automation;
}
private async delete(tuyaAutomationId: string, tuyaSpaceId: string) {
async deleteAutomation(param: AutomationParamDto) {
const { automationUuid } = param;
await this.validateProject(param.projectUuid);
try {
const automationData = await this.findAutomation(
automationUuid,
param.projectUuid,
);
const space = await this.getSpaceByUuid(
automationData.space.uuid,
param.projectUuid,
);
await this.delete(automationData.automationTuyaUuid, space.spaceTuyaUuid);
const existingSceneDevice = await this.sceneDeviceRepository.findOne({
where: { automationTuyaUuid: automationData.automationTuyaUuid },
});
if (existingSceneDevice) {
await this.sceneDeviceRepository.delete({
automationTuyaUuid: automationData.automationTuyaUuid,
});
}
await this.automationRepository.update(
{
uuid: automationUuid,
},
{ disabled: true },
);
return new SuccessResponseDto({
message: `Automation with ID ${automationUuid} deleted successfully`,
});
} catch (err) {
if (err instanceof HttpException) {
throw err;
} else {
throw new HttpException(
err.message || `Automation not found for id ${param.automationUuid}`,
err.status || HttpStatus.NOT_FOUND,
);
}
}
}
async delete(tuyaAutomationId: string, tuyaSpaceId: string) {
try {
const response = (await this.tuyaService.deleteSceneRule(
tuyaAutomationId,
@ -638,7 +578,7 @@ export class AutomationService {
}
}
}
private async updateAutomationExternalService(
async updateAutomationExternalService(
spaceTuyaUuid: string,
automationUuid: string,
updateAutomationDto: UpdateAutomationDto,
@ -686,6 +626,95 @@ export class AutomationService {
}
}
}
async updateAutomation(
updateAutomationDto: UpdateAutomationDto,
param: AutomationParamDto,
) {
await this.validateProject(param.projectUuid);
try {
const automation = await this.findAutomation(
param.automationUuid,
param.projectUuid,
);
const space = await this.getSpaceByUuid(
automation.space.uuid,
param.projectUuid,
);
const updateTuyaAutomationResponse =
await this.updateAutomationExternalService(
space.spaceTuyaUuid,
automation.automationTuyaUuid,
updateAutomationDto,
param.projectUuid,
);
if (!updateTuyaAutomationResponse.success) {
throw new HttpException(
`Failed to update a external automation`,
HttpStatus.BAD_GATEWAY,
);
}
const updatedScene = await this.automationRepository.update(
{ uuid: param.automationUuid },
{
space: { uuid: automation.space.uuid },
},
);
return new SuccessResponseDto({
data: updatedScene,
message: `Automation with ID ${param.automationUuid} updated successfully`,
});
} catch (err) {
if (err instanceof BadRequestException) {
throw err; // Re-throw BadRequestException
} else {
throw new HttpException(
err.message || 'Automation not found',
err.status || HttpStatus.NOT_FOUND,
);
}
}
}
async updateAutomationStatus(
updateAutomationStatusDto: UpdateAutomationStatusDto,
param: AutomationParamDto,
) {
const { isEnable, spaceUuid } = updateAutomationStatusDto;
await this.validateProject(param.projectUuid);
try {
const automation = await this.findAutomation(
param.automationUuid,
param.projectUuid,
);
const space = await this.getSpaceByUuid(spaceUuid, param.projectUuid);
if (!space.spaceTuyaUuid) {
throw new HttpException(
`Invalid space UUID ${spaceUuid}`,
HttpStatus.NOT_FOUND,
);
}
const response = await this.tuyaService.updateAutomationState(
space.spaceTuyaUuid,
automation.automationTuyaUuid,
isEnable,
);
return response;
} catch (err) {
if (err instanceof BadRequestException) {
throw err; // Re-throw BadRequestException
} else {
throw new HttpException(
err.message || 'Automation not found',
err.status || HttpStatus.NOT_FOUND,
);
}
}
}
private async prepareActions(
actions: Action[],
@ -724,7 +753,7 @@ export class AutomationService {
action.action_executor === ActionExecutorEnum.RULE_ENABLE
) {
if (action.action_type === ActionTypeEnum.AUTOMATION) {
const automation = await this.findAutomationByUuid(
const automation = await this.findAutomation(
action.entity_id,
projectUuid,
);

View File

@ -3,11 +3,11 @@ import { IsNotEmpty, IsOptional, IsUUID, Matches } from 'class-validator';
export class BookingRequestDto {
@ApiProperty({
description: 'Month in MM-YYYY format',
example: '07-2025',
description: 'Month in MM/YYYY format',
example: '07/2025',
})
@IsNotEmpty()
@Matches(/^(0[1-9]|1[0-2])\-\d{4}$/, {
@Matches(/^(0[1-9]|1[0-2])\/\d{4}$/, {
message: 'Date must be in MM/YYYY format',
})
month: string;

View File

@ -1,12 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import {
IsDate,
IsNotEmpty,
IsString,
IsUUID,
Matches,
MinDate,
} from 'class-validator';
import { IsDate, IsNotEmpty, IsString, IsUUID, Matches } from 'class-validator';
export class CreateBookingDto {
@ApiProperty({
@ -22,7 +15,6 @@ export class CreateBookingDto {
})
@IsNotEmpty()
@IsDate()
@MinDate(new Date())
date: Date;
@ApiProperty({ example: '09:00' })

View File

@ -54,8 +54,7 @@ export class BookableSpaceService {
.createQueryBuilder('space')
.leftJoinAndSelect('space.parent', 'parentSpace')
.leftJoinAndSelect('space.community', 'community')
.where('space.disabled = :disabled', { disabled: false })
.andWhere('community.project = :project', { project });
.where('community.project = :project', { project });
if (search) {
qb = qb.andWhere(

View File

@ -51,7 +51,7 @@ export class BookingService {
}
async findAll({ month, space }: BookingRequestDto, project: string) {
const [monthNumber, year] = month.split('-').map(Number);
const [monthNumber, year] = month.split('/').map(Number);
const fromDate = new Date(year, monthNumber - 1, 1);
const toDate = new Date(year, monthNumber, 0, 23, 59, 59);
return this.bookingEntityRepository.find({

View File

@ -25,7 +25,7 @@ import {
NotFoundException,
} from '@nestjs/common';
import { SpaceService } from 'src/space/services';
import { Brackets, QueryRunner, SelectQueryBuilder } from 'typeorm';
import { QueryRunner, SelectQueryBuilder } from 'typeorm';
import { AddCommunityDto, GetCommunityParams, ProjectParam } from '../dtos';
import { UpdateCommunityNameDto } from '../dtos/update.community.dto';
@ -184,46 +184,18 @@ export class CommunityService {
let qb: undefined | SelectQueryBuilder<CommunityEntity> = undefined;
const matchingCommunityIdsQb = this.communityRepository
.createQueryBuilder('c')
.select('c.uuid')
.where('c.project = :projectUuid', { projectUuid })
.andWhere('c.name != :orphanCommunityName', {
orphanCommunityName: `${ORPHAN_COMMUNITY_NAME}-${project.name}`,
})
.distinct(true);
if (includeSpaces) {
matchingCommunityIdsQb.leftJoin('c.spaces', 'space');
}
if (search) {
matchingCommunityIdsQb
.andWhere(
new Brackets((qb) => {
qb.where('c.name ILIKE :search');
if (includeSpaces) qb.orWhere('space.spaceName ILIKE :search');
}),
)
.setParameter('search', `%${search}%`);
}
qb = this.communityRepository
.createQueryBuilder('c')
.where('c.project = :projectUuid', { projectUuid })
.andWhere('c.name != :orphanCommunityName', {
orphanCommunityName: `${ORPHAN_COMMUNITY_NAME}-${project.name}`,
})
.andWhere(`c.uuid IN (${matchingCommunityIdsQb.getQuery()})`)
.setParameters(matchingCommunityIdsQb.getParameters());
.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,
},
{ disabled: false, orphanSpaceName: ORPHAN_SPACE_NAME },
)
.leftJoinAndSelect('space.parent', 'parent')
.leftJoinAndSelect(
@ -232,7 +204,16 @@ export class CommunityService {
'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: `%${search}%` },
);
}
const customModel = TypeORMCustomModel(this.communityRepository);
const { baseResponseDto, paginationResponseDto } =

View File

@ -48,7 +48,8 @@ async function bootstrap() {
logger.error('Seeding failed!', error.stack || error);
}
logger.log('Starting auth at port ...', process.env.PORT || 4000);
await app.listen(process.env.PORT || 4000);
const port = process.env.PORT || 3000;
logger.log(`Starting application on port ${port}...`);
await app.listen(port, '0.0.0.0');
}
bootstrap();

View File

@ -1,7 +1,4 @@
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 { SuccessResponseDto } from '@app/common/dto/success.response.dto';
import { SceneService } from '../services/scene.service';
import {
Body,
Controller,
@ -11,21 +8,21 @@ import {
Param,
Post,
Put,
Query,
Req,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Permissions } from 'src/decorators/permissions.decorator';
import { PermissionsGuard } from 'src/guards/permissions.guard';
import { SceneParamDto } from '../dtos';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import {
AddSceneIconDto,
AddSceneTapToRunDto,
GetSceneDto,
UpdateSceneTapToRunDto,
} from '../dtos/scene.dto';
import { SceneService } from '../services/scene.service';
import { EnableDisableStatusEnum } from '@app/common/constants/days.enum';
import { SceneParamDto } from '../dtos';
import { BaseResponseDto } from '@app/common/dto/base.response.dto';
import { ControllerRoute } from '@app/common/constants/controller-route';
import { PermissionsGuard } from 'src/guards/permissions.guard';
import { Permissions } from 'src/decorators/permissions.decorator';
@ApiTags('Scene Module')
@Controller({
@ -55,27 +52,6 @@ export class SceneController {
);
}
@ApiBearerAuth()
@UseGuards(PermissionsGuard)
@Permissions('SCENES_VIEW')
@Get('tap-to-run')
@ApiOperation({
summary: ControllerRoute.SCENE.ACTIONS.GET_TAP_TO_RUN_SCENES_SUMMARY,
description:
ControllerRoute.SCENE.ACTIONS.GET_TAP_TO_RUN_SCENES_DESCRIPTION,
})
async getTapToRunSceneBySpaces(
@Query() dto: GetSceneDto,
@Req() req: any,
): Promise<BaseResponseDto> {
const projectUuid = req.user.project.uuid;
const data = await this.sceneService.findScenesBySpaces(dto, projectUuid);
return new SuccessResponseDto({
message: 'Scenes Retrieved Successfully',
data,
});
}
@ApiBearerAuth()
@UseGuards(PermissionsGuard)
@Permissions('SCENES_DELETE')

View File

@ -1,16 +1,15 @@
import { BooleanValues } from '@app/common/constants/boolean-values.enum';
import { ApiProperty } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import {
IsArray,
IsBoolean,
IsNotEmpty,
IsNumber,
IsOptional,
IsString,
IsUUID,
IsArray,
ValidateNested,
IsOptional,
IsNumber,
IsBoolean,
} from 'class-validator';
import { Transform, Type } from 'class-transformer';
import { BooleanValues } from '@app/common/constants/boolean-values.enum';
class ExecutorProperty {
@ApiProperty({
@ -188,19 +187,4 @@ export class GetSceneDto {
return value.obj.showInHomePage === BooleanValues.TRUE;
})
public showInHomePage: boolean = false;
@ApiProperty({
description: 'List of Space IDs to filter automation',
required: false,
example: ['60d21b4667d0d8992e610c85', '60d21b4967d0d8992e610c86'],
})
@IsOptional()
@Transform(({ value }) => {
if (!Array.isArray(value)) {
return [value];
}
return value;
})
@IsUUID('4', { each: true })
public spaces?: string[];
}

View File

@ -1,36 +1,12 @@
import {
ActionExecutorEnum,
ActionTypeEnum,
} from '@app/common/constants/automation.enum';
import { SceneIconType } from '@app/common/constants/secne-icon-type.enum';
import { BaseResponseDto } from '@app/common/dto/base.response.dto';
import { SuccessResponseDto } from '@app/common/dto/success.response.dto';
import { convertKeysToCamelCase } from '@app/common/helper/camelCaseConverter';
import { convertKeysToSnakeCase } from '@app/common/helper/snakeCaseConverter';
import { ConvertedAction } from '@app/common/integrations/tuya/interfaces';
import { TuyaService } from '@app/common/integrations/tuya/services/tuya.service';
import { AutomationRepository } from '@app/common/modules/automation/repositories';
import { SceneDeviceRepository } from '@app/common/modules/scene-device/repositories';
import {
SceneEntity,
SceneIconEntity,
} from '@app/common/modules/scene/entities';
import {
SceneIconRepository,
SceneRepository,
} from '@app/common/modules/scene/repositories';
import { SpaceRepository } from '@app/common/modules/space/repositories';
import {
BadRequestException,
forwardRef,
Injectable,
HttpException,
HttpStatus,
BadRequestException,
forwardRef,
Inject,
Injectable,
} from '@nestjs/common';
import { HttpStatusCode } from 'axios';
import { DeviceService } from 'src/device/services';
import { In } from 'typeorm';
import { SpaceRepository } from '@app/common/modules/space/repositories';
import {
Action,
AddSceneIconDto,
@ -39,12 +15,35 @@ import {
SceneParamDto,
UpdateSceneTapToRunDto,
} from '../dtos';
import { convertKeysToSnakeCase } from '@app/common/helper/snakeCaseConverter';
import {
AddTapToRunSceneInterface,
DeleteTapToRunSceneInterface,
SceneDetails,
SceneDetailsResult,
} from '../interface/scene.interface';
import { convertKeysToCamelCase } from '@app/common/helper/camelCaseConverter';
import {
ActionExecutorEnum,
ActionTypeEnum,
} from '@app/common/constants/automation.enum';
import {
SceneIconRepository,
SceneRepository,
} from '@app/common/modules/scene/repositories';
import { SceneIconType } from '@app/common/constants/secne-icon-type.enum';
import {
SceneEntity,
SceneIconEntity,
} from '@app/common/modules/scene/entities';
import { TuyaService } from '@app/common/integrations/tuya/services/tuya.service';
import { BaseResponseDto } from '@app/common/dto/base.response.dto';
import { SuccessResponseDto } from '@app/common/dto/success.response.dto';
import { HttpStatusCode } from 'axios';
import { ConvertedAction } from '@app/common/integrations/tuya/interfaces';
import { DeviceService } from 'src/device/services';
import { SceneDeviceRepository } from '@app/common/modules/scene-device/repositories';
import { AutomationRepository } from '@app/common/modules/automation/repositories';
@Injectable()
export class SceneService {
@ -93,48 +92,158 @@ export class SceneService {
}
}
async findScenesBySpace(spaceUuid: string, { showInHomePage }: GetSceneDto) {
async create(
spaceTuyaUuid: string,
addSceneTapToRunDto: AddSceneTapToRunDto,
projectUuid: string,
): Promise<SceneEntity> {
const { iconUuid, showInHomePage, spaceUuid } = addSceneTapToRunDto;
try {
await this.getSpaceByUuid(spaceUuid);
return this.findScenesBySpaces({ showInHomePage, spaces: [spaceUuid] });
} catch (error) {
console.error(
`Error fetching Tap-to-Run scenes for space UUID ${spaceUuid}:`,
error.message,
const [defaultSceneIcon] = await Promise.all([
this.getDefaultSceneIcon(),
]);
if (!defaultSceneIcon) {
throw new HttpException(
'Default scene icon not found',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
const response = await this.createSceneExternalService(
spaceTuyaUuid,
addSceneTapToRunDto,
projectUuid,
);
throw error instanceof HttpException
? error
: new HttpException(
'An error occurred while retrieving scenes',
HttpStatus.INTERNAL_SERVER_ERROR,
);
const scene = await this.sceneRepository.save({
sceneTuyaUuid: response.result.id,
sceneIcon: { uuid: iconUuid || defaultSceneIcon.uuid },
showInHomePage,
space: { uuid: spaceUuid },
});
return scene;
} catch (err) {
if (err instanceof HttpException) {
throw err;
} else if (err.message?.includes('tuya')) {
throw new HttpException(
'API error: Failed to create scene',
HttpStatus.BAD_GATEWAY,
);
} else {
throw new HttpException(
'Database error: Failed to save scene',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
}
async updateSceneExternalService(
spaceTuyaUuid: string,
sceneTuyaUuid: string,
updateSceneTapToRunDto: UpdateSceneTapToRunDto,
projectUuid: string,
) {
const { sceneName, decisionExpr, actions } = updateSceneTapToRunDto;
try {
const formattedActions = await this.prepareActions(actions, projectUuid);
const response = (await this.tuyaService.updateTapToRunScene(
sceneTuyaUuid,
spaceTuyaUuid,
sceneName,
formattedActions,
decisionExpr,
)) as AddTapToRunSceneInterface;
if (!response.success) {
throw new HttpException(
'Failed to update scene in Tuya',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
return response;
} catch (err) {
if (err instanceof HttpException) {
throw err;
} else if (err.message?.includes('tuya')) {
throw new HttpException(
'API error: Failed to update scene',
HttpStatus.BAD_GATEWAY,
);
} else {
throw new HttpException(
`An Internal error has been occured ${err}`,
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
}
async findScenesBySpaces(
{ showInHomePage, spaces }: GetSceneDto,
projectUuid?: string,
async createSceneExternalService(
spaceTuyaUuid: string,
addSceneTapToRunDto: AddSceneTapToRunDto,
projectUuid: string,
) {
const { sceneName, decisionExpr, actions } = addSceneTapToRunDto;
try {
const formattedActions = await this.prepareActions(actions, projectUuid);
const response = (await this.tuyaService.addTapToRunScene(
spaceTuyaUuid,
sceneName,
formattedActions,
decisionExpr,
)) as AddTapToRunSceneInterface;
if (!response.result?.id) {
throw new HttpException(
'Failed to create scene in Tuya',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
return response;
} catch (err) {
if (err instanceof HttpException) {
throw err;
} else if (err.message?.includes('tuya')) {
throw new HttpException(
'API error: Failed to create scene',
HttpStatus.BAD_GATEWAY,
);
} else {
throw new HttpException(
`An Internal error has been occured ${err}`,
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
}
async findScenesBySpace(spaceUuid: string, filter: GetSceneDto) {
try {
await this.getSpaceByUuid(spaceUuid);
const showInHomePage = filter?.showInHomePage;
const scenesData = await this.sceneRepository.find({
where: {
space: {
uuid: In(spaces ?? []),
community: projectUuid ? { project: { uuid: projectUuid } } : null,
},
space: { uuid: spaceUuid },
disabled: false,
...(showInHomePage ? { showInHomePage } : {}),
},
relations: ['sceneIcon', 'space', 'space.community'],
});
const safeFetch = async (scene: SceneEntity) => {
const safeFetch = async (scene: any) => {
try {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { actions, ...sceneDetails } = await this.getScene(
scene,
scene.space.uuid,
spaceUuid,
);
return sceneDetails;
} catch (error) {
@ -150,7 +259,7 @@ export class SceneService {
return scenes.filter(Boolean); // Remove null values
} catch (error) {
console.error(
`Error fetching Tap-to-Run scenes for specified spaces:`,
`Error fetching Tap-to-Run scenes for space UUID ${spaceUuid}:`,
error.message,
);
@ -182,6 +291,45 @@ export class SceneService {
}
}
async fetchSceneDetailsFromTuya(
sceneId: string,
): Promise<SceneDetailsResult> {
try {
const response = await this.tuyaService.getSceneRule(sceneId);
const camelCaseResponse = convertKeysToCamelCase(response);
const {
id,
name,
type,
status,
actions: tuyaActions = [],
} = camelCaseResponse.result;
const actions = tuyaActions.map((action) => ({ ...action }));
return {
id,
name,
type,
status,
actions,
} as SceneDetailsResult;
} catch (err) {
console.error(
`Error fetching scene details for scene ID ${sceneId}:`,
err,
);
if (err instanceof HttpException) {
throw err;
} else {
throw new HttpException(
'An error occurred while fetching scene details from Tuya',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
}
async updateTapToRunScene(
updateSceneTapToRunDto: UpdateSceneTapToRunDto,
sceneUuid: string,
@ -238,38 +386,6 @@ export class SceneService {
}
}
async deleteScene(params: SceneParamDto): Promise<BaseResponseDto> {
const { sceneUuid } = params;
try {
const scene = await this.findScene(sceneUuid);
const space = await this.getSpaceByUuid(scene.space.uuid);
await this.delete(scene.sceneTuyaUuid, space.spaceTuyaUuid);
await this.sceneDeviceRepository.update(
{ uuid: sceneUuid },
{ disabled: true },
);
await this.sceneRepository.update(
{
uuid: sceneUuid,
},
{ disabled: true },
);
return new SuccessResponseDto({
message: `Scene with ID ${sceneUuid} deleted successfully`,
});
} catch (err) {
if (err instanceof HttpException) {
throw err;
} else {
throw new HttpException(
err.message || `Scene not found for id ${params.sceneUuid}`,
err.status || HttpStatus.NOT_FOUND,
);
}
}
}
async addSceneIcon(addSceneIconDto: AddSceneIconDto) {
try {
const icon = await this.sceneIconRepository.save({
@ -338,237 +454,7 @@ export class SceneService {
}
}
async findScene(sceneUuid: string): Promise<SceneEntity> {
const scene = await this.sceneRepository.findOne({
where: { uuid: sceneUuid },
relations: ['sceneIcon', 'space', 'space.community'],
});
if (!scene) {
throw new HttpException(
`Invalid scene with id ${sceneUuid}`,
HttpStatus.NOT_FOUND,
);
}
return scene;
}
async getSpaceByUuid(spaceUuid: string) {
try {
const space = await this.spaceRepository.findOne({
where: {
uuid: spaceUuid,
},
relations: ['community'],
});
if (!space) {
throw new HttpException(
`Invalid space UUID ${spaceUuid}`,
HttpStatusCode.BadRequest,
);
}
if (!space.community.externalId) {
throw new HttpException(
`Space doesn't have any association with tuya${spaceUuid}`,
HttpStatusCode.BadRequest,
);
}
return {
uuid: space.uuid,
createdAt: space.createdAt,
updatedAt: space.updatedAt,
name: space.spaceName,
spaceTuyaUuid: space.community.externalId,
};
} catch (err) {
if (err instanceof BadRequestException) {
throw err;
} else {
throw new HttpException(
`Space with id ${spaceUuid} not found`,
HttpStatus.NOT_FOUND,
);
}
}
}
private async create(
spaceTuyaUuid: string,
addSceneTapToRunDto: AddSceneTapToRunDto,
projectUuid: string,
): Promise<SceneEntity> {
const { iconUuid, showInHomePage, spaceUuid } = addSceneTapToRunDto;
try {
const [defaultSceneIcon] = await Promise.all([
this.getDefaultSceneIcon(),
]);
if (!defaultSceneIcon) {
throw new HttpException(
'Default scene icon not found',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
const response = await this.createSceneExternalService(
spaceTuyaUuid,
addSceneTapToRunDto,
projectUuid,
);
const scene = await this.sceneRepository.save({
sceneTuyaUuid: response.result.id,
sceneIcon: { uuid: iconUuid || defaultSceneIcon.uuid },
showInHomePage,
space: { uuid: spaceUuid },
});
return scene;
} catch (err) {
if (err instanceof HttpException) {
throw err;
} else if (err.message?.includes('tuya')) {
throw new HttpException(
'API error: Failed to create scene',
HttpStatus.BAD_GATEWAY,
);
} else {
throw new HttpException(
'Database error: Failed to save scene',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
}
private async updateSceneExternalService(
spaceTuyaUuid: string,
sceneTuyaUuid: string,
updateSceneTapToRunDto: UpdateSceneTapToRunDto,
projectUuid: string,
) {
const { sceneName, decisionExpr, actions } = updateSceneTapToRunDto;
try {
const formattedActions = await this.prepareActions(actions, projectUuid);
const response = (await this.tuyaService.updateTapToRunScene(
sceneTuyaUuid,
spaceTuyaUuid,
sceneName,
formattedActions,
decisionExpr,
)) as AddTapToRunSceneInterface;
if (!response.success) {
throw new HttpException(
'Failed to update scene in Tuya',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
return response;
} catch (err) {
if (err instanceof HttpException) {
throw err;
} else if (err.message?.includes('tuya')) {
throw new HttpException(
'API error: Failed to update scene',
HttpStatus.BAD_GATEWAY,
);
} else {
throw new HttpException(
`An Internal error has been occured ${err}`,
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
}
private async createSceneExternalService(
spaceTuyaUuid: string,
addSceneTapToRunDto: AddSceneTapToRunDto,
projectUuid: string,
) {
const { sceneName, decisionExpr, actions } = addSceneTapToRunDto;
try {
const formattedActions = await this.prepareActions(actions, projectUuid);
const response = (await this.tuyaService.addTapToRunScene(
spaceTuyaUuid,
sceneName,
formattedActions,
decisionExpr,
)) as AddTapToRunSceneInterface;
if (!response.result?.id) {
throw new HttpException(
'Failed to create scene in Tuya',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
return response;
} catch (err) {
if (err instanceof HttpException) {
throw err;
} else if (err.message?.includes('tuya')) {
throw new HttpException(
'API error: Failed to create scene',
HttpStatus.BAD_GATEWAY,
);
} else {
throw new HttpException(
`An Internal error has been occured ${err}`,
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
}
private async fetchSceneDetailsFromTuya(
sceneId: string,
): Promise<SceneDetailsResult> {
try {
const response = await this.tuyaService.getSceneRule(sceneId);
const camelCaseResponse = convertKeysToCamelCase(response);
const {
id,
name,
type,
status,
actions: tuyaActions = [],
} = camelCaseResponse.result;
const actions = tuyaActions.map((action) => ({ ...action }));
return {
id,
name,
type,
status,
actions,
} as SceneDetailsResult;
} catch (err) {
console.error(
`Error fetching scene details for scene ID ${sceneId}:`,
err,
);
if (err instanceof HttpException) {
throw err;
} else {
throw new HttpException(
'An error occurred while fetching scene details from Tuya',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
}
private async getScene(
scene: SceneEntity,
spaceUuid: string,
): Promise<SceneDetails> {
async getScene(scene: SceneEntity, spaceUuid: string): Promise<SceneDetails> {
try {
const { actions, name, status } = await this.fetchSceneDetailsFromTuya(
scene.sceneTuyaUuid,
@ -633,7 +519,54 @@ export class SceneService {
}
}
private async delete(tuyaSceneId: string, tuyaSpaceId: string) {
async deleteScene(params: SceneParamDto): Promise<BaseResponseDto> {
const { sceneUuid } = params;
try {
const scene = await this.findScene(sceneUuid);
const space = await this.getSpaceByUuid(scene.space.uuid);
await this.delete(scene.sceneTuyaUuid, space.spaceTuyaUuid);
await this.sceneDeviceRepository.update(
{ uuid: sceneUuid },
{ disabled: true },
);
await this.sceneRepository.update(
{
uuid: sceneUuid,
},
{ disabled: true },
);
return new SuccessResponseDto({
message: `Scene with ID ${sceneUuid} deleted successfully`,
});
} catch (err) {
if (err instanceof HttpException) {
throw err;
} else {
throw new HttpException(
err.message || `Scene not found for id ${params.sceneUuid}`,
err.status || HttpStatus.NOT_FOUND,
);
}
}
}
async findScene(sceneUuid: string): Promise<SceneEntity> {
const scene = await this.sceneRepository.findOne({
where: { uuid: sceneUuid },
relations: ['sceneIcon', 'space', 'space.community'],
});
if (!scene) {
throw new HttpException(
`Invalid scene with id ${sceneUuid}`,
HttpStatus.NOT_FOUND,
);
}
return scene;
}
async delete(tuyaSceneId: string, tuyaSpaceId: string) {
try {
const response = (await this.tuyaService.deleteSceneRule(
tuyaSceneId,
@ -693,4 +626,45 @@ export class SceneService {
});
return defaultIcon;
}
async getSpaceByUuid(spaceUuid: string) {
try {
const space = await this.spaceRepository.findOne({
where: {
uuid: spaceUuid,
},
relations: ['community'],
});
if (!space) {
throw new HttpException(
`Invalid space UUID ${spaceUuid}`,
HttpStatusCode.BadRequest,
);
}
if (!space.community.externalId) {
throw new HttpException(
`Space doesn't have any association with tuya${spaceUuid}`,
HttpStatusCode.BadRequest,
);
}
return {
uuid: space.uuid,
createdAt: space.createdAt,
updatedAt: space.updatedAt,
name: space.spaceName,
spaceTuyaUuid: space.community.externalId,
};
} catch (err) {
if (err instanceof BadRequestException) {
throw err;
} else {
throw new HttpException(
`Space with id ${spaceUuid} not found`,
HttpStatus.NOT_FOUND,
);
}
}
}
}

View File

@ -17,7 +17,6 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Permissions } from 'src/decorators/permissions.decorator';
import { PermissionsGuard } from 'src/guards/permissions.guard';
import { AddSpaceDto, CommunitySpaceParam, UpdateSpaceDto } from '../dtos';
import { DuplicateSpaceDto } from '../dtos/duplicate-space.dto';
import { GetSpaceDto } from '../dtos/get.space.dto';
import { GetSpaceParam } from '../dtos/get.space.param';
import { OrderSpacesDto } from '../dtos/order.spaces.dto';
@ -49,26 +48,6 @@ export class SpaceController {
);
}
@ApiBearerAuth()
@UseGuards(PermissionsGuard)
@Permissions('SPACE_ADD')
@ApiOperation({
summary: ControllerRoute.SPACE.ACTIONS.DUPLICATE_SPACE_SUMMARY,
description: ControllerRoute.SPACE.ACTIONS.DUPLICATE_SPACE_DESCRIPTION,
})
@Post(':spaceUuid/duplicate')
async duplicateSpace(
@Param('spaceUuid', ParseUUIDPipe) spaceUuid: string,
@Body() dto: DuplicateSpaceDto,
@Param() communitySpaceParam: CommunitySpaceParam,
): Promise<BaseResponseDto> {
return await this.spaceService.duplicateSpace(
spaceUuid,
communitySpaceParam,
dto,
);
}
@ApiBearerAuth()
@UseGuards(PermissionsGuard)
@Permissions('SPACE_VIEW')

View File

@ -6,6 +6,7 @@ import {
IsArray,
IsMongoId,
IsNotEmpty,
IsNumber,
IsOptional,
IsString,
IsUUID,
@ -47,6 +48,14 @@ export class AddSpaceDto {
@IsOptional()
public icon?: string;
@ApiProperty({ description: 'X position on canvas', example: 120 })
@IsNumber()
x: number;
@ApiProperty({ description: 'Y position on canvas', example: 200 })
@IsNumber()
y: number;
@ApiProperty({
description: 'UUID of the Space Model',
example: 'd290f1ee-6c54-4b01-90e6-d701748f0851',

View File

@ -1,18 +0,0 @@
import { ORPHAN_SPACE_NAME } from '@app/common/constants/orphan-constant';
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, NotEquals } from 'class-validator';
export class DuplicateSpaceDto {
@ApiProperty({
description: 'Name of the space (e.g., Floor 1, Unit 101)',
example: 'Unit 101',
})
@IsString()
@IsNotEmpty()
@NotEquals(ORPHAN_SPACE_NAME, {
message() {
return `Space name cannot be "${ORPHAN_SPACE_NAME}". Please choose a different name.`;
},
})
spaceName: string;
}

View File

@ -4,6 +4,7 @@ import { Type } from 'class-transformer';
import {
ArrayUnique,
IsArray,
IsNumber,
IsOptional,
IsString,
NotEquals,
@ -35,6 +36,16 @@ export class UpdateSpaceDto {
@IsOptional()
public icon?: string;
@ApiProperty({ description: 'X position on canvas', example: 120 })
@IsNumber()
@IsOptional()
x?: number;
@ApiProperty({ description: 'Y position on canvas', example: 200 })
@IsNumber()
@IsOptional()
y?: number;
@ApiPropertyOptional({
description: 'List of subspace modifications',
type: [UpdateSubspaceDto],

View File

@ -133,7 +133,7 @@ export class ValidationService {
'subspaces.productAllocations',
'subspaces.productAllocations.product',
'subspaces.devices',
// 'spaceModel',
'spaceModel',
],
});

View File

@ -8,7 +8,6 @@ import { generateRandomString } from '@app/common/helper/randomString';
import { removeCircularReferences } from '@app/common/helper/removeCircularReferences';
import { SpaceProductAllocationEntity } from '@app/common/modules/space/entities/space-product-allocation.entity';
import { SpaceEntity } from '@app/common/modules/space/entities/space.entity';
import { SubspaceProductAllocationEntity } from '@app/common/modules/space/entities/subspace/subspace-product-allocation.entity';
import { SubspaceEntity } from '@app/common/modules/space/entities/subspace/subspace.entity';
import {
InviteSpaceRepository,
@ -25,7 +24,6 @@ import { DeviceService } from 'src/device/services';
import { SpaceModelService } from 'src/space-model/services';
import { TagService } from 'src/tags/services/tags.service';
import { DataSource, In, Not, QueryRunner } from 'typeorm';
import { v4 as uuidV4 } from 'uuid';
import { DisableSpaceCommand } from '../commands';
import {
AddSpaceDto,
@ -34,7 +32,6 @@ import {
UpdateSpaceDto,
} from '../dtos';
import { CreateProductAllocationDto } from '../dtos/create-product-allocation.dto';
import { DuplicateSpaceDto } from '../dtos/duplicate-space.dto';
import { GetSpaceDto } from '../dtos/get.space.dto';
import { OrderSpacesDto } from '../dtos/order.spaces.dto';
import { SpaceWithParentsDto } from '../dtos/space.parents.dto';
@ -96,9 +93,6 @@ export class SpaceService {
parentUuid && !isRecursiveCall
? await this.validationService.validateSpace(parentUuid)
: null;
if (parent) {
await this.validateNamingConflict(addSpaceDto.spaceName, parent);
}
const spaceModel = spaceModelUuid
? await this.validationService.validateSpaceModel(spaceModelUuid)
@ -108,6 +102,8 @@ export class SpaceService {
// 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: isRecursiveCall
? recursiveCallParentEntity
@ -185,154 +181,6 @@ export class SpaceService {
!isRecursiveCall ? await queryRunner.release() : null;
}
}
async duplicateSpace(
spaceUuid: string,
{ communityUuid, projectUuid }: CommunitySpaceParam,
dto: DuplicateSpaceDto,
queryRunner?: QueryRunner,
): Promise<BaseResponseDto> {
if (!queryRunner) {
queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
}
try {
await this.validationService.validateCommunityAndProject(
communityUuid,
projectUuid,
queryRunner,
);
await this.handleSpaceDuplication(spaceUuid, dto.spaceName, queryRunner);
await queryRunner.commitTransaction();
const { data } = await this.getSpacesHierarchyForCommunity(
{
projectUuid,
communityUuid,
},
{ onlyWithDevices: false },
);
return new SuccessResponseDto({
message: `Space with ID ${spaceUuid} successfully duplicated`,
data,
});
} catch (error) {
await queryRunner.rollbackTransaction();
console.log((error as Error).stack);
throw new HttpException(error.message, HttpStatus.INTERNAL_SERVER_ERROR);
} finally {
await queryRunner.release();
}
}
private async handleSpaceDuplication(
spaceUuid: string,
newSpaceName: string | null,
queryRunner: QueryRunner,
parent?: SpaceEntity,
) {
const space = await this.spaceRepository.findOne({
where: { uuid: spaceUuid },
relations: [
'children',
'productAllocations',
'subspaces',
'subspaces.productAllocations',
],
});
const clonedSpace = structuredClone(space);
const newSpace = queryRunner.manager.create(SpaceEntity, {
...clonedSpace,
spaceName: newSpaceName || clonedSpace.spaceName,
parent,
children: undefined,
subspaces: undefined,
productAllocations: undefined,
uuid: uuidV4(),
});
if (clonedSpace.productAllocations?.length) {
newSpace.productAllocations = this.copySpaceAllocations(
newSpace,
clonedSpace.productAllocations,
queryRunner,
);
}
if (clonedSpace.subspaces?.length) {
newSpace.subspaces = this.copySpaceSubspaces(
newSpace,
clonedSpace.subspaces,
queryRunner,
);
}
const savedSpace = await queryRunner.manager.save(newSpace);
if (clonedSpace.children?.length) {
for (const child of clonedSpace.children) {
if (child.disabled) continue;
await this.handleSpaceDuplication(
child.uuid,
child.spaceName,
queryRunner,
savedSpace,
);
}
}
return savedSpace;
}
private copySpaceSubspaces(
newSpace: SpaceEntity,
subspaces: SubspaceEntity[],
queryRunner: QueryRunner,
) {
const newSubspaces = [];
for (const sub of subspaces) {
if (sub.disabled) continue;
const clonedSub = structuredClone(sub);
delete clonedSub.uuid;
const newSubspace = queryRunner.manager.create(SubspaceEntity, {
...clonedSub,
space: newSpace,
productAllocations: [],
uuid: uuidV4(),
});
if (sub.productAllocations?.length) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
for (const { uuid, ...allocation } of sub.productAllocations) {
newSubspace.productAllocations.push(
queryRunner.manager.create(SubspaceProductAllocationEntity, {
...allocation,
subspace: newSubspace,
uuid: uuidV4(),
}),
);
}
}
newSubspaces.push(newSubspace);
}
return newSubspaces;
}
private copySpaceAllocations(
newSpace: SpaceEntity,
allocations: SpaceProductAllocationEntity[],
queryRunner: QueryRunner,
) {
const newAllocations = [];
// eslint-disable-next-line @typescript-eslint/no-unused-vars
for (const { uuid, ...allocation } of allocations) {
newAllocations.push(
queryRunner.manager.create(SpaceProductAllocationEntity, {
...allocation,
space: newSpace,
uuid: uuidV4(),
}),
);
}
return newAllocations;
}
private checkDuplicateTags(allocations: CreateProductAllocationDto[]) {
const tagUuidSet = new Set<string>();
const tagNameProductSet = new Set<string>();
@ -657,8 +505,6 @@ export class SpaceService {
spaceUuid,
);
await this.validateNamingConflict(updateSpaceDto.spaceName, space, true);
if (space.spaceModel && !updateSpaceDto.spaceModelUuid) {
await queryRunner.manager.update(SpaceEntity, space.uuid, {
spaceModel: null,
@ -809,11 +655,13 @@ export class SpaceService {
updateSpaceDto: UpdateSpaceDto,
queryRunner: QueryRunner,
): Promise<void> {
const { spaceName, icon } = updateSpaceDto;
const { spaceName, x, y, icon } = updateSpaceDto;
const updateFields: Partial<SpaceEntity> = {};
if (spaceName) updateFields.spaceName = spaceName;
if (x !== undefined) updateFields.x = x;
if (y !== undefined) updateFields.y = y;
if (icon) updateFields.icon = icon;
if (Object.keys(updateFields).length > 0) {
@ -980,34 +828,4 @@ export class SpaceService {
queryRunner,
);
}
async validateNamingConflict(
newSpaceName: string,
parent: SpaceEntity,
isUpdate: boolean = false,
): Promise<void> {
if (!isUpdate && parent.spaceName === newSpaceName) {
throw new HttpException(
`Space can't be created with the same name as its parent space`,
HttpStatus.BAD_REQUEST,
);
}
if (parent.children?.some((child) => child.spaceName === newSpaceName)) {
throw new HttpException(
`Space name cannot be the same as one of its siblings/children`,
HttpStatus.BAD_REQUEST,
);
}
if (isUpdate) {
const sibling = await this.spaceRepository.exists({
where: { spaceName: newSpaceName, parent: { uuid: parent.uuid } },
});
if (sibling) {
throw new HttpException(
`Space name cannot be the same as one of its siblings/children`,
HttpStatus.BAD_REQUEST,
);
}
}
}
}

View File

@ -1,11 +1,11 @@
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, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { UserParamDto } from '../dtos';
import { UserSpaceService } from '../services';
import { JwtAuthGuard } from '@app/common/guards/jwt.auth.guard';
import { BaseResponseDto } from '@app/common/dto/base.response.dto';
import { UserParamDto } from '../dtos';
@ApiTags('User Module')
@Controller({

View File

@ -11,7 +11,6 @@ import {
Param,
Patch,
Put,
Query,
Req,
UseGuards,
} from '@nestjs/common';
@ -25,7 +24,6 @@ import {
UpdateRegionDataDto,
UpdateTimezoneDataDto,
} from '../dtos';
import { UsersWithBookableSpacesFilterDto } from '../dtos/users-with-bookable-spaces-filter.dto';
import { UserService } from '../services/user.service';
@ApiTags('User Module')
@ -36,21 +34,6 @@ import { UserService } from '../services/user.service';
export class UserController {
constructor(private readonly userService: UserService) {}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Get('with-bookable-spaces')
@ApiOperation({
summary:
ControllerRoute.USER.ACTIONS.GET_USERS_WITH_BOOKABLE_SPACES_SUMMARY,
description:
ControllerRoute.USER.ACTIONS.GET_USERS_WITH_BOOKABLE_SPACES_DESCRIPTION,
})
async getUsersWithBookableSpaces(
@Query() dto: UsersWithBookableSpacesFilterDto,
): Promise<BaseResponseDto> {
return this.userService.getUsersWithBookableSpaces(dto);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Get(':userUuid')

View File

@ -1,21 +0,0 @@
import { PaginationRequestGetListDto } from '@app/common/dto/pagination.request.dto';
import { ApiProperty } from '@nestjs/swagger';
import { IsEnum, IsOptional, ValidateIf } from 'class-validator';
export class UsersWithBookableSpacesFilterDto extends PaginationRequestGetListDto {
@ApiProperty({
enum: ['accessStartDate', 'accessEndDate'],
required: false,
})
@IsEnum(['accessStartDate', 'accessEndDate'])
@ValidateIf((o) => o.sortDirection)
sortBy: 'accessStartDate' | 'accessEndDate';
@ApiProperty({
enum: ['asc', 'desc'],
required: false,
})
@IsEnum(['asc', 'desc'])
@IsOptional()
sortDirection: 'asc' | 'desc';
}

View File

@ -1,18 +1,15 @@
import { PageResponse } from '@app/common/dto/pagination.response.dto';
import { SuccessResponseDto } from '@app/common/dto/success.response.dto';
import { removeBase64Prefix } from '@app/common/helper/removeBase64Prefix';
import { RegionRepository } from '@app/common/modules/region/repositories';
import { TimeZoneRepository } from '@app/common/modules/timezone/repositories';
import { UserEntity } from '@app/common/modules/user/entities';
import { UserRepository } from '@app/common/modules/user/repositories';
import { getPaginationResponseDto } from '@app/common/util/getPaginationResponseDto';
import {
BadRequestException,
HttpException,
HttpStatus,
Injectable,
} from '@nestjs/common';
import { UsersWithBookableSpacesFilterDto } from '../dtos/users-with-bookable-spaces-filter.dto';
import {
UpdateNameDto,
UpdateProfilePictureDataDto,
@ -66,38 +63,6 @@ export class UserService {
}
}
async getUsersWithBookableSpaces({
sortBy,
sortDirection,
page,
size,
}: UsersWithBookableSpacesFilterDto) {
size = size ?? 10;
page = page ?? 1;
const qb = this.userRepository
.createQueryBuilder('user')
.innerJoin('user.userSpaces', 'userSpaces')
.innerJoin('userSpaces.space', 'space')
.innerJoin('space.bookableConfig', 'bookableConfig')
.where('bookableConfig.uuid IS NOT NULL')
.leftJoinAndSelect('user.inviteUser', 'inviteUser')
.take(size)
.skip((page - 1) * size)
.distinct(true);
if (sortBy) {
qb.orderBy(
':sortBy',
sortDirection == 'desc' ? 'DESC' : 'ASC',
).setParameter('sortBy', sortBy);
}
const [data, count] = await qb.getManyAndCount();
return new PageResponse(
{ message: 'users fetched successfully', data },
getPaginationResponseDto(count, page, size),
);
}
async updateProfilePictureByUserUuid(
userUuid: string,
updateProfilePictureDataDto: UpdateProfilePictureDataDto,