feat(club): backend закрытого клуба + вход через Яндекс ID
Миграция club_access.sql (access_requests, users.club_tier/yandex_id/ avatar_url, password NULLable, works.club_only). OAuth-роуты Яндекса (/api/auth/yandex[/callback]) с CSRF-state и мягкой 503 без ключей. Клуб: /api/club/tiers|request|my-request|gallery + админ requests/ approve/decline. Rate-limit на /api/auth сохранён. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
ad35d4e992
commit
8291d0f725
@@ -18,6 +18,15 @@ TELEGRAM_BOT_TOKEN=bot:token
|
||||
TELEGRAM_CHANNEL_ID=@nika_channel
|
||||
OWNER_TELEGRAM_ID=123456789
|
||||
|
||||
# Yandex ID OAuth (вход через Яндекс). Создать приложение: https://oauth.yandex.ru/client/new
|
||||
# Права: «Доступ к адресу электронной почты» (login:email) + «Доступ к логину, имени и полу» (login:info).
|
||||
# Redirect URI в кабинете Яндекса должен ТОЧНО совпадать с YANDEX_REDIRECT_URI ниже.
|
||||
YANDEX_CLIENT_ID=
|
||||
YANDEX_CLIENT_SECRET=
|
||||
YANDEX_REDIRECT_URI=https://nika-gallery.ru/api/auth/yandex/callback
|
||||
# Куда вернуть пользователя с токеном (обычно = SITE_URL). Токен приходит в hash: /club#token=...
|
||||
FRONTEND_URL=https://nika-gallery.ru
|
||||
|
||||
# App
|
||||
SITE_URL=https://nika-gallery.ru
|
||||
PORT=4000
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "node --watch src/index.js",
|
||||
"check": "node --check src/index.js && node --check src/db/pool.js && node --check src/middleware/auth.js && node --check src/middleware/rateLimit.js && node --check src/routes/auth.js && node --check src/routes/works.js && node --check src/routes/auctions.js && node --check src/routes/subscriptions.js"
|
||||
"check": "node --check src/index.js && node --check src/db/pool.js && node --check src/middleware/auth.js && node --check src/middleware/rateLimit.js && node --check src/routes/auth.js && node --check src/routes/oauth.js && node --check src/routes/club.js && node --check src/routes/works.js && node --check src/routes/auctions.js && node --check src/routes/subscriptions.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
|
||||
+10
-2
@@ -7,6 +7,8 @@ import cors from 'cors';
|
||||
import { pool } from './db/pool.js';
|
||||
import rateLimit from './middleware/rateLimit.js';
|
||||
import authRoutes from './routes/auth.js';
|
||||
import oauthRoutes from './routes/oauth.js';
|
||||
import clubRoutes from './routes/club.js';
|
||||
import worksRoutes from './routes/works.js';
|
||||
import auctionsRoutes from './routes/auctions.js';
|
||||
import subscriptionsRoutes from './routes/subscriptions.js';
|
||||
@@ -63,8 +65,14 @@ app.get('/api/health', async (req, res) => {
|
||||
return res.json(body);
|
||||
});
|
||||
|
||||
// Rate-limit только на аутентификацию (защита от подбора).
|
||||
app.use('/api/auth', rateLimit, authRoutes);
|
||||
// Rate-limit только на аутентификацию (защита от подбора). Один раз на префикс —
|
||||
// покрывает и локальный вход, и OAuth-роуты (вход через Яндекс), без двойного счёта.
|
||||
app.use('/api/auth', rateLimit);
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/auth', oauthRoutes);
|
||||
|
||||
// Закрытый клуб (заявки, модерация, закрытая галерея).
|
||||
app.use('/api/club', clubRoutes);
|
||||
|
||||
app.use('/api/works', worksRoutes);
|
||||
app.use('/api/auctions', auctionsRoutes);
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
// Закрытый клуб: заявки на уровни доступа, модерация админом, закрытая галерея.
|
||||
//
|
||||
// Уровни (club_tier): vip / collector / partner / photographer / organizer / press.
|
||||
// Публичные: GET /api/club/tiers — описание уровней.
|
||||
// Авторизованные:
|
||||
// POST /api/club/request — подать заявку на уровень.
|
||||
// GET /api/club/my-request — статус своей заявки + текущий уровень.
|
||||
// GET /api/club/gallery — закрытая галерея (только для членов клуба).
|
||||
// Админ: GET /api/club/requests — список заявок (по умолч. pending).
|
||||
// POST /api/club/requests/:id/approve — одобрить (ставит users.club_tier).
|
||||
// POST /api/club/requests/:id/decline — отклонить.
|
||||
import { Router } from 'express';
|
||||
import { pool } from '../db/pool.js';
|
||||
import { authRequired, requireRole, ADMIN_ROLES } from '../middleware/auth.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Канон уровней клуба (совпадает с CHECK в миграции + витриной фронта).
|
||||
export const CLUB_TIERS = [
|
||||
{ id: 'vip', title: 'VIP', desc: 'Полный доступ ко всем закрытым работам, приоритет на аукционах, личные превью.' },
|
||||
{ id: 'collector', title: 'Коллекционеры', desc: 'Ранний доступ к новым работам и коллекционным сериям, консультации по подбору.' },
|
||||
{ id: 'partner', title: 'Партнёры', desc: 'Совместные проекты и коллаборации, специальные условия сотрудничества.' },
|
||||
{ id: 'photographer', title: 'Фотографы', desc: 'Доступ к backstage-материалам и совместным съёмкам body art.' },
|
||||
{ id: 'organizer', title: 'Организаторы', desc: 'Работы и материалы для выставок, перформансов и мероприятий.' },
|
||||
{ id: 'press', title: 'Пресса', desc: 'Пресс-материалы, изображения в высоком разрешении, интервью.' },
|
||||
];
|
||||
const TIER_IDS = CLUB_TIERS.map((t) => t.id);
|
||||
|
||||
// GET /api/club/tiers — публичное описание уровней (для витрины «Закрытый клуб»).
|
||||
router.get('/tiers', (_req, res) => {
|
||||
res.json({ tiers: CLUB_TIERS });
|
||||
});
|
||||
|
||||
// POST /api/club/request — авторизованный пользователь подаёт заявку на уровень.
|
||||
router.post('/request', authRequired, async (req, res, next) => {
|
||||
try {
|
||||
const { tier, note } = req.body ?? {};
|
||||
if (!TIER_IDS.includes(tier)) {
|
||||
return res.status(400).json({ error: `Недопустимый уровень. Допустимо: ${TIER_IDS.join(', ')}.` });
|
||||
}
|
||||
const cleanNote = note ? String(note).slice(0, 2000) : null;
|
||||
|
||||
// Уже член клуба этого уровня?
|
||||
const { rows: urows } = await pool.query('SELECT club_tier FROM users WHERE id = $1', [
|
||||
req.user.sub,
|
||||
]);
|
||||
if (!urows[0]) return res.status(404).json({ error: 'Пользователь не найден.' });
|
||||
if (urows[0].club_tier === tier) {
|
||||
return res.status(409).json({ error: 'У вас уже есть этот уровень доступа.' });
|
||||
}
|
||||
|
||||
// Одна активная заявка на пользователя (частичный uniq-индекс это гарантирует).
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO access_requests (user_id, tier, note)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, tier, status, note, created_at`,
|
||||
[req.user.sub, tier, cleanNote]
|
||||
);
|
||||
return res.status(201).json({ request: rows[0] });
|
||||
} catch (err) {
|
||||
if (err && err.code === '23505') {
|
||||
return res.status(409).json({ error: 'У вас уже есть заявка на рассмотрении.' });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/club/my-request — своя заявка (последняя) + текущий уровень.
|
||||
router.get('/my-request', authRequired, async (req, res, next) => {
|
||||
try {
|
||||
const { rows: urows } = await pool.query('SELECT club_tier FROM users WHERE id = $1', [
|
||||
req.user.sub,
|
||||
]);
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, tier, status, note, admin_note, created_at, reviewed_at
|
||||
FROM access_requests WHERE user_id = $1
|
||||
ORDER BY created_at DESC LIMIT 1`,
|
||||
[req.user.sub]
|
||||
);
|
||||
return res.json({
|
||||
club_tier: urows[0]?.club_tier ?? null,
|
||||
request: rows[0] ?? null,
|
||||
});
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/club/gallery — закрытая галерея. Только для членов клуба (любой club_tier) и админов.
|
||||
router.get('/gallery', authRequired, async (req, res, next) => {
|
||||
try {
|
||||
const isAdmin = ADMIN_ROLES.includes(req.user.role);
|
||||
let hasTier = isAdmin;
|
||||
if (!hasTier) {
|
||||
const { rows } = await pool.query('SELECT club_tier FROM users WHERE id = $1', [req.user.sub]);
|
||||
hasTier = Boolean(rows[0]?.club_tier);
|
||||
}
|
||||
if (!hasTier) {
|
||||
return res.status(403).json({ error: 'Доступ только для членов закрытого клуба.' });
|
||||
}
|
||||
|
||||
const limit = Math.min(Number(req.query.limit) || 24, 100);
|
||||
const offset = Math.max(Number(req.query.offset) || 0, 0);
|
||||
|
||||
// Клубные работы: works.club_only = true. Агрегаты берём из works_extended по id.
|
||||
const { rows } = await pool.query(
|
||||
`SELECT we.id, we.title, we.slug, we.description, we.medium, we.dimensions,
|
||||
we.year, we.price, we.status, we.auction_mode, we.likes_count,
|
||||
we.comments_count, we.avg_rating, we.reviews_count,
|
||||
we.cover_url, we.cover_thumb, we.created_at
|
||||
FROM works_extended we
|
||||
JOIN works w ON w.id = we.id
|
||||
WHERE w.club_only = true
|
||||
AND (we.status = 'published' OR $3 = true)
|
||||
ORDER BY we.created_at DESC
|
||||
LIMIT $1 OFFSET $2`,
|
||||
[limit, offset, isAdmin]
|
||||
);
|
||||
return res.json({ items: rows, limit, offset });
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/club/requests — список заявок (админ). ?status=pending|approved|declined|all
|
||||
router.get('/requests', authRequired, requireRole(ADMIN_ROLES), async (req, res, next) => {
|
||||
try {
|
||||
const status = String(req.query.status || 'pending');
|
||||
const params = [];
|
||||
let where = '';
|
||||
if (status !== 'all') {
|
||||
if (!['pending', 'approved', 'declined'].includes(status)) {
|
||||
return res.status(400).json({ error: 'Недопустимый status.' });
|
||||
}
|
||||
params.push(status);
|
||||
where = 'WHERE ar.status = $1';
|
||||
}
|
||||
const { rows } = await pool.query(
|
||||
`SELECT ar.id, ar.tier, ar.status, ar.note, ar.admin_note,
|
||||
ar.created_at, ar.reviewed_at,
|
||||
u.id AS user_id, u.name AS user_name, u.email AS user_email,
|
||||
u.avatar_url AS user_avatar, u.club_tier AS user_current_tier
|
||||
FROM access_requests ar
|
||||
JOIN users u ON u.id = ar.user_id
|
||||
${where}
|
||||
ORDER BY ar.created_at DESC
|
||||
LIMIT 200`,
|
||||
params
|
||||
);
|
||||
return res.json({ items: rows });
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// Общий обработчик решения по заявке.
|
||||
async function decide(req, res, next, decision) {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const adminNote = req.body?.admin_note ? String(req.body.admin_note).slice(0, 2000) : null;
|
||||
|
||||
await client.query('BEGIN');
|
||||
const { rows } = await client.query(
|
||||
`SELECT id, user_id, tier, status FROM access_requests WHERE id = $1 FOR UPDATE`,
|
||||
[id]
|
||||
);
|
||||
const reqRow = rows[0];
|
||||
if (!reqRow) {
|
||||
await client.query('ROLLBACK');
|
||||
return res.status(404).json({ error: 'Заявка не найдена.' });
|
||||
}
|
||||
if (reqRow.status !== 'pending') {
|
||||
await client.query('ROLLBACK');
|
||||
return res.status(409).json({ error: `Заявка уже обработана (${reqRow.status}).` });
|
||||
}
|
||||
|
||||
const newStatus = decision === 'approve' ? 'approved' : 'declined';
|
||||
await client.query(
|
||||
`UPDATE access_requests
|
||||
SET status = $1, admin_note = $2, reviewed_by = $3, reviewed_at = now(), updated_at = now()
|
||||
WHERE id = $4`,
|
||||
[newStatus, adminNote, req.user.sub, id]
|
||||
);
|
||||
|
||||
// При одобрении — выставляем уровень пользователю.
|
||||
if (decision === 'approve') {
|
||||
await client.query('UPDATE users SET club_tier = $1, updated_at = now() WHERE id = $2', [
|
||||
reqRow.tier,
|
||||
reqRow.user_id,
|
||||
]);
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
return res.json({ ok: true, id, status: newStatus, tier: reqRow.tier });
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK').catch(() => {});
|
||||
return next(err);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/club/requests/:id/approve — одобрить (ставит users.club_tier).
|
||||
router.post('/requests/:id/approve', authRequired, requireRole(ADMIN_ROLES), (req, res, next) =>
|
||||
decide(req, res, next, 'approve')
|
||||
);
|
||||
|
||||
// POST /api/club/requests/:id/decline — отклонить.
|
||||
router.post('/requests/:id/decline', authRequired, requireRole(ADMIN_ROLES), (req, res, next) =>
|
||||
decide(req, res, next, 'decline')
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,220 @@
|
||||
// Вход через Яндекс ID (OAuth 2.0 authorization code).
|
||||
// По образцу рабочего Heritage (routes/oauth.js): редирект → callback → JWT в hash фронта.
|
||||
//
|
||||
// Поток:
|
||||
// GET /api/auth/yandex → редирект на oauth.yandex.ru/authorize (state = короткоживущий JWT, CSRF).
|
||||
// GET /api/auth/yandex/callback → обмен code на access_token, профиль login.yandex.ru/info,
|
||||
// поиск/создание пользователя по yandex_id/email,
|
||||
// выдача нашего JWT, редирект на FRONTEND_URL/#token=...
|
||||
//
|
||||
// Ключи НЕ хардкодятся — берутся из env. Если не заданы — понятная 503, процесс НЕ падает.
|
||||
import { Router } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { pool } from '../db/pool.js';
|
||||
import { signAccessToken, signRefreshToken } from '../middleware/auth.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const AUTHORIZE_URL = 'https://oauth.yandex.ru/authorize';
|
||||
const TOKEN_URL = 'https://oauth.yandex.ru/token';
|
||||
const INFO_URL = 'https://login.yandex.ru/info';
|
||||
|
||||
// Секрет для подписи state (используем общий JWT_SECRET — он уже валидируется в middleware/auth).
|
||||
const STATE_SECRET = process.env.JWT_SECRET ?? '';
|
||||
const STATE_TTL = '10m';
|
||||
|
||||
function config() {
|
||||
return {
|
||||
clientId: process.env.YANDEX_CLIENT_ID || '',
|
||||
clientSecret: process.env.YANDEX_CLIENT_SECRET || '',
|
||||
// REDIRECT_URI должен совпадать с зарегистрированным в кабинете oauth.yandex.ru.
|
||||
redirectUri: process.env.YANDEX_REDIRECT_URI || '',
|
||||
// Куда вернуть пользователя с токеном (фронт).
|
||||
frontendUrl: (process.env.FRONTEND_URL || process.env.SITE_URL || '').replace(/\/$/, ''),
|
||||
};
|
||||
}
|
||||
|
||||
// Настроен ли Яндекс-вход? (иначе — 503, но сервер живёт).
|
||||
function isConfigured(c) {
|
||||
return Boolean(c.clientId && c.clientSecret && c.redirectUri);
|
||||
}
|
||||
|
||||
function frontendRedirect(c) {
|
||||
// Фолбэк на локальный фронт в dev, чтобы редирект не был битым.
|
||||
return c.frontendUrl || 'http://localhost:3000';
|
||||
}
|
||||
|
||||
// GET /api/auth/yandex — старт авторизации.
|
||||
router.get('/yandex', (req, res) => {
|
||||
const c = config();
|
||||
if (!isConfigured(c)) {
|
||||
return res.status(503).json({
|
||||
error: 'Вход через Яндекс временно недоступен (не настроены ключи YANDEX_*).',
|
||||
});
|
||||
}
|
||||
if (!STATE_SECRET) {
|
||||
return res.status(503).json({ error: 'Сервер не настроен (нет JWT_SECRET для state).' });
|
||||
}
|
||||
|
||||
// CSRF-защита: подписанный короткоживущий state.
|
||||
const state = jwt.sign({ n: Math.random().toString(36).slice(2) }, STATE_SECRET, {
|
||||
expiresIn: STATE_TTL,
|
||||
});
|
||||
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: c.clientId,
|
||||
redirect_uri: c.redirectUri,
|
||||
state,
|
||||
// login:email — почта, login:info — имя/аватар/id.
|
||||
scope: 'login:email login:info',
|
||||
force_confirm: 'yes',
|
||||
});
|
||||
return res.redirect(`${AUTHORIZE_URL}?${params.toString()}`);
|
||||
});
|
||||
|
||||
// GET /api/auth/yandex/callback — приём кода, обмен, вход/создание.
|
||||
router.get('/yandex/callback', async (req, res, next) => {
|
||||
const c = config();
|
||||
const front = frontendRedirect(c);
|
||||
|
||||
try {
|
||||
if (!isConfigured(c) || !STATE_SECRET) {
|
||||
return res.status(503).json({ error: 'Вход через Яндекс не настроен.' });
|
||||
}
|
||||
|
||||
const { code, state, error: oauthError } = req.query;
|
||||
if (oauthError) {
|
||||
return res.redirect(`${front}/club?error=${encodeURIComponent(String(oauthError))}`);
|
||||
}
|
||||
if (!code || !state) {
|
||||
return res.redirect(`${front}/club?error=no_code`);
|
||||
}
|
||||
|
||||
// Проверяем state (CSRF).
|
||||
try {
|
||||
jwt.verify(String(state), STATE_SECRET);
|
||||
} catch {
|
||||
return res.redirect(`${front}/club?error=bad_state`);
|
||||
}
|
||||
|
||||
// 1) Обмен кода на access_token Яндекса.
|
||||
const tokenResp = await fetch(TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code: String(code),
|
||||
client_id: c.clientId,
|
||||
client_secret: c.clientSecret,
|
||||
redirect_uri: c.redirectUri,
|
||||
}),
|
||||
});
|
||||
if (!tokenResp.ok) {
|
||||
const t = await tokenResp.text().catch(() => '');
|
||||
console.error('[yandex] token exchange failed:', tokenResp.status, t);
|
||||
return res.redirect(`${front}/club?error=token_exchange`);
|
||||
}
|
||||
const tokenJson = await tokenResp.json();
|
||||
const yToken = tokenJson.access_token;
|
||||
if (!yToken) {
|
||||
return res.redirect(`${front}/club?error=no_token`);
|
||||
}
|
||||
|
||||
// 2) Профиль пользователя.
|
||||
const infoResp = await fetch(INFO_URL, {
|
||||
headers: { Authorization: `OAuth ${yToken}` },
|
||||
});
|
||||
if (!infoResp.ok) {
|
||||
console.error('[yandex] info failed:', infoResp.status);
|
||||
return res.redirect(`${front}/club?error=profile`);
|
||||
}
|
||||
const info = await infoResp.json();
|
||||
|
||||
const yandexId = String(info.id);
|
||||
const email = (info.default_email || (info.emails && info.emails[0]) || '')
|
||||
.toString()
|
||||
.toLowerCase()
|
||||
.trim();
|
||||
const name =
|
||||
info.real_name || info.display_name || info.first_name || info.login || 'Пользователь Яндекс';
|
||||
const avatar =
|
||||
info.default_avatar_id && !info.is_avatar_empty
|
||||
? `https://avatars.yandex.net/get-yapic/${info.default_avatar_id}/islands-200`
|
||||
: null;
|
||||
|
||||
// 3) Поиск/создание пользователя (по yandex_id, затем по email — связываем аккаунты).
|
||||
const user = await findOrCreateYandexUser({ yandexId, email, name, avatar });
|
||||
|
||||
// 4) Наши токены. Сохраняем refresh для последующего отзыва.
|
||||
const accessToken = signAccessToken(user);
|
||||
const refreshToken = signRefreshToken(user);
|
||||
await pool.query('UPDATE users SET refresh_token = $1, updated_at = now() WHERE id = $2', [
|
||||
refreshToken,
|
||||
user.id,
|
||||
]);
|
||||
|
||||
// 5) Редирект на фронт: токен в hash (не попадает в логи/Referer сервера).
|
||||
const hash = new URLSearchParams({
|
||||
token: accessToken,
|
||||
refresh: refreshToken,
|
||||
});
|
||||
return res.redirect(`${front}/club#${hash.toString()}`);
|
||||
} catch (err) {
|
||||
console.error('[yandex] callback error:', err);
|
||||
// Не показываем стек пользователю — мягкий редирект.
|
||||
if (!res.headersSent) {
|
||||
return res.redirect(`${front}/club?error=internal`);
|
||||
}
|
||||
return next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// Поиск по yandex_id → по email (линковка) → создание нового.
|
||||
async function findOrCreateYandexUser({ yandexId, email, name, avatar }) {
|
||||
// 1. По yandex_id.
|
||||
let { rows } = await pool.query(
|
||||
`SELECT id, email, name, role, club_tier, telegram_id, created_at
|
||||
FROM users WHERE yandex_id = $1`,
|
||||
[yandexId]
|
||||
);
|
||||
if (rows[0]) {
|
||||
// Обновим аватар/имя при изменении.
|
||||
await pool.query(
|
||||
`UPDATE users SET avatar_url = COALESCE($1, avatar_url), updated_at = now() WHERE id = $2`,
|
||||
[avatar, rows[0].id]
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
// 2. По email — привязываем Яндекс к существующему аккаунту.
|
||||
if (email) {
|
||||
({ rows } = await pool.query(
|
||||
`SELECT id, email, name, role, club_tier, telegram_id, created_at
|
||||
FROM users WHERE email = $1`,
|
||||
[email]
|
||||
));
|
||||
if (rows[0]) {
|
||||
await pool.query(
|
||||
`UPDATE users
|
||||
SET yandex_id = $1, avatar_url = COALESCE($2, avatar_url), updated_at = now()
|
||||
WHERE id = $3`,
|
||||
[yandexId, avatar, rows[0].id]
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Новый пользователь. Роль 'buyer', пароль NULL (вход только через Яндекс).
|
||||
// Если email пуст (пользователь скрыл почту) — синтезируем плейсхолдер.
|
||||
const finalEmail = email || `yandex_${yandexId}@yandex.local`;
|
||||
const insert = await pool.query(
|
||||
`INSERT INTO users (email, name, password, role, yandex_id, avatar_url)
|
||||
VALUES ($1, $2, NULL, 'buyer', $3, $4)
|
||||
RETURNING id, email, name, role, club_tier, telegram_id, created_at`,
|
||||
[finalEmail, name, yandexId, avatar]
|
||||
);
|
||||
return insert.rows[0];
|
||||
}
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,67 @@
|
||||
-- nika-gallery: миграция «Закрытый клуб» + вход через Яндекс ID.
|
||||
-- Аддитивно к db/schema.sql. Идемпотентна (IF NOT EXISTS / IF EXISTS).
|
||||
-- Задача: feat/club-yandex-auth (Claude Opus 4.8, 2026-08-05).
|
||||
--
|
||||
-- Что добавляет:
|
||||
-- 1. users: club_tier (уровень клуба), yandex_id (uniq), avatar_url;
|
||||
-- password делаем NULLable (вход только через Яндекс — без локального пароля).
|
||||
-- 2. access_requests: заявки в клуб с модерацией (pending/approved/declined).
|
||||
-- 3. works: флаг club_only (работа видна только членам клуба).
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ── users: поля клуба и Яндекс ID ───────────────────────────────
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS club_tier TEXT;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS yandex_id TEXT;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar_url TEXT;
|
||||
|
||||
-- Допустимые уровни клуба (nullable — большинство пользователей вне клуба).
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'users_club_tier_check'
|
||||
) THEN
|
||||
ALTER TABLE users ADD CONSTRAINT users_club_tier_check
|
||||
CHECK (club_tier IS NULL OR club_tier IN
|
||||
('vip','collector','partner','photographer','organizer','press'));
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- yandex_id уникален, но только среди заполненных (частичный уникальный индекс).
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uniq_users_yandex_id
|
||||
ON users(yandex_id) WHERE yandex_id IS NOT NULL;
|
||||
|
||||
-- Пароль больше не обязателен: пользователь может входить только через Яндекс.
|
||||
-- (Локальная регистрация всё равно требует пароль на уровне API.)
|
||||
ALTER TABLE users ALTER COLUMN password DROP NOT NULL;
|
||||
|
||||
-- ── access_requests: заявки в клуб с модерацией ─────────────────
|
||||
CREATE TABLE IF NOT EXISTS access_requests (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
tier TEXT NOT NULL CHECK (tier IN
|
||||
('vip','collector','partner','photographer','organizer','press')),
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending','approved','declined')),
|
||||
note TEXT, -- сообщение заявителя «о себе»
|
||||
admin_note TEXT, -- комментарий модератора (опц.)
|
||||
reviewed_by UUID REFERENCES users(id),
|
||||
reviewed_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_access_requests_status
|
||||
ON access_requests(status, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_access_requests_user
|
||||
ON access_requests(user_id, created_at DESC);
|
||||
|
||||
-- Не более одной активной (pending) заявки на пользователя.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uniq_access_requests_pending
|
||||
ON access_requests(user_id) WHERE status = 'pending';
|
||||
|
||||
-- ── works: закрытые (клубные) работы ────────────────────────────
|
||||
ALTER TABLE works ADD COLUMN IF NOT EXISTS club_only BOOLEAN NOT NULL DEFAULT false;
|
||||
CREATE INDEX IF NOT EXISTS idx_works_club_only ON works(club_only) WHERE club_only = true;
|
||||
|
||||
COMMIT;
|
||||
Reference in New Issue
Block a user