feat: initial commit — backend API + student cabinet frontend
- Go backend: auth (JWT), points earn/spend, QR token generation, partners, admin grant/stats endpoints with chi router - Next.js 14 frontend: login, student dashboard, transaction history, QR display, partners list - PostgreSQL migrations (4 tables), Redis cache, Docker Compose - CORS middleware, role-based route protection, Zustand auth store Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
// All API calls must go through this module — never call fetch directly in components.
|
||||
// Automatically attaches the Bearer token and handles 401 → token refresh → retry.
|
||||
|
||||
import type { ApiError, ApiResponse } from './types';
|
||||
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8080';
|
||||
|
||||
function getAccessToken(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return localStorage.getItem('access_token');
|
||||
}
|
||||
|
||||
function getRefreshToken(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return localStorage.getItem('refresh_token');
|
||||
}
|
||||
|
||||
/** Attempts to refresh the access token using the stored refresh token.
|
||||
* On success: updates localStorage + cookie and returns the new token.
|
||||
* On failure: returns null so the caller can redirect to /login. */
|
||||
async function tryRefresh(): Promise<string | null> {
|
||||
const refreshToken = getRefreshToken();
|
||||
if (!refreshToken) return null;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${BASE_URL}/api/v1/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
|
||||
const json = (await res.json()) as ApiResponse<{ access_token: string }>;
|
||||
const newToken = json.data.access_token;
|
||||
|
||||
// Sync to localStorage + cookie so the middleware cookie stays valid.
|
||||
localStorage.setItem('access_token', newToken);
|
||||
document.cookie = `access_token=${newToken}; path=/; SameSite=Strict; max-age=900`;
|
||||
|
||||
// Also patch the Zustand persist entry so the store stays consistent after page reload.
|
||||
try {
|
||||
const raw = localStorage.getItem('cu-points-auth');
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as { state?: { accessToken?: string } };
|
||||
if (parsed.state) {
|
||||
parsed.state.accessToken = newToken;
|
||||
localStorage.setItem('cu-points-auth', JSON.stringify(parsed));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// If patching the store fails it's not critical — the next setTokens call will fix it.
|
||||
}
|
||||
|
||||
return newToken;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface RequestOptions extends Omit<RequestInit, 'body'> {
|
||||
body?: unknown;
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
path: string,
|
||||
options: RequestOptions = {},
|
||||
isRetry = false,
|
||||
): Promise<T> {
|
||||
const token = getAccessToken();
|
||||
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(options.headers as Record<string, string> | undefined),
|
||||
};
|
||||
|
||||
const res = await fetch(`${BASE_URL}${path}`, {
|
||||
...options,
|
||||
headers,
|
||||
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
|
||||
});
|
||||
|
||||
// 401: attempt refresh once, then give up and redirect to login.
|
||||
if (res.status === 401 && !isRetry) {
|
||||
const newToken = await tryRefresh();
|
||||
if (newToken) {
|
||||
return request<T>(path, options, true);
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
throw new Error('Session expired. Redirecting to login.');
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
let message = `HTTP ${res.status}`;
|
||||
try {
|
||||
const err = (await res.json()) as ApiError;
|
||||
message = err.error ?? message;
|
||||
} catch {
|
||||
// response body was not JSON
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const json = (await res.json()) as ApiResponse<T>;
|
||||
return json.data;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string) => request<T>(path, { method: 'GET' }),
|
||||
post: <T>(path: string, body: unknown) => request<T>(path, { method: 'POST', body }),
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
// Global client state managed with Zustand.
|
||||
// Only truly global state lives here: auth tokens and the current user profile.
|
||||
// Local UI state (loading flags, form values) stays in component useState.
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { User } from './types';
|
||||
|
||||
interface AuthState {
|
||||
accessToken: string | null;
|
||||
refreshToken: string | null;
|
||||
user: User | null;
|
||||
setTokens: (access: string, refresh: string) => void;
|
||||
setUser: (user: User) => void;
|
||||
updateBalance: (newBalance: number) => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
/** Writes the access token to localStorage and to a short-lived cookie so
|
||||
* Next.js middleware (edge runtime) can read it for role-based redirects. */
|
||||
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`;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
user: null,
|
||||
|
||||
setTokens: (accessToken, refreshToken) => {
|
||||
persistToken(accessToken);
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('refresh_token', refreshToken);
|
||||
}
|
||||
set({ accessToken, refreshToken });
|
||||
},
|
||||
|
||||
setUser: (user) => set({ user }),
|
||||
|
||||
updateBalance: (newBalance) => {
|
||||
const { user } = get();
|
||||
if (user) set({ user: { ...user, balance: newBalance } });
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
// Clear the auth cookie so middleware stops treating this session as logged in.
|
||||
document.cookie = 'access_token=; path=/; max-age=0';
|
||||
}
|
||||
set({ accessToken: null, refreshToken: null, user: null });
|
||||
},
|
||||
}),
|
||||
{ name: 'cu-points-auth' }
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,70 @@
|
||||
// All API response types live here. Never use `any` — add a proper type instead.
|
||||
// Field names match the backend JSON exactly (snake_case) so no conversion layer is needed.
|
||||
|
||||
export type TransactionType = 'earn' | 'spend' | 'admin_grant' | 'expire';
|
||||
export type UserRole = 'student' | 'partner' | 'admin';
|
||||
|
||||
// Profile returned by GET /api/v1/me
|
||||
export interface Profile {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
student_id: string;
|
||||
balance: number;
|
||||
}
|
||||
|
||||
// User stored in the Zustand auth store: Profile + role extracted from JWT claims.
|
||||
export interface User extends Profile {
|
||||
role: UserRole;
|
||||
}
|
||||
|
||||
export interface Transaction {
|
||||
id: string;
|
||||
amount: number;
|
||||
type: TransactionType;
|
||||
description: string;
|
||||
partner_id: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Partner {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
max_spend_pct: number;
|
||||
}
|
||||
|
||||
// Token pair returned by POST /api/v1/auth/login
|
||||
export interface TokenPair {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
}
|
||||
|
||||
// Response from GET /api/v1/me/qr
|
||||
export interface QRResponse {
|
||||
token: string;
|
||||
}
|
||||
|
||||
// Stats returned by GET /api/v1/admin/stats
|
||||
export interface Stats {
|
||||
total_students: number;
|
||||
total_points_issued: number;
|
||||
total_points_spent: number;
|
||||
active_partners: number;
|
||||
}
|
||||
|
||||
// Generic success envelope: every API response is wrapped in { "data": ... }
|
||||
export interface ApiResponse<T> {
|
||||
data: T;
|
||||
}
|
||||
|
||||
// Shape of paginated transaction endpoints (both /me/transactions and /admin/transactions)
|
||||
export interface PaginatedResponse<T> {
|
||||
transactions: T[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
// Error envelope: { "error": "..." }
|
||||
export interface ApiError {
|
||||
error: string;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Utility helpers for formatting numbers and dates throughout the UI.
|
||||
|
||||
/**
|
||||
* Formats a point balance for display: 1234 → "1 234 pts"
|
||||
* Uses the Russian locale so thousands are separated by a space.
|
||||
*/
|
||||
export function formatPoints(n: number): string {
|
||||
return `${new Intl.NumberFormat('ru-RU').format(n)} pts`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a transaction amount with a sign prefix.
|
||||
* Positive amounts use a "+" prefix; negative amounts use the Unicode minus sign "−".
|
||||
* Example: 50 → "+50", -30 → "−30"
|
||||
*/
|
||||
export function formatTransactionAmount(amount: number): string {
|
||||
if (amount >= 0) return `+${amount}`;
|
||||
return `−${Math.abs(amount)}`; // U+2212 MINUS SIGN, visually distinct from hyphen
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a UTC ISO timestamp to a compact Russian date+time string.
|
||||
* Example: "2024-04-28T14:32:00Z" → "28 апр., 14:32"
|
||||
*/
|
||||
export function formatDate(iso: string): string {
|
||||
return new Intl.DateTimeFormat('ru-RU', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(new Date(iso));
|
||||
}
|
||||
Reference in New Issue
Block a user