Compare commits
10
Commits
b3f9655cf7
...
dec96c6ff0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dec96c6ff0 | ||
|
|
7a7424f4ae | ||
|
|
02a9320c69 | ||
|
|
0679ff21ae | ||
|
|
80d0ebb640 | ||
|
|
6e96e72b3e | ||
|
|
094c8953b6 | ||
|
|
e2e8afd07a | ||
|
|
2dba7f7cd2 | ||
|
|
9bc2d65b43 |
@@ -2,6 +2,9 @@
|
|||||||
.env
|
.env
|
||||||
*.env.local
|
*.env.local
|
||||||
|
|
||||||
|
# Runtime logs and PID files
|
||||||
|
logs/
|
||||||
|
|
||||||
# Go
|
# Go
|
||||||
backend/vendor/
|
backend/vendor/
|
||||||
backend/coverage.out
|
backend/coverage.out
|
||||||
|
|||||||
@@ -2,32 +2,79 @@
|
|||||||
-include .env
|
-include .env
|
||||||
export
|
export
|
||||||
|
|
||||||
.PHONY: docker-up docker-down migrate-up migrate-down \
|
# Ensure Go and user Go binaries are always on PATH (needed on servers where
|
||||||
run-backend run-frontend test test-coverage lint seed
|
# /etc/profile.d is only sourced for login shells, not by make).
|
||||||
|
export PATH := /usr/local/go/bin:$(HOME)/go/bin:$(PATH)
|
||||||
|
|
||||||
|
.PHONY: install build \
|
||||||
|
docker-up docker-down migrate-up migrate-down \
|
||||||
|
run-backend run-frontend stop-backend stop-frontend logs-backend logs-frontend \
|
||||||
|
dev-backend dev-frontend \
|
||||||
|
test test-coverage lint seed
|
||||||
|
|
||||||
|
## ── Setup ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Install all dependencies: Go CLI tools, Go modules, Node packages.
|
||||||
|
install:
|
||||||
|
go install github.com/pressly/goose/v3/cmd/goose@latest
|
||||||
|
go install github.com/air-verse/air@latest
|
||||||
|
cd backend && go mod download
|
||||||
|
cd frontend && npm ci
|
||||||
|
|
||||||
|
# Build the Go binary and the Next.js production bundle.
|
||||||
|
build:
|
||||||
|
mkdir -p backend/bin
|
||||||
|
cd backend && go build -o bin/api ./cmd/api
|
||||||
|
cd frontend && npm run build
|
||||||
|
|
||||||
|
## ── Infrastructure ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
## Infrastructure
|
|
||||||
docker-up:
|
docker-up:
|
||||||
docker compose up -d postgres redis
|
docker compose up -d postgres redis
|
||||||
|
|
||||||
docker-down:
|
docker-down:
|
||||||
docker compose down
|
docker compose down
|
||||||
|
|
||||||
## Migrations (requires goose: go install github.com/pressly/goose/v3/cmd/goose@latest)
|
|
||||||
migrate-up:
|
migrate-up:
|
||||||
PATH=$$HOME/go/bin:$$PATH goose -dir migrations postgres "$(DATABASE_URL)" up
|
goose -dir migrations postgres "$(DATABASE_URL)" up
|
||||||
|
|
||||||
migrate-down:
|
migrate-down:
|
||||||
PATH=$$HOME/go/bin:$$PATH goose -dir migrations postgres "$(DATABASE_URL)" down
|
goose -dir migrations postgres "$(DATABASE_URL)" down
|
||||||
|
|
||||||
|
## ── Production servers (require `make build` first) ─────────────────────────
|
||||||
|
|
||||||
## Development servers
|
|
||||||
run-backend:
|
run-backend:
|
||||||
cd backend && PATH=$$HOME/go/bin:$$PATH air
|
mkdir -p logs
|
||||||
|
nohup backend/bin/api > logs/backend.log 2>&1 & echo $$! > logs/backend.pid
|
||||||
|
@echo "Backend started (pid $$(cat logs/backend.pid)) — logs/backend.log"
|
||||||
|
|
||||||
# PORT is exported from .env (backend uses it); unset it so Next.js defaults to 3000.
|
|
||||||
run-frontend:
|
run-frontend:
|
||||||
|
mkdir -p logs
|
||||||
|
nohup sh -c 'cd frontend && env -u PORT npx next start -p 3001' > logs/frontend.log 2>&1 & echo $$! > logs/frontend.pid
|
||||||
|
@echo "Frontend started (pid $$(cat logs/frontend.pid)) — logs/frontend.log"
|
||||||
|
|
||||||
|
stop-backend:
|
||||||
|
@[ -f logs/backend.pid ] && kill $$(cat logs/backend.pid) && rm logs/backend.pid && echo "Backend stopped" || echo "Backend not running"
|
||||||
|
|
||||||
|
stop-frontend:
|
||||||
|
@[ -f logs/frontend.pid ] && kill $$(cat logs/frontend.pid) && rm logs/frontend.pid && echo "Frontend stopped" || echo "Frontend not running"
|
||||||
|
|
||||||
|
logs-backend:
|
||||||
|
tail -f logs/backend.log
|
||||||
|
|
||||||
|
logs-frontend:
|
||||||
|
tail -f logs/frontend.log
|
||||||
|
|
||||||
|
## ── Development servers (hot reload) ────────────────────────────────────────
|
||||||
|
|
||||||
|
dev-backend:
|
||||||
|
cd backend && air
|
||||||
|
|
||||||
|
dev-frontend:
|
||||||
cd frontend && env -u PORT npm run dev
|
cd frontend && env -u PORT npm run dev
|
||||||
|
|
||||||
## Testing
|
## ── Testing ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
test:
|
test:
|
||||||
cd backend && go test ./... && cd ../frontend && npm test -- --passWithNoTests
|
cd backend && go test ./... && cd ../frontend && npm test -- --passWithNoTests
|
||||||
|
|
||||||
@@ -35,11 +82,13 @@ test-coverage:
|
|||||||
cd backend && go test -coverprofile=coverage.out ./... && go tool cover -html=coverage.out -o coverage.html
|
cd backend && go test -coverprofile=coverage.out ./... && go tool cover -html=coverage.out -o coverage.html
|
||||||
@echo "Coverage report: backend/coverage.html"
|
@echo "Coverage report: backend/coverage.html"
|
||||||
|
|
||||||
## Dev seed (local DB only — never run against production)
|
## ── Dev seed (local DB only — never run against production) ──────────────────
|
||||||
|
|
||||||
seed:
|
seed:
|
||||||
cd backend && go run ./cmd/seed
|
cd backend && go run ./cmd/seed
|
||||||
|
|
||||||
## Linting
|
## ── Linting ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
cd backend && golangci-lint run ./...
|
cd backend && golangci-lint run ./...
|
||||||
cd frontend && npx tsc --noEmit && npx eslint .
|
cd frontend && npx tsc --noEmit && npx eslint .
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package admin
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
@@ -123,10 +124,12 @@ func (s *Service) ListTransactions(ctx context.Context, limit, offset int, txTyp
|
|||||||
var txs []AdminTransaction
|
var txs []AdminTransaction
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var t AdminTransaction
|
var t AdminTransaction
|
||||||
|
var createdAt time.Time
|
||||||
if err := rows.Scan(&t.ID, &t.UserID, &t.UserEmail, &t.PartnerID,
|
if err := rows.Scan(&t.ID, &t.UserID, &t.UserEmail, &t.PartnerID,
|
||||||
&t.Amount, &t.Type, &t.Description, &t.CreatedAt); err != nil {
|
&t.Amount, &t.Type, &t.Description, &createdAt); err != nil {
|
||||||
return nil, 0, fmt.Errorf("service.ListTransactions: scan: %w", err)
|
return nil, 0, fmt.Errorf("service.ListTransactions: scan: %w", err)
|
||||||
}
|
}
|
||||||
|
t.CreatedAt = createdAt.UTC().Format(time.RFC3339)
|
||||||
txs = append(txs, t)
|
txs = append(txs, t)
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
@@ -189,9 +192,11 @@ func (s *Service) ListStudents(ctx context.Context, search string, limit, offset
|
|||||||
var students []Student
|
var students []Student
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var st Student
|
var st Student
|
||||||
if err := rows.Scan(&st.ID, &st.Email, &st.Name, &st.StudentID, &st.Balance, &st.CreatedAt); err != nil {
|
var createdAt time.Time
|
||||||
|
if err := rows.Scan(&st.ID, &st.Email, &st.Name, &st.StudentID, &st.Balance, &createdAt); err != nil {
|
||||||
return nil, 0, fmt.Errorf("service.ListStudents: scan: %w", err)
|
return nil, 0, fmt.Errorf("service.ListStudents: scan: %w", err)
|
||||||
}
|
}
|
||||||
|
st.CreatedAt = createdAt.UTC().Format(time.RFC3339)
|
||||||
students = append(students, st)
|
students = append(students, st)
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
@@ -95,9 +96,11 @@ func (r *Repository) ListTransactions(ctx context.Context, userID string, limit,
|
|||||||
var txs []Transaction
|
var txs []Transaction
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var t Transaction
|
var t Transaction
|
||||||
if err := rows.Scan(&t.ID, &t.Amount, &t.Type, &t.Description, &t.PartnerID, &t.CreatedAt); err != nil {
|
var createdAt time.Time
|
||||||
|
if err := rows.Scan(&t.ID, &t.Amount, &t.Type, &t.Description, &t.PartnerID, &createdAt); err != nil {
|
||||||
return nil, fmt.Errorf("repository.ListTransactions: scan: %w", err)
|
return nil, fmt.Errorf("repository.ListTransactions: scan: %w", err)
|
||||||
}
|
}
|
||||||
|
t.CreatedAt = createdAt.UTC().Format(time.RFC3339)
|
||||||
txs = append(txs, t)
|
txs = append(txs, t)
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, type FormEvent } from 'react';
|
import { useState, type FormEvent } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { Button, Input, Card } from '@/components/ui';
|
import { Button, Input, Card } from '@/components/ui';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { useAuthStore } from '@/lib/store';
|
import { useAuthStore } from '@/lib/store';
|
||||||
@@ -29,7 +28,6 @@ const ROLE_REDIRECT: Record<UserRole, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const router = useRouter();
|
|
||||||
const { setTokens, setUser } = useAuthStore();
|
const { setTokens, setUser } = useAuthStore();
|
||||||
|
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
@@ -52,7 +50,9 @@ export default function LoginPage() {
|
|||||||
if (!role) throw new Error('Не удалось определить роль пользователя');
|
if (!role) throw new Error('Не удалось определить роль пользователя');
|
||||||
|
|
||||||
setUser({ ...profile, role });
|
setUser({ ...profile, role });
|
||||||
router.push(ROLE_REDIRECT[role]);
|
// Hard redirect so the browser sends a fresh request with the new cookie.
|
||||||
|
// router.push() can use a stale Next.js router cache and miss the cookie.
|
||||||
|
window.location.href = ROLE_REDIRECT[role];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : 'Ошибка входа');
|
setError(e instanceof Error ? e.message : 'Ошибка входа');
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { StudentNav } from '@/components/StudentNav';
|
||||||
import { BalanceCard } from '@/components/BalanceCard';
|
import { BalanceCard } from '@/components/BalanceCard';
|
||||||
import { TransactionList } from '@/components/TransactionList';
|
import { TransactionList } from '@/components/TransactionList';
|
||||||
import { Spinner } from '@/components/ui';
|
import { Spinner } from '@/components/ui';
|
||||||
@@ -58,6 +59,8 @@ export default function StudentDashboardPage() {
|
|||||||
const balance = profile?.balance ?? user?.balance ?? 0;
|
const balance = profile?.balance ?? user?.balance ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="min-h-screen">
|
||||||
|
<StudentNav />
|
||||||
<main className="mx-auto max-w-lg space-y-6 p-4 pb-10 pt-6">
|
<main className="mx-auto max-w-lg space-y-6 p-4 pb-10 pt-6">
|
||||||
<h1 className="text-xl font-bold text-white">
|
<h1 className="text-xl font-bold text-white">
|
||||||
Привет, {displayName.split(' ')[0]} 👋
|
Привет, {displayName.split(' ')[0]} 👋
|
||||||
@@ -77,5 +80,6 @@ export default function StudentDashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import Link from 'next/link';
|
import { StudentNav } from '@/components/StudentNav';
|
||||||
import { TransactionList } from '@/components/TransactionList';
|
import { TransactionList } from '@/components/TransactionList';
|
||||||
import { Button } from '@/components/ui';
|
import { Button } from '@/components/ui';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
@@ -49,13 +49,10 @@ export default function HistoryPage() {
|
|||||||
const hasMore = transactions.length < total;
|
const hasMore = transactions.length < total;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="min-h-screen">
|
||||||
|
<StudentNav />
|
||||||
<main className="mx-auto max-w-lg p-4 pb-10 pt-6">
|
<main className="mx-auto max-w-lg p-4 pb-10 pt-6">
|
||||||
<div className="mb-4 flex items-center gap-3">
|
<h1 className="mb-4 text-xl font-bold text-white">История операций</h1>
|
||||||
<Link href="/dashboard" className="text-sm text-blue-400 hover:text-blue-300">
|
|
||||||
← Назад
|
|
||||||
</Link>
|
|
||||||
<h1 className="text-xl font-bold text-white">История операций</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<p className="mb-4 rounded-lg bg-red-900/30 px-3 py-2 text-sm text-red-400">{error}</p>
|
<p className="mb-4 rounded-lg bg-red-900/30 px-3 py-2 text-sm text-red-400">{error}</p>
|
||||||
@@ -77,5 +74,6 @@ export default function HistoryPage() {
|
|||||||
<p className="mt-4 text-center text-xs text-gray-600">Это все операции</p>
|
<p className="mt-4 text-center text-xs text-gray-600">Это все операции</p>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import Link from 'next/link';
|
import { StudentNav } from '@/components/StudentNav';
|
||||||
import { PartnerCard } from '@/components/PartnerCard';
|
import { PartnerCard } from '@/components/PartnerCard';
|
||||||
import { Spinner } from '@/components/ui';
|
import { Spinner } from '@/components/ui';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
@@ -27,13 +27,10 @@ export default function PartnersPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="min-h-screen">
|
||||||
|
<StudentNav />
|
||||||
<main className="mx-auto max-w-lg p-4 pb-10 pt-6">
|
<main className="mx-auto max-w-lg p-4 pb-10 pt-6">
|
||||||
<div className="mb-4 flex items-center gap-3">
|
<h1 className="mb-4 text-xl font-bold text-white">Партнёры</h1>
|
||||||
<Link href="/dashboard" className="text-sm text-blue-400 hover:text-blue-300">
|
|
||||||
← Назад
|
|
||||||
</Link>
|
|
||||||
<h1 className="text-xl font-bold text-white">Партнёры</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="flex justify-center py-12">
|
<div className="flex justify-center py-12">
|
||||||
@@ -57,5 +54,6 @@ export default function PartnersPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,21 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import { StudentNav } from '@/components/StudentNav';
|
||||||
import { QRDisplay } from '@/components/QRDisplay';
|
import { QRDisplay } from '@/components/QRDisplay';
|
||||||
|
|
||||||
export default function QRPage() {
|
export default function QRPage() {
|
||||||
return (
|
return (
|
||||||
<main className="flex min-h-screen flex-col items-center justify-center gap-6 p-6">
|
<div className="min-h-screen">
|
||||||
<h1 className="text-xl font-bold text-white">Оплата поинтами</h1>
|
<StudentNav />
|
||||||
|
<main className="flex flex-col items-center justify-center gap-6 p-6 pt-12">
|
||||||
|
<h1 className="text-xl font-bold text-white">Оплата поинтами</h1>
|
||||||
|
|
||||||
<QRDisplay />
|
<QRDisplay />
|
||||||
|
|
||||||
<p className="max-w-xs text-center text-sm text-gray-500">
|
<p className="max-w-xs text-center text-sm text-gray-500">
|
||||||
Покажи этот QR кассиру. Он действителен 5 минут и может быть использован только один раз.
|
Покажи этот QR кассиру. Он действителен 5 минут и может быть использован только один раз.
|
||||||
</p>
|
</p>
|
||||||
|
</main>
|
||||||
<Link href="/dashboard" className="text-sm text-blue-400 hover:text-blue-300">
|
</div>
|
||||||
← Назад
|
|
||||||
</Link>
|
|
||||||
</main>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
import { useAuthStore } from '@/lib/store';
|
||||||
|
|
||||||
|
const LINKS = [
|
||||||
|
{ href: '/dashboard', label: 'Главная' },
|
||||||
|
{ href: '/history', label: 'История' },
|
||||||
|
{ href: '/qr', label: 'QR-код' },
|
||||||
|
{ href: '/partners', label: 'Партнёры' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function StudentNav() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const { logout } = useAuthStore();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="border-b border-gray-700 bg-gray-900">
|
||||||
|
<div className="mx-auto flex max-w-lg flex-wrap items-center gap-1 px-4 py-3">
|
||||||
|
<span className="mr-4 text-sm font-semibold text-white">CU Points</span>
|
||||||
|
{LINKS.map((link) => (
|
||||||
|
<Link
|
||||||
|
key={link.href}
|
||||||
|
href={link.href}
|
||||||
|
className={`rounded-lg px-3 py-1.5 text-sm transition-colors ${
|
||||||
|
pathname === link.href
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'text-gray-400 hover:bg-gray-700 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
onClick={logout}
|
||||||
|
className="ml-auto rounded-lg px-3 py-1.5 text-sm text-gray-400 transition-colors hover:bg-gray-700 hover:text-white"
|
||||||
|
>
|
||||||
|
Выйти
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ function persistToken(accessToken: string): void {
|
|||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
localStorage.setItem('access_token', accessToken);
|
localStorage.setItem('access_token', accessToken);
|
||||||
// 15 min lifetime matches the default JWT_ACCESS_TTL
|
// 15 min lifetime matches the default JWT_ACCESS_TTL
|
||||||
document.cookie = `access_token=${accessToken}; path=/; SameSite=Strict; max-age=900`;
|
document.cookie = `access_token=${accessToken}; path=/; SameSite=Lax; max-age=900`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAuthStore = create<AuthState>()(
|
export const useAuthStore = create<AuthState>()(
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# setup.sh — install system dependencies (Go + Node.js) and project deps.
|
||||||
|
# Run once on a fresh server: bash setup.sh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
GO_VERSION="1.22.5"
|
||||||
|
NODE_MAJOR="20"
|
||||||
|
|
||||||
|
# ── helpers ────────────────────────────────────────────────────────────────────
|
||||||
|
info() { echo "[setup] $*"; }
|
||||||
|
error() { echo "[setup] ERROR: $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
# ── Go ─────────────────────────────────────────────────────────────────────────
|
||||||
|
if command -v go &>/dev/null; then
|
||||||
|
info "Go already installed: $(go version)"
|
||||||
|
else
|
||||||
|
info "Installing Go ${GO_VERSION}..."
|
||||||
|
ARCH=$(uname -m)
|
||||||
|
case "$ARCH" in
|
||||||
|
x86_64) GOARCH="amd64" ;;
|
||||||
|
aarch64) GOARCH="arm64" ;;
|
||||||
|
*) error "Unsupported arch: $ARCH" ;;
|
||||||
|
esac
|
||||||
|
TARBALL="go${GO_VERSION}.linux-${GOARCH}.tar.gz"
|
||||||
|
curl -fsSL "https://go.dev/dl/${TARBALL}" -o "/tmp/${TARBALL}"
|
||||||
|
rm -rf /usr/local/go
|
||||||
|
tar -C /usr/local -xzf "/tmp/${TARBALL}"
|
||||||
|
rm "/tmp/${TARBALL}"
|
||||||
|
|
||||||
|
# Persist PATH for future shells
|
||||||
|
PROFILE=/etc/profile.d/go.sh
|
||||||
|
echo 'export PATH=$PATH:/usr/local/go/bin:$HOME/go/bin' > "$PROFILE"
|
||||||
|
info "Go installed. PATH updated in $PROFILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Make Go available in this script's session
|
||||||
|
export PATH=$PATH:/usr/local/go/bin:$HOME/go/bin
|
||||||
|
|
||||||
|
go version || error "Go install failed"
|
||||||
|
|
||||||
|
# ── Node.js ────────────────────────────────────────────────────────────────────
|
||||||
|
if command -v node &>/dev/null; then
|
||||||
|
info "Node.js already installed: $(node --version)"
|
||||||
|
else
|
||||||
|
info "Installing Node.js ${NODE_MAJOR}.x via NodeSource..."
|
||||||
|
if command -v apt-get &>/dev/null; then
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y -qq curl ca-certificates gnupg
|
||||||
|
curl -fsSL "https://deb.nodesource.com/setup_${NODE_MAJOR}.x" | bash -
|
||||||
|
apt-get install -y -qq nodejs
|
||||||
|
elif command -v dnf &>/dev/null; then
|
||||||
|
dnf install -y "nodejs:${NODE_MAJOR}"
|
||||||
|
else
|
||||||
|
error "Unsupported package manager — install Node.js ${NODE_MAJOR}+ manually then re-run."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
node --version || error "Node.js install failed"
|
||||||
|
npm --version
|
||||||
|
|
||||||
|
# ── Docker ─────────────────────────────────────────────────────────────────────
|
||||||
|
if command -v docker &>/dev/null; then
|
||||||
|
info "Docker already installed: $(docker --version)"
|
||||||
|
else
|
||||||
|
info "Installing Docker..."
|
||||||
|
if command -v apt-get &>/dev/null; then
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y -qq ca-certificates curl gnupg lsb-release
|
||||||
|
install -m 0755 -d /etc/apt/keyrings
|
||||||
|
curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg \
|
||||||
|
| gpg --dearmor -o /etc/apt/keyrings/docker.gpg
|
||||||
|
chmod a+r /etc/apt/keyrings/docker.gpg
|
||||||
|
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
|
||||||
|
https://download.docker.com/linux/$(. /etc/os-release && echo "$ID") \
|
||||||
|
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
|
||||||
|
> /etc/apt/sources.list.d/docker.list
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-compose-plugin
|
||||||
|
elif command -v dnf &>/dev/null; then
|
||||||
|
dnf install -y docker docker-compose-plugin
|
||||||
|
systemctl enable --now docker
|
||||||
|
else
|
||||||
|
error "Unsupported package manager — install Docker manually then re-run."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker --version || error "Docker install failed"
|
||||||
|
docker compose version || error "Docker Compose plugin missing"
|
||||||
|
|
||||||
|
# ── Project dependencies ───────────────────────────────────────────────────────
|
||||||
|
info "Running make install..."
|
||||||
|
make install
|
||||||
|
|
||||||
|
info "Done. Next steps:"
|
||||||
|
info " 1. cp .env.example .env (fill in your secrets)"
|
||||||
|
info " 2. make docker-up && make migrate-up"
|
||||||
|
info " 3. make build"
|
||||||
|
info " 4. make run-backend (terminal 1)"
|
||||||
|
info " 5. make run-frontend (terminal 2)"
|
||||||
Reference in New Issue
Block a user