Repository: awscommunitymx/builders_apps
Pull request #32: Create User Route
Author: login: ramtoearth
Created: 2025-04-03T03:24:22Z | Merged: 2025-04-04T00:17:38Z

# Create UserProfile Route #16 

- I've added the `UserProfileRoute.tsx` file inside `frontend/src/components` to get the id from url parameters and render the UserProfile component.
- I updated `main.tsx` to add the new route
- I updated `UserProfile.tsx` to load the `shortId` and it is validated as follows

```jsx
// UserProfile.tsx
export function UserProfile({ initialId = '' }: { initialId?: string }) {
  const [shortId, setShortId] = useState<string>(initialId);

  const isValidId = shortId && shortId.trim().length > 0;

  const { loading, error, data } = useQuery(GET_USER, {
    variables: { shortId },
    skip: !isValidId,
  });
  ...
```
- If we get an invalid `shortId` we show the following alert

```jsx
// UserProfile.tsx
{shortId && !isValidId && <Alert type="error">Please enter a valid user ID.</Alert>}
```

## Summary by Sourcery

Add user profile route and enhance frontend routing to support dynamic user profile pages

New Features:
- Create a new route for rendering user profiles with dynamic ID parameter
- Add support for loading user profiles from URL parameters

Enhancements:
- Improve UserProfile component to handle initial ID and validate user ID input
- Update deployment scripts to support frontend deployment

CI:
- Update GitHub Actions workflow to deploy frontend environments
- Modify deployment scripts to support frontend-specific deployment steps


