diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml new file mode 100644 index 0000000..8930c0c --- /dev/null +++ b/.github/workflows/ci-cd.yml @@ -0,0 +1,57 @@ +name: ProjectLens CI/CD + +# Trigger the workflow on push or pull request to the main branch +on: + pull_request: + branches: [ main ] + +jobs: + # Job 1: Build and Test the Backend (NestJS) + backend-ci: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + cache-dependency-path: backend/package-lock.json + + - name: Install Dependencies + run: npm install + working-directory: backend + + - name: Lint + run: npm run lint + working-directory: backend + + - name: Run Tests + run: npm run test + working-directory: backend + + - name: Build + run: npm run build + working-directory: backend + + # Job 2: Build and Test the Frontend (Next.js) + frontend-ci: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + cache-dependency-path: frontend/package-lock.json + + - name: Install Dependencies + run: npm install + working-directory: frontend + + - name: Build + run: npm run build + working-directory: frontend + env: + NEXT_PUBLIC_API_URL: http://localhost:4000 diff --git a/README.md b/README.md index 710e0db..3e2917e 100644 Binary files a/README.md and b/README.md differ diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..c2ce66a --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,38 @@ +# --- Build Stage --- +# Use Node.js 22 Alpine for a lightweight build environment +FROM node:22-alpine AS builder + +WORKDIR /app + +# Copy configuration files for dependency installation +COPY package*.json ./ +RUN npm install + +# Copy source code and build the application +COPY . . +RUN npm run build + +# --- Runtime Stage --- +# Use a fresh slim image for the production environment +FROM node:22-alpine + +WORKDIR /app + +# Copy only the necessary files from the builder stage +COPY --from=builder /app/package*.json ./ +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/dist ./dist +# Include src for the seed script (in a real prod app, we might bundle this differently) +COPY --from=builder /app/src/seed.ts ./src/seed.ts + +# Documentation: Port 4000 is the default for our NestJS API +EXPOSE 4000 + +# Documentation: Port 4000 is the default for our NestJS API +EXPOSE 4000 + +# Documentation: Environment variables for MongoDB +ENV MONGODB_URI=mongodb://mongodb:27017/projectlens + +# Start the application +CMD ["node", "dist/main"] diff --git a/backend/package-lock.json b/backend/package-lock.json index a271737..54138f5 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -10,6 +10,7 @@ "license": "UNLICENSED", "dependencies": { "@nestjs/common": "^11.0.1", + "@nestjs/config": "^4.0.3", "@nestjs/core": "^11.0.1", "@nestjs/mongoose": "^11.0.4", "@nestjs/platform-express": "^11.0.1", @@ -2322,6 +2323,21 @@ } } }, + "node_modules/@nestjs/config": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@nestjs/config/-/config-4.0.3.tgz", + "integrity": "sha512-FQ3M3Ohqfl+nHAn5tp7++wUQw0f2nAk+SFKe8EpNRnIifPqvfJP6JQxPKtFLMOHbyer4X646prFG4zSRYEssQQ==", + "license": "MIT", + "dependencies": { + "dotenv": "17.2.3", + "dotenv-expand": "12.0.3", + "lodash": "4.17.23" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "rxjs": "^7.1.0" + } + }, "node_modules/@nestjs/core": { "version": "11.1.17", "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.17.tgz", @@ -4965,6 +4981,45 @@ "node": ">=0.3.1" } }, + "node_modules/dotenv": { + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", + "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-12.0.3.tgz", + "integrity": "sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==", + "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand/node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", diff --git a/backend/package.json b/backend/package.json index 1bb4da9..7019638 100644 --- a/backend/package.json +++ b/backend/package.json @@ -21,6 +21,7 @@ }, "dependencies": { "@nestjs/common": "^11.0.1", + "@nestjs/config": "^4.0.3", "@nestjs/core": "^11.0.1", "@nestjs/mongoose": "^11.0.4", "@nestjs/platform-express": "^11.0.1", diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 8a2541a..54e9806 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { MongooseModule } from '@nestjs/mongoose'; +import { ConfigModule, ConfigService } from '@nestjs/config'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { ProjectsModule } from './projects/projects.module'; @@ -7,7 +8,18 @@ import { TasksModule } from './tasks/tasks.module'; @Module({ imports: [ - MongooseModule.forRoot('mongodb://localhost:27017/projectlens'), + ConfigModule.forRoot({ + isGlobal: true, + }), + MongooseModule.forRootAsync({ + imports: [ConfigModule], + useFactory: (configService: ConfigService) => ({ + uri: + configService.get('MONGODB_URI') || + 'mongodb://localhost:27017/projectlens', + }), + inject: [ConfigService], + }), ProjectsModule, TasksModule, ], diff --git a/backend/src/main.ts b/backend/src/main.ts index f42ffd6..212c170 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -8,11 +8,13 @@ async function bootstrap() { app.enableCors(); // Enable CORS for development - app.useGlobalPipes(new ValidationPipe({ - whitelist: true, - forbidNonWhitelisted: true, - transform: true, - })); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ); const config = new DocumentBuilder() .setTitle('ProjectLens API') @@ -26,4 +28,7 @@ async function bootstrap() { await app.listen(process.env.PORT ?? 4000); } -bootstrap(); +void bootstrap().catch((err) => { + console.error('Error starting application:', err); + process.exit(1); +}); diff --git a/backend/src/projects/dto/create-project.dto.ts b/backend/src/projects/dto/create-project.dto.ts index 5201e01..e1d6b5d 100644 --- a/backend/src/projects/dto/create-project.dto.ts +++ b/backend/src/projects/dto/create-project.dto.ts @@ -7,7 +7,10 @@ export class CreateProjectDto { @IsNotEmpty() name: string; - @ApiProperty({ description: 'The description of the project', required: false }) + @ApiProperty({ + description: 'The description of the project', + required: false, + }) @IsString() @IsOptional() description?: string; diff --git a/backend/src/projects/projects.controller.ts b/backend/src/projects/projects.controller.ts index b019840..491b297 100644 --- a/backend/src/projects/projects.controller.ts +++ b/backend/src/projects/projects.controller.ts @@ -1,5 +1,5 @@ import { Controller, Get, Post, Body, Param } from '@nestjs/common'; -import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { ApiTags } from '@nestjs/swagger'; import { ProjectService } from './projects.service'; import { CreateProjectDto } from './dto/create-project.dto'; diff --git a/backend/src/projects/projects.service.spec.ts b/backend/src/projects/projects.service.spec.ts index 9bd1bd8..e16888b 100644 --- a/backend/src/projects/projects.service.spec.ts +++ b/backend/src/projects/projects.service.spec.ts @@ -1,9 +1,9 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-argument */ import { Test, TestingModule } from '@nestjs/testing'; import { ProjectService } from './projects.service'; import { ProjectRepository } from './repositories/project.repository'; import { TaskRepository } from '../tasks/repositories/task.repository'; import { TaskStatus } from '../tasks/schemas/task.schema'; -import { NotFoundException } from '@nestjs/common'; import { describe, beforeEach, it, expect, jest } from '@jest/globals'; describe('ProjectService', () => { diff --git a/backend/src/projects/projects.service.ts b/backend/src/projects/projects.service.ts index 4081eed..f5ad34a 100644 --- a/backend/src/projects/projects.service.ts +++ b/backend/src/projects/projects.service.ts @@ -42,7 +42,9 @@ export class ProjectService { }; } - const completedTasks = tasks.filter((t) => t.status === TaskStatus.COMPLETED); + const completedTasks = tasks.filter( + (t) => t.status === TaskStatus.COMPLETED, + ); const totalTasksCount = tasks.length; const completedTasksCount = completedTasks.length; diff --git a/backend/src/projects/repositories/project.repository.ts b/backend/src/projects/repositories/project.repository.ts index 9053d94..374e3c2 100644 --- a/backend/src/projects/repositories/project.repository.ts +++ b/backend/src/projects/repositories/project.repository.ts @@ -5,7 +5,9 @@ import { Project } from '../schemas/project.schema'; @Injectable() export class ProjectRepository { - constructor(@InjectModel(Project.name) private projectModel: Model) {} + constructor( + @InjectModel(Project.name) private projectModel: Model, + ) {} async create(projectData: any): Promise { const newProject = new this.projectModel(projectData); diff --git a/backend/src/seed.ts b/backend/src/seed.ts index 35ea7c7..88475ec 100644 --- a/backend/src/seed.ts +++ b/backend/src/seed.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access */ import mongoose from 'mongoose'; const mongoUri = 'mongodb://localhost:27017/projectlens'; @@ -7,14 +8,18 @@ const ProjectSchema = new mongoose.Schema({ description: String, status: { type: String, default: 'ACTIVE' }, isTeamFocus: { type: Boolean, default: false }, - createdAt: { type: Date, default: Date.now } + createdAt: { type: Date, default: Date.now }, }); const TaskSchema = new mongoose.Schema({ name: String, - status: { type: String, enum: ['PENDING', 'COMPLETED', 'IN_PROGRESS'], default: 'PENDING' }, + status: { + type: String, + enum: ['PENDING', 'COMPLETED', 'IN_PROGRESS'], + default: 'PENDING', + }, projectId: mongoose.Schema.Types.ObjectId, - completedAt: Date + completedAt: Date, }); const Project = mongoose.model('Project', ProjectSchema); @@ -30,41 +35,46 @@ async function seed() { await Task.deleteMany({}); console.log('Cleared existing data'); - const projects = [ - { - name: 'ProjectLens Core API', - description: 'Main GraphQL and REST infrastructure for the ProjectLens ecosystem. Sprint 04 in progress.', + const projects: any[] = [ + { + name: 'ProjectLens Core API', + description: + 'Main GraphQL and REST infrastructure for the ProjectLens ecosystem. Sprint 04 in progress.', status: 'ACTIVE', - isTeamFocus: true + isTeamFocus: true, }, - { - name: 'Mobile App Redesign', - description: 'Transitioning to React Native with a focus on gesture-driven navigation and offline-first data sync.', + { + name: 'Mobile App Redesign', + description: + 'Transitioning to React Native with a focus on gesture-driven navigation and offline-first data sync.', status: 'ACTIVE', - isTeamFocus: false + isTeamFocus: false, }, - { - name: 'Cloud Migration Prep', - description: 'Historical audit of infrastructure in preparation for the multi-cloud migration strategy.', + { + name: 'Cloud Migration Prep', + description: + 'Historical audit of infrastructure in preparation for the multi-cloud migration strategy.', status: 'ARCHIVED', - isTeamFocus: false + isTeamFocus: false, }, - { - name: 'Legacy UI Cleanup', - description: 'Deprecation and cleanup of the jQuery-based dashboard components.', + { + name: 'Legacy UI Cleanup', + description: + 'Deprecation and cleanup of the jQuery-based dashboard components.', status: 'ARCHIVED', - isTeamFocus: false + isTeamFocus: false, }, - { - name: 'Security Audit 2026', - description: 'Quarterly pen-testing and vulnerability assessment of the core authentication gateway.', + { + name: 'Security Audit 2026', + description: + 'Quarterly pen-testing and vulnerability assessment of the core authentication gateway.', status: 'ACTIVE', - isTeamFocus: true - } + isTeamFocus: true, + }, ]; for (const p of projects) { - const project = await Project.create(p); + const project: any = await Project.create(p); console.log(`Created project: ${project.name}`); // Special case: Legacy UI Cleanup (Archived, no tasks) @@ -76,8 +86,8 @@ async function seed() { // Special case: Cloud Migration Prep (Archived, low progress) const isLowProgress = p.name === 'Cloud Migration Prep'; const taskCount = isLowProgress ? 12 : 8; - - const tasks = Array.from({ length: taskCount }).map((_, i) => { + + const tasks: any[] = Array.from({ length: taskCount }).map((_, i) => { let status = 'PENDING'; let completedAt: Date | null = null; @@ -103,7 +113,7 @@ async function seed() { name: `${['Research', 'Design', 'Architecture', 'Coding', 'Integration', 'Review', 'Testing', 'QA', 'Staging', 'Security', 'Doc', 'Launch'][i % 12]} for ${project.name}`, status, projectId: project._id, - completedAt + completedAt, }; }); @@ -120,4 +130,10 @@ async function seed() { } } -seed(); +void (async () => { + try { + await seed(); + } catch { + process.exit(1); + } +})(); diff --git a/backend/src/tasks/dto/task.dto.ts b/backend/src/tasks/dto/task.dto.ts index 34baadc..06a8894 100644 --- a/backend/src/tasks/dto/task.dto.ts +++ b/backend/src/tasks/dto/task.dto.ts @@ -1,4 +1,10 @@ -import { IsString, IsNotEmpty, IsOptional, IsEnum, IsMongoId } from 'class-validator'; +import { + IsString, + IsNotEmpty, + IsOptional, + IsEnum, + IsMongoId, +} from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; import { TaskStatus } from '../schemas/task.schema'; @@ -8,7 +14,11 @@ export class CreateTaskDto { @IsNotEmpty() name: string; - @ApiProperty({ enum: TaskStatus, required: false, default: TaskStatus.PENDING }) + @ApiProperty({ + enum: TaskStatus, + required: false, + default: TaskStatus.PENDING, + }) @IsEnum(TaskStatus) @IsOptional() status?: TaskStatus; diff --git a/backend/src/tasks/repositories/task.repository.ts b/backend/src/tasks/repositories/task.repository.ts index 3ebb506..ff89e8f 100644 --- a/backend/src/tasks/repositories/task.repository.ts +++ b/backend/src/tasks/repositories/task.repository.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; -import { Model } from 'mongoose'; +import { Model, UpdateQuery } from 'mongoose'; import { Task } from '../schemas/task.schema'; @Injectable() @@ -20,7 +20,12 @@ export class TaskRepository { return this.taskModel.findById(id).exec(); } - async update(id: string, updateData: any): Promise { - return this.taskModel.findByIdAndUpdate(id, updateData, { new: true }).exec(); + async update( + id: string, + updateData: UpdateQuery, + ): Promise { + return this.taskModel + .findByIdAndUpdate(id, updateData, { new: true }) + .exec(); } } diff --git a/backend/src/tasks/tasks.controller.ts b/backend/src/tasks/tasks.controller.ts index f7c7eac..64911fe 100644 --- a/backend/src/tasks/tasks.controller.ts +++ b/backend/src/tasks/tasks.controller.ts @@ -1,5 +1,13 @@ -import { Controller, Post, Body, Patch, Param, Get, Query } from '@nestjs/common'; -import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { + Controller, + Post, + Body, + Patch, + Param, + Get, + Query, +} from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; import { TaskService } from './tasks.service'; import { CreateTaskDto } from './dto/task.dto'; @@ -19,7 +27,7 @@ export class TaskController { return this.taskService.findByProject(projectId); } // Return all tasks if no projectId is provided - return []; + return []; } @Patch(':id/complete') diff --git a/backend/src/tasks/tasks.service.ts b/backend/src/tasks/tasks.service.ts index 930d119..1e7e1e9 100644 --- a/backend/src/tasks/tasks.service.ts +++ b/backend/src/tasks/tasks.service.ts @@ -23,7 +23,7 @@ export class TaskService { }); if (!updatedTask) { - throw new NotFoundException(`Task with ID ${id} could not be updated`); + throw new NotFoundException(`Task with ID ${id} could not be updated`); } return updatedTask; diff --git a/backend/test/app-db.e2e-spec.ts b/backend/test/app-db.e2e-spec.ts deleted file mode 100644 index ac5d776..0000000 --- a/backend/test/app-db.e2e-spec.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { INestApplication } from '@nestjs/common'; -import request from 'supertest'; -import { AppModule } from './../src/app.module'; -import { Connection } from 'mongoose'; -import { getConnectionToken } from '@nestjs/mongoose'; - -describe('Database Connectivity and Aggregation (e2e)', () => { - let app: INestApplication; - let connection: Connection; - - beforeAll(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModule], - }).compile(); - - app = moduleFixture.createNestApplication(); - await app.init(); - connection = moduleFixture.get(getConnectionToken()); - }); - - afterAll(async () => { - // Clean up after tests - await connection.dropDatabase(); - await app.close(); - }); - - it('should verify database connection', () => { - expect(connection.readyState).toBe(1); // 1 means connected - }); - - it('should create a project, add tasks, and get correct metrics', async () => { - // 1. Create Project - const projectResponse = await request(app.getHttpServer()) - .post('/projects') - .send({ name: 'E2E Project', description: 'Testing metrics' }) - .expect(201); - - const projectId = projectResponse.body._id; - - // 2. Create 2 tasks (one completed, one pending) - const task1Response = await request(app.getHttpServer()) - .post('/tasks') - .send({ name: 'Task 1', projectId }) - .expect(201); - - await request(app.getHttpServer()) - .post('/tasks') - .send({ name: 'Task 2', projectId }) - .expect(201); - - // 3. Complete Task 1 - await request(app.getHttpServer()) - .patch(`/tasks/${task1Response.body._id}/complete`) - .expect(200); - - // 4. Verify Metrics - const metricsResponse = await request(app.getHttpServer()) - .get(`/projects/${projectId}/metrics`) - .expect(200); - - expect(metricsResponse.body.progress).toBe(50); - expect(metricsResponse.body.totalTasks).toBe(2); - expect(metricsResponse.body.completedTasks).toBe(1); - expect(metricsResponse.body.averageCompletionTimeMinutes).toBeGreaterThanOrEqual(0); - }); -}); diff --git a/docker-compose.yml b/docker-compose.yml index 2cb675f..953b524 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,13 +1,66 @@ services: + # MongoDB Database Service + # We use the official Mongo 8.0 image for state-of-the-art performance mongodb: image: mongo:8.0 container_name: projectlens-mongodb ports: - - "27017:27017" + - "27017:27017" # Exposed for local debugging tools (Compass, etc.) volumes: - - mongodb_data:/data/db + - mongodb_data:/data/db # Persistence for our projects and tasks environment: - MONGO_INITDB_DATABASE: projectlens + MONGO_INITDB_DATABASE: projectlens # Default database created on start + # Backend API Service (NestJS) + # Built from the documented multi-stage Dockerfile in the /backend directory + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: projectlens-backend + ports: + - "4000:4000" + environment: + - MONGODB_URI=mongodb://mongodb:27017/projectlens + depends_on: + - mongodb # Ensure database is up before starting the API + + # Ephemeral Seed Service + # Runs once at startup to populate the database and then exits. + # Note: To skip seeding, you can comment out this service. + seed: + build: + context: ./backend + dockerfile: Dockerfile + container_name: projectlens-seed + environment: + - MONGODB_URI=mongodb://mongodb:27017/projectlens + command: npx ts-node src/seed.ts + depends_on: + - mongodb + + # Frontend Application (Next.js) + # Built from the ./frontend directory, optimized for production + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + args: + # Build-time argument for NEXT_PUBLIC variables + # Note: We use localhost:4000 because this is a 'use client' app. + # The browser (outside Docker) must reach the API via the exposed port on localhost. + - NEXT_PUBLIC_API_URL=http://localhost:4000 + container_name: projectlens-frontend + ports: + - "3000:3000" + environment: + # Client-side: Browser needs to reach the API at localhost:4000 + - NEXT_PUBLIC_API_URL=http://localhost:4000 + # Server-side (Optional): If using SSR fetching inside Docker, use http://backend:4000 + - INTERNAL_API_URL=http://backend:4000 + depends_on: + - backend # Ensure the API is ready for SSR and client calls + +# Named volume for database persistence across container restarts volumes: mongodb_data: diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..58bc5b2 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,35 @@ +# --- Build Stage --- +# Use Node.js 22 Alpine for consistent build environment +FROM node:22-alpine AS builder + +WORKDIR /app + +# Install dependencies based on package-lock.json +COPY package*.json ./ +RUN npm install + +# Copy project files and build for production +COPY . . +# NEXT_PUBLIC_API_URL must be available at build time for client-side environment variables +ARG NEXT_PUBLIC_API_URL=http://localhost:4000 +ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL + +RUN npm run build + +# --- Runtime Stage --- +# Using a lightweight Node image for serving the app +FROM node:22-alpine + +WORKDIR /app + +# Copy production build and dependencies +COPY --from=builder /app/package*.json ./ +COPY --from=builder /app/.next ./.next +COPY --from=builder /app/public ./public +COPY --from=builder /app/node_modules ./node_modules + +# Documentation: Next.js default port is 3000 +EXPOSE 3000 + +# Start the application in production mode +CMD ["npm", "start"]