This commit is contained in:
208
.drone.yml
Normal file
208
.drone.yml
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
---
|
||||||
|
kind: pipeline
|
||||||
|
type: kubernetes
|
||||||
|
name: main-pipeline
|
||||||
|
|
||||||
|
# Триггер: запускать при изменениях в backend, frontend или .drone.yml
|
||||||
|
trigger:
|
||||||
|
branch:
|
||||||
|
- main
|
||||||
|
- master
|
||||||
|
event:
|
||||||
|
- push
|
||||||
|
|
||||||
|
steps:
|
||||||
|
# ============================================================
|
||||||
|
# СБОРКА ОБРАЗОВ (параллельно)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# --- Сборка Backend образа ---
|
||||||
|
- name: build-backend
|
||||||
|
image: plugins/kaniko
|
||||||
|
when:
|
||||||
|
changeset:
|
||||||
|
includes:
|
||||||
|
- backend/**
|
||||||
|
- .drone.yml
|
||||||
|
excludes:
|
||||||
|
- backend/README.md
|
||||||
|
- backend/**/*.md
|
||||||
|
settings:
|
||||||
|
registry: registry.vigdorov.ru
|
||||||
|
repo: registry.vigdorov.ru/library/team-planner-backend
|
||||||
|
dockerfile: backend/Dockerfile
|
||||||
|
context: backend
|
||||||
|
tags:
|
||||||
|
- ${DRONE_COMMIT_SHA:0:7}
|
||||||
|
- latest
|
||||||
|
cache: true
|
||||||
|
cache_repo: registry.vigdorov.ru/library/team-planner-backend-cache
|
||||||
|
username:
|
||||||
|
from_secret: HARBOR_USER
|
||||||
|
password:
|
||||||
|
from_secret: HARBOR_PASSWORD
|
||||||
|
no_push_metadata: true
|
||||||
|
|
||||||
|
# --- Сборка Frontend образа (параллельно с backend) ---
|
||||||
|
- name: build-frontend
|
||||||
|
image: plugins/kaniko
|
||||||
|
when:
|
||||||
|
changeset:
|
||||||
|
includes:
|
||||||
|
- frontend/**
|
||||||
|
- .drone.yml
|
||||||
|
excludes:
|
||||||
|
- frontend/README.md
|
||||||
|
- frontend/**/*.md
|
||||||
|
settings:
|
||||||
|
registry: registry.vigdorov.ru
|
||||||
|
repo: registry.vigdorov.ru/library/team-planner-frontend
|
||||||
|
dockerfile: frontend/Dockerfile
|
||||||
|
context: frontend
|
||||||
|
tags:
|
||||||
|
- ${DRONE_COMMIT_SHA:0:7}
|
||||||
|
- latest
|
||||||
|
cache: true
|
||||||
|
cache_repo: registry.vigdorov.ru/library/team-planner-frontend-cache
|
||||||
|
username:
|
||||||
|
from_secret: HARBOR_USER
|
||||||
|
password:
|
||||||
|
from_secret: HARBOR_PASSWORD
|
||||||
|
no_push_metadata: true
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# ДЕПЛОЙ (только после завершения ОБЕИХ сборок)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# --- Развертывание Backend в PROD ---
|
||||||
|
- name: deploy-backend
|
||||||
|
image: alpine/k8s:1.28.2
|
||||||
|
depends_on:
|
||||||
|
- build-backend
|
||||||
|
- build-frontend
|
||||||
|
when:
|
||||||
|
changeset:
|
||||||
|
includes:
|
||||||
|
- backend/**
|
||||||
|
- .drone.yml
|
||||||
|
excludes:
|
||||||
|
- backend/README.md
|
||||||
|
- backend/**/*.md
|
||||||
|
environment:
|
||||||
|
KUBE_CONFIG_CONTENT:
|
||||||
|
from_secret: KUBE_CONFIG
|
||||||
|
commands:
|
||||||
|
- mkdir -p ~/.kube
|
||||||
|
- echo "$KUBE_CONFIG_CONTENT" > ~/.kube/config
|
||||||
|
- chmod 600 ~/.kube/config
|
||||||
|
- sed -i "s|https://127.0.0.1:6443|https://10.10.10.100:6443|g" ~/.kube/config
|
||||||
|
- export APP_NAMESPACE="team-planner"
|
||||||
|
- export IMAGE_TAG="${DRONE_COMMIT_SHA:0:7}"
|
||||||
|
- export BACKEND_IMAGE="registry.vigdorov.ru/library/team-planner-backend"
|
||||||
|
- kubectl cluster-info
|
||||||
|
- sed -e "s|__BACKEND_IMAGE__|$BACKEND_IMAGE:$IMAGE_TAG|g" k8s/backend-deployment.yaml | kubectl apply -n $APP_NAMESPACE -f -
|
||||||
|
- kubectl apply -n $APP_NAMESPACE -f k8s/backend-service.yaml
|
||||||
|
- kubectl rollout status deployment/team-planner-backend -n $APP_NAMESPACE --timeout=300s
|
||||||
|
- echo "✅ Backend deployed to PROD (image:$IMAGE_TAG)"
|
||||||
|
|
||||||
|
# --- Развертывание Frontend в PROD ---
|
||||||
|
- name: deploy-frontend
|
||||||
|
image: alpine/k8s:1.28.2
|
||||||
|
depends_on:
|
||||||
|
- build-backend
|
||||||
|
- build-frontend
|
||||||
|
when:
|
||||||
|
changeset:
|
||||||
|
includes:
|
||||||
|
- frontend/**
|
||||||
|
- .drone.yml
|
||||||
|
excludes:
|
||||||
|
- frontend/README.md
|
||||||
|
- frontend/**/*.md
|
||||||
|
environment:
|
||||||
|
KUBE_CONFIG_CONTENT:
|
||||||
|
from_secret: KUBE_CONFIG
|
||||||
|
commands:
|
||||||
|
- mkdir -p ~/.kube
|
||||||
|
- echo "$KUBE_CONFIG_CONTENT" > ~/.kube/config
|
||||||
|
- chmod 600 ~/.kube/config
|
||||||
|
- sed -i "s|https://127.0.0.1:6443|https://10.10.10.100:6443|g" ~/.kube/config
|
||||||
|
- export APP_NAMESPACE="team-planner"
|
||||||
|
- export IMAGE_TAG="${DRONE_COMMIT_SHA:0:7}"
|
||||||
|
- export FRONTEND_IMAGE="registry.vigdorov.ru/library/team-planner-frontend"
|
||||||
|
- kubectl cluster-info
|
||||||
|
- sed -e "s|__FRONTEND_IMAGE__|$FRONTEND_IMAGE:$IMAGE_TAG|g" k8s/frontend-deployment.yaml | kubectl apply -n $APP_NAMESPACE -f -
|
||||||
|
- kubectl apply -n $APP_NAMESPACE -f k8s/frontend-service.yaml
|
||||||
|
- kubectl rollout status deployment/team-planner-frontend -n $APP_NAMESPACE --timeout=300s
|
||||||
|
- echo "✅ Frontend deployed to PROD (image:$IMAGE_TAG)"
|
||||||
|
|
||||||
|
---
|
||||||
|
kind: pipeline
|
||||||
|
type: kubernetes
|
||||||
|
name: infra-pipeline
|
||||||
|
|
||||||
|
# Триггер: запускать только при изменениях в k8s конфигах
|
||||||
|
trigger:
|
||||||
|
branch:
|
||||||
|
- main
|
||||||
|
- master
|
||||||
|
event:
|
||||||
|
- push
|
||||||
|
paths:
|
||||||
|
include:
|
||||||
|
- k8s/**
|
||||||
|
|
||||||
|
steps:
|
||||||
|
# --- Создание секретов (УДАЛИТЬ ПОСЛЕ ПЕРВОГО ДЕПЛОЯ) ---
|
||||||
|
- name: create-secrets
|
||||||
|
image: alpine/k8s:1.28.2
|
||||||
|
environment:
|
||||||
|
KUBE_CONFIG_CONTENT:
|
||||||
|
from_secret: KUBE_CONFIG
|
||||||
|
DB_NAME:
|
||||||
|
from_secret: DB_NAME
|
||||||
|
DB_USER:
|
||||||
|
from_secret: DB_USER
|
||||||
|
DB_PASSWORD:
|
||||||
|
from_secret: DB_PASSWORD
|
||||||
|
commands:
|
||||||
|
- mkdir -p ~/.kube
|
||||||
|
- echo "$KUBE_CONFIG_CONTENT" > ~/.kube/config
|
||||||
|
- chmod 600 ~/.kube/config
|
||||||
|
- sed -i "s|https://127.0.0.1:6443|https://10.10.10.100:6443|g" ~/.kube/config
|
||||||
|
- export APP_NAMESPACE="team-planner"
|
||||||
|
- kubectl create namespace $APP_NAMESPACE --dry-run=client -o yaml | kubectl apply -f -
|
||||||
|
- |
|
||||||
|
kubectl create secret generic team-planner-secrets \
|
||||||
|
--from-literal=db-name="$DB_NAME" \
|
||||||
|
--from-literal=db-user="$DB_USER" \
|
||||||
|
--from-literal=db-password="$DB_PASSWORD" \
|
||||||
|
--namespace=$APP_NAMESPACE \
|
||||||
|
--dry-run=client -o yaml | kubectl apply -f -
|
||||||
|
- echo "✅ Secrets created/updated"
|
||||||
|
|
||||||
|
# --- Развертывание инфраструктуры (PostgreSQL, Services, Ingress) ---
|
||||||
|
- name: deploy-infra
|
||||||
|
image: alpine/k8s:1.28.2
|
||||||
|
depends_on:
|
||||||
|
- create-secrets
|
||||||
|
environment:
|
||||||
|
KUBE_CONFIG_CONTENT:
|
||||||
|
from_secret: KUBE_CONFIG
|
||||||
|
commands:
|
||||||
|
- mkdir -p ~/.kube
|
||||||
|
- echo "$KUBE_CONFIG_CONTENT" > ~/.kube/config
|
||||||
|
- chmod 600 ~/.kube/config
|
||||||
|
- sed -i "s|https://127.0.0.1:6443|https://10.10.10.100:6443|g" ~/.kube/config
|
||||||
|
- export APP_NAMESPACE="team-planner"
|
||||||
|
- export HOSTNAME="team-planner.vigdorov.ru"
|
||||||
|
- export SECRET_NAME="wildcard-cert"
|
||||||
|
- kubectl cluster-info
|
||||||
|
- kubectl create namespace $APP_NAMESPACE --dry-run=client -o yaml | kubectl apply -f -
|
||||||
|
- kubectl apply -n $APP_NAMESPACE -f k8s/postgres-pvc.yaml
|
||||||
|
- kubectl apply -n $APP_NAMESPACE -f k8s/postgres-statefulset.yaml
|
||||||
|
- kubectl apply -n $APP_NAMESPACE -f k8s/postgres-service.yaml
|
||||||
|
- kubectl apply -n $APP_NAMESPACE -f k8s/backend-service.yaml
|
||||||
|
- kubectl apply -n $APP_NAMESPACE -f k8s/frontend-service.yaml
|
||||||
|
- sed -e "s|__HOSTNAME__|$HOSTNAME|g" -e "s|__SECRET_NAME__|$SECRET_NAME|g" k8s/ingress.yaml | kubectl apply -n $APP_NAMESPACE -f -
|
||||||
|
- echo "✅ Infrastructure updated"
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
@ -6,6 +6,9 @@ node_modules
|
|||||||
**/*/dist/
|
**/*/dist/
|
||||||
**/dist-ssr/
|
**/dist-ssr/
|
||||||
|
|
||||||
|
.serena/
|
||||||
|
.claude/
|
||||||
|
|
||||||
# Environment
|
# Environment
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
@ -29,3 +32,6 @@ pnpm-debug.log*
|
|||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# Kubernetes secrets
|
||||||
|
k8s/secrets.yaml
|
||||||
|
|||||||
1
.serena/.gitignore
vendored
1
.serena/.gitignore
vendored
@ -1 +0,0 @@
|
|||||||
/cache
|
|
||||||
@ -1,84 +0,0 @@
|
|||||||
# list of languages for which language servers are started; choose from:
|
|
||||||
# al bash clojure cpp csharp csharp_omnisharp
|
|
||||||
# dart elixir elm erlang fortran go
|
|
||||||
# haskell java julia kotlin lua markdown
|
|
||||||
# nix perl php python python_jedi r
|
|
||||||
# rego ruby ruby_solargraph rust scala swift
|
|
||||||
# terraform typescript typescript_vts yaml zig
|
|
||||||
# Note:
|
|
||||||
# - For C, use cpp
|
|
||||||
# - For JavaScript, use typescript
|
|
||||||
# Special requirements:
|
|
||||||
# - csharp: Requires the presence of a .sln file in the project folder.
|
|
||||||
# When using multiple languages, the first language server that supports a given file will be used for that file.
|
|
||||||
# The first language is the default language and the respective language server will be used as a fallback.
|
|
||||||
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
|
|
||||||
languages:
|
|
||||||
- typescript
|
|
||||||
|
|
||||||
# the encoding used by text files in the project
|
|
||||||
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
|
|
||||||
encoding: "utf-8"
|
|
||||||
|
|
||||||
# whether to use the project's gitignore file to ignore files
|
|
||||||
# Added on 2025-04-07
|
|
||||||
ignore_all_files_in_gitignore: true
|
|
||||||
|
|
||||||
# list of additional paths to ignore
|
|
||||||
# same syntax as gitignore, so you can use * and **
|
|
||||||
# Was previously called `ignored_dirs`, please update your config if you are using that.
|
|
||||||
# Added (renamed) on 2025-04-07
|
|
||||||
ignored_paths: []
|
|
||||||
|
|
||||||
# whether the project is in read-only mode
|
|
||||||
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
|
|
||||||
# Added on 2025-04-18
|
|
||||||
read_only: false
|
|
||||||
|
|
||||||
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
|
|
||||||
# Below is the complete list of tools for convenience.
|
|
||||||
# To make sure you have the latest list of tools, and to view their descriptions,
|
|
||||||
# execute `uv run scripts/print_tool_overview.py`.
|
|
||||||
#
|
|
||||||
# * `activate_project`: Activates a project by name.
|
|
||||||
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
|
|
||||||
# * `create_text_file`: Creates/overwrites a file in the project directory.
|
|
||||||
# * `delete_lines`: Deletes a range of lines within a file.
|
|
||||||
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
|
|
||||||
# * `execute_shell_command`: Executes a shell command.
|
|
||||||
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
|
|
||||||
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
|
|
||||||
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
|
|
||||||
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
|
|
||||||
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
|
|
||||||
# * `initial_instructions`: Gets the initial instructions for the current project.
|
|
||||||
# Should only be used in settings where the system prompt cannot be set,
|
|
||||||
# e.g. in clients you have no control over, like Claude Desktop.
|
|
||||||
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
|
|
||||||
# * `insert_at_line`: Inserts content at a given line in a file.
|
|
||||||
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
|
|
||||||
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
|
|
||||||
# * `list_memories`: Lists memories in Serena's project-specific memory store.
|
|
||||||
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
|
|
||||||
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
|
|
||||||
# * `read_file`: Reads a file within the project directory.
|
|
||||||
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
|
|
||||||
# * `remove_project`: Removes a project from the Serena configuration.
|
|
||||||
# * `replace_lines`: Replaces a range of lines within a file with new content.
|
|
||||||
# * `replace_symbol_body`: Replaces the full definition of a symbol.
|
|
||||||
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
|
|
||||||
# * `search_for_pattern`: Performs a search for a pattern in the project.
|
|
||||||
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
|
|
||||||
# * `switch_modes`: Activates modes by providing a list of their names
|
|
||||||
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
|
|
||||||
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
|
|
||||||
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
|
|
||||||
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
|
|
||||||
excluded_tools: []
|
|
||||||
|
|
||||||
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
|
||||||
# (contrary to the memories, which are loaded on demand).
|
|
||||||
initial_prompt: ""
|
|
||||||
|
|
||||||
project_name: "team-planner"
|
|
||||||
included_optional_tools: []
|
|
||||||
48
backend/Dockerfile
Normal file
48
backend/Dockerfile
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Install ALL dependencies (including devDependencies for build)
|
||||||
|
RUN npm install --include=dev
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build the application
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Production stage
|
||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install only production dependencies
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install --only=production && npm cache clean --force
|
||||||
|
|
||||||
|
# Copy built application from builder
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN addgroup -g 1001 -S nodejs && \
|
||||||
|
adduser -S nestjs -u 1001
|
||||||
|
|
||||||
|
# Change ownership
|
||||||
|
RUN chown -R nestjs:nodejs /app
|
||||||
|
|
||||||
|
# Switch to non-root user
|
||||||
|
USER nestjs
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 4001
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s \
|
||||||
|
CMD node -e "require('http').get('http://localhost:4001/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
|
||||||
|
|
||||||
|
# Start the application
|
||||||
|
CMD ["node", "dist/main"]
|
||||||
@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint';
|
|||||||
|
|
||||||
export default tseslint.config(
|
export default tseslint.config(
|
||||||
{
|
{
|
||||||
ignores: ['eslint.config.mjs'],
|
ignores: ['eslint.config.mjs', 'coverage'],
|
||||||
},
|
},
|
||||||
eslint.configs.recommended,
|
eslint.configs.recommended,
|
||||||
...tseslint.configs.recommendedTypeChecked,
|
...tseslint.configs.recommendedTypeChecked,
|
||||||
@ -27,8 +27,12 @@ export default tseslint.config(
|
|||||||
{
|
{
|
||||||
rules: {
|
rules: {
|
||||||
'@typescript-eslint/no-explicit-any': 'off',
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
'@typescript-eslint/no-floating-promises': 'warn',
|
'@typescript-eslint/no-floating-promises': 'error',
|
||||||
'@typescript-eslint/no-unsafe-argument': 'warn',
|
'@typescript-eslint/no-unsafe-argument': 'error',
|
||||||
|
'@typescript-eslint/no-unsafe-assignment': 'error',
|
||||||
|
'@typescript-eslint/no-unsafe-call': 'error',
|
||||||
|
'@typescript-eslint/no-unsafe-member-access': 'error',
|
||||||
|
'@typescript-eslint/no-unsafe-return': 'error',
|
||||||
"prettier/prettier": ["error", { endOfLine: "auto" }],
|
"prettier/prettier": ["error", { endOfLine: "auto" }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -13,12 +13,13 @@
|
|||||||
"start:dev": "nest start --watch",
|
"start:dev": "nest start --watch",
|
||||||
"start:debug": "nest start --debug --watch",
|
"start:debug": "nest start --debug --watch",
|
||||||
"start:prod": "node dist/main",
|
"start:prod": "node dist/main",
|
||||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
"lint": "tsc --noEmit && eslint \"{src,apps,libs,test}/**/*.ts\" && prettier --check \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
"test:cov": "jest --coverage",
|
"test:cov": "jest --coverage",
|
||||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||||
|
"clean": "rm -rf dist node_modules"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^11.0.1",
|
"@nestjs/common": "^11.0.1",
|
||||||
|
|||||||
@ -9,4 +9,9 @@ export class AppController {
|
|||||||
getHello(): string {
|
getHello(): string {
|
||||||
return this.appService.getHello();
|
return this.appService.getHello();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('health')
|
||||||
|
health(): { status: string } {
|
||||||
|
return { status: 'ok' };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,4 @@
|
|||||||
import {
|
import { IsString, IsOptional, IsEnum, MaxLength } from 'class-validator';
|
||||||
IsString,
|
|
||||||
IsOptional,
|
|
||||||
IsEnum,
|
|
||||||
MaxLength,
|
|
||||||
} from 'class-validator';
|
|
||||||
import { IdeaStatus, IdeaPriority } from '../entities/idea.entity';
|
import { IdeaStatus, IdeaPriority } from '../entities/idea.entity';
|
||||||
|
|
||||||
export class CreateIdeaDto {
|
export class CreateIdeaDto {
|
||||||
|
|||||||
@ -49,7 +49,12 @@ export class Idea {
|
|||||||
@Column({ type: 'varchar', length: 100, nullable: true })
|
@Column({ type: 'varchar', length: 100, nullable: true })
|
||||||
module: string | null;
|
module: string | null;
|
||||||
|
|
||||||
@Column({ name: 'target_audience', type: 'varchar', length: 255, nullable: true })
|
@Column({
|
||||||
|
name: 'target_audience',
|
||||||
|
type: 'varchar',
|
||||||
|
length: 255,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
targetAudience: string | null;
|
targetAudience: string | null;
|
||||||
|
|
||||||
@Column({ type: 'text', nullable: true })
|
@Column({ type: 'text', nullable: true })
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository, Like, FindOptionsWhere } from 'typeorm';
|
import { Repository, FindOptionsWhere } from 'typeorm';
|
||||||
import { Idea } from './entities/idea.entity';
|
import { Idea } from './entities/idea.entity';
|
||||||
import { CreateIdeaDto, UpdateIdeaDto, QueryIdeasDto } from './dto';
|
import { CreateIdeaDto, UpdateIdeaDto, QueryIdeasDto } from './dto';
|
||||||
|
|
||||||
@ -119,7 +119,7 @@ export class IdeasService {
|
|||||||
.createQueryBuilder('idea')
|
.createQueryBuilder('idea')
|
||||||
.select('DISTINCT idea.module', 'module')
|
.select('DISTINCT idea.module', 'module')
|
||||||
.where('idea.module IS NOT NULL')
|
.where('idea.module IS NOT NULL')
|
||||||
.getRawMany();
|
.getRawMany<{ module: string }>();
|
||||||
|
|
||||||
return result.map((r) => r.module).filter(Boolean);
|
return result.map((r) => r.module).filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -29,4 +29,4 @@ async function bootstrap() {
|
|||||||
await app.listen(port);
|
await app.listen(port);
|
||||||
console.log(`Backend running on http://localhost:${port}`);
|
console.log(`Backend running on http://localhost:${port}`);
|
||||||
}
|
}
|
||||||
bootstrap();
|
void bootstrap();
|
||||||
|
|||||||
4
frontend/.prettierrc
Normal file
4
frontend/.prettierrc
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all"
|
||||||
|
}
|
||||||
40
frontend/Dockerfile
Normal file
40
frontend/Dockerfile
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build argument for API URL (optional, defaults to empty for production)
|
||||||
|
# Empty value means use relative paths, which works with nginx proxy
|
||||||
|
ARG VITE_API_URL=""
|
||||||
|
ENV VITE_API_URL=$VITE_API_URL
|
||||||
|
|
||||||
|
# Build the application
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Production stage
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
# Copy built files from builder
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# Copy nginx configuration
|
||||||
|
COPY nginx.conf /etc/nginx/nginx.conf
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
|
||||||
|
CMD wget --quiet --tries=1 --spider http://localhost/health || exit 1
|
||||||
|
|
||||||
|
# Start nginx
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
@ -6,18 +6,36 @@ import tseslint from 'typescript-eslint'
|
|||||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
export default defineConfig([
|
export default defineConfig([
|
||||||
globalIgnores(['dist']),
|
globalIgnores(['dist', 'coverage', '*.config.ts', '*.config.js']),
|
||||||
{
|
{
|
||||||
files: ['**/*.{ts,tsx}'],
|
files: ['**/*.{ts,tsx}'],
|
||||||
extends: [
|
extends: [
|
||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
tseslint.configs.recommended,
|
...tseslint.configs.strictTypeChecked,
|
||||||
reactHooks.configs.flat.recommended,
|
reactHooks.configs.flat.recommended,
|
||||||
reactRefresh.configs.vite,
|
reactRefresh.configs.vite,
|
||||||
],
|
],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
ecmaVersion: 2020,
|
ecmaVersion: 2020,
|
||||||
globals: globals.browser,
|
globals: globals.browser,
|
||||||
|
parserOptions: {
|
||||||
|
projectService: true,
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-unnecessary-condition': 'error',
|
||||||
|
'@typescript-eslint/prefer-nullish-coalescing': 'error',
|
||||||
|
'@typescript-eslint/no-confusing-void-expression': 'off',
|
||||||
|
'@typescript-eslint/no-misused-promises': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
checksVoidReturn: {
|
||||||
|
attributes: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'react-hooks/set-state-in-effect': 'warn',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|||||||
62
frontend/nginx.conf
Normal file
62
frontend/nginx.conf
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
events {}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
access_log /var/log/nginx/access.log;
|
||||||
|
error_log /var/log/nginx/error.log;
|
||||||
|
|
||||||
|
# Performance
|
||||||
|
sendfile on;
|
||||||
|
tcp_nopush on;
|
||||||
|
tcp_nodelay on;
|
||||||
|
keepalive_timeout 65;
|
||||||
|
types_hash_max_size 2048;
|
||||||
|
|
||||||
|
# Gzip compression
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json application/javascript;
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Health check endpoint for k8s
|
||||||
|
location /health {
|
||||||
|
access_log off;
|
||||||
|
return 200 "healthy\n";
|
||||||
|
add_header Content-Type text/plain;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy API requests to backend
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://team-planner-backend-service:4001;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA fallback - all routes go to index.html
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Static assets with caching
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,8 +6,10 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "tsc -b --noEmit && eslint . && prettier --check \"src/**/*.{ts,tsx,json,css,md}\"",
|
||||||
"preview": "vite preview"
|
"format": "prettier --write \"src/**/*.{ts,tsx,json,css,md}\"",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"clean": "rm -rf dist node_modules"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
@ -34,6 +36,7 @@
|
|||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
|
"prettier": "^3.4.2",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.46.4",
|
"typescript-eslint": "^8.46.4",
|
||||||
"vite": "^7.2.4"
|
"vite": "^7.2.4"
|
||||||
|
|||||||
@ -10,7 +10,14 @@ function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Container maxWidth="xl" sx={{ py: 4 }}>
|
<Container maxWidth="xl" sx={{ py: 4 }}>
|
||||||
<Box sx={{ mb: 4, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<Box
|
||||||
|
sx={{
|
||||||
|
mb: 4,
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="h4" component="h1">
|
<Typography variant="h4" component="h1">
|
||||||
Team Planner
|
Team Planner
|
||||||
|
|||||||
@ -69,7 +69,12 @@ export function CreateIdeaModal() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={createModalOpen} onClose={handleClose} maxWidth="sm" fullWidth>
|
<Dialog
|
||||||
|
open={createModalOpen}
|
||||||
|
onClose={handleClose}
|
||||||
|
maxWidth="sm"
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<DialogTitle>Create New Idea</DialogTitle>
|
<DialogTitle>Create New Idea</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
@ -163,7 +168,9 @@ export function CreateIdeaModal() {
|
|||||||
<TextField
|
<TextField
|
||||||
label="Verification Method"
|
label="Verification Method"
|
||||||
value={formData.verificationMethod}
|
value={formData.verificationMethod}
|
||||||
onChange={(e) => handleChange('verificationMethod', e.target.value)}
|
onChange={(e) =>
|
||||||
|
handleChange('verificationMethod', e.target.value)
|
||||||
|
}
|
||||||
multiline
|
multiline
|
||||||
rows={2}
|
rows={2}
|
||||||
placeholder="How to verify this is done?"
|
placeholder="How to verify this is done?"
|
||||||
|
|||||||
@ -32,7 +32,7 @@ const priorityOptions: { value: IdeaPriority; label: string }[] = [
|
|||||||
export function IdeasFilters() {
|
export function IdeasFilters() {
|
||||||
const { filters, setFilter, clearFilters } = useIdeasStore();
|
const { filters, setFilter, clearFilters } = useIdeasStore();
|
||||||
const { data: modules = [] } = useModulesQuery();
|
const { data: modules = [] } = useModulesQuery();
|
||||||
const [searchValue, setSearchValue] = useState(filters.search || '');
|
const [searchValue, setSearchValue] = useState(filters.search ?? '');
|
||||||
|
|
||||||
// Debounced search
|
// Debounced search
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -42,31 +42,40 @@ export function IdeasFilters() {
|
|||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [searchValue, setFilter]);
|
}, [searchValue, setFilter]);
|
||||||
|
|
||||||
const hasFilters = filters.status || filters.priority || filters.module || filters.search;
|
const hasFilters = Boolean(
|
||||||
|
filters.status ?? filters.priority ?? filters.module ?? filters.search,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap', alignItems: 'center' }}>
|
<Box
|
||||||
|
sx={{ display: 'flex', gap: 2, flexWrap: 'wrap', alignItems: 'center' }}
|
||||||
|
>
|
||||||
<TextField
|
<TextField
|
||||||
size="small"
|
size="small"
|
||||||
placeholder="Search ideas..."
|
placeholder="Search ideas..."
|
||||||
value={searchValue}
|
value={searchValue}
|
||||||
onChange={(e) => setSearchValue(e.target.value)}
|
onChange={(e) => setSearchValue(e.target.value)}
|
||||||
sx={{ minWidth: 200 }}
|
sx={{ minWidth: 200 }}
|
||||||
InputProps={{
|
slotProps={{
|
||||||
startAdornment: (
|
input: {
|
||||||
<InputAdornment position="start">
|
startAdornment: (
|
||||||
<Search fontSize="small" />
|
<InputAdornment position="start">
|
||||||
</InputAdornment>
|
<Search fontSize="small" />
|
||||||
),
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormControl size="small" sx={{ minWidth: 120 }}>
|
<FormControl size="small" sx={{ minWidth: 120 }}>
|
||||||
<InputLabel>Status</InputLabel>
|
<InputLabel>Status</InputLabel>
|
||||||
<Select
|
<Select<IdeaStatus | ''>
|
||||||
value={filters.status || ''}
|
value={filters.status ?? ''}
|
||||||
label="Status"
|
label="Status"
|
||||||
onChange={(e) => setFilter('status', e.target.value as IdeaStatus || undefined)}
|
onChange={(e) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
setFilter('status', val === '' ? undefined : val);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<MenuItem value="">All</MenuItem>
|
<MenuItem value="">All</MenuItem>
|
||||||
{statusOptions.map((opt) => (
|
{statusOptions.map((opt) => (
|
||||||
@ -79,10 +88,13 @@ export function IdeasFilters() {
|
|||||||
|
|
||||||
<FormControl size="small" sx={{ minWidth: 120 }}>
|
<FormControl size="small" sx={{ minWidth: 120 }}>
|
||||||
<InputLabel>Priority</InputLabel>
|
<InputLabel>Priority</InputLabel>
|
||||||
<Select
|
<Select<IdeaPriority | ''>
|
||||||
value={filters.priority || ''}
|
value={filters.priority ?? ''}
|
||||||
label="Priority"
|
label="Priority"
|
||||||
onChange={(e) => setFilter('priority', e.target.value as IdeaPriority || undefined)}
|
onChange={(e) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
setFilter('priority', val === '' ? undefined : val);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<MenuItem value="">All</MenuItem>
|
<MenuItem value="">All</MenuItem>
|
||||||
{priorityOptions.map((opt) => (
|
{priorityOptions.map((opt) => (
|
||||||
@ -96,7 +108,7 @@ export function IdeasFilters() {
|
|||||||
<FormControl size="small" sx={{ minWidth: 120 }}>
|
<FormControl size="small" sx={{ minWidth: 120 }}>
|
||||||
<InputLabel>Module</InputLabel>
|
<InputLabel>Module</InputLabel>
|
||||||
<Select
|
<Select
|
||||||
value={filters.module || ''}
|
value={filters.module ?? ''}
|
||||||
label="Module"
|
label="Module"
|
||||||
onChange={(e) => setFilter('module', e.target.value || undefined)}
|
onChange={(e) => setFilter('module', e.target.value || undefined)}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import {
|
|||||||
Box,
|
Box,
|
||||||
ClickAwayListener,
|
ClickAwayListener,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import type { Idea, IdeaStatus, IdeaPriority } from '../../types/idea';
|
import type { Idea } from '../../types/idea';
|
||||||
import { useUpdateIdea } from '../../hooks/useIdeas';
|
import { useUpdateIdea } from '../../hooks/useIdeas';
|
||||||
|
|
||||||
interface EditableCellProps {
|
interface EditableCellProps {
|
||||||
@ -27,7 +27,7 @@ export function EditableCell({
|
|||||||
renderDisplay,
|
renderDisplay,
|
||||||
}: EditableCellProps) {
|
}: EditableCellProps) {
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [editValue, setEditValue] = useState(value || '');
|
const [editValue, setEditValue] = useState(value ?? '');
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const updateIdea = useUpdateIdea();
|
const updateIdea = useUpdateIdea();
|
||||||
|
|
||||||
@ -40,7 +40,7 @@ export function EditableCell({
|
|||||||
|
|
||||||
const handleDoubleClick = () => {
|
const handleDoubleClick = () => {
|
||||||
setIsEditing(true);
|
setIsEditing(true);
|
||||||
setEditValue(value || '');
|
setEditValue(value ?? '');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
@ -55,10 +55,10 @@ export function EditableCell({
|
|||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
handleSave();
|
void handleSave();
|
||||||
} else if (e.key === 'Escape') {
|
} else if (e.key === 'Escape') {
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
setEditValue(value || '');
|
setEditValue(value ?? '');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -124,20 +124,3 @@ export function EditableCell({
|
|||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status options
|
|
||||||
export const statusOptions: { value: IdeaStatus; label: string }[] = [
|
|
||||||
{ value: 'backlog', label: 'Backlog' },
|
|
||||||
{ value: 'todo', label: 'To Do' },
|
|
||||||
{ value: 'in_progress', label: 'In Progress' },
|
|
||||||
{ value: 'done', label: 'Done' },
|
|
||||||
{ value: 'cancelled', label: 'Cancelled' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Priority options
|
|
||||||
export const priorityOptions: { value: IdeaPriority; label: string }[] = [
|
|
||||||
{ value: 'low', label: 'Low' },
|
|
||||||
{ value: 'medium', label: 'Medium' },
|
|
||||||
{ value: 'high', label: 'High' },
|
|
||||||
{ value: 'critical', label: 'Critical' },
|
|
||||||
];
|
|
||||||
|
|||||||
@ -28,13 +28,15 @@ const SKELETON_COLUMNS_COUNT = 7;
|
|||||||
export function IdeasTable() {
|
export function IdeasTable() {
|
||||||
const { data, isLoading, isError } = useIdeasQuery();
|
const { data, isLoading, isError } = useIdeasQuery();
|
||||||
const deleteIdea = useDeleteIdea();
|
const deleteIdea = useDeleteIdea();
|
||||||
const { sorting, setSorting, pagination, setPage, setLimit } = useIdeasStore();
|
const { sorting, setSorting, pagination, setPage, setLimit } =
|
||||||
|
useIdeasStore();
|
||||||
|
|
||||||
const columns = useMemo(
|
const columns = useMemo(
|
||||||
() => createColumns((id) => deleteIdea.mutate(id)),
|
() => createColumns((id) => deleteIdea.mutate(id)),
|
||||||
[deleteIdea]
|
[deleteIdea],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/incompatible-library
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: data?.data ?? [],
|
data: data?.data ?? [],
|
||||||
columns,
|
columns,
|
||||||
@ -51,7 +53,9 @@ export function IdeasTable() {
|
|||||||
setPage(newPage + 1);
|
setPage(newPage + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleChangeRowsPerPage = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleChangeRowsPerPage = (
|
||||||
|
event: React.ChangeEvent<HTMLInputElement>,
|
||||||
|
) => {
|
||||||
setLimit(parseInt(event.target.value, 10));
|
setLimit(parseInt(event.target.value, 10));
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -84,20 +88,22 @@ export function IdeasTable() {
|
|||||||
active={sorting.sortBy === header.id}
|
active={sorting.sortBy === header.id}
|
||||||
direction={
|
direction={
|
||||||
sorting.sortBy === header.id
|
sorting.sortBy === header.id
|
||||||
? (sorting.sortOrder.toLowerCase() as 'asc' | 'desc')
|
? (sorting.sortOrder.toLowerCase() as
|
||||||
|
| 'asc'
|
||||||
|
| 'desc')
|
||||||
: 'asc'
|
: 'asc'
|
||||||
}
|
}
|
||||||
onClick={() => handleSort(header.id)}
|
onClick={() => handleSort(header.id)}
|
||||||
>
|
>
|
||||||
{flexRender(
|
{flexRender(
|
||||||
header.column.columnDef.header,
|
header.column.columnDef.header,
|
||||||
header.getContext()
|
header.getContext(),
|
||||||
)}
|
)}
|
||||||
</TableSortLabel>
|
</TableSortLabel>
|
||||||
) : (
|
) : (
|
||||||
flexRender(
|
flexRender(
|
||||||
header.column.columnDef.header,
|
header.column.columnDef.header,
|
||||||
header.getContext()
|
header.getContext(),
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@ -109,11 +115,13 @@ export function IdeasTable() {
|
|||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
Array.from({ length: 5 }).map((_, index) => (
|
Array.from({ length: 5 }).map((_, index) => (
|
||||||
<TableRow key={index}>
|
<TableRow key={index}>
|
||||||
{Array.from({ length: SKELETON_COLUMNS_COUNT }).map((_, colIndex) => (
|
{Array.from({ length: SKELETON_COLUMNS_COUNT }).map(
|
||||||
<TableCell key={colIndex}>
|
(_, colIndex) => (
|
||||||
<Skeleton variant="text" />
|
<TableCell key={colIndex}>
|
||||||
</TableCell>
|
<Skeleton variant="text" />
|
||||||
))}
|
</TableCell>
|
||||||
|
),
|
||||||
|
)}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
) : table.getRowModel().rows.length === 0 ? (
|
) : table.getRowModel().rows.length === 0 ? (
|
||||||
@ -156,7 +164,7 @@ export function IdeasTable() {
|
|||||||
<TableCell key={cell.id}>
|
<TableCell key={cell.id}>
|
||||||
{flexRender(
|
{flexRender(
|
||||||
cell.column.columnDef.cell,
|
cell.column.columnDef.cell,
|
||||||
cell.getContext()
|
cell.getContext(),
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -2,11 +2,15 @@ import { createColumnHelper } from '@tanstack/react-table';
|
|||||||
import { Chip, Box, IconButton } from '@mui/material';
|
import { Chip, Box, IconButton } from '@mui/material';
|
||||||
import { Delete } from '@mui/icons-material';
|
import { Delete } from '@mui/icons-material';
|
||||||
import type { Idea, IdeaStatus, IdeaPriority } from '../../types/idea';
|
import type { Idea, IdeaStatus, IdeaPriority } from '../../types/idea';
|
||||||
import { EditableCell, statusOptions, priorityOptions } from './EditableCell';
|
import { EditableCell } from './EditableCell';
|
||||||
|
import { statusOptions, priorityOptions } from './constants';
|
||||||
|
|
||||||
const columnHelper = createColumnHelper<Idea>();
|
const columnHelper = createColumnHelper<Idea>();
|
||||||
|
|
||||||
const statusColors: Record<IdeaStatus, 'default' | 'primary' | 'secondary' | 'success' | 'error'> = {
|
const statusColors: Record<
|
||||||
|
IdeaStatus,
|
||||||
|
'default' | 'primary' | 'secondary' | 'success' | 'error'
|
||||||
|
> = {
|
||||||
backlog: 'default',
|
backlog: 'default',
|
||||||
todo: 'primary',
|
todo: 'primary',
|
||||||
in_progress: 'secondary',
|
in_progress: 'secondary',
|
||||||
@ -14,7 +18,10 @@ const statusColors: Record<IdeaStatus, 'default' | 'primary' | 'secondary' | 'su
|
|||||||
cancelled: 'error',
|
cancelled: 'error',
|
||||||
};
|
};
|
||||||
|
|
||||||
const priorityColors: Record<IdeaPriority, 'default' | 'info' | 'warning' | 'error'> = {
|
const priorityColors: Record<
|
||||||
|
IdeaPriority,
|
||||||
|
'default' | 'info' | 'warning' | 'error'
|
||||||
|
> = {
|
||||||
low: 'default',
|
low: 'default',
|
||||||
medium: 'info',
|
medium: 'info',
|
||||||
high: 'warning',
|
high: 'warning',
|
||||||
@ -30,7 +37,7 @@ export const createColumns = (onDelete: (id: string) => void) => [
|
|||||||
field="title"
|
field="title"
|
||||||
value={info.getValue()}
|
value={info.getValue()}
|
||||||
renderDisplay={(value) => (
|
renderDisplay={(value) => (
|
||||||
<Box sx={{ fontWeight: 500 }}>{value || '—'}</Box>
|
<Box sx={{ fontWeight: 500 }}>{value ?? '—'}</Box>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
@ -40,7 +47,8 @@ export const createColumns = (onDelete: (id: string) => void) => [
|
|||||||
header: 'Status',
|
header: 'Status',
|
||||||
cell: (info) => {
|
cell: (info) => {
|
||||||
const status = info.getValue();
|
const status = info.getValue();
|
||||||
const label = statusOptions.find((s) => s.value === status)?.label || status;
|
const label =
|
||||||
|
statusOptions.find((s) => s.value === status)?.label ?? status;
|
||||||
return (
|
return (
|
||||||
<EditableCell
|
<EditableCell
|
||||||
idea={info.row.original}
|
idea={info.row.original}
|
||||||
@ -60,7 +68,8 @@ export const createColumns = (onDelete: (id: string) => void) => [
|
|||||||
header: 'Priority',
|
header: 'Priority',
|
||||||
cell: (info) => {
|
cell: (info) => {
|
||||||
const priority = info.getValue();
|
const priority = info.getValue();
|
||||||
const label = priorityOptions.find((p) => p.value === priority)?.label || priority;
|
const label =
|
||||||
|
priorityOptions.find((p) => p.value === priority)?.label ?? priority;
|
||||||
return (
|
return (
|
||||||
<EditableCell
|
<EditableCell
|
||||||
idea={info.row.original}
|
idea={info.row.original}
|
||||||
@ -88,7 +97,7 @@ export const createColumns = (onDelete: (id: string) => void) => [
|
|||||||
idea={info.row.original}
|
idea={info.row.original}
|
||||||
field="module"
|
field="module"
|
||||||
value={info.getValue()}
|
value={info.getValue()}
|
||||||
renderDisplay={(value) => value || '—'}
|
renderDisplay={(value) => value ?? '—'}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
size: 120,
|
size: 120,
|
||||||
@ -100,7 +109,7 @@ export const createColumns = (onDelete: (id: string) => void) => [
|
|||||||
idea={info.row.original}
|
idea={info.row.original}
|
||||||
field="targetAudience"
|
field="targetAudience"
|
||||||
value={info.getValue()}
|
value={info.getValue()}
|
||||||
renderDisplay={(value) => value || '—'}
|
renderDisplay={(value) => value ?? '—'}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
size: 150,
|
size: 150,
|
||||||
|
|||||||
16
frontend/src/components/IdeasTable/constants.ts
Normal file
16
frontend/src/components/IdeasTable/constants.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import type { IdeaStatus, IdeaPriority } from '../../types/idea';
|
||||||
|
|
||||||
|
export const statusOptions: { value: IdeaStatus; label: string }[] = [
|
||||||
|
{ value: 'backlog', label: 'Backlog' },
|
||||||
|
{ value: 'todo', label: 'To Do' },
|
||||||
|
{ value: 'in_progress', label: 'In Progress' },
|
||||||
|
{ value: 'done', label: 'Done' },
|
||||||
|
{ value: 'cancelled', label: 'Cancelled' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const priorityOptions: { value: IdeaPriority; label: string }[] = [
|
||||||
|
{ value: 'low', label: 'Low' },
|
||||||
|
{ value: 'medium', label: 'Medium' },
|
||||||
|
{ value: 'high', label: 'High' },
|
||||||
|
{ value: 'critical', label: 'Critical' },
|
||||||
|
];
|
||||||
@ -42,7 +42,7 @@ export function useCreateIdea() {
|
|||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (dto: CreateIdeaDto) => ideasApi.create(dto),
|
mutationFn: (dto: CreateIdeaDto) => ideasApi.create(dto),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
|
void queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -54,7 +54,7 @@ export function useUpdateIdea() {
|
|||||||
mutationFn: ({ id, dto }: { id: string; dto: UpdateIdeaDto }) =>
|
mutationFn: ({ id, dto }: { id: string; dto: UpdateIdeaDto }) =>
|
||||||
ideasApi.update(id, dto),
|
ideasApi.update(id, dto),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
|
void queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -65,7 +65,7 @@ export function useDeleteIdea() {
|
|||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (id: string) => ideasApi.delete(id),
|
mutationFn: (id: string) => ideasApi.delete(id),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
|
void queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react';
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { ThemeProvider, CssBaseline } from '@mui/material'
|
import { ThemeProvider, CssBaseline } from '@mui/material';
|
||||||
import { createTheme } from '@mui/material/styles'
|
import { createTheme } from '@mui/material/styles';
|
||||||
import App from './App.tsx'
|
import App from './App.tsx';
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
@ -12,15 +12,20 @@ const queryClient = new QueryClient({
|
|||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
const theme = createTheme({
|
const theme = createTheme({
|
||||||
palette: {
|
palette: {
|
||||||
mode: 'light',
|
mode: 'light',
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
const rootElement = document.getElementById('root');
|
||||||
|
if (!rootElement) {
|
||||||
|
throw new Error('Root element not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoot(rootElement).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<ThemeProvider theme={theme}>
|
<ThemeProvider theme={theme}>
|
||||||
@ -29,4 +34,4 @@ createRoot(document.getElementById('root')!).render(
|
|||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
);
|
||||||
|
|||||||
@ -1,5 +1,11 @@
|
|||||||
import { api } from './api';
|
import { api } from './api';
|
||||||
import type { Idea, CreateIdeaDto, UpdateIdeaDto, IdeaStatus, IdeaPriority } from '../types/idea';
|
import type {
|
||||||
|
Idea,
|
||||||
|
CreateIdeaDto,
|
||||||
|
UpdateIdeaDto,
|
||||||
|
IdeaStatus,
|
||||||
|
IdeaPriority,
|
||||||
|
} from '../types/idea';
|
||||||
|
|
||||||
export interface QueryIdeasParams {
|
export interface QueryIdeasParams {
|
||||||
status?: IdeaStatus;
|
status?: IdeaStatus;
|
||||||
@ -23,8 +29,12 @@ export interface PaginatedResponse<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ideasApi = {
|
export const ideasApi = {
|
||||||
getAll: async (params?: QueryIdeasParams): Promise<PaginatedResponse<Idea>> => {
|
getAll: async (
|
||||||
const { data } = await api.get<PaginatedResponse<Idea>>('/ideas', { params });
|
params?: QueryIdeasParams,
|
||||||
|
): Promise<PaginatedResponse<Idea>> => {
|
||||||
|
const { data } = await api.get<PaginatedResponse<Idea>>('/ideas', {
|
||||||
|
params,
|
||||||
|
});
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@ -21,7 +21,10 @@ interface IdeasPagination {
|
|||||||
interface IdeasStore {
|
interface IdeasStore {
|
||||||
// Filters
|
// Filters
|
||||||
filters: IdeasFilters;
|
filters: IdeasFilters;
|
||||||
setFilter: <K extends keyof IdeasFilters>(key: K, value: IdeasFilters[K]) => void;
|
setFilter: <K extends keyof IdeasFilters>(
|
||||||
|
key: K,
|
||||||
|
value: IdeasFilters[K],
|
||||||
|
) => void;
|
||||||
clearFilters: () => void;
|
clearFilters: () => void;
|
||||||
|
|
||||||
// Sorting
|
// Sorting
|
||||||
@ -59,7 +62,11 @@ export const useIdeasStore = create<IdeasStore>((set) => ({
|
|||||||
set((state) => ({
|
set((state) => ({
|
||||||
sorting: {
|
sorting: {
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder: sortOrder ?? (state.sorting.sortBy === sortBy && state.sorting.sortOrder === 'ASC' ? 'DESC' : 'ASC'),
|
sortOrder:
|
||||||
|
sortOrder ??
|
||||||
|
(state.sorting.sortBy === sortBy && state.sorting.sortOrder === 'ASC'
|
||||||
|
? 'DESC'
|
||||||
|
: 'ASC'),
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,9 @@
|
|||||||
export type IdeaStatus = 'backlog' | 'todo' | 'in_progress' | 'done' | 'cancelled';
|
export type IdeaStatus =
|
||||||
|
| 'backlog'
|
||||||
|
| 'todo'
|
||||||
|
| 'in_progress'
|
||||||
|
| 'done'
|
||||||
|
| 'cancelled';
|
||||||
export type IdeaPriority = 'low' | 'medium' | 'high' | 'critical';
|
export type IdeaPriority = 'low' | 'medium' | 'high' | 'critical';
|
||||||
|
|
||||||
export interface Idea {
|
export interface Idea {
|
||||||
|
|||||||
64
k8s/backend-deployment.yaml
Normal file
64
k8s/backend-deployment.yaml
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: team-planner-backend
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: team-planner-backend
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: team-planner-backend
|
||||||
|
spec:
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: harbor-creds
|
||||||
|
containers:
|
||||||
|
- name: team-planner-backend
|
||||||
|
image: __BACKEND_IMAGE__
|
||||||
|
ports:
|
||||||
|
- containerPort: 4001
|
||||||
|
env:
|
||||||
|
- name: NODE_ENV
|
||||||
|
value: "production"
|
||||||
|
- name: PORT
|
||||||
|
value: "4001"
|
||||||
|
- name: DB_HOST
|
||||||
|
value: "postgres-service"
|
||||||
|
- name: DB_PORT
|
||||||
|
value: "5432"
|
||||||
|
- name: DB_DATABASE
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: team-planner-secrets
|
||||||
|
key: db-name
|
||||||
|
- name: DB_USERNAME
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: team-planner-secrets
|
||||||
|
key: db-user
|
||||||
|
- name: DB_PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: team-planner-secrets
|
||||||
|
key: db-password
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: "256Mi"
|
||||||
|
cpu: "250m"
|
||||||
|
limits:
|
||||||
|
memory: "512Mi"
|
||||||
|
cpu: "500m"
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health
|
||||||
|
port: 4001
|
||||||
|
initialDelaySeconds: 30
|
||||||
|
periodSeconds: 10
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health
|
||||||
|
port: 4001
|
||||||
|
initialDelaySeconds: 10
|
||||||
|
periodSeconds: 5
|
||||||
12
k8s/backend-service.yaml
Normal file
12
k8s/backend-service.yaml
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: team-planner-backend-service
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: team-planner-backend
|
||||||
|
ports:
|
||||||
|
- protocol: TCP
|
||||||
|
port: 4001
|
||||||
|
targetPort: 4001
|
||||||
|
type: ClusterIP
|
||||||
40
k8s/frontend-deployment.yaml
Normal file
40
k8s/frontend-deployment.yaml
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: team-planner-frontend
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: team-planner-frontend
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: team-planner-frontend
|
||||||
|
spec:
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: harbor-creds
|
||||||
|
containers:
|
||||||
|
- name: team-planner-frontend
|
||||||
|
image: __FRONTEND_IMAGE__
|
||||||
|
ports:
|
||||||
|
- containerPort: 80
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: "64Mi"
|
||||||
|
cpu: "100m"
|
||||||
|
limits:
|
||||||
|
memory: "128Mi"
|
||||||
|
cpu: "200m"
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health
|
||||||
|
port: 80
|
||||||
|
initialDelaySeconds: 10
|
||||||
|
periodSeconds: 10
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health
|
||||||
|
port: 80
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 5
|
||||||
12
k8s/frontend-service.yaml
Normal file
12
k8s/frontend-service.yaml
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: team-planner-frontend-service
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: team-planner-frontend
|
||||||
|
ports:
|
||||||
|
- protocol: TCP
|
||||||
|
port: 80
|
||||||
|
targetPort: 80
|
||||||
|
type: ClusterIP
|
||||||
33
k8s/ingress.yaml
Normal file
33
k8s/ingress.yaml
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: team-planner-ingress
|
||||||
|
annotations:
|
||||||
|
traefik.ingress.kubernetes.io/router.entrypoints: websecure
|
||||||
|
traefik.ingress.kubernetes.io/router.tls: "true"
|
||||||
|
spec:
|
||||||
|
ingressClassName: traefik
|
||||||
|
tls:
|
||||||
|
- hosts:
|
||||||
|
- __HOSTNAME__
|
||||||
|
secretName: __SECRET_NAME__
|
||||||
|
rules:
|
||||||
|
- host: __HOSTNAME__
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
# Backend API routes
|
||||||
|
- path: /api
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: team-planner-backend-service
|
||||||
|
port:
|
||||||
|
number: 4001
|
||||||
|
# Frontend routes (all other paths)
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: team-planner-frontend-service
|
||||||
|
port:
|
||||||
|
number: 80
|
||||||
11
k8s/postgres-pvc.yaml
Normal file
11
k8s/postgres-pvc.yaml
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: postgres-pvc
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 10Gi
|
||||||
|
storageClassName: local-path
|
||||||
12
k8s/postgres-service.yaml
Normal file
12
k8s/postgres-service.yaml
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: postgres-service
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: postgres
|
||||||
|
ports:
|
||||||
|
- protocol: TCP
|
||||||
|
port: 5432
|
||||||
|
targetPort: 5432
|
||||||
|
type: ClusterIP
|
||||||
71
k8s/postgres-statefulset.yaml
Normal file
71
k8s/postgres-statefulset.yaml
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: StatefulSet
|
||||||
|
metadata:
|
||||||
|
name: postgres
|
||||||
|
spec:
|
||||||
|
serviceName: postgres-service
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: postgres
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: postgres
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: postgres
|
||||||
|
image: postgres:15-alpine
|
||||||
|
ports:
|
||||||
|
- containerPort: 5432
|
||||||
|
name: postgres
|
||||||
|
env:
|
||||||
|
- name: POSTGRES_DB
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: team-planner-secrets
|
||||||
|
key: db-name
|
||||||
|
- name: POSTGRES_USER
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: team-planner-secrets
|
||||||
|
key: db-user
|
||||||
|
- name: POSTGRES_PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: team-planner-secrets
|
||||||
|
key: db-password
|
||||||
|
- name: PGDATA
|
||||||
|
value: /var/lib/postgresql/data/pgdata
|
||||||
|
volumeMounts:
|
||||||
|
- name: postgres-storage
|
||||||
|
mountPath: /var/lib/postgresql/data
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: "256Mi"
|
||||||
|
cpu: "250m"
|
||||||
|
limits:
|
||||||
|
memory: "512Mi"
|
||||||
|
cpu: "500m"
|
||||||
|
livenessProbe:
|
||||||
|
exec:
|
||||||
|
command:
|
||||||
|
- sh
|
||||||
|
- -c
|
||||||
|
- 'pg_isready -h 127.0.0.1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"'
|
||||||
|
initialDelaySeconds: 30
|
||||||
|
periodSeconds: 10
|
||||||
|
timeoutSeconds: 5
|
||||||
|
readinessProbe:
|
||||||
|
exec:
|
||||||
|
command:
|
||||||
|
- sh
|
||||||
|
- -c
|
||||||
|
- 'pg_isready -h 127.0.0.1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"'
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 5
|
||||||
|
timeoutSeconds: 5
|
||||||
|
volumes:
|
||||||
|
- name: postgres-storage
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: postgres-pvc
|
||||||
16
k8s/secrets.yaml.example
Normal file
16
k8s/secrets.yaml.example
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
# This is an example file. Create the actual secrets.yaml with your real values
|
||||||
|
# DO NOT commit secrets.yaml to git!
|
||||||
|
#
|
||||||
|
# To create the secrets in your cluster, run:
|
||||||
|
# kubectl create -f secrets.yaml -n prod-ns
|
||||||
|
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: team-planner-secrets
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
# PostgreSQL credentials
|
||||||
|
db-name: "teamplanner"
|
||||||
|
db-user: "teamplanner"
|
||||||
|
db-password: "CHANGE_ME_STRONG_PASSWORD"
|
||||||
31
package-lock.json
generated
31
package-lock.json
generated
@ -6623,20 +6623,6 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"backend/node_modules/prettier": {
|
|
||||||
"version": "3.7.4",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"bin": {
|
|
||||||
"prettier": "bin/prettier.cjs"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/prettier/prettier?sponsor=1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"backend/node_modules/prettier-linter-helpers": {
|
"backend/node_modules/prettier-linter-helpers": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@ -8501,6 +8487,7 @@
|
|||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
|
"prettier": "^3.4.2",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.46.4",
|
"typescript-eslint": "^8.46.4",
|
||||||
"vite": "^7.2.4"
|
"vite": "^7.2.4"
|
||||||
@ -11825,6 +11812,22 @@
|
|||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/prettier": {
|
||||||
|
"version": "3.7.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz",
|
||||||
|
"integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"prettier": "bin/prettier.cjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/reflect-metadata": {
|
"node_modules/reflect-metadata": {
|
||||||
"version": "0.2.2",
|
"version": "0.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
|
||||||
|
|||||||
@ -13,7 +13,6 @@
|
|||||||
"build": "npm run build:backend && npm run build:frontend",
|
"build": "npm run build:backend && npm run build:frontend",
|
||||||
"build:backend": "npm run build -w backend",
|
"build:backend": "npm run build -w backend",
|
||||||
"build:frontend": "npm run build -w frontend",
|
"build:frontend": "npm run build -w frontend",
|
||||||
"lint": "npm run lint -w backend && npm run lint -w frontend",
|
|
||||||
"db:up": "docker-compose up -d postgres",
|
"db:up": "docker-compose up -d postgres",
|
||||||
"db:down": "docker-compose down"
|
"db:down": "docker-compose down"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user