Compare commits
5 commits
9c12407c09
...
950ac91f7e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
950ac91f7e | ||
|
|
0875d5b9e6 | ||
|
|
52e5a32fd5 | ||
| 5647c2b40e | |||
| 749c96cd0c |
4 changed files with 119 additions and 3 deletions
|
|
@ -5,7 +5,7 @@
|
||||||
месяц (UTC, период 'YYYY-MM'); конфигурируется через env ESTIMATE_QUOTA_LIMIT,
|
месяц (UTC, период 'YYYY-MM'); конфигурируется через env ESTIMATE_QUOTA_LIMIT,
|
||||||
default 15.
|
default 15.
|
||||||
- Персональный override: таблица account_quota_overrides (username → monthly_limit),
|
- Персональный override: таблица account_quota_overrides (username → monthly_limit),
|
||||||
см. миграцию 184_account_quota_overrides.sql. Заменяет прежний хак бонусных попыток
|
см. миграцию 185_account_quota_overrides.sql. Заменяет прежний хак бонусных попыток
|
||||||
через negative `used` (ломал /quota — «Осталось 50 из 15»).
|
через negative `used` (ломал /quota — «Осталось 50 из 15»).
|
||||||
- Без лимита (unlimited): роль admin ИЛИ username == 'kopylov'.
|
- Без лимита (unlimited): роль admin ИЛИ username == 'kopylov'.
|
||||||
- Учитываются ТОЛЬКО успешные оценки (инкремент ПОСЛЕ estimate_quality).
|
- Учитываются ТОЛЬКО успешные оценки (инкремент ПОСЛЕ estimate_quality).
|
||||||
|
|
@ -61,7 +61,7 @@ def user_limit(db: Session, username: str) -> int:
|
||||||
"""Персональный месячный лимит для username, иначе глобальный MONTHLY_LIMIT.
|
"""Персональный месячный лимит для username, иначе глобальный MONTHLY_LIMIT.
|
||||||
|
|
||||||
Источник override — таблица account_quota_overrides (см. миграцию
|
Источник override — таблица account_quota_overrides (см. миграцию
|
||||||
184_account_quota_overrides.sql). Заменяет прежний хак бонусных попыток через
|
185_account_quota_overrides.sql). Заменяет прежний хак бонусных попыток через
|
||||||
negative `used`, который ломал /quota (limit=15, used=-35 → remaining=50 —
|
negative `used`, который ломал /quota (limit=15, used=-35 → remaining=50 —
|
||||||
«Осталось 50 из 15»).
|
«Осталось 50 из 15»).
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
60
tradein-mvp/backend/data/sql/184_user_events.sql
Normal file
60
tradein-mvp/backend/data/sql/184_user_events.sql
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
-- 184_user_events.sql
|
||||||
|
-- Foundation schema for Features 2 & 3: unified user-event tracking.
|
||||||
|
--
|
||||||
|
-- WHY:
|
||||||
|
-- trade-in had no durable event log for login/IP audit or behavior
|
||||||
|
-- analytics. A near-identical design already existed once — `audit_log`
|
||||||
|
-- (event_type, ip_address inet, user_agent, estimate_id uuid, payload
|
||||||
|
-- jsonb, created_at) was defined in 002_core_tables.sql and DROPPED in
|
||||||
|
-- 095_dead_schema.sql as unused dead schema at the time. Product now
|
||||||
|
-- needs exactly that shape again, so this migration resurrects the
|
||||||
|
-- design under a new name, `user_events`, as the ONE unified
|
||||||
|
-- append-only table serving:
|
||||||
|
-- - login-audit (who logged in, from what IP/UA, when)
|
||||||
|
-- - search-audit (estimate requests, listing lookups)
|
||||||
|
-- - behavior-analytics (pdf_download, listing_click, page_view,
|
||||||
|
-- drawer_open, and future event_type values)
|
||||||
|
--
|
||||||
|
-- WHAT:
|
||||||
|
-- `user_events` — append-only, admin-read-only. No 152-ФЗ consent
|
||||||
|
-- gating and no mandatory retention policy for MVP (product-owner
|
||||||
|
-- decision): we log IP/UA/path/method/payload unconditionally for every
|
||||||
|
-- tracked event. `estimate_id` is a plain uuid column with NO FK
|
||||||
|
-- constraint on purpose — this keeps the log decoupled/append-only so
|
||||||
|
-- estimate deletion (or any future estimate lifecycle change) never
|
||||||
|
-- blocks or cascades into event rows.
|
||||||
|
--
|
||||||
|
-- IDEMPOTENCY / SAFETY:
|
||||||
|
-- CREATE TABLE IF NOT EXISTS + CREATE INDEX IF NOT EXISTS throughout —
|
||||||
|
-- safe re-run. Purely additive: no existing table/view/column is
|
||||||
|
-- touched.
|
||||||
|
--
|
||||||
|
-- Dependencies: none (new standalone table). Auto-applied on deploy via
|
||||||
|
-- _schema_migrations tracking (tradein-mvp/backend/data/sql convention).
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_events (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
event_type text NOT NULL,
|
||||||
|
username text NOT NULL,
|
||||||
|
ip_address inet,
|
||||||
|
user_agent text,
|
||||||
|
path text,
|
||||||
|
method text,
|
||||||
|
estimate_id uuid,
|
||||||
|
payload jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE user_events IS 'Unified append-only event log (login/IP audit + behavior analytics), admin-read-only. Resurrects the design of audit_log (002_core_tables.sql, dropped in 095_dead_schema.sql).';
|
||||||
|
COMMENT ON COLUMN user_events.event_type IS 'e.g. login / estimate_request / pdf_download / listing_click / page_view / drawer_open.';
|
||||||
|
COMMENT ON COLUMN user_events.username IS 'X-Authenticated-User value at the time of the event.';
|
||||||
|
COMMENT ON COLUMN user_events.estimate_id IS 'No FK constraint by design — keeps the log decoupled/append-only from trade_in_estimates lifecycle.';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS user_events_username_created_at_idx ON user_events (username, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS user_events_event_type_created_at_idx ON user_events (event_type, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS user_events_ip_address_idx ON user_events (ip_address);
|
||||||
|
CREATE INDEX IF NOT EXISTS user_events_created_at_idx ON user_events (created_at DESC);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
-- Migration 184: account_quota_overrides — персональные месячные лимиты оценок
|
-- Migration 185: account_quota_overrides — персональные месячные лимиты оценок
|
||||||
--
|
--
|
||||||
-- WHY:
|
-- WHY:
|
||||||
-- Бонусные попытки для платящих пилотов раздавались хаком через SQL-runbook:
|
-- Бонусные попытки для платящих пилотов раздавались хаком через SQL-runbook:
|
||||||
|
|
@ -9,6 +9,58 @@ import { safeUrl } from "@/lib/safeUrl";
|
||||||
import { useBrand } from "@/lib/useBrand";
|
import { useBrand } from "@/lib/useBrand";
|
||||||
import { useMe } from "@/lib/useMe";
|
import { useMe } from "@/lib/useMe";
|
||||||
|
|
||||||
|
/** Telegram-канал обратной связи для пилота. Build-time env — пусто до тех
|
||||||
|
пор, пока devops не настроит реальную ссылку (см. FeedbackButton ниже). */
|
||||||
|
const FEEDBACK_TG_URL = process.env.NEXT_PUBLIC_FEEDBACK_TG_URL ?? "";
|
||||||
|
|
||||||
|
/** Inline Send icon (lucide-react `Send` SVG path, stroke 1.5) — та же
|
||||||
|
конвенция, что и LogOutIcon в UserMenu.tsx: tradein-mvp не тянет
|
||||||
|
lucide-react в deps, поэтому иконка — inline SVG. */
|
||||||
|
function TelegramIcon() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={16}
|
||||||
|
height={16}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="m22 2-7 20-4-9-9-4Z" />
|
||||||
|
<path d="M22 2 11 13" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Кнопка обратной связи в Telegram — рендерится для ВСЕХ авторизованных
|
||||||
|
* пользователей (не зависит от RBAC allowed_paths, в отличие от NAV_ITEMS).
|
||||||
|
*
|
||||||
|
* Env-gated: пока `NEXT_PUBLIC_FEEDBACK_TG_URL` не задан на билде, кнопка не
|
||||||
|
* рендерится вообще — безопасно шипить до появления реальной Telegram-ссылки.
|
||||||
|
* safeUrl() дополнительно блокирует не-http(s) схемы (#766-class XSS).
|
||||||
|
*/
|
||||||
|
function FeedbackButton() {
|
||||||
|
const href = safeUrl(FEEDBACK_TG_URL);
|
||||||
|
if (!href) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="top-cta"
|
||||||
|
aria-label="Обратная связь в Telegram"
|
||||||
|
>
|
||||||
|
<TelegramIcon />
|
||||||
|
Обратная связь
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** «Мера» mark — буква М над размерной линией («мера» = измерение).
|
/** «Мера» mark — буква М над размерной линией («мера» = измерение).
|
||||||
Глиф в currentColor; тёмный фон даёт CSS-бейдж .brand-mark. */
|
Глиф в currentColor; тёмный фон даёт CSS-бейдж .brand-mark. */
|
||||||
function MeraMark() {
|
function MeraMark() {
|
||||||
|
|
@ -156,6 +208,10 @@ export function Topbar({ active }: TopbarProps) {
|
||||||
{/* UserMenu — личный кабинет справа от nav. В dev (401) или до /me
|
{/* UserMenu — личный кабинет справа от nav. В dev (401) или до /me
|
||||||
UserMenu сам рендерит null. */}
|
UserMenu сам рендерит null. */}
|
||||||
<UserMenu />
|
<UserMenu />
|
||||||
|
{/* FeedbackButton — вне items.map/RBAC: видна всем авторизованным
|
||||||
|
пользователям независимо от allowed_paths. Сама себя прячет,
|
||||||
|
пока NEXT_PUBLIC_FEEDBACK_TG_URL не задан. */}
|
||||||
|
<FeedbackButton />
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue