mirror of
https://github.com/sakaljurgis/best-choice.git
synced 2026-07-08 21:47:40 +00:00
initial setup
This commit is contained in:
+11
@@ -0,0 +1,11 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
client/dist
|
||||||
|
server/dist
|
||||||
|
codex.sh
|
||||||
|
.env
|
||||||
|
.idea
|
||||||
|
.git
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
# syntax=docker/dockerfile:1.6
|
||||||
|
|
||||||
|
FROM node:20-alpine AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
COPY client/package.json client/
|
||||||
|
COPY server/package.json server/
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
FROM deps AS build
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
RUN npm prune --omit=dev
|
||||||
|
|
||||||
|
FROM node:20-alpine AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3000
|
||||||
|
COPY --from=build /app/package.json ./
|
||||||
|
COPY --from=build /app/package-lock.json ./
|
||||||
|
COPY --from=build /app/client/package.json client/
|
||||||
|
COPY --from=build /app/server/package.json server/
|
||||||
|
COPY --from=build /app/node_modules ./node_modules
|
||||||
|
COPY --from=build /app/server/dist ./server/dist
|
||||||
|
COPY --from=build /app/client/dist ./client/dist
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["node", "server/dist/index.js"]
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>BestChoice</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "@best-choice/client",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"lint": "eslint \"src/**/*.{ts,tsx}\"",
|
||||||
|
"test": "echo \"No tests yet\""
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "MIT",
|
||||||
|
"description": "React frontend for the best-choice project",
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/react-query": "^5.90.2",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"react-router-dom": "^7.9.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.2.2",
|
||||||
|
"@types/react-dom": "^19.2.1",
|
||||||
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
|
"autoprefixer": "^10.4.21",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"tailwindcss": "^3.4.15",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vite": "^7.1.9"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Public Assets
|
||||||
|
|
||||||
|
Place static files (images, documents, etc.) in this directory to be served by Vite during development and included in the production build.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { apiFetch } from './http-client';
|
||||||
|
|
||||||
|
export type HealthResponse = {
|
||||||
|
status: string;
|
||||||
|
service: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getHealth() {
|
||||||
|
return apiFetch<HealthResponse>('/health');
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? '/api';
|
||||||
|
|
||||||
|
type ApiFetchOptions = Omit<RequestInit, 'body'> & {
|
||||||
|
body?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function apiFetch<TResponse>(path: string, options: ApiFetchOptions = {}): Promise<TResponse> {
|
||||||
|
const { body, headers, ...rest } = options;
|
||||||
|
const init: RequestInit = {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...headers
|
||||||
|
},
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
...rest
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE_URL}${path}`, init);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Request failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<TResponse>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import AppRoutes from './routes/app-routes';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return <AppRoutes />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
color: #1f2933;
|
||||||
|
background-color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply m-0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { NavLink, Outlet } from 'react-router-dom';
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ label: 'Health Check', to: '/' },
|
||||||
|
// future nav items can be added here
|
||||||
|
];
|
||||||
|
|
||||||
|
function AppLayout() {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen bg-slate-50 text-slate-800">
|
||||||
|
<aside className="flex w-64 flex-col border-r border-slate-200 bg-white px-6 py-8 shadow-sm">
|
||||||
|
<div className="mb-8">
|
||||||
|
<span className="block text-sm font-semibold uppercase tracking-wide text-slate-400">
|
||||||
|
BestChoice
|
||||||
|
</span>
|
||||||
|
<h1 className="text-2xl font-semibold text-slate-900">Control Center</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex flex-1 flex-col gap-2">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
end
|
||||||
|
className={({ isActive }) =>
|
||||||
|
[
|
||||||
|
'rounded-md px-3 py-2 text-sm font-medium transition',
|
||||||
|
isActive
|
||||||
|
? 'bg-blue-600 text-white shadow-sm'
|
||||||
|
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900',
|
||||||
|
].join(' ')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<footer className="mt-8 text-xs text-slate-400">
|
||||||
|
<p>© {new Date().getFullYear()} BestChoice</p>
|
||||||
|
</footer>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div className="flex flex-1 flex-col">
|
||||||
|
<header className="border-b border-slate-200 bg-white px-8 py-6 shadow-sm">
|
||||||
|
<h2 className="text-xl font-semibold text-slate-900">Dashboard</h2>
|
||||||
|
<p className="text-sm text-slate-500">Monitor service health and status</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="flex-1 overflow-y-auto px-8 py-10">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AppLayout;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import App from './app';
|
||||||
|
import './index.css';
|
||||||
|
import AppProviders from './providers/app-providers';
|
||||||
|
|
||||||
|
const rootElement = document.getElementById('root');
|
||||||
|
|
||||||
|
if (!rootElement) {
|
||||||
|
throw new Error('Root element not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
ReactDOM.createRoot(rootElement).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<AppProviders>
|
||||||
|
<App />
|
||||||
|
</AppProviders>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { useHealthQuery } from '../query/use-health-query';
|
||||||
|
|
||||||
|
function HealthPage() {
|
||||||
|
const appName = import.meta.env.VITE_APP_NAME ?? 'BestChoice';
|
||||||
|
const { data, isLoading, isError, error, refetch, isFetching } = useHealthQuery();
|
||||||
|
|
||||||
|
const statusText = (() => {
|
||||||
|
if (isLoading) {
|
||||||
|
return 'loading...';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return 'unreachable';
|
||||||
|
}
|
||||||
|
|
||||||
|
return data?.status ?? 'unknown';
|
||||||
|
})();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="mx-auto flex max-w-md flex-col items-center gap-4 px-4 py-12 text-center text-slate-800">
|
||||||
|
<h1 className="text-4xl font-semibold">{appName}</h1>
|
||||||
|
<p className="text-lg">
|
||||||
|
API status: <span className="font-medium text-blue-600">{statusText}</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-slate-500">Service: {data?.service ?? 'n/a'}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => refetch()}
|
||||||
|
disabled={isFetching}
|
||||||
|
className="rounded-full bg-blue-600 px-5 py-2 text-white transition enabled:hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-slate-400"
|
||||||
|
>
|
||||||
|
{isFetching ? 'Refreshing…' : 'Refresh'}
|
||||||
|
</button>
|
||||||
|
{isError ? <p className="text-sm text-red-600">{(error as Error).message}</p> : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default HealthPage;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { PropsWithChildren } from 'react';
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
import { queryClient } from '../query/query-client';
|
||||||
|
|
||||||
|
function AppProviders({ children }: PropsWithChildren) {
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AppProviders;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { QueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
export const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
retry: 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { getHealth, type HealthResponse } from '../api/health';
|
||||||
|
|
||||||
|
const healthQueryKeys = {
|
||||||
|
all: ['health'] as const
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useHealthQuery() {
|
||||||
|
return useQuery<HealthResponse>({
|
||||||
|
queryKey: healthQueryKeys.all,
|
||||||
|
queryFn: getHealth,
|
||||||
|
staleTime: 30_000
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { Route, Routes } from 'react-router-dom';
|
||||||
|
import AppLayout from '../layouts/app-layout';
|
||||||
|
import HealthPage from '../pages/health-page';
|
||||||
|
|
||||||
|
function AppRoutes() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route element={<AppLayout />}>
|
||||||
|
<Route index element={<HealthPage />} />
|
||||||
|
<Route path="*" element={<HealthPage />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AppRoutes;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { Config } from 'tailwindcss';
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||||
|
theme: {
|
||||||
|
extend: {}
|
||||||
|
},
|
||||||
|
plugins: []
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"extends": "../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"baseUrl": "./src"
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { defineConfig, loadEnv } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
export default defineConfig(({ mode }) => {
|
||||||
|
const rootDir = path.resolve(__dirname, '..');
|
||||||
|
const env = loadEnv(mode, rootDir, '');
|
||||||
|
const clientPort = Number(env.CLIENT_PORT) || 5173;
|
||||||
|
const proxyTarget = env.API_PROXY_TARGET || 'http://localhost:3000';
|
||||||
|
|
||||||
|
return {
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: clientPort,
|
||||||
|
proxy: {
|
||||||
|
'/api': proxyTarget
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
version: "3.9"
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: pgvector/pgvector:pg16
|
||||||
|
container_name: bc_pg
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: bc_dev
|
||||||
|
POSTGRES_USER: admin
|
||||||
|
POSTGRES_PASSWORD: admin
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import js from '@eslint/js';
|
||||||
|
import reactPlugin from 'eslint-plugin-react';
|
||||||
|
import reactHooksPlugin from 'eslint-plugin-react-hooks';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{
|
||||||
|
ignores: ['node_modules', 'dist', 'build', 'server/dist', 'client/dist']
|
||||||
|
},
|
||||||
|
js.configs.recommended,
|
||||||
|
...tseslint.configs.recommended,
|
||||||
|
{
|
||||||
|
files: ['client/**/*.{ts,tsx,js,jsx}'],
|
||||||
|
plugins: {
|
||||||
|
react: reactPlugin,
|
||||||
|
'react-hooks': reactHooksPlugin
|
||||||
|
},
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
ecmaFeatures: {
|
||||||
|
jsx: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'react/react-in-jsx-scope': 'off',
|
||||||
|
'react/jsx-uses-react': 'off',
|
||||||
|
'react-hooks/rules-of-hooks': 'error',
|
||||||
|
'react-hooks/exhaustive-deps': 'warn'
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
react: {
|
||||||
|
version: 'detect'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
quotes: ['error', 'single', { allowTemplateLiterals: true }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
Generated
+6426
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "best-choice",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"",
|
||||||
|
"dev:server": "npm --workspace @best-choice/server run dev",
|
||||||
|
"dev:client": "npm --workspace @best-choice/client run dev",
|
||||||
|
"build": "npm run build --workspaces --if-present",
|
||||||
|
"lint": "npm run lint --workspaces --if-present",
|
||||||
|
"test": "npm run test --workspaces --if-present"
|
||||||
|
},
|
||||||
|
"workspaces": [
|
||||||
|
"client",
|
||||||
|
"server"
|
||||||
|
],
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "MIT",
|
||||||
|
"description": "",
|
||||||
|
"type": "module",
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.37.0",
|
||||||
|
"concurrently": "^9.2.1",
|
||||||
|
"eslint": "^9.37.0",
|
||||||
|
"eslint-plugin-react": "^7.37.5",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.0",
|
||||||
|
"typescript-eslint": "^8.46.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "@best-choice/server",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"build": "tsc --project tsconfig.json",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"lint": "eslint \"src/**/*.ts\"",
|
||||||
|
"test": "echo \"No tests yet\""
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "MIT",
|
||||||
|
"description": "Express API for the best-choice project",
|
||||||
|
"dependencies": {
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"express": "^5.1.0",
|
||||||
|
"pg": "^8.12.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/express": "^5.0.3",
|
||||||
|
"@types/node": "^24.7.1",
|
||||||
|
"@types/pg": "^8.11.6",
|
||||||
|
"tsx": "^4.20.6",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import apiRouter from './routes/api-router.js';
|
||||||
|
import { env } from './config/env.js';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(env.apiBasePath, apiRouter);
|
||||||
|
|
||||||
|
export default app;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { config } from 'dotenv';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const rootDir = path.resolve(__dirname, '../../..');
|
||||||
|
|
||||||
|
config({ path: path.resolve(rootDir, '.env') });
|
||||||
|
|
||||||
|
const port = Number(process.env.SERVER_PORT ?? process.env.PORT ?? 3000);
|
||||||
|
|
||||||
|
export const env = {
|
||||||
|
port,
|
||||||
|
apiBasePath: process.env.API_BASE_PATH ?? '/api',
|
||||||
|
databaseUrl: process.env.DATABASE_URL ?? '',
|
||||||
|
appName: process.env.APP_NAME ?? 'BestChoice'
|
||||||
|
};
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { Request, Response } from 'express';
|
||||||
|
|
||||||
|
export function getHealth(_req: Request, res: Response) {
|
||||||
|
res.json({ status: 'ok', service: 'best-choice-api' });
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { Migration } from './types.js';
|
||||||
|
|
||||||
|
export const migration: Migration = {
|
||||||
|
name: '0001-enable-pgvector',
|
||||||
|
up: async (client) => {
|
||||||
|
await client.query('CREATE EXTENSION IF NOT EXISTS vector;');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import type { Migration } from './types.js';
|
||||||
|
import { migration as enablePgvector } from './0001-enable-pgvector.js';
|
||||||
|
|
||||||
|
export const migrations: Migration[] = [enablePgvector];
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import type { ClientBase } from 'pg';
|
||||||
|
|
||||||
|
export interface Migration {
|
||||||
|
name: string;
|
||||||
|
up: (client: ClientBase) => Promise<void>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Pool } from 'pg';
|
||||||
|
import { env } from '../config/env.js';
|
||||||
|
|
||||||
|
if (!env.databaseUrl) {
|
||||||
|
throw new Error('DATABASE_URL is not set.');
|
||||||
|
}
|
||||||
|
|
||||||
|
export const pool = new Pool({
|
||||||
|
connectionString: env.databaseUrl
|
||||||
|
});
|
||||||
|
|
||||||
|
pool.on('error', (error) => {
|
||||||
|
console.error('Unexpected PostgreSQL client error', error);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getClient = () => pool.connect();
|
||||||
|
|
||||||
|
export const query = (text: string, params?: unknown[]) => pool.query(text, params);
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import type { PoolClient } from 'pg';
|
||||||
|
import { pool } from './pool.js';
|
||||||
|
import { migrations } from './migrations/index.js';
|
||||||
|
|
||||||
|
const MIGRATIONS_TABLE = 'migrations';
|
||||||
|
|
||||||
|
const ensureMigrationsTable = async (client: PoolClient) => {
|
||||||
|
await client.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS ${MIGRATIONS_TABLE} (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
run_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const runMigrations = async () => {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await ensureMigrationsTable(client);
|
||||||
|
|
||||||
|
const executedMigrations = await client.query<{ name: string }>(
|
||||||
|
`SELECT name FROM ${MIGRATIONS_TABLE} ORDER BY run_at ASC`
|
||||||
|
);
|
||||||
|
const executedCodeNames = new Set(
|
||||||
|
executedMigrations.rows.map((row) => row.name)
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const migration of migrations) {
|
||||||
|
if (executedCodeNames.has(migration.name)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Running migration ${migration.name}`);
|
||||||
|
|
||||||
|
await client.query('BEGIN');
|
||||||
|
try {
|
||||||
|
await migration.up(client);
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO ${MIGRATIONS_TABLE} (name) VALUES ($1)`,
|
||||||
|
[migration.name]
|
||||||
|
);
|
||||||
|
await client.query('COMMIT');
|
||||||
|
console.log(`Migration ${migration.name} completed`);
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import app from './app.js';
|
||||||
|
import { env } from './config/env.js';
|
||||||
|
import { runMigrations } from './db/run-migrations.js';
|
||||||
|
|
||||||
|
const startServer = async () => {
|
||||||
|
try {
|
||||||
|
await runMigrations();
|
||||||
|
|
||||||
|
const port = env.port;
|
||||||
|
app.listen(port, () => {
|
||||||
|
console.log(`API server ready on http://localhost:${port}`);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to start server', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void startServer();
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { getHealth } from '../controllers/health-controller.js';
|
||||||
|
|
||||||
|
const apiRouter = Router();
|
||||||
|
|
||||||
|
apiRouter.get('/health', getHealth);
|
||||||
|
|
||||||
|
export default apiRouter;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src"
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2021",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"resolveJsonModule": true
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user