Compare commits

..
10 Commits
Author SHA1 Message Date
emil28092005andClaude Sonnet 4.6 dec96c6ff0 chore: run production servers in background with nohup, add stop/logs targets
Backend CI / Lint (push) Waiting to run
Backend CI / Test (push) Waiting to run
Frontend CI / Lint, Type-check & Build (push) Waiting to run
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 02:40:33 +03:00
emil28092005andClaude Sonnet 4.6 7a7424f4ae fix: remove unused Link import in history page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 02:37:54 +03:00
emil28092005andClaude Sonnet 4.6 02a9320c69 fix: export Go PATH globally in Makefile so all targets find go/goose/air
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 02:35:09 +03:00
emil28092005andClaude Sonnet 4.6 0679ff21ae chore: add Docker installation to setup.sh
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 02:30:09 +03:00
emil28092005andClaude Sonnet 4.6 80d0ebb640 chore: add setup.sh to install Go and Node.js on fresh server
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 02:23:45 +03:00
emil28092005andClaude Sonnet 4.6 6e96e72b3e chore: add install/build targets and split dev/prod server commands
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 02:16:35 +03:00
emilandClaude Sonnet 4.6 094c8953b6 fix: scan timestamptz into time.Time in users repository
Same pgx v5 binary protocol issue as admin service — created_at
in transactions cannot be scanned into *string.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 17:10:41 +03:00
emilandClaude Sonnet 4.6 e2e8afd07a fix: scan timestamptz into time.Time in admin service
pgx v5 uses binary protocol for timestamptz and cannot scan it
directly into *string. Scan into time.Time and format as RFC3339.
Fixes /admin/users internal server error and broken grant search.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 17:06:34 +03:00
emilandClaude Sonnet 4.6 2dba7f7cd2 feat: add StudentNav with logout to all student pages
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 17:00:08 +03:00
emilandClaude Sonnet 4.6 9bc2d65b43 fix: use hard redirect after login to avoid stale router cache
router.push() can reuse a cached middleware redirect that was
issued before the cookie was set, sending the user back to /login.
window.location.href forces a fresh browser request that carries
the newly set access_token cookie.

Also changed SameSite=Strict -> SameSite=Lax so the cookie is
included in all same-site navigation patterns.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 16:56:38 +03:00
12 changed files with 247 additions and 45 deletions
+3
View File
@@ -2,6 +2,9 @@
.env
*.env.local
# Runtime logs and PID files
logs/
# Go
backend/vendor/
backend/coverage.out
+61 -12
View File
@@ -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 .
+7 -2
View File
@@ -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 -1
View File
@@ -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 {
+3 -3
View File
@@ -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>
);
}
+5 -7
View File
@@ -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>
);
}
+5 -7
View File
@@ -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>
);
}
+11 -12
View File
@@ -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>
);
}
+44
View File
@@ -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>
);
}
+1 -1
View File
@@ -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>()(
Executable
+99
View File
@@ -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)"