add deploy
Some checks reported errors
continuous-integration/drone/push Build was killed

This commit is contained in:
2025-12-31 09:50:51 +03:00
parent 524f3ebf23
commit 85c4a36e17
40 changed files with 855 additions and 199 deletions

208
.drone.yml Normal file
View 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
View File

@ -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
View File

@ -1 +0,0 @@
/cache

View File

@ -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
View 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"]

View File

@ -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" }],
}, },
}, },

View File

@ -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",

View File

@ -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' };
}
} }

View File

@ -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 {

View File

@ -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 })

View File

@ -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);
} }

View File

@ -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
View File

@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}

40
frontend/Dockerfile Normal file
View 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;"]

View File

@ -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
View 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";
}
}
}

View File

@ -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"

View File

@ -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

View File

@ -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?"

View File

@ -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={{
input: {
startAdornment: ( startAdornment: (
<InputAdornment position="start"> <InputAdornment position="start">
<Search fontSize="small" /> <Search fontSize="small" />
</InputAdornment> </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)}
> >

View File

@ -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' },
];

View File

@ -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(
(_, colIndex) => (
<TableCell key={colIndex}> <TableCell key={colIndex}>
<Skeleton variant="text" /> <Skeleton variant="text" />
</TableCell> </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>
))} ))}

View File

@ -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,

View 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' },
];

View File

@ -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] });
}, },
}); });
} }

View File

@ -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>,
) );

View File

@ -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;
}, },

View File

@ -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'),
}, },
})), })),

View File

@ -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 {

View 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
View 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

View 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
View 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
View 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
View 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
View 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

View 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
View 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
View File

@ -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",

View File

@ -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"
}, },