Reduce Supabase disk IO
This commit is contained in:
@@ -22,6 +22,7 @@
|
||||
在 Supabase SQL Editor 执行:
|
||||
|
||||
- `scripts/supabase/schema.sql`
|
||||
- 既有生产项目遇到 Disk IO Budget 告警时,再执行 `scripts/supabase/io_budget_indexes.sql`
|
||||
|
||||
会创建支付与订阅相关表:
|
||||
|
||||
@@ -33,6 +34,15 @@
|
||||
- `payment_intents`
|
||||
- `payment_transactions`
|
||||
|
||||
### 3.1 Disk IO 告警处理
|
||||
|
||||
如果 Supabase 提示项目正在耗尽 Disk IO Budget,先在 SQL Editor 执行:
|
||||
|
||||
1. `scripts/supabase/io_budget_indexes.sql`
|
||||
2. `scripts/supabase/disk_io_diagnostics.sql`
|
||||
|
||||
第一个脚本会把热查询索引收敛为更低写放大的部分索引,移除已被唯一约束覆盖或无热路径使用的冗余索引,并对相关表执行 `ANALYZE`。第二个脚本用于查看表扫描、dead tuples、索引命中和 `pg_stat_statements` 中的高读块 SQL。生产执行后,继续观察 Supabase daily/hourly Disk IO 图表,确认请求延迟和 IO wait 是否下降。
|
||||
|
||||
## 4. 环境变量
|
||||
|
||||
### 4.1 前端(Vercel / frontend/.env.local)
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
getLocalDevAuthPayload,
|
||||
isLocalFullAccessHost,
|
||||
} from "@/lib/local-dev-access";
|
||||
import { hasSupabaseServerEnv } from "@/lib/supabase/server";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -33,8 +34,22 @@ export async function GET(req: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
let auth: Awaited<ReturnType<typeof buildBackendRequestHeaders>> | null = null;
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
auth = await buildBackendRequestHeaders(req);
|
||||
if (
|
||||
hasSupabaseServerEnv() &&
|
||||
!auth.authUserId &&
|
||||
!req.headers.get("authorization")
|
||||
) {
|
||||
const response = NextResponse.json({
|
||||
authenticated: false,
|
||||
subscription_active: false,
|
||||
points: 0,
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 6000);
|
||||
let res: Response;
|
||||
@@ -98,8 +113,7 @@ export async function GET(req: NextRequest) {
|
||||
const response = NextResponse.json(data);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
if (auth.authUserId) {
|
||||
if (auth?.authUserId) {
|
||||
const response = NextResponse.json({
|
||||
authenticated: true,
|
||||
user_id: auth.authUserId,
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -17,6 +18,9 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const url = new URL(`${API_BASE}/api/ops/analytics/funnel`);
|
||||
const days = req.nextUrl.searchParams.get("days");
|
||||
if (days) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
const BACKEND = API_BASE ? `${API_BASE}/api/ops/config` : "";
|
||||
@@ -9,6 +10,9 @@ export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const res = await fetch(BACKEND, { headers: auth.headers, cache: "no-store" });
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
|
||||
@@ -20,6 +24,9 @@ export async function PUT(req: NextRequest) {
|
||||
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const body = await req.text();
|
||||
const res = await fetch(BACKEND, { method: "PUT", headers: { ...auth.headers, "Content-Type": "application/json" }, body, cache: "no-store" });
|
||||
const raw = await res.text();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -8,6 +9,9 @@ export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/ops/health-check`, { headers: auth.headers, cache: "no-store" });
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -17,6 +18,9 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const url = new URL(`${API_BASE}/api/ops/leaderboard/weekly`);
|
||||
const limit = req.nextUrl.searchParams.get("limit");
|
||||
if (limit) url.searchParams.set("limit", limit);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -8,6 +9,8 @@ export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
const url = new URL(`${API_BASE}/api/ops/memberships/growth`);
|
||||
const days = req.nextUrl.searchParams.get("days");
|
||||
if (days) url.searchParams.set("days", days);
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
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 url = new URL(`${API_BASE}/api/ops/memberships/overview`);
|
||||
const limit = req.nextUrl.searchParams.get("limit");
|
||||
const days = req.nextUrl.searchParams.get("days");
|
||||
if (limit) url.searchParams.set("limit", limit);
|
||||
if (days) url.searchParams.set("days", days);
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
"Content-Type": res.headers.get("content-type") || "application/json",
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch memberships overview",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -17,6 +18,8 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
const url = new URL(`${API_BASE}/api/ops/memberships`);
|
||||
const limit = req.nextUrl.searchParams.get("limit");
|
||||
if (limit) url.searchParams.set("limit", limit);
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -17,6 +18,9 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/ops/online-users`, {
|
||||
headers: auth.headers,
|
||||
cache: "no-store",
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -21,6 +22,9 @@ export async function POST(req: NextRequest, context: RouteContext) {
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const { eventId } = await context.params;
|
||||
const res = await fetch(`${API_BASE}/api/ops/payments/incidents/${eventId}/resolve`, {
|
||||
method: "POST",
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -17,6 +18,9 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const url = new URL(`${API_BASE}/api/ops/payments/incidents`);
|
||||
const limit = req.nextUrl.searchParams.get("limit");
|
||||
if (limit) url.searchParams.set("limit", limit);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
const ENTITLEMENT_TOKEN = process.env.POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN?.trim() || "";
|
||||
@@ -9,6 +10,9 @@ export async function POST(req: NextRequest) {
|
||||
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const body = await req.text();
|
||||
const headers: Record<string, string> = { ...auth.headers as Record<string, string>, "Content-Type": "application/json" };
|
||||
if (ENTITLEMENT_TOKEN) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -41,6 +42,23 @@ async function findSupabaseUserIdByEmail(email: string) {
|
||||
if (!supabaseUrl || !serviceRoleKey) {
|
||||
throw new Error("Supabase service role is not configured on Vercel");
|
||||
}
|
||||
const profileRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/profiles?select=id&email=eq.${encodeURIComponent(email)}&limit=1`,
|
||||
{
|
||||
headers: {
|
||||
apikey: serviceRoleKey,
|
||||
Authorization: `Bearer ${serviceRoleKey}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
cache: "no-store",
|
||||
},
|
||||
);
|
||||
const profileData = (await profileRes.json().catch(() => [])) as Array<{ id?: string }>;
|
||||
if (profileRes.ok) {
|
||||
const profileUserId = String(profileData?.[0]?.id || "").trim();
|
||||
if (profileUserId) return { supabaseUrl, serviceRoleKey, userId: profileUserId };
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/auth/v1/admin/users?filter=${encodeURIComponent(`email.eq.${email}`)}`,
|
||||
{
|
||||
@@ -109,7 +127,7 @@ async function grantSubscriptionDirectly(req: NextRequest, bodyText: string, aut
|
||||
apikey: serviceRoleKey,
|
||||
Authorization: `Bearer ${serviceRoleKey}`,
|
||||
"Content-Type": "application/json",
|
||||
Prefer: "return=representation",
|
||||
Prefer: "return=minimal",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
cache: "no-store",
|
||||
@@ -138,6 +156,9 @@ async function grantSubscriptionDirectly(req: NextRequest, bodyText: string, aut
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const body = await req.text();
|
||||
if (!API_BASE) {
|
||||
return grantSubscriptionDirectly(req, body, auth.authEmail);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -8,6 +9,9 @@ export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/ops/telegram/members-audit`, { headers: auth.headers, cache: "no-store" });
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -8,6 +9,9 @@ export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/ops/training/accuracy`, { headers: auth.headers, cache: "no-store" });
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -17,6 +18,9 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const url = new URL(`${API_BASE}/api/ops/truth-history`);
|
||||
for (const key of ["city", "date_from", "date_to", "limit"]) {
|
||||
const value = req.nextUrl.searchParams.get(key);
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -17,6 +18,9 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const body = await req.text();
|
||||
const res = await fetch(`${API_BASE}/api/ops/users/grant-points`, {
|
||||
method: "POST",
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -17,6 +18,9 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const url = new URL(`${API_BASE}/api/ops/users`);
|
||||
const q = req.nextUrl.searchParams.get("q");
|
||||
const limit = req.nextUrl.searchParams.get("limit");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -8,6 +9,9 @@ export async function GET(req: NextRequest) {
|
||||
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const authError = requireOpsProxyAuth(req, auth);
|
||||
if (authError) return authError;
|
||||
|
||||
const url = new URL(`${API_BASE}/api/ops/logs`);
|
||||
const level = req.nextUrl.searchParams.get("level");
|
||||
const lines = req.nextUrl.searchParams.get("lines");
|
||||
|
||||
@@ -13,7 +13,7 @@ export async function GET(req: NextRequest) {
|
||||
return proxyBackendJsonGet(req, {
|
||||
cacheControl: "public, max-age=0, s-maxage=300, stale-while-revalidate=900",
|
||||
detailLimit: 350,
|
||||
includeSupabaseIdentity: true,
|
||||
includeSupabaseIdentity: false,
|
||||
publicMessage: "Failed to fetch payment config",
|
||||
revalidateSeconds: 300,
|
||||
url: `${API_BASE}/api/payments/config`,
|
||||
|
||||
@@ -16,7 +16,7 @@ export async function GET(req: NextRequest) {
|
||||
conditionalResponse: false,
|
||||
detailLimit: 500,
|
||||
fetchCache: "no-store",
|
||||
includeSupabaseIdentity: true,
|
||||
includeSupabaseIdentity: false,
|
||||
publicMessage: "Failed to fetch payment runtime",
|
||||
url: `${API_BASE}/api/payments/runtime`,
|
||||
});
|
||||
|
||||
@@ -37,7 +37,9 @@ export async function POST(req: NextRequest) {
|
||||
const timeoutId = setTimeout(() => controller.abort(), OVERVIEW_PROXY_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
auth = await buildBackendRequestHeaders(req);
|
||||
auth = await buildBackendRequestHeaders(req, {
|
||||
includeSupabaseIdentity: false,
|
||||
});
|
||||
const headers = new Headers(auth.headers);
|
||||
headers.set("Content-Type", "application/json");
|
||||
headers.set("Accept", "application/json");
|
||||
|
||||
@@ -107,6 +107,75 @@ export function runTests() {
|
||||
!middlewareSource.includes("return NextResponse.next();\n }\n }\n\n const response = NextResponse.next"),
|
||||
"middleware must not treat the mere presence of a bearer token as authenticated",
|
||||
);
|
||||
assert(
|
||||
middlewareSource.includes("function hasSupabaseSessionCookie") &&
|
||||
middlewareSource.includes("request.cookies.getAll()") &&
|
||||
middlewareSource.includes("hasSupabaseSessionCookieValues"),
|
||||
"middleware must detect non-empty Supabase session cookies locally via the shared helper before refreshing auth",
|
||||
);
|
||||
assert(
|
||||
middlewareSource.includes("redirectToLogin(request, pathname)") &&
|
||||
middlewareSource.indexOf("if (!hasSupabaseSessionCookie(request))") <
|
||||
middlewareSource.indexOf("await refreshMiddlewareSession(request)"),
|
||||
"middleware must redirect no-cookie page requests without calling Supabase auth",
|
||||
);
|
||||
assert(
|
||||
middlewareSource.includes("unauthorizedSupabaseSessionResponse()"),
|
||||
"middleware must reject no-cookie protected API requests without calling Supabase auth",
|
||||
);
|
||||
for (const route of [
|
||||
"app/api/ops/analytics/funnel/route.ts",
|
||||
"app/api/ops/config/route.ts",
|
||||
"app/api/ops/health-check/route.ts",
|
||||
"app/api/ops/leaderboard/weekly/route.ts",
|
||||
"app/api/ops/memberships/route.ts",
|
||||
"app/api/ops/memberships/growth/route.ts",
|
||||
"app/api/ops/memberships/overview/route.ts",
|
||||
"app/api/ops/online-users/route.ts",
|
||||
"app/api/ops/payments/incidents/route.ts",
|
||||
"app/api/ops/payments/incidents/[eventId]/resolve/route.ts",
|
||||
"app/api/ops/subscriptions/extend/route.ts",
|
||||
"app/api/ops/subscriptions/grant/route.ts",
|
||||
"app/api/ops/telegram/members-audit/route.ts",
|
||||
"app/api/ops/training/accuracy/route.ts",
|
||||
"app/api/ops/truth-history/route.ts",
|
||||
"app/api/ops/users/route.ts",
|
||||
"app/api/ops/users/grant-points/route.ts",
|
||||
"app/api/ops/view-logs/route.ts",
|
||||
]) {
|
||||
const routeSource = fs.readFileSync(path.join(projectRoot, route), "utf8");
|
||||
assert(
|
||||
routeSource.includes("requireOpsProxyAuth(req, auth)") &&
|
||||
routeSource.indexOf("requireOpsProxyAuth(req, auth)") >
|
||||
routeSource.indexOf("buildBackendRequestHeaders(req"),
|
||||
`${route} must reject requests without Supabase identity before forwarding the backend entitlement token`,
|
||||
);
|
||||
}
|
||||
assert(
|
||||
middlewareSource.includes('pathname === "/api/payments/config"'),
|
||||
"middleware must treat public payment config as public API so cached config requests do not refresh Supabase sessions",
|
||||
);
|
||||
const optionalRefreshFunction = middlewareSource.slice(
|
||||
middlewareSource.indexOf("function shouldRefreshOptionalSupabaseSession"),
|
||||
middlewareSource.indexOf("function hasSupabaseSessionCookie"),
|
||||
);
|
||||
assert(
|
||||
!optionalRefreshFunction.includes('pathname.startsWith("/api/ops/")') &&
|
||||
!optionalRefreshFunction.includes('pathname.startsWith("/api/payments/")'),
|
||||
"optional Supabase middleware refresh must not pre-read sessions for API routes that already build backend auth headers",
|
||||
);
|
||||
const optionalRefreshIndex = middlewareSource.indexOf(
|
||||
"function shouldRefreshOptionalSupabaseSession",
|
||||
);
|
||||
const systemStatusPublicIndex = middlewareSource.indexOf(
|
||||
'pathname === "/api/system/status"',
|
||||
);
|
||||
assert(
|
||||
systemStatusPublicIndex >= 0 &&
|
||||
optionalRefreshIndex >= 0 &&
|
||||
systemStatusPublicIndex < optionalRefreshIndex,
|
||||
"middleware must treat public system status as public API instead of optional Supabase session refresh",
|
||||
);
|
||||
|
||||
for (const route of paymentRoutes) {
|
||||
const routeSource = fs.readFileSync(path.join(projectRoot, route), "utf8");
|
||||
|
||||
@@ -92,6 +92,26 @@ export function runTests() {
|
||||
path.join(projectRoot, "app", "api", "auth", "me", "route.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const paymentConfigRouteSource = fs.readFileSync(
|
||||
path.join(projectRoot, "app", "api", "payments", "config", "route.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const paymentRuntimeRouteSource = fs.readFileSync(
|
||||
path.join(projectRoot, "app", "api", "payments", "runtime", "route.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const backendAuthSource = fs.readFileSync(
|
||||
path.join(projectRoot, "lib", "backend-auth.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const opsAdminSource = fs.readFileSync(
|
||||
path.join(projectRoot, "lib", "ops-admin.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const supabaseServerSource = fs.readFileSync(
|
||||
path.join(projectRoot, "lib", "supabase", "server.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const subscriptionsPageSource = fs.readFileSync(
|
||||
path.join(
|
||||
projectRoot,
|
||||
@@ -102,6 +122,26 @@ export function runTests() {
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
const membershipsPageSource = fs.readFileSync(
|
||||
path.join(
|
||||
projectRoot,
|
||||
"components",
|
||||
"ops",
|
||||
"memberships",
|
||||
"MembershipsPageClient.tsx",
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
const opsOverviewSource = fs.readFileSync(
|
||||
path.join(
|
||||
projectRoot,
|
||||
"components",
|
||||
"ops",
|
||||
"overview",
|
||||
"OverviewPageClient.tsx",
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert(
|
||||
accountCenterSource.includes(
|
||||
@@ -157,6 +197,11 @@ export function runTests() {
|
||||
hookSource.includes("retriedBackendJson"),
|
||||
"account snapshot loader must retry with a refreshed Supabase token when local user exists but /api/auth/me reports unauthenticated",
|
||||
);
|
||||
assert(
|
||||
!hookSource.includes(".auth.getUser()") &&
|
||||
hookSource.includes(".auth.getSession()"),
|
||||
"account snapshot loader must use the local Supabase session instead of calling getUser before /api/auth/me validates the bearer",
|
||||
);
|
||||
assert(
|
||||
subscriptionsPageSource.includes("getSupabaseBrowserClient") &&
|
||||
subscriptionsPageSource.includes("refreshSession") &&
|
||||
@@ -165,11 +210,28 @@ export function runTests() {
|
||||
subscriptionsPageSource.includes("/api/ops/subscriptions/extend"),
|
||||
"ops manual subscription grant/extend must send the Supabase bearer token to avoid 401 when route cookies are stale",
|
||||
);
|
||||
assert(
|
||||
membershipsPageSource.includes("opsApi.membershipsOverview") &&
|
||||
!membershipsPageSource.includes('fetch("/api/ops/memberships/growth?days=90"') &&
|
||||
!membershipsPageSource.includes("Promise.all"),
|
||||
"ops memberships page must load memberships and growth through one overview proxy request to avoid duplicate Supabase session/subscription reads",
|
||||
);
|
||||
assert(
|
||||
opsOverviewSource.includes("opsApi.membershipsOverview(200, 30)") &&
|
||||
!opsOverviewSource.includes("opsApi.memberships()") &&
|
||||
!opsOverviewSource.includes("opsApi.membershipsGrowth(30)"),
|
||||
"ops overview page must reuse membershipsOverview for table and growth data instead of issuing separate membership/growth proxy requests",
|
||||
);
|
||||
assert(
|
||||
grantRouteSource.includes("grantSubscriptionDirectly") &&
|
||||
grantRouteSource.includes("res.status === 404") &&
|
||||
grantRouteSource.includes('"status": "active"'),
|
||||
"ops subscription grant route must fall back to direct Supabase grant when the VPS backend route is missing",
|
||||
grantRouteSource.includes('"status": "active"') &&
|
||||
grantRouteSource.includes("/rest/v1/profiles") &&
|
||||
grantRouteSource.includes("select=id&email=eq.") &&
|
||||
grantRouteSource.indexOf("/rest/v1/profiles") <
|
||||
grantRouteSource.indexOf("/auth/v1/admin/users") &&
|
||||
grantRouteSource.includes('Prefer: "return=minimal"'),
|
||||
"ops subscription grant route must fall back to direct Supabase grant and resolve users via indexed profiles before Auth Admin",
|
||||
);
|
||||
assert(
|
||||
authMeRouteSource.includes("if ((res.status === 401 || res.status === 403) && auth.authUserId)") &&
|
||||
@@ -177,4 +239,76 @@ export function runTests() {
|
||||
authMeRouteSource.includes("subscription_active: null"),
|
||||
"auth profile proxy must preserve authenticated identity with unknown subscription on backend 401/403 instead of forcing a false paywall",
|
||||
);
|
||||
assert(
|
||||
(authMeRouteSource.match(/buildBackendRequestHeaders\(req\)/g) || []).length === 1 &&
|
||||
authMeRouteSource.includes("let auth: Awaited<ReturnType<typeof buildBackendRequestHeaders>> | null = null"),
|
||||
"auth profile proxy must build backend auth headers once and reuse them on timeout/error fallback",
|
||||
);
|
||||
assert(
|
||||
authMeRouteSource.includes("hasSupabaseServerEnv()") &&
|
||||
authMeRouteSource.includes("!auth.authUserId") &&
|
||||
authMeRouteSource.includes('req.headers.get("authorization")') &&
|
||||
authMeRouteSource.indexOf("authenticated: false") <
|
||||
authMeRouteSource.indexOf("await fetch(`${API_BASE}/api/auth/me`"),
|
||||
"auth profile proxy must return unauthenticated locally for no-session Supabase requests instead of forwarding the backend entitlement token",
|
||||
);
|
||||
assert(
|
||||
backendAuthSource.includes("if (incomingAuth) {") &&
|
||||
backendAuthSource.indexOf("if (incomingAuth) {") <
|
||||
backendAuthSource.indexOf("const supabase = createSupabaseRouteClient"),
|
||||
"backend proxy must forward caller bearer tokens before creating a Supabase route client to avoid duplicate getUser calls",
|
||||
);
|
||||
assert(
|
||||
backendAuthSource.includes("headers.set(FORWARDED_SUPABASE_USER_ID_HEADER") &&
|
||||
backendAuthSource.includes("headers.set(FORWARDED_SUPABASE_EMAIL_HEADER") &&
|
||||
backendAuthSource.indexOf("headers.set(FORWARDED_SUPABASE_USER_ID_HEADER") >
|
||||
backendAuthSource.indexOf("const sessionUser = session?.user"),
|
||||
"backend proxy must forward Supabase session user id/email with the backend token so Python can skip duplicate /auth/v1/user validation",
|
||||
);
|
||||
assert(
|
||||
backendAuthSource.includes("function hasSupabaseSessionCookie") &&
|
||||
backendAuthSource.includes("String(cookie.value || \"\").trim()") &&
|
||||
backendAuthSource.includes("if (!hasSupabaseSessionCookie(request))") &&
|
||||
backendAuthSource.indexOf("if (!hasSupabaseSessionCookie(request))") <
|
||||
backendAuthSource.indexOf("const supabase = createSupabaseRouteClient"),
|
||||
"backend proxy must skip Supabase route client/session reads when no auth cookie is present",
|
||||
);
|
||||
assert(
|
||||
!backendAuthSource.includes(".auth.getUser()") &&
|
||||
backendAuthSource.includes(".auth.getSession()"),
|
||||
"backend proxy must not call Supabase getUser before forwarding a bearer token that the backend validates again",
|
||||
);
|
||||
assert(
|
||||
supabaseServerSource.includes("export function hasSupabaseSessionCookieValues") &&
|
||||
supabaseServerSource.includes('name === "supabase-auth-token"') &&
|
||||
supabaseServerSource.includes('name.startsWith("sb-")') &&
|
||||
supabaseServerSource.indexOf("if (!hasSupabaseSessionCookieValues") <
|
||||
supabaseServerSource.indexOf("const supabase = createSupabaseServerClient"),
|
||||
"Supabase server helpers must expose one cookie detector and skip refresh getUser calls when no session cookie exists",
|
||||
);
|
||||
assert(
|
||||
supabaseServerSource.includes(".auth.getClaims()") &&
|
||||
!supabaseServerSource.includes(".auth.getUser()"),
|
||||
"Supabase middleware refresh must validate JWTs with getClaims so asymmetric JWT projects avoid per-request /auth/v1/user reads",
|
||||
);
|
||||
assert(
|
||||
opsAdminSource.includes("hasSupabaseSessionCookieValues") &&
|
||||
opsAdminSource.indexOf("if (!hasSupabaseSessionCookieValues") <
|
||||
opsAdminSource.indexOf("const supabase = createSupabaseServerClient"),
|
||||
"ops admin page gate must redirect before creating a Supabase client/getUser call when no session cookie exists",
|
||||
);
|
||||
assert(
|
||||
opsAdminSource.includes(".auth.getClaims()") &&
|
||||
!opsAdminSource.includes(".auth.getUser()") &&
|
||||
opsAdminSource.includes("claims?.email"),
|
||||
"ops admin page gate must use verified JWT claims instead of a per-page getUser auth lookup",
|
||||
);
|
||||
assert(
|
||||
paymentConfigRouteSource.includes("includeSupabaseIdentity: false"),
|
||||
"payment config proxy must not read Supabase session cookies for public cached config",
|
||||
);
|
||||
assert(
|
||||
paymentRuntimeRouteSource.includes("includeSupabaseIdentity: false"),
|
||||
"payment runtime proxy must not read Supabase session cookies because backend entitlement token already protects the runtime status payload",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -151,14 +151,17 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
|
||||
setErrorText("");
|
||||
const attempt = async (retry: boolean): Promise<void> => {
|
||||
const userPromise = supabaseReady
|
||||
? getSupabaseBrowserClient().auth.getUser()
|
||||
: Promise.resolve({ data: { user: null as User | null } });
|
||||
? getSupabaseBrowserClient()
|
||||
.auth.getSession()
|
||||
.then(({ data: { session } }) => session?.user ?? null)
|
||||
.catch(() => null as User | null)
|
||||
: Promise.resolve(null as User | null);
|
||||
const authHeadersPromise = buildAuthedHeaders(false);
|
||||
const backendPromise = authHeadersPromise.then((headers) =>
|
||||
fetch("/api/auth/me", { cache: "no-store", headers }),
|
||||
);
|
||||
const [userResult, backendResult] = await Promise.all([userPromise, backendPromise]);
|
||||
setUser(userResult.data?.user ?? null);
|
||||
const [localUser, backendResult] = await Promise.all([userPromise, backendPromise]);
|
||||
setUser(localUser);
|
||||
if (!backendResult.ok) {
|
||||
if (retry && backendResult.status === 401) {
|
||||
await new Promise((r) => setTimeout(r, 1200));
|
||||
@@ -171,7 +174,7 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
|
||||
if (
|
||||
retry &&
|
||||
supabaseReady &&
|
||||
userResult.data?.user &&
|
||||
localUser &&
|
||||
backendJson.authenticated === false
|
||||
) {
|
||||
try {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
buildCityDetailProxyCachePolicy,
|
||||
buildForceRefreshProxyCachePolicy,
|
||||
@@ -22,4 +24,32 @@ export function runTests() {
|
||||
|
||||
const scanForced = buildForceRefreshProxyCachePolicy("true", 10);
|
||||
assert.equal(scanForced.fetchMode, "no-store");
|
||||
|
||||
const overviewProxySource = fs.readFileSync(
|
||||
path.join(
|
||||
process.cwd(),
|
||||
"app",
|
||||
"api",
|
||||
"scan",
|
||||
"terminal",
|
||||
"overview",
|
||||
"route.ts",
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(
|
||||
overviewProxySource,
|
||||
/buildBackendRequestHeaders\(req,\s*\{\s*includeSupabaseIdentity:\s*false,\s*\}\)/s,
|
||||
"scan terminal overview proxy must not read Supabase sessions for public overview payloads",
|
||||
);
|
||||
|
||||
const priorityWarmProxySource = fs.readFileSync(
|
||||
path.join(process.cwd(), "lib", "system-priority-proxy.ts"),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(
|
||||
priorityWarmProxySource,
|
||||
/buildBackendRequestHeaders\(req,\s*\{\s*includeSupabaseIdentity:\s*false,\s*\}\)/s,
|
||||
"priority warm proxy must not read Supabase sessions for backend-token warm hints",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -41,12 +41,9 @@ export function MembershipsPageClient() {
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [mData, gData] = await Promise.all([
|
||||
opsApi.memberships(),
|
||||
fetch("/api/ops/memberships/growth?days=90", { cache: "no-store" }).then(r => r.ok ? r.json() : null),
|
||||
]);
|
||||
setMemberships((mData as unknown as { memberships?: MembershipEntry[] }).memberships ?? []);
|
||||
setGrowth((gData as { daily?: GrowthPoint[] })?.daily ?? []);
|
||||
const data = await opsApi.membershipsOverview(200, 90);
|
||||
setMemberships((data as unknown as { memberships?: MembershipEntry[] }).memberships ?? []);
|
||||
setGrowth((data as { daily?: GrowthPoint[] })?.daily ?? []);
|
||||
} catch { /* */ }
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
@@ -44,16 +44,15 @@ export function OverviewPageClient() {
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [s, m, f, g] = await Promise.all([
|
||||
const [s, m, f] = await Promise.all([
|
||||
opsApi.systemStatus() as Promise<SystemStatusPayload>,
|
||||
opsApi.memberships() as Promise<MembershipsPayload>,
|
||||
opsApi.membershipsOverview(200, 30) as Promise<MembershipsPayload & { daily?: { date: string; trial: number; paid: number; total: number; cumulative: number }[] }>,
|
||||
opsApi.funnel(30),
|
||||
opsApi.membershipsGrowth(30),
|
||||
]);
|
||||
setStatus(s);
|
||||
setMemberships((m as MembershipsPayload).memberships ?? []);
|
||||
setFunnel(f);
|
||||
setGrowth(g?.daily ?? []);
|
||||
setGrowth(m?.daily ?? []);
|
||||
} catch { /* */ }
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@ export const BACKEND_ENTITLEMENT_HEADER = "x-polyweather-entitlement";
|
||||
export const FORWARDED_SUPABASE_USER_ID_HEADER = "x-polyweather-auth-user-id";
|
||||
export const FORWARDED_SUPABASE_EMAIL_HEADER = "x-polyweather-auth-email";
|
||||
|
||||
type HeaderBuildResult = {
|
||||
export type BackendHeaderBuildResult = {
|
||||
headers: HeadersInit;
|
||||
response: NextResponse | null;
|
||||
authUserId?: string | null;
|
||||
@@ -26,10 +26,22 @@ function extractBearerToken(headerValue: string | null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
function hasSupabaseSessionCookie(request: NextRequest) {
|
||||
return request.cookies.getAll().some((cookie) => {
|
||||
const name = cookie.name.toLowerCase();
|
||||
const value = String(cookie.value || "").trim();
|
||||
if (!value) return false;
|
||||
return (
|
||||
name === "supabase-auth-token" ||
|
||||
(name.startsWith("sb-") && name.includes("-auth-token"))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function buildBackendRequestHeaders(
|
||||
request: NextRequest,
|
||||
options?: HeaderBuildOptions,
|
||||
): Promise<HeaderBuildResult> {
|
||||
): Promise<BackendHeaderBuildResult> {
|
||||
const headers = new Headers({
|
||||
Accept: "application/json",
|
||||
});
|
||||
@@ -39,31 +51,19 @@ export async function buildBackendRequestHeaders(
|
||||
}
|
||||
|
||||
const incomingAuth = extractBearerToken(request.headers.get("authorization"));
|
||||
if (incomingAuth) {
|
||||
headers.set("Authorization", `Bearer ${incomingAuth}`);
|
||||
return { headers, response: null, authUserId: null, authEmail: null };
|
||||
}
|
||||
|
||||
const includeSupabaseIdentity = options?.includeSupabaseIdentity !== false;
|
||||
if (hasSupabaseServerEnv() && includeSupabaseIdentity) {
|
||||
if (!hasSupabaseSessionCookie(request)) {
|
||||
return { headers, response: null, authUserId: null, authEmail: null };
|
||||
}
|
||||
|
||||
const passthroughResponse = new NextResponse(null, { status: 200 });
|
||||
const supabase = createSupabaseRouteClient(request, passthroughResponse);
|
||||
|
||||
// Always call getUser() to ensure token is validated and refreshed if expired
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
const forwardedUserId = String(user?.id || "").trim();
|
||||
const forwardedEmail = String(user?.email || "").trim();
|
||||
if (forwardedUserId) {
|
||||
headers.set(FORWARDED_SUPABASE_USER_ID_HEADER, forwardedUserId);
|
||||
}
|
||||
if (forwardedEmail) {
|
||||
headers.set(FORWARDED_SUPABASE_EMAIL_HEADER, forwardedEmail);
|
||||
}
|
||||
|
||||
if (incomingAuth) {
|
||||
headers.set("Authorization", `Bearer ${incomingAuth}`);
|
||||
return { headers, response: passthroughResponse, authUserId: forwardedUserId || null, authEmail: forwardedEmail || null };
|
||||
}
|
||||
|
||||
// Call getSession() to get the updated access token (after getUser() has refreshed it if needed)
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
@@ -72,12 +72,18 @@ export async function buildBackendRequestHeaders(
|
||||
// Fallback to cookie-backed session when request does not carry bearer.
|
||||
headers.set("Authorization", `Bearer ${accessToken}`);
|
||||
}
|
||||
const sessionUser = session?.user;
|
||||
const forwardedUserId = String(sessionUser?.id || "").trim();
|
||||
const forwardedEmail = String(sessionUser?.email || "").trim();
|
||||
if (forwardedUserId) {
|
||||
headers.set(FORWARDED_SUPABASE_USER_ID_HEADER, forwardedUserId);
|
||||
}
|
||||
if (forwardedEmail) {
|
||||
headers.set(FORWARDED_SUPABASE_EMAIL_HEADER, forwardedEmail);
|
||||
}
|
||||
return { headers, response: passthroughResponse, authUserId: forwardedUserId || null, authEmail: forwardedEmail || null };
|
||||
}
|
||||
|
||||
if (incomingAuth) {
|
||||
headers.set("Authorization", `Bearer ${incomingAuth}`);
|
||||
}
|
||||
return { headers, response: null, authUserId: null, authEmail: null };
|
||||
}
|
||||
|
||||
@@ -94,7 +100,7 @@ export function applyAuthResponseCookies(
|
||||
return target;
|
||||
}
|
||||
|
||||
export function requireBackendAuthUser(auth: HeaderBuildResult) {
|
||||
export function requireBackendAuthUser(auth: BackendHeaderBuildResult) {
|
||||
if (auth.authUserId) return null;
|
||||
return applyAuthResponseCookies(
|
||||
NextResponse.json(
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { createSupabaseServerClient, hasSupabaseServerEnv } from "@/lib/supabase/server";
|
||||
import {
|
||||
createSupabaseServerClient,
|
||||
hasSupabaseServerEnv,
|
||||
hasSupabaseSessionCookieValues,
|
||||
} from "@/lib/supabase/server";
|
||||
|
||||
function parseAdminEmails() {
|
||||
return String(process.env.POLYWEATHER_OPS_ADMIN_EMAILS || "")
|
||||
@@ -16,12 +20,17 @@ export async function requireOpsAdmin(nextPath = "/ops") {
|
||||
}
|
||||
|
||||
const cookieStore = await cookies();
|
||||
const supabaseCookies = cookieStore.getAll().map((item) => ({
|
||||
name: item.name,
|
||||
value: item.value,
|
||||
}));
|
||||
if (!hasSupabaseSessionCookieValues(supabaseCookies)) {
|
||||
redirect(`/auth/login?next=${encodeURIComponent(nextPath)}`);
|
||||
}
|
||||
|
||||
const supabase = createSupabaseServerClient({
|
||||
getAll() {
|
||||
return cookieStore.getAll().map((item) => ({
|
||||
name: item.name,
|
||||
value: item.value,
|
||||
}));
|
||||
return supabaseCookies;
|
||||
},
|
||||
setAll() {
|
||||
// Server components cannot persist refreshed cookies. Route handlers keep
|
||||
@@ -30,10 +39,11 @@ export async function requireOpsAdmin(nextPath = "/ops") {
|
||||
});
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
data,
|
||||
error,
|
||||
} = await supabase.auth.getClaims();
|
||||
|
||||
const email = String(user?.email || "").trim().toLowerCase();
|
||||
const email = error ? "" : String(data?.claims?.email || "").trim().toLowerCase();
|
||||
if (!email) {
|
||||
redirect(`/auth/login?next=${encodeURIComponent(nextPath)}`);
|
||||
}
|
||||
|
||||
@@ -67,6 +67,13 @@ export const opsApi = {
|
||||
memberships() {
|
||||
return opsFetch<Record<string, unknown>>("/api/ops/memberships?limit=200");
|
||||
},
|
||||
membershipsOverview(limit = 200, days = 90) {
|
||||
return opsFetch<{
|
||||
memberships?: Array<Record<string, unknown>>;
|
||||
days?: number;
|
||||
daily?: { date: string; trial: number; paid: number; total: number; cumulative: number }[];
|
||||
}>(`/api/ops/memberships/overview?limit=${limit}&days=${days}`);
|
||||
},
|
||||
membershipsGrowth(days = 90) {
|
||||
return opsFetch<{
|
||||
days: number;
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
type BackendHeaderBuildResult,
|
||||
} from "@/lib/backend-auth";
|
||||
|
||||
function hasBearerAuth(request: NextRequest) {
|
||||
const raw = request.headers.get("authorization");
|
||||
if (!raw) return false;
|
||||
const parts = raw.trim().split(/\s+/);
|
||||
return parts.length === 2 && parts[0].toLowerCase() === "bearer" && Boolean(parts[1]);
|
||||
}
|
||||
|
||||
export function requireOpsProxyAuth(
|
||||
request: NextRequest,
|
||||
auth: BackendHeaderBuildResult,
|
||||
): NextResponse | null {
|
||||
if (auth.authUserId || hasBearerAuth(request)) return null;
|
||||
return applyAuthResponseCookies(
|
||||
NextResponse.json(
|
||||
{ error: "Unauthorized", detail: "Supabase session required" },
|
||||
{ status: 401 },
|
||||
),
|
||||
auth.response,
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,20 @@ export function hasSupabaseServerEnv() {
|
||||
return Boolean(url && anonKey);
|
||||
}
|
||||
|
||||
export function hasSupabaseSessionCookieValues(
|
||||
cookies: { name: string; value: string }[],
|
||||
) {
|
||||
return cookies.some((cookie) => {
|
||||
const name = cookie.name.toLowerCase();
|
||||
const value = String(cookie.value || "").trim();
|
||||
if (!value) return false;
|
||||
return (
|
||||
name === "supabase-auth-token" ||
|
||||
(name.startsWith("sb-") && name.includes("-auth-token"))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function createSupabaseServerClient(
|
||||
cookieAdapter: CookieAdapter,
|
||||
) {
|
||||
@@ -79,12 +93,17 @@ export async function refreshMiddlewareSession(request: NextRequest) {
|
||||
return { response, user: null };
|
||||
}
|
||||
|
||||
const supabaseCookies = request.cookies.getAll().map((item) => ({
|
||||
name: item.name,
|
||||
value: item.value,
|
||||
}));
|
||||
if (!hasSupabaseSessionCookieValues(supabaseCookies)) {
|
||||
return { response, user: null };
|
||||
}
|
||||
|
||||
const supabase = createSupabaseServerClient({
|
||||
getAll() {
|
||||
return request.cookies.getAll().map((item) => ({
|
||||
name: item.name,
|
||||
value: item.value,
|
||||
}));
|
||||
return supabaseCookies;
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
for (const cookie of cookiesToSet) {
|
||||
@@ -103,11 +122,18 @@ export async function refreshMiddlewareSession(request: NextRequest) {
|
||||
|
||||
try {
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
data,
|
||||
error,
|
||||
} = await supabase.auth.getClaims();
|
||||
if (error || !data?.claims?.sub) {
|
||||
return { response, user: null };
|
||||
}
|
||||
const user = {
|
||||
id: String(data.claims.sub || ""),
|
||||
email: String(data.claims.email || ""),
|
||||
};
|
||||
return { response, user };
|
||||
} catch {
|
||||
return { response, user: null };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,9 @@ export async function forwardPriorityWarmHint(req: NextRequest) {
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const auth = await buildBackendRequestHeaders(req, {
|
||||
includeSupabaseIdentity: false,
|
||||
});
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/system/priority-warm${
|
||||
params.size ? `?${params.toString()}` : ""
|
||||
|
||||
+46
-18
@@ -1,5 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
hasSupabaseSessionCookieValues,
|
||||
hasSupabaseServerEnv,
|
||||
refreshMiddlewareSession,
|
||||
} from "@/lib/supabase/server";
|
||||
@@ -33,7 +34,9 @@ function isPublicApi(pathname: string) {
|
||||
pathname === "/api/auth/me" ||
|
||||
pathname === "/api/analytics/events" ||
|
||||
pathname === "/api/cities" ||
|
||||
pathname === "/api/payments/config" ||
|
||||
pathname === "/api/scan/terminal" ||
|
||||
pathname === "/api/system/status" ||
|
||||
pathname === "/api/vitals" ||
|
||||
/^\/api\/city\/[^/]+$/i.test(pathname) ||
|
||||
/^\/api\/city\/[^/]+\/summary$/i.test(pathname) ||
|
||||
@@ -45,10 +48,31 @@ function isPublicApi(pathname: string) {
|
||||
function shouldRefreshOptionalSupabaseSession(pathname: string) {
|
||||
return (
|
||||
pathname.startsWith("/account") ||
|
||||
pathname.startsWith("/ops") ||
|
||||
pathname.startsWith("/api/ops/") ||
|
||||
pathname.startsWith("/api/payments/") ||
|
||||
pathname === "/api/system/status"
|
||||
pathname.startsWith("/ops")
|
||||
);
|
||||
}
|
||||
|
||||
function hasSupabaseSessionCookie(request: NextRequest) {
|
||||
return hasSupabaseSessionCookieValues(
|
||||
request.cookies.getAll().map((cookie) => ({
|
||||
name: cookie.name,
|
||||
value: cookie.value,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
function redirectToLogin(request: NextRequest, pathname: string) {
|
||||
const loginUrl = request.nextUrl.clone();
|
||||
loginUrl.pathname = "/auth/login";
|
||||
loginUrl.search = "";
|
||||
loginUrl.searchParams.set("next", pathname);
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
|
||||
function unauthorizedSupabaseSessionResponse() {
|
||||
return NextResponse.json(
|
||||
{ error: "Unauthorized", detail: "Supabase session required" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -69,6 +93,10 @@ async function handleTerminalGate(request: NextRequest): Promise<NextResponse> {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
if (!hasSupabaseSessionCookie(request)) {
|
||||
return redirectToLogin(request, pathname);
|
||||
}
|
||||
|
||||
const { response, user } = await refreshMiddlewareSession(request);
|
||||
|
||||
if (user) {
|
||||
@@ -77,11 +105,7 @@ async function handleTerminalGate(request: NextRequest): Promise<NextResponse> {
|
||||
}
|
||||
|
||||
// Layer 1: Not logged in → redirect to /auth/login?next=/terminal
|
||||
const loginUrl = request.nextUrl.clone();
|
||||
loginUrl.pathname = "/auth/login";
|
||||
loginUrl.search = "";
|
||||
loginUrl.searchParams.set("next", pathname);
|
||||
return NextResponse.redirect(loginUrl);
|
||||
return redirectToLogin(request, pathname);
|
||||
}
|
||||
|
||||
async function handleSupabaseAuthGate(request: NextRequest) {
|
||||
@@ -90,6 +114,13 @@ async function handleSupabaseAuthGate(request: NextRequest) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
if (!hasSupabaseSessionCookie(request)) {
|
||||
if (pathname.startsWith("/api/")) {
|
||||
return unauthorizedSupabaseSessionResponse();
|
||||
}
|
||||
return redirectToLogin(request, pathname);
|
||||
}
|
||||
|
||||
const { response, user } = await refreshMiddlewareSession(request);
|
||||
|
||||
if (user) {
|
||||
@@ -97,17 +128,10 @@ async function handleSupabaseAuthGate(request: NextRequest) {
|
||||
}
|
||||
|
||||
if (pathname.startsWith("/api/")) {
|
||||
return NextResponse.json(
|
||||
{ error: "Unauthorized", detail: "Supabase session required" },
|
||||
{ status: 401 },
|
||||
);
|
||||
return unauthorizedSupabaseSessionResponse();
|
||||
}
|
||||
|
||||
const loginUrl = request.nextUrl.clone();
|
||||
loginUrl.pathname = "/auth/login";
|
||||
loginUrl.search = "";
|
||||
loginUrl.searchParams.set("next", pathname);
|
||||
return NextResponse.redirect(loginUrl);
|
||||
return redirectToLogin(request, pathname);
|
||||
}
|
||||
|
||||
async function handleSupabaseOptionalSession(request: NextRequest) {
|
||||
@@ -120,6 +144,10 @@ async function handleSupabaseOptionalSession(request: NextRequest) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
if (!hasSupabaseSessionCookie(request)) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
const { response } = await refreshMiddlewareSession(request);
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -49,6 +49,22 @@ def _select_exact_user_id(payload: object, email: str) -> str:
|
||||
def _lookup_user_id_by_email(email: str) -> str:
|
||||
from src.payments.contract_checkout import PAYMENT_CHECKOUT
|
||||
|
||||
normalized_email = str(email or "").strip().lower()
|
||||
profile_rows = PAYMENT_CHECKOUT._rest( # noqa: SLF001
|
||||
"GET",
|
||||
"profiles",
|
||||
params={
|
||||
"select": "id",
|
||||
"email": f"eq.{normalized_email}",
|
||||
"limit": "1",
|
||||
},
|
||||
allowed_status=[200],
|
||||
)
|
||||
if isinstance(profile_rows, list) and profile_rows:
|
||||
user_id = str((profile_rows[0] or {}).get("id") or "").strip()
|
||||
if user_id:
|
||||
return user_id
|
||||
|
||||
payload = PAYMENT_CHECKOUT._auth_admin_request( # noqa: SLF001
|
||||
"GET",
|
||||
f"/admin/users?email={email}",
|
||||
@@ -117,7 +133,7 @@ def main() -> int:
|
||||
"GET",
|
||||
"subscriptions",
|
||||
params={
|
||||
"select": "id,expires_at,status,plan_code,starts_at,source,created_at",
|
||||
"select": "id,plan_code,source,starts_at,expires_at",
|
||||
"user_id": f"eq.{user_id}",
|
||||
"status": "eq.active",
|
||||
"order": "expires_at.desc",
|
||||
@@ -169,37 +185,38 @@ def main() -> int:
|
||||
expires_at = starts_at + timedelta(days=days)
|
||||
|
||||
if isinstance(upcoming, dict) and str(upcoming.get("id") or "").strip():
|
||||
updated = PAYMENT_CHECKOUT._rest( # noqa: SLF001
|
||||
subscription_payload = {
|
||||
"starts_at": starts_at.isoformat(),
|
||||
"expires_at": expires_at.isoformat(),
|
||||
"updated_at": now.isoformat(),
|
||||
}
|
||||
PAYMENT_CHECKOUT._rest( # noqa: SLF001
|
||||
"PATCH",
|
||||
"subscriptions",
|
||||
params={"id": f"eq.{upcoming['id']}"},
|
||||
payload={
|
||||
"starts_at": starts_at.isoformat(),
|
||||
"expires_at": expires_at.isoformat(),
|
||||
"updated_at": now.isoformat(),
|
||||
},
|
||||
prefer="return=representation",
|
||||
payload=subscription_payload,
|
||||
prefer="return=minimal",
|
||||
allowed_status=[200],
|
||||
)
|
||||
subscription = updated[0] if isinstance(updated, list) and updated else {}
|
||||
subscription = {**upcoming, **subscription_payload}
|
||||
else:
|
||||
created = PAYMENT_CHECKOUT._rest( # noqa: SLF001
|
||||
subscription = {
|
||||
"user_id": user_id,
|
||||
"plan_code": plan_code,
|
||||
"status": "active",
|
||||
"starts_at": starts_at.isoformat(),
|
||||
"expires_at": expires_at.isoformat(),
|
||||
"source": actor,
|
||||
"created_at": now.isoformat(),
|
||||
"updated_at": now.isoformat(),
|
||||
}
|
||||
PAYMENT_CHECKOUT._rest( # noqa: SLF001
|
||||
"POST",
|
||||
"subscriptions",
|
||||
payload={
|
||||
"user_id": user_id,
|
||||
"plan_code": plan_code,
|
||||
"status": "active",
|
||||
"starts_at": starts_at.isoformat(),
|
||||
"expires_at": expires_at.isoformat(),
|
||||
"source": actor,
|
||||
"created_at": now.isoformat(),
|
||||
"updated_at": now.isoformat(),
|
||||
},
|
||||
prefer="return=representation",
|
||||
payload=subscription,
|
||||
prefer="return=minimal",
|
||||
allowed_status=[201],
|
||||
)
|
||||
subscription = created[0] if isinstance(created, list) and created else {}
|
||||
|
||||
PAYMENT_CHECKOUT._rest( # noqa: SLF001
|
||||
"POST",
|
||||
@@ -219,7 +236,7 @@ def main() -> int:
|
||||
},
|
||||
"created_at": now.isoformat(),
|
||||
},
|
||||
prefer="return=representation",
|
||||
prefer="return=minimal",
|
||||
allowed_status=[201],
|
||||
)
|
||||
SUPABASE_ENTITLEMENT.invalidate_subscription_cache(user_id)
|
||||
|
||||
@@ -50,31 +50,48 @@ def _list_recent_intents(user_id: str, limit: int = 20) -> List[Dict[str, Any]]:
|
||||
|
||||
|
||||
def _find_intent_by_tx(user_id: str, tx_hash: str) -> Optional[Dict[str, Any]]:
|
||||
rows = _list_recent_intents(user_id, limit=50)
|
||||
tx_norm = str(tx_hash or "").strip().lower()
|
||||
for row in rows:
|
||||
if str(row.get("tx_hash") or "").strip().lower() == tx_norm:
|
||||
return row
|
||||
return None
|
||||
|
||||
|
||||
def _find_intent_by_order_id(user_id: str, order_id_hex: str) -> Optional[Dict[str, Any]]:
|
||||
if not tx_norm:
|
||||
return None
|
||||
rows = PAYMENT_CHECKOUT._rest(
|
||||
"GET",
|
||||
"payment_intents",
|
||||
params={
|
||||
"select": (
|
||||
"id,user_id,plan_code,status,tx_hash,order_id_hex,"
|
||||
"amount_units,token_address,created_at,updated_at"
|
||||
),
|
||||
"user_id": f"eq.{user_id}",
|
||||
"order_id_hex": f"eq.{order_id_hex.lower()}",
|
||||
"order": "created_at.desc",
|
||||
"limit": "5",
|
||||
"select": "id,user_id",
|
||||
"tx_hash": f"eq.{tx_norm}",
|
||||
"limit": "1",
|
||||
},
|
||||
allowed_status=[200],
|
||||
)
|
||||
return rows[0] if isinstance(rows, list) and rows else None
|
||||
row = rows[0] if isinstance(rows, list) and rows else None
|
||||
if not isinstance(row, dict):
|
||||
return None
|
||||
if str(row.get("user_id") or "").strip() != str(user_id or "").strip():
|
||||
return None
|
||||
return row
|
||||
|
||||
|
||||
def _find_intent_by_order_id(user_id: str, order_id_hex: str) -> Optional[Dict[str, Any]]:
|
||||
normalized_user_id = str(user_id or "").strip()
|
||||
normalized_order_id = str(order_id_hex or "").strip().lower()
|
||||
if not normalized_user_id or not normalized_order_id:
|
||||
return None
|
||||
rows = PAYMENT_CHECKOUT._rest(
|
||||
"GET",
|
||||
"payment_intents",
|
||||
params={
|
||||
"select": "id,user_id",
|
||||
"order_id_hex": f"eq.{normalized_order_id}",
|
||||
"limit": "1",
|
||||
},
|
||||
allowed_status=[200],
|
||||
)
|
||||
row = rows[0] if isinstance(rows, list) and rows else None
|
||||
if not isinstance(row, dict):
|
||||
return None
|
||||
if str(row.get("user_id") or "").strip() != normalized_user_id:
|
||||
return None
|
||||
return row
|
||||
|
||||
|
||||
def _decode_pay_call(tx_hash: str) -> Optional[Dict[str, Any]]:
|
||||
|
||||
@@ -16,6 +16,21 @@ def _lookup_user_id_by_email(email: str) -> str:
|
||||
from src.payments.contract_checkout import PAYMENT_CHECKOUT, PaymentCheckoutError
|
||||
|
||||
normalized_email = str(email or "").strip().lower()
|
||||
profile_rows = PAYMENT_CHECKOUT._rest( # noqa: SLF001
|
||||
"GET",
|
||||
"profiles",
|
||||
params={
|
||||
"select": "id",
|
||||
"email": f"eq.{normalized_email}",
|
||||
"limit": "1",
|
||||
},
|
||||
allowed_status=[200],
|
||||
)
|
||||
if isinstance(profile_rows, list) and profile_rows:
|
||||
user_id = str((profile_rows[0] or {}).get("id") or "").strip()
|
||||
if user_id:
|
||||
return user_id
|
||||
|
||||
payload = PAYMENT_CHECKOUT._auth_admin_request( # noqa: SLF001
|
||||
"GET",
|
||||
f"/admin/users?email={email}",
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
-- Supabase Disk IO diagnostics for PolyWeather.
|
||||
-- Run sections independently if your project does not expose every stats view.
|
||||
|
||||
-- 1) Tables with high sequential scan pressure.
|
||||
select
|
||||
schemaname,
|
||||
relname,
|
||||
seq_scan,
|
||||
seq_tup_read,
|
||||
idx_scan,
|
||||
n_tup_ins,
|
||||
n_tup_upd,
|
||||
n_tup_del,
|
||||
n_dead_tup
|
||||
from pg_stat_user_tables
|
||||
where schemaname = 'public'
|
||||
order by seq_tup_read desc
|
||||
limit 30;
|
||||
|
||||
-- 2) Largest public tables and indexes.
|
||||
select
|
||||
relname,
|
||||
pg_size_pretty(pg_total_relation_size(relid)) as total_size,
|
||||
pg_size_pretty(pg_relation_size(relid)) as table_size,
|
||||
pg_size_pretty(pg_indexes_size(relid)) as index_size
|
||||
from pg_catalog.pg_statio_user_tables
|
||||
where schemaname = 'public'
|
||||
order by pg_total_relation_size(relid) desc
|
||||
limit 30;
|
||||
|
||||
-- 3) Slow/high-read statements, if pg_stat_statements is available.
|
||||
select
|
||||
calls,
|
||||
round(total_exec_time::numeric, 2) as total_exec_ms,
|
||||
round(mean_exec_time::numeric, 2) as mean_exec_ms,
|
||||
rows,
|
||||
shared_blks_read,
|
||||
shared_blks_hit,
|
||||
temp_blks_read,
|
||||
temp_blks_written,
|
||||
left(query, 500) as query_sample
|
||||
from pg_stat_statements
|
||||
order by shared_blks_read desc, total_exec_time desc
|
||||
limit 30;
|
||||
|
||||
-- 4) Dead tuple pressure that can trigger heavier autovacuum work.
|
||||
select
|
||||
schemaname,
|
||||
relname,
|
||||
n_live_tup,
|
||||
n_dead_tup,
|
||||
last_vacuum,
|
||||
last_autovacuum,
|
||||
last_analyze,
|
||||
last_autoanalyze
|
||||
from pg_stat_user_tables
|
||||
where schemaname = 'public'
|
||||
order by n_dead_tup desc
|
||||
limit 30;
|
||||
|
||||
-- 5) Indexes that still read the most disk blocks.
|
||||
select
|
||||
s.schemaname,
|
||||
s.relname,
|
||||
s.indexrelname,
|
||||
s.idx_scan,
|
||||
s.idx_tup_read,
|
||||
s.idx_tup_fetch,
|
||||
io.idx_blks_read,
|
||||
io.idx_blks_hit,
|
||||
pg_size_pretty(pg_relation_size(s.indexrelid)) as index_size
|
||||
from pg_stat_user_indexes s
|
||||
join pg_statio_user_indexes io
|
||||
on io.indexrelid = s.indexrelid
|
||||
where s.schemaname = 'public'
|
||||
order by io.idx_blks_read desc, s.idx_scan desc
|
||||
limit 50;
|
||||
|
||||
-- 6) Large non-unique indexes with no recorded scans since stats reset.
|
||||
-- Treat this as a candidate list only; confirm with production traffic history
|
||||
-- before dropping anything.
|
||||
select
|
||||
s.schemaname,
|
||||
s.relname,
|
||||
s.indexrelname,
|
||||
s.idx_scan,
|
||||
pg_size_pretty(pg_relation_size(s.indexrelid)) as index_size,
|
||||
i.indisunique,
|
||||
i.indisprimary
|
||||
from pg_stat_user_indexes s
|
||||
join pg_index i
|
||||
on i.indexrelid = s.indexrelid
|
||||
where s.schemaname = 'public'
|
||||
and s.idx_scan = 0
|
||||
and not i.indisprimary
|
||||
and not i.indisunique
|
||||
order by pg_relation_size(s.indexrelid) desc
|
||||
limit 30;
|
||||
@@ -0,0 +1,89 @@
|
||||
-- PolyWeather Supabase Disk IO mitigation indexes.
|
||||
-- Run this in the Supabase SQL Editor for an existing production project.
|
||||
|
||||
drop index if exists public.idx_subscriptions_user_status_expiry;
|
||||
create index if not exists idx_subscriptions_user_status_expiry
|
||||
on public.subscriptions(user_id, expires_at desc)
|
||||
include (id, starts_at, plan_code, source)
|
||||
where status = 'active';
|
||||
|
||||
drop index if exists public.idx_profiles_email;
|
||||
create index if not exists idx_profiles_email
|
||||
on public.profiles(email)
|
||||
include (id);
|
||||
|
||||
drop index if exists public.idx_profiles_id_lookup;
|
||||
create index if not exists idx_profiles_id_lookup
|
||||
on public.profiles(id)
|
||||
include (email, created_at);
|
||||
|
||||
drop index if exists public.idx_subscriptions_status_expiry;
|
||||
create index if not exists idx_subscriptions_status_expiry
|
||||
on public.subscriptions(expires_at asc)
|
||||
include (user_id, starts_at, plan_code)
|
||||
where status = 'active';
|
||||
|
||||
drop index if exists public.idx_subscriptions_user_created;
|
||||
create index if not exists idx_subscriptions_user_created
|
||||
on public.subscriptions(user_id, created_at desc)
|
||||
include (id, status, plan_code, source, starts_at, expires_at, updated_at);
|
||||
|
||||
drop index if exists public.idx_payments_created_at;
|
||||
create index if not exists idx_payments_created_at
|
||||
on public.payments(created_at desc)
|
||||
include (id, user_id, amount, currency, chain, tx_hash, status);
|
||||
|
||||
drop index if exists public.idx_user_wallets_user_chain;
|
||||
create index if not exists idx_user_wallets_user_chain
|
||||
on public.user_wallets(user_id, chain_id, is_primary desc, verified_at desc)
|
||||
include (id, address)
|
||||
where status = 'active';
|
||||
|
||||
drop index if exists public.idx_user_wallets_chain_address_owner;
|
||||
create index if not exists idx_user_wallets_chain_address_owner
|
||||
on public.user_wallets(chain_id, address)
|
||||
include (user_id, status);
|
||||
|
||||
drop index if exists public.idx_wallet_link_challenges_lookup;
|
||||
|
||||
drop index if exists public.idx_payment_intents_user_status;
|
||||
|
||||
drop index if exists public.idx_payment_intents_status_updated;
|
||||
create index if not exists idx_payment_intents_status_updated
|
||||
on public.payment_intents(status, updated_at desc)
|
||||
include (user_id)
|
||||
where status in ('submitted', 'confirmed');
|
||||
|
||||
create index if not exists idx_payment_intents_user_status_updated
|
||||
on public.payment_intents(user_id, status, updated_at desc);
|
||||
|
||||
drop index if exists public.idx_payment_intents_submitted_tx_updated;
|
||||
create index if not exists idx_payment_intents_submitted_tx_updated
|
||||
on public.payment_intents(updated_at asc)
|
||||
include (id, user_id, tx_hash, chain_id)
|
||||
where status = 'submitted' and tx_hash is not null;
|
||||
|
||||
create index if not exists idx_payment_intents_user_created
|
||||
on public.payment_intents(user_id, created_at desc);
|
||||
|
||||
drop index if exists public.idx_payment_intents_tx_hash;
|
||||
create index if not exists idx_payment_intents_tx_hash
|
||||
on public.payment_intents(tx_hash)
|
||||
include (id, user_id)
|
||||
where tx_hash is not null;
|
||||
|
||||
create index if not exists idx_payment_transactions_intent
|
||||
on public.payment_transactions(intent_id, created_at desc);
|
||||
|
||||
drop index if exists public.idx_payment_transactions_tx_hash_intent;
|
||||
create index if not exists idx_payment_transactions_tx_hash_intent
|
||||
on public.payment_transactions(tx_hash)
|
||||
include (intent_id);
|
||||
|
||||
analyze public.subscriptions;
|
||||
analyze public.profiles;
|
||||
analyze public.payments;
|
||||
analyze public.user_wallets;
|
||||
analyze public.wallet_link_challenges;
|
||||
analyze public.payment_intents;
|
||||
analyze public.payment_transactions;
|
||||
@@ -12,6 +12,14 @@ create table if not exists public.profiles (
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists idx_profiles_email
|
||||
on public.profiles(email)
|
||||
include (id);
|
||||
|
||||
create index if not exists idx_profiles_id_lookup
|
||||
on public.profiles(id)
|
||||
include (email, created_at);
|
||||
|
||||
create table if not exists public.subscriptions (
|
||||
id bigserial primary key,
|
||||
user_id uuid not null references auth.users(id) on delete cascade,
|
||||
@@ -25,7 +33,18 @@ create table if not exists public.subscriptions (
|
||||
);
|
||||
|
||||
create index if not exists idx_subscriptions_user_status_expiry
|
||||
on public.subscriptions(user_id, status, expires_at desc);
|
||||
on public.subscriptions(user_id, expires_at desc)
|
||||
include (id, starts_at, plan_code, source)
|
||||
where status = 'active';
|
||||
|
||||
create index if not exists idx_subscriptions_status_expiry
|
||||
on public.subscriptions(expires_at asc)
|
||||
include (user_id, starts_at, plan_code)
|
||||
where status = 'active';
|
||||
|
||||
create index if not exists idx_subscriptions_user_created
|
||||
on public.subscriptions(user_id, created_at desc)
|
||||
include (id, status, plan_code, source, starts_at, expires_at, updated_at);
|
||||
|
||||
create table if not exists public.payments (
|
||||
id bigserial primary key,
|
||||
@@ -40,6 +59,10 @@ create table if not exists public.payments (
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists idx_payments_created_at
|
||||
on public.payments(created_at desc)
|
||||
include (id, user_id, amount, currency, chain, tx_hash, status);
|
||||
|
||||
create table if not exists public.entitlement_events (
|
||||
id bigserial primary key,
|
||||
user_id uuid not null references auth.users(id) on delete cascade,
|
||||
@@ -65,7 +88,13 @@ create table if not exists public.user_wallets (
|
||||
);
|
||||
|
||||
create index if not exists idx_user_wallets_user_chain
|
||||
on public.user_wallets(user_id, chain_id, status, is_primary desc, verified_at desc);
|
||||
on public.user_wallets(user_id, chain_id, is_primary desc, verified_at desc)
|
||||
include (id, address)
|
||||
where status = 'active';
|
||||
|
||||
create index if not exists idx_user_wallets_chain_address_owner
|
||||
on public.user_wallets(chain_id, address)
|
||||
include (user_id, status);
|
||||
|
||||
create table if not exists public.wallet_link_challenges (
|
||||
id bigserial primary key,
|
||||
@@ -79,9 +108,6 @@ create table if not exists public.wallet_link_challenges (
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists idx_wallet_link_challenges_lookup
|
||||
on public.wallet_link_challenges(user_id, chain_id, address, nonce, created_at desc);
|
||||
|
||||
create table if not exists public.payment_intents (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
user_id uuid not null references auth.users(id) on delete cascade,
|
||||
@@ -103,11 +129,26 @@ create table if not exists public.payment_intents (
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists idx_payment_intents_user_status
|
||||
on public.payment_intents(user_id, status, created_at desc);
|
||||
create index if not exists idx_payment_intents_status_updated
|
||||
on public.payment_intents(status, updated_at desc)
|
||||
include (user_id)
|
||||
where status in ('submitted', 'confirmed');
|
||||
|
||||
create index if not exists idx_payment_intents_user_status_updated
|
||||
on public.payment_intents(user_id, status, updated_at desc);
|
||||
|
||||
create index if not exists idx_payment_intents_submitted_tx_updated
|
||||
on public.payment_intents(updated_at asc)
|
||||
include (id, user_id, tx_hash, chain_id)
|
||||
where status = 'submitted' and tx_hash is not null;
|
||||
|
||||
create index if not exists idx_payment_intents_user_created
|
||||
on public.payment_intents(user_id, created_at desc);
|
||||
|
||||
create index if not exists idx_payment_intents_tx_hash
|
||||
on public.payment_intents(tx_hash);
|
||||
on public.payment_intents(tx_hash)
|
||||
include (id, user_id)
|
||||
where tx_hash is not null;
|
||||
|
||||
create table if not exists public.payment_transactions (
|
||||
id bigserial primary key,
|
||||
@@ -128,6 +169,10 @@ create table if not exists public.payment_transactions (
|
||||
create index if not exists idx_payment_transactions_intent
|
||||
on public.payment_transactions(intent_id, created_at desc);
|
||||
|
||||
create index if not exists idx_payment_transactions_tx_hash_intent
|
||||
on public.payment_transactions(tx_hash)
|
||||
include (intent_id);
|
||||
|
||||
create or replace function public.sync_profile_from_auth()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
|
||||
@@ -69,6 +69,14 @@ class SupabaseEntitlementService:
|
||||
self._identity_cache_lock = threading.Lock()
|
||||
self._sub_cache: Dict[str, Dict[str, object]] = {}
|
||||
self._sub_cache_lock = threading.Lock()
|
||||
self._latest_subscription_cache: Dict[str, Dict[str, object]] = {}
|
||||
self._latest_subscription_cache_lock = threading.Lock()
|
||||
self._active_subscription_bool_cache: Dict[str, Dict[str, object]] = {}
|
||||
self._active_subscription_bool_cache_lock = threading.Lock()
|
||||
self._active_subscriptions_cache: Dict[str, object] = {}
|
||||
self._active_subscriptions_cache_lock = threading.Lock()
|
||||
self._auth_users_cache: Dict[str, Dict[str, object]] = {}
|
||||
self._auth_users_cache_lock = threading.Lock()
|
||||
|
||||
def invalidate_subscription_cache(self, user_id: str) -> None:
|
||||
key = str(user_id or "").strip()
|
||||
@@ -76,6 +84,12 @@ class SupabaseEntitlementService:
|
||||
return
|
||||
with self._sub_cache_lock:
|
||||
self._sub_cache.pop(key, None)
|
||||
with self._latest_subscription_cache_lock:
|
||||
self._latest_subscription_cache.pop(key, None)
|
||||
with self._active_subscription_bool_cache_lock:
|
||||
self._active_subscription_bool_cache.pop(key, None)
|
||||
with self._active_subscriptions_cache_lock:
|
||||
self._active_subscriptions_cache.clear()
|
||||
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
@@ -90,6 +104,9 @@ class SupabaseEntitlementService:
|
||||
def _entitlement_events_endpoint(self) -> str:
|
||||
return f"{self.supabase_url}/rest/v1/entitlement_events"
|
||||
|
||||
def _profiles_endpoint(self) -> str:
|
||||
return f"{self.supabase_url}/rest/v1/profiles"
|
||||
|
||||
def _request_headers_for_user(self, access_token: str) -> Dict[str, str]:
|
||||
return {
|
||||
"apikey": self.anon_key,
|
||||
@@ -116,8 +133,7 @@ class SupabaseEntitlementService:
|
||||
cached = self._identity_cache.get(access_token)
|
||||
if cached and now_ts - float(cached.get("ts") or 0) < self.cache_ttl_sec:
|
||||
identity = cached.get("identity")
|
||||
if isinstance(identity, SupabaseIdentity):
|
||||
return identity
|
||||
return identity if isinstance(identity, SupabaseIdentity) else None
|
||||
|
||||
if not self.configured:
|
||||
return None
|
||||
@@ -129,10 +145,21 @@ class SupabaseEntitlementService:
|
||||
timeout=self.timeout_sec,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
if response.status_code in {401, 403}:
|
||||
with self._identity_cache_lock:
|
||||
self._identity_cache[access_token] = {
|
||||
"identity": None,
|
||||
"ts": now_ts,
|
||||
}
|
||||
return None
|
||||
data = response.json() if response.content else {}
|
||||
user_id = str(data.get("id") or "").strip()
|
||||
if not user_id:
|
||||
with self._identity_cache_lock:
|
||||
self._identity_cache[access_token] = {
|
||||
"identity": None,
|
||||
"ts": now_ts,
|
||||
}
|
||||
return None
|
||||
|
||||
# Extract points from user_metadata
|
||||
@@ -176,17 +203,26 @@ class SupabaseEntitlementService:
|
||||
if isinstance(row, dict):
|
||||
return row
|
||||
return None
|
||||
with self._active_subscription_bool_cache_lock:
|
||||
cached_bool = self._active_subscription_bool_cache.get(user_id)
|
||||
if (
|
||||
cached_bool
|
||||
and now_ts - float(cached_bool.get("ts") or 0) < self.sub_cache_ttl_sec
|
||||
and cached_bool.get("active") is False
|
||||
):
|
||||
return None
|
||||
|
||||
try:
|
||||
now = datetime.now(timezone.utc)
|
||||
now_iso = now.isoformat()
|
||||
params = {
|
||||
"select": "id,user_id,status,plan_code,starts_at,expires_at",
|
||||
"select": "plan_code,source,starts_at,expires_at",
|
||||
"user_id": f"eq.{user_id}",
|
||||
"status": "eq.active",
|
||||
"starts_at": f"lte.{now_iso}",
|
||||
"expires_at": f"gt.{now_iso}",
|
||||
"order": "expires_at.desc",
|
||||
"limit": "20",
|
||||
"limit": "1",
|
||||
}
|
||||
response = requests.get(
|
||||
self._subscription_endpoint(),
|
||||
@@ -205,13 +241,12 @@ class SupabaseEntitlementService:
|
||||
else:
|
||||
data = response.json() if response.content else []
|
||||
rows = [item for item in data if isinstance(item, dict)] if isinstance(data, list) else []
|
||||
row = self._pick_latest_current_subscription(rows, now=now)
|
||||
row = rows[0] if rows else None
|
||||
|
||||
with self._sub_cache_lock:
|
||||
self._sub_cache[user_id] = {
|
||||
"active": bool(row),
|
||||
"row": row,
|
||||
"rows": rows,
|
||||
"ts": now_ts,
|
||||
}
|
||||
return row
|
||||
@@ -238,16 +273,12 @@ class SupabaseEntitlementService:
|
||||
rows = cached.get("rows")
|
||||
if isinstance(rows, list):
|
||||
return [row for row in rows if isinstance(row, dict)]
|
||||
row = cached.get("row")
|
||||
if isinstance(row, dict):
|
||||
return [row]
|
||||
return []
|
||||
|
||||
try:
|
||||
now = datetime.now(timezone.utc)
|
||||
now_iso = now.isoformat()
|
||||
params = {
|
||||
"select": "id,user_id,status,plan_code,starts_at,expires_at,source,created_at,updated_at",
|
||||
"select": "plan_code,source,starts_at,expires_at",
|
||||
"user_id": f"eq.{user_id}",
|
||||
"status": "eq.active",
|
||||
"expires_at": f"gt.{now_iso}",
|
||||
@@ -290,9 +321,15 @@ class SupabaseEntitlementService:
|
||||
) -> Optional[Dict[str, object]]:
|
||||
if not user_id or not self.service_role_key:
|
||||
return None
|
||||
now_ts = time.time()
|
||||
with self._latest_subscription_cache_lock:
|
||||
cached = self._latest_subscription_cache.get(user_id)
|
||||
if cached and now_ts - float(cached.get("ts") or 0) < self.sub_cache_ttl_sec:
|
||||
row = cached.get("row")
|
||||
return row if isinstance(row, dict) else None
|
||||
try:
|
||||
params = {
|
||||
"select": "id,user_id,status,plan_code,starts_at,expires_at,source,created_at,updated_at",
|
||||
"select": "plan_code,starts_at,expires_at",
|
||||
"user_id": f"eq.{user_id}",
|
||||
"order": "created_at.desc",
|
||||
"limit": "1",
|
||||
@@ -312,7 +349,13 @@ class SupabaseEntitlementService:
|
||||
return None
|
||||
data = response.json() if response.content else []
|
||||
row = data[0] if isinstance(data, list) and data else None
|
||||
return row if isinstance(row, dict) else None
|
||||
result = row if isinstance(row, dict) else None
|
||||
with self._latest_subscription_cache_lock:
|
||||
self._latest_subscription_cache[user_id] = {
|
||||
"row": result,
|
||||
"ts": now_ts,
|
||||
}
|
||||
return result
|
||||
except Exception as exc:
|
||||
logger.warning(f"supabase subscription history query error user_id={user_id}: {exc}")
|
||||
return None
|
||||
@@ -359,7 +402,68 @@ class SupabaseEntitlementService:
|
||||
return None
|
||||
|
||||
def _query_active_subscription(self, user_id: str) -> bool:
|
||||
return self._query_latest_active_subscription(user_id) is not None
|
||||
if not user_id:
|
||||
return False
|
||||
if not self.service_role_key:
|
||||
logger.warning("SUPABASE_SERVICE_ROLE_KEY is missing")
|
||||
return False
|
||||
|
||||
now_ts = time.time()
|
||||
with self._sub_cache_lock:
|
||||
cached_detail = self._sub_cache.get(user_id)
|
||||
if cached_detail and now_ts - float(cached_detail.get("ts") or 0) < self.sub_cache_ttl_sec:
|
||||
rows = cached_detail.get("rows")
|
||||
if isinstance(rows, list):
|
||||
return self._pick_latest_current_subscription(
|
||||
[row for row in rows if isinstance(row, dict)]
|
||||
) is not None
|
||||
if "row" in cached_detail:
|
||||
return isinstance(cached_detail.get("row"), dict)
|
||||
|
||||
with self._active_subscription_bool_cache_lock:
|
||||
cached = self._active_subscription_bool_cache.get(user_id)
|
||||
if cached and now_ts - float(cached.get("ts") or 0) < self.sub_cache_ttl_sec:
|
||||
return bool(cached.get("active"))
|
||||
|
||||
try:
|
||||
now = datetime.now(timezone.utc)
|
||||
now_iso = now.isoformat()
|
||||
params = {
|
||||
"select": "expires_at",
|
||||
"user_id": f"eq.{user_id}",
|
||||
"status": "eq.active",
|
||||
"starts_at": f"lte.{now_iso}",
|
||||
"expires_at": f"gt.{now_iso}",
|
||||
"order": "expires_at.desc",
|
||||
"limit": "1",
|
||||
}
|
||||
response = requests.get(
|
||||
self._subscription_endpoint(),
|
||||
headers=self._request_headers_for_service_role(),
|
||||
params=params,
|
||||
timeout=self.timeout_sec,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
logger.warning(
|
||||
"supabase active subscription bool query failed user_id={} status={}",
|
||||
user_id,
|
||||
response.status_code,
|
||||
)
|
||||
active = False
|
||||
else:
|
||||
data = response.json() if response.content else []
|
||||
rows = [row for row in data if isinstance(row, dict)] if isinstance(data, list) else []
|
||||
active = bool(rows)
|
||||
|
||||
with self._active_subscription_bool_cache_lock:
|
||||
self._active_subscription_bool_cache[user_id] = {
|
||||
"active": bool(active),
|
||||
"ts": now_ts,
|
||||
}
|
||||
return bool(active)
|
||||
except Exception as exc:
|
||||
logger.warning(f"supabase active subscription bool query error user_id={user_id}: {exc}")
|
||||
return False
|
||||
|
||||
def get_latest_active_subscription(
|
||||
self,
|
||||
@@ -385,9 +489,14 @@ class SupabaseEntitlementService:
|
||||
if respect_requirement and not self.require_subscription:
|
||||
return {}
|
||||
rows = self._query_active_subscription_rows(user_id, bypass_cache=bypass_cache)
|
||||
return self._subscription_window_from_rows(rows)
|
||||
|
||||
def _subscription_window_from_rows(
|
||||
self,
|
||||
rows: List[Dict[str, object]],
|
||||
) -> Dict[str, object]:
|
||||
if not rows:
|
||||
return {}
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
current = self._pick_latest_current_subscription(rows, now=now)
|
||||
total_expiry: Optional[datetime] = None
|
||||
@@ -422,6 +531,153 @@ class SupabaseEntitlementService:
|
||||
"rows": rows,
|
||||
}
|
||||
|
||||
def list_subscription_windows(
|
||||
self,
|
||||
user_ids: List[str],
|
||||
bypass_cache: bool = False,
|
||||
) -> Dict[str, Dict[str, object]]:
|
||||
keys: List[str] = []
|
||||
for item in user_ids or []:
|
||||
key = str(item or "").strip().lower()
|
||||
if key and key not in keys:
|
||||
keys.append(key)
|
||||
if not keys:
|
||||
return {}
|
||||
|
||||
out: Dict[str, Dict[str, object]] = {}
|
||||
if not bypass_cache:
|
||||
missing: List[str] = []
|
||||
now_ts = time.time()
|
||||
with self._sub_cache_lock:
|
||||
for key in keys:
|
||||
cached = self._sub_cache.get(key)
|
||||
if cached and now_ts - float(cached.get("ts") or 0) < self.sub_cache_ttl_sec:
|
||||
rows = cached.get("rows")
|
||||
if isinstance(rows, list):
|
||||
out[key] = self._subscription_window_from_rows(
|
||||
[row for row in rows if isinstance(row, dict)]
|
||||
)
|
||||
continue
|
||||
missing.append(key)
|
||||
keys = missing
|
||||
if not keys:
|
||||
return out
|
||||
|
||||
if not self.service_role_key:
|
||||
logger.warning("SUPABASE_SERVICE_ROLE_KEY is missing")
|
||||
return out
|
||||
|
||||
try:
|
||||
now = datetime.now(timezone.utc)
|
||||
now_iso = now.isoformat()
|
||||
params = {
|
||||
"select": "user_id,plan_code,source,starts_at,expires_at",
|
||||
"user_id": f"in.({','.join(keys)})",
|
||||
"status": "eq.active",
|
||||
"expires_at": f"gt.{now_iso}",
|
||||
"order": "user_id.asc,expires_at.desc",
|
||||
"limit": str(max(1, min(len(keys) * 20, 1000))),
|
||||
}
|
||||
response = requests.get(
|
||||
self._subscription_endpoint(),
|
||||
headers=self._request_headers_for_service_role(),
|
||||
params=params,
|
||||
timeout=self.timeout_sec,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
logger.warning(
|
||||
"supabase subscription window batch query failed users={} status={}",
|
||||
len(keys),
|
||||
response.status_code,
|
||||
)
|
||||
return out
|
||||
|
||||
data = response.json() if response.content else []
|
||||
rows = [row for row in data if isinstance(row, dict)] if isinstance(data, list) else []
|
||||
grouped: Dict[str, List[Dict[str, object]]] = {key: [] for key in keys}
|
||||
for row in rows:
|
||||
key = str(row.get("user_id") or "").strip().lower()
|
||||
if key in grouped:
|
||||
grouped[key].append(row)
|
||||
|
||||
now_ts = time.time()
|
||||
with self._sub_cache_lock:
|
||||
for key, user_rows in grouped.items():
|
||||
current_row = self._pick_latest_current_subscription(user_rows, now=now)
|
||||
self._sub_cache[key] = {
|
||||
"active": bool(current_row),
|
||||
"row": current_row,
|
||||
"rows": user_rows,
|
||||
"ts": now_ts,
|
||||
}
|
||||
out[key] = self._subscription_window_from_rows(user_rows)
|
||||
return out
|
||||
except Exception as exc:
|
||||
logger.warning(f"supabase subscription window batch query error users={len(keys)}: {exc}")
|
||||
return out
|
||||
|
||||
def list_active_subscription_windows(self, limit: int = 200) -> Dict[str, object]:
|
||||
if not self.service_role_key:
|
||||
logger.warning("SUPABASE_SERVICE_ROLE_KEY is missing")
|
||||
return {"subscriptions": [], "windows": {}}
|
||||
safe_limit = max(1, min(int(limit or 200), 1000))
|
||||
try:
|
||||
now = datetime.now(timezone.utc)
|
||||
now_iso = now.isoformat()
|
||||
params = {
|
||||
"select": "user_id,plan_code,source,starts_at,expires_at",
|
||||
"status": "eq.active",
|
||||
"expires_at": f"gt.{now_iso}",
|
||||
"order": "user_id.asc,expires_at.desc",
|
||||
"limit": str(max(1, min(safe_limit * 20, 5000))),
|
||||
}
|
||||
response = requests.get(
|
||||
self._subscription_endpoint(),
|
||||
headers=self._request_headers_for_service_role(),
|
||||
params=params,
|
||||
timeout=self.timeout_sec,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
logger.warning(
|
||||
"supabase active subscription window query failed status={}",
|
||||
response.status_code,
|
||||
)
|
||||
return {"subscriptions": [], "windows": {}}
|
||||
data = response.json() if response.content else []
|
||||
rows = [row for row in data if isinstance(row, dict)] if isinstance(data, list) else []
|
||||
grouped: Dict[str, List[Dict[str, object]]] = {}
|
||||
for row in rows:
|
||||
key = str(row.get("user_id") or "").strip().lower()
|
||||
if key:
|
||||
grouped.setdefault(key, []).append(row)
|
||||
|
||||
windows: Dict[str, Dict[str, object]] = {}
|
||||
current_rows: List[Dict[str, object]] = []
|
||||
now_ts = time.time()
|
||||
with self._sub_cache_lock:
|
||||
for key, user_rows in grouped.items():
|
||||
current_row = self._pick_latest_current_subscription(user_rows, now=now)
|
||||
self._sub_cache[key] = {
|
||||
"active": bool(current_row),
|
||||
"row": current_row,
|
||||
"rows": user_rows,
|
||||
"ts": now_ts,
|
||||
}
|
||||
windows[key] = self._subscription_window_from_rows(user_rows)
|
||||
if isinstance(current_row, dict):
|
||||
current_rows.append(current_row)
|
||||
current_rows.sort(key=lambda row: str(row.get("expires_at") or ""))
|
||||
current_rows = current_rows[:safe_limit]
|
||||
with self._active_subscriptions_cache_lock:
|
||||
self._active_subscriptions_cache[str(safe_limit)] = {
|
||||
"rows": current_rows,
|
||||
"ts": now_ts,
|
||||
}
|
||||
return {"subscriptions": current_rows, "windows": windows}
|
||||
except Exception as exc:
|
||||
logger.warning(f"supabase active subscription window query error: {exc}")
|
||||
return {"subscriptions": [], "windows": {}}
|
||||
|
||||
def has_active_subscription(
|
||||
self,
|
||||
user_id: str,
|
||||
@@ -435,12 +691,20 @@ class SupabaseEntitlementService:
|
||||
if not self.service_role_key:
|
||||
logger.warning("SUPABASE_SERVICE_ROLE_KEY is missing")
|
||||
return []
|
||||
safe_limit = max(1, min(int(limit or 200), 1000))
|
||||
cache_key = str(safe_limit)
|
||||
now_ts = time.time()
|
||||
with self._active_subscriptions_cache_lock:
|
||||
cached = self._active_subscriptions_cache.get(cache_key)
|
||||
if isinstance(cached, dict) and now_ts - float(cached.get("ts") or 0) < self.sub_cache_ttl_sec:
|
||||
rows = cached.get("rows")
|
||||
if isinstance(rows, list):
|
||||
return [row for row in rows if isinstance(row, dict)]
|
||||
try:
|
||||
now = datetime.now(timezone.utc)
|
||||
safe_limit = max(1, min(int(limit or 200), 1000))
|
||||
now_iso = now.isoformat()
|
||||
params = {
|
||||
"select": "id,user_id,status,plan_code,starts_at,expires_at",
|
||||
"select": "user_id,plan_code,starts_at,expires_at",
|
||||
"status": "eq.active",
|
||||
"expires_at": f"gt.{now_iso}",
|
||||
"order": "expires_at.asc",
|
||||
@@ -461,11 +725,17 @@ class SupabaseEntitlementService:
|
||||
data = response.json() if response.content else []
|
||||
if not isinstance(data, list):
|
||||
return []
|
||||
return [
|
||||
rows = [
|
||||
row
|
||||
for row in data
|
||||
if isinstance(row, dict) and self._is_subscription_started(row, now=now)
|
||||
]
|
||||
with self._active_subscriptions_cache_lock:
|
||||
self._active_subscriptions_cache[cache_key] = {
|
||||
"rows": rows,
|
||||
"ts": now_ts,
|
||||
}
|
||||
return rows
|
||||
except Exception as exc:
|
||||
logger.warning(f"supabase active subscriptions query error: {exc}")
|
||||
return []
|
||||
@@ -484,6 +754,29 @@ class SupabaseEntitlementService:
|
||||
return {}
|
||||
|
||||
out: Dict[str, Dict[str, object]] = {}
|
||||
now_ts = time.time()
|
||||
missing_keys: List[str] = []
|
||||
with self._auth_users_cache_lock:
|
||||
for key in keys:
|
||||
cached = self._auth_users_cache.get(key)
|
||||
if cached and now_ts - float(cached.get("ts") or 0) < self.sub_cache_ttl_sec:
|
||||
user = cached.get("user")
|
||||
if isinstance(user, dict):
|
||||
out[key] = dict(user)
|
||||
continue
|
||||
missing_keys.append(key)
|
||||
keys = missing_keys
|
||||
if not keys:
|
||||
return out
|
||||
|
||||
profile_users = self._get_profile_users(keys)
|
||||
if profile_users:
|
||||
self._remember_auth_users(profile_users)
|
||||
out.update(profile_users)
|
||||
keys = [key for key in keys if key not in out]
|
||||
if not keys:
|
||||
return out
|
||||
|
||||
for user_id in keys:
|
||||
try:
|
||||
response = requests.get(
|
||||
@@ -506,9 +799,69 @@ class SupabaseEntitlementService:
|
||||
"email": str(payload.get("email") or "").strip(),
|
||||
"created_at": payload.get("created_at"),
|
||||
}
|
||||
self._remember_auth_users({user_id: out[user_id]})
|
||||
except Exception as exc:
|
||||
logger.warning(f"supabase admin user query error user_id={user_id}: {exc}")
|
||||
return out
|
||||
|
||||
def _remember_auth_users(self, users: Dict[str, Dict[str, object]]) -> None:
|
||||
if not users:
|
||||
return
|
||||
now_ts = time.time()
|
||||
with self._auth_users_cache_lock:
|
||||
for raw_key, user in users.items():
|
||||
key = str(raw_key or "").strip().lower()
|
||||
if key and isinstance(user, dict):
|
||||
self._auth_users_cache[key] = {
|
||||
"user": dict(user),
|
||||
"ts": now_ts,
|
||||
}
|
||||
if len(self._auth_users_cache) > 4096:
|
||||
oldest_keys = sorted(
|
||||
self._auth_users_cache,
|
||||
key=lambda key: float(
|
||||
self._auth_users_cache[key].get("ts") or 0.0
|
||||
),
|
||||
)
|
||||
for key in oldest_keys[: len(self._auth_users_cache) - 4096]:
|
||||
self._auth_users_cache.pop(key, None)
|
||||
|
||||
def _get_profile_users(self, user_ids: List[str]) -> Dict[str, Dict[str, object]]:
|
||||
if not user_ids or not self.service_role_key:
|
||||
return {}
|
||||
try:
|
||||
response = requests.get(
|
||||
self._profiles_endpoint(),
|
||||
headers=self._request_headers_for_service_role(),
|
||||
params={
|
||||
"select": "id,email,created_at",
|
||||
"id": f"in.({','.join(user_ids)})",
|
||||
"limit": str(max(1, min(len(user_ids), 1000))),
|
||||
},
|
||||
timeout=self.timeout_sec,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
logger.warning(
|
||||
"supabase profile users batch query failed users={} status={}",
|
||||
len(user_ids),
|
||||
response.status_code,
|
||||
)
|
||||
return {}
|
||||
data = response.json() if response.content else []
|
||||
rows = [row for row in data if isinstance(row, dict)] if isinstance(data, list) else []
|
||||
out: Dict[str, Dict[str, object]] = {}
|
||||
for row in rows:
|
||||
user_id = str(row.get("id") or "").strip().lower()
|
||||
if not user_id:
|
||||
continue
|
||||
out[user_id] = {
|
||||
"email": str(row.get("email") or "").strip(),
|
||||
"created_at": row.get("created_at"),
|
||||
}
|
||||
return out
|
||||
except Exception as exc:
|
||||
logger.warning(f"supabase profile users batch query error users={len(user_ids)}: {exc}")
|
||||
return {}
|
||||
|
||||
|
||||
SUPABASE_ENTITLEMENT = SupabaseEntitlementService()
|
||||
|
||||
@@ -457,6 +457,7 @@ class BasicCommandHandler:
|
||||
for row in rows:
|
||||
if self._subscription_row_is_paid(row):
|
||||
return True
|
||||
return False
|
||||
if hasattr(self.entitlement_service, "get_latest_active_subscription"):
|
||||
row = self.entitlement_service.get_latest_active_subscription(
|
||||
supabase_user_id,
|
||||
|
||||
@@ -169,7 +169,7 @@ def _grant_bonus_subscription_days(
|
||||
}
|
||||
ins = requests.post(
|
||||
f"{base}/rest/v1/subscriptions",
|
||||
headers={**headers, "Prefer": "return=representation"},
|
||||
headers={**headers, "Prefer": "return=minimal"},
|
||||
json=create_payload,
|
||||
timeout=timeout_sec,
|
||||
)
|
||||
|
||||
+169
-6
@@ -4,6 +4,7 @@ import hashlib
|
||||
import json
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict, Any, List, Set
|
||||
|
||||
@@ -14,6 +15,10 @@ from loguru import logger
|
||||
class DBManager:
|
||||
_init_lock = threading.Lock()
|
||||
_initialized_paths: Set[str] = set()
|
||||
_points_sync_lock = threading.Lock()
|
||||
_points_sync_cache: Dict[str, Dict[str, Any]] = {}
|
||||
_profile_sync_lock = threading.Lock()
|
||||
_profile_sync_cache: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
def __init__(self, db_path: Optional[str] = None):
|
||||
self.db_path = self._resolve_db_path(db_path)
|
||||
@@ -67,7 +72,135 @@ class DBManager:
|
||||
return ""
|
||||
return f"{supabase_url}/auth/v1/admin/users"
|
||||
|
||||
def _sync_points_to_supabase_user_metadata(self, telegram_id: int) -> bool:
|
||||
def _profile_sync_min_interval_sec(self) -> float:
|
||||
raw = str(
|
||||
os.getenv("POLYWEATHER_SUPABASE_PROFILE_SYNC_MIN_INTERVAL_SEC", "3600")
|
||||
or ""
|
||||
).strip()
|
||||
try:
|
||||
return max(0.0, float(raw))
|
||||
except ValueError:
|
||||
return 3600.0
|
||||
|
||||
def _profile_sync_cache_key(self, supabase_user_id: str) -> str:
|
||||
endpoint = self._supabase_profiles_endpoint()
|
||||
return f"{endpoint}:{str(supabase_user_id or '').strip().lower()}"
|
||||
|
||||
def _should_skip_profile_sync(
|
||||
self,
|
||||
*,
|
||||
supabase_user_id: str,
|
||||
telegram_id: Optional[int],
|
||||
telegram_username: Optional[str],
|
||||
force: bool = False,
|
||||
) -> bool:
|
||||
if force:
|
||||
return False
|
||||
min_interval = self._profile_sync_min_interval_sec()
|
||||
if min_interval <= 0:
|
||||
return False
|
||||
payload_key = {
|
||||
"telegram_id": int(telegram_id) if telegram_id is not None else None,
|
||||
"telegram_username": str(telegram_username or "").strip() or None,
|
||||
}
|
||||
cache_key = self._profile_sync_cache_key(supabase_user_id)
|
||||
now_ts = time.monotonic()
|
||||
with self._profile_sync_lock:
|
||||
cached = self._profile_sync_cache.get(cache_key)
|
||||
if not isinstance(cached, dict):
|
||||
return False
|
||||
cached_payload = cached.get("payload")
|
||||
cached_ts = float(cached.get("ts") or 0.0)
|
||||
return cached_payload == payload_key and now_ts - cached_ts < min_interval
|
||||
|
||||
def _remember_profile_sync(
|
||||
self,
|
||||
*,
|
||||
supabase_user_id: str,
|
||||
telegram_id: Optional[int],
|
||||
telegram_username: Optional[str],
|
||||
) -> None:
|
||||
cache_key = self._profile_sync_cache_key(supabase_user_id)
|
||||
payload_key = {
|
||||
"telegram_id": int(telegram_id) if telegram_id is not None else None,
|
||||
"telegram_username": str(telegram_username or "").strip() or None,
|
||||
}
|
||||
with self._profile_sync_lock:
|
||||
self._profile_sync_cache[cache_key] = {
|
||||
"payload": payload_key,
|
||||
"ts": time.monotonic(),
|
||||
}
|
||||
if len(self._profile_sync_cache) > 4096:
|
||||
oldest_key = min(
|
||||
self._profile_sync_cache,
|
||||
key=lambda key: float(
|
||||
self._profile_sync_cache[key].get("ts") or 0.0
|
||||
),
|
||||
)
|
||||
self._profile_sync_cache.pop(oldest_key, None)
|
||||
|
||||
def _points_sync_cache_key(self, telegram_id: int) -> str:
|
||||
return f"{os.path.abspath(self.db_path)}:{int(telegram_id)}"
|
||||
|
||||
def _points_sync_min_interval_sec(self) -> float:
|
||||
raw = str(
|
||||
os.getenv("POLYWEATHER_SUPABASE_POINTS_SYNC_MIN_INTERVAL_SEC", "60")
|
||||
or ""
|
||||
).strip()
|
||||
try:
|
||||
return max(0.0, float(raw))
|
||||
except Exception:
|
||||
return 60.0
|
||||
|
||||
def _should_skip_points_metadata_sync(
|
||||
self,
|
||||
*,
|
||||
telegram_id: int,
|
||||
points: int,
|
||||
force: bool,
|
||||
) -> bool:
|
||||
if force:
|
||||
return False
|
||||
cache_key = self._points_sync_cache_key(telegram_id)
|
||||
now_ts = time.monotonic()
|
||||
min_interval = self._points_sync_min_interval_sec()
|
||||
with self._points_sync_lock:
|
||||
cached = self._points_sync_cache.get(cache_key)
|
||||
if not cached:
|
||||
return False
|
||||
cached_points = int(cached.get("points") or 0)
|
||||
cached_ts = float(cached.get("ts") or 0.0)
|
||||
if cached_points == int(points):
|
||||
return True
|
||||
return min_interval > 0 and (now_ts - cached_ts) < min_interval
|
||||
|
||||
def _remember_points_metadata_sync(
|
||||
self,
|
||||
*,
|
||||
telegram_id: int,
|
||||
points: int,
|
||||
) -> None:
|
||||
cache_key = self._points_sync_cache_key(telegram_id)
|
||||
with self._points_sync_lock:
|
||||
self._points_sync_cache[cache_key] = {
|
||||
"points": int(points),
|
||||
"ts": time.monotonic(),
|
||||
}
|
||||
if len(self._points_sync_cache) > 4096:
|
||||
oldest_key = min(
|
||||
self._points_sync_cache,
|
||||
key=lambda key: float(
|
||||
self._points_sync_cache[key].get("ts") or 0.0
|
||||
),
|
||||
)
|
||||
self._points_sync_cache.pop(oldest_key, None)
|
||||
|
||||
def _sync_points_to_supabase_user_metadata(
|
||||
self,
|
||||
telegram_id: int,
|
||||
*,
|
||||
force: bool = False,
|
||||
) -> bool:
|
||||
supabase_url = str(os.getenv("SUPABASE_URL") or "").strip().rstrip("/")
|
||||
if not supabase_url:
|
||||
return False
|
||||
@@ -104,6 +237,13 @@ class DBManager:
|
||||
if pts_row:
|
||||
points = max(0, int(pts_row["points"] or 0))
|
||||
|
||||
if self._should_skip_points_metadata_sync(
|
||||
telegram_id=int(telegram_id),
|
||||
points=points,
|
||||
force=force,
|
||||
):
|
||||
return False
|
||||
|
||||
try:
|
||||
resp = requests.patch(
|
||||
f"{endpoint}/{supabase_user_id}",
|
||||
@@ -120,6 +260,10 @@ class DBManager:
|
||||
(resp.text or "")[:200],
|
||||
)
|
||||
return False
|
||||
self._remember_points_metadata_sync(
|
||||
telegram_id=int(telegram_id),
|
||||
points=points,
|
||||
)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
@@ -136,12 +280,20 @@ class DBManager:
|
||||
supabase_user_id: str,
|
||||
telegram_id: Optional[int],
|
||||
telegram_username: Optional[str],
|
||||
force: bool = False,
|
||||
) -> bool:
|
||||
normalized_uid = str(supabase_user_id or "").strip().lower()
|
||||
endpoint = self._supabase_profiles_endpoint()
|
||||
headers = self._supabase_service_headers()
|
||||
if not normalized_uid or not endpoint or not headers:
|
||||
return False
|
||||
if self._should_skip_profile_sync(
|
||||
supabase_user_id=normalized_uid,
|
||||
telegram_id=telegram_id,
|
||||
telegram_username=telegram_username,
|
||||
force=force,
|
||||
):
|
||||
return False
|
||||
|
||||
payload = {
|
||||
"telegram_user_id": int(telegram_id) if telegram_id is not None else None,
|
||||
@@ -164,6 +316,11 @@ class DBManager:
|
||||
(response.text or "")[:240],
|
||||
)
|
||||
return False
|
||||
self._remember_profile_sync(
|
||||
supabase_user_id=normalized_uid,
|
||||
telegram_id=telegram_id,
|
||||
telegram_username=telegram_username,
|
||||
)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
@@ -1274,7 +1431,7 @@ class DBManager:
|
||||
(after, telegram_id),
|
||||
)
|
||||
conn.commit()
|
||||
self._sync_points_to_supabase_user_metadata(telegram_id)
|
||||
self._sync_points_to_supabase_user_metadata(telegram_id, force=True)
|
||||
return {
|
||||
"ok": True,
|
||||
"telegram_id": telegram_id,
|
||||
@@ -1326,7 +1483,7 @@ class DBManager:
|
||||
(after, telegram_id),
|
||||
)
|
||||
conn.commit()
|
||||
self._sync_points_to_supabase_user_metadata(telegram_id)
|
||||
self._sync_points_to_supabase_user_metadata(telegram_id, force=True)
|
||||
return {
|
||||
"ok": True,
|
||||
"telegram_id": telegram_id,
|
||||
@@ -1471,6 +1628,7 @@ class DBManager:
|
||||
supabase_user_id=normalized_uid,
|
||||
telegram_id=int(telegram_id),
|
||||
telegram_username=str((user_row["username"] if user_row else "") or "").strip(),
|
||||
force=True,
|
||||
)
|
||||
return {"ok": True, "reason": "already_bound_same"}
|
||||
|
||||
@@ -1496,6 +1654,7 @@ class DBManager:
|
||||
supabase_user_id=normalized_uid,
|
||||
telegram_id=int(telegram_id),
|
||||
telegram_username=str((user_row["username"] if user_row else "") or "").strip(),
|
||||
force=True,
|
||||
)
|
||||
return {"ok": True, "reason": "bound"}
|
||||
|
||||
@@ -1551,6 +1710,7 @@ class DBManager:
|
||||
supabase_user_id=user_id,
|
||||
telegram_id=None,
|
||||
telegram_username=None,
|
||||
force=True,
|
||||
)
|
||||
return {"ok": True, "reason": "unbound", "previous_supabase_user_id": current_uid}
|
||||
|
||||
@@ -1940,7 +2100,7 @@ class DBManager:
|
||||
(new_balance, telegram_id),
|
||||
)
|
||||
conn.commit()
|
||||
self._sync_points_to_supabase_user_metadata(telegram_id)
|
||||
self._sync_points_to_supabase_user_metadata(telegram_id, force=True)
|
||||
return {"ok": True, "balance": new_balance, "spent": amount}
|
||||
|
||||
def spend_points_by_supabase_user_id(self, supabase_user_id: str, amount: int) -> Dict[str, Any]:
|
||||
@@ -1978,7 +2138,7 @@ class DBManager:
|
||||
(new_balance, telegram_id),
|
||||
)
|
||||
conn.commit()
|
||||
self._sync_points_to_supabase_user_metadata(telegram_id)
|
||||
self._sync_points_to_supabase_user_metadata(telegram_id, force=True)
|
||||
return {"ok": True, "balance": new_balance, "spent": amount}
|
||||
|
||||
def set_premium(self, telegram_id: int, plan: str, months: int = 1):
|
||||
@@ -2290,7 +2450,10 @@ class DBManager:
|
||||
)
|
||||
conn.commit()
|
||||
if bonus > 0:
|
||||
self._sync_points_to_supabase_user_metadata(int(telegram_id))
|
||||
self._sync_points_to_supabase_user_metadata(
|
||||
int(telegram_id),
|
||||
force=True,
|
||||
)
|
||||
return True
|
||||
|
||||
def append_airport_obs(
|
||||
|
||||
+123
-123
@@ -4,6 +4,7 @@ import json
|
||||
import os
|
||||
import secrets
|
||||
import threading
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from decimal import Decimal, InvalidOperation, ROUND_FLOOR
|
||||
@@ -1307,7 +1308,7 @@ class PaymentContractCheckoutService:
|
||||
"GET",
|
||||
"user_wallets",
|
||||
params={
|
||||
"select": "chain_id,address,status,is_primary,verified_at",
|
||||
"select": "chain_id,address,is_primary,verified_at",
|
||||
"user_id": f"eq.{user_id}",
|
||||
"chain_id": f"eq.{self.chain_id}",
|
||||
"status": "eq.active",
|
||||
@@ -1323,7 +1324,7 @@ class PaymentContractCheckoutService:
|
||||
WalletBindingRecord(
|
||||
chain_id=int(row.get("chain_id") or self.chain_id),
|
||||
address=_normalize_address(row.get("address") or ""),
|
||||
status=str(row.get("status") or "active"),
|
||||
status="active",
|
||||
is_primary=bool(row.get("is_primary")),
|
||||
verified_at=row.get("verified_at"),
|
||||
)
|
||||
@@ -1338,7 +1339,7 @@ class PaymentContractCheckoutService:
|
||||
"GET",
|
||||
"user_wallets",
|
||||
params={
|
||||
"select": "id,user_id,address,chain_id,status,is_primary",
|
||||
"select": "status",
|
||||
"user_id": f"eq.{user_id}",
|
||||
"chain_id": f"eq.{self.chain_id}",
|
||||
"address": f"eq.{normalized}",
|
||||
@@ -1381,7 +1382,7 @@ class PaymentContractCheckoutService:
|
||||
"message": message,
|
||||
"expires_at": _to_iso(expires),
|
||||
},
|
||||
prefer="return=representation",
|
||||
prefer="return=minimal",
|
||||
allowed_status=[201],
|
||||
)
|
||||
return {
|
||||
@@ -1414,13 +1415,12 @@ class PaymentContractCheckoutService:
|
||||
"GET",
|
||||
"wallet_link_challenges",
|
||||
params={
|
||||
"select": "id,user_id,address,nonce,message,expires_at,consumed_at",
|
||||
"select": "id,message,expires_at",
|
||||
"user_id": f"eq.{user_id}",
|
||||
"chain_id": f"eq.{self.chain_id}",
|
||||
"address": f"eq.{normalized}",
|
||||
"nonce": f"eq.{nonce_text}",
|
||||
"consumed_at": "is.null",
|
||||
"order": "created_at.desc",
|
||||
"limit": "1",
|
||||
},
|
||||
allowed_status=[200],
|
||||
@@ -1455,7 +1455,7 @@ class PaymentContractCheckoutService:
|
||||
"GET",
|
||||
"user_wallets",
|
||||
params={
|
||||
"select": "id,user_id,address,status,is_primary",
|
||||
"select": "user_id,status",
|
||||
"chain_id": f"eq.{self.chain_id}",
|
||||
"address": f"eq.{normalized}",
|
||||
"limit": "1",
|
||||
@@ -1501,7 +1501,7 @@ class PaymentContractCheckoutService:
|
||||
"verified_at": now_iso,
|
||||
"updated_at": now_iso,
|
||||
},
|
||||
prefer="resolution=merge-duplicates,return=representation",
|
||||
prefer="resolution=merge-duplicates,return=minimal",
|
||||
allowed_status=[200, 201],
|
||||
)
|
||||
self._rest(
|
||||
@@ -1509,7 +1509,7 @@ class PaymentContractCheckoutService:
|
||||
"wallet_link_challenges",
|
||||
params={"id": f"eq.{challenge.get('id')}"},
|
||||
payload={"consumed_at": now_iso},
|
||||
prefer="return=representation",
|
||||
prefer="return=minimal",
|
||||
allowed_status=[200],
|
||||
)
|
||||
return WalletBindingRecord(
|
||||
@@ -1543,7 +1543,7 @@ class PaymentContractCheckoutService:
|
||||
"is_primary": False,
|
||||
"updated_at": now_iso,
|
||||
},
|
||||
prefer="return=representation",
|
||||
prefer="return=minimal",
|
||||
allowed_status=[200],
|
||||
)
|
||||
|
||||
@@ -1591,7 +1591,7 @@ class PaymentContractCheckoutService:
|
||||
"user_wallets",
|
||||
params={"id": f"eq.{candidate_id}"},
|
||||
payload={"is_primary": True, "updated_at": now_iso},
|
||||
prefer="return=representation",
|
||||
prefer="return=minimal",
|
||||
allowed_status=[200],
|
||||
)
|
||||
new_primary = candidate_addr
|
||||
@@ -1780,32 +1780,32 @@ class PaymentContractCheckoutService:
|
||||
order_id_hex = "0x" + secrets.token_hex(32)
|
||||
now = _now_utc()
|
||||
expires_at = now + timedelta(seconds=self.intent_ttl_sec)
|
||||
rows = self._rest(
|
||||
intent_payload = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"user_id": user_id,
|
||||
"plan_code": plan["plan_code"],
|
||||
"plan_id": plan["plan_id"],
|
||||
"chain_id": selected_chain_id,
|
||||
"token_address": selected_token.address,
|
||||
"receiver_address": receiver_address,
|
||||
"amount_units": str(amount_units),
|
||||
"payment_mode": mode,
|
||||
"allowed_wallet": target_wallet or None,
|
||||
"order_id_hex": order_id_hex,
|
||||
"status": "created",
|
||||
"expires_at": _to_iso(expires_at),
|
||||
"metadata": combined_metadata,
|
||||
"created_at": _to_iso(now),
|
||||
"updated_at": _to_iso(now),
|
||||
}
|
||||
self._rest(
|
||||
"POST",
|
||||
"payment_intents",
|
||||
payload={
|
||||
"user_id": user_id,
|
||||
"plan_code": plan["plan_code"],
|
||||
"plan_id": plan["plan_id"],
|
||||
"chain_id": selected_chain_id,
|
||||
"token_address": selected_token.address,
|
||||
"receiver_address": receiver_address,
|
||||
"amount_units": str(amount_units),
|
||||
"payment_mode": mode,
|
||||
"allowed_wallet": target_wallet or None,
|
||||
"order_id_hex": order_id_hex,
|
||||
"status": "created",
|
||||
"expires_at": _to_iso(expires_at),
|
||||
"metadata": combined_metadata,
|
||||
"created_at": _to_iso(now),
|
||||
"updated_at": _to_iso(now),
|
||||
},
|
||||
prefer="return=representation",
|
||||
payload=intent_payload,
|
||||
prefer="return=minimal",
|
||||
allowed_status=[201],
|
||||
)
|
||||
if not isinstance(rows, list) or not rows:
|
||||
raise PaymentCheckoutError(500, "failed to create payment intent")
|
||||
intent = self._serialize_intent(rows[0])
|
||||
intent = self._serialize_intent(intent_payload)
|
||||
response = {
|
||||
"intent": intent.__dict__,
|
||||
"tx_payload": None if mode == "direct" else self._build_tx_payload(intent),
|
||||
@@ -1885,7 +1885,7 @@ class PaymentContractCheckoutService:
|
||||
"GET",
|
||||
"payment_intents",
|
||||
params={
|
||||
"select": "id,user_id,tx_hash,status,updated_at,created_at,chain_id",
|
||||
"select": "id,user_id,tx_hash,chain_id",
|
||||
"status": "eq.submitted",
|
||||
"tx_hash": "not.is.null",
|
||||
"order": "updated_at.asc",
|
||||
@@ -1911,9 +1911,6 @@ class PaymentContractCheckoutService:
|
||||
"user_id": user_id,
|
||||
"tx_hash": tx_hash,
|
||||
"chain_id": int(row.get("chain_id") or self.chain_id),
|
||||
"status": str(row.get("status") or ""),
|
||||
"updated_at": row.get("updated_at"),
|
||||
"created_at": row.get("created_at"),
|
||||
}
|
||||
)
|
||||
return out
|
||||
@@ -1937,8 +1934,7 @@ class PaymentContractCheckoutService:
|
||||
"payment_intents",
|
||||
params={
|
||||
"select": (
|
||||
"id,user_id,status,tx_hash,order_id_hex,plan_id,token_address,"
|
||||
"amount_units,payment_mode,allowed_wallet,chain_id,created_at,updated_at"
|
||||
"id,user_id,status,tx_hash,plan_id,token_address,amount_units"
|
||||
),
|
||||
"order_id_hex": f"eq.{normalized_order}",
|
||||
"status": "in.(created,submitted,confirmed)",
|
||||
@@ -1965,15 +1961,9 @@ class PaymentContractCheckoutService:
|
||||
"user_id": user_id,
|
||||
"status": status,
|
||||
"tx_hash": str(row.get("tx_hash") or "").strip().lower(),
|
||||
"order_id_hex": str(row.get("order_id_hex") or "").strip().lower(),
|
||||
"plan_id": int(row.get("plan_id") or 0),
|
||||
"chain_id": int(row.get("chain_id") or self.chain_id),
|
||||
"token_address": _normalize_address(row.get("token_address")),
|
||||
"amount_units": int(row.get("amount_units") or 0),
|
||||
"payment_mode": str(row.get("payment_mode") or "").strip().lower(),
|
||||
"allowed_wallet": _normalize_address(row.get("allowed_wallet")),
|
||||
"created_at": row.get("created_at"),
|
||||
"updated_at": row.get("updated_at"),
|
||||
}
|
||||
)
|
||||
return out
|
||||
@@ -1986,7 +1976,7 @@ class PaymentContractCheckoutService:
|
||||
"GET",
|
||||
"payment_transactions",
|
||||
params={
|
||||
"select": "intent_id,tx_hash,status",
|
||||
"select": "intent_id",
|
||||
"tx_hash": f"eq.{tx_hash_text}",
|
||||
"limit": "5",
|
||||
},
|
||||
@@ -2018,7 +2008,7 @@ class PaymentContractCheckoutService:
|
||||
return {}
|
||||
now_iso = _to_iso(_now_utc())
|
||||
try:
|
||||
rows = self._rest(
|
||||
self._rest(
|
||||
"POST",
|
||||
"payment_transactions",
|
||||
params={"on_conflict": "tx_hash"},
|
||||
@@ -2040,10 +2030,10 @@ class PaymentContractCheckoutService:
|
||||
},
|
||||
"updated_at": now_iso,
|
||||
},
|
||||
prefer="resolution=merge-duplicates,return=representation",
|
||||
prefer="resolution=merge-duplicates,return=minimal",
|
||||
allowed_status=[200, 201],
|
||||
)
|
||||
return rows[0] if isinstance(rows, list) and rows else {}
|
||||
return {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
@@ -2060,6 +2050,13 @@ class PaymentContractCheckoutService:
|
||||
"""
|
||||
self._ensure_enabled()
|
||||
intent = self.get_intent(user_id, intent_id)
|
||||
return self._validate_loaded_intent_tx(intent, tx_hash)
|
||||
|
||||
def _validate_loaded_intent_tx(
|
||||
self,
|
||||
intent: PaymentIntentRecord,
|
||||
tx_hash: str,
|
||||
) -> Dict[str, Any]:
|
||||
tx_hash_text = str(tx_hash or "").strip().lower()
|
||||
if not (tx_hash_text.startswith("0x") and len(tx_hash_text) == 66):
|
||||
return {
|
||||
@@ -2266,7 +2263,7 @@ class PaymentContractCheckoutService:
|
||||
"payment_intents",
|
||||
params={"id": f"eq.{intent.intent_id}", "user_id": f"eq.{user_id}"},
|
||||
payload={"status": "expired", "updated_at": _to_iso(now)},
|
||||
prefer="return=representation",
|
||||
prefer="return=minimal",
|
||||
allowed_status=[200],
|
||||
)
|
||||
raise PaymentCheckoutError(409, "payment intent expired")
|
||||
@@ -2283,7 +2280,7 @@ class PaymentContractCheckoutService:
|
||||
self._require_user_wallet(user_id, from_addr)
|
||||
|
||||
try:
|
||||
validation = self.validate_intent_tx(user_id, intent.intent_id, tx_hash_text)
|
||||
validation = self._validate_loaded_intent_tx(intent, tx_hash_text)
|
||||
except Exception as exc:
|
||||
raise PaymentCheckoutError(
|
||||
400,
|
||||
@@ -2305,26 +2302,25 @@ class PaymentContractCheckoutService:
|
||||
"tx_hash": tx_hash_text,
|
||||
"updated_at": now_iso,
|
||||
},
|
||||
prefer="return=representation",
|
||||
prefer="return=minimal",
|
||||
allowed_status=[200],
|
||||
)
|
||||
tx_rows = self._rest(
|
||||
tx_payload = {
|
||||
"intent_id": intent.intent_id,
|
||||
"chain_id": int(intent.chain_id),
|
||||
"tx_hash": tx_hash_text,
|
||||
"from_address": from_addr,
|
||||
"to_address": intent.receiver_address,
|
||||
"payment_method": "direct" if intent.payment_mode == "direct" else "wallet",
|
||||
"status": "submitted",
|
||||
"updated_at": now_iso,
|
||||
}
|
||||
self._rest(
|
||||
"POST",
|
||||
"payment_transactions",
|
||||
params={"on_conflict": "tx_hash"},
|
||||
payload={
|
||||
"intent_id": intent.intent_id,
|
||||
"chain_id": int(intent.chain_id),
|
||||
"tx_hash": tx_hash_text,
|
||||
"from_address": from_addr,
|
||||
"to_address": intent.receiver_address,
|
||||
"payment_method": "direct"
|
||||
if intent.payment_mode == "direct"
|
||||
else "wallet",
|
||||
"status": "submitted",
|
||||
"updated_at": now_iso,
|
||||
},
|
||||
prefer="resolution=merge-duplicates,return=representation",
|
||||
payload=tx_payload,
|
||||
prefer="resolution=merge-duplicates,return=minimal",
|
||||
allowed_status=[200, 201],
|
||||
)
|
||||
return {
|
||||
@@ -2332,9 +2328,7 @@ class PaymentContractCheckoutService:
|
||||
"status": "submitted",
|
||||
"tx_hash": tx_hash_text,
|
||||
"from_address": from_addr,
|
||||
"transaction": tx_rows[0]
|
||||
if isinstance(tx_rows, list) and tx_rows
|
||||
else None,
|
||||
"transaction": tx_payload,
|
||||
}
|
||||
|
||||
def _wait_receipt(self, tx_hash: str, chain_id: Optional[int] = None) -> Any:
|
||||
@@ -2466,24 +2460,25 @@ class PaymentContractCheckoutService:
|
||||
token_decimals = self._token_decimals_for(token_address, payment_chain_id)
|
||||
amount_dec = _units_to_decimal(amount_units, token_decimals)
|
||||
currency = self._token_symbol_for(token_address, payment_chain_id)
|
||||
rows = self._rest(
|
||||
payment_payload = {
|
||||
"user_id": user_id,
|
||||
"amount": str(amount_dec),
|
||||
"currency": currency,
|
||||
"chain": self._chain_label_for(payment_chain_id),
|
||||
"tx_hash": tx_hash,
|
||||
"status": "confirmed",
|
||||
"raw_payload": payload,
|
||||
"updated_at": _to_iso(_now_utc()),
|
||||
}
|
||||
self._rest(
|
||||
"POST",
|
||||
"payments",
|
||||
params={"on_conflict": "tx_hash"},
|
||||
payload={
|
||||
"user_id": user_id,
|
||||
"amount": str(amount_dec),
|
||||
"currency": currency,
|
||||
"chain": self._chain_label_for(payment_chain_id),
|
||||
"tx_hash": tx_hash,
|
||||
"status": "confirmed",
|
||||
"raw_payload": payload,
|
||||
"updated_at": _to_iso(_now_utc()),
|
||||
},
|
||||
prefer="resolution=merge-duplicates,return=representation",
|
||||
payload=payment_payload,
|
||||
prefer="resolution=merge-duplicates,return=minimal",
|
||||
allowed_status=[200, 201],
|
||||
)
|
||||
return rows[0] if isinstance(rows, list) and rows else {}
|
||||
return payment_payload
|
||||
|
||||
def _grant_subscription(
|
||||
self,
|
||||
@@ -2498,7 +2493,7 @@ class PaymentContractCheckoutService:
|
||||
"GET",
|
||||
"subscriptions",
|
||||
params={
|
||||
"select": "id,expires_at,status,plan_code,source,starts_at",
|
||||
"select": "starts_at,expires_at",
|
||||
"user_id": f"eq.{user_id}",
|
||||
"status": "eq.active",
|
||||
"order": "expires_at.desc",
|
||||
@@ -2539,20 +2534,21 @@ class PaymentContractCheckoutService:
|
||||
except Exception:
|
||||
pass
|
||||
expires = starts + timedelta(days=max(1, duration_days))
|
||||
sub_rows = self._rest(
|
||||
subscription_payload = {
|
||||
"user_id": user_id,
|
||||
"plan_code": plan_code,
|
||||
"status": "active",
|
||||
"starts_at": _to_iso(starts),
|
||||
"expires_at": _to_iso(expires),
|
||||
"source": "payment_contract",
|
||||
"created_at": _to_iso(now),
|
||||
"updated_at": _to_iso(now),
|
||||
}
|
||||
self._rest(
|
||||
"POST",
|
||||
"subscriptions",
|
||||
payload={
|
||||
"user_id": user_id,
|
||||
"plan_code": plan_code,
|
||||
"status": "active",
|
||||
"starts_at": _to_iso(starts),
|
||||
"expires_at": _to_iso(expires),
|
||||
"source": "payment_contract",
|
||||
"created_at": _to_iso(now),
|
||||
"updated_at": _to_iso(now),
|
||||
},
|
||||
prefer="return=representation",
|
||||
payload=subscription_payload,
|
||||
prefer="return=minimal",
|
||||
allowed_status=[201],
|
||||
)
|
||||
self._rest(
|
||||
@@ -2566,11 +2562,11 @@ class PaymentContractCheckoutService:
|
||||
"payload": {"tx_hash": tx_hash, **payload},
|
||||
"created_at": _to_iso(now),
|
||||
},
|
||||
prefer="return=representation",
|
||||
prefer="return=minimal",
|
||||
allowed_status=[201],
|
||||
)
|
||||
SUPABASE_ENTITLEMENT.invalidate_subscription_cache(user_id)
|
||||
return sub_rows[0] if isinstance(sub_rows, list) and sub_rows else {}
|
||||
return subscription_payload
|
||||
|
||||
def _ensure_confirmed_subscription(
|
||||
self,
|
||||
@@ -2578,7 +2574,6 @@ class PaymentContractCheckoutService:
|
||||
intent: PaymentIntentRecord,
|
||||
tx_hash: str,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
SUPABASE_ENTITLEMENT.invalidate_subscription_cache(user_id)
|
||||
latest_subscription = SUPABASE_ENTITLEMENT.get_latest_active_subscription(
|
||||
user_id,
|
||||
respect_requirement=False,
|
||||
@@ -2696,7 +2691,7 @@ class PaymentContractCheckoutService:
|
||||
"metadata": metadata,
|
||||
"updated_at": now_iso,
|
||||
},
|
||||
prefer="return=representation",
|
||||
prefer="return=minimal",
|
||||
allowed_status=[200],
|
||||
)
|
||||
if tx_hash:
|
||||
@@ -2713,7 +2708,7 @@ class PaymentContractCheckoutService:
|
||||
"status": "failed",
|
||||
"updated_at": now_iso,
|
||||
},
|
||||
prefer="resolution=merge-duplicates,return=representation",
|
||||
prefer="resolution=merge-duplicates,return=minimal",
|
||||
allowed_status=[200, 201],
|
||||
)
|
||||
self._db.append_payment_audit_event(
|
||||
@@ -2931,6 +2926,7 @@ class PaymentContractCheckoutService:
|
||||
"PATCH",
|
||||
"payment_intents",
|
||||
params={
|
||||
"select": "id",
|
||||
"id": f"eq.{intent.intent_id}",
|
||||
"user_id": f"eq.{user_id}",
|
||||
"status": "in.(created,submitted,failed)",
|
||||
@@ -2972,24 +2968,25 @@ class PaymentContractCheckoutService:
|
||||
raise PaymentCheckoutError(
|
||||
409, f"intent status is {refreshed.status}, cannot confirm"
|
||||
)
|
||||
tx_rows = self._rest(
|
||||
tx_payload = {
|
||||
"intent_id": intent.intent_id,
|
||||
"tx_hash": tx_hash_text,
|
||||
"chain_id": int(intent.chain_id),
|
||||
"from_address": tx_from,
|
||||
"to_address": tx_to,
|
||||
"block_number": block_number,
|
||||
"payment_method": "direct" if is_direct else "wallet",
|
||||
"status": "confirmed",
|
||||
"raw_receipt": json.loads(Web3.to_json(receipt)),
|
||||
"raw_tx": json.loads(Web3.to_json(tx)) if tx is not None else None,
|
||||
"updated_at": now_iso,
|
||||
}
|
||||
self._rest(
|
||||
"POST",
|
||||
"payment_transactions",
|
||||
params={"on_conflict": "tx_hash"},
|
||||
payload={
|
||||
"intent_id": intent.intent_id,
|
||||
"tx_hash": tx_hash_text,
|
||||
"chain_id": int(intent.chain_id),
|
||||
"from_address": tx_from,
|
||||
"to_address": tx_to,
|
||||
"block_number": block_number,
|
||||
"payment_method": "direct" if is_direct else "wallet",
|
||||
"status": "confirmed",
|
||||
"raw_receipt": json.loads(Web3.to_json(receipt)),
|
||||
"raw_tx": json.loads(Web3.to_json(tx)) if tx is not None else None,
|
||||
"updated_at": now_iso,
|
||||
},
|
||||
prefer="resolution=merge-duplicates,return=representation",
|
||||
payload=tx_payload,
|
||||
prefer="resolution=merge-duplicates,return=minimal",
|
||||
allowed_status=[200, 201],
|
||||
)
|
||||
payload = {
|
||||
@@ -3036,12 +3033,17 @@ class PaymentContractCheckoutService:
|
||||
amount_usdc=intent.amount_usdc,
|
||||
tx_hash=tx_hash_text,
|
||||
)
|
||||
refreshed = self.get_intent(user_id, intent.intent_id)
|
||||
refreshed = PaymentIntentRecord(
|
||||
**{
|
||||
**intent.__dict__,
|
||||
"status": "confirmed",
|
||||
"tx_hash": tx_hash_text,
|
||||
"metadata": confirmed_metadata,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"intent": refreshed.__dict__,
|
||||
"transaction": tx_rows[0]
|
||||
if isinstance(tx_rows, list) and tx_rows
|
||||
else None,
|
||||
"transaction": tx_payload,
|
||||
"payment": payment_row,
|
||||
"subscription": subscription_row,
|
||||
"points_redemption": points_result,
|
||||
@@ -3091,11 +3093,10 @@ class PaymentContractCheckoutService:
|
||||
repaired = self._ensure_confirm_side_effects(
|
||||
user_id, intent, tx_hash_text
|
||||
)
|
||||
refreshed = self.get_intent(user_id, intent.intent_id)
|
||||
return {
|
||||
"ok": True,
|
||||
"action": "reconciled_confirmed_intent",
|
||||
"intent": refreshed.__dict__,
|
||||
"intent": intent.__dict__,
|
||||
"payment": repaired.get("payment"),
|
||||
"subscription": repaired.get("subscription"),
|
||||
}
|
||||
@@ -3109,7 +3110,6 @@ class PaymentContractCheckoutService:
|
||||
}
|
||||
)
|
||||
|
||||
SUPABASE_ENTITLEMENT.invalidate_subscription_cache(user_id)
|
||||
latest_subscription = SUPABASE_ENTITLEMENT.get_latest_active_subscription(
|
||||
user_id,
|
||||
respect_requirement=False,
|
||||
@@ -3128,7 +3128,7 @@ class PaymentContractCheckoutService:
|
||||
"GET",
|
||||
"payment_intents",
|
||||
params={
|
||||
"select": "id,user_id,status,updated_at",
|
||||
"select": "user_id",
|
||||
"status": "in.(submitted,confirmed)",
|
||||
"order": "updated_at.desc",
|
||||
"limit": str(safe_limit),
|
||||
|
||||
@@ -381,6 +381,46 @@ def test_join_request_approves_trial_user_with_queued_paid_subscription(monkeypa
|
||||
assert bot.approved_join_requests == [{"chat_id": -100123, "user_id": 12345}]
|
||||
|
||||
|
||||
def test_join_request_uses_subscription_window_without_latest_fallback(monkeypatch):
|
||||
monkeypatch.setenv("POLYWEATHER_TELEGRAM_GROUP_ID", "-100123")
|
||||
bot = DummyBot()
|
||||
db = SimpleNamespace(list_supabase_user_ids_for_telegram=lambda telegram_id: ["user-1"])
|
||||
io_layer = SimpleNamespace(
|
||||
build_welcome_text=lambda: "WELCOME",
|
||||
build_points_rank_text=lambda _user: "TOP",
|
||||
db=db,
|
||||
)
|
||||
|
||||
def _fail_latest(*_args, **_kwargs):
|
||||
raise AssertionError("subscription window rows should avoid latest subscription fallback")
|
||||
|
||||
entitlement = SimpleNamespace(
|
||||
get_subscription_window=lambda user_id, respect_requirement=False: {
|
||||
"rows": [
|
||||
{"plan_code": "signup_trial_3d", "source": "signup_trial"},
|
||||
]
|
||||
},
|
||||
get_latest_active_subscription=_fail_latest,
|
||||
)
|
||||
handler = BasicCommandHandler(
|
||||
bot=bot,
|
||||
io_layer=io_layer,
|
||||
runtime_status_provider=lambda: RuntimeStatus(
|
||||
started_at="2026-03-12 00:00:00 UTC",
|
||||
loops=[],
|
||||
command_access_mode="group_member",
|
||||
protected_commands=["/city", "/deb"],
|
||||
required_group_chat_id="-100123",
|
||||
),
|
||||
entitlement_service=entitlement,
|
||||
)
|
||||
|
||||
result = handler.handle_chat_join_request(_join_request())
|
||||
|
||||
assert result == "pending:no_active_subscription"
|
||||
assert bot.approved_join_requests == []
|
||||
|
||||
|
||||
def test_join_request_can_decline_ineligible_user_when_configured(monkeypatch):
|
||||
monkeypatch.setenv("POLYWEATHER_TELEGRAM_GROUP_ID", "-100123")
|
||||
monkeypatch.setenv("POLYWEATHER_TELEGRAM_JOIN_INELIGIBLE_ACTION", "decline")
|
||||
|
||||
@@ -186,14 +186,15 @@ def test_confirm_direct_transfer_uses_intent_chain_rpc(monkeypatch, tmp_path):
|
||||
)
|
||||
confirmed_intent = PaymentIntentRecord(**{**intent.__dict__, "status": "confirmed"})
|
||||
intents = [intent, confirmed_intent]
|
||||
get_intent_calls = []
|
||||
requested_chains = []
|
||||
tx_rows = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"get_intent",
|
||||
lambda user_id, intent_id: intents.pop(0) if intents else confirmed_intent,
|
||||
)
|
||||
def fake_get_intent(user_id, intent_id):
|
||||
get_intent_calls.append((user_id, intent_id))
|
||||
return intents.pop(0) if intents else confirmed_intent
|
||||
|
||||
monkeypatch.setattr(service, "get_intent", fake_get_intent)
|
||||
|
||||
class _Eth:
|
||||
chain_id = 1
|
||||
@@ -260,7 +261,9 @@ def test_confirm_direct_transfer_uses_intent_chain_rpc(monkeypatch, tmp_path):
|
||||
if method == "GET" and table == "payment_transactions":
|
||||
return []
|
||||
if method == "PATCH" and table == "payment_intents":
|
||||
return [{"id": intent.intent_id, "status": "confirmed"}]
|
||||
assert kwargs["prefer"] == "return=representation"
|
||||
assert kwargs["params"]["select"] == "id"
|
||||
return [{"id": intent.intent_id}]
|
||||
if method == "POST" and table == "payment_transactions":
|
||||
tx_rows.append(kwargs["payload"])
|
||||
return [kwargs["payload"]]
|
||||
@@ -273,6 +276,7 @@ def test_confirm_direct_transfer_uses_intent_chain_rpc(monkeypatch, tmp_path):
|
||||
assert 1 in requested_chains
|
||||
assert tx_rows[0]["chain_id"] == 1
|
||||
assert result["payment"]["chain_id"] == 1
|
||||
assert get_intent_calls == [("user-1", intent.intent_id)]
|
||||
|
||||
|
||||
def test_direct_intent_does_not_require_bound_wallet(monkeypatch, tmp_path):
|
||||
@@ -344,7 +348,7 @@ def test_direct_submit_tx_does_not_require_from_address(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(service, "_rest", fake_rest)
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"validate_intent_tx",
|
||||
"_validate_loaded_intent_tx",
|
||||
lambda *args, **kwargs: {"valid": True, "checks": {"tx_mined": True}},
|
||||
)
|
||||
|
||||
@@ -384,7 +388,7 @@ def test_submit_rejects_direct_tx_until_validation_passes(monkeypatch, tmp_path)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"validate_intent_tx",
|
||||
"_validate_loaded_intent_tx",
|
||||
lambda *args, **kwargs: {
|
||||
"valid": False,
|
||||
"reason": "tx_not_mined",
|
||||
@@ -437,7 +441,7 @@ def test_submit_rejects_mined_direct_tx_when_receiver_mismatches(monkeypatch, tm
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"validate_intent_tx",
|
||||
"_validate_loaded_intent_tx",
|
||||
lambda *args, **kwargs: {
|
||||
"valid": False,
|
||||
"reason": "receiver_mismatch",
|
||||
@@ -543,7 +547,7 @@ def test_confirm_direct_transfer_uses_erc20_transfer_without_wallet_binding(monk
|
||||
if method == "PATCH" and table == "payment_intents":
|
||||
return [{"id": intent.intent_id, "status": "confirmed"}]
|
||||
if method == "POST" and table == "payment_transactions":
|
||||
return [kwargs["payload"]]
|
||||
return []
|
||||
raise AssertionError((method, table, kwargs))
|
||||
|
||||
monkeypatch.setattr(service, "_rest", fake_rest)
|
||||
@@ -552,8 +556,14 @@ def test_confirm_direct_transfer_uses_erc20_transfer_without_wallet_binding(monk
|
||||
result = service.confirm_intent_tx("user-1", intent.intent_id, tx_hash)
|
||||
|
||||
assert result["subscription"]["status"] == "active"
|
||||
assert result["transaction"]["tx_hash"] == tx_hash
|
||||
assert result["tx"]["event"]["amount_units"] == 5000000
|
||||
assert any(call[1] == "payment_transactions" for call in rest_calls)
|
||||
transaction_write = next(
|
||||
call
|
||||
for call in rest_calls
|
||||
if call[0] == "POST" and call[1] == "payment_transactions"
|
||||
)
|
||||
assert transaction_write[2]["prefer"] == "resolution=merge-duplicates,return=minimal"
|
||||
|
||||
def test_submit_rejects_tx_hash_used_by_another_intent(monkeypatch, tmp_path):
|
||||
_setup_env(monkeypatch, tmp_path)
|
||||
|
||||
@@ -21,6 +21,517 @@ def _payment_env(monkeypatch, tmp_path):
|
||||
monkeypatch.delenv("POLYWEATHER_PAYMENT_DIRECT_RECEIVER_ADDRESS", raising=False)
|
||||
|
||||
|
||||
def test_wallet_challenge_insert_uses_minimal_return(monkeypatch, tmp_path):
|
||||
_payment_env(monkeypatch, tmp_path)
|
||||
service = PaymentContractCheckoutService()
|
||||
calls = []
|
||||
|
||||
def _fake_rest(method, table, **kwargs):
|
||||
calls.append({"method": method, "table": table, **kwargs})
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(service, "_rest", _fake_rest)
|
||||
|
||||
result = service.create_wallet_challenge(
|
||||
"user-1",
|
||||
"0x1111111111111111111111111111111111111111",
|
||||
)
|
||||
|
||||
assert result["address"] == "0x1111111111111111111111111111111111111111"
|
||||
assert calls[0]["table"] == "wallet_link_challenges"
|
||||
assert calls[0]["prefer"] == "return=minimal"
|
||||
|
||||
|
||||
def test_entitlement_event_insert_uses_minimal_return(monkeypatch, tmp_path):
|
||||
_payment_env(monkeypatch, tmp_path)
|
||||
service = PaymentContractCheckoutService()
|
||||
calls = []
|
||||
|
||||
def _fake_rest(method, table, **kwargs):
|
||||
calls.append({"method": method, "table": table, **kwargs})
|
||||
if method == "GET" and table == "subscriptions":
|
||||
return []
|
||||
if method == "POST" and table == "subscriptions":
|
||||
return [kwargs.get("payload") or {}]
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(service, "_rest", _fake_rest)
|
||||
|
||||
service._grant_subscription(
|
||||
user_id="user-1",
|
||||
plan_code="pro_monthly",
|
||||
duration_days=30,
|
||||
tx_hash="0x" + "1" * 64,
|
||||
payload={"kind": "test"},
|
||||
)
|
||||
|
||||
entitlement_event = next(call for call in calls if call["table"] == "entitlement_events")
|
||||
assert entitlement_event["prefer"] == "return=minimal"
|
||||
|
||||
|
||||
def test_subscription_insert_uses_minimal_return_and_local_payload(monkeypatch, tmp_path):
|
||||
_payment_env(monkeypatch, tmp_path)
|
||||
service = PaymentContractCheckoutService()
|
||||
calls = []
|
||||
|
||||
def _fake_rest(method, table, **kwargs):
|
||||
calls.append({"method": method, "table": table, **kwargs})
|
||||
if method == "GET" and table == "subscriptions":
|
||||
return []
|
||||
if method == "POST" and table == "subscriptions":
|
||||
return []
|
||||
if method == "POST" and table == "entitlement_events":
|
||||
return []
|
||||
raise AssertionError((method, table, kwargs))
|
||||
|
||||
monkeypatch.setattr(service, "_rest", _fake_rest)
|
||||
|
||||
result = service._grant_subscription(
|
||||
user_id="user-1",
|
||||
plan_code="pro_monthly",
|
||||
duration_days=30,
|
||||
tx_hash="0x" + "1" * 64,
|
||||
payload={"kind": "test"},
|
||||
)
|
||||
|
||||
subscription_write = next(
|
||||
call for call in calls if call["method"] == "POST" and call["table"] == "subscriptions"
|
||||
)
|
||||
assert subscription_write["prefer"] == "return=minimal"
|
||||
assert result == subscription_write["payload"]
|
||||
assert result["user_id"] == "user-1"
|
||||
assert result["plan_code"] == "pro_monthly"
|
||||
assert result["status"] == "active"
|
||||
|
||||
|
||||
def test_wallet_binding_writes_use_minimal_return(monkeypatch, tmp_path):
|
||||
_payment_env(monkeypatch, tmp_path)
|
||||
service = PaymentContractCheckoutService()
|
||||
address = "0x1111111111111111111111111111111111111111"
|
||||
calls = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.payments.contract_checkout.Account.recover_message",
|
||||
lambda *args, **kwargs: address,
|
||||
)
|
||||
|
||||
def _fake_rest(method, table, **kwargs):
|
||||
calls.append({"method": method, "table": table, **kwargs})
|
||||
if method == "GET" and table == "wallet_link_challenges":
|
||||
return [
|
||||
{
|
||||
"id": "challenge-1",
|
||||
"user_id": "user-1",
|
||||
"address": address,
|
||||
"nonce": "nonce-1",
|
||||
"message": "message",
|
||||
"expires_at": "2099-01-01T00:00:00+00:00",
|
||||
"consumed_at": None,
|
||||
}
|
||||
]
|
||||
if method == "GET" and table == "user_wallets":
|
||||
return []
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(service, "_rest", _fake_rest)
|
||||
|
||||
result = service.verify_wallet_binding("user-1", address, "nonce-1", "0xsig")
|
||||
|
||||
assert result.address == address
|
||||
writes = [call for call in calls if call["method"] in {"POST", "PATCH"}]
|
||||
assert writes[0]["table"] == "user_wallets"
|
||||
assert writes[0]["prefer"] == "resolution=merge-duplicates,return=minimal"
|
||||
assert writes[1]["table"] == "wallet_link_challenges"
|
||||
assert writes[1]["prefer"] == "return=minimal"
|
||||
|
||||
|
||||
def test_require_user_wallet_selects_only_status(monkeypatch, tmp_path):
|
||||
_payment_env(monkeypatch, tmp_path)
|
||||
service = PaymentContractCheckoutService()
|
||||
calls = []
|
||||
|
||||
def _fake_rest(method, table, **kwargs):
|
||||
calls.append({"method": method, "table": table, **kwargs})
|
||||
return [{"status": "active"}]
|
||||
|
||||
monkeypatch.setattr(service, "_rest", _fake_rest)
|
||||
|
||||
result = service._require_user_wallet(
|
||||
"user-1",
|
||||
"0x1111111111111111111111111111111111111111",
|
||||
)
|
||||
|
||||
assert result == {"status": "active"}
|
||||
assert calls[0]["params"]["select"] == "status"
|
||||
|
||||
|
||||
def test_list_wallets_omits_status_from_active_wallet_query(monkeypatch, tmp_path):
|
||||
_payment_env(monkeypatch, tmp_path)
|
||||
service = PaymentContractCheckoutService()
|
||||
calls = []
|
||||
|
||||
def _fake_rest(method, table, **kwargs):
|
||||
calls.append({"method": method, "table": table, **kwargs})
|
||||
return [
|
||||
{
|
||||
"chain_id": 137,
|
||||
"address": "0x1111111111111111111111111111111111111111",
|
||||
"is_primary": True,
|
||||
"verified_at": "2099-01-01T00:00:00+00:00",
|
||||
}
|
||||
]
|
||||
|
||||
monkeypatch.setattr(service, "_rest", _fake_rest)
|
||||
|
||||
wallets = service.list_wallets("user-1")
|
||||
|
||||
assert calls[0]["params"]["select"] == "chain_id,address,is_primary,verified_at"
|
||||
assert wallets[0].status == "active"
|
||||
|
||||
|
||||
def test_wallet_binding_existing_lookup_selects_only_owner_status(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
_payment_env(monkeypatch, tmp_path)
|
||||
service = PaymentContractCheckoutService()
|
||||
address = "0x1111111111111111111111111111111111111111"
|
||||
calls = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.payments.contract_checkout.Account.recover_message",
|
||||
lambda *args, **kwargs: address,
|
||||
)
|
||||
|
||||
def _fake_rest(method, table, **kwargs):
|
||||
calls.append({"method": method, "table": table, **kwargs})
|
||||
if method == "GET" and table == "wallet_link_challenges":
|
||||
return [
|
||||
{
|
||||
"id": "challenge-1",
|
||||
"user_id": "user-1",
|
||||
"address": address,
|
||||
"nonce": "nonce-1",
|
||||
"message": "message",
|
||||
"expires_at": "2099-01-01T00:00:00+00:00",
|
||||
"consumed_at": None,
|
||||
}
|
||||
]
|
||||
if method == "GET" and table == "user_wallets":
|
||||
return []
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(service, "_rest", _fake_rest)
|
||||
|
||||
service.verify_wallet_binding("user-1", address, "nonce-1", "0xsig")
|
||||
|
||||
existing_lookup = [
|
||||
call
|
||||
for call in calls
|
||||
if call["method"] == "GET"
|
||||
and call["table"] == "user_wallets"
|
||||
and "address" in call["params"]
|
||||
][0]
|
||||
assert existing_lookup["params"]["select"] == "user_id,status"
|
||||
|
||||
challenge_lookup = [
|
||||
call
|
||||
for call in calls
|
||||
if call["method"] == "GET"
|
||||
and call["table"] == "wallet_link_challenges"
|
||||
][0]
|
||||
assert challenge_lookup["params"]["select"] == "id,message,expires_at"
|
||||
assert "order" not in challenge_lookup["params"]
|
||||
|
||||
|
||||
def test_wallet_unbind_writes_use_minimal_return(monkeypatch, tmp_path):
|
||||
_payment_env(monkeypatch, tmp_path)
|
||||
service = PaymentContractCheckoutService()
|
||||
calls = []
|
||||
|
||||
monkeypatch.setattr(service, "_require_user_wallet", lambda *args, **kwargs: {})
|
||||
|
||||
def _fake_rest(method, table, **kwargs):
|
||||
calls.append({"method": method, "table": table, **kwargs})
|
||||
if method == "GET" and table == "user_wallets" and kwargs["params"].get("is_primary") == "eq.true":
|
||||
return []
|
||||
if method == "GET" and table == "user_wallets":
|
||||
return [{"id": "wallet-2", "address": "0x2222222222222222222222222222222222222222"}]
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(service, "_rest", _fake_rest)
|
||||
|
||||
result = service.unbind_wallet("user-1", "0x1111111111111111111111111111111111111111")
|
||||
|
||||
assert result["new_primary"] == "0x2222222222222222222222222222222222222222"
|
||||
writes = [call for call in calls if call["method"] == "PATCH"]
|
||||
assert [call["prefer"] for call in writes] == ["return=minimal", "return=minimal"]
|
||||
|
||||
|
||||
def test_submit_intent_status_patch_uses_minimal_return(monkeypatch, tmp_path):
|
||||
_payment_env(monkeypatch, tmp_path)
|
||||
service = PaymentContractCheckoutService()
|
||||
calls = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"get_intent",
|
||||
lambda user_id, intent_id: service._serialize_intent(
|
||||
{
|
||||
"id": intent_id,
|
||||
"plan_code": "pro_monthly",
|
||||
"plan_id": 101,
|
||||
"chain_id": 137,
|
||||
"token_address": "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174",
|
||||
"receiver_address": "0xeD2f13Aa5fF033c58FB436E178451Cd07f693f32",
|
||||
"amount_units": "5000000",
|
||||
"payment_mode": "direct",
|
||||
"allowed_wallet": None,
|
||||
"order_id_hex": "0x" + "1" * 64,
|
||||
"status": "created",
|
||||
"expires_at": "2099-01-01T00:00:00+00:00",
|
||||
"tx_hash": None,
|
||||
"metadata": {},
|
||||
}
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"_validate_loaded_intent_tx",
|
||||
lambda *args, **kwargs: {"valid": True, "checks": {"tx_mined": True}},
|
||||
)
|
||||
|
||||
def _fake_rest(method, table, **kwargs):
|
||||
calls.append({"method": method, "table": table, **kwargs})
|
||||
if method == "GET" and table == "payment_transactions":
|
||||
return []
|
||||
if method == "POST" and table == "payment_transactions":
|
||||
return []
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(service, "_rest", _fake_rest)
|
||||
|
||||
result = service.submit_intent_tx("user-1", "intent-1", "0x" + "2" * 64, "")
|
||||
|
||||
assert result["status"] == "submitted"
|
||||
intent_patch = next(call for call in calls if call["method"] == "PATCH")
|
||||
assert intent_patch["table"] == "payment_intents"
|
||||
assert intent_patch["prefer"] == "return=minimal"
|
||||
transaction_write = next(
|
||||
call
|
||||
for call in calls
|
||||
if call["method"] == "POST" and call["table"] == "payment_transactions"
|
||||
)
|
||||
assert transaction_write["prefer"] == "resolution=merge-duplicates,return=minimal"
|
||||
assert result["transaction"]["tx_hash"] == "0x" + "2" * 64
|
||||
|
||||
|
||||
def test_submit_intent_tx_reuses_loaded_intent_for_validation(monkeypatch, tmp_path):
|
||||
_payment_env(monkeypatch, tmp_path)
|
||||
service = PaymentContractCheckoutService()
|
||||
intent = service._serialize_intent(
|
||||
{
|
||||
"id": "intent-1",
|
||||
"plan_code": "pro_monthly",
|
||||
"plan_id": 101,
|
||||
"chain_id": 137,
|
||||
"token_address": "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174",
|
||||
"receiver_address": "0xeD2f13Aa5fF033c58FB436E178451Cd07f693f32",
|
||||
"amount_units": "5000000",
|
||||
"payment_mode": "direct",
|
||||
"allowed_wallet": None,
|
||||
"order_id_hex": "0x" + "1" * 64,
|
||||
"status": "created",
|
||||
"expires_at": "2099-01-01T00:00:00+00:00",
|
||||
"tx_hash": None,
|
||||
"metadata": {},
|
||||
}
|
||||
)
|
||||
calls = {"get_intent": 0}
|
||||
rest_calls = []
|
||||
|
||||
class _FakeEth:
|
||||
def get_transaction_receipt(self, tx_hash):
|
||||
return {"status": 1, "to": intent.receiver_address, "blockNumber": 123}
|
||||
|
||||
class _FakeWeb3:
|
||||
eth = _FakeEth()
|
||||
|
||||
def _fake_get_intent(user_id, intent_id):
|
||||
calls["get_intent"] += 1
|
||||
return intent
|
||||
|
||||
def _fake_rest(method, table, **kwargs):
|
||||
rest_calls.append({"method": method, "table": table, **kwargs})
|
||||
if method == "GET" and table == "payment_transactions":
|
||||
return []
|
||||
if method == "POST" and table == "payment_transactions":
|
||||
return [kwargs["payload"]]
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(service, "get_intent", _fake_get_intent)
|
||||
monkeypatch.setattr(service, "_get_web3", lambda *args, **kwargs: _FakeWeb3())
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"_extract_direct_transfer_event",
|
||||
lambda receipt, loaded_intent: {
|
||||
"from": "0x2222222222222222222222222222222222222222",
|
||||
"to": loaded_intent.receiver_address,
|
||||
"amount_units": int(loaded_intent.amount_units),
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(service, "_rest", _fake_rest)
|
||||
|
||||
result = service.submit_intent_tx("user-1", "intent-1", "0x" + "2" * 64, "")
|
||||
|
||||
assert result["status"] == "submitted"
|
||||
assert calls["get_intent"] == 1
|
||||
assert [call["table"] for call in rest_calls].count("payment_transactions") == 2
|
||||
|
||||
|
||||
def test_failed_intent_writes_use_minimal_return(monkeypatch, tmp_path):
|
||||
_payment_env(monkeypatch, tmp_path)
|
||||
service = PaymentContractCheckoutService()
|
||||
calls = []
|
||||
intent = PaymentIntentRecord(
|
||||
intent_id="intent-1",
|
||||
order_id_hex="0x" + "1" * 64,
|
||||
plan_code="pro_monthly",
|
||||
plan_id=101,
|
||||
chain_id=137,
|
||||
amount_units=5_000_000,
|
||||
amount_usdc="5",
|
||||
token_address="0x2791bca1f2de4661ed88a30c99a7a9449aa84174",
|
||||
token_decimals=6,
|
||||
token_symbol="USDC",
|
||||
receiver_address="0xed2f13aa5ff033c58fb436e178451cd07f693f32",
|
||||
status="submitted",
|
||||
payment_mode="direct",
|
||||
allowed_wallet=None,
|
||||
expires_at="2099-01-01T00:00:00+00:00",
|
||||
tx_hash=None,
|
||||
metadata={},
|
||||
)
|
||||
|
||||
def _fake_rest(method, table, **kwargs):
|
||||
calls.append({"method": method, "table": table, **kwargs})
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(service, "_rest", _fake_rest)
|
||||
|
||||
service._mark_intent_failed(
|
||||
user_id="user-1",
|
||||
intent=intent,
|
||||
tx_hash="0x" + "3" * 64,
|
||||
reason="test_failure",
|
||||
detail="test",
|
||||
)
|
||||
|
||||
assert calls[0]["table"] == "payment_intents"
|
||||
assert calls[0]["prefer"] == "return=minimal"
|
||||
assert calls[1]["table"] == "payment_transactions"
|
||||
assert calls[1]["prefer"] == "resolution=merge-duplicates,return=minimal"
|
||||
|
||||
|
||||
def test_duplicate_transaction_write_uses_minimal_return(monkeypatch, tmp_path):
|
||||
_payment_env(monkeypatch, tmp_path)
|
||||
service = PaymentContractCheckoutService()
|
||||
calls = []
|
||||
intent = PaymentIntentRecord(
|
||||
intent_id="intent-1",
|
||||
order_id_hex="0x" + "1" * 64,
|
||||
plan_code="pro_monthly",
|
||||
plan_id=101,
|
||||
chain_id=137,
|
||||
amount_units=5_000_000,
|
||||
amount_usdc="5",
|
||||
token_address="0x2791bca1f2de4661ed88a30c99a7a9449aa84174",
|
||||
token_decimals=6,
|
||||
token_symbol="USDC",
|
||||
receiver_address="0xed2f13aa5ff033c58fb436e178451cd07f693f32",
|
||||
status="confirmed",
|
||||
payment_mode="direct",
|
||||
allowed_wallet=None,
|
||||
expires_at="2099-01-01T00:00:00+00:00",
|
||||
tx_hash="0x" + "2" * 64,
|
||||
metadata={},
|
||||
)
|
||||
|
||||
def _fake_rest(method, table, **kwargs):
|
||||
calls.append({"method": method, "table": table, **kwargs})
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(service, "_rest", _fake_rest)
|
||||
|
||||
result = service._record_duplicate_transaction(
|
||||
intent=intent,
|
||||
tx_hash="0x" + "3" * 64,
|
||||
from_address="0x2222222222222222222222222222222222222222",
|
||||
status="refund_required",
|
||||
)
|
||||
|
||||
assert result == {}
|
||||
assert calls[0]["table"] == "payment_transactions"
|
||||
assert calls[0]["prefer"] == "resolution=merge-duplicates,return=minimal"
|
||||
|
||||
|
||||
def test_payment_record_upsert_uses_minimal_return(monkeypatch, tmp_path):
|
||||
_payment_env(monkeypatch, tmp_path)
|
||||
service = PaymentContractCheckoutService()
|
||||
calls = []
|
||||
|
||||
def _fake_rest(method, table, **kwargs):
|
||||
calls.append({"method": method, "table": table, **kwargs})
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(service, "_rest", _fake_rest)
|
||||
|
||||
result = service._insert_payment_record(
|
||||
user_id="user-1",
|
||||
tx_hash="0x" + "4" * 64,
|
||||
amount_units=5_000_000,
|
||||
token_address="0x2791bca1f2de4661ed88a30c99a7a9449aa84174",
|
||||
chain_id=137,
|
||||
payload={"kind": "test"},
|
||||
)
|
||||
|
||||
assert calls[0]["table"] == "payments"
|
||||
assert calls[0]["prefer"] == "resolution=merge-duplicates,return=minimal"
|
||||
assert result["tx_hash"] == "0x" + "4" * 64
|
||||
assert result["status"] == "confirmed"
|
||||
assert result["amount"] == "5"
|
||||
|
||||
|
||||
def test_create_intent_insert_uses_minimal_return_and_local_payload(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
_payment_env(monkeypatch, tmp_path)
|
||||
service = PaymentContractCheckoutService()
|
||||
calls = []
|
||||
|
||||
def _fake_rest(method, table, **kwargs):
|
||||
calls.append({"method": method, "table": table, **kwargs})
|
||||
if method == "POST" and table == "payment_intents":
|
||||
return []
|
||||
raise AssertionError((method, table, kwargs))
|
||||
|
||||
monkeypatch.setattr(service, "_rest", _fake_rest)
|
||||
|
||||
result = service.create_intent(
|
||||
"00000000-0000-0000-0000-000000000001",
|
||||
"pro_monthly",
|
||||
payment_mode="direct",
|
||||
)
|
||||
|
||||
intent_write = calls[0]
|
||||
payload = intent_write["payload"]
|
||||
assert intent_write["prefer"] == "return=minimal"
|
||||
assert payload["id"] == result["intent"]["intent_id"]
|
||||
assert payload["order_id_hex"] == result["intent"]["order_id_hex"]
|
||||
assert result["intent"]["status"] == "created"
|
||||
assert result["direct_payment"]["amount_units"] == str(payload["amount_units"])
|
||||
|
||||
|
||||
def test_payment_runtime_state_and_audit_event_roundtrip(tmp_path):
|
||||
db_path = tmp_path / "payments.db"
|
||||
db = DBManager(str(db_path))
|
||||
@@ -246,6 +757,121 @@ def test_reconcile_latest_intent_confirms_submitted_first(monkeypatch, tmp_path)
|
||||
assert result["action"] == "confirmed_submitted_intent"
|
||||
|
||||
|
||||
def test_reconcile_latest_intent_reuses_confirmed_row_after_repair(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("POLYWEATHER_PAYMENT_ENABLED", "true")
|
||||
monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co")
|
||||
monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role")
|
||||
monkeypatch.setenv("POLYWEATHER_PAYMENT_RPC_URL", "https://rpc-1.example")
|
||||
monkeypatch.setenv(
|
||||
"POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON",
|
||||
'[{"code":"usdc_e","address":"0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174","decimals":6,"receiver_contract":"0xeD2f13Aa5fF033c58FB436E178451Cd07f693f32","is_default":true}]',
|
||||
)
|
||||
monkeypatch.setenv("POLYWEATHER_DB_PATH", str(tmp_path / "payments.db"))
|
||||
|
||||
service = PaymentContractCheckoutService()
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"_rest",
|
||||
lambda method, table, **kwargs: [
|
||||
{
|
||||
"id": "intent-1",
|
||||
"plan_code": "pro_monthly",
|
||||
"plan_id": 101,
|
||||
"chain_id": 137,
|
||||
"token_address": "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174",
|
||||
"receiver_address": "0xeD2f13Aa5fF033c58FB436E178451Cd07f693f32",
|
||||
"amount_units": "5000000",
|
||||
"payment_mode": "strict",
|
||||
"allowed_wallet": "0x1111111111111111111111111111111111111111",
|
||||
"order_id_hex": "0x" + "1" * 64,
|
||||
"status": "confirmed",
|
||||
"expires_at": "2099-01-01T00:00:00+00:00",
|
||||
"tx_hash": "0x" + "2" * 64,
|
||||
"metadata": {},
|
||||
}
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"get_intent",
|
||||
lambda user_id, intent_id: (_ for _ in ()).throw(
|
||||
AssertionError("confirmed repair should reuse loaded row")
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"_ensure_confirm_side_effects",
|
||||
lambda user_id, local_intent, tx_hash: {
|
||||
"payment": {"tx_hash": tx_hash},
|
||||
"subscription": {"plan_code": local_intent.plan_code},
|
||||
},
|
||||
)
|
||||
|
||||
result = service.reconcile_latest_intent("user-1")
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["action"] == "reconciled_confirmed_intent"
|
||||
assert result["intent"]["intent_id"] == "intent-1"
|
||||
|
||||
|
||||
def test_reconcile_latest_intent_without_candidates_does_not_clear_subscription_cache(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
monkeypatch.setenv("POLYWEATHER_PAYMENT_ENABLED", "true")
|
||||
monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co")
|
||||
monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role")
|
||||
monkeypatch.setenv("POLYWEATHER_PAYMENT_RPC_URL", "https://rpc-1.example")
|
||||
monkeypatch.setenv(
|
||||
"POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON",
|
||||
'[{"code":"usdc_e","address":"0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174","decimals":6,"receiver_contract":"0xeD2f13Aa5fF033c58FB436E178451Cd07f693f32","is_default":true}]',
|
||||
)
|
||||
monkeypatch.setenv("POLYWEATHER_DB_PATH", str(tmp_path / "payments.db"))
|
||||
|
||||
service = PaymentContractCheckoutService()
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"_rest",
|
||||
lambda method, table, **kwargs: [
|
||||
{
|
||||
"id": "intent-created",
|
||||
"plan_code": "pro_monthly",
|
||||
"plan_id": 101,
|
||||
"chain_id": 137,
|
||||
"token_address": "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174",
|
||||
"receiver_address": "0xeD2f13Aa5fF033c58FB436E178451Cd07f693f32",
|
||||
"amount_units": "5000000",
|
||||
"payment_mode": "strict",
|
||||
"allowed_wallet": "0x1111111111111111111111111111111111111111",
|
||||
"order_id_hex": "0x" + "1" * 64,
|
||||
"status": "created",
|
||||
"expires_at": "2099-01-01T00:00:00+00:00",
|
||||
"tx_hash": None,
|
||||
"metadata": {},
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
invalidations = []
|
||||
monkeypatch.setattr(
|
||||
"src.payments.contract_checkout.SUPABASE_ENTITLEMENT.invalidate_subscription_cache",
|
||||
lambda user_id: invalidations.append(user_id),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.payments.contract_checkout.SUPABASE_ENTITLEMENT.get_latest_active_subscription",
|
||||
lambda user_id, respect_requirement=False: {
|
||||
"plan_code": "pro_monthly",
|
||||
"expires_at": "2099-01-01T00:00:00+00:00",
|
||||
},
|
||||
)
|
||||
|
||||
result = service.reconcile_latest_intent("user-1")
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["action"] == "checked_without_repair"
|
||||
assert invalidations == []
|
||||
|
||||
|
||||
def test_confirm_intent_tx_repairs_side_effect_failure(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("POLYWEATHER_PAYMENT_ENABLED", "true")
|
||||
monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co")
|
||||
@@ -386,6 +1012,117 @@ def test_reconcile_recent_intents_dedupes_users(monkeypatch, tmp_path):
|
||||
assert seen == ["user-1", "user-2"]
|
||||
|
||||
|
||||
def test_reconcile_recent_intents_selects_only_user_ids(monkeypatch, tmp_path):
|
||||
_payment_env(monkeypatch, tmp_path)
|
||||
service = PaymentContractCheckoutService()
|
||||
calls = []
|
||||
|
||||
def _fake_rest(method, table, **kwargs):
|
||||
calls.append({"method": method, "table": table, **kwargs})
|
||||
return [{"user_id": "user-1"}, {"user_id": "user-1"}, {"user_id": "user-2"}]
|
||||
|
||||
seen = []
|
||||
monkeypatch.setattr(service, "_rest", _fake_rest)
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"reconcile_latest_intent",
|
||||
lambda user_id: seen.append(user_id) or {"ok": True, "subscription": {"user_id": user_id}},
|
||||
)
|
||||
|
||||
result = service.reconcile_recent_intents(limit=10)
|
||||
|
||||
assert calls[0]["params"]["select"] == "user_id"
|
||||
assert seen == ["user-1", "user-2"]
|
||||
assert result["processed_users"] == 2
|
||||
|
||||
|
||||
def test_pending_confirm_intents_selects_only_confirm_loop_fields(monkeypatch, tmp_path):
|
||||
_payment_env(monkeypatch, tmp_path)
|
||||
service = PaymentContractCheckoutService()
|
||||
calls = []
|
||||
|
||||
def _fake_rest(method, table, **kwargs):
|
||||
calls.append({"method": method, "table": table, **kwargs})
|
||||
return [
|
||||
{
|
||||
"id": "intent-1",
|
||||
"user_id": "user-1",
|
||||
"chain_id": 137,
|
||||
"tx_hash": "0x" + "2" * 64,
|
||||
}
|
||||
]
|
||||
|
||||
monkeypatch.setattr(service, "_rest", _fake_rest)
|
||||
|
||||
result = service.list_pending_confirm_intents(limit=20)
|
||||
|
||||
assert result == [
|
||||
{
|
||||
"intent_id": "intent-1",
|
||||
"user_id": "user-1",
|
||||
"chain_id": 137,
|
||||
"tx_hash": "0x" + "2" * 64,
|
||||
}
|
||||
]
|
||||
assert calls[0]["params"]["select"] == "id,user_id,tx_hash,chain_id"
|
||||
|
||||
|
||||
def test_open_intents_by_order_id_selects_only_event_loop_fields(monkeypatch, tmp_path):
|
||||
_payment_env(monkeypatch, tmp_path)
|
||||
service = PaymentContractCheckoutService()
|
||||
calls = []
|
||||
|
||||
def _fake_rest(method, table, **kwargs):
|
||||
calls.append({"method": method, "table": table, **kwargs})
|
||||
return [
|
||||
{
|
||||
"id": "intent-1",
|
||||
"user_id": "user-1",
|
||||
"status": "submitted",
|
||||
"tx_hash": "0x" + "2" * 64,
|
||||
"plan_id": 101,
|
||||
"token_address": "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174",
|
||||
"amount_units": "5000000",
|
||||
}
|
||||
]
|
||||
|
||||
monkeypatch.setattr(service, "_rest", _fake_rest)
|
||||
|
||||
result = service.list_open_intents_by_order_id("0x" + "1" * 64)
|
||||
|
||||
assert result == [
|
||||
{
|
||||
"intent_id": "intent-1",
|
||||
"user_id": "user-1",
|
||||
"status": "submitted",
|
||||
"tx_hash": "0x" + "2" * 64,
|
||||
"plan_id": 101,
|
||||
"token_address": "0x2791bca1f2de4661ed88a30c99a7a9449aa84174",
|
||||
"amount_units": 5000000,
|
||||
}
|
||||
]
|
||||
assert (
|
||||
calls[0]["params"]["select"]
|
||||
== "id,user_id,status,tx_hash,plan_id,token_address,amount_units"
|
||||
)
|
||||
|
||||
|
||||
def test_tx_hash_unused_check_selects_only_intent_id(monkeypatch, tmp_path):
|
||||
_payment_env(monkeypatch, tmp_path)
|
||||
service = PaymentContractCheckoutService()
|
||||
calls = []
|
||||
|
||||
def _fake_rest(method, table, **kwargs):
|
||||
calls.append({"method": method, "table": table, **kwargs})
|
||||
return [{"intent_id": "intent-1"}]
|
||||
|
||||
monkeypatch.setattr(service, "_rest", _fake_rest)
|
||||
|
||||
service._ensure_tx_hash_unused("0x" + "1" * 64, "intent-1")
|
||||
|
||||
assert calls[0]["params"]["select"] == "intent_id"
|
||||
|
||||
|
||||
def test_grant_subscription_starts_after_trial_when_only_trial_is_active(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("POLYWEATHER_PAYMENT_ENABLED", "true")
|
||||
monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co")
|
||||
@@ -403,12 +1140,9 @@ def test_grant_subscription_starts_after_trial_when_only_trial_is_active(monkeyp
|
||||
|
||||
def _fake_rest(method, table, **kwargs):
|
||||
if method == "GET" and table == "subscriptions":
|
||||
assert kwargs["params"]["select"] == "starts_at,expires_at"
|
||||
return [
|
||||
{
|
||||
"id": 1,
|
||||
"status": "active",
|
||||
"plan_code": "signup_trial_3d",
|
||||
"source": "signup_trial",
|
||||
"starts_at": (datetime.now(timezone.utc) - timedelta(days=1)).isoformat(),
|
||||
"expires_at": trial_end.isoformat(),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _load_script():
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
path = root / "scripts" / "reconcile_payment_tx.py"
|
||||
spec = importlib.util.spec_from_file_location("reconcile_payment_tx", path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec and spec.loader
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_find_intent_by_tx_queries_tx_hash_directly(monkeypatch):
|
||||
module = _load_script()
|
||||
tx_hash = "0x" + "a" * 64
|
||||
calls = []
|
||||
|
||||
class FakeCheckout:
|
||||
def _rest(self, method, table, *, params, allowed_status):
|
||||
calls.append(
|
||||
{
|
||||
"method": method,
|
||||
"table": table,
|
||||
"params": params,
|
||||
"allowed_status": allowed_status,
|
||||
}
|
||||
)
|
||||
return [{"id": "intent-1", "user_id": "user-1", "tx_hash": tx_hash}]
|
||||
|
||||
monkeypatch.setattr(module, "PAYMENT_CHECKOUT", FakeCheckout())
|
||||
|
||||
result = module._find_intent_by_tx("user-1", tx_hash.upper())
|
||||
|
||||
assert result["id"] == "intent-1"
|
||||
assert calls == [
|
||||
{
|
||||
"method": "GET",
|
||||
"table": "payment_intents",
|
||||
"params": {
|
||||
"select": "id,user_id",
|
||||
"tx_hash": f"eq.{tx_hash}",
|
||||
"limit": "1",
|
||||
},
|
||||
"allowed_status": [200],
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_find_intent_by_tx_rejects_other_user(monkeypatch):
|
||||
module = _load_script()
|
||||
|
||||
class FakeCheckout:
|
||||
def _rest(self, method, table, *, params, allowed_status):
|
||||
return [{"id": "intent-other", "user_id": "user-2"}]
|
||||
|
||||
monkeypatch.setattr(module, "PAYMENT_CHECKOUT", FakeCheckout())
|
||||
|
||||
assert module._find_intent_by_tx("user-1", "0x" + "a" * 64) is None
|
||||
|
||||
|
||||
def test_find_intent_by_order_id_uses_unique_order_lookup(monkeypatch):
|
||||
module = _load_script()
|
||||
calls = []
|
||||
|
||||
class FakeCheckout:
|
||||
def _rest(self, method, table, *, params, allowed_status):
|
||||
calls.append(
|
||||
{
|
||||
"method": method,
|
||||
"table": table,
|
||||
"params": params,
|
||||
"allowed_status": allowed_status,
|
||||
}
|
||||
)
|
||||
return [
|
||||
{
|
||||
"id": "intent-1",
|
||||
"user_id": "user-1",
|
||||
"order_id_hex": "0x" + "b" * 64,
|
||||
}
|
||||
]
|
||||
|
||||
monkeypatch.setattr(module, "PAYMENT_CHECKOUT", FakeCheckout())
|
||||
|
||||
result = module._find_intent_by_order_id("user-1", "0X" + "B" * 64)
|
||||
|
||||
assert result["id"] == "intent-1"
|
||||
assert calls == [
|
||||
{
|
||||
"method": "GET",
|
||||
"table": "payment_intents",
|
||||
"params": {
|
||||
"select": "id,user_id",
|
||||
"order_id_hex": "eq.0x" + "b" * 64,
|
||||
"limit": "1",
|
||||
},
|
||||
"allowed_status": [200],
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_find_intent_by_order_id_rejects_other_user(monkeypatch):
|
||||
module = _load_script()
|
||||
|
||||
class FakeCheckout:
|
||||
def _rest(self, method, table, *, params, allowed_status):
|
||||
return [
|
||||
{
|
||||
"id": "intent-other",
|
||||
"user_id": "user-2",
|
||||
"order_id_hex": "0x" + "b" * 64,
|
||||
}
|
||||
]
|
||||
|
||||
monkeypatch.setattr(module, "PAYMENT_CHECKOUT", FakeCheckout())
|
||||
|
||||
assert module._find_intent_by_order_id("user-1", "0x" + "b" * 64) is None
|
||||
@@ -0,0 +1,104 @@
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _load_script(name: str):
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
path = root / "scripts" / f"{name}.py"
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec and spec.loader
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
grant_script = _load_script("grant_subscription_by_email")
|
||||
reconcile_script = _load_script("reconcile_subscription_by_email")
|
||||
|
||||
|
||||
class _Checkout:
|
||||
supabase_url = "https://example.supabase.co"
|
||||
supabase_service_role_key = "service-role"
|
||||
|
||||
def __init__(self):
|
||||
self.rest_calls = []
|
||||
self.admin_calls = []
|
||||
|
||||
def _rest(self, method, table, params=None, **kwargs):
|
||||
self.rest_calls.append((method, table, params))
|
||||
assert table == "profiles"
|
||||
return [{"id": "user-1", "email": "user@example.com"}]
|
||||
|
||||
def _auth_admin_request(self, *args, **kwargs):
|
||||
self.admin_calls.append((args, kwargs))
|
||||
raise AssertionError("profile-backed lookup should avoid Auth Admin")
|
||||
|
||||
|
||||
def test_grant_script_email_lookup_prefers_profiles(monkeypatch):
|
||||
checkout = _Checkout()
|
||||
monkeypatch.setattr("src.payments.contract_checkout.PAYMENT_CHECKOUT", checkout)
|
||||
|
||||
assert grant_script._lookup_user_id_by_email("user@example.com") == "user-1"
|
||||
assert checkout.rest_calls == [
|
||||
(
|
||||
"GET",
|
||||
"profiles",
|
||||
{
|
||||
"select": "id",
|
||||
"email": "eq.user@example.com",
|
||||
"limit": "1",
|
||||
},
|
||||
)
|
||||
]
|
||||
assert checkout.admin_calls == []
|
||||
|
||||
|
||||
def test_reconcile_script_email_lookup_prefers_profiles(monkeypatch):
|
||||
checkout = _Checkout()
|
||||
monkeypatch.setattr("src.payments.contract_checkout.PAYMENT_CHECKOUT", checkout)
|
||||
|
||||
assert reconcile_script._lookup_user_id_by_email("user@example.com") == "user-1"
|
||||
assert checkout.rest_calls == [
|
||||
(
|
||||
"GET",
|
||||
"profiles",
|
||||
{
|
||||
"select": "id",
|
||||
"email": "eq.user@example.com",
|
||||
"limit": "1",
|
||||
},
|
||||
)
|
||||
]
|
||||
assert checkout.admin_calls == []
|
||||
|
||||
|
||||
def test_grant_script_entitlement_event_uses_minimal_return():
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
source = (root / "scripts" / "grant_subscription_by_email.py").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
entitlement_event_pos = source.index('"entitlement_events"')
|
||||
minimal_pos = source.index('prefer="return=minimal"', entitlement_event_pos)
|
||||
assert minimal_pos > entitlement_event_pos
|
||||
|
||||
|
||||
def test_grant_script_subscription_writes_use_minimal_return():
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
source = (root / "scripts" / "grant_subscription_by_email.py").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
subscription_write_count = source.count('prefer="return=minimal"')
|
||||
assert subscription_write_count >= 3
|
||||
assert 'prefer="return=representation"' not in source
|
||||
|
||||
|
||||
def test_grant_script_subscription_lookup_selects_only_grant_fields():
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
source = (root / "scripts" / "grant_subscription_by_email.py").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
assert '"select": "id,plan_code,source,starts_at,expires_at"' in source
|
||||
assert '"select": "id,expires_at,status,plan_code,starts_at,source,created_at"' not in source
|
||||
@@ -30,17 +30,11 @@ def test_latest_active_subscription_ignores_future_start(monkeypatch):
|
||||
"starts_at": (now - timedelta(days=1)).isoformat(),
|
||||
"expires_at": (now + timedelta(days=2)).isoformat(),
|
||||
}
|
||||
future_paid = {
|
||||
"id": 2,
|
||||
"user_id": "user-1",
|
||||
"status": "active",
|
||||
"plan_code": "pro_monthly",
|
||||
"starts_at": (now + timedelta(days=2)).isoformat(),
|
||||
"expires_at": (now + timedelta(days=32)).isoformat(),
|
||||
}
|
||||
|
||||
def _fake_get(url, headers=None, params=None, timeout=None):
|
||||
return _Response(200, [future_paid, current_trial])
|
||||
assert params["select"] == "plan_code,source,starts_at,expires_at"
|
||||
assert str(params["starts_at"]).startswith("lte.")
|
||||
assert params["limit"] == "1"
|
||||
return _Response(200, [current_trial])
|
||||
|
||||
monkeypatch.setattr(entitlement_module.requests, "get", _fake_get)
|
||||
|
||||
@@ -50,6 +44,44 @@ def test_latest_active_subscription_ignores_future_start(monkeypatch):
|
||||
assert result["plan_code"] == "signup_trial_3d"
|
||||
|
||||
|
||||
def test_get_identity_caches_invalid_token_result(monkeypatch):
|
||||
monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co")
|
||||
monkeypatch.setenv("SUPABASE_ANON_KEY", "anon-key")
|
||||
monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role")
|
||||
|
||||
service = SupabaseEntitlementService()
|
||||
calls = {"count": 0}
|
||||
|
||||
def _fake_get(url, headers=None, params=None, timeout=None):
|
||||
calls["count"] += 1
|
||||
return _Response(401, {"message": "invalid token"})
|
||||
|
||||
monkeypatch.setattr(entitlement_module.requests, "get", _fake_get)
|
||||
|
||||
assert service.get_identity("bad-token") is None
|
||||
assert service.get_identity("bad-token") is None
|
||||
assert calls["count"] == 1
|
||||
|
||||
|
||||
def test_get_identity_does_not_cache_transient_auth_errors(monkeypatch):
|
||||
monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co")
|
||||
monkeypatch.setenv("SUPABASE_ANON_KEY", "anon-key")
|
||||
monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role")
|
||||
|
||||
service = SupabaseEntitlementService()
|
||||
calls = {"count": 0}
|
||||
|
||||
def _fake_get(url, headers=None, params=None, timeout=None):
|
||||
calls["count"] += 1
|
||||
return _Response(503, {"message": "temporarily unavailable"})
|
||||
|
||||
monkeypatch.setattr(entitlement_module.requests, "get", _fake_get)
|
||||
|
||||
assert service.get_identity("temporarily-bad-token") is None
|
||||
assert service.get_identity("temporarily-bad-token") is None
|
||||
assert calls["count"] == 2
|
||||
|
||||
|
||||
def test_subscription_window_keeps_queued_renewal_after_current_cache(monkeypatch):
|
||||
monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co")
|
||||
monkeypatch.setenv("SUPABASE_ANON_KEY", "anon-key")
|
||||
@@ -75,7 +107,16 @@ def test_subscription_window_keeps_queued_renewal_after_current_cache(monkeypatc
|
||||
"expires_at": (now + timedelta(days=31)).isoformat(),
|
||||
}
|
||||
|
||||
calls = []
|
||||
|
||||
def _fake_get(url, headers=None, params=None, timeout=None):
|
||||
calls.append(params)
|
||||
assert params["select"] == "plan_code,source,starts_at,expires_at"
|
||||
if params["limit"] == "1":
|
||||
assert str(params["starts_at"]).startswith("lte.")
|
||||
return _Response(200, [current])
|
||||
assert params["limit"] == "100"
|
||||
assert "starts_at" not in params
|
||||
return _Response(200, [queued, current])
|
||||
|
||||
monkeypatch.setattr(entitlement_module.requests, "get", _fake_get)
|
||||
@@ -88,3 +129,412 @@ def test_subscription_window_keeps_queued_renewal_after_current_cache(monkeypatc
|
||||
assert window["total_expires_at"] == queued["expires_at"]
|
||||
assert window["queued_days"] == 30
|
||||
assert window["queued_count"] == 1
|
||||
assert len(calls) == 2
|
||||
|
||||
|
||||
def test_subscription_window_query_selects_only_window_fields(monkeypatch):
|
||||
monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co")
|
||||
monkeypatch.setenv("SUPABASE_ANON_KEY", "anon-key")
|
||||
monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role")
|
||||
|
||||
service = SupabaseEntitlementService()
|
||||
|
||||
def _fake_get(url, headers=None, params=None, timeout=None):
|
||||
assert params["select"] == "plan_code,source,starts_at,expires_at"
|
||||
return _Response(
|
||||
200,
|
||||
[
|
||||
{
|
||||
"plan_code": "pro_monthly",
|
||||
"source": "payment_contract",
|
||||
"starts_at": "2026-03-01T00:00:00+00:00",
|
||||
"expires_at": "2099-04-01T00:00:00+00:00",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
monkeypatch.setattr(entitlement_module.requests, "get", _fake_get)
|
||||
|
||||
window = service.get_subscription_window(
|
||||
"user-1",
|
||||
respect_requirement=False,
|
||||
bypass_cache=True,
|
||||
)
|
||||
|
||||
assert window["current"]["plan_code"] == "pro_monthly"
|
||||
|
||||
|
||||
def test_list_subscription_windows_selects_only_batch_window_fields(monkeypatch):
|
||||
monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co")
|
||||
monkeypatch.setenv("SUPABASE_ANON_KEY", "anon-key")
|
||||
monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role")
|
||||
|
||||
service = SupabaseEntitlementService()
|
||||
|
||||
def _fake_get(url, headers=None, params=None, timeout=None):
|
||||
assert params["select"] == "user_id,plan_code,source,starts_at,expires_at"
|
||||
assert params["user_id"] == "in.(user-1,user-2)"
|
||||
return _Response(
|
||||
200,
|
||||
[
|
||||
{
|
||||
"user_id": "user-1",
|
||||
"plan_code": "pro_monthly",
|
||||
"source": "payment_contract",
|
||||
"starts_at": "2026-03-01T00:00:00+00:00",
|
||||
"expires_at": "2099-04-01T00:00:00+00:00",
|
||||
},
|
||||
{
|
||||
"user_id": "user-2",
|
||||
"plan_code": "pro_monthly",
|
||||
"source": "payment_contract",
|
||||
"starts_at": "2026-03-02T00:00:00+00:00",
|
||||
"expires_at": "2099-04-02T00:00:00+00:00",
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
monkeypatch.setattr(entitlement_module.requests, "get", _fake_get)
|
||||
|
||||
windows = service.list_subscription_windows(
|
||||
["user-1", "user-2"],
|
||||
bypass_cache=True,
|
||||
)
|
||||
|
||||
assert set(windows) == {"user-1", "user-2"}
|
||||
|
||||
|
||||
def test_list_active_subscription_windows_uses_single_window_query(monkeypatch):
|
||||
monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co")
|
||||
monkeypatch.setenv("SUPABASE_ANON_KEY", "anon-key")
|
||||
monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role")
|
||||
|
||||
service = SupabaseEntitlementService()
|
||||
calls = []
|
||||
now = datetime.now(timezone.utc)
|
||||
current = {
|
||||
"user_id": "user-1",
|
||||
"plan_code": "pro_monthly",
|
||||
"source": "payment_contract",
|
||||
"starts_at": (now - timedelta(days=1)).isoformat(),
|
||||
"expires_at": (now + timedelta(days=10)).isoformat(),
|
||||
}
|
||||
queued = {
|
||||
"user_id": "user-1",
|
||||
"plan_code": "pro_monthly",
|
||||
"source": "payment_contract",
|
||||
"starts_at": (now + timedelta(days=10)).isoformat(),
|
||||
"expires_at": (now + timedelta(days=40)).isoformat(),
|
||||
}
|
||||
|
||||
def _fake_get(url, headers=None, params=None, timeout=None):
|
||||
calls.append(params)
|
||||
assert params["select"] == "user_id,plan_code,source,starts_at,expires_at"
|
||||
assert params["status"] == "eq.active"
|
||||
assert params["order"] == "user_id.asc,expires_at.desc"
|
||||
return _Response(200, [queued, current])
|
||||
|
||||
monkeypatch.setattr(entitlement_module.requests, "get", _fake_get)
|
||||
|
||||
result = service.list_active_subscription_windows(limit=200)
|
||||
|
||||
assert result["subscriptions"] == [current]
|
||||
assert result["windows"]["user-1"]["queued_count"] == 1
|
||||
assert calls and len(calls) == 1
|
||||
|
||||
|
||||
def test_latest_subscription_any_status_uses_cache(monkeypatch):
|
||||
monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co")
|
||||
monkeypatch.setenv("SUPABASE_ANON_KEY", "anon-key")
|
||||
monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role")
|
||||
|
||||
service = SupabaseEntitlementService()
|
||||
calls = {"count": 0}
|
||||
latest = {
|
||||
"id": 3,
|
||||
"user_id": "user-1",
|
||||
"status": "expired",
|
||||
"plan_code": "pro_monthly",
|
||||
"starts_at": "2026-03-01T00:00:00+00:00",
|
||||
"expires_at": "2026-04-01T00:00:00+00:00",
|
||||
"created_at": "2026-03-01T00:00:00+00:00",
|
||||
"updated_at": "2026-04-01T00:00:00+00:00",
|
||||
}
|
||||
|
||||
def _fake_get(url, headers=None, params=None, timeout=None):
|
||||
calls["count"] += 1
|
||||
assert params["user_id"] == "eq.user-1"
|
||||
assert params["order"] == "created_at.desc"
|
||||
assert params["select"] == "plan_code,starts_at,expires_at"
|
||||
return _Response(200, [latest])
|
||||
|
||||
monkeypatch.setattr(entitlement_module.requests, "get", _fake_get)
|
||||
|
||||
assert service.get_latest_subscription_any_status("user-1") == latest
|
||||
assert service.get_latest_subscription_any_status("user-1") == latest
|
||||
assert calls["count"] == 1
|
||||
|
||||
|
||||
def test_get_auth_users_batches_profiles_before_admin_fallback(monkeypatch):
|
||||
monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co")
|
||||
monkeypatch.setenv("SUPABASE_ANON_KEY", "anon-key")
|
||||
monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role")
|
||||
|
||||
service = SupabaseEntitlementService()
|
||||
calls = []
|
||||
|
||||
def _fake_get(url, headers=None, params=None, timeout=None):
|
||||
calls.append((url, params))
|
||||
if url.endswith("/rest/v1/profiles"):
|
||||
assert params["id"] == "in.(user-1,user-2)"
|
||||
return _Response(
|
||||
200,
|
||||
[
|
||||
{
|
||||
"id": "user-1",
|
||||
"email": "one@example.com",
|
||||
"created_at": "2026-03-01T00:00:00+00:00",
|
||||
},
|
||||
{
|
||||
"id": "user-2",
|
||||
"email": "two@example.com",
|
||||
"created_at": "2026-03-02T00:00:00+00:00",
|
||||
},
|
||||
],
|
||||
)
|
||||
raise AssertionError(f"unexpected admin fallback call: {url}")
|
||||
|
||||
monkeypatch.setattr(entitlement_module.requests, "get", _fake_get)
|
||||
|
||||
result = service.get_auth_users(["user-1", "user-2"])
|
||||
|
||||
assert result == {
|
||||
"user-1": {
|
||||
"email": "one@example.com",
|
||||
"created_at": "2026-03-01T00:00:00+00:00",
|
||||
},
|
||||
"user-2": {
|
||||
"email": "two@example.com",
|
||||
"created_at": "2026-03-02T00:00:00+00:00",
|
||||
},
|
||||
}
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
def test_get_auth_users_uses_short_cache_for_profile_results(monkeypatch):
|
||||
monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co")
|
||||
monkeypatch.setenv("SUPABASE_ANON_KEY", "anon-key")
|
||||
monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role")
|
||||
|
||||
service = SupabaseEntitlementService()
|
||||
calls = {"count": 0}
|
||||
|
||||
def _fake_get(url, headers=None, params=None, timeout=None):
|
||||
calls["count"] += 1
|
||||
assert url.endswith("/rest/v1/profiles")
|
||||
return _Response(
|
||||
200,
|
||||
[
|
||||
{
|
||||
"id": "user-1",
|
||||
"email": "one@example.com",
|
||||
"created_at": "2026-03-01T00:00:00+00:00",
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
monkeypatch.setattr(entitlement_module.requests, "get", _fake_get)
|
||||
|
||||
assert service.get_auth_users(["user-1"]) == {
|
||||
"user-1": {
|
||||
"email": "one@example.com",
|
||||
"created_at": "2026-03-01T00:00:00+00:00",
|
||||
},
|
||||
}
|
||||
assert service.get_auth_users(["user-1"]) == {
|
||||
"user-1": {
|
||||
"email": "one@example.com",
|
||||
"created_at": "2026-03-01T00:00:00+00:00",
|
||||
},
|
||||
}
|
||||
assert calls["count"] == 1
|
||||
|
||||
|
||||
def test_list_active_subscriptions_uses_cache_and_invalidation(monkeypatch):
|
||||
monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co")
|
||||
monkeypatch.setenv("SUPABASE_ANON_KEY", "anon-key")
|
||||
monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role")
|
||||
|
||||
service = SupabaseEntitlementService()
|
||||
calls = {"count": 0}
|
||||
row = {
|
||||
"id": 1,
|
||||
"user_id": "user-1",
|
||||
"status": "active",
|
||||
"plan_code": "pro_monthly",
|
||||
"starts_at": "2026-03-01T00:00:00+00:00",
|
||||
"expires_at": "2099-04-01T00:00:00+00:00",
|
||||
}
|
||||
|
||||
def _fake_get(url, headers=None, params=None, timeout=None):
|
||||
calls["count"] += 1
|
||||
assert params["status"] == "eq.active"
|
||||
assert params["select"] == "user_id,plan_code,starts_at,expires_at"
|
||||
return _Response(200, [row])
|
||||
|
||||
monkeypatch.setattr(entitlement_module.requests, "get", _fake_get)
|
||||
|
||||
assert service.list_active_subscriptions(limit=200) == [row]
|
||||
assert service.list_active_subscriptions(limit=200) == [row]
|
||||
assert calls["count"] == 1
|
||||
|
||||
service.invalidate_subscription_cache("user-1")
|
||||
|
||||
assert service.list_active_subscriptions(limit=200) == [row]
|
||||
assert calls["count"] == 2
|
||||
|
||||
|
||||
def test_has_active_subscription_uses_lightweight_query_without_polluting_detail_cache(
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co")
|
||||
monkeypatch.setenv("SUPABASE_ANON_KEY", "anon-key")
|
||||
monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role")
|
||||
|
||||
service = SupabaseEntitlementService()
|
||||
calls = []
|
||||
|
||||
def _fake_get(url, headers=None, params=None, timeout=None):
|
||||
calls.append(params["select"])
|
||||
if params["select"] == "expires_at":
|
||||
assert str(params["starts_at"]).startswith("lte.")
|
||||
assert params["limit"] == "1"
|
||||
return _Response(
|
||||
200,
|
||||
[
|
||||
{
|
||||
"expires_at": "2099-04-01T00:00:00+00:00",
|
||||
}
|
||||
],
|
||||
)
|
||||
if params["select"] == "plan_code,source,starts_at,expires_at":
|
||||
return _Response(
|
||||
200,
|
||||
[
|
||||
{
|
||||
"plan_code": "pro_monthly",
|
||||
"source": "payment_contract",
|
||||
"starts_at": "2026-03-01T00:00:00+00:00",
|
||||
"expires_at": "2099-04-01T00:00:00+00:00",
|
||||
}
|
||||
],
|
||||
)
|
||||
raise AssertionError(params["select"])
|
||||
|
||||
monkeypatch.setattr(entitlement_module.requests, "get", _fake_get)
|
||||
|
||||
assert service.has_active_subscription("user-1", respect_requirement=False) is True
|
||||
assert service.has_active_subscription("user-1", respect_requirement=False) is True
|
||||
assert service.get_latest_active_subscription(
|
||||
"user-1",
|
||||
respect_requirement=False,
|
||||
)["plan_code"] == "pro_monthly"
|
||||
|
||||
assert calls == [
|
||||
"expires_at",
|
||||
"plan_code,source,starts_at,expires_at",
|
||||
]
|
||||
|
||||
|
||||
def test_has_active_subscription_lightweight_cache_invalidates(monkeypatch):
|
||||
monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co")
|
||||
monkeypatch.setenv("SUPABASE_ANON_KEY", "anon-key")
|
||||
monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role")
|
||||
|
||||
service = SupabaseEntitlementService()
|
||||
calls = {"count": 0}
|
||||
|
||||
def _fake_get(url, headers=None, params=None, timeout=None):
|
||||
calls["count"] += 1
|
||||
assert params["select"] == "expires_at"
|
||||
assert str(params["starts_at"]).startswith("lte.")
|
||||
assert params["limit"] == "1"
|
||||
return _Response(
|
||||
200,
|
||||
[
|
||||
{
|
||||
"expires_at": "2099-04-01T00:00:00+00:00",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
monkeypatch.setattr(entitlement_module.requests, "get", _fake_get)
|
||||
|
||||
assert service.has_active_subscription("user-1", respect_requirement=False) is True
|
||||
assert service.has_active_subscription("user-1", respect_requirement=False) is True
|
||||
assert calls["count"] == 1
|
||||
|
||||
service.invalidate_subscription_cache("user-1")
|
||||
|
||||
assert service.has_active_subscription("user-1", respect_requirement=False) is True
|
||||
assert calls["count"] == 2
|
||||
|
||||
|
||||
def test_has_active_subscription_reuses_detailed_subscription_cache(monkeypatch):
|
||||
monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co")
|
||||
monkeypatch.setenv("SUPABASE_ANON_KEY", "anon-key")
|
||||
monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role")
|
||||
|
||||
service = SupabaseEntitlementService()
|
||||
calls = []
|
||||
|
||||
def _fake_get(url, headers=None, params=None, timeout=None):
|
||||
calls.append(params["select"])
|
||||
assert params["select"] == "plan_code,source,starts_at,expires_at"
|
||||
return _Response(
|
||||
200,
|
||||
[
|
||||
{
|
||||
"plan_code": "pro_monthly",
|
||||
"source": "payment_contract",
|
||||
"starts_at": "2026-03-01T00:00:00+00:00",
|
||||
"expires_at": "2099-04-01T00:00:00+00:00",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
monkeypatch.setattr(entitlement_module.requests, "get", _fake_get)
|
||||
|
||||
assert service.get_latest_active_subscription(
|
||||
"user-1",
|
||||
respect_requirement=False,
|
||||
)["plan_code"] == "pro_monthly"
|
||||
assert service.has_active_subscription("user-1", respect_requirement=False) is True
|
||||
|
||||
assert calls == ["plan_code,source,starts_at,expires_at"]
|
||||
|
||||
|
||||
def test_latest_active_subscription_reuses_negative_lightweight_cache(monkeypatch):
|
||||
monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co")
|
||||
monkeypatch.setenv("SUPABASE_ANON_KEY", "anon-key")
|
||||
monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role")
|
||||
|
||||
service = SupabaseEntitlementService()
|
||||
calls = []
|
||||
|
||||
def _fake_get(url, headers=None, params=None, timeout=None):
|
||||
calls.append(params["select"])
|
||||
assert params["select"] == "expires_at"
|
||||
assert str(params["starts_at"]).startswith("lte.")
|
||||
assert params["limit"] == "1"
|
||||
return _Response(200, [])
|
||||
|
||||
monkeypatch.setattr(entitlement_module.requests, "get", _fake_get)
|
||||
|
||||
assert service.has_active_subscription("user-1", respect_requirement=False) is False
|
||||
assert service.get_latest_active_subscription(
|
||||
"user-1",
|
||||
respect_requirement=False,
|
||||
) is None
|
||||
|
||||
assert calls == ["expires_at"]
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import src.database.db_manager as db_manager_module
|
||||
from src.database.db_manager import DBManager
|
||||
|
||||
|
||||
def _bound_db(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co")
|
||||
monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role")
|
||||
db = DBManager(str(tmp_path / "points-sync.db"))
|
||||
db.upsert_user(1001, "eraer")
|
||||
with db._get_connection() as conn: # noqa: SLF001
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE users
|
||||
SET supabase_user_id = ?, supabase_email = ?
|
||||
WHERE telegram_id = ?
|
||||
""",
|
||||
("supabase-user-1", "eraer@example.com", 1001),
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO supabase_bindings (supabase_user_id, telegram_id, supabase_email)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
("supabase-user-1", 1001, "eraer@example.com"),
|
||||
)
|
||||
conn.commit()
|
||||
return db
|
||||
|
||||
|
||||
def test_message_points_sync_to_supabase_metadata_is_throttled(tmp_path, monkeypatch):
|
||||
db = _bound_db(tmp_path, monkeypatch)
|
||||
calls = []
|
||||
now = {"value": 1000.0}
|
||||
|
||||
monkeypatch.setattr(
|
||||
db_manager_module,
|
||||
"time",
|
||||
SimpleNamespace(monotonic=lambda: now["value"]),
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
db_manager_module.requests,
|
||||
"patch",
|
||||
lambda *args, **kwargs: calls.append((args, kwargs))
|
||||
or SimpleNamespace(status_code=204, text="", content=b""),
|
||||
)
|
||||
|
||||
first = db.add_message_activity(
|
||||
1001,
|
||||
"第一条有效发言",
|
||||
cooldown_sec=0,
|
||||
daily_cap=1000,
|
||||
)
|
||||
now["value"] += 10.0
|
||||
second = db.add_message_activity(
|
||||
1001,
|
||||
"第二条有效发言",
|
||||
cooldown_sec=0,
|
||||
daily_cap=1000,
|
||||
)
|
||||
|
||||
assert first["awarded"] is True
|
||||
assert second["awarded"] is True
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
def test_manual_point_grant_forces_supabase_metadata_sync(tmp_path, monkeypatch):
|
||||
db = _bound_db(tmp_path, monkeypatch)
|
||||
calls = []
|
||||
now = {"value": 1000.0}
|
||||
|
||||
monkeypatch.setattr(
|
||||
db_manager_module,
|
||||
"time",
|
||||
SimpleNamespace(monotonic=lambda: now["value"]),
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
db_manager_module.requests,
|
||||
"patch",
|
||||
lambda *args, **kwargs: calls.append((args, kwargs))
|
||||
or SimpleNamespace(status_code=204, text="", content=b""),
|
||||
)
|
||||
|
||||
db.add_message_activity(1001, "第一条有效发言", cooldown_sec=0, daily_cap=1000)
|
||||
now["value"] += 10.0
|
||||
result = db.grant_points_by_supabase_email("eraer@example.com", 300)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert len(calls) == 2
|
||||
assert calls[-1][1]["json"]["user_metadata"]["points"] == result["points_after"]
|
||||
@@ -0,0 +1,83 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import src.database.db_manager as db_manager_module
|
||||
from src.database.db_manager import DBManager
|
||||
|
||||
|
||||
def _bound_db(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co")
|
||||
monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role")
|
||||
monkeypatch.setenv("POLYWEATHER_SUPABASE_PROFILE_SYNC_MIN_INTERVAL_SEC", "3600")
|
||||
DBManager._profile_sync_cache.clear() # noqa: SLF001
|
||||
db = DBManager(str(tmp_path / "profile-sync.db"))
|
||||
db.upsert_user(1001, "eraer")
|
||||
with db._get_connection() as conn: # noqa: SLF001
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE users
|
||||
SET supabase_user_id = ?, supabase_email = ?
|
||||
WHERE telegram_id = ?
|
||||
""",
|
||||
("supabase-user-1", "eraer@example.com", 1001),
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO supabase_bindings (supabase_user_id, telegram_id, supabase_email)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
("supabase-user-1", 1001, "eraer@example.com"),
|
||||
)
|
||||
conn.commit()
|
||||
return db
|
||||
|
||||
|
||||
def test_repeated_user_upsert_coalesces_supabase_profile_sync(tmp_path, monkeypatch):
|
||||
db = _bound_db(tmp_path, monkeypatch)
|
||||
calls = []
|
||||
now = {"value": 1000.0}
|
||||
|
||||
monkeypatch.setattr(
|
||||
db_manager_module,
|
||||
"time",
|
||||
SimpleNamespace(monotonic=lambda: now["value"]),
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
db_manager_module.requests,
|
||||
"patch",
|
||||
lambda *args, **kwargs: calls.append((args, kwargs))
|
||||
or SimpleNamespace(status_code=204, text="", content=b""),
|
||||
)
|
||||
|
||||
db.upsert_user(1001, "eraer")
|
||||
now["value"] += 10.0
|
||||
db.upsert_user(1001, "eraer")
|
||||
|
||||
assert len(calls) == 1
|
||||
assert calls[0][1]["headers"]["Prefer"] == "return=minimal"
|
||||
|
||||
|
||||
def test_changed_username_bypasses_supabase_profile_sync_coalescing(tmp_path, monkeypatch):
|
||||
db = _bound_db(tmp_path, monkeypatch)
|
||||
calls = []
|
||||
now = {"value": 1000.0}
|
||||
|
||||
monkeypatch.setattr(
|
||||
db_manager_module,
|
||||
"time",
|
||||
SimpleNamespace(monotonic=lambda: now["value"]),
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
db_manager_module.requests,
|
||||
"patch",
|
||||
lambda *args, **kwargs: calls.append((args, kwargs))
|
||||
or SimpleNamespace(status_code=204, text="", content=b""),
|
||||
)
|
||||
|
||||
db.upsert_user(1001, "eraer")
|
||||
now["value"] += 10.0
|
||||
db.upsert_user(1001, "new-name")
|
||||
|
||||
assert len(calls) == 2
|
||||
assert calls[-1][1]["json"]["telegram_username"] == "new-name"
|
||||
@@ -0,0 +1,137 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _schema_sql() -> str:
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
return (root / "scripts" / "supabase" / "schema.sql").read_text(encoding="utf-8").lower()
|
||||
|
||||
|
||||
def test_supabase_schema_has_io_friendly_indexes_for_hot_ops_queries():
|
||||
schema = _schema_sql()
|
||||
|
||||
assert (
|
||||
"create index if not exists idx_profiles_email\n"
|
||||
" on public.profiles(email)\n"
|
||||
" include (id)"
|
||||
) in schema
|
||||
assert (
|
||||
"create index if not exists idx_profiles_id_lookup\n"
|
||||
" on public.profiles(id)\n"
|
||||
" include (email, created_at)"
|
||||
) in schema
|
||||
assert "idx_subscriptions_status_expiry" in schema
|
||||
assert "on public.subscriptions(expires_at asc)" in schema
|
||||
assert "idx_subscriptions_user_status_expiry" in schema
|
||||
assert "on public.subscriptions(user_id, expires_at desc)" in schema
|
||||
assert "include (id, starts_at, plan_code, source)" in schema
|
||||
assert schema.count("where status = 'active'") >= 2
|
||||
assert "idx_subscriptions_user_created" in schema
|
||||
assert "on public.subscriptions(user_id, created_at desc)" in schema
|
||||
assert "include (id, status, plan_code, source, starts_at, expires_at, updated_at)" in schema
|
||||
assert "idx_payment_intents_status_updated" in schema
|
||||
assert "on public.payment_intents(status, updated_at desc)" in schema
|
||||
assert "include (user_id)" in schema
|
||||
assert "where status in ('submitted', 'confirmed')" in schema
|
||||
assert "idx_payment_intents_user_status_updated" in schema
|
||||
assert "on public.payment_intents(user_id, status, updated_at desc)" in schema
|
||||
assert "idx_payment_intents_user_status\n" not in schema
|
||||
assert "idx_payment_intents_submitted_tx_updated" in schema
|
||||
assert "include (id, user_id, tx_hash, chain_id)" in schema
|
||||
assert "where status = 'submitted' and tx_hash is not null" in schema
|
||||
assert "idx_payment_intents_user_created" in schema
|
||||
assert "on public.payment_intents(user_id, created_at desc)" in schema
|
||||
assert "idx_payment_intents_tx_hash" in schema
|
||||
assert "on public.payment_intents(tx_hash)" in schema
|
||||
assert "include (id, user_id)" in schema
|
||||
assert "where tx_hash is not null" in schema
|
||||
assert "idx_payments_created_at" in schema
|
||||
assert "on public.payments(created_at desc)" in schema
|
||||
assert "include (id, user_id, amount, currency, chain, tx_hash, status)" in schema
|
||||
assert "idx_user_wallets_user_chain" in schema
|
||||
assert "on public.user_wallets(user_id, chain_id, is_primary desc, verified_at desc)" in schema
|
||||
assert "include (id, address)" in schema
|
||||
assert (
|
||||
"create index if not exists idx_user_wallets_chain_address_owner\n"
|
||||
" on public.user_wallets(chain_id, address)\n"
|
||||
" include (user_id, status)"
|
||||
) in schema
|
||||
assert schema.count("where status = 'active'") >= 3
|
||||
assert "idx_wallet_link_challenges_lookup" not in schema
|
||||
assert (
|
||||
"create index if not exists idx_payment_transactions_tx_hash_intent\n"
|
||||
" on public.payment_transactions(tx_hash)\n"
|
||||
" include (intent_id)"
|
||||
) in schema
|
||||
|
||||
|
||||
def test_supabase_io_budget_scripts_are_production_runnable():
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
indexes = (root / "scripts" / "supabase" / "io_budget_indexes.sql").read_text(encoding="utf-8").lower()
|
||||
diagnostics = (root / "scripts" / "supabase" / "disk_io_diagnostics.sql").read_text(encoding="utf-8").lower()
|
||||
|
||||
assert "drop index if exists public.idx_profiles_email" in indexes
|
||||
assert (
|
||||
"create index if not exists idx_profiles_email\n"
|
||||
" on public.profiles(email)\n"
|
||||
" include (id)"
|
||||
) in indexes
|
||||
assert "drop index if exists public.idx_profiles_id_lookup" in indexes
|
||||
assert (
|
||||
"create index if not exists idx_profiles_id_lookup\n"
|
||||
" on public.profiles(id)\n"
|
||||
" include (email, created_at)"
|
||||
) in indexes
|
||||
assert "idx_subscriptions_user_created" in indexes
|
||||
assert "drop index if exists public.idx_subscriptions_user_status_expiry" in indexes
|
||||
assert "drop index if exists public.idx_subscriptions_status_expiry" in indexes
|
||||
assert "on public.subscriptions(user_id, expires_at desc)" in indexes
|
||||
assert "on public.subscriptions(expires_at asc)" in indexes
|
||||
assert "include (id, starts_at, plan_code, source)" in indexes
|
||||
assert "include (user_id, starts_at, plan_code)" in indexes
|
||||
assert "include (id, status, plan_code, source, starts_at, expires_at, updated_at)" in indexes
|
||||
assert "drop index if exists public.idx_payment_intents_user_status" in indexes
|
||||
assert "drop index if exists public.idx_payment_intents_status_updated" in indexes
|
||||
assert "include (user_id)" in indexes
|
||||
assert "where status in ('submitted', 'confirmed')" in indexes
|
||||
assert "drop index if exists public.idx_payment_intents_submitted_tx_updated" in indexes
|
||||
assert "include (id, user_id, tx_hash, chain_id)" in indexes
|
||||
assert "drop index if exists public.idx_payment_intents_tx_hash" in indexes
|
||||
assert "include (id, user_id)" in indexes
|
||||
assert "where tx_hash is not null" in indexes
|
||||
assert "drop index if exists public.idx_payments_created_at" in indexes
|
||||
assert "include (id, user_id, amount, currency, chain, tx_hash, status)" in indexes
|
||||
assert "drop index if exists public.idx_user_wallets_user_chain" in indexes
|
||||
assert "on public.user_wallets(user_id, chain_id, is_primary desc, verified_at desc)" in indexes
|
||||
assert "include (id, address)" in indexes
|
||||
assert "drop index if exists public.idx_user_wallets_chain_address_owner" in indexes
|
||||
assert (
|
||||
"create index if not exists idx_user_wallets_chain_address_owner\n"
|
||||
" on public.user_wallets(chain_id, address)\n"
|
||||
" include (user_id, status)"
|
||||
) in indexes
|
||||
assert "drop index if exists public.idx_wallet_link_challenges_lookup" in indexes
|
||||
assert "create index if not exists idx_wallet_link_challenges_lookup" not in indexes
|
||||
assert "drop index if exists public.idx_payment_transactions_tx_hash_intent" in indexes
|
||||
assert (
|
||||
"create index if not exists idx_payment_transactions_tx_hash_intent\n"
|
||||
" on public.payment_transactions(tx_hash)\n"
|
||||
" include (intent_id)"
|
||||
) in indexes
|
||||
assert "analyze public.payment_intents" in indexes
|
||||
assert "pg_stat_user_tables" in diagnostics
|
||||
assert "pg_stat_statements" in diagnostics
|
||||
assert "shared_blks_read" in diagnostics
|
||||
assert "pg_stat_user_indexes" in diagnostics
|
||||
assert "pg_statio_user_indexes" in diagnostics
|
||||
assert "indexrelname" in diagnostics
|
||||
assert "idx_blks_read" in diagnostics
|
||||
assert "idx_scan = 0" in diagnostics
|
||||
|
||||
|
||||
def test_supabase_setup_doc_includes_io_budget_runbook():
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
doc = (root / "docs" / "SUPABASE_SETUP_ZH.md").read_text(encoding="utf-8")
|
||||
|
||||
assert "scripts/supabase/io_budget_indexes.sql" in doc
|
||||
assert "scripts/supabase/disk_io_diagnostics.sql" in doc
|
||||
assert "Disk IO" in doc
|
||||
@@ -1,10 +1,14 @@
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from starlette.requests import Request
|
||||
|
||||
import web.core as web_core
|
||||
import web.services.auth_api as auth_api
|
||||
from web.app import app
|
||||
import web.routes as routes
|
||||
import web.services.ops_api as ops_api
|
||||
import web.scan_terminal_cache as scan_terminal_cache
|
||||
import web.scan_terminal_service as scan_terminal_service
|
||||
from web.scan_terminal_cache import scan_terminal_cache_key
|
||||
@@ -94,6 +98,138 @@ def test_payment_runtime_endpoint_returns_shape():
|
||||
assert 'recent_audit_events' in payload
|
||||
|
||||
|
||||
def test_payment_config_does_not_require_entitlement(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"_assert_entitlement",
|
||||
lambda request: (_ for _ in ()).throw(
|
||||
AssertionError("public payment config should not validate Supabase auth"),
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
routes.PAYMENT_CHECKOUT,
|
||||
"get_config_payload",
|
||||
lambda: {"enabled": True, "plans": []},
|
||||
)
|
||||
|
||||
response = client.get("/api/payments/config")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["enabled"] is True
|
||||
|
||||
|
||||
def test_payment_wallets_requires_identity_without_subscription_gate(monkeypatch):
|
||||
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "enabled", True)
|
||||
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "require_subscription", True)
|
||||
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "supabase_url", "https://example.supabase.co")
|
||||
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "anon_key", "anon-key")
|
||||
monkeypatch.setattr(web_core, "_SUPABASE_AUTH_REQUIRED", True)
|
||||
|
||||
class _Identity:
|
||||
user_id = "user-1"
|
||||
email = "user@example.com"
|
||||
points = 0
|
||||
created_at = "2026-05-01T00:00:00+00:00"
|
||||
|
||||
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "get_identity", lambda token: _Identity())
|
||||
monkeypatch.setattr(
|
||||
web_core.SUPABASE_ENTITLEMENT,
|
||||
"has_active_subscription",
|
||||
lambda user_id: (_ for _ in ()).throw(
|
||||
AssertionError("payment identity endpoints should not query subscription gate"),
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(routes.PAYMENT_CHECKOUT, "list_wallets", lambda user_id: [])
|
||||
monkeypatch.setattr(routes.PAYMENT_CHECKOUT, "chain_id", 137)
|
||||
|
||||
response = client.get(
|
||||
"/api/payments/wallets",
|
||||
headers={"Authorization": "Bearer access-token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"wallets": [], "chain_id": 137}
|
||||
|
||||
|
||||
def test_telegram_identity_endpoints_skip_subscription_gate(monkeypatch):
|
||||
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "enabled", True)
|
||||
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "require_subscription", True)
|
||||
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "supabase_url", "https://example.supabase.co")
|
||||
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "anon_key", "anon-key")
|
||||
monkeypatch.setattr(web_core, "_SUPABASE_AUTH_REQUIRED", True)
|
||||
|
||||
class _Identity:
|
||||
user_id = "user-1"
|
||||
email = "user@example.com"
|
||||
points = 0
|
||||
created_at = "2026-05-01T00:00:00+00:00"
|
||||
|
||||
class _FakePricing:
|
||||
configured = True
|
||||
|
||||
@staticmethod
|
||||
def verify_login_payload(payload):
|
||||
return {"telegram_id": int(payload["id"]), "username": "tester"}
|
||||
|
||||
@staticmethod
|
||||
def get_member_status(telegram_id):
|
||||
return "member"
|
||||
|
||||
@staticmethod
|
||||
def resolve_price_for_telegram_id(telegram_id):
|
||||
return {"telegram_id": telegram_id, "pricing_source": "telegram_group_member"}
|
||||
|
||||
class _FakeDB:
|
||||
@staticmethod
|
||||
def upsert_user(telegram_id, username):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def bind_supabase_identity(*, telegram_id, supabase_user_id, supabase_email):
|
||||
return {"ok": True}
|
||||
|
||||
@staticmethod
|
||||
def consume_bind_token(token):
|
||||
return 12345
|
||||
|
||||
@staticmethod
|
||||
def create_web_bind_token(*, supabase_user_id, supabase_email, ttl_minutes):
|
||||
return "bind-token"
|
||||
|
||||
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "get_identity", lambda token: _Identity())
|
||||
monkeypatch.setattr(
|
||||
web_core.SUPABASE_ENTITLEMENT,
|
||||
"has_active_subscription",
|
||||
lambda user_id: (_ for _ in ()).throw(
|
||||
AssertionError("telegram identity endpoints should not query subscription gate"),
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(auth_api, "TelegramGroupPricing", lambda: _FakePricing())
|
||||
monkeypatch.setattr(auth_api, "DBManager", lambda: _FakeDB())
|
||||
|
||||
auth_headers = {"Authorization": "Bearer access-token"}
|
||||
|
||||
login_response = client.post(
|
||||
"/api/auth/telegram/login",
|
||||
headers=auth_headers,
|
||||
json={"id": 12345, "username": "tester", "auth_date": 1770000000, "hash": "x" * 64},
|
||||
)
|
||||
assert login_response.status_code == 200
|
||||
|
||||
token_response = client.post(
|
||||
"/api/auth/telegram/bind-by-token",
|
||||
headers=auth_headers,
|
||||
json={"token": "token-12345"},
|
||||
)
|
||||
assert token_response.status_code == 200
|
||||
|
||||
link_response = client.post(
|
||||
"/api/auth/telegram/bot-bind-link",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert link_response.status_code == 200
|
||||
|
||||
|
||||
def test_auth_me_does_not_reconcile_on_status_probe(monkeypatch):
|
||||
monkeypatch.setattr(routes, "_assert_entitlement", lambda request: None)
|
||||
|
||||
@@ -106,21 +242,21 @@ def test_auth_me_does_not_reconcile_on_status_probe(monkeypatch):
|
||||
monkeypatch.setattr(routes, "_resolve_weekly_profile", lambda request: {"weekly_points": 0, "weekly_rank": None})
|
||||
monkeypatch.setattr(routes.SUPABASE_ENTITLEMENT, "enabled", True)
|
||||
|
||||
calls = {"count": 0}
|
||||
reconcile_calls = {"count": 0}
|
||||
|
||||
def _latest_subscription(user_id, respect_requirement=False):
|
||||
calls["count"] += 1
|
||||
def _subscription_window(user_id, respect_requirement=False):
|
||||
return {
|
||||
"plan_code": "pro_monthly",
|
||||
"starts_at": "2026-03-22T00:00:00+00:00",
|
||||
"expires_at": "2026-04-21T00:00:00+00:00",
|
||||
"current": {
|
||||
"plan_code": "pro_monthly",
|
||||
"starts_at": "2026-03-22T00:00:00+00:00",
|
||||
"expires_at": "2026-04-21T00:00:00+00:00",
|
||||
},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
routes.SUPABASE_ENTITLEMENT,
|
||||
"get_latest_active_subscription",
|
||||
_latest_subscription,
|
||||
"get_subscription_window",
|
||||
_subscription_window,
|
||||
)
|
||||
monkeypatch.setattr(routes.PAYMENT_CHECKOUT, "enabled", True)
|
||||
|
||||
@@ -143,6 +279,265 @@ def test_auth_me_does_not_reconcile_on_status_probe(monkeypatch):
|
||||
assert reconcile_calls["count"] == 0
|
||||
|
||||
|
||||
def test_auth_me_reuses_identity_bound_by_entitlement(monkeypatch):
|
||||
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "enabled", True)
|
||||
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "require_subscription", True)
|
||||
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "supabase_url", "https://example.supabase.co")
|
||||
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "anon_key", "anon-key")
|
||||
monkeypatch.setattr(web_core, "_SUPABASE_AUTH_REQUIRED", True)
|
||||
monkeypatch.setattr(routes, "_resolve_weekly_profile", lambda request: {"weekly_points": 0, "weekly_rank": None})
|
||||
|
||||
class _Identity:
|
||||
user_id = "user-1"
|
||||
email = "user@example.com"
|
||||
points = 7
|
||||
created_at = "2026-05-01T00:00:00+00:00"
|
||||
|
||||
calls = {"identity": 0}
|
||||
|
||||
def _get_identity(token):
|
||||
calls["identity"] += 1
|
||||
assert token == "access-token"
|
||||
return _Identity()
|
||||
|
||||
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "get_identity", _get_identity)
|
||||
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "has_active_subscription", lambda user_id: True)
|
||||
monkeypatch.setattr(
|
||||
web_core.SUPABASE_ENTITLEMENT,
|
||||
"get_subscription_window",
|
||||
lambda user_id, respect_requirement=False: {
|
||||
"current": {
|
||||
"plan_code": "pro_monthly",
|
||||
"starts_at": "2026-03-22T00:00:00+00:00",
|
||||
"expires_at": "2026-04-21T00:00:00+00:00",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
response = client.get(
|
||||
"/api/auth/me",
|
||||
headers={"Authorization": "Bearer access-token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["authenticated"] is True
|
||||
assert payload["points"] == 7
|
||||
assert calls["identity"] == 1
|
||||
|
||||
|
||||
def test_auth_me_uses_subscription_window_as_required_subscription_gate(monkeypatch):
|
||||
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "enabled", True)
|
||||
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "require_subscription", True)
|
||||
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "supabase_url", "https://example.supabase.co")
|
||||
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "anon_key", "anon-key")
|
||||
monkeypatch.setattr(web_core, "_SUPABASE_AUTH_REQUIRED", True)
|
||||
monkeypatch.setattr(routes, "_resolve_weekly_profile", lambda request: {"weekly_points": 0, "weekly_rank": None})
|
||||
|
||||
class _Identity:
|
||||
user_id = "user-1"
|
||||
email = "user@example.com"
|
||||
points = 7
|
||||
created_at = "2026-05-01T00:00:00+00:00"
|
||||
|
||||
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "get_identity", lambda token: _Identity())
|
||||
monkeypatch.setattr(
|
||||
web_core.SUPABASE_ENTITLEMENT,
|
||||
"has_active_subscription",
|
||||
lambda user_id: (_ for _ in ()).throw(
|
||||
AssertionError("auth/me should not run a separate lightweight subscription gate"),
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
web_core.SUPABASE_ENTITLEMENT,
|
||||
"get_latest_active_subscription",
|
||||
lambda *args, **kwargs: (_ for _ in ()).throw(
|
||||
AssertionError("auth/me should derive current subscription from the window query"),
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
web_core.SUPABASE_ENTITLEMENT,
|
||||
"get_subscription_window",
|
||||
lambda user_id, respect_requirement=False: {
|
||||
"current": {
|
||||
"plan_code": "pro_monthly",
|
||||
"starts_at": "2026-03-22T00:00:00+00:00",
|
||||
"expires_at": "2026-04-21T00:00:00+00:00",
|
||||
},
|
||||
"total_expires_at": "2026-05-21T00:00:00+00:00",
|
||||
"queued_days": 30,
|
||||
"queued_count": 1,
|
||||
},
|
||||
)
|
||||
|
||||
response = client.get(
|
||||
"/api/auth/me",
|
||||
headers={"Authorization": "Bearer access-token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["subscription_active"] is True
|
||||
assert payload["subscription_plan_code"] == "pro_monthly"
|
||||
assert payload["subscription_queued_days"] == 30
|
||||
|
||||
|
||||
def test_auth_me_uses_window_rows_for_non_required_latest_known_subscription(monkeypatch):
|
||||
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "enabled", True)
|
||||
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "require_subscription", False)
|
||||
monkeypatch.setattr(web_core, "_SUPABASE_AUTH_REQUIRED", False)
|
||||
monkeypatch.setattr(routes, "_resolve_weekly_profile", lambda request: {"weekly_points": 0, "weekly_rank": None})
|
||||
monkeypatch.setattr(routes, "_resolve_auth_points", lambda request: 0)
|
||||
|
||||
def _bind_identity(request):
|
||||
request.state.auth_user_id = "user-1"
|
||||
request.state.auth_email = "user@example.com"
|
||||
|
||||
monkeypatch.setattr(routes, "_assert_entitlement", lambda request: None)
|
||||
monkeypatch.setattr(routes, "_bind_optional_supabase_identity", _bind_identity)
|
||||
monkeypatch.setattr(
|
||||
routes.SUPABASE_ENTITLEMENT,
|
||||
"get_subscription_window",
|
||||
lambda user_id, respect_requirement=False: {
|
||||
"current": None,
|
||||
"rows": [
|
||||
{
|
||||
"plan_code": "pro_monthly",
|
||||
"starts_at": "2026-06-01T00:00:00+00:00",
|
||||
"expires_at": "2026-07-01T00:00:00+00:00",
|
||||
}
|
||||
],
|
||||
"total_expires_at": "2026-07-01T00:00:00+00:00",
|
||||
"queued_days": 0,
|
||||
"queued_count": 0,
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
routes.SUPABASE_ENTITLEMENT,
|
||||
"get_latest_active_subscription",
|
||||
lambda *args, **kwargs: (_ for _ in ()).throw(
|
||||
AssertionError("auth/me should reuse window rows before latest active fallback"),
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
routes.SUPABASE_ENTITLEMENT,
|
||||
"get_latest_subscription_any_status",
|
||||
lambda *args, **kwargs: (_ for _ in ()).throw(
|
||||
AssertionError("auth/me should reuse window rows before historical fallback"),
|
||||
),
|
||||
)
|
||||
|
||||
response = client.get("/api/auth/me")
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["subscription_active"] is False
|
||||
assert payload["subscription_plan_code"] == "pro_monthly"
|
||||
assert payload["subscription_expires_at"] == "2026-07-01T00:00:00+00:00"
|
||||
|
||||
|
||||
def test_auth_me_skips_latest_active_after_empty_non_required_window(monkeypatch):
|
||||
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "enabled", True)
|
||||
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "require_subscription", False)
|
||||
monkeypatch.setattr(web_core, "_SUPABASE_AUTH_REQUIRED", False)
|
||||
monkeypatch.setattr(routes, "_resolve_weekly_profile", lambda request: {"weekly_points": 0, "weekly_rank": None})
|
||||
monkeypatch.setattr(routes, "_resolve_auth_points", lambda request: 0)
|
||||
|
||||
def _bind_identity(request):
|
||||
request.state.auth_user_id = "user-1"
|
||||
request.state.auth_email = "user@example.com"
|
||||
|
||||
latest_any_calls = {"count": 0}
|
||||
|
||||
def _latest_any_status(user_id):
|
||||
latest_any_calls["count"] += 1
|
||||
return {
|
||||
"plan_code": "expired_pro",
|
||||
"starts_at": "2026-03-01T00:00:00+00:00",
|
||||
"expires_at": "2026-04-01T00:00:00+00:00",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(routes, "_assert_entitlement", lambda request: None)
|
||||
monkeypatch.setattr(routes, "_bind_optional_supabase_identity", _bind_identity)
|
||||
monkeypatch.setattr(
|
||||
routes.SUPABASE_ENTITLEMENT,
|
||||
"get_subscription_window",
|
||||
lambda user_id, respect_requirement=False: {},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
routes.SUPABASE_ENTITLEMENT,
|
||||
"get_latest_active_subscription",
|
||||
lambda *args, **kwargs: (_ for _ in ()).throw(
|
||||
AssertionError("empty subscription window should skip latest active fallback"),
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
routes.SUPABASE_ENTITLEMENT,
|
||||
"get_latest_subscription_any_status",
|
||||
_latest_any_status,
|
||||
)
|
||||
|
||||
response = client.get("/api/auth/me")
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["subscription_active"] is False
|
||||
assert payload["subscription_plan_code"] == "expired_pro"
|
||||
assert latest_any_calls["count"] == 1
|
||||
|
||||
|
||||
def test_auth_me_preserves_required_subscription_403_from_window(monkeypatch):
|
||||
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "enabled", True)
|
||||
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "require_subscription", True)
|
||||
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "supabase_url", "https://example.supabase.co")
|
||||
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "anon_key", "anon-key")
|
||||
monkeypatch.setattr(web_core, "_SUPABASE_AUTH_REQUIRED", True)
|
||||
|
||||
class _Identity:
|
||||
user_id = "user-1"
|
||||
email = "user@example.com"
|
||||
points = 0
|
||||
created_at = "2026-05-01T00:00:00+00:00"
|
||||
|
||||
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "get_identity", lambda token: _Identity())
|
||||
monkeypatch.setattr(
|
||||
web_core.SUPABASE_ENTITLEMENT,
|
||||
"has_active_subscription",
|
||||
lambda user_id: (_ for _ in ()).throw(
|
||||
AssertionError("auth/me should not run a separate lightweight subscription gate"),
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
web_core.SUPABASE_ENTITLEMENT,
|
||||
"get_subscription_window",
|
||||
lambda user_id, respect_requirement=False: {},
|
||||
)
|
||||
latest_any_calls = {"count": 0}
|
||||
|
||||
def _latest_any_status(user_id):
|
||||
latest_any_calls["count"] += 1
|
||||
return {
|
||||
"plan_code": "expired_pro",
|
||||
"starts_at": "2026-03-01T00:00:00+00:00",
|
||||
"expires_at": "2026-04-01T00:00:00+00:00",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
web_core.SUPABASE_ENTITLEMENT,
|
||||
"get_latest_subscription_any_status",
|
||||
_latest_any_status,
|
||||
)
|
||||
|
||||
response = client.get(
|
||||
"/api/auth/me",
|
||||
headers={"Authorization": "Bearer access-token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == "Subscription required"
|
||||
assert latest_any_calls["count"] == 0
|
||||
|
||||
|
||||
def test_backend_entitlement_token_binds_forwarded_supabase_identity(monkeypatch):
|
||||
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "enabled", True)
|
||||
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "supabase_url", "https://example.supabase.co")
|
||||
@@ -167,6 +562,45 @@ def test_backend_entitlement_token_binds_forwarded_supabase_identity(monkeypatch
|
||||
assert request.state.auth_email == "user@example.com"
|
||||
|
||||
|
||||
def test_backend_entitlement_token_without_forwarded_identity_validates_bearer(monkeypatch):
|
||||
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "enabled", True)
|
||||
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "supabase_url", "https://example.supabase.co")
|
||||
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "anon_key", "anon-key")
|
||||
monkeypatch.setattr(web_core, "_SUPABASE_AUTH_REQUIRED", False)
|
||||
monkeypatch.setattr(web_core, "_ENTITLEMENT_TOKEN", "backend-token")
|
||||
|
||||
class _Identity:
|
||||
user_id = "user-1"
|
||||
email = "user@example.com"
|
||||
points = 7
|
||||
created_at = "2026-05-01T00:00:00+00:00"
|
||||
|
||||
calls = {"count": 0}
|
||||
|
||||
def _get_identity(token):
|
||||
calls["count"] += 1
|
||||
assert token == "access-token"
|
||||
return _Identity()
|
||||
|
||||
monkeypatch.setattr(web_core.SUPABASE_ENTITLEMENT, "get_identity", _get_identity)
|
||||
|
||||
request = Request(
|
||||
{
|
||||
"type": "http",
|
||||
"headers": [
|
||||
(b"x-polyweather-entitlement", b"backend-token"),
|
||||
(b"authorization", b"Bearer access-token"),
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
web_core._assert_entitlement(request)
|
||||
|
||||
assert calls["count"] == 1
|
||||
assert request.state.auth_user_id == "user-1"
|
||||
assert request.state.auth_email == "user@example.com"
|
||||
|
||||
|
||||
def test_ops_memberships_prefers_supabase_auth_email(monkeypatch):
|
||||
monkeypatch.setattr(routes, "_assert_entitlement", lambda request: None)
|
||||
monkeypatch.setattr(routes, "_require_ops_admin", lambda request: None)
|
||||
@@ -216,6 +650,504 @@ def test_ops_memberships_prefers_supabase_auth_email(monkeypatch):
|
||||
assert payload["memberships"][0]["email"] == "fresh@example.com"
|
||||
|
||||
|
||||
def test_ops_memberships_uses_batched_subscription_windows(monkeypatch):
|
||||
monkeypatch.setattr(routes, "_assert_entitlement", lambda request: None)
|
||||
monkeypatch.setattr(routes, "_require_ops_admin", lambda request: None)
|
||||
monkeypatch.setattr(routes.PAYMENT_CHECKOUT, "enabled", False)
|
||||
|
||||
class _FakeDB:
|
||||
@staticmethod
|
||||
def get_users_by_supabase_user_ids(user_ids):
|
||||
return {
|
||||
"user-1": {"supabase_email": "one@example.com"},
|
||||
"user-2": {"supabase_email": "two@example.com"},
|
||||
}
|
||||
|
||||
import src.database.db_manager as db_module
|
||||
|
||||
monkeypatch.setattr(db_module, "DBManager", lambda: _FakeDB())
|
||||
monkeypatch.setattr(
|
||||
routes.SUPABASE_ENTITLEMENT,
|
||||
"list_active_subscriptions",
|
||||
lambda limit=200: [
|
||||
{
|
||||
"user_id": "user-1",
|
||||
"plan_code": "pro_monthly",
|
||||
"starts_at": "2026-03-01T00:00:00+00:00",
|
||||
"expires_at": "2026-04-01T00:00:00+00:00",
|
||||
},
|
||||
{
|
||||
"user_id": "user-2",
|
||||
"plan_code": "pro_monthly",
|
||||
"starts_at": "2026-03-02T00:00:00+00:00",
|
||||
"expires_at": "2026-04-02T00:00:00+00:00",
|
||||
},
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr(routes.SUPABASE_ENTITLEMENT, "get_auth_users", lambda user_ids: {})
|
||||
|
||||
def _fail_per_user_window(*args, **kwargs):
|
||||
raise AssertionError("per-user subscription window query should not run")
|
||||
|
||||
monkeypatch.setattr(
|
||||
routes.SUPABASE_ENTITLEMENT,
|
||||
"get_subscription_window",
|
||||
_fail_per_user_window,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
routes.SUPABASE_ENTITLEMENT,
|
||||
"list_subscription_windows",
|
||||
lambda user_ids, bypass_cache=True: {
|
||||
"user-1": {"total_expires_at": "2026-04-15T00:00:00+00:00", "queued_days": 14, "queued_count": 1},
|
||||
"user-2": {"total_expires_at": "2026-04-02T00:00:00+00:00", "queued_days": 0, "queued_count": 0},
|
||||
},
|
||||
raising=False,
|
||||
)
|
||||
|
||||
response = client.get("/api/ops/memberships")
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
rows = {row["user_id"]: row for row in payload["memberships"]}
|
||||
assert rows["user-1"]["queued_days"] == 14
|
||||
assert rows["user-2"]["queued_days"] == 0
|
||||
|
||||
|
||||
def test_ops_memberships_prefers_single_active_subscription_window_query(monkeypatch):
|
||||
monkeypatch.setattr(routes, "_assert_entitlement", lambda request: None)
|
||||
monkeypatch.setattr(routes, "_require_ops_admin", lambda request: None)
|
||||
monkeypatch.setattr(routes.PAYMENT_CHECKOUT, "enabled", False)
|
||||
|
||||
class _FakeDB:
|
||||
@staticmethod
|
||||
def get_users_by_supabase_user_ids(user_ids):
|
||||
return {"user-1": {"supabase_email": "one@example.com"}}
|
||||
|
||||
import src.database.db_manager as db_module
|
||||
|
||||
monkeypatch.setattr(db_module, "DBManager", lambda: _FakeDB())
|
||||
monkeypatch.setattr(
|
||||
routes.SUPABASE_ENTITLEMENT,
|
||||
"list_active_subscriptions",
|
||||
lambda *args, **kwargs: (_ for _ in ()).throw(
|
||||
AssertionError("memberships should not run a separate active subscription query"),
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
routes.SUPABASE_ENTITLEMENT,
|
||||
"list_subscription_windows",
|
||||
lambda *args, **kwargs: (_ for _ in ()).throw(
|
||||
AssertionError("memberships should not run a second window query"),
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(routes.SUPABASE_ENTITLEMENT, "get_auth_users", lambda user_ids: {})
|
||||
monkeypatch.setattr(
|
||||
routes.SUPABASE_ENTITLEMENT,
|
||||
"list_active_subscription_windows",
|
||||
lambda limit=200: {
|
||||
"subscriptions": [
|
||||
{
|
||||
"user_id": "user-1",
|
||||
"plan_code": "pro_monthly",
|
||||
"source": "payment_contract",
|
||||
"starts_at": "2026-03-01T00:00:00+00:00",
|
||||
"expires_at": "2026-04-01T00:00:00+00:00",
|
||||
}
|
||||
],
|
||||
"windows": {
|
||||
"user-1": {
|
||||
"total_expires_at": "2026-05-01T00:00:00+00:00",
|
||||
"queued_days": 30,
|
||||
"queued_count": 1,
|
||||
}
|
||||
},
|
||||
},
|
||||
raising=False,
|
||||
)
|
||||
|
||||
response = client.get("/api/ops/memberships")
|
||||
|
||||
assert response.status_code == 200
|
||||
row = response.json()["memberships"][0]
|
||||
assert row["queued_days"] == 30
|
||||
assert row["expires_at"] == "2026-05-01T00:00:00+00:00"
|
||||
|
||||
|
||||
def test_ops_memberships_growth_reuses_active_subscription_window_query(monkeypatch):
|
||||
monkeypatch.setattr(routes, "_assert_entitlement", lambda request: None)
|
||||
monkeypatch.setattr(routes, "_require_ops_admin", lambda request: None)
|
||||
|
||||
starts_at = datetime.utcnow().replace(microsecond=0).isoformat()
|
||||
|
||||
monkeypatch.setattr(
|
||||
routes.SUPABASE_ENTITLEMENT,
|
||||
"list_active_subscriptions",
|
||||
lambda *args, **kwargs: (_ for _ in ()).throw(
|
||||
AssertionError("growth should not run a separate active subscription query"),
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
routes.SUPABASE_ENTITLEMENT,
|
||||
"list_active_subscription_windows",
|
||||
lambda limit=5000: {
|
||||
"subscriptions": [
|
||||
{
|
||||
"user_id": "user-1",
|
||||
"plan_code": "pro_monthly",
|
||||
"source": "payment_contract",
|
||||
"starts_at": starts_at,
|
||||
"expires_at": "2099-01-01T00:00:00+00:00",
|
||||
}
|
||||
],
|
||||
"windows": {},
|
||||
},
|
||||
raising=False,
|
||||
)
|
||||
|
||||
response = client.get("/api/ops/memberships/growth?days=7")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert any(day["paid"] == 1 for day in response.json()["daily"])
|
||||
|
||||
|
||||
def test_ops_telegram_audit_reuses_active_subscription_window_query(monkeypatch):
|
||||
monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "bot-token")
|
||||
monkeypatch.setenv("TELEGRAM_CHAT_IDS", "chat-1")
|
||||
monkeypatch.delenv("TELEGRAM_CHAT_ID", raising=False)
|
||||
monkeypatch.delenv("POLYWEATHER_TELEGRAM_GROUP_ID", raising=False)
|
||||
monkeypatch.delenv("POLYWEATHER_TELEGRAM_TOPICS_GROUP_ID", raising=False)
|
||||
monkeypatch.setattr(ops_api, "_require_ops", lambda request: None)
|
||||
|
||||
class _FakeRows(list):
|
||||
def fetchall(self):
|
||||
return self
|
||||
|
||||
class _FakeConnection:
|
||||
row_factory = None
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
return False
|
||||
|
||||
def execute(self, sql):
|
||||
if "FROM users" in sql:
|
||||
return _FakeRows([{"telegram_id": 1, "username": "tester"}])
|
||||
if "FROM supabase_bindings" in sql:
|
||||
return _FakeRows(
|
||||
[
|
||||
{
|
||||
"telegram_id": 1,
|
||||
"supabase_user_id": "user-1",
|
||||
"supabase_email": "one@example.com",
|
||||
}
|
||||
]
|
||||
)
|
||||
raise AssertionError(sql)
|
||||
|
||||
class _FakeDB:
|
||||
@staticmethod
|
||||
def _get_connection():
|
||||
return _FakeConnection()
|
||||
|
||||
class _Response:
|
||||
status_code = 200
|
||||
|
||||
@staticmethod
|
||||
def json():
|
||||
return {"ok": True, "result": {"status": "member"}}
|
||||
|
||||
import requests as requests_module
|
||||
import src.database.db_manager as db_module
|
||||
|
||||
monkeypatch.setattr(db_module, "DBManager", lambda: _FakeDB())
|
||||
monkeypatch.setattr(requests_module, "get", lambda *args, **kwargs: _Response())
|
||||
monkeypatch.setattr(
|
||||
routes.SUPABASE_ENTITLEMENT,
|
||||
"list_active_subscriptions",
|
||||
lambda *args, **kwargs: (_ for _ in ()).throw(
|
||||
AssertionError("telegram audit should not run a separate active subscription query"),
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
routes.SUPABASE_ENTITLEMENT,
|
||||
"list_active_subscription_windows",
|
||||
lambda limit=5000: {
|
||||
"subscriptions": [
|
||||
{
|
||||
"user_id": "user-1",
|
||||
"plan_code": "pro_monthly",
|
||||
"source": "payment_contract",
|
||||
"starts_at": "2026-03-01T00:00:00+00:00",
|
||||
"expires_at": "2099-01-01T00:00:00+00:00",
|
||||
}
|
||||
],
|
||||
"windows": {},
|
||||
},
|
||||
raising=False,
|
||||
)
|
||||
|
||||
result = ops_api.get_ops_telegram_audit(object())
|
||||
|
||||
assert result["valid_count"] == 1
|
||||
assert result["anomaly_count"] == 0
|
||||
|
||||
|
||||
def test_ops_memberships_overview_combines_memberships_and_growth_in_one_subscription_query(monkeypatch):
|
||||
monkeypatch.setattr(routes, "_assert_entitlement", lambda request: None)
|
||||
monkeypatch.setattr(routes, "_require_ops_admin", lambda request: None)
|
||||
monkeypatch.setattr(routes.PAYMENT_CHECKOUT, "enabled", False)
|
||||
|
||||
starts_at = datetime.utcnow().replace(microsecond=0).isoformat()
|
||||
calls = {"active_windows": 0}
|
||||
|
||||
class _FakeDB:
|
||||
@staticmethod
|
||||
def get_users_by_supabase_user_ids(user_ids):
|
||||
return {"user-1": {"supabase_email": "one@example.com"}}
|
||||
|
||||
import src.database.db_manager as db_module
|
||||
|
||||
monkeypatch.setattr(db_module, "DBManager", lambda: _FakeDB())
|
||||
monkeypatch.setattr(
|
||||
routes.SUPABASE_ENTITLEMENT,
|
||||
"list_active_subscriptions",
|
||||
lambda *args, **kwargs: (_ for _ in ()).throw(
|
||||
AssertionError("overview should not run a separate active subscription query"),
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
routes.SUPABASE_ENTITLEMENT,
|
||||
"list_subscription_windows",
|
||||
lambda *args, **kwargs: (_ for _ in ()).throw(
|
||||
AssertionError("overview should not run a second window query"),
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(routes.SUPABASE_ENTITLEMENT, "get_auth_users", lambda user_ids: {})
|
||||
|
||||
def _active_windows(limit=5000):
|
||||
calls["active_windows"] += 1
|
||||
assert limit == 5000
|
||||
return {
|
||||
"subscriptions": [
|
||||
{
|
||||
"user_id": "user-1",
|
||||
"plan_code": "pro_monthly",
|
||||
"source": "payment_contract",
|
||||
"starts_at": starts_at,
|
||||
"expires_at": "2099-01-01T00:00:00+00:00",
|
||||
},
|
||||
{
|
||||
"user_id": "user-2",
|
||||
"plan_code": "signup_trial_3d",
|
||||
"source": "signup_trial",
|
||||
"starts_at": starts_at,
|
||||
"expires_at": "2099-01-02T00:00:00+00:00",
|
||||
},
|
||||
],
|
||||
"windows": {
|
||||
"user-1": {
|
||||
"total_expires_at": "2099-01-01T00:00:00+00:00",
|
||||
"queued_days": 0,
|
||||
"queued_count": 0,
|
||||
},
|
||||
"user-2": {
|
||||
"total_expires_at": "2099-01-02T00:00:00+00:00",
|
||||
"queued_days": 0,
|
||||
"queued_count": 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
routes.SUPABASE_ENTITLEMENT,
|
||||
"list_active_subscription_windows",
|
||||
_active_windows,
|
||||
raising=False,
|
||||
)
|
||||
|
||||
response = client.get("/api/ops/memberships/overview?limit=1&days=7")
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert len(payload["memberships"]) == 1
|
||||
assert payload["memberships"][0]["user_id"] == "user-1"
|
||||
assert any(day["paid"] == 1 and day["trial"] == 1 for day in payload["daily"])
|
||||
assert calls["active_windows"] == 1
|
||||
|
||||
|
||||
def test_ops_memberships_does_not_reconcile_payments_by_default(monkeypatch):
|
||||
monkeypatch.delenv("POLYWEATHER_OPS_MEMBERSHIPS_RECONCILE_ENABLED", raising=False)
|
||||
monkeypatch.setattr(routes, "_assert_entitlement", lambda request: None)
|
||||
monkeypatch.setattr(routes, "_require_ops_admin", lambda request: None)
|
||||
monkeypatch.setattr(routes.PAYMENT_CHECKOUT, "enabled", True)
|
||||
|
||||
calls = {"count": 0}
|
||||
|
||||
def _count_reconcile(*args, **kwargs):
|
||||
calls["count"] += 1
|
||||
return {"ok": True}
|
||||
|
||||
monkeypatch.setattr(routes.PAYMENT_CHECKOUT, "reconcile_recent_intents", _count_reconcile)
|
||||
|
||||
class _FakeDB:
|
||||
@staticmethod
|
||||
def get_users_by_supabase_user_ids(user_ids):
|
||||
return {}
|
||||
|
||||
import src.database.db_manager as db_module
|
||||
|
||||
monkeypatch.setattr(db_module, "DBManager", lambda: _FakeDB())
|
||||
monkeypatch.setattr(routes.SUPABASE_ENTITLEMENT, "list_active_subscriptions", lambda limit=200: [])
|
||||
monkeypatch.setattr(routes.SUPABASE_ENTITLEMENT, "get_auth_users", lambda user_ids: {})
|
||||
monkeypatch.setattr(routes.SUPABASE_ENTITLEMENT, "list_subscription_windows", lambda user_ids, bypass_cache=True: {})
|
||||
|
||||
response = client.get("/api/ops/memberships")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["memberships"] == []
|
||||
assert calls["count"] == 0
|
||||
|
||||
|
||||
def test_ops_email_lookup_prefers_profiles_over_auth_admin(monkeypatch):
|
||||
monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co")
|
||||
monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role")
|
||||
|
||||
calls = []
|
||||
|
||||
class _Response:
|
||||
ok = True
|
||||
status_code = 200
|
||||
content = b"1"
|
||||
text = ""
|
||||
|
||||
def __init__(self, payload):
|
||||
self._payload = payload
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
def _fake_get(url, headers=None, params=None, timeout=None):
|
||||
calls.append((url, params))
|
||||
if url.endswith("/rest/v1/profiles"):
|
||||
assert headers is not None
|
||||
assert headers.get("Prefer") != "return=representation"
|
||||
assert params["select"] == "id"
|
||||
assert params["email"] == "eq.user@example.com"
|
||||
return _Response([{"id": "user-1"}])
|
||||
raise AssertionError(f"unexpected auth admin lookup: {url}")
|
||||
|
||||
monkeypatch.setattr(ops_api._requests, "get", _fake_get)
|
||||
|
||||
assert (
|
||||
ops_api._lookup_supabase_user_id_by_email(
|
||||
"https://example.supabase.co",
|
||||
"service-role",
|
||||
"user@example.com",
|
||||
)
|
||||
== "user-1"
|
||||
)
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
def test_ops_subscription_grant_invalidates_subscription_cache(monkeypatch):
|
||||
monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co")
|
||||
monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role")
|
||||
monkeypatch.setattr(ops_api, "_require_ops", lambda request: {"email": "admin@example.com"})
|
||||
|
||||
class _Response:
|
||||
ok = True
|
||||
status_code = 200
|
||||
content = b"1"
|
||||
text = ""
|
||||
|
||||
def __init__(self, payload):
|
||||
self._payload = payload
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
def _fake_get(url, headers=None, params=None, timeout=None):
|
||||
assert url.endswith("/rest/v1/profiles")
|
||||
assert params["select"] == "id"
|
||||
return _Response([{"id": "user-1"}])
|
||||
|
||||
def _fake_post(url, headers=None, json=None, timeout=None):
|
||||
assert url.endswith("/rest/v1/subscriptions")
|
||||
assert headers["Prefer"] == "return=minimal"
|
||||
return _Response([{"id": 1, "user_id": "user-1"}])
|
||||
|
||||
invalidated = []
|
||||
monkeypatch.setattr(ops_api._requests, "get", _fake_get)
|
||||
monkeypatch.setattr(ops_api._requests, "post", _fake_post)
|
||||
monkeypatch.setattr(
|
||||
ops_api.legacy_routes.SUPABASE_ENTITLEMENT,
|
||||
"invalidate_subscription_cache",
|
||||
lambda user_id: invalidated.append(user_id),
|
||||
)
|
||||
|
||||
result = ops_api.grant_ops_subscription(object(), "user@example.com")
|
||||
|
||||
assert result["ok"] is True
|
||||
assert invalidated == ["user-1"]
|
||||
|
||||
|
||||
def test_ops_subscription_extend_uses_minimal_return_and_invalidates_cache(monkeypatch):
|
||||
monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co")
|
||||
monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role")
|
||||
monkeypatch.setattr(ops_api, "_require_ops", lambda request: {"email": "admin@example.com"})
|
||||
|
||||
class _Response:
|
||||
ok = True
|
||||
status_code = 200
|
||||
content = b"1"
|
||||
text = ""
|
||||
|
||||
def __init__(self, payload):
|
||||
self._payload = payload
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
def _fake_get(url, headers=None, params=None, timeout=None):
|
||||
if url.endswith("/rest/v1/profiles"):
|
||||
assert params["select"] == "id"
|
||||
return _Response([{"id": "user-1"}])
|
||||
if url.endswith("/rest/v1/subscriptions"):
|
||||
assert params["select"] == "id,expires_at"
|
||||
return _Response(
|
||||
[
|
||||
{
|
||||
"id": 7,
|
||||
"expires_at": "2026-04-01T00:00:00+00:00",
|
||||
}
|
||||
]
|
||||
)
|
||||
raise AssertionError(url)
|
||||
|
||||
def _fake_patch(url, headers=None, json=None, timeout=None):
|
||||
assert url.endswith("/rest/v1/subscriptions?id=eq.7")
|
||||
assert headers["Prefer"] == "return=minimal"
|
||||
assert "expires_at" in json
|
||||
return _Response([])
|
||||
|
||||
invalidated = []
|
||||
monkeypatch.setattr(ops_api._requests, "get", _fake_get)
|
||||
monkeypatch.setattr(ops_api._requests, "patch", _fake_patch)
|
||||
monkeypatch.setattr(
|
||||
ops_api.legacy_routes.SUPABASE_ENTITLEMENT,
|
||||
"invalidate_subscription_cache",
|
||||
lambda user_id: invalidated.append(user_id),
|
||||
)
|
||||
|
||||
result = ops_api.extend_ops_subscription(object(), "user@example.com", additional_days=7)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["new_expires_at"].startswith("2026-04-08")
|
||||
assert invalidated == ["user-1"]
|
||||
|
||||
|
||||
def test_ops_truth_history_returns_filtered_rows(monkeypatch):
|
||||
monkeypatch.setattr(routes, "_assert_entitlement", lambda request: None)
|
||||
monkeypatch.setattr(routes, "_require_ops_admin", lambda request: None)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
from datetime import datetime
|
||||
|
||||
import src.bot.weekly_reward_loop as weekly_reward_loop
|
||||
|
||||
|
||||
class _Response:
|
||||
def __init__(self, status_code=200, payload=None):
|
||||
self.status_code = status_code
|
||||
self._payload = payload
|
||||
self.content = b"1"
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
|
||||
def test_weekly_reward_bonus_subscription_insert_uses_minimal_return(monkeypatch):
|
||||
calls = []
|
||||
|
||||
def _fake_get(url, headers=None, params=None, timeout=None):
|
||||
calls.append(("GET", url, headers, params))
|
||||
return _Response(200, [])
|
||||
|
||||
def _fake_post(url, headers=None, json=None, timeout=None):
|
||||
calls.append(("POST", url, headers, json))
|
||||
assert headers["Prefer"] == "return=minimal"
|
||||
return _Response(201, [])
|
||||
|
||||
monkeypatch.setattr(weekly_reward_loop.requests, "get", _fake_get)
|
||||
monkeypatch.setattr(weekly_reward_loop.requests, "post", _fake_post)
|
||||
|
||||
ok, reason, expires_at = weekly_reward_loop._grant_bonus_subscription_days(
|
||||
supabase_url="https://example.supabase.co",
|
||||
service_role_key="service-role",
|
||||
user_id="user-1",
|
||||
days=7,
|
||||
timeout_sec=5,
|
||||
)
|
||||
|
||||
assert ok is True
|
||||
assert reason == ""
|
||||
assert datetime.fromisoformat(str(expires_at)).tzinfo is not None
|
||||
assert [call[0] for call in calls] == ["GET", "POST"]
|
||||
+13
-3
@@ -303,8 +303,10 @@ def _resolve_weekly_profile(request: Request) -> Dict[str, Any]:
|
||||
def _assert_entitlement(request: Request) -> None:
|
||||
if SUPABASE_ENTITLEMENT.enabled:
|
||||
if _legacy_service_token_valid(request):
|
||||
_bind_forwarded_supabase_identity(request)
|
||||
return
|
||||
if _bind_forwarded_supabase_identity(request):
|
||||
return
|
||||
if not extract_bearer_token(request.headers.get("authorization")):
|
||||
return
|
||||
if not _SUPABASE_AUTH_REQUIRED:
|
||||
_bind_optional_supabase_identity(request)
|
||||
return
|
||||
@@ -321,11 +323,19 @@ def _assert_entitlement(request: Request) -> None:
|
||||
identity = SUPABASE_ENTITLEMENT.get_identity(access_token)
|
||||
if not identity:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
if not SUPABASE_ENTITLEMENT.has_active_subscription(identity.user_id):
|
||||
skip_subscription_gate = bool(
|
||||
getattr(request.state, "skip_subscription_gate", False)
|
||||
)
|
||||
if (
|
||||
not skip_subscription_gate
|
||||
and not SUPABASE_ENTITLEMENT.has_active_subscription(identity.user_id)
|
||||
):
|
||||
raise HTTPException(status_code=403, detail="Subscription required")
|
||||
|
||||
request.state.auth_user_id = identity.user_id
|
||||
request.state.auth_email = identity.email
|
||||
request.state.auth_points = identity.points
|
||||
request.state.auth_created_at = identity.created_at
|
||||
from src.utils.online_tracker import record_activity
|
||||
record_activity(identity.user_id)
|
||||
return
|
||||
|
||||
@@ -8,6 +8,7 @@ from web.services.ops_api import (
|
||||
get_ops_analytics_funnel,
|
||||
get_ops_config,
|
||||
get_ops_memberships_growth,
|
||||
get_ops_memberships_overview,
|
||||
get_ops_health_check,
|
||||
get_ops_logs,
|
||||
get_ops_truth_history,
|
||||
@@ -55,6 +56,15 @@ async def ops_memberships_growth(request: Request, days: int = 90):
|
||||
return get_ops_memberships_growth(request, days=days)
|
||||
|
||||
|
||||
@router.get("/api/ops/memberships/overview")
|
||||
async def ops_memberships_overview(
|
||||
request: Request,
|
||||
limit: int = 200,
|
||||
days: int = 90,
|
||||
):
|
||||
return get_ops_memberships_overview(request, limit=limit, days=days)
|
||||
|
||||
|
||||
@router.get("/api/ops/payments/incidents")
|
||||
async def ops_payment_incidents(
|
||||
request: Request,
|
||||
|
||||
+51
-19
@@ -12,9 +12,17 @@ from web.core import TelegramLoginRequest
|
||||
import web.routes as legacy_routes
|
||||
|
||||
|
||||
def get_auth_me_payload(request: Request) -> Dict[str, Any]:
|
||||
def _require_auth_identity_without_subscription_gate(request: Request) -> Dict[str, str]:
|
||||
request.state.skip_subscription_gate = True
|
||||
legacy_routes._assert_entitlement(request)
|
||||
legacy_routes._bind_optional_supabase_identity(request)
|
||||
return legacy_routes._require_supabase_identity(request)
|
||||
|
||||
|
||||
def get_auth_me_payload(request: Request) -> Dict[str, Any]:
|
||||
request.state.skip_subscription_gate = True
|
||||
legacy_routes._assert_entitlement(request)
|
||||
if not str(getattr(request.state, "auth_user_id", "") or "").strip():
|
||||
legacy_routes._bind_optional_supabase_identity(request)
|
||||
|
||||
user_id = getattr(request.state, "auth_user_id", None)
|
||||
subscription_required = bool(
|
||||
@@ -31,25 +39,48 @@ def get_auth_me_payload(request: Request) -> Dict[str, Any]:
|
||||
|
||||
if legacy_routes.SUPABASE_ENTITLEMENT.enabled and user_id:
|
||||
try:
|
||||
latest_subscription = (
|
||||
legacy_routes.SUPABASE_ENTITLEMENT.get_latest_active_subscription(
|
||||
user_id,
|
||||
respect_requirement=False,
|
||||
)
|
||||
subscription_window = legacy_routes.SUPABASE_ENTITLEMENT.get_subscription_window(
|
||||
user_id,
|
||||
respect_requirement=False,
|
||||
)
|
||||
latest_subscription = None
|
||||
latest_known_subscription = None
|
||||
subscription_window_known = isinstance(subscription_window, dict)
|
||||
if isinstance(subscription_window, dict):
|
||||
current_subscription = subscription_window.get("current")
|
||||
if isinstance(current_subscription, dict):
|
||||
latest_subscription = current_subscription
|
||||
rows = subscription_window.get("rows")
|
||||
if not latest_subscription and isinstance(rows, list):
|
||||
latest_known_subscription = next(
|
||||
(row for row in rows if isinstance(row, dict)),
|
||||
None,
|
||||
)
|
||||
if (
|
||||
not latest_subscription
|
||||
and not latest_known_subscription
|
||||
and not subscription_window_known
|
||||
and not subscription_required
|
||||
):
|
||||
latest_subscription = (
|
||||
legacy_routes.SUPABASE_ENTITLEMENT.get_latest_active_subscription(
|
||||
user_id,
|
||||
respect_requirement=False,
|
||||
)
|
||||
)
|
||||
|
||||
latest_known_subscription = latest_subscription
|
||||
subscription_active = bool(latest_subscription)
|
||||
if subscription_required and not subscription_active:
|
||||
raise HTTPException(status_code=403, detail="Subscription required")
|
||||
|
||||
if not latest_known_subscription:
|
||||
latest_known_subscription = latest_subscription
|
||||
if not latest_known_subscription:
|
||||
latest_known_subscription = (
|
||||
legacy_routes.SUPABASE_ENTITLEMENT.get_latest_subscription_any_status(
|
||||
user_id
|
||||
)
|
||||
)
|
||||
subscription_window = legacy_routes.SUPABASE_ENTITLEMENT.get_subscription_window(
|
||||
user_id,
|
||||
respect_requirement=False,
|
||||
)
|
||||
subscription_active = bool(latest_subscription)
|
||||
if isinstance(latest_subscription, dict):
|
||||
subscription_plan_code = latest_subscription.get("plan_code")
|
||||
subscription_starts_at = latest_subscription.get("starts_at")
|
||||
@@ -62,7 +93,11 @@ def get_auth_me_payload(request: Request) -> Dict[str, Any]:
|
||||
subscription_total_expires_at = subscription_window.get("total_expires_at")
|
||||
subscription_queued_days = int(subscription_window.get("queued_days") or 0)
|
||||
subscription_queued_count = int(subscription_window.get("queued_count") or 0)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
if subscription_required:
|
||||
raise HTTPException(status_code=403, detail="Subscription required")
|
||||
subscription_active = None
|
||||
subscription_plan_code = None
|
||||
subscription_starts_at = None
|
||||
@@ -124,8 +159,7 @@ def get_auth_me_payload(request: Request) -> Dict[str, Any]:
|
||||
|
||||
|
||||
def login_with_telegram(request: Request, body: TelegramLoginRequest) -> Dict[str, Any]:
|
||||
legacy_routes._assert_entitlement(request)
|
||||
identity = legacy_routes._require_supabase_identity(request)
|
||||
identity = _require_auth_identity_without_subscription_gate(request)
|
||||
pricing = TelegramGroupPricing()
|
||||
if not pricing.configured:
|
||||
raise HTTPException(status_code=503, detail="telegram login is not configured")
|
||||
@@ -156,8 +190,7 @@ def login_with_telegram(request: Request, body: TelegramLoginRequest) -> Dict[st
|
||||
|
||||
def bind_telegram_by_token(request: Request, body) -> Dict[str, Any]:
|
||||
"""Bind Telegram identity using a one-time token from the bot /bind command."""
|
||||
legacy_routes._assert_entitlement(request)
|
||||
identity = legacy_routes._require_supabase_identity(request)
|
||||
identity = _require_auth_identity_without_subscription_gate(request)
|
||||
|
||||
token = str(getattr(body, "token", "") or "").strip()
|
||||
if not token:
|
||||
@@ -196,8 +229,7 @@ def bind_telegram_by_token(request: Request, body) -> Dict[str, Any]:
|
||||
|
||||
def create_telegram_bot_bind_link(request: Request) -> Dict[str, Any]:
|
||||
"""Create a one-time web-to-bot bind deep link for the authenticated account."""
|
||||
legacy_routes._assert_entitlement(request)
|
||||
identity = legacy_routes._require_supabase_identity(request)
|
||||
identity = _require_auth_identity_without_subscription_gate(request)
|
||||
|
||||
db = DBManager()
|
||||
token = db.create_web_bind_token(
|
||||
|
||||
+209
-73
@@ -2,9 +2,11 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Dict
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
import requests as _requests
|
||||
|
||||
from src.database.db_manager import DBManager
|
||||
from web.core import GrantPointsRequest
|
||||
@@ -29,19 +31,46 @@ def get_ops_weekly_leaderboard(request: Request, limit: int = 20) -> Dict[str, A
|
||||
return {"leaderboard": db.get_weekly_leaderboard(limit=limit)}
|
||||
|
||||
|
||||
def list_ops_memberships(request: Request, limit: int = 200) -> Dict[str, Any]:
|
||||
_require_ops(request)
|
||||
db = DBManager()
|
||||
if getattr(legacy_routes.PAYMENT_CHECKOUT, "enabled", False):
|
||||
try:
|
||||
legacy_routes.PAYMENT_CHECKOUT.reconcile_recent_intents(
|
||||
limit=min(max(int(limit or 200), 20), 200)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
subscriptions = legacy_routes.SUPABASE_ENTITLEMENT.list_active_subscriptions(
|
||||
limit=limit
|
||||
def _list_active_subscriptions_with_windows(
|
||||
limit: int,
|
||||
) -> tuple[list[dict[str, Any]], dict[str, dict[str, Any]], bool]:
|
||||
active_window_query = getattr(
|
||||
legacy_routes.SUPABASE_ENTITLEMENT,
|
||||
"list_active_subscription_windows",
|
||||
None,
|
||||
)
|
||||
if not callable(active_window_query):
|
||||
return [], {}, False
|
||||
try:
|
||||
active_window_payload = active_window_query(limit=limit)
|
||||
except Exception:
|
||||
return [], {}, False
|
||||
if not isinstance(active_window_payload, dict):
|
||||
return [], {}, False
|
||||
|
||||
active_window_subscriptions = active_window_payload.get("subscriptions")
|
||||
active_window_windows = active_window_payload.get("windows")
|
||||
if not isinstance(active_window_subscriptions, list):
|
||||
return [], {}, False
|
||||
if not active_window_subscriptions and not (
|
||||
isinstance(active_window_windows, dict) and active_window_windows
|
||||
):
|
||||
return [], {}, False
|
||||
|
||||
subscriptions = [
|
||||
item for item in active_window_subscriptions if isinstance(item, dict)
|
||||
]
|
||||
subscription_windows = (
|
||||
active_window_windows if isinstance(active_window_windows, dict) else {}
|
||||
)
|
||||
return subscriptions, subscription_windows, True
|
||||
|
||||
|
||||
def _build_membership_rows(
|
||||
db: DBManager,
|
||||
subscriptions: list[dict[str, Any]],
|
||||
subscription_windows: dict[str, dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
subscription_user_ids = [str(item.get("user_id") or "") for item in subscriptions]
|
||||
user_map = db.get_users_by_supabase_user_ids(subscription_user_ids)
|
||||
unresolved_user_ids = [
|
||||
@@ -56,18 +85,17 @@ def list_ops_memberships(request: Request, limit: int = 200) -> Dict[str, Any]:
|
||||
auth_user_map = legacy_routes.SUPABASE_ENTITLEMENT.get_auth_users(
|
||||
unresolved_user_ids
|
||||
)
|
||||
if not subscription_windows:
|
||||
subscription_windows = legacy_routes.SUPABASE_ENTITLEMENT.list_subscription_windows(
|
||||
subscription_user_ids,
|
||||
bypass_cache=True,
|
||||
)
|
||||
deduped: dict[str, dict] = {}
|
||||
for item in subscriptions:
|
||||
user_id = str(item.get("user_id") or "").strip().lower()
|
||||
local_user = user_map.get(user_id, {})
|
||||
auth_user = auth_user_map.get(user_id, {})
|
||||
subscription_window = (
|
||||
legacy_routes.SUPABASE_ENTITLEMENT.get_subscription_window(
|
||||
user_id,
|
||||
respect_requirement=False,
|
||||
bypass_cache=True,
|
||||
)
|
||||
)
|
||||
subscription_window = subscription_windows.get(user_id, {})
|
||||
current_expires_at = item.get("expires_at")
|
||||
total_expires_at = (
|
||||
subscription_window.get("total_expires_at")
|
||||
@@ -112,22 +140,20 @@ def list_ops_memberships(request: Request, limit: int = 200) -> Dict[str, Any]:
|
||||
current_expires = str(row.get("expires_at") or "")
|
||||
if existing is None or current_expires > existing_expires:
|
||||
deduped[user_id] = row
|
||||
rows = sorted(
|
||||
return sorted(
|
||||
deduped.values(),
|
||||
key=lambda item: str(item.get("expires_at") or ""),
|
||||
)
|
||||
return {"memberships": rows}
|
||||
|
||||
|
||||
def get_ops_memberships_growth(request: Request, days: int = 90) -> dict[str, Any]:
|
||||
_require_ops(request)
|
||||
def _build_membership_growth(
|
||||
subscriptions: list[dict[str, Any]],
|
||||
days: int,
|
||||
) -> dict[str, Any]:
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
safe_days = max(7, min(365, int(days or 90)))
|
||||
subscriptions = legacy_routes.SUPABASE_ENTITLEMENT.list_active_subscriptions(
|
||||
limit=5000
|
||||
)
|
||||
now = datetime.utcnow()
|
||||
cutoff = now - timedelta(days=safe_days)
|
||||
|
||||
@@ -178,6 +204,78 @@ def get_ops_memberships_growth(request: Request, days: int = 90) -> dict[str, An
|
||||
return {"days": safe_days, "daily": daily}
|
||||
|
||||
|
||||
def list_ops_memberships(request: Request, limit: int = 200) -> Dict[str, Any]:
|
||||
_require_ops(request)
|
||||
db = DBManager()
|
||||
reconcile_enabled = (
|
||||
str(os.getenv("POLYWEATHER_OPS_MEMBERSHIPS_RECONCILE_ENABLED") or "")
|
||||
.strip()
|
||||
.lower()
|
||||
in {"1", "true", "yes", "on"}
|
||||
)
|
||||
if reconcile_enabled and getattr(legacy_routes.PAYMENT_CHECKOUT, "enabled", False):
|
||||
try:
|
||||
legacy_routes.PAYMENT_CHECKOUT.reconcile_recent_intents(
|
||||
limit=min(max(int(limit or 200), 20), 200)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
subscriptions, subscription_windows, used_active_window_query = (
|
||||
_list_active_subscriptions_with_windows(limit)
|
||||
)
|
||||
if not used_active_window_query:
|
||||
subscriptions = legacy_routes.SUPABASE_ENTITLEMENT.list_active_subscriptions(
|
||||
limit=limit
|
||||
)
|
||||
return {
|
||||
"memberships": _build_membership_rows(
|
||||
db,
|
||||
subscriptions,
|
||||
subscription_windows,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def get_ops_memberships_growth(request: Request, days: int = 90) -> dict[str, Any]:
|
||||
_require_ops(request)
|
||||
|
||||
subscriptions, _, used_active_window_query = _list_active_subscriptions_with_windows(
|
||||
limit=5000
|
||||
)
|
||||
if not used_active_window_query:
|
||||
subscriptions = legacy_routes.SUPABASE_ENTITLEMENT.list_active_subscriptions(
|
||||
limit=5000
|
||||
)
|
||||
return _build_membership_growth(subscriptions, days)
|
||||
|
||||
|
||||
def get_ops_memberships_overview(
|
||||
request: Request,
|
||||
limit: int = 200,
|
||||
days: int = 90,
|
||||
) -> dict[str, Any]:
|
||||
_require_ops(request)
|
||||
db = DBManager()
|
||||
safe_limit = max(1, min(int(limit or 200), 1000))
|
||||
query_limit = max(safe_limit, 5000)
|
||||
subscriptions, subscription_windows, used_active_window_query = (
|
||||
_list_active_subscriptions_with_windows(limit=query_limit)
|
||||
)
|
||||
if not used_active_window_query:
|
||||
subscriptions = legacy_routes.SUPABASE_ENTITLEMENT.list_active_subscriptions(
|
||||
limit=query_limit
|
||||
)
|
||||
membership_subscriptions = subscriptions[:safe_limit]
|
||||
return {
|
||||
"memberships": _build_membership_rows(
|
||||
db,
|
||||
membership_subscriptions,
|
||||
subscription_windows,
|
||||
),
|
||||
**_build_membership_growth(subscriptions, days),
|
||||
}
|
||||
|
||||
|
||||
def list_ops_payment_incidents(
|
||||
request: Request,
|
||||
limit: int = 50,
|
||||
@@ -415,6 +513,59 @@ def update_ops_config(request: Request, key: str, value: str) -> dict[str, Any]:
|
||||
# ── Subscriptions ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _supabase_service_headers(
|
||||
service_role_key: str,
|
||||
*,
|
||||
prefer: str | None = None,
|
||||
) -> dict[str, str]:
|
||||
headers = {
|
||||
"apikey": service_role_key,
|
||||
"Authorization": f"Bearer {service_role_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
if prefer:
|
||||
headers["Prefer"] = prefer
|
||||
return headers
|
||||
|
||||
|
||||
def _lookup_supabase_user_id_by_email(
|
||||
supabase_url: str,
|
||||
service_role_key: str,
|
||||
email: str,
|
||||
) -> str:
|
||||
normalized_email = str(email or "").strip().lower()
|
||||
if not normalized_email:
|
||||
return ""
|
||||
base = str(supabase_url or "").strip().rstrip("/")
|
||||
headers = _supabase_service_headers(service_role_key)
|
||||
|
||||
profile_resp = _requests.get(
|
||||
f"{base}/rest/v1/profiles",
|
||||
headers=headers,
|
||||
params={
|
||||
"select": "id",
|
||||
"email": f"eq.{normalized_email}",
|
||||
"limit": "1",
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
if profile_resp.ok:
|
||||
profiles = profile_resp.json() if profile_resp.content else []
|
||||
if isinstance(profiles, list) and profiles:
|
||||
user_id = str((profiles[0] or {}).get("id") or "").strip()
|
||||
if user_id:
|
||||
return user_id
|
||||
|
||||
user_resp = _requests.get(
|
||||
f"{base}/auth/v1/admin/users",
|
||||
headers=headers,
|
||||
params={"filter": f"email.eq.{normalized_email}"},
|
||||
timeout=10,
|
||||
)
|
||||
users = user_resp.json().get("users", []) if user_resp.ok else []
|
||||
return str(users[0].get("id") or "").strip() if users else ""
|
||||
|
||||
|
||||
def grant_ops_subscription(
|
||||
request: Request,
|
||||
email: str,
|
||||
@@ -423,9 +574,7 @@ def grant_ops_subscription(
|
||||
deduct_points: int = 0,
|
||||
) -> dict[str, Any]:
|
||||
_require_ops(request)
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
import requests as _requests
|
||||
|
||||
supabase_url = str(os.getenv("SUPABASE_URL") or "").strip().rstrip("/")
|
||||
service_role_key = str(os.getenv("SUPABASE_SERVICE_ROLE_KEY") or "").strip()
|
||||
@@ -444,22 +593,11 @@ def grant_ops_subscription(
|
||||
if not normalized_email:
|
||||
raise HTTPException(status_code=400, detail="email is required")
|
||||
|
||||
headers = {
|
||||
"apikey": service_role_key,
|
||||
"Authorization": f"Bearer {service_role_key}",
|
||||
"Content-Type": "application/json",
|
||||
"Prefer": "return=representation",
|
||||
}
|
||||
|
||||
# Look up user by email
|
||||
user_resp = _requests.get(
|
||||
f"{supabase_url}/auth/v1/admin/users",
|
||||
headers=headers,
|
||||
params={"filter": f"email.eq.{normalized_email}"},
|
||||
timeout=10,
|
||||
user_id = _lookup_supabase_user_id_by_email(
|
||||
supabase_url,
|
||||
service_role_key,
|
||||
normalized_email,
|
||||
)
|
||||
users = user_resp.json().get("users", []) if user_resp.ok else []
|
||||
user_id = users[0].get("id") if users else None
|
||||
if not user_id:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"user not found: {normalized_email}"
|
||||
@@ -481,7 +619,7 @@ def grant_ops_subscription(
|
||||
|
||||
resp = _requests.post(
|
||||
f"{supabase_url}/rest/v1/subscriptions",
|
||||
headers=headers,
|
||||
headers=_supabase_service_headers(service_role_key, prefer="return=minimal"),
|
||||
json=payload,
|
||||
timeout=10,
|
||||
)
|
||||
@@ -489,6 +627,7 @@ def grant_ops_subscription(
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Supabase insert failed: {resp.text[:200]}"
|
||||
)
|
||||
legacy_routes.SUPABASE_ENTITLEMENT.invalidate_subscription_cache(user_id)
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"ok": True,
|
||||
@@ -516,9 +655,7 @@ def extend_ops_subscription(
|
||||
additional_days: int = 30,
|
||||
) -> dict[str, Any]:
|
||||
_require_ops(request)
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
import requests as _requests
|
||||
|
||||
supabase_url = str(os.getenv("SUPABASE_URL") or "").strip().rstrip("/")
|
||||
service_role_key = str(os.getenv("SUPABASE_SERVICE_ROLE_KEY") or "").strip()
|
||||
@@ -530,20 +667,26 @@ def extend_ops_subscription(
|
||||
if not normalized_email:
|
||||
raise HTTPException(status_code=400, detail="email is required")
|
||||
|
||||
headers = {
|
||||
"apikey": service_role_key,
|
||||
"Authorization": f"Bearer {service_role_key}",
|
||||
"Content-Type": "application/json",
|
||||
"Prefer": "return=representation",
|
||||
}
|
||||
headers = _supabase_service_headers(service_role_key)
|
||||
|
||||
user_id = _lookup_supabase_user_id_by_email(
|
||||
supabase_url,
|
||||
service_role_key,
|
||||
normalized_email,
|
||||
)
|
||||
if not user_id:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"user not found: {normalized_email}"
|
||||
)
|
||||
|
||||
# Find latest active subscription
|
||||
subs_resp = _requests.get(
|
||||
f"{supabase_url}/rest/v1/subscriptions",
|
||||
headers=headers,
|
||||
params={
|
||||
"select": "*",
|
||||
"email": f"eq.{normalized_email}",
|
||||
"select": "id,expires_at",
|
||||
"user_id": f"eq.{user_id}",
|
||||
"status": "eq.active",
|
||||
"order": "expires_at.desc",
|
||||
"limit": "1",
|
||||
},
|
||||
@@ -565,11 +708,12 @@ def extend_ops_subscription(
|
||||
|
||||
patch_resp = _requests.patch(
|
||||
f"{supabase_url}/rest/v1/subscriptions?id=eq.{sub['id']}",
|
||||
headers=headers,
|
||||
headers=_supabase_service_headers(service_role_key, prefer="return=minimal"),
|
||||
json={"expires_at": new_expiry},
|
||||
timeout=10,
|
||||
)
|
||||
if patch_resp.ok:
|
||||
legacy_routes.SUPABASE_ENTITLEMENT.invalidate_subscription_cache(user_id)
|
||||
return {
|
||||
"ok": True,
|
||||
"email": normalized_email,
|
||||
@@ -587,8 +731,6 @@ def get_ops_user_subscriptions(
|
||||
) -> dict[str, Any]:
|
||||
"""Return ALL subscription rows for a user (by email), regardless of status."""
|
||||
_require_ops(request)
|
||||
import os
|
||||
import requests as _requests
|
||||
|
||||
supabase_url = str(os.getenv("SUPABASE_URL") or "").strip().rstrip("/")
|
||||
service_role_key = str(os.getenv("SUPABASE_SERVICE_ROLE_KEY") or "").strip()
|
||||
@@ -599,21 +741,13 @@ def get_ops_user_subscriptions(
|
||||
if not normalized_email:
|
||||
raise HTTPException(status_code=400, detail="email is required")
|
||||
|
||||
headers = {
|
||||
"apikey": service_role_key,
|
||||
"Authorization": f"Bearer {service_role_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
headers = _supabase_service_headers(service_role_key)
|
||||
|
||||
# Resolve user_id from email via auth admin API
|
||||
user_resp = _requests.get(
|
||||
f"{supabase_url}/auth/v1/admin/users",
|
||||
headers=headers,
|
||||
params={"filter": f"email.eq.{normalized_email}"},
|
||||
timeout=10,
|
||||
user_id = _lookup_supabase_user_id_by_email(
|
||||
supabase_url,
|
||||
service_role_key,
|
||||
normalized_email,
|
||||
)
|
||||
users = user_resp.json().get("users", []) if user_resp.ok else []
|
||||
user_id = users[0].get("id") if users else None
|
||||
if not user_id:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"user not found: {normalized_email}"
|
||||
@@ -1134,11 +1268,13 @@ def get_ops_telegram_audit(request: Request) -> Dict[str, Any]:
|
||||
anomalies = []
|
||||
valid_members = []
|
||||
|
||||
from web.services.ops_api import legacy_routes
|
||||
|
||||
active_subs = legacy_routes.SUPABASE_ENTITLEMENT.list_active_subscriptions(
|
||||
active_subs, _, used_active_window_query = _list_active_subscriptions_with_windows(
|
||||
limit=5000
|
||||
)
|
||||
if not used_active_window_query:
|
||||
active_subs = legacy_routes.SUPABASE_ENTITLEMENT.list_active_subscriptions(
|
||||
limit=5000
|
||||
)
|
||||
active_subs_map = {}
|
||||
for sub in active_subs:
|
||||
uid = str(sub.get("user_id") or "").strip().lower()
|
||||
|
||||
@@ -27,6 +27,7 @@ def _raise_payment_error(exc: Exception) -> None:
|
||||
|
||||
|
||||
def _require_payment_identity(request: Request) -> Dict[str, Any]:
|
||||
request.state.skip_subscription_gate = True
|
||||
legacy_routes._assert_entitlement(request)
|
||||
identity = legacy_routes._require_supabase_identity(request)
|
||||
user_id = str(identity.get("user_id") or "").strip()
|
||||
@@ -36,7 +37,6 @@ def _require_payment_identity(request: Request) -> Dict[str, Any]:
|
||||
|
||||
|
||||
def get_payment_config(request: Request) -> Dict[str, Any]:
|
||||
legacy_routes._assert_entitlement(request)
|
||||
try:
|
||||
return legacy_routes.PAYMENT_CHECKOUT.get_config_payload()
|
||||
except legacy_routes.PaymentCheckoutError as exc:
|
||||
|
||||
Reference in New Issue
Block a user