Compare commits

..

4 Commits

48 changed files with 1713 additions and 5358 deletions

View File

@ -1,4 +1,4 @@
name: 🤖 AI PR Description Commenter (100% Safe with jq)
name: 🤖 AI PR Description Generator (with Template)
on:
pull_request:
@ -12,10 +12,8 @@ jobs:
- name: Checkout Repo
uses: actions/checkout@v4
- name: Install GitHub CLI and jq
run: |
sudo apt-get update
sudo apt-get install gh jq -y
- name: Install GitHub CLI
uses: cli/cli-action@v2
- name: Fetch PR Commits
id: fetch_commits
@ -25,31 +23,23 @@ jobs:
echo "$COMMITS" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
env:
GH_TOKEN: ${{ secrets.GH_PERSONAL_TOKEN }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Generate PR Description with OpenAI (Safe JSON with jq)
- name: Generate PR Description with OpenAI
id: generate_description
run: |
REQUEST_BODY=$(jq -n \
--arg model "gpt-4o" \
--arg content "Given the following commit messages:\n\n${commits}\n\nGenerate a clear and professional pull request description." \
'{
model: $model,
messages: [{ role: "user", content: $content }]
}'
)
RESPONSE=$(curl -s https://api.openai.com/v1/chat/completions \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-H "Content-Type: application/json" \
-d "$REQUEST_BODY")
-d '{
"model": "gpt-4o",
"messages": [{
"role": "user",
"content": "Given the following commit messages:\n\n'"${commits}"'\n\nFill the following pull request template. Only fill the \"## Description\" section:\n\n<!--\n Thanks for contributing!\n\n Provide a description of your changes below and a general summary in the title.\n-->\n\n## Jira Ticket\n\n[SP-0000](https://syncrow.atlassian.net/browse/SP-0000)\n\n## Description\n\n<!--- Describe your changes in detail -->\n\n## How to Test\n\n<!--- Describe the created APIs / Logic -->"
}]
}')
DESCRIPTION=$(echo "$RESPONSE" | jq -r '.choices[0].message.content')
echo "---------- OpenAI Raw Response ----------"
echo "$RESPONSE"
echo "---------- Extracted Description ----------"
echo "$DESCRIPTION"
echo "description<<EOF" >> $GITHUB_ENV
echo "$DESCRIPTION" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
@ -57,8 +47,8 @@ jobs:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
commits: ${{ env.commits }}
- name: Post AI Generated Description as Comment
- name: Update PR Body with AI Description
run: |
gh pr comment ${{ github.event.pull_request.number }} --body "${{ env.description }}"
gh pr edit ${{ github.event.pull_request.number }} --body "${{ env.description }}"
env:
GH_TOKEN: ${{ secrets.GH_PERSONAL_TOKEN }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

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

7
.gitignore vendored
View File

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

View File

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

View File

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

View File

@ -1,19 +1,17 @@
# 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.
@ -21,11 +19,10 @@ Other Entities - sessions, OTPs, etc.
The entities have a one-to-many relationship - a user has multiple homes, a home has multiple rooms, and a room has multiple devices.
## Architecture
The application is deployed on Azure App Service using Docker containers. There are separate deployment slots for development, staging, and production environments.
## Installation
## Installation
First, ensure that you have Node.js `v20.11` or newer (LTS ONLY) installed on your system.
To install the project dependencies, run the following command in the project root directory:
@ -64,8 +61,8 @@ $ npm run test:cov
![Syncrow ERD Digram](https://github.com/SyncrowIOT/backend/assets/83524184/94273a2b-625c-4a34-9cd4-fb14415ce884)
## Architecture
## Architecture
+----------------------------------+
| |
| Applications |
@ -110,29 +107,3 @@ $ npm run test:cov
| | Standby Node | | |
| +------------------+----------------+ |
+-----------------------------------------------------------------+
## CDK Deployment
• Bootstrap CDK (first time only): npx cdk bootstrap aws://482311766496/me-central-1
• List available stacks: npx cdk list
• Deploy infrastructure: npx cdk deploy --require-approval never
• View changes before deploy: npx cdk diff
• Generate CloudFormation template: npx cdk synth
• Destroy infrastructure: npx cdk destroy
• Environment variables are configured in infrastructure/stack.ts
• After code changes: build Docker image, push to ECR, force ECS deployment
• Database seeding happens automatically on first deployment with DB_SYNC=true
• Admin credentials: admin@syncrow.ae / YourSecureAdminPassword123!
• Production API: https://api.syncos.syncrow.ae
• Health check: https://api.syncos.syncrow.ae/health
## GitHub Actions Deployment
• Automatic deployment on push to master/main branch
• Configure GitHub Secrets (see GITHUB_SETUP.md for complete list)
• Required secrets: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, JWT_SECRET, JWT_SECRET_REFRESH
• Workflow builds Docker image, pushes to ECR, and deploys CDK stack
• Environment variables are passed securely via GitHub Secrets
• Manual deployment: Go to Actions tab and run "Deploy Backend to AWS" workflow
• Check deployment status in GitHub Actions tab
• Logs available in CloudWatch under /ecs/syncrow-backend log group

View File

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

View File

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

View File

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

View File

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

View File

@ -1,16 +0,0 @@
#!/usr/bin/env node
import * as cdk from 'aws-cdk-lib';
import 'source-map-support/register';
import { BackendStack } from './stack';
const app = new cdk.App();
new BackendStack(app, 'SyncrowBackendStack', {
env: {
account: process.env.CDK_DEFAULT_ACCOUNT,
region: 'me-central-1',
},
databaseName: 'postgres',
certificateArn:
'arn:aws:acm:me-central-1:482311766496:certificate/423b343e-402b-4978-89bd-cda25f7a8873',
});

View File

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

View File

@ -1,13 +1,15 @@
import { DeviceStatusLogRepository } from '@app/common/modules/device-status-log/repositories';
import { DeviceRepository } from '@app/common/modules/device/repositories';
import {
HttpException,
HttpStatus,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AddDeviceStatusDto } from '../dtos/add.devices-status.dto';
import { DeviceRepository } from '@app/common/modules/device/repositories';
import { GetDeviceDetailsFunctionsStatusInterface } from 'src/device/interfaces/get.device.interface';
import { TuyaContext } from '@tuya/tuya-connector-nodejs';
import { ConfigService } from '@nestjs/config';
import { firebaseDataBase } from '../../firebase.config';
import {
Database,
DataSnapshot,
@ -15,9 +17,7 @@ import {
ref,
runTransaction,
} from 'firebase/database';
import { GetDeviceDetailsFunctionsStatusInterface } from 'src/device/interfaces/get.device.interface';
import { firebaseDataBase } from '../../firebase.config';
import { AddDeviceStatusDto } from '../dtos/add.devices-status.dto';
import { DeviceStatusLogRepository } from '@app/common/modules/device-status-log/repositories';
@Injectable()
export class DeviceStatusFirebaseService {
private tuya: TuyaContext;
@ -79,77 +79,64 @@ export class DeviceStatusFirebaseService {
device: any;
}[],
): Promise<void> {
console.log(`🔁 Preparing logs from batch of ${batch.length} items...`);
const allLogs = [];
console.log(`🔁 Preparing logs from batch of ${batch.length} items...`);
for (const item of batch) {
const device = item.device;
if (!device?.uuid) {
console.log(`⛔ Skipped unknown device: ${item.deviceTuyaUuid}`);
continue;
}
// Determine properties based on environment
const properties =
this.isDevEnv && Array.isArray(item.log?.properties)
? item.log.properties
: Array.isArray(item.status)
? item.status
: null;
if (!properties) {
console.log(
`⛔ Skipped invalid status/properties for device: ${item.deviceTuyaUuid}`,
);
continue;
}
const logs = properties.map((property) =>
const logs = item.log.properties.map((property) =>
this.deviceStatusLogRepository.create({
deviceId: device.uuid,
deviceTuyaId: item.deviceTuyaUuid,
productId: device.productDevice?.uuid,
productId: item.log.productId,
log: item.log,
code: property.code,
value: property.value,
eventId: item.log?.dataId,
eventTime: new Date(
this.isDevEnv ? property.time : property.t,
).toISOString(),
eventId: item.log.dataId,
eventTime: new Date(property.time).toISOString(),
}),
);
allLogs.push(...logs);
}
console.log(`📝 Total logs to insert: ${allLogs.length}`);
const chunkSize = 300;
let insertedCount = 0;
const insertLogsPromise = (async () => {
const chunkSize = 300;
let insertedCount = 0;
for (let i = 0; i < allLogs.length; i += chunkSize) {
const chunk = allLogs.slice(i, i + chunkSize);
try {
const result = await this.deviceStatusLogRepository
.createQueryBuilder()
.insert()
.into('device-status-log')
.values(chunk)
.orIgnore()
.execute();
for (let i = 0; i < allLogs.length; i += chunkSize) {
const chunk = allLogs.slice(i, i + chunkSize);
try {
const result = await this.deviceStatusLogRepository
.createQueryBuilder()
.insert()
.into('device-status-log') // or use DeviceStatusLogEntity
.values(chunk)
.orIgnore() // skip duplicates
.execute();
insertedCount += result.identifiers.length;
console.log(
`✅ Inserted ${result.identifiers.length} / ${chunk.length} logs (chunk)`,
);
} catch (error) {
console.error('❌ Insert error (skipped chunk):', error.message);
insertedCount += result.identifiers.length;
console.log(
`✅ Inserted ${result.identifiers.length} / ${chunk.length} logs (chunk)`,
);
} catch (error) {
console.error('❌ Insert error (skipped chunk):', error.message);
}
}
}
console.log(`✅ Total logs inserted: ${insertedCount} / ${allLogs.length}`);
console.log(
`✅ Total logs inserted: ${insertedCount} / ${allLogs.length}`,
);
})();
await insertLogsPromise;
}
async addDeviceStatusToFirebase(

View File

@ -3,32 +3,21 @@ import { getDatabase } from 'firebase/database';
import { ConfigService } from '@nestjs/config';
export const initializeFirebaseApp = (configService: ConfigService) => {
try {
const firebaseConfig = {
apiKey: configService.get<string>('FIREBASE_API_KEY'),
authDomain: configService.get<string>('FIREBASE_AUTH_DOMAIN'),
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 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'),
};
// 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;
}
const app = initializeApp(firebaseConfig);
return getDatabase(app);
};
export const firebaseDataBase = (configService: ConfigService) =>

View File

@ -1,9 +1,9 @@
import { DeviceStatusFirebaseService } from '@app/common/firebase/devices-status/services/devices-status.service';
import { Injectable, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as NodeCache from 'node-cache';
import TuyaWebsocket from '../../config/tuya-web-socket-config';
import { ConfigService } from '@nestjs/config';
import { DeviceStatusFirebaseService } from '@app/common/firebase/devices-status/services/devices-status.service';
import { SosHandlerService } from './sos.handler.service';
import * as NodeCache from 'node-cache';
@Injectable()
export class TuyaWebSocketService implements OnModuleInit {
@ -74,12 +74,7 @@ export class TuyaWebSocketService implements OnModuleInit {
this.client.message(async (ws: WebSocket, message: any) => {
try {
const { devId, status, logData } = this.extractMessageData(message);
// console.log(
// `📬 Received message for device: ${devId}, status:`,
// status,
// logData,
// );
if (!Array.isArray(status)) {
if (!Array.isArray(logData?.properties)) {
this.client.ackMessage(message.messageId);
return;
}
@ -167,8 +162,6 @@ export class TuyaWebSocketService implements OnModuleInit {
status: any;
logData: any;
} {
// console.log('Received message:', message);
const payloadData = message.payload.data;
if (this.isDevEnv) {

View File

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

4414
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -6,25 +6,19 @@
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "npx nest build",
"build:lambda": "npx nest build && cp package*.json dist/",
"build": "npm run test && npx nest build",
"format": "prettier --write \"apps/**/*.ts\" \"libs/**/*.ts\"",
"start": "node dist/main",
"start:dev": "npx nest start --watch",
"start": "npm run test && node dist/main",
"start:dev": "npm run test && npx nest start --watch",
"dev": "npx nest start --watch",
"start:debug": "npx nest start --debug --watch",
"start:prod": "node dist/main",
"start:lambda": "node dist/lambda",
"start:debug": "npm run test && npx nest start --debug --watch",
"start:prod": "npm run test && node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest --config jest.config.js",
"test:watch": "jest --watch --config jest.config.js",
"test:cov": "jest --coverage --config jest.config.js",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./apps/backend/test/jest-e2e.json",
"deploy": "./deploy.sh",
"infra:build": "bash build.sh",
"infra:deploy": "cdk deploy SyncrowBackendStack",
"infra:destroy": "cdk destroy SyncrowBackendStack"
"test:e2e": "jest --config ./apps/backend/test/jest-e2e.json"
},
"dependencies": {
"@fast-csv/format": "^5.0.2",
@ -43,16 +37,13 @@
"@nestjs/typeorm": "^10.0.2",
"@nestjs/websockets": "^10.3.8",
"@tuya/tuya-connector-nodejs": "^2.1.2",
"@types/aws-lambda": "^8.10.150",
"argon2": "^0.40.1",
"aws-serverless-express": "^3.4.0",
"axios": "^1.7.7",
"bcryptjs": "^2.4.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"crypto-js": "^4.2.0",
"csv-parser": "^3.2.0",
"dotenv": "^17.0.0",
"date-fns": "^4.1.0",
"express-rate-limit": "^7.1.5",
"firebase": "^10.12.5",
@ -64,13 +55,11 @@
"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",
"webpack": "^5.99.9",
"winston": "^3.17.0",
"ws": "^8.17.0"
},
@ -88,9 +77,7 @@
"@types/supertest": "^6.0.0",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"aws-cdk-lib": "^2.202.0",
"concurrently": "^8.2.2",
"constructs": "^10.4.2",
"eslint": "^8.42.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-import": "^2.31.0",

View File

@ -1,11 +0,0 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { SqlLoaderService } from '@app/common/helper/services/sql-loader.service';
import { AqiService } from './services';
import { AqiController } from './controllers';
@Module({
imports: [ConfigModule],
controllers: [AqiController],
providers: [AqiService, SqlLoaderService],
})
export class AqiModule {}

View File

@ -1,64 +0,0 @@
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
import {
ApiTags,
ApiBearerAuth,
ApiOperation,
ApiParam,
} from '@nestjs/swagger';
import { EnableDisableStatusEnum } from '@app/common/constants/days.enum';
import { ControllerRoute } from '@app/common/constants/controller-route';
import { JwtAuthGuard } from '@app/common/guards/jwt.auth.guard';
import { AqiService } from '../services/aqi.service';
import {
GetAqiDailyBySpaceDto,
GetAqiPollutantBySpaceDto,
} from '../dto/get-aqi.dto';
import { BaseResponseDto } from '@app/common/dto/base.response.dto';
import { SpaceParamsDto } from '../dto/aqi-params.dto';
@ApiTags('AQI Module')
@Controller({
version: EnableDisableStatusEnum.ENABLED,
path: ControllerRoute.AQI.ROUTE,
})
export class AqiController {
constructor(private readonly aqiService: AqiService) {}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Get('range/space/:spaceUuid')
@ApiOperation({
summary: ControllerRoute.AQI.ACTIONS.GET_AQI_RANGE_DATA_SUMMARY,
description: ControllerRoute.AQI.ACTIONS.GET_AQI_RANGE_DATA_DESCRIPTION,
})
@ApiParam({
name: 'spaceUuid',
description: 'UUID of the Space',
required: true,
})
async getAQIRangeDataBySpace(
@Param() params: SpaceParamsDto,
@Query() query: GetAqiDailyBySpaceDto,
): Promise<BaseResponseDto> {
return await this.aqiService.getAQIRangeDataBySpace(params, query);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Get('distribution/space/:spaceUuid')
@ApiOperation({
summary: ControllerRoute.AQI.ACTIONS.GET_AQI_DISTRIBUTION_DATA_SUMMARY,
description:
ControllerRoute.AQI.ACTIONS.GET_AQI_DISTRIBUTION_DATA_DESCRIPTION,
})
@ApiParam({
name: 'spaceUuid',
description: 'UUID of the Space',
required: true,
})
async getAQIDistributionDataBySpace(
@Param() params: SpaceParamsDto,
@Query() query: GetAqiPollutantBySpaceDto,
): Promise<BaseResponseDto> {
return await this.aqiService.getAQIDistributionDataBySpace(params, query);
}
}

View File

@ -1 +0,0 @@
export * from './aqi.controller';

View File

@ -1,7 +0,0 @@
import { IsNotEmpty, IsUUID } from 'class-validator';
export class SpaceParamsDto {
@IsUUID('4', { message: 'Invalid UUID format' })
@IsNotEmpty()
spaceUuid: string;
}

View File

@ -1,37 +0,0 @@
import { PollutantType } from '@app/common/constants/pollutants.enum';
import { ApiProperty } from '@nestjs/swagger';
import { Matches, IsNotEmpty, IsString } from 'class-validator';
export class GetAqiDailyBySpaceDto {
@ApiProperty({
description: 'Month and year in format YYYY-MM',
example: '2025-03',
required: true,
})
@Matches(/^\d{4}-(0[1-9]|1[0-2])$/, {
message: 'monthDate must be in YYYY-MM format',
})
@IsNotEmpty()
monthDate: string;
}
export class GetAqiPollutantBySpaceDto {
@ApiProperty({
description: 'Pollutant Type',
enum: PollutantType,
example: PollutantType.AQI,
required: true,
})
@IsString()
@IsNotEmpty()
public pollutantType: string;
@ApiProperty({
description: 'Month and year in format YYYY-MM',
example: '2025-03',
required: true,
})
@Matches(/^\d{4}-(0[1-9]|1[0-2])$/, {
message: 'monthDate must be in YYYY-MM format',
})
@IsNotEmpty()
monthDate: string;
}

View File

@ -1,138 +0,0 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import {
GetAqiDailyBySpaceDto,
GetAqiPollutantBySpaceDto,
} from '../dto/get-aqi.dto';
import { SuccessResponseDto } from '@app/common/dto/success.response.dto';
import { SpaceParamsDto } from '../dto/aqi-params.dto';
import { SqlLoaderService } from '@app/common/helper/services/sql-loader.service';
import { DataSource } from 'typeorm';
import { SQL_PROCEDURES_PATH } from '@app/common/constants/sql-query-path';
import { BaseResponseDto } from '@app/common/dto/base.response.dto';
import { convertKeysToCamelCase } from '@app/common/helper/camelCaseConverter';
import { PollutantType } from '@app/common/constants/pollutants.enum';
@Injectable()
export class AqiService {
constructor(
private readonly sqlLoader: SqlLoaderService,
private readonly dataSource: DataSource,
) {}
async getAQIDistributionDataBySpace(
params: SpaceParamsDto,
query: GetAqiPollutantBySpaceDto,
): Promise<BaseResponseDto> {
const { monthDate, pollutantType } = query;
const { spaceUuid } = params;
try {
const data = await this.executeProcedure(
'fact_daily_space_aqi',
'proceduce_select_daily_space_aqi',
[spaceUuid, monthDate],
);
const categories = [
'good',
'moderate',
'unhealthy_sensitive',
'unhealthy',
'very_unhealthy',
'hazardous',
];
const transformedData = data.map((item) => {
const date = new Date(item.event_date).toLocaleDateString('en-CA'); // YYYY-MM-DD
const categoryData = categories.map((category) => {
const key = `${category}_${pollutantType.toLowerCase()}_percentage`;
return {
type: category,
percentage: item[key] ?? 0,
};
});
return { date, data: categoryData };
});
const response = this.buildResponse(
`AQI distribution data fetched successfully for ${spaceUuid} space and pollutant ${pollutantType}`,
transformedData,
);
return response;
} catch (error) {
console.error('Failed to fetch AQI distribution data', {
error,
spaceUuid,
});
throw new HttpException(
error.response?.message || 'Failed to fetch AQI distribution data',
error.status || HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
async getAQIRangeDataBySpace(
params: SpaceParamsDto,
query: GetAqiDailyBySpaceDto,
): Promise<BaseResponseDto> {
const { monthDate } = query;
const { spaceUuid } = params;
try {
const data = await this.executeProcedure(
'fact_daily_space_aqi',
'proceduce_select_daily_space_aqi',
[spaceUuid, monthDate],
);
// Define pollutants dynamically
const pollutants = Object.values(PollutantType);
const transformedData = data.map((item) => {
const date = new Date(item.event_date).toLocaleDateString('en-CA'); // YYYY-MM-DD
const dailyData = pollutants.map((type) => ({
type,
min: item[`daily_min_${type}`],
max: item[`daily_max_${type}`],
average: item[`daily_avg_${type}`],
}));
return { date, data: dailyData };
});
const response = this.buildResponse(
`AQI data fetched successfully for ${spaceUuid} space`,
transformedData,
);
return convertKeysToCamelCase(response);
} catch (error) {
console.error('Failed to fetch AQI data', {
error,
spaceUuid,
});
throw new HttpException(
error.response?.message || 'Failed to fetch AQI data',
error.status || HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
private buildResponse(message: string, data: any[]) {
return new SuccessResponseDto({
message,
data,
statusCode: HttpStatus.OK,
});
}
private async executeProcedure(
procedureFolderName: string,
procedureFileName: string,
params: (string | number | null)[],
): Promise<any[]> {
const query = this.loadQuery(procedureFolderName, procedureFileName);
return await this.dataSource.query(query, params);
}
private loadQuery(folderName: string, fileName: string): string {
return this.sqlLoader.loadQuery(folderName, fileName, SQL_PROCEDURES_PATH);
}
}

View File

@ -1 +0,0 @@
export * from './aqi.service';

View File

@ -1,29 +0,0 @@
import { BookingRepositoryModule } from '@app/common/modules/booking/booking.repository.module';
import { BookableSpaceEntityRepository } from '@app/common/modules/booking/repositories/bookable-space.repository';
import { BookingEntityRepository } from '@app/common/modules/booking/repositories/booking.repository';
import { SpaceRepository } from '@app/common/modules/space';
import { UserRepository } from '@app/common/modules/user/repositories';
import { EmailService } from '@app/common/util/email/email.service';
import { Global, Module } from '@nestjs/common';
import { BookableSpaceController } from './controllers/bookable-space.controller';
import { BookingController } from './controllers/booking.controller';
import { BookableSpaceService } from './services/bookable-space.service';
import { BookingService } from './services/booking.service';
@Global()
@Module({
imports: [BookingRepositoryModule],
controllers: [BookableSpaceController, BookingController],
providers: [
BookableSpaceService,
BookingService,
EmailService,
BookableSpaceEntityRepository,
BookingEntityRepository,
SpaceRepository,
UserRepository,
],
exports: [BookableSpaceService, BookingService],
})
export class BookingModule {}

View File

@ -1,107 +0,0 @@
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 {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Post,
Put,
Query,
Req,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { PageResponse } from '@app/common/dto/pagination.response.dto';
import { SuccessResponseDto } from '@app/common/dto/success.response.dto';
import { plainToInstance } from 'class-transformer';
import { BookableSpaceRequestDto } from '../dtos/bookable-space-request.dto';
import { BookableSpaceResponseDto } from '../dtos/bookable-space-response.dto';
import { CreateBookableSpaceDto } from '../dtos/create-bookable-space.dto';
import { UpdateBookableSpaceDto } from '../dtos/update-bookable-space.dto';
import { BookableSpaceService } from '../services/bookable-space.service';
@ApiTags('Booking Module')
@Controller({
version: EnableDisableStatusEnum.ENABLED,
path: ControllerRoute.BOOKABLE_SPACES.ROUTE,
})
export class BookableSpaceController {
constructor(private readonly bookableSpaceService: BookableSpaceService) {}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Post()
@ApiOperation({
summary:
ControllerRoute.BOOKABLE_SPACES.ACTIONS.ADD_BOOKABLE_SPACES_SUMMARY,
description:
ControllerRoute.BOOKABLE_SPACES.ACTIONS.ADD_BOOKABLE_SPACES_DESCRIPTION,
})
async create(@Body() dto: CreateBookableSpaceDto): Promise<BaseResponseDto> {
const result = await this.bookableSpaceService.create(dto);
return new SuccessResponseDto({
data: result,
message: 'Successfully created bookable spaces',
});
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Get()
@ApiOperation({
summary:
ControllerRoute.BOOKABLE_SPACES.ACTIONS.GET_ALL_BOOKABLE_SPACES_SUMMARY,
description:
ControllerRoute.BOOKABLE_SPACES.ACTIONS
.GET_ALL_BOOKABLE_SPACES_DESCRIPTION,
})
async findAll(
@Query() query: BookableSpaceRequestDto,
@Req() req: Request,
): Promise<PageResponse<BookableSpaceResponseDto>> {
const project = req['user']?.project?.uuid;
if (!project) {
throw new Error('Project UUID is required in the request');
}
const { data, pagination } = await this.bookableSpaceService.findAll(
query,
project,
);
return new PageResponse<BookableSpaceResponseDto>(
{
data: data.map((space) =>
plainToInstance(BookableSpaceResponseDto, space, {
excludeExtraneousValues: true,
}),
),
message: 'Successfully fetched all bookable spaces',
},
pagination,
);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Put(':spaceUuid')
@ApiOperation({
summary:
ControllerRoute.BOOKABLE_SPACES.ACTIONS.UPDATE_BOOKABLE_SPACES_SUMMARY,
description:
ControllerRoute.BOOKABLE_SPACES.ACTIONS
.UPDATE_BOOKABLE_SPACES_DESCRIPTION,
})
async update(
@Param('spaceUuid', ParseUUIDPipe) spaceUuid: string,
@Body() dto: UpdateBookableSpaceDto,
): Promise<BaseResponseDto> {
const result = await this.bookableSpaceService.update(spaceUuid, dto);
return new SuccessResponseDto({
data: result,
message: 'Successfully updated bookable spaces',
});
}
}

View File

@ -1,107 +0,0 @@
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 {
Body,
Controller,
Get,
Post,
Query,
Req,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { SuccessResponseDto } from '@app/common/dto/success.response.dto';
import { plainToInstance } from 'class-transformer';
import { AdminRoleGuard } from 'src/guards/admin.role.guard';
import { BookingRequestDto } from '../dtos/booking-request.dto';
import { BookingResponseDto } from '../dtos/booking-response.dto';
import { CreateBookingDto } from '../dtos/create-booking.dto';
import { MyBookingRequestDto } from '../dtos/my-booking-request.dto';
import { BookingService } from '../services/booking.service';
@ApiTags('Booking Module')
@Controller({
version: EnableDisableStatusEnum.ENABLED,
path: ControllerRoute.BOOKING.ROUTE,
})
export class BookingController {
constructor(private readonly bookingService: BookingService) {}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Post()
@ApiOperation({
summary: ControllerRoute.BOOKING.ACTIONS.ADD_BOOKING_SUMMARY,
description: ControllerRoute.BOOKING.ACTIONS.ADD_BOOKING_DESCRIPTION,
})
async create(
@Body() dto: CreateBookingDto,
@Req() req: Request,
): Promise<BaseResponseDto> {
const userUuid = req['user']?.uuid;
if (!userUuid) {
throw new Error('User UUID is required in the request');
}
const result = await this.bookingService.create(userUuid, dto);
return new SuccessResponseDto({
data: result,
message: 'Successfully created booking',
});
}
@ApiBearerAuth()
@UseGuards(AdminRoleGuard)
@Get()
@ApiOperation({
summary: ControllerRoute.BOOKING.ACTIONS.GET_ALL_BOOKINGS_SUMMARY,
description: ControllerRoute.BOOKING.ACTIONS.GET_ALL_BOOKINGS_DESCRIPTION,
})
async findAll(
@Query() query: BookingRequestDto,
@Req() req: Request,
): Promise<BaseResponseDto> {
const project = req['user']?.project?.uuid;
if (!project) {
throw new Error('Project UUID is required in the request');
}
const result = await this.bookingService.findAll(query, project);
return new SuccessResponseDto({
data: plainToInstance(BookingResponseDto, result, {
excludeExtraneousValues: true,
}),
message: 'Successfully fetched all bookings',
});
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Get('my-bookings')
@ApiOperation({
summary: ControllerRoute.BOOKING.ACTIONS.GET_MY_BOOKINGS_SUMMARY,
description: ControllerRoute.BOOKING.ACTIONS.GET_MY_BOOKINGS_DESCRIPTION,
})
async findMyBookings(
@Query() query: MyBookingRequestDto,
@Req() req: Request,
): Promise<BaseResponseDto> {
const userUuid = req['user']?.uuid;
const project = req['user']?.project?.uuid;
if (!project) {
throw new Error('Project UUID is required in the request');
}
const result = await this.bookingService.findMyBookings(
query,
userUuid,
project,
);
return new SuccessResponseDto({
data: plainToInstance(BookingResponseDto, result, {
excludeExtraneousValues: true,
}),
message: 'Successfully fetched all bookings',
});
}
}

View File

@ -1,31 +0,0 @@
import { BooleanValues } from '@app/common/constants/boolean-values.enum';
import { PaginationRequestWithSearchGetListDto } from '@app/common/dto/pagination-with-search.request.dto';
import { ApiProperty, OmitType } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsBoolean, IsNotEmpty, IsOptional } from 'class-validator';
export class BookableSpaceRequestDto extends OmitType(
PaginationRequestWithSearchGetListDto,
['includeSpaces'],
) {
@ApiProperty({
type: Boolean,
required: false,
})
@IsBoolean()
@IsOptional()
@Transform(({ obj }) => {
return obj.active === BooleanValues.TRUE;
})
active?: boolean;
@ApiProperty({
type: Boolean,
})
@IsBoolean()
@IsNotEmpty()
@Transform(({ obj }) => {
return obj.configured === BooleanValues.TRUE;
})
configured: boolean;
}

View File

@ -1,59 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { Expose, Type } from 'class-transformer';
export class BookableSpaceConfigResponseDto {
@ApiProperty()
@Expose()
uuid: string;
@ApiProperty({
type: [String],
})
@Expose()
daysAvailable: string[];
@ApiProperty()
@Expose()
startTime: string;
@ApiProperty()
@Expose()
endTime: string;
@ApiProperty({
type: Boolean,
})
@Expose()
active: boolean;
@ApiProperty({
type: Number,
nullable: true,
})
@Expose()
points?: number;
}
export class BookableSpaceResponseDto {
@ApiProperty()
@Expose()
uuid: string;
@ApiProperty()
@Expose()
spaceUuid: string;
@ApiProperty()
@Expose()
spaceName: string;
@ApiProperty()
@Expose()
virtualLocation: string;
@ApiProperty({
type: BookableSpaceConfigResponseDto,
})
@Expose()
@Type(() => BookableSpaceConfigResponseDto)
bookableConfig: BookableSpaceConfigResponseDto;
}

View File

@ -1,23 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsOptional, IsUUID, Matches } from 'class-validator';
export class BookingRequestDto {
@ApiProperty({
description: 'Month in MM/YYYY format',
example: '07/2025',
})
@IsNotEmpty()
@Matches(/^(0[1-9]|1[0-2])\/\d{4}$/, {
message: 'Date must be in MM/YYYY format',
})
month: string;
@ApiProperty({
description: 'Space UUID',
example: '550e8400-e29b-41d4-a716-446655440000',
required: false,
})
@IsOptional()
@IsUUID('4')
space?: string;
}

View File

@ -1,88 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { Expose, Transform, Type } from 'class-transformer';
export class BookingUserResponseDto {
@ApiProperty()
@Expose()
uuid: string;
@ApiProperty()
@Expose()
firstName: string;
@ApiProperty()
@Expose()
lastName: string;
@ApiProperty({
type: String,
nullable: true,
})
@Expose()
email: string;
@ApiProperty({
type: String,
nullable: true,
})
@Expose()
@Transform(({ obj }) => obj.inviteUser?.companyName || null)
companyName: string;
@ApiProperty({
type: String,
nullable: true,
})
@Expose()
phoneNumber: string;
}
export class BookingSpaceResponseDto {
@ApiProperty()
@Expose()
uuid: string;
@ApiProperty()
@Expose()
spaceName: string;
}
export class BookingResponseDto {
@ApiProperty()
@Expose()
uuid: string;
@ApiProperty({
type: Date,
})
@Expose()
date: Date;
@ApiProperty()
@Expose()
startTime: string;
@ApiProperty()
@Expose()
endTime: string;
@ApiProperty({
type: Number,
})
@Expose()
cost: number;
@ApiProperty({
type: BookingUserResponseDto,
})
@Type(() => BookingUserResponseDto)
@Expose()
user: BookingUserResponseDto;
@ApiProperty({
type: BookingSpaceResponseDto,
})
@Type(() => BookingSpaceResponseDto)
@Expose()
space: BookingSpaceResponseDto;
}

View File

@ -1,63 +0,0 @@
import { DaysEnum } from '@app/common/constants/days.enum';
import { ApiProperty } from '@nestjs/swagger';
import {
ArrayMinSize,
IsArray,
IsEnum,
IsInt,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
Matches,
Max,
Min,
} from 'class-validator';
export class CreateBookableSpaceDto {
@ApiProperty({
type: 'string',
isArray: true,
example: [
'3fa85f64-5717-4562-b3fc-2c963f66afa6',
'4fa85f64-5717-4562-b3fc-2c963f66afa7',
],
})
@IsArray()
@ArrayMinSize(1, { message: 'At least one space must be selected' })
@IsUUID('all', { each: true, message: 'Invalid space UUID provided' })
spaceUuids: string[];
@ApiProperty({
enum: DaysEnum,
isArray: true,
example: [DaysEnum.MON, DaysEnum.WED, DaysEnum.FRI],
})
@IsArray()
@ArrayMinSize(1, { message: 'At least one day must be selected' })
@IsEnum(DaysEnum, { each: true, message: 'Invalid day provided' })
daysAvailable: DaysEnum[];
@ApiProperty({ example: '09:00' })
@IsString()
@IsNotEmpty({ message: 'Start time cannot be empty' })
@Matches(/^([01]?[0-9]|2[0-3]):[0-5][0-9]$/, {
message: 'Start time must be in HH:mm format (24-hour)',
})
startTime: string;
@ApiProperty({ example: '17:00' })
@IsString()
@IsNotEmpty({ message: 'End time cannot be empty' })
@Matches(/^([01]?[0-9]|2[0-3]):[0-5][0-9]$/, {
message: 'End time must be in HH:mm format (24-hour)',
})
endTime: string;
@ApiProperty({ example: 10, required: false })
@IsOptional()
@IsInt()
@Min(0, { message: 'Points cannot be negative' })
@Max(1000, { message: 'Points cannot exceed 1000' })
points?: number;
}

View File

@ -1,35 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsDate, IsNotEmpty, IsString, IsUUID, Matches } from 'class-validator';
export class CreateBookingDto {
@ApiProperty({
type: 'string',
example: '4fa85f64-5717-4562-b3fc-2c963f66afa7',
})
@IsNotEmpty()
@IsUUID('4', { message: 'Invalid space UUID provided' })
spaceUuid: string;
@ApiProperty({
type: Date,
})
@IsNotEmpty()
@IsDate()
date: Date;
@ApiProperty({ example: '09:00' })
@IsString()
@IsNotEmpty({ message: 'Start time cannot be empty' })
@Matches(/^([01]?[0-9]|2[0-3]):[0-5][0-9]$/, {
message: 'Start time must be in HH:mm format (24-hour)',
})
startTime: string;
@ApiProperty({ example: '17:00' })
@IsString()
@IsNotEmpty({ message: 'End time cannot be empty' })
@Matches(/^([01]?[0-9]|2[0-3]):[0-5][0-9]$/, {
message: 'End time must be in HH:mm format (24-hour)',
})
endTime: string;
}

View File

@ -1,14 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsIn, IsOptional } from 'class-validator';
export class MyBookingRequestDto {
@ApiProperty({
description: 'Filter bookings by time period',
example: 'past',
enum: ['past', 'future'],
required: false,
})
@IsOptional()
@IsIn(['past', 'future'])
when?: 'past' | 'future';
}

View File

@ -1,12 +0,0 @@
import { ApiProperty, OmitType, PartialType } from '@nestjs/swagger';
import { IsBoolean, IsOptional } from 'class-validator';
import { CreateBookableSpaceDto } from './create-bookable-space.dto';
export class UpdateBookableSpaceDto extends PartialType(
OmitType(CreateBookableSpaceDto, ['spaceUuids']),
) {
@ApiProperty({ type: Boolean })
@IsOptional()
@IsBoolean()
active?: boolean;
}

View File

@ -1 +0,0 @@
export * from './booking.module';

View File

@ -1,370 +0,0 @@
import { BaseResponseDto } from '@app/common/dto/base.response.dto';
import { PageResponseDto } from '@app/common/dto/pagination.response.dto';
import { timeToMinutes } from '@app/common/helper/timeToMinutes';
import { TypeORMCustomModel } from '@app/common/models/typeOrmCustom.model';
import { BookableSpaceEntityRepository } from '@app/common/modules/booking/repositories/bookable-space.repository';
import { BookingEntityRepository } from '@app/common/modules/booking/repositories/booking.repository';
import { SpaceEntity } from '@app/common/modules/space/entities/space.entity';
import { SpaceRepository } from '@app/common/modules/space/repositories/space.repository';
import { EmailService } from '@app/common/util/email/email.service';
import { to12HourFormat } from '@app/common/util/time-to-12-hours-convetion';
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { format } from 'date-fns';
import { Brackets, In } from 'typeorm';
import { BookableSpaceRequestDto } from '../dtos/bookable-space-request.dto';
import { CreateBookableSpaceDto } from '../dtos/create-bookable-space.dto';
import { UpdateBookableSpaceDto } from '../dtos/update-bookable-space.dto';
@Injectable()
export class BookableSpaceService {
constructor(
private readonly emailService: EmailService,
private readonly bookableSpaceEntityRepository: BookableSpaceEntityRepository,
private readonly bookingEntityRepository: BookingEntityRepository,
private readonly spaceRepository: SpaceRepository,
) {}
async create(dto: CreateBookableSpaceDto) {
// Validate time slots first
this.validateTimeSlot(dto.startTime, dto.endTime);
// fetch spaces exist
const spaces = await this.getSpacesOrFindMissing(dto.spaceUuids);
// Validate no duplicate bookable configurations
await this.validateNoDuplicateBookableConfigs(dto.spaceUuids);
// Create and save bookable spaces
return this.createBookableSpaces(spaces, dto);
}
async findAll(
{ active, page, size, configured, search }: BookableSpaceRequestDto,
project: string,
): Promise<{
data: BaseResponseDto['data'];
pagination: PageResponseDto;
}> {
let qb = this.spaceRepository
.createQueryBuilder('space')
.leftJoinAndSelect('space.parent', 'parentSpace')
.leftJoinAndSelect('space.community', 'community')
.where('community.project = :project', { project });
if (search) {
qb = qb.andWhere(
'(space.spaceName ILIKE :search OR community.name ILIKE :search OR parentSpace.spaceName ILIKE :search)',
{ search: `%${search}%` },
);
}
if (configured) {
qb = qb
.leftJoinAndSelect('space.bookableConfig', 'bookableConfig')
.andWhere('bookableConfig.uuid IS NOT NULL');
if (active !== undefined) {
qb = qb.andWhere('bookableConfig.active = :active', { active });
}
} else {
qb = qb
.leftJoinAndSelect('space.bookableConfig', 'bookableConfig')
.andWhere('bookableConfig.uuid IS NULL');
}
const customModel = TypeORMCustomModel(this.spaceRepository);
const { baseResponseDto, paginationResponseDto } =
await customModel.findAll({ page, size, modelName: 'space' }, qb);
return {
data: baseResponseDto.data.map((space) => {
return {
...space,
virtualLocation: `${space.community?.name} - ${space.parent ? space.parent?.spaceName + ' - ' : ''}${space.spaceName}`,
};
}),
pagination: paginationResponseDto,
};
}
/**
* Update bookable space configuration
*/
async update(spaceUuid: string, dto: UpdateBookableSpaceDto) {
// fetch spaces exist
const space = (await this.getSpacesOrFindMissing([spaceUuid]))[0];
if (!space.bookableConfig) {
throw new NotFoundException(
`Bookable configuration not found for space: ${spaceUuid}`,
);
}
if (dto.startTime || dto.endTime) {
// Validate time slots first
this.validateTimeSlot(
dto.startTime || space.bookableConfig.startTime,
dto.endTime || space.bookableConfig.endTime,
);
if (
dto.startTime != space.bookableConfig.startTime ||
dto.endTime != space.bookableConfig.endTime ||
dto.daysAvailable != space.bookableConfig.daysAvailable
) {
this.handleTimingUpdate(
{
daysAvailable:
dto.daysAvailable || space.bookableConfig.daysAvailable,
startTime: dto.startTime || space.bookableConfig.startTime,
endTime: dto.endTime || space.bookableConfig.endTime,
},
space,
);
}
}
if (
dto.active !== undefined &&
dto.active !== space.bookableConfig.active
) {
this.handleAvailabilityUpdate(dto.active, space);
}
Object.assign(space.bookableConfig, dto);
return this.bookableSpaceEntityRepository.save(space.bookableConfig);
}
private async handleTimingUpdate(
dto: UpdateBookableSpaceDto,
space: SpaceEntity,
): Promise<void> {
const affectedUsers = await this.getAffectedBookings(space.uuid);
if (!affectedUsers.length) return;
const groupedParams = this.groupBookingsByUser(affectedUsers);
return this.emailService.sendUpdateBookingTimingEmailWithTemplate(
groupedParams,
{
space_name: space.spaceName,
start_time: to12HourFormat(dto.startTime),
end_time: to12HourFormat(dto.endTime),
days: dto.daysAvailable.join(', '),
},
);
}
private async getAffectedBookings(spaceUuid: string) {
const today = new Date();
const nowTime = format(today, 'HH:mm');
const bookingWithDayCte = this.bookingEntityRepository
.createQueryBuilder('b')
.select('b.*')
.addSelect(
`
CASE EXTRACT(DOW FROM b.date)
WHEN 0 THEN 'Sun'
WHEN 1 THEN 'Mon'
WHEN 2 THEN 'Tue'
WHEN 3 THEN 'Wed'
WHEN 4 THEN 'Thu'
WHEN 5 THEN 'Fri'
WHEN 6 THEN 'Sat'
END::"bookable-space_days_available_enum"
`,
'booking_day',
)
.where(
`(DATE(b.date) > :today OR (DATE(b.date) = :today AND b.startTime >= :nowTime))`,
{ today, nowTime },
)
.andWhere('b.space_uuid = :spaceUuid', { spaceUuid });
const query = this.bookableSpaceEntityRepository
.createQueryBuilder('bs')
.distinct(true)
.addCommonTableExpression(bookingWithDayCte, 'booking_with_day')
.select('u.first_name', 'name')
.addSelect('u.email', 'email')
.addSelect('DATE(bwd.date)', 'date')
.addSelect('bwd.start_time', 'start_time')
.addSelect('bwd.end_time', 'end_time')
.from('booking_with_day', 'bwd')
.innerJoin('user', 'u', 'u.uuid = bwd.user_uuid')
.where('bs.space_uuid = :spaceUuid', { spaceUuid })
.andWhere(
new Brackets((qb) => {
qb.where('NOT (bwd.booking_day = ANY(bs.days_available))')
.orWhere('bwd.start_time < bs.start_time')
.orWhere('bwd.end_time > bs.end_time');
}),
);
return query.getRawMany<{
name: string;
email: string;
date: string;
start_time: string;
end_time: string;
}>();
}
private groupBookingsByUser(
bookings: {
name: string;
email: string;
date: string;
start_time: string;
end_time: string;
}[],
): {
name: string;
email: string;
bookings: { date: string; start_time: string; end_time: string }[];
}[] {
const grouped: Record<
string,
{
name: string;
email: string;
bookings: { date: string; start_time: string; end_time: string }[];
}
> = {};
for (const { name, email, date, start_time, end_time } of bookings) {
const formattedDate = format(new Date(date), 'yyyy-MM-dd');
const formattedStartTime = to12HourFormat(start_time);
const formattedEndTime = to12HourFormat(end_time);
if (!grouped[email]) {
grouped[email] = {
name,
email,
bookings: [],
};
}
grouped[email].bookings.push({
date: formattedDate,
start_time: formattedStartTime,
end_time: formattedEndTime,
});
}
return Object.values(grouped);
}
private async handleAvailabilityUpdate(
active: boolean,
space: SpaceEntity,
): Promise<void> {
space = await this.spaceRepository.findOne({
where: { uuid: space.uuid },
relations: ['userSpaces', 'userSpaces.user'],
});
const emails = space.userSpaces.map((userSpace) => ({
email: userSpace.user.email,
name: userSpace.user.firstName,
}));
if (!emails.length) return Promise.resolve();
return this.emailService.sendUpdateBookingAvailabilityEmailWithTemplate(
emails,
{
availability: active ? 'Available' : 'Unavailable',
space_name: space.spaceName,
isAvailable: active,
},
);
}
/**
* Fetch spaces by UUIDs and throw an error if any are missing
*/
private async getSpacesOrFindMissing(
spaceUuids: string[],
): Promise<SpaceEntity[]> {
const spaces = await this.spaceRepository.find({
where: { uuid: In(spaceUuids) },
relations: ['bookableConfig'],
});
if (spaces.length !== spaceUuids.length) {
const foundUuids = spaces.map((s) => s.uuid);
const missingUuids = spaceUuids.filter(
(uuid) => !foundUuids.includes(uuid),
);
throw new NotFoundException(
`Spaces not found: ${missingUuids.join(', ')}`,
);
}
return spaces;
}
/**
* Validate there are no existing bookable configurations for these spaces
*/
private async validateNoDuplicateBookableConfigs(
spaceUuids: string[],
): Promise<void> {
const existingBookables = await this.bookableSpaceEntityRepository.find({
where: { space: { uuid: In(spaceUuids) } },
relations: ['space'],
});
if (existingBookables.length > 0) {
const existingUuids = [
...new Set(existingBookables.map((b) => b.space.uuid)),
];
throw new ConflictException(
`Bookable configuration already exists for spaces: ${existingUuids.join(', ')}`,
);
}
}
/**
* Ensure the slot start time is before the end time
*/
private validateTimeSlot(startTime: string, endTime: string): void {
const start = timeToMinutes(startTime);
const end = timeToMinutes(endTime);
if (start >= end) {
throw new BadRequestException(
`End time must be after start time for slot: ${startTime}-${endTime}`,
);
}
}
/**
* Create bookable space entries after all validations pass
*/
private async createBookableSpaces(
spaces: SpaceEntity[],
dto: CreateBookableSpaceDto,
) {
try {
const entries = spaces.map((space) =>
this.bookableSpaceEntityRepository.create({
space,
daysAvailable: dto.daysAvailable,
startTime: dto.startTime,
endTime: dto.endTime,
points: dto.points,
}),
);
return this.bookableSpaceEntityRepository.save(entries);
} catch (error) {
if (error.code === '23505') {
throw new ConflictException(
'Duplicate bookable space configuration detected',
);
}
throw error;
}
}
}

View File

@ -1,218 +0,0 @@
import { DaysEnum } from '@app/common/constants/days.enum';
import { timeToMinutes } from '@app/common/helper/timeToMinutes';
import { BookingEntityRepository } from '@app/common/modules/booking/repositories/booking.repository';
import { SpaceEntity } from '@app/common/modules/space/entities/space.entity';
import { SpaceRepository } from '@app/common/modules/space/repositories/space.repository';
import { UserRepository } from '@app/common/modules/user/repositories/user.repository';
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { format } from 'date-fns';
import { Between } from 'typeorm/find-options/operator/Between';
import { BookingRequestDto } from '../dtos/booking-request.dto';
import { CreateBookingDto } from '../dtos/create-booking.dto';
import { MyBookingRequestDto } from '../dtos/my-booking-request.dto';
@Injectable()
export class BookingService {
constructor(
private readonly bookingEntityRepository: BookingEntityRepository,
private readonly spaceRepository: SpaceRepository,
private readonly userRepository: UserRepository,
) {}
async create(userUuid: string, dto: CreateBookingDto) {
console.log(userUuid);
const user = await this.userRepository.findOne({
where: { uuid: userUuid },
relations: ['userSpaces', 'userSpaces.space'],
});
console.log(user.userSpaces);
if (!user.userSpaces.some(({ space }) => space.uuid === dto.spaceUuid)) {
throw new ForbiddenException(
`User does not have permission to book this space: ${dto.spaceUuid}`,
);
}
// Validate time slots first
this.validateTimeSlot(dto.startTime, dto.endTime);
// fetch spaces exist
const space = await this.getSpaceConfigurationAndBookings(dto.spaceUuid);
// Validate booking availability
this.validateBookingAvailability(space, dto);
// Create and save booking
return this.createBookings(space, userUuid, dto);
}
async findAll({ month, space }: BookingRequestDto, project: string) {
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({
where: {
space: {
community: { project: { uuid: project } },
uuid: space ? space : undefined,
},
date: Between(fromDate, toDate),
},
relations: ['space', 'user', 'user.inviteUser'],
order: { date: 'DESC' },
});
}
async findMyBookings(
{ when }: MyBookingRequestDto,
userUuid: string,
project: string,
) {
const now = new Date();
const nowTime = format(now, 'HH:mm');
const query = this.bookingEntityRepository
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.space', 'space')
.innerJoin(
'space.community',
'community',
'community.project = :project',
{ project },
)
.leftJoinAndSelect('booking.user', 'user')
.where('user.uuid = :userUuid', { userUuid });
if (when === 'past') {
query.andWhere(
`(DATE(booking.date) < :today OR (DATE(booking.date) = :today AND booking.startTime < :nowTime))`,
{ today: now, nowTime },
);
} else if (when === 'future') {
query.andWhere(
`(DATE(booking.date) > :today OR (DATE(booking.date) = :today AND booking.startTime >= :nowTime))`,
{ today: now, nowTime },
);
}
query.orderBy({
'DATE(booking.date)': 'DESC',
'booking.startTime': 'DESC',
});
return query.getMany();
}
/**
* Fetch space by UUID and throw an error if not found or if not configured for booking
*/
private async getSpaceConfigurationAndBookings(
spaceUuid: string,
): Promise<SpaceEntity> {
const space = await this.spaceRepository.findOne({
where: { uuid: spaceUuid },
relations: ['bookableConfig', 'bookings'],
});
if (!space) {
throw new NotFoundException(`Space not found: ${spaceUuid}`);
}
if (!space.bookableConfig) {
throw new NotFoundException(
`This space is not configured for booking: ${spaceUuid}`,
);
}
return space;
}
/**
* Ensure the slot start time is before the end time
*/
private validateTimeSlot(startTime: string, endTime: string): void {
const start = timeToMinutes(startTime);
const end = timeToMinutes(endTime);
if (start >= end) {
throw new BadRequestException(
`End time must be after start time for slot: ${startTime}-${endTime}`,
);
}
}
/**
* check if the space is available for booking on the requested day
* and if the requested time slot is within the available hours
*/
private validateBookingAvailability(
space: SpaceEntity,
dto: CreateBookingDto,
): void {
// Check if the space is available for booking on the requested day
const availableDays = space.bookableConfig?.daysAvailable || [];
const requestedDay = new Date(dto.date).toLocaleDateString('en-US', {
weekday: 'short',
}) as DaysEnum;
if (!availableDays.includes(requestedDay)) {
const dayFullName = new Date(dto.date).toLocaleDateString('en-US', {
weekday: 'long',
});
throw new BadRequestException(
`Space is not available for booking on ${dayFullName}s`,
);
}
const dtoStartTimeInMinutes = timeToMinutes(dto.startTime);
const dtoEndTimeInMinutes = timeToMinutes(dto.endTime);
if (
dtoStartTimeInMinutes < timeToMinutes(space.bookableConfig.startTime) ||
dtoEndTimeInMinutes > timeToMinutes(space.bookableConfig.endTime)
) {
throw new BadRequestException(
`Booking time must be within the available hours for space: ${space.spaceName}`,
);
}
const previousBookings = space.bookings.filter(
(booking) =>
timeToMinutes(booking.startTime) < dtoEndTimeInMinutes &&
timeToMinutes(booking.endTime) > dtoStartTimeInMinutes &&
format(new Date(booking.date), 'yyyy-MM-dd') ===
format(new Date(dto.date), 'yyyy-MM-dd'),
);
if (previousBookings.length > 0) {
// tell the user what time is unavailable
const unavailableTimes = previousBookings.map((booking) => {
return `${booking.startTime}-${booking.endTime}`;
});
throw new ConflictException(
`Space is already booked during this times: ${unavailableTimes.join(', ')}`,
);
}
}
/**
* Create bookable space entries after all validations pass
*/
private async createBookings(
space: SpaceEntity,
user: string,
{ spaceUuid, date, ...dto }: CreateBookingDto,
) {
const entry = this.bookingEntityRepository.create({
space: { uuid: spaceUuid },
user: { uuid: user },
...dto,
date: new Date(date),
cost: space.bookableConfig?.points || null,
});
return this.bookingEntityRepository.save(entry);
}
}

View File

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

View File

@ -1 +0,0 @@
export * from './occupancy.controller';

View File

@ -1,71 +0,0 @@
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
import {
ApiTags,
ApiBearerAuth,
ApiOperation,
ApiParam,
} from '@nestjs/swagger';
import { EnableDisableStatusEnum } from '@app/common/constants/days.enum';
import { ControllerRoute } from '@app/common/constants/controller-route';
import { JwtAuthGuard } from '@app/common/guards/jwt.auth.guard';
import { OccupancyService } from '../services/occupancy.service';
import {
GetOccupancyDurationBySpaceDto,
GetOccupancyHeatMapBySpaceDto,
} from '../dto/get-occupancy.dto';
import { BaseResponseDto } from '@app/common/dto/base.response.dto';
import { SpaceParamsDto } from '../dto/occupancy-params.dto';
@ApiTags('Occupancy Module')
@Controller({
version: EnableDisableStatusEnum.ENABLED,
path: ControllerRoute.Occupancy.ROUTE,
})
export class OccupancyController {
constructor(private readonly occupancyService: OccupancyService) {}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Get('heat-map/space/:spaceUuid')
@ApiOperation({
summary: ControllerRoute.Occupancy.ACTIONS.GET_OCCUPANCY_HEAT_MAP_SUMMARY,
description:
ControllerRoute.Occupancy.ACTIONS.GET_OCCUPANCY_HEAT_MAP_DESCRIPTION,
})
@ApiParam({
name: 'spaceUuid',
description: 'UUID of the Space',
required: true,
})
async getOccupancyHeatMapDataBySpace(
@Param() params: SpaceParamsDto,
@Query() query: GetOccupancyHeatMapBySpaceDto,
): Promise<BaseResponseDto> {
return await this.occupancyService.getOccupancyHeatMapDataBySpace(
params,
query,
);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Get('duration/space/:spaceUuid')
@ApiOperation({
summary: ControllerRoute.Occupancy.ACTIONS.GET_OCCUPANCY_HEAT_MAP_SUMMARY,
description:
ControllerRoute.Occupancy.ACTIONS.GET_OCCUPANCY_HEAT_MAP_DESCRIPTION,
})
@ApiParam({
name: 'spaceUuid',
description: 'UUID of the Space',
required: true,
})
async getOccupancyDurationDataBySpace(
@Param() params: SpaceParamsDto,
@Query() query: GetOccupancyDurationBySpaceDto,
): Promise<BaseResponseDto> {
return await this.occupancyService.getOccupancyDurationDataBySpace(
params,
query,
);
}
}

View File

@ -1,27 +0,0 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Matches, IsNotEmpty } from 'class-validator';
export class GetOccupancyHeatMapBySpaceDto {
@ApiPropertyOptional({
description: 'Input year in YYYY format to filter the data',
example: '2025',
required: false,
})
@IsNotEmpty()
@Matches(/^\d{4}$/, {
message: 'Year must be in YYYY format',
})
year: string;
}
export class GetOccupancyDurationBySpaceDto {
@ApiPropertyOptional({
description: 'Month and year in format YYYY-MM',
example: '2025-03',
required: true,
})
@Matches(/^\d{4}-(0[1-9]|1[0-2])$/, {
message: 'monthDate must be in YYYY-MM format',
})
@IsNotEmpty()
monthDate: string;
}

View File

@ -1,7 +0,0 @@
import { IsNotEmpty, IsUUID } from 'class-validator';
export class SpaceParamsDto {
@IsUUID('4', { message: 'Invalid UUID format' })
@IsNotEmpty()
spaceUuid: string;
}

View File

@ -1,11 +0,0 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { OccupancyController } from './controllers';
import { SqlLoaderService } from '@app/common/helper/services/sql-loader.service';
import { OccupancyService } from './services';
@Module({
imports: [ConfigModule],
controllers: [OccupancyController],
providers: [OccupancyService, SqlLoaderService],
})
export class OccupancyModule {}

View File

@ -1 +0,0 @@
export * from './occupancy.service';

View File

@ -1,103 +0,0 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import {
GetOccupancyDurationBySpaceDto,
GetOccupancyHeatMapBySpaceDto,
} from '../dto/get-occupancy.dto';
import { SuccessResponseDto } from '@app/common/dto/success.response.dto';
import { SpaceParamsDto } from '../dto/occupancy-params.dto';
import { SqlLoaderService } from '@app/common/helper/services/sql-loader.service';
import { DataSource } from 'typeorm';
import { SQL_PROCEDURES_PATH } from '@app/common/constants/sql-query-path';
import { BaseResponseDto } from '@app/common/dto/base.response.dto';
@Injectable()
export class OccupancyService {
constructor(
private readonly sqlLoader: SqlLoaderService,
private readonly dataSource: DataSource,
) {}
async getOccupancyDurationDataBySpace(
params: SpaceParamsDto,
query: GetOccupancyDurationBySpaceDto,
): Promise<BaseResponseDto> {
const { monthDate } = query;
const { spaceUuid } = params;
try {
const data = await this.executeProcedure(
'fact_daily_space_occupancy_duration',
'procedure_select_daily_space_occupancy_duration',
[spaceUuid, monthDate],
);
const formattedData = data.map((item) => ({
...item,
event_date: new Date(item.event_date).toLocaleDateString('en-CA'), // YYYY-MM-DD
}));
return this.buildResponse(
`Occupancy duration data fetched successfully for ${spaceUuid} space`,
formattedData,
);
} catch (error) {
console.error('Failed to fetch occupancy duration data', {
error,
spaceUuid,
});
throw new HttpException(
error.response?.message || 'Failed to fetch occupancy duration data',
error.status || HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
async getOccupancyHeatMapDataBySpace(
params: SpaceParamsDto,
query: GetOccupancyHeatMapBySpaceDto,
): Promise<BaseResponseDto> {
const { year } = query;
const { spaceUuid } = params;
try {
const data = await this.executeProcedure(
'fact_space_occupancy_count',
'proceduce_select_fact_space_occupancy',
[spaceUuid, year],
);
const formattedData = data.map((item) => ({
...item,
event_date: new Date(item.event_date).toLocaleDateString('en-CA'), // YYYY-MM-DD
}));
return this.buildResponse(
`Occupancy heat map data fetched successfully for ${spaceUuid} space`,
formattedData,
);
} catch (error) {
console.error('Failed to fetch occupancy heat map data', {
error,
spaceUuid,
});
throw new HttpException(
error.response?.message || 'Failed to fetch occupancy heat map data',
error.status || HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
private buildResponse(message: string, data: any[]) {
return new SuccessResponseDto({
message,
data,
statusCode: HttpStatus.OK,
});
}
private async executeProcedure(
procedureFolderName: string,
procedureFileName: string,
params: (string | number | null)[],
): Promise<any[]> {
const query = this.loadQuery(procedureFolderName, procedureFileName);
return await this.dataSource.query(query, params);
}
private loadQuery(folderName: string, fileName: string): string {
return this.sqlLoader.loadQuery(folderName, fileName, SQL_PROCEDURES_PATH);
}
}

0
test-ai.txt Normal file
View File