Diff:
diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml
index 777673a..8e54038 100644
--- a/.github/workflows/cicd.yml
+++ b/.github/workflows/cicd.yml
@@ -44,26 +44,57 @@ jobs:
         id: deploy
         run: |
           SANITIZED_BRANCH=$(echo ${{ github.head_ref }} | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g')
+          SANITIZED_BRANCH="dev-$SANITIZED_BRANCH"
           chmod +x ./scripts/deploy.sh
-          ./scripts/deploy.sh -e $SANITIZED_BRANCH
+          ./scripts/deploy.sh --deploy-frontend -e $SANITIZED_BRANCH
 
-          API_URL=$(aws cloudformation describe-stacks --stack-name "ProfilesStack-$(echo $SANITIZED_BRANCH | tr '/' '-')" --query "Stacks[0].Outputs[?starts_with(OutputKey,'ApiStackGraphQLApiUrl')].OutputValue" --output text)          
+          APP_URL=$(aws cloudformation describe-stacks --stack-name "ProfilesStackFrontend-$(echo $SANITIZED_BRANCH | tr '/' '-')" --query "Stacks[0].Outputs[?OutputKey=='WebsiteURL'].OutputValue" --output text)          
 
           # Guardar la URL como un output del paso para usarlo después
-          echo "api_url=$API_URL" >> $GITHUB_OUTPUT
+          echo "app_url=$APP_URL" >> $GITHUB_OUTPUT
 
       - uses: actions/github-script@v7
         name: Comment on PR
         with:
           github-token: ${{ secrets.GITHUB_TOKEN }}
           script: |
-            const apiUrl = "${{ steps.deploy.outputs.api_url }}" || 'URL no disponible';
-            github.rest.issues.createComment({
+            const appUrl = "${{ steps.deploy.outputs.app_url }}" || 'URL unavailable';
+
+            // Check for existing comments with the URL
+            const { data: comments } = await github.rest.issues.listComments({
               issue_number: context.issue.number,
               owner: context.repo.owner,
-              repo: context.repo.repo,
-              body: `🚀 La API está desplegada y disponible en: ${apiUrl}`
-            })
+              repo: context.repo.repo
+            });
+
+            // Create the comment message
+            const commentMessage = `> [!TIP]\n> 🚀 The application is deployed and available at: ${appUrl}`;
+
+            // Look for an existing deployment comment by our bot that contains our specific message format
+            const existingComment = comments.find(comment => 
+              comment.user.type === 'Bot' && 
+              comment.body.includes('The application is deployed and available at:')
+            );
+
+            if (existingComment) {
+              // Update existing comment
+              await github.rest.issues.updateComment({
+                comment_id: existingComment.id,
+                owner: context.repo.owner,
+                repo: context.repo.repo,
+                body: commentMessage
+              });
+              console.log("Updated existing deployment comment");
+            } else {
+              // Create new comment
+              await github.rest.issues.createComment({
+                issue_number: context.issue.number,
+                owner: context.repo.owner,
+                repo: context.repo.repo,
+                body: commentMessage
+              });
+              console.log("Created new deployment comment");
+            }
 
   # Job for PR closed events
   CleanupPR:
@@ -94,6 +125,7 @@ jobs:
       - name: Run cleanup script
         run: |
           SANITIZED_BRANCH=$(echo ${{ github.head_ref }} | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g')
+          SANITIZED_BRANCH="dev-$SANITIZED_BRANCH"
           chmod +x ./scripts/deploy.sh
           ./scripts/deploy.sh -e $SANITIZED_BRANCH --destroy
 
@@ -102,10 +134,9 @@ jobs:
         with:
           github-token: ${{ secrets.GITHUB_TOKEN }}
           script: |
-            const apiUrl = "${{ steps.deploy.outputs.api_url }}" || 'URL no disponible';
             github.rest.issues.createComment({
               issue_number: context.issue.number,
               owner: context.repo.owner,
               repo: context.repo.repo,
-              body: `🧹 Environment for branch \`${{ github.head_ref }}\` has been successfully cleaned up and destroyed.`
+              body: `> [!NOTE]\n> 🧹 Environment for branch \`${{ github.head_ref }}\` has been successfully cleaned up and destroyed.`
             });
diff --git a/bin/cdk_test.ts b/bin/cdk_test.ts
index e4f232d..035f68e 100644
--- a/bin/cdk_test.ts
+++ b/bin/cdk_test.ts
@@ -1,14 +1,23 @@
 #!/usr/bin/env node
 import * as cdk from 'aws-cdk-lib';
-import { AppStack } from '../lib/app-stack';
+import { BackendStack } from '../lib/backend-stack';
+import { FrontendStack } from '../lib/frontend-stack';
 
 const app = new cdk.App();
 
 // Get environment from context or default to 'dev'
 const environmentName = app.node.tryGetContext('env') || 'dev';
 
-// Create the stack with environment-specific name
-new AppStack(app, `ProfilesStack-${environmentName}`, {
+const tags = {
+  Environment: environmentName,
+  Cdk: 'True',
+};
+
+for (const [key, value] of Object.entries(tags)) {
+  cdk.Tags.of(app).add(key, value);
+}
+
+const backendStack = new BackendStack(app, `ProfilesStack-${environmentName}`, {
   environmentName,
   env: {
     account: process.env.CDK_DEFAULT_ACCOUNT,
@@ -16,3 +25,13 @@ new AppStack(app, `ProfilesStack-${environmentName}`, {
   },
   description: `Profiles API stack - ${environmentName} environment`,
 });
+
+const frontendStack = new FrontendStack(app, `ProfilesStackFrontend-${environmentName}`, {
+  apiUrl: backendStack.apiUrl,
+  apiKey: backendStack.apiKey,
+  environment: environmentName,
+  env: {
+    account: process.env.CDK_DEFAULT_ACCOUNT,
+    region: 'us-east-1',
+  },
+});
diff --git a/frontend/.gitignore b/frontend/.gitignore
index 2d1f044..7039495 100644
--- a/frontend/.gitignore
+++ b/frontend/.gitignore
@@ -27,3 +27,5 @@ dist-ssr
 .env
 .env.local
 .env.*.local
+
+!vite-env.d.ts
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 6d2fa61..6f474bb 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -7,88 +7,6 @@ function App() {
   return (
     <ApolloProvider client={client}>
       <div>
-        <TopNavigation
-          identity={{
-            href: '#',
-            title: 'Service',
-            logo: {
-              src: '/logo-small-top-navigation.svg',
-              alt: 'Service',
-            },
-          }}
-          utilities={[
-            {
-              type: 'button',
-              text: 'Link',
-              href: 'https://example.com/',
-              external: true,
-              externalIconAriaLabel: ' (opens in a new tab)',
-            },
-            {
-              type: 'button',
-              iconName: 'notification',
-              title: 'Notifications',
-              ariaLabel: 'Notifications (unread)',
-              badge: true,
-              disableUtilityCollapse: false,
-            },
-            {
-              type: 'button',
-              iconName: 'heart',
-              title: 'Favorites',
-              ariaLabel: 'Favorites',
-            },
-            {
-              type: 'menu-dropdown',
-              iconName: 'settings',
-              ariaLabel: 'Settings',
-              title: 'Settings',
-              items: [
-                {
-                  id: 'settings-org',
-                  text: 'Organizational settings',
-                },
-                {
-                  id: 'settings-project',
-                  text: 'Project settings',
-                },
-              ],
-            },
-            {
-              type: 'menu-dropdown',
-              text: 'Customer Name',
-              description: 'email@example.com',
-              iconName: 'user-profile',
-              items: [
-                { id: 'profile', text: 'Profile' },
-                { id: 'preferences', text: 'Preferences' },
-                { id: 'security', text: 'Security' },
-                {
-                  id: 'support-group',
-                  text: 'Support',
-                  items: [
-                    {
-                      id: 'documentation',
-                      text: 'Documentation',
-                      href: '#',
-                      external: true,
-                      externalIconAriaLabel: ' (opens in new tab)',
-                    },
-                    { id: 'support', text: 'Support' },
-                    {
-                      id: 'feedback',
-                      text: 'Feedback',
-                      href: '#',
-                      external: true,
-                      externalIconAriaLabel: ' (opens in new tab)',
-                    },
-                  ],
-                },
-                { id: 'signout', text: 'Sign out' },
-              ],
-            },
-          ]}
-        />
         <UserProfile />
       </div>
     </ApolloProvider>
diff --git a/frontend/src/Layout.tsx b/frontend/src/Layout.tsx
new file mode 100644
index 0000000..56651e9
--- /dev/null
+++ b/frontend/src/Layout.tsx
@@ -0,0 +1,92 @@
+import TopNavigation from '@cloudscape-design/components/top-navigation';
+import { Outlet } from 'react-router';
+
+export function Layout() {
+  return (
+    <div>
+      <TopNavigation
+        identity={{
+          href: '#',
+          title: 'Service',
+          logo: {
+            src: '/logo-small-top-navigation.svg',
+            alt: 'Service',
+          },
+        }}
+        utilities={[
+          {
+            type: 'button',
+            text: 'Link',
+            href: 'https://example.com/',
+            external: true,
+            externalIconAriaLabel: ' (opens in a new tab)',
+          },
+          {
+            type: 'button',
+            iconName: 'notification',
+            title: 'Notifications',
+            ariaLabel: 'Notifications (unread)',
+            badge: true,
+            disableUtilityCollapse: false,
+          },
+          {
+            type: 'button',
+            iconName: 'heart',
+            title: 'Favorites',
+            ariaLabel: 'Favorites',
+          },
+          {
+            type: 'menu-dropdown',
+            iconName: 'settings',
+            ariaLabel: 'Settings',
+            title: 'Settings',
+            items: [
+              {
+                id: 'settings-org',
+                text: 'Organizational settings',
+              },
+              {
+                id: 'settings-project',
+                text: 'Project settings',
+              },
+            ],
+          },
+          {
+            type: 'menu-dropdown',
+            text: 'Customer Name',
+            description: 'email@example.com',
+            iconName: 'user-profile',
+            items: [
+              { id: 'profile', text: 'Profile' },
+              { id: 'preferences', text: 'Preferences' },
+              { id: 'security', text: 'Security' },
+              {
+                id: 'support-group',
+                text: 'Support',
+                items: [
+                  {
+                    id: 'documentation',
+                    text: 'Documentation',
+                    href: '#',
+                    external: true,
+                    externalIconAriaLabel: ' (opens in new tab)',
+                  },
+                  { id: 'support', text: 'Support' },
+                  {
+                    id: 'feedback',
+                    text: 'Feedback',
+                    href: '#',
+                    external: true,
+                    externalIconAriaLabel: ' (opens in new tab)',
+                  },
+                ],
+              },
+              { id: 'signout', text: 'Sign out' },
+            ],
+          },
+        ]}
+      />
+      <Outlet /> {/* This renders the current route's component */}
+    </div>
+  );
+}
diff --git a/frontend/src/components/UserProfile.tsx b/frontend/src/components/UserProfile.tsx
index 7e1aa63..95e27c2 100644
--- a/frontend/src/components/UserProfile.tsx
+++ b/frontend/src/components/UserProfile.tsx
@@ -1,4 +1,4 @@
-import { useState } from 'react';
+import { useEffect, useState } from 'react';
 import { useQuery } from '@apollo/client';
 import { gql } from '@apollo/client';
 import { User } from '@awscommunity/generated-react/hooks';
@@ -11,6 +11,7 @@ import {
   TextContent,
   Alert,
 } from '@cloudscape-design/components';
+import { useNavigate, useParams } from 'react-router';
 
 const GET_USER = gql`
   query GetUserByShortId($shortId: String!) {
@@ -26,11 +27,23 @@ const GET_USER = gql`
   }
 `;
 
-export function UserProfile() {
-  const [shortId, setShortId] = useState<string>('');
+export function UserProfile({ initialId = '' }: { initialId?: string }) {
+  const { id } = useParams<{ id?: string }>();
+  const [shortId, setShortId] = useState<string>(initialId);
+
+  const navigate = useNavigate();
+
+  useEffect(() => {
+    if (shortId && shortId !== id) {
+      navigate(`/uid/${shortId}`);
+    }
+  }, [shortId, id, navigate]);
+
+  const isValidId = shortId && shortId.trim().length > 0;
+
   const { loading, error, data } = useQuery(GET_USER, {
     variables: { shortId },
-    skip: !shortId,
+    skip: !isValidId,
   });
 
   return (
@@ -51,10 +64,9 @@ export function UserProfile() {
       }
     >
       <SpaceBetween size="l">
+        {shortId && !isValidId && <Alert type="error">Please enter a valid user ID.</Alert>}
         {error && <Alert type="error">Error loading profile: {error.message}</Alert>}
-
         {loading && <Alert type="info">Loading profile...</Alert>}
-
         {data?.getUserByShortId && (
           <Cards
             cardDefinition={{
diff --git a/frontend/src/components/UserProfileRoute.tsx b/frontend/src/components/UserProfileRoute.tsx
new file mode 100644
index 0000000..db23be6
--- /dev/null
+++ b/frontend/src/components/UserProfileRoute.tsx
@@ -0,0 +1,8 @@
+import { useParams } from 'react-router';
+import { UserProfile } from './UserProfile';
+
+export default function UserProfileRoute() {
+  const { id } = useParams<{ id: string }>();
+
+  return <UserProfile initialId={id || ''} />;
+}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index 3ac005a..4144336 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -2,11 +2,20 @@ import { createRoot } from 'react-dom/client';
 import './index.css';
 import App from './App.tsx';
 import { BrowserRouter, Route, Routes } from 'react-router';
+import UserProfileRoute from './components/UserProfileRoute.tsx';
+import { ApolloProvider } from '@apollo/client';
+import { client } from './apollo-client';
+import { Layout } from './Layout.tsx';
 
 createRoot(document.getElementById('root')!).render(
-  <BrowserRouter>
-    <Routes>
-      <Route path="/" element={<App />} />
-    </Routes>
-  </BrowserRouter>
+  <ApolloProvider client={client}>
+    <BrowserRouter>
+      <Routes>
+        <Route path="/" element={<Layout />}>
+          <Route index element={<App />} />
+          <Route path="/uid/:id" element={<UserProfileRoute />} />
+        </Route>
+      </Routes>
+    </BrowserRouter>
+  </ApolloProvider>
 );
diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts
new file mode 100644
index 0000000..7ef46e3
--- /dev/null
+++ b/frontend/src/vite-env.d.ts
@@ -0,0 +1,10 @@
+/// <reference types="vite/client" />
+interface ImportMetaEnv {
+  VITE_GRAPHQL_API_URL: string;
+  VITE_GRAPHQL_API_KEY: string;
+  VITE_ENVIRONMENT: string;
+}
+
+interface ImportMeta {
+  readonly env: ImportMetaEnv;
+}
diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json
index 358ca9b..9e357e6 100644
--- a/frontend/tsconfig.app.json
+++ b/frontend/tsconfig.app.json
@@ -17,7 +17,7 @@
 
     /* Linting */
     "strict": true,
-    "noUnusedLocals": true,
+    "noUnusedLocals": false,
     "noUnusedParameters": true,
     "noFallthroughCasesInSwitch": true,
     "noUncheckedSideEffectImports": true
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
index 1ffef60..d32ff68 100644
--- a/frontend/tsconfig.json
+++ b/frontend/tsconfig.json
@@ -1,7 +1,4 @@
 {
   "files": [],
-  "references": [
-    { "path": "./tsconfig.app.json" },
-    { "path": "./tsconfig.node.json" }
-  ]
+  "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
 }
diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json
index db0becc..18a14e7 100644
--- a/frontend/tsconfig.node.json
+++ b/frontend/tsconfig.node.json
@@ -20,5 +20,5 @@
     "noFallthroughCasesInSwitch": true,
     "noUncheckedSideEffectImports": true
   },
-  "include": ["vite.config.ts"]
+  "include": ["vite.config.ts", "vite-env.d.ts"]
 }
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index 8b0f57b..4a5def4 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -1,7 +1,7 @@
-import { defineConfig } from 'vite'
-import react from '@vitejs/plugin-react'
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
 
 // https://vite.dev/config/
 export default defineConfig({
   plugins: [react()],
-})
+});
diff --git a/lib/app-stack.ts b/lib/backend-stack.ts
similarity index 74%
rename from lib/app-stack.ts
rename to lib/backend-stack.ts
index 7f6988d..ef209e0 100644
--- a/lib/app-stack.ts
+++ b/lib/backend-stack.ts
@@ -8,7 +8,10 @@ export interface AppStackProps extends cdk.StackProps {
   environmentName: string;
 }
 
-export class AppStack extends cdk.Stack {
+export class BackendStack extends cdk.Stack {
+  public readonly apiUrl: string;
+  public readonly apiKey: string;
+
   constructor(scope: Construct, id: string, props: AppStackProps) {
     super(scope, id, props);
 
@@ -21,10 +24,14 @@ export class AppStack extends cdk.Stack {
       table: databaseStack.table,
     });
 
-    new ApiStack(this, 'ApiStack', {
+    const apiStack = new ApiStack(this, 'ApiStack', {
       environmentName: props.environmentName,
       table: databaseStack.table,
       viewProfileFunction: lambdaStack.viewProfileFunction,
     });
+
+    // Expose API URL and Key
+    this.apiUrl = apiStack.api.graphqlUrl;
+    this.apiKey = apiStack.api.apiKey || '';
   }
 }
diff --git a/lib/frontend-stack.ts b/lib/frontend-stack.ts
new file mode 100644
index 0000000..1dcfaae
--- /dev/null
+++ b/lib/frontend-stack.ts
@@ -0,0 +1,128 @@
+import * as cdk from 'aws-cdk-lib';
+import { Construct } from 'constructs';
+import * as s3 from 'aws-cdk-lib/aws-s3';
+import * as s3deploy from 'aws-cdk-lib/aws-s3-deployment';
+import * as cloudfront from 'aws-cdk-lib/aws-cloudfront';
+import * as origins from 'aws-cdk-lib/aws-cloudfront-origins';
+import * as iam from 'aws-cdk-lib/aws-iam';
+import * as path from 'path';
+
+interface FrontendStackProps extends cdk.StackProps {
+  apiUrl: string;
+  apiKey: string;
+  environment?: string;
+}
+
+export class FrontendStack extends cdk.Stack {
+  constructor(scope: Construct, id: string, props: FrontendStackProps) {
+    super(scope, id, props);
+
+    // Create S3 bucket to host the React app
+    const websiteBucket = new s3.Bucket(this, 'WebsiteBucket', {
+      websiteIndexDocument: 'index.html',
+      websiteErrorDocument: 'index.html', // SPA routing support
+      publicReadAccess: true,
+      blockPublicAccess: {
+        blockPublicAcls: false,
+        blockPublicPolicy: false,
+        ignorePublicAcls: false,
+        restrictPublicBuckets: false,
+      },
+      removalPolicy: cdk.RemovalPolicy.DESTROY, // For development - change for production
+      autoDeleteObjects: true, // For development - change for production
+    });
+
+    // CloudFront distribution for HTTPS and caching
+    const distribution = new cloudfront.Distribution(this, 'Distribution', {
+      defaultBehavior: {
+        origin: new origins.S3Origin(websiteBucket),
+        viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
+        cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
+      },
+      defaultRootObject: 'index.html',
+      errorResponses: [
+        {
+          httpStatus: 403,
+          responseHttpStatus: 200,
+          responsePagePath: '/index.html',
+        },
+        {
+          httpStatus: 404,
+          responseHttpStatus: 200,
+          responsePagePath: '/index.html',
+        },
+      ],
+      priceClass: cloudfront.PriceClass.PRICE_CLASS_100,
+    });
+
+    // Create a policy document for CloudFront invalidation
+    const cloudfrontPolicy = new iam.PolicyDocument({
+      statements: [
+        new iam.PolicyStatement({
+          actions: [
+            'cloudfront:CreateInvalidation',
+            'cloudfront:GetInvalidation',
+            'cloudfront:ListInvalidations',
+          ],
+          effect: iam.Effect.ALLOW,
+          resources: [distribution.distributionArn], // Ideally restrict to just this distribution
+        }),
+      ],
+    });
+
+    // Build and deploy React app
+    // Using environment variables for the build process
+    const frontendPath = path.join(__dirname, '../frontend');
+
+    // Deploy with pre-created execution role with CloudFront permissions
+    const customRole = new iam.Role(this, 'CloudFrontInvalidationRole', {
+      assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'),
+      inlinePolicies: {
+        'cloudfront-invalidation': cloudfrontPolicy,
+      },
+      managedPolicies: [
+        iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaBasicExecutionRole'),
+      ],
+    });
+
+    // Add S3 permissions to the role
+    customRole.addToPolicy(
+      new iam.PolicyStatement({
+        actions: [
+          's3:GetObject*',
+          's3:GetBucket*',
+          's3:List*',
+          's3:DeleteObject*',
+          's3:PutObject*',
+          's3:Abort*',
+        ],
+        resources: [websiteBucket.bucketArn, `${websiteBucket.bucketArn}/*`],
+      })
+    );
+
+    new s3deploy.BucketDeployment(this, 'DeployWebsite', {
+      sources: [s3deploy.Source.asset(path.join(frontendPath, 'dist'))],
+      destinationBucket: websiteBucket,
+      distribution,
+      distributionPaths: ['/*'],
+      role: customRole,
+      prune: true,
+      memoryLimit: 1024, // Increase memory for the deployment Lambda
+      useEfs: false, // Don't use EFS
+      retainOnDelete: false, // Clean up when deleting stack
+      logRetention: cdk.aws_logs.RetentionDays.ONE_WEEK,
+    });
+
+    // Output the CloudFront URL
+    new cdk.CfnOutput(this, 'WebsiteURL', {
+      value: `https://${distribution.distributionDomainName}`,
+      description: 'Website URL',
+    });
+
+    // Output the S3 bucket URL
+    new cdk.CfnOutput(this, 'S3BucketURL', {
+      value: websiteBucket.bucketWebsiteUrl,
+      description: 'S3 Bucket URL',
+    });
+  }
+}
diff --git a/package-lock.json b/package-lock.json
index ed5637e..f59ffc5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -290,14 +290,13 @@
       "license": "Apache-2.0"
     },
     "node_modules/@aws-cdk/cloud-assembly-schema": {
-      "version": "40.7.0",
-      "resolved": "https://registry.npmjs.org/@aws-cdk/cloud-assembly-schema/-/cloud-assembly-schema-40.7.0.tgz",
-      "integrity": "sha512-00wVKn9pOOGXbeNwA4E8FUFt0zIB4PmSO7PvIiDWgpaFX3G/sWyy0A3s6bg/n2Yvkghu8r4a8ckm+mAzkAYmfA==",
+      "version": "41.2.0",
+      "resolved": "https://registry.npmjs.org/@aws-cdk/cloud-assembly-schema/-/cloud-assembly-schema-41.2.0.tgz",
+      "integrity": "sha512-JaulVS6z9y5+u4jNmoWbHZRs9uGOnmn/ktXygNWKNu1k6lF3ad4so3s18eRu15XCbUIomxN9WPYT6Ehh7hzONw==",
       "bundleDependencies": [
         "jsonschema",
         "semver"
       ],
-      "license": "Apache-2.0",
       "dependencies": {
         "jsonschema": "~1.4.1",
         "semver": "^7.7.1"
@@ -6403,9 +6402,9 @@
       }
     },
     "node_modules/aws-cdk-lib": {
-      "version": "2.184.0",
-      "resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.184.0.tgz",
-      "integrity": "sha512-IC/38376PVCJ9cEsfklH0eBd5uwT7SKZxQJlfAd51TquZ7skpo7dt9oetrkUk4H+tsg4VcTF5B0ZbVZdDDP2yg==",
+      "version": "2.187.0",
+      "resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.187.0.tgz",
+      "integrity": "sha512-OrAWin+LD5sZhRF5cWuEYEkmC/sxxlgcAasCpfzeRsj6yDImwmeQsaKhM7xqzZQBInog6ZbN6oFZYiWEGJMSIA==",
       "bundleDependencies": [
         "@balena/dockerignore",
         "case",
@@ -6419,11 +6418,10 @@
         "yaml",
         "mime-types"
       ],
-      "license": "Apache-2.0",
       "dependencies": {
-        "@aws-cdk/asset-awscli-v1": "^2.2.227",
+        "@aws-cdk/asset-awscli-v1": "^2.2.229",
         "@aws-cdk/asset-node-proxy-agent-v6": "^2.1.0",
-        "@aws-cdk/cloud-assembly-schema": "^40.7.0",
+        "@aws-cdk/cloud-assembly-schema": "^41.0.0",
         "@balena/dockerignore": "^1.0.2",
         "case": "1.6.3",
         "fs-extra": "^11.3.0",
@@ -13131,9 +13129,9 @@
       "link": true
     },
     "node_modules/vite": {
-      "version": "6.2.3",
-      "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.3.tgz",
-      "integrity": "sha512-IzwM54g4y9JA/xAeBPNaDXiBF8Jsgl3VBQ2YQ/wOY6fyW3xMdSoltIV3Bo59DErdqdE6RxUfv8W69DvUorE4Eg==",
+      "version": "6.2.4",
+      "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.4.tgz",
+      "integrity": "sha512-veHMSew8CcRzhL5o8ONjy8gkfmFJAd5Ac16oxBUjlwgX3Gq2Wqr+qNC3TjPIpy7TPV/KporLga5GT9HqdrCizw==",
       "dev": true,
       "dependencies": {
         "esbuild": "^0.25.0",
diff --git a/package.json b/package.json
index e413aff..d1e7cdd 100644
--- a/package.json
+++ b/package.json
@@ -5,8 +5,6 @@
     "cdk_test": "bin/cdk_test.js"
   },
   "scripts": {
-    "build": "tsc",
-    "watch": "tsc -w",
     "test": "jest",
     "cdk": "cdk",
     "format": "prettier --write .",
@@ -16,6 +14,7 @@
     "frontend:dev": "npm run dev --workspace=frontend",
     "populate-dynamodb": "ts-node scripts/populate-dynamodb.ts",
     "deploy": "bash scripts/deploy.sh",
+    "deploy:with-frontend": "bash scripts/deploy.sh --deploy-frontend",
     "deploy:destroy": "bash scripts/deploy.sh --destroy",
     "deploy:help": "bash scripts/deploy.sh --help"
   },
diff --git a/scripts/deploy.sh b/scripts/deploy.sh
index fa91b3c..143f861 100755
--- a/scripts/deploy.sh
+++ b/scripts/deploy.sh
@@ -1,6 +1,9 @@
 #!/usr/bin/env bash
 set -e
 
+# Needed for AWS CDK
+mkdir -p frontend/dist
+
 # Color definitions
 RED='\033[0;31m'
 GREEN='\033[0;32m'
@@ -33,6 +36,7 @@ show_help() {
   echo -e "  ${BLUE}--region, -r${NC}     AWS region (default: $DEFAULT_REGION)"
   echo -e "  ${BLUE}--destroy, -d${NC}    Destroy stack instead of deploying"
   echo -e "  ${BLUE}--allow-prod${NC}     Allow deployment to production environment"
+  echo -e "  ${BLUE}--deploy-frontend${NC} Also build and deploy frontend (default: false)"
   echo -e "  ${BLUE}--help, -h${NC}       Show this help message"
   echo
   echo -e "${YELLOW}🌿 Environment Detection:${NC}"
@@ -85,6 +89,14 @@ while [[ $# -gt 0 ]]; do
       DESTROY=true
       shift
       ;;
+    --allow-prod)
+      ALLOW_PROD=true
+      shift
+      ;;
+    --deploy-frontend)
+      DEPLOY_FRONTEND=true
+      shift
+      ;;
     --help|-h)
       show_help
       ;;
@@ -144,14 +156,10 @@ CONTEXT_VALUES=(
   "--context region=${REGION}"
 )
 
-# Prepare for deployment
-# echo -e "${CYAN}🔨 Synthesizing stack for environment: ${BLUE}$ENV${NC} in region: ${BLUE}$REGION${NC}"
-# npx cdk synth --quiet ${CONTEXT_VALUES[*]}
-
 # Deploy or destroy based on flag
 if [ "$DESTROY" == "true" ]; then
   echo -e "${RED}🗑️  Destroying stack for environment: ${BLUE}$ENV${NC} in region: ${BLUE}$REGION${NC}"
-  npx cdk destroy --force ${CONTEXT_VALUES[*]} "ProfilesStack-${ENV}"
+  npx cdk destroy --force ${CONTEXT_VALUES[*]} --all
 else
   echo -e "${CYAN}🚀 Deploying stack for environment: ${BLUE}$ENV${NC} in region: ${BLUE}$REGION${NC}"
   
@@ -173,7 +181,7 @@ else
     fi
     DEPLOY_FLAGS=""
   fi
-  
+
   npx cdk deploy ${DEPLOY_FLAGS} --require-approval never ${CONTEXT_VALUES[*]} "ProfilesStack-${ENV}"
   
   # Get the API URL and key from CloudFormation exports
@@ -187,6 +195,13 @@ VITE_GRAPHQL_API_KEY=${API_KEY}
 EOL
   
   echo -e "${GREEN}✅ Created frontend/.env file with API configuration${NC}"
+  
+  if [ "$DEPLOY_FRONTEND" = true ]; then
+    echo -e "${CYAN}📦 Building and deploying frontend...${NC}"
+    npm run frontend:build
+    npx cdk deploy --require-approval never ${CONTEXT_VALUES[*]} "ProfilesStackFrontend-${ENV}"
+    echo -e "${GREEN}✅ Frontend deployment completed${NC}"
+  fi
 
   # Populate DynamoDB with sample data for development environments on first deployment
   if [[ "$ENV" =~ ^dev- ]] && [ "$FIRST_TIME" = true ]; then


Reviews:
user: login: sourcery-ai[bot]
body: Hey @RamMaths - I've reviewed your changes - here's some feedback:

**Overall Comments**:
- Consider adding a build step to the cicd workflow to ensure the frontend builds correctly before deploying.
- The deploy script mixes frontend and backend deployment logic; consider separating these concerns for clarity.

<details>
<summary>Here's what I looked at during the review</summary>

- 🟢 **General issues**: all looks good
- 🟢 **Security**: all looks good
- 🟢 **Testing**: all looks good
- 🟢 **Complexity**: all looks good
- 🟢 **Documentation**: all looks good
</details>

***

<details>
<summary>Sourcery is free for open source - if you like our reviews please consider sharing them ✨</summary>

- [X](https://twitter.com/intent/tweet?text=I%20just%20got%20an%20instant%20code%20review%20from%20%40SourceryAI%2C%20and%20it%20was%20brilliant%21%20It%27s%20free%20for%20open%20source%20and%20has%20a%20free%20trial%20for%20private%20code.%20Check%20it%20out%20https%3A//sourcery.ai)
- [Mastodon](https://mastodon.social/share?text=I%20just%20got%20an%20instant%20code%20review%20from%20%40SourceryAI%2C%20and%20it%20was%20brilliant%21%20It%27s%20free%20for%20open%20source%20and%20has%20a%20free%20trial%20for%20private%20code.%20Check%20it%20out%20https%3A//sourcery.ai)
- [LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https://sourcery.ai)
- [Facebook](https://www.facebook.com/sharer/sharer.php?u=https://sourcery.ai)

</details>

<sub>
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
</sub>
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T03:25:29Z
user: login: sebastianmarines
body: ```
Uncaught Invariant Violation: An error occurred! For more details, see the full error text at https://go.apollo.dev/c/err#%7B%22version%22%3A%223.13.3%22%2C%22message%22%3A58%2C%22args%22%3A%5B%5D%7D
    InvariantError2 invariant.ts:13
```

> Could not find "client" in the context or passed in as an option. Wrap the root component in an `<ApolloProvider>`, or pass an ApolloClient instance in via options.
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-04-03T03:52:54Z
user: login: sebastianmarines
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-04T00:17:31Z

Review comments:
diff_hunk: @@ -2,11 +2,13 @@ import { createRoot } from 'react-dom/client';
 import './index.css';
 import App from './App.tsx';
 import { BrowserRouter, Route, Routes } from 'react-router';
+import UserProfileRoute from './components/UserProfileRoute.tsx';
path: frontend/src/main.tsx
user: login: sebastianmarines
body: ```suggestion
import UserProfileRoute from './components/UserProfileRoute.tsx';
import { ApolloProvider } from '@apollo/client';
import { client } from './apollo-client';
```
created_at: 2025-04-03T03:47:56Z
author_association: MEMBER
diff_hunk: @@ -2,11 +2,13 @@ import { createRoot } from 'react-dom/client';
 import './index.css';
 import App from './App.tsx';
 import { BrowserRouter, Route, Routes } from 'react-router';
+import UserProfileRoute from './components/UserProfileRoute.tsx';
 
 createRoot(document.getElementById('root')!).render(
   <BrowserRouter>
path: frontend/src/main.tsx
user: login: sebastianmarines
body: ```suggestion
<ApolloProvider client={client}>
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<App />} />
        <Route path="/uid/:id" element={<UserProfileRoute />} />
      </Routes>
    </BrowserRouter>
  </ApolloProvider>
```
created_at: 2025-04-03T03:49:05Z
author_association: MEMBER

PR comments:
user: login: sourcery-ai[bot]
created_at: 2025-04-03T03:24:27Z
body: <!-- Generated by sourcery-ai[bot]: start review_guide -->

## Reviewer's Guide by Sourcery

This pull request introduces a user profile route, enhances the deployment process to include frontend deployments, and refactors the CDK application into separate backend and frontend stacks. The user profile route allows users to view profiles based on a short ID from the URL. The deployment script now supports building and deploying the frontend, and the CDK refactor improves separation of concerns and maintainability.

#### Sequence diagram for User Profile Retrieval

```mermaid
sequenceDiagram
    participant User
    participant UserProfileRoute
    participant UserProfile Component
    participant GraphQL API
    participant Database

    User->>UserProfileRoute: Navigates to /user/:id
    UserProfileRoute->>UserProfile Component: Renders UserProfile with initialId = id
    UserProfile Component->>GraphQL API: useQuery(GET_USER, { shortId: id })
    GraphQL API->>Database: Fetches user data by shortId
    Database-->>GraphQL API: Returns user data
    GraphQL API-->>UserProfile Component: Returns user data
    UserProfile Component->>User: Displays user profile
```

#### Updated class diagram for UserProfile Component

```mermaid
classDiagram
    class UserProfile {
        -shortId: string
        -isValidId: boolean
        +UserProfile(initialId: string)
        +render()
    }
    note for UserProfile "Renders user profile based on shortId from URL parameters."
```

### File-Level Changes

| Change | Details | Files |
| ------ | ------- | ----- |
| Introduced a new route for displaying user profiles based on a short ID obtained from the URL parameters. | <ul><li>Created a new `UserProfileRoute.tsx` component to extract the `id` parameter from the URL.</li><li>Render the `UserProfile` component within the new route, passing the `id` as the `initialId` prop.</li><li>Updated `main.tsx` to include the new `UserProfileRoute`.</li><li>Modified the `UserProfile` component to accept an `initialId` prop and use it to set the initial state of the `shortId` state variable.</li><li>Added validation for the `shortId` to ensure it's not empty before making the GraphQL query.</li><li>Displayed an error message if the `shortId` is invalid.</li></ul> | `frontend/src/components/UserProfileRoute.tsx`<br/>`frontend/src/main.tsx`<br/>`frontend/src/components/UserProfile.tsx` |
| Modified the deployment script to support frontend deployments and environment cleanup. | <ul><li>Added a `--deploy-frontend` flag to the deployment script to trigger frontend builds and deployments.</li><li>Added logic to update an existing comment on PRs, instead of creating a new one, when a deployment occurs.</li><li>Modified the deployment script to include frontend stack deployment.</li><li>Updated the cleanup script to properly destroy the frontend stack.</li><li>Added a check for an existing deployment comment by the bot and update it if it exists.</li><li>Added a note to the cleanup comment to indicate that the environment has been cleaned up and destroyed.</li></ul> | `.github/workflows/cicd.yml`<br/>`scripts/deploy.sh` |
| Refactored the CDK application to separate the backend and frontend stacks. | <ul><li>Created a new `FrontendStack` class to manage frontend resources.</li><li>Modified the `AppStack` class to `BackendStack` and updated its responsibilities.</li><li>Updated the CDK entry point to instantiate both `BackendStack` and `FrontendStack`.</li><li>Exposed the API URL and API Key from the `BackendStack` to be used by the `FrontendStack`.</li><li>Added tags to the stacks.</li><li>Configured the frontend stack to use the API URL and API key from the backend stack.</li></ul> | `bin/cdk_test.ts`<br/>`lib/backend-stack.ts`<br/>`lib/frontend-stack.ts` |

---

<details>
<summary>Tips and commands</summary>

#### Interacting with Sourcery

- **Trigger a new review:** Comment `@sourcery-ai review` on the pull request.
- **Continue discussions:** Reply directly to Sourcery's review comments.
- **Generate a GitHub issue from a review comment:** Ask Sourcery to create an
  issue from a review comment by replying to it. You can also reply to a
  review comment with `@sourcery-ai issue` to create an issue from it.
- **Generate a pull request title:** Write `@sourcery-ai` anywhere in the pull
  request title to generate a title at any time. You can also comment
  `@sourcery-ai title` on the pull request to (re-)generate the title at any time.
- **Generate a pull request summary:** Write `@sourcery-ai summary` anywhere in
  the pull request body to generate a PR summary at any time exactly where you
  want it. You can also comment `@sourcery-ai summary` on the pull request to
  (re-)generate the summary at any time.
- **Generate reviewer's guide:** Comment `@sourcery-ai guide` on the pull
  request to (re-)generate the reviewer's guide at any time.
- **Resolve all Sourcery comments:** Comment `@sourcery-ai resolve` on the
  pull request to resolve all Sourcery comments. Useful if you've already
  addressed all the comments and don't want to see them anymore.
- **Dismiss all Sourcery reviews:** Comment `@sourcery-ai dismiss` on the pull
  request to dismiss all existing Sourcery reviews. Especially useful if you
  want to start fresh with a new review - don't forget to comment
  `@sourcery-ai review` to trigger a new review!
- **Generate a plan of action for an issue:** Comment `@sourcery-ai plan` on
  an issue to generate a plan of action for it.

#### Customizing Your Experience

Access your [dashboard](https://app.sourcery.ai) to:
- Enable or disable review features such as the Sourcery-generated pull request
  summary, the reviewer's guide, and others.
- Change the review language.
- Add, remove or edit custom review instructions.
- Adjust other review settings.

#### Getting Help

- [Contact our support team](mailto:support@sourcery.ai) for questions or feedback.
- Visit our [documentation](https://docs.sourcery.ai) for detailed guides and information.
- Keep in touch with the Sourcery team by following us on [X/Twitter](https://x.com/SourceryAI), [LinkedIn](https://www.linkedin.com/company/sourcery-ai/) or [GitHub](https://github.com/sourcery-ai).

</details>

<!-- Generated by sourcery-ai[bot]: end review_guide -->
author_association: CONTRIBUTOR
user: login: sebastianmarines
created_at: 2025-04-03T03:50:05Z
body: Preview URL: https://dop18dsqjviwb.cloudfront.net/
author_association: MEMBER
user: login: ramtoearth
created_at: 2025-04-03T21:08:46Z
body: I corrected the changes suggested. Also I added a new component `Layout.tsx` and `main.tsx` has been modified as follows to share the layout of the page with children components:

```tsx
// main.tsx
...
<Routes>
  <Route path="/" element={<Layout />}>
    <Route index element={<App />} />
    <Route path="/uid/:id" element={<UserProfileRoute />} />
  </Route>
</Routes>
...
```

preview URL: https://d3u51inbad6957.cloudfront.net/


author_association: COLLABORATOR