feat(frontend): read user profile + plan from V2 Identity instead of Supabase

getUserProfile now calls the gateway /v1/users/me and /v1/users/me/plan with
the access-token cookie, mapping plan_code → PlanId. Falls back to a free-plan
profile when signed out or Identity is unreachable. Stripe ids drop to null
(V2 billing runs through the payments service). Signature unchanged so the
dashboard plan badge + settings call sites are untouched.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-05-30 06:02:17 +03:30
parent 14cdb772b4
commit 6d2a296c38
+69 -33
View File
@@ -1,5 +1,6 @@
import { gatewayUrl } from "@/lib/api/gateway";
import { getAccessToken } from "@/lib/auth/session";
import type { PlanId } from "@/lib/plans";
import { createClient } from "@/lib/supabase/server";
export interface UserProfile {
id: string;
@@ -10,45 +11,80 @@ export interface UserProfile {
stripe_subscription_id: string | null;
}
export async function getUserProfile(userId: string): Promise<UserProfile> {
const supabase = await createClient();
// ── V2 identity response shapes (snake_case JSON) ────────────────────────────
const { data, error } = await supabase
.from("profiles")
.select("id, email, plan, billing_period, stripe_customer_id, stripe_subscription_id")
.eq("id", userId)
.maybeSingle();
interface V2User {
id: string;
email?: string | null;
[key: string]: unknown;
}
if (error) {
interface V2UserPlan {
id: string;
plan_id: string;
plan_code: string;
plan_name: string;
initial_seconds_charge: number;
remain_charge_sec: number;
monthly_renders_used: number;
starts_at: string;
expires_at: string;
cancelled_at?: string | null;
auto_renew: boolean;
billing_period?: string | null;
}
function fallbackProfile(userId: string, email: string | null = null): UserProfile {
return {
id: userId,
email: null,
email,
plan: "free",
billing_period: null,
// V2 billing runs through the payments service (ZarinPal/Stripe); Stripe ids
// are no longer surfaced on the profile. Kept null for shape compatibility.
stripe_customer_id: null,
stripe_subscription_id: null,
};
}
if (!data) {
return {
id: userId,
email: null,
plan: "free",
billing_period: null,
stripe_customer_id: null,
stripe_subscription_id: null,
};
}
const plan = data.plan as PlanId;
return {
id: data.id,
email: data.email,
plan: plan === "pro" || plan === "business" ? plan : "free",
billing_period: data.billing_period,
stripe_customer_id: data.stripe_customer_id,
stripe_subscription_id: data.stripe_subscription_id,
};
}
function normalizePlan(code: string | null | undefined): PlanId {
const c = (code ?? "").toLowerCase();
return c === "pro" || c === "business" ? c : "free";
}
/**
* Read the current user's profile + plan from the Identity service via the
* gateway, authenticated with the access-token cookie. The `userId` argument is
* retained for call-site compatibility but the gateway derives identity from the
* JWT. Degrades to a free-plan fallback when signed out or the service is down.
*/
export async function getUserProfile(userId: string): Promise<UserProfile> {
const token = await getAccessToken();
if (!token) return fallbackProfile(userId);
const authHeaders = {
Accept: "application/json",
Authorization: `Bearer ${token}`,
};
try {
const [userRes, planRes] = await Promise.all([
fetch(gatewayUrl("/v1/users/me"), { cache: "no-store", headers: authHeaders }),
fetch(gatewayUrl("/v1/users/me/plan"), { cache: "no-store", headers: authHeaders }),
]);
const user = userRes.ok ? ((await userRes.json().catch(() => null)) as V2User | null) : null;
const plan = planRes.ok ? ((await planRes.json().catch(() => null)) as V2UserPlan | null) : null;
return {
id: user?.id ?? userId,
email: user?.email ?? null,
plan: normalizePlan(plan?.plan_code),
billing_period: plan?.billing_period ?? null,
stripe_customer_id: null,
stripe_subscription_id: null,
};
} catch {
return fallbackProfile(userId);
}
}