Add ops feedback reward grants
This commit is contained in:
@@ -0,0 +1,55 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||||
|
import {
|
||||||
|
applyAuthResponseCookies,
|
||||||
|
buildBackendRequestHeaders,
|
||||||
|
} from "@/lib/backend-auth";
|
||||||
|
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||||
|
|
||||||
|
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
req: NextRequest,
|
||||||
|
context: { params: Promise<{ feedbackId: string }> },
|
||||||
|
) {
|
||||||
|
if (!API_BASE) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const auth = await buildBackendRequestHeaders(req);
|
||||||
|
const authError = requireOpsProxyAuth(req, auth);
|
||||||
|
if (authError) return authError;
|
||||||
|
|
||||||
|
const { feedbackId } = await context.params;
|
||||||
|
const body = await req.json();
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_BASE}/api/ops/feedback/${encodeURIComponent(feedbackId)}/reward`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
cache: "no-store",
|
||||||
|
headers: {
|
||||||
|
...Object.fromEntries(new Headers(auth.headers).entries()),
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const raw = await res.text();
|
||||||
|
const response = new NextResponse(raw, {
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
"Content-Type": res.headers.get("content-type") || "application/json",
|
||||||
|
},
|
||||||
|
status: res.status,
|
||||||
|
});
|
||||||
|
return applyAuthResponseCookies(response, auth.response);
|
||||||
|
} catch (error) {
|
||||||
|
return buildProxyExceptionResponse(error, {
|
||||||
|
publicMessage: "Failed to grant feedback reward",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
function assert(condition: unknown, message: string): asserts condition {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runTests() {
|
||||||
|
const projectRoot = process.cwd();
|
||||||
|
const feedbackPageSource = fs.readFileSync(
|
||||||
|
path.join(projectRoot, "components", "ops", "feedback", "FeedbackPageClient.tsx"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
const opsApiSource = fs.readFileSync(path.join(projectRoot, "lib", "ops-api.ts"), "utf8");
|
||||||
|
const rewardRoutePath = path.join(
|
||||||
|
projectRoot,
|
||||||
|
"app",
|
||||||
|
"api",
|
||||||
|
"ops",
|
||||||
|
"feedback",
|
||||||
|
"[feedbackId]",
|
||||||
|
"reward",
|
||||||
|
"route.ts",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(fs.existsSync(rewardRoutePath), "ops feedback reward proxy route must exist");
|
||||||
|
assert(
|
||||||
|
opsApiSource.includes("grantFeedbackReward") &&
|
||||||
|
opsApiSource.includes("/api/ops/feedback/${feedbackId}/reward") &&
|
||||||
|
opsApiSource.includes("reason"),
|
||||||
|
"ops API client must expose grantFeedbackReward with reason",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
feedbackPageSource.includes("rewardDrafts") &&
|
||||||
|
feedbackPageSource.includes("handleRewardGrant") &&
|
||||||
|
feedbackPageSource.includes("发放奖励") &&
|
||||||
|
feedbackPageSource.includes("奖励原因") &&
|
||||||
|
feedbackPageSource.includes("opsApi.grantFeedbackReward"),
|
||||||
|
"ops feedback page must provide per-feedback reward grant controls",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
feedbackPageSource.includes("已发放") &&
|
||||||
|
feedbackPageSource.includes("reward_points") &&
|
||||||
|
feedbackPageSource.includes("reward_reason"),
|
||||||
|
"ops feedback page must show existing feedback reward details",
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Bug, CheckCircle2, MessageSquare, RefreshCcw } from "lucide-react";
|
import { Bug, CheckCircle2, Coins, MessageSquare, RefreshCcw } from "lucide-react";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { opsApi } from "@/lib/ops-api";
|
import { opsApi } from "@/lib/ops-api";
|
||||||
@@ -27,6 +27,11 @@ const REWARD_GUIDELINES = [
|
|||||||
{ points: "1500+", title: "重大事故", detail: "大面积不可用或严重业务损失,谨慎使用" },
|
{ points: "1500+", title: "重大事故", detail: "大面积不可用或严重业务损失,谨慎使用" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
type RewardDraft = {
|
||||||
|
points: string;
|
||||||
|
reason: string;
|
||||||
|
};
|
||||||
|
|
||||||
function compactDate(value?: string) {
|
function compactDate(value?: string) {
|
||||||
if (!value) return "—";
|
if (!value) return "—";
|
||||||
return value.slice(0, 19).replace("T", " ");
|
return value.slice(0, 19).replace("T", " ");
|
||||||
@@ -76,6 +81,8 @@ export function FeedbackPageClient() {
|
|||||||
const [filter, setFilter] = useState("");
|
const [filter, setFilter] = useState("");
|
||||||
const [payload, setPayload] = useState<UserFeedbackPayload | null>(null);
|
const [payload, setPayload] = useState<UserFeedbackPayload | null>(null);
|
||||||
const [updatingId, setUpdatingId] = useState<number | null>(null);
|
const [updatingId, setUpdatingId] = useState<number | null>(null);
|
||||||
|
const [rewardingId, setRewardingId] = useState<number | null>(null);
|
||||||
|
const [rewardDrafts, setRewardDrafts] = useState<Record<number, RewardDraft>>({});
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -120,6 +127,50 @@ export function FeedbackPageClient() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateRewardDraft = (rowId: number, patch: Partial<RewardDraft>) => {
|
||||||
|
setRewardDrafts((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[rowId]: {
|
||||||
|
points: prev[rowId]?.points || "",
|
||||||
|
reason: prev[rowId]?.reason || "",
|
||||||
|
...patch,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRewardGrant = async (row: UserFeedbackEntry) => {
|
||||||
|
const draft = rewardDrafts[row.id] || { points: "", reason: "" };
|
||||||
|
const points = Number.parseInt(draft.points, 10);
|
||||||
|
const reason = draft.reason.trim();
|
||||||
|
if (!row.user_email) {
|
||||||
|
setError("这条反馈没有绑定用户邮箱,不能从反馈页直接发放积分。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(points) || points <= 0) {
|
||||||
|
setError("请输入有效的奖励积分。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!reason) {
|
||||||
|
setError("请输入奖励原因,用户账户页会展示这条原因。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRewardingId(row.id);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await opsApi.grantFeedbackReward(row.id, points, reason);
|
||||||
|
setRewardDrafts((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[row.id];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
setError(String(err).slice(0, 220));
|
||||||
|
} finally {
|
||||||
|
setRewardingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (loading && !payload) {
|
if (loading && !payload) {
|
||||||
return <div className="text-slate-400 animate-pulse">加载中...</div>;
|
return <div className="text-slate-400 animate-pulse">加载中...</div>;
|
||||||
}
|
}
|
||||||
@@ -244,51 +295,109 @@ export function FeedbackPageClient() {
|
|||||||
<th className="py-2 pr-4 font-bold">上下文</th>
|
<th className="py-2 pr-4 font-bold">上下文</th>
|
||||||
<th className="py-2 pr-4 font-bold">用户</th>
|
<th className="py-2 pr-4 font-bold">用户</th>
|
||||||
<th className="py-2 pr-4 font-bold">时间</th>
|
<th className="py-2 pr-4 font-bold">时间</th>
|
||||||
|
<th className="py-2 pr-4 font-bold">奖励</th>
|
||||||
<th className="py-2 pr-4 font-bold">操作</th>
|
<th className="py-2 pr-4 font-bold">操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{rows.map((row) => (
|
{rows.map((row) => {
|
||||||
<tr key={row.id} className="border-b border-slate-100 align-top">
|
const draft = rewardDrafts[row.id] || { points: "", reason: "" };
|
||||||
<td className="py-3 pr-4">
|
const rewardPoints = Number(row.reward_points || 0);
|
||||||
<span className={`inline-flex rounded-md border px-2 py-1 text-xs font-bold ${statusTone(row.status)}`}>
|
const rewardReason = String(row.reward_reason || "").trim();
|
||||||
{statusLabel(row.status)}
|
const rewardStatus = String(row.reward_status || "").toLowerCase();
|
||||||
</span>
|
const hasReward = rewardStatus === "granted" && rewardPoints > 0;
|
||||||
</td>
|
return (
|
||||||
<td className="py-3 pr-4 text-slate-500">{categoryLabel(row.category)}</td>
|
<tr key={row.id} className="border-b border-slate-100 align-top">
|
||||||
<td className="max-w-xl py-3 pr-4">
|
<td className="py-3 pr-4">
|
||||||
<div className="font-semibold leading-5 text-slate-900">{row.message || "—"}</div>
|
<span className={`inline-flex rounded-md border px-2 py-1 text-xs font-bold ${statusTone(row.status)}`}>
|
||||||
{row.contact && <div className="mt-1 text-xs text-slate-500">联系:{row.contact}</div>}
|
{statusLabel(row.status)}
|
||||||
</td>
|
</span>
|
||||||
<td className="py-3 pr-4">
|
</td>
|
||||||
<div className="font-mono text-xs text-blue-700">{contextSummary(row.context)}</div>
|
<td className="py-3 pr-4 text-slate-500">{categoryLabel(row.category)}</td>
|
||||||
{Boolean(row.context?.detail_error) && (
|
<td className="max-w-xl py-3 pr-4">
|
||||||
<div className="mt-1 max-w-xs text-xs text-amber-700">
|
<div className="font-semibold leading-5 text-slate-900">{row.message || "—"}</div>
|
||||||
{String(row.context?.detail_error || "").slice(0, 120)}
|
{row.contact && <div className="mt-1 text-xs text-slate-500">联系:{row.contact}</div>}
|
||||||
</div>
|
</td>
|
||||||
)}
|
<td className="py-3 pr-4">
|
||||||
</td>
|
<div className="font-mono text-xs text-blue-700">{contextSummary(row.context)}</div>
|
||||||
<td className="py-3 pr-4 font-mono text-xs text-slate-500">
|
{Boolean(row.context?.detail_error) && (
|
||||||
{row.user_email || row.user_id || "—"}
|
<div className="mt-1 max-w-xs text-xs text-amber-700">
|
||||||
</td>
|
{String(row.context?.detail_error || "").slice(0, 120)}
|
||||||
<td className="whitespace-nowrap py-3 pr-4 text-xs text-slate-500">{compactDate(row.created_at)}</td>
|
</div>
|
||||||
<td className="py-3 pr-4">
|
)}
|
||||||
<select
|
</td>
|
||||||
value={String(row.status || "open").toLowerCase()}
|
<td className="py-3 pr-4 font-mono text-xs text-slate-500">
|
||||||
onChange={(event) => changeStatus(row, event.target.value)}
|
{row.user_email || row.user_id || "—"}
|
||||||
disabled={updatingId === row.id}
|
</td>
|
||||||
className="h-8 min-w-[108px] rounded border border-slate-200 bg-white px-2 text-xs font-bold text-slate-600 outline-none transition hover:bg-slate-50 focus:border-blue-300 focus:ring-2 focus:ring-blue-100 disabled:cursor-wait disabled:opacity-60"
|
<td className="whitespace-nowrap py-3 pr-4 text-xs text-slate-500">{compactDate(row.created_at)}</td>
|
||||||
aria-label="更新反馈状态"
|
<td className="min-w-[260px] py-3 pr-4">
|
||||||
>
|
{hasReward ? (
|
||||||
{STATUS_UPDATE_OPTIONS.map((item) => (
|
<div className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs">
|
||||||
<option key={item.key} value={item.key}>
|
<div className="font-black text-emerald-700">
|
||||||
{item.label}
|
已发放 +{rewardPoints.toLocaleString()} 分
|
||||||
</option>
|
</div>
|
||||||
))}
|
{rewardReason && (
|
||||||
</select>
|
<div className="mt-1 leading-4 text-emerald-700">
|
||||||
</td>
|
{rewardReason}
|
||||||
</tr>
|
</div>
|
||||||
))}
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={100000}
|
||||||
|
value={draft.points}
|
||||||
|
onChange={(event) => updateRewardDraft(row.id, { points: event.target.value })}
|
||||||
|
placeholder="积分"
|
||||||
|
aria-label="奖励积分"
|
||||||
|
className="h-8 w-20 rounded border border-slate-200 bg-white px-2 text-xs font-bold text-slate-700 outline-none focus:border-blue-300 focus:ring-2 focus:ring-blue-100"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={draft.reason}
|
||||||
|
onChange={(event) => updateRewardDraft(row.id, { reason: event.target.value })}
|
||||||
|
placeholder="奖励原因"
|
||||||
|
aria-label="奖励原因"
|
||||||
|
className="h-8 min-w-0 flex-1 rounded border border-slate-200 bg-white px-2 text-xs text-slate-700 outline-none focus:border-blue-300 focus:ring-2 focus:ring-blue-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleRewardGrant(row)}
|
||||||
|
disabled={rewardingId === row.id || !row.user_email}
|
||||||
|
className="h-8 gap-1.5"
|
||||||
|
>
|
||||||
|
<Coins className="h-3.5 w-3.5" />
|
||||||
|
发放奖励
|
||||||
|
</Button>
|
||||||
|
{!row.user_email && (
|
||||||
|
<div className="text-[11px] text-amber-700">无用户邮箱,无法直接发放。</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 pr-4">
|
||||||
|
<select
|
||||||
|
value={String(row.status || "open").toLowerCase()}
|
||||||
|
onChange={(event) => changeStatus(row, event.target.value)}
|
||||||
|
disabled={updatingId === row.id}
|
||||||
|
className="h-8 min-w-[108px] rounded border border-slate-200 bg-white px-2 text-xs font-bold text-slate-600 outline-none transition hover:bg-slate-50 focus:border-blue-300 focus:ring-2 focus:ring-blue-100 disabled:cursor-wait disabled:opacity-60"
|
||||||
|
aria-label="更新反馈状态"
|
||||||
|
>
|
||||||
|
{STATUS_UPDATE_OPTIONS.map((item) => (
|
||||||
|
<option key={item.key} value={item.key}>
|
||||||
|
{item.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -121,6 +121,13 @@ export const opsApi = {
|
|||||||
body: JSON.stringify({ status }),
|
body: JSON.stringify({ status }),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
grantFeedbackReward(feedbackId: string | number, points: number, reason: string) {
|
||||||
|
return opsFetch<Record<string, unknown>>(`/api/ops/feedback/${feedbackId}/reward`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ points, reason }),
|
||||||
|
});
|
||||||
|
},
|
||||||
resolveIncident(eventId: string | number) {
|
resolveIncident(eventId: string | number) {
|
||||||
return opsFetch<Record<string, unknown>>(`/api/ops/payments/incidents/${eventId}/resolve`, {
|
return opsFetch<Record<string, unknown>>(`/api/ops/payments/incidents/${eventId}/resolve`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -1298,6 +1298,135 @@ class DBManager:
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
return self._feedback_row_to_dict(row) if row else None
|
return self._feedback_row_to_dict(row) if row else None
|
||||||
|
|
||||||
|
def grant_feedback_reward(
|
||||||
|
self,
|
||||||
|
feedback_id: int,
|
||||||
|
*,
|
||||||
|
points: int,
|
||||||
|
reason: str = "",
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
safe_points = int(points or 0)
|
||||||
|
if safe_points <= 0:
|
||||||
|
return {"ok": False, "reason": "invalid_amount"}
|
||||||
|
normalized_reason = str(reason or "").strip()[:500]
|
||||||
|
if not normalized_reason:
|
||||||
|
return {"ok": False, "reason": "missing_reward_reason"}
|
||||||
|
|
||||||
|
now = datetime.now().isoformat()
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
|
feedback_row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, category, message, source, status, contact, user_id,
|
||||||
|
user_email, context_json, reward_points, reward_reason,
|
||||||
|
rewarded_at, reward_status, created_at, updated_at
|
||||||
|
FROM user_feedback
|
||||||
|
WHERE id = ?
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(int(feedback_id),),
|
||||||
|
).fetchone()
|
||||||
|
if not feedback_row:
|
||||||
|
return {"ok": False, "reason": "feedback_not_found"}
|
||||||
|
|
||||||
|
existing_points = int(feedback_row["reward_points"] or 0)
|
||||||
|
existing_status = str(feedback_row["reward_status"] or "").strip().lower()
|
||||||
|
email = str(feedback_row["user_email"] or "").strip().lower()
|
||||||
|
if not email:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"reason": "missing_feedback_user_email",
|
||||||
|
"feedback": self._feedback_row_to_dict(feedback_row),
|
||||||
|
}
|
||||||
|
|
||||||
|
user_row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT telegram_id, username, points, supabase_email
|
||||||
|
FROM users
|
||||||
|
WHERE lower(trim(COALESCE(supabase_email, ''))) = ?
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(email,),
|
||||||
|
).fetchone()
|
||||||
|
if not user_row:
|
||||||
|
user_row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT u.telegram_id, u.username, u.points, b.supabase_email
|
||||||
|
FROM users u
|
||||||
|
JOIN supabase_bindings b ON b.telegram_id = u.telegram_id
|
||||||
|
WHERE lower(trim(COALESCE(b.supabase_email, ''))) = ?
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(email,),
|
||||||
|
).fetchone()
|
||||||
|
if not user_row:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"reason": "user_not_found",
|
||||||
|
"supabase_email": email,
|
||||||
|
"feedback": self._feedback_row_to_dict(feedback_row),
|
||||||
|
}
|
||||||
|
|
||||||
|
before = int(user_row["points"] or 0)
|
||||||
|
if existing_status == "granted" and existing_points > 0:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"reason": "already_rewarded",
|
||||||
|
"feedback": self._feedback_row_to_dict(feedback_row),
|
||||||
|
"points_after": before,
|
||||||
|
}
|
||||||
|
|
||||||
|
telegram_id = int(user_row["telegram_id"] or 0)
|
||||||
|
after = before + safe_points
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE users
|
||||||
|
SET points = ?
|
||||||
|
WHERE telegram_id = ?
|
||||||
|
""",
|
||||||
|
(after, telegram_id),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE user_feedback
|
||||||
|
SET reward_points = ?,
|
||||||
|
reward_reason = ?,
|
||||||
|
reward_status = 'granted',
|
||||||
|
rewarded_at = ?,
|
||||||
|
updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(safe_points, normalized_reason, now, now, int(feedback_id)),
|
||||||
|
)
|
||||||
|
updated_feedback_row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, category, message, source, status, contact, user_id,
|
||||||
|
user_email, context_json, reward_points, reward_reason,
|
||||||
|
rewarded_at, reward_status, created_at, updated_at
|
||||||
|
FROM user_feedback
|
||||||
|
WHERE id = ?
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(int(feedback_id),),
|
||||||
|
).fetchone()
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
self._sync_points_to_supabase_user_metadata(telegram_id, force=True)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"feedback_id": int(feedback_id),
|
||||||
|
"telegram_id": telegram_id,
|
||||||
|
"username": str(user_row["username"] or ""),
|
||||||
|
"supabase_email": str(user_row["supabase_email"] or email),
|
||||||
|
"points_before": before,
|
||||||
|
"points_added": safe_points,
|
||||||
|
"points_after": after,
|
||||||
|
"feedback": self._feedback_row_to_dict(updated_feedback_row)
|
||||||
|
if updated_feedback_row
|
||||||
|
else None,
|
||||||
|
}
|
||||||
|
|
||||||
def get_app_analytics_funnel_summary(self, *, days: int = 30) -> Dict[str, Any]:
|
def get_app_analytics_funnel_summary(self, *, days: int = 30) -> Dict[str, Any]:
|
||||||
safe_days = max(1, min(int(days or 30), 365))
|
safe_days = max(1, min(int(days or 30), 365))
|
||||||
since_dt = datetime.now() - timedelta(days=safe_days)
|
since_dt = datetime.now() - timedelta(days=safe_days)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from fastapi import HTTPException
|
|||||||
|
|
||||||
from src.database.db_manager import DBManager
|
from src.database.db_manager import DBManager
|
||||||
from web.services import feedback_api
|
from web.services import feedback_api
|
||||||
|
from web.services import ops_api
|
||||||
|
|
||||||
|
|
||||||
def test_user_feedback_round_trip_includes_context_and_status(tmp_path):
|
def test_user_feedback_round_trip_includes_context_and_status(tmp_path):
|
||||||
@@ -93,6 +94,85 @@ def test_user_feedback_reward_metadata_round_trip(tmp_path):
|
|||||||
assert row["rewarded_at"] == rewarded["rewarded_at"]
|
assert row["rewarded_at"] == rewarded["rewarded_at"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_feedback_reward_grant_adds_points_and_marks_feedback(tmp_path):
|
||||||
|
db = DBManager(str(tmp_path / "polyweather-feedback-grant.db"))
|
||||||
|
db.upsert_user(1001, "pilot")
|
||||||
|
with db._get_connection() as conn: # noqa: SLF001
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE users
|
||||||
|
SET points = ?, supabase_email = ?
|
||||||
|
WHERE telegram_id = ?
|
||||||
|
""",
|
||||||
|
(50, "pilot@example.com", 1001),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
created = db.append_user_feedback(
|
||||||
|
category="data",
|
||||||
|
message="Amsterdam METAR stale.",
|
||||||
|
user_id="user-1001",
|
||||||
|
user_email="pilot@example.com",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = db.grant_feedback_reward(
|
||||||
|
created["id"],
|
||||||
|
points=300,
|
||||||
|
reason="Valid stale METAR report",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["ok"] is True
|
||||||
|
assert result["points_before"] == 50
|
||||||
|
assert result["points_added"] == 300
|
||||||
|
assert result["points_after"] == 350
|
||||||
|
assert result["feedback"]["reward_points"] == 300
|
||||||
|
assert result["feedback"]["reward_reason"] == "Valid stale METAR report"
|
||||||
|
assert result["feedback"]["reward_status"] == "granted"
|
||||||
|
assert db.get_points_by_supabase_email("pilot@example.com") == 350
|
||||||
|
|
||||||
|
duplicate = db.grant_feedback_reward(
|
||||||
|
created["id"],
|
||||||
|
points=300,
|
||||||
|
reason="Duplicate grant should not apply",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert duplicate["ok"] is False
|
||||||
|
assert duplicate["reason"] == "already_rewarded"
|
||||||
|
assert duplicate["points_after"] == 350
|
||||||
|
|
||||||
|
|
||||||
|
def test_ops_feedback_reward_service_returns_operator(monkeypatch):
|
||||||
|
class FakeDB:
|
||||||
|
def grant_feedback_reward(self, feedback_id, *, points, reason):
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"feedback_id": feedback_id,
|
||||||
|
"points_added": points,
|
||||||
|
"points_after": 900,
|
||||||
|
"feedback": {
|
||||||
|
"id": feedback_id,
|
||||||
|
"reward_points": points,
|
||||||
|
"reward_reason": reason,
|
||||||
|
"reward_status": "granted",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(ops_api, "_require_ops", lambda request: {"email": "ops@example.com"})
|
||||||
|
monkeypatch.setattr(ops_api, "DBManager", lambda: FakeDB())
|
||||||
|
|
||||||
|
payload = ops_api.grant_ops_feedback_reward(
|
||||||
|
object(),
|
||||||
|
feedback_id=42,
|
||||||
|
points=500,
|
||||||
|
reason="High impact chart bug",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert payload["ok"] is True
|
||||||
|
assert payload["operator_email"] == "ops@example.com"
|
||||||
|
assert payload["feedback"]["reward_points"] == 500
|
||||||
|
assert payload["feedback"]["reward_reason"] == "High impact chart bug"
|
||||||
|
|
||||||
|
|
||||||
def test_user_feedback_identity_filter_returns_only_matching_user(tmp_path):
|
def test_user_feedback_identity_filter_returns_only_matching_user(tmp_path):
|
||||||
db = DBManager(str(tmp_path / "polyweather-feedback-identity.db"))
|
db = DBManager(str(tmp_path / "polyweather-feedback-identity.db"))
|
||||||
mine_by_user_id = db.append_user_feedback(
|
mine_by_user_id = db.append_user_feedback(
|
||||||
|
|||||||
@@ -504,6 +504,11 @@ class GrantPointsRequest(BaseModel):
|
|||||||
points: int = Field(..., gt=0, le=100000)
|
points: int = Field(..., gt=0, le=100000)
|
||||||
|
|
||||||
|
|
||||||
|
class FeedbackRewardRequest(BaseModel):
|
||||||
|
points: int = Field(..., gt=0, le=100000)
|
||||||
|
reason: str = Field(..., min_length=2, max_length=500)
|
||||||
|
|
||||||
|
|
||||||
def _sf(v) -> Optional[float]:
|
def _sf(v) -> Optional[float]:
|
||||||
if v is None:
|
if v is None:
|
||||||
return None
|
return None
|
||||||
|
|||||||
+16
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from fastapi import APIRouter, Request, Response
|
from fastapi import APIRouter, Request, Response
|
||||||
|
|
||||||
from web.core import GrantPointsRequest
|
from web.core import FeedbackRewardRequest, GrantPointsRequest
|
||||||
from web.services.ops_api import (
|
from web.services.ops_api import (
|
||||||
extend_ops_subscription,
|
extend_ops_subscription,
|
||||||
get_ops_analytics_funnel,
|
get_ops_analytics_funnel,
|
||||||
@@ -28,6 +28,7 @@ from web.services.ops_api import (
|
|||||||
list_ops_payments,
|
list_ops_payments,
|
||||||
search_ops_users,
|
search_ops_users,
|
||||||
update_ops_config,
|
update_ops_config,
|
||||||
|
grant_ops_feedback_reward,
|
||||||
update_ops_sensitive_config,
|
update_ops_sensitive_config,
|
||||||
update_ops_feedback_status,
|
update_ops_feedback_status,
|
||||||
get_ops_training_accuracy,
|
get_ops_training_accuracy,
|
||||||
@@ -88,6 +89,20 @@ async def ops_feedback_update_status(request: Request, feedback_id: int):
|
|||||||
return update_ops_feedback_status(request, feedback_id=feedback_id, status=status)
|
return update_ops_feedback_status(request, feedback_id=feedback_id, status=status)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/ops/feedback/{feedback_id}/reward")
|
||||||
|
async def ops_feedback_grant_reward(
|
||||||
|
request: Request,
|
||||||
|
feedback_id: int,
|
||||||
|
body: FeedbackRewardRequest,
|
||||||
|
):
|
||||||
|
return grant_ops_feedback_reward(
|
||||||
|
request,
|
||||||
|
feedback_id=feedback_id,
|
||||||
|
points=body.points,
|
||||||
|
reason=body.reason,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/ops/memberships")
|
@router.get("/api/ops/memberships")
|
||||||
async def ops_memberships(request: Request, limit: int = 200):
|
async def ops_memberships(request: Request, limit: int = 200):
|
||||||
return list_ops_memberships(request, limit=limit)
|
return list_ops_memberships(request, limit=limit)
|
||||||
|
|||||||
@@ -189,6 +189,30 @@ def update_ops_feedback_status(
|
|||||||
return {"ok": True, "feedback": updated}
|
return {"ok": True, "feedback": updated}
|
||||||
|
|
||||||
|
|
||||||
|
def grant_ops_feedback_reward(
|
||||||
|
request: Request,
|
||||||
|
*,
|
||||||
|
feedback_id: int,
|
||||||
|
points: int,
|
||||||
|
reason: str,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
admin = _require_ops(request) or {}
|
||||||
|
db = DBManager()
|
||||||
|
result = db.grant_feedback_reward(
|
||||||
|
feedback_id,
|
||||||
|
points=points,
|
||||||
|
reason=reason,
|
||||||
|
)
|
||||||
|
result["operator_email"] = admin.get("email")
|
||||||
|
if not result.get("ok"):
|
||||||
|
reason_code = str(result.get("reason") or "feedback_reward_failed")
|
||||||
|
status_code = 404 if reason_code in {"feedback_not_found", "user_not_found"} else 400
|
||||||
|
if reason_code == "already_rewarded":
|
||||||
|
status_code = 409
|
||||||
|
raise HTTPException(status_code=status_code, detail=result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _list_active_subscriptions_with_windows(
|
def _list_active_subscriptions_with_windows(
|
||||||
limit: int,
|
limit: int,
|
||||||
) -> tuple[list[dict[str, Any]], dict[str, dict[str, Any]], bool]:
|
) -> tuple[list[dict[str, Any]], dict[str, dict[str, Any]], bool]:
|
||||||
|
|||||||
Reference in New Issue
Block a user