Compare commits
10
Commits
b3f9655cf7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dec96c6ff0 | ||
|
|
7a7424f4ae | ||
|
|
02a9320c69 | ||
|
|
0679ff21ae | ||
|
|
80d0ebb640 | ||
|
|
6e96e72b3e | ||
|
|
094c8953b6 | ||
|
|
e2e8afd07a | ||
|
|
2dba7f7cd2 | ||
|
|
9bc2d65b43 |
@@ -2,6 +2,9 @@
|
||||
.env
|
||||
*.env.local
|
||||
|
||||
# Runtime logs and PID files
|
||||
logs/
|
||||
|
||||
# Go
|
||||
backend/vendor/
|
||||
backend/coverage.out
|
||||
|
||||
@@ -2,32 +2,79 @@
|
||||
-include .env
|
||||
export
|
||||
|
||||
.PHONY: docker-up docker-down migrate-up migrate-down \
|
||||
run-backend run-frontend test test-coverage lint seed
|
||||
# Ensure Go and user Go binaries are always on PATH (needed on servers where
|
||||
# /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 compose up -d postgres redis
|
||||
|
||||
docker-down:
|
||||
docker compose down
|
||||
|
||||
## Migrations (requires goose: go install github.com/pressly/goose/v3/cmd/goose@latest)
|
||||
migrate-up:
|
||||
PATH=$$HOME/go/bin:$$PATH goose -dir migrations postgres "$(DATABASE_URL)" up
|
||||
goose -dir migrations postgres "$(DATABASE_URL)" up
|
||||
|
||||
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:
|
||||
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:
|
||||
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
|
||||
|
||||
## Testing
|
||||
## ── Testing ──────────────────────────────────────────────────────────────────
|
||||
|
||||
test:
|
||||
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
|
||||
@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:
|
||||
cd backend && go run ./cmd/seed
|
||||
|
||||
## Linting
|
||||
## ── Linting ──────────────────────────────────────────────────────────────────
|
||||
|
||||
lint:
|
||||
cd backend && golangci-lint run ./...
|
||||
cd frontend && npx tsc --noEmit && npx eslint .
|
||||
|
||||
@@ -3,6 +3,7 @@ package admin
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
@@ -123,10 +124,12 @@ func (s *Service) ListTransactions(ctx context.Context, limit, offset int, txTyp
|
||||
var txs []AdminTransaction
|
||||
for rows.Next() {
|
||||
var t AdminTransaction
|
||||
var createdAt time.Time
|
||||
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)
|
||||
}
|
||||
t.CreatedAt = createdAt.UTC().Format(time.RFC3339)
|
||||
txs = append(txs, t)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
@@ -189,9 +192,11 @@ func (s *Service) ListStudents(ctx context.Context, search string, limit, offset
|
||||
var students []Student
|
||||
for rows.Next() {
|
||||
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)
|
||||
}
|
||||
st.CreatedAt = createdAt.UTC().Format(time.RFC3339)
|
||||
students = append(students, st)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
@@ -95,9 +96,11 @@ func (r *Repository) ListTransactions(ctx context.Context, userID string, limit,
|
||||
var txs []Transaction
|
||||
for rows.Next() {
|
||||
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)
|
||||
}
|
||||
t.CreatedAt = createdAt.UTC().Format(time.RFC3339)
|
||||
txs = append(txs, t)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Button, Input, Card } from '@/components/ui';
|
||||
import { api } from '@/lib/api';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
@@ -29,7 +28,6 @@ const ROLE_REDIRECT: Record<UserRole, string> = {
|
||||
};
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const { setTokens, setUser } = useAuthStore();
|
||||
|
||||
const [email, setEmail] = useState('');
|
||||
@@ -52,7 +50,9 @@ export default function LoginPage() {
|
||||
if (!role) throw new Error('Не удалось определить роль пользователя');
|
||||
|
||||
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) {
|
||||
setError(e instanceof Error ? e.message : 'Ошибка входа');
|
||||
} finally {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { StudentNav } from '@/components/StudentNav';
|
||||
import { BalanceCard } from '@/components/BalanceCard';
|
||||
import { TransactionList } from '@/components/TransactionList';
|
||||
import { Spinner } from '@/components/ui';
|
||||
@@ -58,6 +59,8 @@ export default function StudentDashboardPage() {
|
||||
const balance = profile?.balance ?? user?.balance ?? 0;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<StudentNav />
|
||||
<main className="mx-auto max-w-lg space-y-6 p-4 pb-10 pt-6">
|
||||
<h1 className="text-xl font-bold text-white">
|
||||
Привет, {displayName.split(' ')[0]} 👋
|
||||
@@ -77,5 +80,6 @@ export default function StudentDashboardPage() {
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { StudentNav } from '@/components/StudentNav';
|
||||
import { TransactionList } from '@/components/TransactionList';
|
||||
import { Button } from '@/components/ui';
|
||||
import { api } from '@/lib/api';
|
||||
@@ -49,13 +49,10 @@ export default function HistoryPage() {
|
||||
const hasMore = transactions.length < total;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<StudentNav />
|
||||
<main className="mx-auto max-w-lg p-4 pb-10 pt-6">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<Link href="/dashboard" className="text-sm text-blue-400 hover:text-blue-300">
|
||||
← Назад
|
||||
</Link>
|
||||
<h1 className="text-xl font-bold text-white">История операций</h1>
|
||||
</div>
|
||||
<h1 className="mb-4 text-xl font-bold text-white">История операций</h1>
|
||||
|
||||
{error && (
|
||||
<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>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { StudentNav } from '@/components/StudentNav';
|
||||
import { PartnerCard } from '@/components/PartnerCard';
|
||||
import { Spinner } from '@/components/ui';
|
||||
import { api } from '@/lib/api';
|
||||
@@ -27,13 +27,10 @@ export default function PartnersPage() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<StudentNav />
|
||||
<main className="mx-auto max-w-lg p-4 pb-10 pt-6">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<Link href="/dashboard" className="text-sm text-blue-400 hover:text-blue-300">
|
||||
← Назад
|
||||
</Link>
|
||||
<h1 className="text-xl font-bold text-white">Партнёры</h1>
|
||||
</div>
|
||||
<h1 className="mb-4 text-xl font-bold text-white">Партнёры</h1>
|
||||
|
||||
{loading && (
|
||||
<div className="flex justify-center py-12">
|
||||
@@ -57,5 +54,6 @@ export default function PartnersPage() {
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { StudentNav } from '@/components/StudentNav';
|
||||
import { QRDisplay } from '@/components/QRDisplay';
|
||||
|
||||
export default function QRPage() {
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-center gap-6 p-6">
|
||||
<h1 className="text-xl font-bold text-white">Оплата поинтами</h1>
|
||||
<div className="min-h-screen">
|
||||
<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">
|
||||
Покажи этот QR кассиру. Он действителен 5 минут и может быть использован только один раз.
|
||||
</p>
|
||||
|
||||
<Link href="/dashboard" className="text-sm text-blue-400 hover:text-blue-300">
|
||||
← Назад
|
||||
</Link>
|
||||
</main>
|
||||
<p className="max-w-xs text-center text-sm text-gray-500">
|
||||
Покажи этот QR кассиру. Он действителен 5 минут и может быть использован только один раз.
|
||||
</p>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
localStorage.setItem('access_token', accessToken);
|
||||
// 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>()(
|
||||
|
||||
@@ -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