Merge remote-tracking branch 'forgejo/main' into fix/tradein-quota-per-user-and-no-burn

This commit is contained in:
bot-backend 2026-07-13 23:05:14 +03:00
commit 52e5a32fd5
2 changed files with 116 additions and 0 deletions

View 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;

View file

@ -9,6 +9,58 @@ import { safeUrl } from "@/lib/safeUrl";
import { useBrand } from "@/lib/useBrand";
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 буква М над размерной линией («мера» = измерение).
Глиф в currentColor; тёмный фон даёт CSS-бейдж .brand-mark. */
function MeraMark() {
@ -156,6 +208,10 @@ export function Topbar({ active }: TopbarProps) {
{/* UserMenu личный кабинет справа от nav. В dev (401) или до /me
UserMenu сам рендерит null. */}
<UserMenu />
{/* FeedbackButton вне items.map/RBAC: видна всем авторизованным
пользователям независимо от allowed_paths. Сама себя прячет,
пока NEXT_PUBLIC_FEEDBACK_TG_URL не задан. */}
<FeedbackButton />
</nav>
</div>
</header>