diff --git a/Dockerfile b/Dockerfile index d9706e88..bfe8cf1e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,28 +12,24 @@ RUN npm run build # Stage 2: Production FROM node:18-alpine AS production -# Install dumb-init for proper signal handling -RUN apk add --no-cache dumb-init \ - && rm -rf /var/cache/apk/* - +ENV NODE_ENV=production WORKDIR /app -# Install production dependencies as root before switching user +RUN apk add --no-cache dumb-init curl + COPY package.json package-lock.json ./ -RUN npm ci --only=production --ignore-scripts \ - && npm cache clean --force +RUN npm ci --omit=dev --ignore-scripts COPY --from=builder /app/dist ./dist -# Create writable tmp dir for the app, then lock down ownership RUN mkdir -p /app/tmp && chown -R node:node /app -# Drop to non-root user USER node EXPOSE 3000 -# no-new-privileges enforced at runtime via security_opt in compose; -# dumb-init ensures SIGTERM is forwarded to the Node process +HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ + CMD curl -f http://localhost:3000/health || exit 1 + ENTRYPOINT ["dumb-init", "--"] -CMD ["node", "dist/main.js"] \ No newline at end of file +CMD ["node", "dist/main.js"] diff --git a/charts/teachlink-backend/.helmignore b/charts/teachlink-backend/.helmignore new file mode 100644 index 00000000..66a3d700 --- /dev/null +++ b/charts/teachlink-backend/.helmignore @@ -0,0 +1,8 @@ +*.tgz +*.bak +*.swp +.DS_Store +.git +.gitignore +node_modules +README.md diff --git a/charts/teachlink-backend/Chart.yaml b/charts/teachlink-backend/Chart.yaml new file mode 100644 index 00000000..f6cd61c1 --- /dev/null +++ b/charts/teachlink-backend/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: teachlink-backend +description: A Helm chart for deploying the TeachLink backend service +type: application +version: 0.1.0 +appVersion: "0.0.1" diff --git a/charts/teachlink-backend/templates/_helpers.tpl b/charts/teachlink-backend/templates/_helpers.tpl new file mode 100644 index 00000000..11109982 --- /dev/null +++ b/charts/teachlink-backend/templates/_helpers.tpl @@ -0,0 +1,30 @@ +{{- define "teachlink-backend.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "teachlink-backend.fullname" -}} +{{- if .Values.fullnameOverride -}} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- printf "%s-%s" (include "teachlink-backend.name" .) .Release.Name | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} + +{{- define "teachlink-backend.labels" -}} +helm.sh/chart: {{ include "teachlink-backend.chart" . }} +app.kubernetes.io/name: {{ include "teachlink-backend.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/version: {{ .Chart.AppVersion }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end -}} + +{{- define "teachlink-backend.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version -}} +{{- end -}} + +{{- define "teachlink-backend.env" -}} +{{- range $index, $env := .Values.env }} +- name: {{ $env.name }} + value: {{ $env.value | quote }} +{{- end -}} +{{- end -}} diff --git a/charts/teachlink-backend/templates/configmap.yaml b/charts/teachlink-backend/templates/configmap.yaml new file mode 100644 index 00000000..76e65731 --- /dev/null +++ b/charts/teachlink-backend/templates/configmap.yaml @@ -0,0 +1,26 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "teachlink-backend.fullname" . }}-config + namespace: {{ .Release.Namespace }} + labels: + {{- include "teachlink-backend.labels" . | nindent 4 }} +data: + NODE_ENV: {{ .Values.config.nodeEnv | quote }} + PORT: "3000" + DATABASE_HOST: {{ .Values.config.databaseHost | quote }} + DATABASE_PORT: {{ .Values.config.databasePort | quote }} + DATABASE_NAME: {{ .Values.config.databaseName | quote }} + DATABASE_POOL_MAX: {{ .Values.config.databasePoolMax | quote }} + DATABASE_POOL_MIN: {{ .Values.config.databasePoolMin | quote }} + REDIS_HOST: {{ .Values.config.redisHost | quote }} + REDIS_PORT: {{ .Values.config.redisPort | quote }} + ELASTICSEARCH_NODE: {{ .Values.config.elasticsearchNode | quote }} + JWT_EXPIRES_IN: {{ .Values.config.jwtExpiresIn | quote }} + JWT_REFRESH_EXPIRES_IN: {{ .Values.config.jwtRefreshExpiresIn | quote }} + BCRYPT_ROUNDS: {{ .Values.config.bcryptRounds | quote }} + APP_URL: {{ .Values.config.appUrl | quote }} + CORS_ALLOWED_ORIGINS: {{ .Values.config.corsAllowedOrigins | quote }} + TRUST_PROXY: {{ .Values.config.trustProxy | quote }} + API_DEFAULT_VERSION: {{ .Values.config.apiDefaultVersion | quote }} + API_SUPPORTED_VERSIONS: {{ .Values.config.apiSupportedVersions | quote }} diff --git a/charts/teachlink-backend/templates/deployment.yaml b/charts/teachlink-backend/templates/deployment.yaml new file mode 100644 index 00000000..eda8437b --- /dev/null +++ b/charts/teachlink-backend/templates/deployment.yaml @@ -0,0 +1,77 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "teachlink-backend.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "teachlink-backend.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + app.kubernetes.io/name: {{ include "teachlink-backend.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + template: + metadata: + labels: + app.kubernetes.io/name: {{ include "teachlink-backend.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + securityContext: + runAsNonRoot: true + runAsUser: 1000 + fsGroup: 1000 + containers: + - name: {{ include "teachlink-backend.name" . }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + ports: + - name: http + containerPort: 3000 + envFrom: + - configMapRef: + name: {{ include "teachlink-backend.fullname" . }}-config + - secretRef: + name: {{ include "teachlink-backend.fullname" . }}-secret + resources: + requests: + cpu: {{ .Values.resources.requests.cpu }} + memory: {{ .Values.resources.requests.memory }} + limits: + cpu: {{ .Values.resources.limits.cpu }} + memory: {{ .Values.resources.limits.memory }} + livenessProbe: + httpGet: + path: /health + port: 3000 + initialDelaySeconds: 15 + periodSeconds: 20 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /health + port: 3000 + initialDelaySeconds: 5 + periodSeconds: 10 + failureThreshold: 3 + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/charts/teachlink-backend/templates/hpa.yaml b/charts/teachlink-backend/templates/hpa.yaml new file mode 100644 index 00000000..1d8c8a64 --- /dev/null +++ b/charts/teachlink-backend/templates/hpa.yaml @@ -0,0 +1,42 @@ +{{- if .Values.autoscaling.enabled -}} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "teachlink-backend.fullname" . }}-hpa + namespace: {{ .Release.Namespace }} + labels: + {{- include "teachlink-backend.labels" . | nindent 4 }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "teachlink-backend.fullname" . }} + minReplicas: {{ .Values.autoscaling.minReplicas }} + maxReplicas: {{ .Values.autoscaling.maxReplicas }} + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }} + behavior: + scaleUp: + stabilizationWindowSeconds: 60 + policies: + - type: Pods + value: 2 + periodSeconds: 60 + scaleDown: + stabilizationWindowSeconds: 300 + policies: + - type: Pods + value: 1 + periodSeconds: 120 +{{- end -}} diff --git a/charts/teachlink-backend/templates/ingress.yaml b/charts/teachlink-backend/templates/ingress.yaml new file mode 100644 index 00000000..f54f7579 --- /dev/null +++ b/charts/teachlink-backend/templates/ingress.yaml @@ -0,0 +1,28 @@ +{{- if .Values.ingress.enabled -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "teachlink-backend.fullname" . }} + labels: + {{- include "teachlink-backend.labels" . | nindent 4 }} + annotations: + {{- toYaml .Values.ingress.annotations | nindent 4 }} +spec: + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + pathType: {{ .pathType }} + backend: + service: + name: {{ include "teachlink-backend.fullname" $ }} + port: + number: {{ $.Values.service.port }} + {{- end }} + {{- end }} + tls: + {{- toYaml .Values.ingress.tls | nindent 4 }} +{{- end -}} diff --git a/charts/teachlink-backend/templates/secret.yaml b/charts/teachlink-backend/templates/secret.yaml new file mode 100644 index 00000000..b33ebd99 --- /dev/null +++ b/charts/teachlink-backend/templates/secret.yaml @@ -0,0 +1,22 @@ +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "teachlink-backend.fullname" . }}-secret + namespace: {{ .Release.Namespace }} + labels: + {{- include "teachlink-backend.labels" . | nindent 4 }} +type: Opaque +stringData: + DATABASE_USER: {{ .Values.secrets.databaseUser | quote }} + DATABASE_PASSWORD: {{ .Values.secrets.databasePassword | quote }} + JWT_SECRET: {{ .Values.secrets.jwtSecret | quote }} + JWT_REFRESH_SECRET: {{ .Values.secrets.jwtRefreshSecret | quote }} + ENCRYPTION_SECRET: {{ .Values.secrets.encryptionSecret | quote }} + SESSION_SECRET: {{ .Values.secrets.sessionSecret | quote }} + STRIPE_SECRET_KEY: {{ .Values.secrets.stripeSecretKey | quote }} + STRIPE_WEBHOOK_SECRET: {{ .Values.secrets.stripeWebhookSecret | quote }} + AWS_ACCESS_KEY_ID: {{ .Values.secrets.awsAccessKeyId | quote }} + AWS_SECRET_ACCESS_KEY: {{ .Values.secrets.awsSecretAccessKey | quote }} + AWS_REGION: {{ .Values.secrets.awsRegion | quote }} + AWS_S3_BUCKET_NAME: {{ .Values.secrets.awsS3BucketName | quote }} + SENDGRID_API_KEY: {{ .Values.secrets.sendgridApiKey | quote }} diff --git a/charts/teachlink-backend/templates/service.yaml b/charts/teachlink-backend/templates/service.yaml new file mode 100644 index 00000000..80385fa8 --- /dev/null +++ b/charts/teachlink-backend/templates/service.yaml @@ -0,0 +1,17 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "teachlink-backend.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "teachlink-backend.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + selector: + app.kubernetes.io/name: {{ include "teachlink-backend.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + ports: + - name: http + port: {{ .Values.service.port }} + targetPort: 3000 + protocol: TCP diff --git a/charts/teachlink-backend/values.yaml b/charts/teachlink-backend/values.yaml new file mode 100644 index 00000000..077df3d5 --- /dev/null +++ b/charts/teachlink-backend/values.yaml @@ -0,0 +1,82 @@ +replicaCount: 2 + +image: + repository: teachlink-backend + tag: latest + pullPolicy: IfNotPresent + +service: + type: ClusterIP + port: 80 + +ingress: + enabled: false + annotations: + nginx.ingress.kubernetes.io/ssl-redirect: "true" + nginx.ingress.kubernetes.io/proxy-body-size: "10m" + hosts: + - host: api.teachlink.io + paths: + - path: / + pathType: Prefix + tls: + - secretName: teachlink-tls + hosts: + - api.teachlink.io + +resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 500m + memory: 512Mi + +autoscaling: + enabled: true + minReplicas: 2 + maxReplicas: 10 + targetCPUUtilizationPercentage: 60 + targetMemoryUtilizationPercentage: 70 + +# Non-sensitive configuration (goes into ConfigMap) +config: + nodeEnv: "production" + databaseHost: "postgres-service" + databasePort: "5432" + databaseName: "teachlink" + databasePoolMax: "30" + databasePoolMin: "5" + redisHost: "redis-service" + redisPort: "6379" + elasticsearchNode: "http://elasticsearch-service:9200" + jwtExpiresIn: "15m" + jwtRefreshExpiresIn: "7d" + bcryptRounds: "12" + appUrl: "https://api.teachlink.io" + corsAllowedOrigins: "https://teachlink.io" + trustProxy: "true" + apiDefaultVersion: "1" + apiSupportedVersions: "1" + +# Sensitive configuration (goes into Secret) +# Override these with --set or a separate values file; never commit real values. +secrets: + databaseUser: "postgres" + databasePassword: "REPLACE_ME" + jwtSecret: "REPLACE_ME" + jwtRefreshSecret: "REPLACE_ME" + encryptionSecret: "REPLACE_ME" + sessionSecret: "REPLACE_ME" + stripeSecretKey: "REPLACE_ME" + stripeWebhookSecret: "REPLACE_ME" + awsAccessKeyId: "REPLACE_ME" + awsSecretAccessKey: "REPLACE_ME" + awsRegion: "us-east-1" + awsS3BucketName: "REPLACE_ME" + sendgridApiKey: "REPLACE_ME" + +nodeSelector: {} +tolerations: [] +affinity: {} +podAnnotations: {} diff --git a/k8s/configmap.yaml b/k8s/configmap.yaml new file mode 100644 index 00000000..a36aef84 --- /dev/null +++ b/k8s/configmap.yaml @@ -0,0 +1,56 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: teachlink-backend-config + namespace: teachlink + labels: + app: teachlink-backend +data: + NODE_ENV: "production" + PORT: "3000" + # Database + DATABASE_HOST: "postgres-service" + DATABASE_PORT: "5432" + DATABASE_NAME: "teachlink" + DATABASE_POOL_MAX: "30" + DATABASE_POOL_MIN: "5" + DATABASE_POOL_ACQUIRE_TIMEOUT_MS: "10000" + DATABASE_POOL_IDLE_TIMEOUT_MS: "30000" + # Redis + REDIS_HOST: "redis-service" + REDIS_PORT: "6379" + # Elasticsearch + ELASTICSEARCH_NODE: "http://elasticsearch-service:9200" + ELASTICSEARCH_REQUEST_TIMEOUT: "30000" + ELASTICSEARCH_MAX_RETRIES: "3" + # JWT + JWT_EXPIRES_IN: "15m" + JWT_REFRESH_EXPIRES_IN: "7d" + # Security + BCRYPT_ROUNDS: "12" + # Session + SESSION_TTL_SECONDS: "604800" + STICKY_SESSIONS_REQUIRED: "true" + TRUST_PROXY: "true" + # App + APP_URL: "https://api.teachlink.io" + CORS_ALLOWED_ORIGINS: "https://teachlink.io,https://app.teachlink.io" + # Feature flags + ENABLE_AUTH: "true" + ENABLE_SESSION_MANAGEMENT: "true" + ENABLE_PAYMENTS: "true" + ENABLE_RATE_LIMITING: "true" + ENABLE_OBSERVABILITY: "true" + ENABLE_CACHING: "true" + ENABLE_SEARCH: "true" + ENABLE_NOTIFICATIONS: "true" + ENABLE_SECURITY: "true" + ENABLE_TENANCY: "true" + ENABLE_MIGRATIONS: "true" + # Circuit breaker + CIRCUIT_BREAKER_TIMEOUT_MS: "3000" + CIRCUIT_BREAKER_ERROR_THRESHOLD: "50" + CIRCUIT_BREAKER_RESET_TIMEOUT_MS: "30000" + # API versioning + API_DEFAULT_VERSION: "1" + API_SUPPORTED_VERSIONS: "1" diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml index 130f741d..c417c6ee 100644 --- a/k8s/deployment.yaml +++ b/k8s/deployment.yaml @@ -2,10 +2,11 @@ apiVersion: apps/v1 kind: Deployment metadata: name: teachlink-backend + namespace: teachlink labels: app: teachlink-backend spec: - replicas: 1 + replicas: 2 selector: matchLabels: app: teachlink-backend @@ -14,16 +15,46 @@ spec: labels: app: teachlink-backend spec: + securityContext: + runAsNonRoot: true + runAsUser: 1000 + fsGroup: 1000 containers: - name: teachlink-backend - image: your-dockerhub-username/teachlink-backend:latest - imagePullPolicy: Always + image: teachlink-backend:latest + imagePullPolicy: IfNotPresent + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: false + capabilities: + drop: + - ALL resources: - limits: - cpu: '250m' - memory: '256Mi' requests: - cpu: '100m' - memory: '128Mi' + cpu: "100m" + memory: "256Mi" + limits: + cpu: "500m" + memory: "512Mi" ports: - containerPort: 3000 + name: http + envFrom: + - configMapRef: + name: teachlink-backend-config + - secretRef: + name: teachlink-backend-secret + livenessProbe: + httpGet: + path: /health + port: 3000 + initialDelaySeconds: 15 + periodSeconds: 20 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /health + port: 3000 + initialDelaySeconds: 5 + periodSeconds: 10 + failureThreshold: 3 diff --git a/k8s/hpa.yaml b/k8s/hpa.yaml new file mode 100644 index 00000000..5a751b90 --- /dev/null +++ b/k8s/hpa.yaml @@ -0,0 +1,40 @@ +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: teachlink-backend-hpa + namespace: teachlink + labels: + app: teachlink-backend +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: teachlink-backend + minReplicas: 2 + maxReplicas: 10 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 60 + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: 70 + behavior: + scaleUp: + stabilizationWindowSeconds: 60 + policies: + - type: Pods + value: 2 + periodSeconds: 60 + scaleDown: + stabilizationWindowSeconds: 300 + policies: + - type: Pods + value: 1 + periodSeconds: 120 diff --git a/k8s/ingress.yaml b/k8s/ingress.yaml index 6cfe2390..8af41476 100644 --- a/k8s/ingress.yaml +++ b/k8s/ingress.yaml @@ -2,9 +2,22 @@ apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: teachlink-backend-ingress + namespace: teachlink + labels: + app: teachlink-backend + annotations: + nginx.ingress.kubernetes.io/rewrite-target: / + nginx.ingress.kubernetes.io/ssl-redirect: "true" + nginx.ingress.kubernetes.io/proxy-body-size: "10m" + nginx.ingress.kubernetes.io/proxy-read-timeout: "60" spec: + ingressClassName: nginx + tls: + - hosts: + - api.teachlink.io + secretName: teachlink-tls rules: - - host: example.local + - host: api.teachlink.io http: paths: - path: / diff --git a/k8s/namespace.yaml b/k8s/namespace.yaml new file mode 100644 index 00000000..572f563b --- /dev/null +++ b/k8s/namespace.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: teachlink + labels: + app: teachlink-backend diff --git a/k8s/secret.yaml b/k8s/secret.yaml new file mode 100644 index 00000000..623e7c19 --- /dev/null +++ b/k8s/secret.yaml @@ -0,0 +1,26 @@ +apiVersion: v1 +kind: Secret +metadata: + name: teachlink-backend-secret + namespace: teachlink + labels: + app: teachlink-backend +type: Opaque +# Values must be base64-encoded: echo -n 'value' | base64 +# Replace all placeholder values before applying to a cluster. +stringData: + DATABASE_USER: "postgres" + DATABASE_PASSWORD: "REPLACE_ME" + JWT_SECRET: "REPLACE_ME" + JWT_REFRESH_SECRET: "REPLACE_ME" + ENCRYPTION_SECRET: "REPLACE_ME" + SESSION_SECRET: "REPLACE_ME" + STRIPE_SECRET_KEY: "REPLACE_ME" + STRIPE_WEBHOOK_SECRET: "REPLACE_ME" + AWS_ACCESS_KEY_ID: "REPLACE_ME" + AWS_SECRET_ACCESS_KEY: "REPLACE_ME" + AWS_REGION: "us-east-1" + AWS_S3_BUCKET_NAME: "REPLACE_ME" + SENDGRID_API_KEY: "REPLACE_ME" + ELASTICSEARCH_USERNAME: "" + ELASTICSEARCH_PASSWORD: "" diff --git a/k8s/service.yaml b/k8s/service.yaml index 9af8e0d9..d72980b5 100644 --- a/k8s/service.yaml +++ b/k8s/service.yaml @@ -2,12 +2,15 @@ apiVersion: v1 kind: Service metadata: name: teachlink-backend-service + namespace: teachlink + labels: + app: teachlink-backend spec: type: ClusterIP + selector: + app: teachlink-backend ports: - - port: 80 + - name: http + port: 80 targetPort: 3000 protocol: TCP - name: http - selector: - app: teachlink-backend diff --git a/src/app.module.ts b/src/app.module.ts index 4543bafd..4eca62ad 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -7,6 +7,7 @@ import { ScheduleModule } from '@nestjs/schedule'; import { AppController } from './app.controller'; import { SearchModule } from './search/search.module'; import { AnalyticsModule } from './analytics/analytics.module'; // ✅ added +import { CoursesModule } from './courses/courses.module'; import { IndexOptimizationModule } from './database/index-optimization/index-optimization.module'; import { RateLimitingModule } from './rate-limiting/rate-limiting.module'; @@ -30,6 +31,7 @@ const featureFlags = loadFeatureFlags(); SessionModule, SearchModule, AnalyticsModule, // ✅ merged from feat branch + CoursesModule, IndexOptimizationModule, ...(featureFlags.ENABLE_RATE_LIMITING ? [RateLimitingModule] : []), DebuggingModule, diff --git a/src/courses/courses.controller.ts b/src/courses/courses.controller.ts new file mode 100644 index 00000000..15faf114 --- /dev/null +++ b/src/courses/courses.controller.ts @@ -0,0 +1,44 @@ +import { Controller, Get, Param, Post, HttpCode, HttpStatus } from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiParam, + ApiResponse, +} from '@nestjs/swagger'; +import { CoursesService } from './courses.service'; + +@ApiTags('courses') +@Controller('courses') +export class CoursesController { + constructor(private readonly coursesService: CoursesService) {} + + @Get(':id/versions') + @ApiOperation({ summary: 'Get course version history' }) + @ApiResponse({ status: 200, description: 'Version history returned' }) + async getVersionHistory(@Param('id') id: string) { + return this.coursesService.getVersionHistory(id); + } + + @Get(':id/versions/:versionNumber/diff') + @ApiOperation({ summary: 'Get a diff between a saved course version and the current course' }) + @ApiParam({ name: 'versionNumber', type: Number }) + @ApiResponse({ status: 200, description: 'Diff returned' }) + async getVersionDiff( + @Param('id') id: string, + @Param('versionNumber') versionNumber: number, + ) { + return this.coursesService.getVersionDiff(id, Number(versionNumber)); + } + + @Post(':id/versions/:versionNumber/rollback') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Rollback a course to a previous version' }) + @ApiParam({ name: 'versionNumber', type: Number }) + @ApiResponse({ status: 200, description: 'Course rolled back successfully' }) + async rollbackVersion( + @Param('id') id: string, + @Param('versionNumber') versionNumber: number, + ) { + return this.coursesService.rollbackToVersion(id, Number(versionNumber)); + } +} diff --git a/src/courses/courses.module.ts b/src/courses/courses.module.ts new file mode 100644 index 00000000..0d27eb08 --- /dev/null +++ b/src/courses/courses.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { CoursesController } from './courses.controller'; +import { CoursesService } from './courses.service'; +import { Course } from './entities/course.entity'; +import { CourseReview } from './entities/course-review.entity'; +import { CourseVersion } from './entities/course-version.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([Course, CourseReview, CourseVersion])], + controllers: [CoursesController], + providers: [CoursesService], + exports: [CoursesService], +}) +export class CoursesModule {} diff --git a/src/courses/courses.service.spec.ts b/src/courses/courses.service.spec.ts new file mode 100644 index 00000000..154316f7 --- /dev/null +++ b/src/courses/courses.service.spec.ts @@ -0,0 +1,149 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { CoursesService } from './courses.service'; +import { Course, CourseStatus } from './entities/course.entity'; +import { CourseReview } from './entities/course-review.entity'; +import { CourseVersion, CourseVersionEventType } from './entities/course-version.entity'; +import { User, UserRole } from '../users/entities/user.entity'; + +const mockCourseRepo = { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + findOneBy: jest.fn(), +}; + +const mockReviewRepo = { + create: jest.fn(), + save: jest.fn(), + find: jest.fn(), +}; + +const mockVersionRepo = { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + find: jest.fn(), +}; + +const instructor: User = { + id: 'instr-1', + role: UserRole.INSTRUCTOR, +} as User; + +const baseCourse: Partial = { + id: 'course-1', + title: 'Original title', + description: 'Original description', + price: 0, + thumbnailUrl: 'https://example.com/image.png', + status: CourseStatus.DRAFT, + instructorId: 'instr-1', +}; + +describe('CoursesService', () => { + let service: CoursesService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CoursesService, + { provide: getRepositoryToken(Course), useValue: mockCourseRepo }, + { provide: getRepositoryToken(CourseReview), useValue: mockReviewRepo }, + { provide: getRepositoryToken(CourseVersion), useValue: mockVersionRepo }, + ], + }).compile(); + + service = module.get(CoursesService); + }); + + afterEach(() => jest.clearAllMocks()); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('create', () => { + it('should create a course and snapshot initial version', async () => { + const dto = { title: 'New course', description: 'A course description.', price: 20 }; + const savedCourse = { ...baseCourse, ...dto }; + + mockCourseRepo.create.mockReturnValue(savedCourse); + mockCourseRepo.save.mockResolvedValue(savedCourse); + mockVersionRepo.findOne.mockResolvedValue(null); + mockVersionRepo.create.mockReturnValue({}); + mockVersionRepo.save.mockResolvedValue({ ...savedCourse, versionNumber: 1 }); + + const result = await service.create(dto as any, instructor); + + expect(mockCourseRepo.create).toHaveBeenCalledWith({ + ...dto, + instructorId: instructor.id, + status: CourseStatus.DRAFT, + }); + expect(mockCourseRepo.save).toHaveBeenCalledWith(savedCourse); + expect(mockVersionRepo.create).toHaveBeenCalledWith(expect.objectContaining({ + courseId: savedCourse.id, + versionNumber: 1, + eventType: CourseVersionEventType.CREATED, + })); + expect(result).toEqual(savedCourse); + }); + }); + + describe('update', () => { + it('should update a course and create a version snapshot when content changes', async () => { + const existingCourse = { ...baseCourse, title: 'Original title', description: 'Original description' }; + const updatedCourse = { ...existingCourse, title: 'Updated title' }; + const previousVersion = { ...existingCourse, versionNumber: 1 } as CourseVersion; + + mockCourseRepo.findOne.mockResolvedValue(existingCourse); + mockCourseRepo.save.mockResolvedValue(updatedCourse); + mockVersionRepo.findOne.mockResolvedValue(previousVersion); + mockVersionRepo.create.mockReturnValue({}); + mockVersionRepo.save.mockResolvedValue({ ...updatedCourse, versionNumber: 2 }); + + const result = await service.update('course-1', { title: 'Updated title' } as any, instructor); + + expect(result).toEqual(updatedCourse); + expect(mockVersionRepo.create).toHaveBeenCalledWith(expect.objectContaining({ + courseId: existingCourse.id, + versionNumber: 2, + eventType: CourseVersionEventType.UPDATED, + })); + }); + }); + + describe('rollbackToVersion', () => { + it('should rollback to a previous version and create a rollback snapshot', async () => { + const currentCourse = { ...baseCourse, title: 'Latest title', status: CourseStatus.PUBLISHED }; + const versionEntry = { + courseId: 'course-1', + versionNumber: 1, + title: 'Original title', + description: 'Original description', + price: 0, + thumbnailUrl: 'https://example.com/image.png', + status: CourseStatus.DRAFT, + submissionNote: null, + } as CourseVersion; + const rolledBackCourse = { ...currentCourse, title: versionEntry.title, status: versionEntry.status }; + + mockCourseRepo.findOne.mockResolvedValue(currentCourse); + mockVersionRepo.findOne.mockResolvedValue(versionEntry); + mockCourseRepo.save.mockResolvedValue(rolledBackCourse); + mockVersionRepo.create.mockReturnValue({}); + mockVersionRepo.save.mockResolvedValue({ ...rolledBackCourse, versionNumber: 2 }); + + const result = await service.rollbackToVersion('course-1', 1, instructor); + + expect(result.title).toBe('Original title'); + expect(result.status).toBe(CourseStatus.DRAFT); + expect(mockVersionRepo.create).toHaveBeenCalledWith(expect.objectContaining({ + courseId: currentCourse.id, + eventType: CourseVersionEventType.ROLLEDBACK, + })); + }); + }); +}); diff --git a/src/courses/courses.service.ts b/src/courses/courses.service.ts index dcfbe87b..e0c19ffe 100644 --- a/src/courses/courses.service.ts +++ b/src/courses/courses.service.ts @@ -7,7 +7,14 @@ import { import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Course, CourseStatus } from './entities/course.entity'; -import { CourseReview, ReviewDecision } from './entities/course-review.entity'; +import { + CourseReview, + ReviewDecision, +} from './entities/course-review.entity'; +import { + CourseVersion, + CourseVersionEventType, +} from './entities/course-version.entity'; import { User, UserRole } from '../users/entities/user.entity'; import { CreateCourseDto } from './dto/create-course.dto'; import { UpdateCourseDto } from './dto/update-course.dto'; @@ -33,6 +40,8 @@ export class CoursesService { private readonly courseRepo: Repository, @InjectRepository(CourseReview) private readonly reviewRepo: Repository, + @InjectRepository(CourseVersion) + private readonly versionRepo: Repository, ) {} // ─── CRUD ──────────────────────────────────────────────────────────────────── @@ -46,7 +55,13 @@ export class CoursesService { instructorId: instructor.id, status: CourseStatus.DRAFT, }); - return this.courseRepo.save(course); + const savedCourse = await this.courseRepo.save(course); + await this.createVersionSnapshot( + savedCourse, + instructor.id, + CourseVersionEventType.CREATED, + ); + return savedCourse; } /** @@ -87,7 +102,13 @@ export class CoursesService { const course = await this.findOne(id); this.assertOwnerOrPrivileged(course, requestingUser); Object.assign(course, dto); - return this.courseRepo.save(course); + const updatedCourse = await this.courseRepo.save(course); + await this.createVersionSnapshot( + updatedCourse, + requestingUser.id, + CourseVersionEventType.UPDATED, + ); + return updatedCourse; } /** @@ -162,6 +183,126 @@ export class CoursesService { }); } + /** + * Returns the full version history for a course. + */ + async getVersionHistory(id: string): Promise { + await this.findOne(id); + return this.versionRepo.find({ + where: { courseId: id }, + relations: ['changedBy'], + order: { versionNumber: 'DESC' }, + }); + } + + async getVersionDiff(id: string, versionNumber: number) { + const currentCourse = await this.findOne(id); + const version = await this.findVersion(id, versionNumber); + return this.computeCourseChanges(version, currentCourse); + } + + async rollbackToVersion( + id: string, + versionNumber: number, + requestingUser?: User, + ): Promise { + const course = await this.findOne(id); + if (requestingUser) { + this.assertOwnerOrPrivileged(course, requestingUser); + } + const version = await this.findVersion(id, versionNumber); + + Object.assign(course, { + title: version.title, + description: version.description, + price: Number(version.price), + thumbnailUrl: version.thumbnailUrl, + status: version.status, + submissionNote: version.submissionNote, + }); + + const rolledBackCourse = await this.courseRepo.save(course); + await this.createVersionSnapshot( + rolledBackCourse, + requestingUser?.id, + CourseVersionEventType.ROLLEDBACK, + ); + return rolledBackCourse; + } + + private async findVersion( + courseId: string, + versionNumber: number, + ): Promise { + const version = await this.versionRepo.findOne({ + where: { courseId, versionNumber }, + }); + if (!version) { + throw new NotFoundException( + `Version ${versionNumber} not found for course ${courseId}`, + ); + } + return version; + } + + private async createVersionSnapshot( + course: Course, + changedByUserId?: string, + eventType: CourseVersionEventType = CourseVersionEventType.UPDATED, + ): Promise { + const previousVersion = await this.versionRepo.findOne({ + where: { courseId: course.id }, + order: { versionNumber: 'DESC' }, + }); + + const versionNumber = previousVersion ? previousVersion.versionNumber + 1 : 1; + const changes = this.computeCourseChanges(previousVersion, course); + + const courseVersion = this.versionRepo.create({ + courseId: course.id, + versionNumber, + eventType, + changedByUserId, + title: course.title, + description: course.description, + price: course.price, + thumbnailUrl: course.thumbnailUrl, + status: course.status, + submissionNote: course.submissionNote, + changes: Object.keys(changes).length ? changes : null, + }); + + return this.versionRepo.save(courseVersion); + } + + private computeCourseChanges( + previous: Partial | null, + current: Partial, + ): Record { + const trackedFields: Array = [ + 'title', + 'description', + 'price', + 'thumbnailUrl', + 'status', + 'submissionNote', + ]; + const changes: Record = {}; + + trackedFields.forEach((field) => { + const previousValue = previous ? previous[field] : undefined; + const currentValue = current[field]; + if (previousValue !== currentValue) { + changes[field as string] = { + previous: previousValue ?? null, + next: currentValue ?? null, + }; + } + }); + + return changes; + } + /** * Returns all courses currently awaiting moderation. */ diff --git a/src/courses/entities/course-version.entity.ts b/src/courses/entities/course-version.entity.ts new file mode 100644 index 00000000..a55b275e --- /dev/null +++ b/src/courses/entities/course-version.entity.ts @@ -0,0 +1,73 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + Index, +} from 'typeorm'; +import { Course, CourseStatus } from './course.entity'; +import { User } from '../../users/entities/user.entity'; + +export enum CourseVersionEventType { + CREATED = 'created', + UPDATED = 'updated', + ROLLEDBACK = 'rolled_back', +} + +@Entity('course_versions') +@Index(['courseId', 'versionNumber'], { unique: true }) +export class CourseVersion { + @PrimaryGeneratedColumn('uuid') + id: string; + + @ManyToOne(() => Course, (course) => course.versions, { onDelete: 'CASCADE' }) + course: Course; + + @Column({ name: 'course_id' }) + @Index() + courseId: string; + + @Column({ type: 'int' }) + versionNumber: number; + + @Column({ type: 'enum', enum: CourseVersionEventType }) + eventType: CourseVersionEventType; + + @ManyToOne(() => User, { nullable: true, onDelete: 'SET NULL' }) + changedBy: User; + + @Column({ name: 'changed_by_user_id', nullable: true }) + changedByUserId?: string; + + @Column() + title: string; + + @Column('text') + description: string; + + @Column({ type: 'decimal', precision: 10, scale: 2, default: 0 }) + price: number; + + @Column({ nullable: true }) + thumbnailUrl?: string; + + @Column({ + type: 'enum', + enum: CourseStatus, + }) + status: CourseStatus; + + @Column({ type: 'text', nullable: true }) + submissionNote?: string; + + @Column({ type: 'jsonb', nullable: true }) + changes?: Record; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/courses/entities/course.entity.ts b/src/courses/entities/course.entity.ts index 8fe55243..f81bafdb 100644 --- a/src/courses/entities/course.entity.ts +++ b/src/courses/entities/course.entity.ts @@ -14,6 +14,7 @@ import { User } from '../../users/entities/user.entity'; import { CourseModule } from './course-module.entity'; import { Enrollment } from './enrollment.entity'; import { CourseReview } from './course-review.entity'; +import { CourseVersion } from './course-version.entity'; /** Lifecycle states a course can be in. */ export enum CourseStatus { @@ -70,6 +71,9 @@ export class Course { @OneToMany(() => Enrollment, (enrollment) => enrollment.course) enrollments: Enrollment[]; + @OneToMany(() => CourseVersion, (version) => version.course, { eager: false }) + versions: CourseVersion[]; + @OneToMany(() => CourseReview, (review) => review.course, { eager: false }) reviews: CourseReview[];