Files
Jyotisha/frontend/src/app/api/account/route.ts
T

309 lines
14 KiB
TypeScript

import { NextResponse } from "next/server";
import {
parseRectificationPriceCredits,
} from "@/lib/birth-time-consultation-consent";
import { resolveAccountRectificationCase, resolveAccountRectificationV4Case } from "@/lib/account-rectification-case";
import {
accountProfilePatchSchema,
applyAccountProfileConcurrencyGuards,
resolveAccountBirthTimeApplicationPatch,
} from "@/lib/account-profile-patch";
import { createAdminSupabaseClient, isAdminUser } from "@/lib/supabase/admin";
import {
isSupabaseConfigurationError,
} from "@/lib/supabase/config";
import { createServerSupabaseClient } from "@/lib/supabase/server";
import { readSelfHostedIdentityConfig } from "@/modules/identity/config";
export const runtime = "nodejs";
const unfinishedRectificationStatuses = ["starting", "active", "paused", "confirming"] as const;
function adminEntryUrl(): string {
return process.env.AUTH_PROVIDER?.trim() === "self-hosted"
? `${readSelfHostedIdentityConfig(process.env).adminOrigin}/admin`
: "/admin";
}
function isMissingProfileColumn(error: { code?: string; message?: string } | null) {
const message = error?.message?.toLowerCase() ?? "";
return error?.code === "PGRST204"
|| error?.code === "42703"
|| message.includes("schema cache")
|| message.includes("column");
}
function isMissingRectificationV4Relation(error: { code?: string; message?: string } | null) {
const message = error?.message?.toLowerCase() ?? "";
return error?.code === "42P01"
|| error?.code === "PGRST205"
|| message.includes('relation "public.birth_time_rectification_v4_cases" does not exist')
|| (message.includes("schema cache") && message.includes("birth_time_rectification_v4_cases"));
}
export async function GET() {
try {
const supabase = await createServerSupabaseClient();
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const userId = user.id;
const rectificationPriceCredits = parseRectificationPriceCredits(
process.env.RECTIFICATION_PRICE_CREDITS,
);
const admin = createAdminSupabaseClient();
const { data: rectificationV4CaseData, error: rectificationV4CaseError } = await admin
.from("birth_time_rectification_v4_cases")
.select("id,status,version,accepted_range_start,updated_at")
.eq("user_id", user.id)
.neq("status", "abandoned")
.is("accepted_range_start", null)
.order("updated_at", { ascending: false })
.limit(1);
if (rectificationV4CaseError && !isMissingRectificationV4Relation(rectificationV4CaseError)) {
return NextResponse.json({ error: "暂时无法读取生时校正状态" }, { status: 500 });
}
const rectificationV4CaseRows = rectificationV4CaseError ? [] : rectificationV4CaseData;
const { data: rectificationCaseRows, error: rectificationCaseError } = await admin
.from("birth_time_rectification_cases")
.select("id,journey_protocol,status,turn_version,revision_of_case_id,baseline_active_time,declared_birth_input,updated_at")
.eq("user_id", user.id)
.eq("journey_protocol", "conversational-evidence-v3")
.in("status", [...unfinishedRectificationStatuses])
.order("updated_at", { ascending: false })
.limit(50);
if (rectificationCaseError) {
return NextResponse.json({ error: "暂时无法读取生时校正状态" }, { status: 500 });
}
// Read the profile after the case snapshot. If a declaration edit races
// with this request, matching uses the later profile and cannot resurrect
// an older case. A concurrently created case simply appears on refresh.
let { data: profile, error: profileError } = await supabase
.from("profiles")
.select("credits,active_birth_time,birth_time_status,birth_date,reported_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,timezone_id,timezone_source,name,birth_time,rectification_case_id")
.eq("id", userId)
.single();
if (profileError && isMissingProfileColumn(profileError)) {
const fallback = await supabase
.from("profiles")
.select("credits,active_birth_time,birth_time_status,birth_date,reported_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,name,birth_time,rectification_case_id")
.eq("id", userId)
.single();
profile = fallback.data ? {
...fallback.data,
birth_place_label: undefined,
birth_place_type: undefined,
birth_place_provider: undefined,
birth_place_provider_id: undefined,
timezone_id: undefined,
timezone_source: undefined,
} : null;
profileError = fallback.error;
}
if (profileError || !profile) {
return NextResponse.json({ error: "暂时无法读取账户余额" }, { status: 500 });
}
const rectificationCase = resolveAccountRectificationV4Case(
Array.isArray(rectificationV4CaseRows) ? rectificationV4CaseRows : [],
) ?? resolveAccountRectificationCase(
profile,
Array.isArray(rectificationCaseRows) ? rectificationCaseRows : [],
);
const { data: activeSubscriptions, error: subscriptionError } = await admin
.from("user_subscriptions")
.select("id,status,starts_at,ends_at,product_code,product_version,product_snapshot,entitlement_snapshot")
.eq("user_id", userId)
.eq("status", "active")
.lte("starts_at", new Date().toISOString())
.gt("ends_at", new Date().toISOString())
.order("ends_at", { ascending: true })
.limit(1);
if (subscriptionError) {
return NextResponse.json({ error: "暂时无法读取会员状态" }, { status: 500 });
}
const activeSubscription = activeSubscriptions?.[0] ?? null;
const isAdmin = await isAdminUser(user);
const adminUrl = isAdmin ? adminEntryUrl() : null;
return NextResponse.json({
user: { id: user.id, email: user.email ?? null },
credits: profile.credits,
isAdmin,
adminUrl,
rectificationPriceCredits,
activeSubscription: activeSubscription ? {
id: activeSubscription.id,
status: activeSubscription.status,
startsAt: activeSubscription.starts_at,
endsAt: activeSubscription.ends_at,
productCode: activeSubscription.product_code,
productVersion: activeSubscription.product_version,
product: activeSubscription.product_snapshot,
entitlements: activeSubscription.entitlement_snapshot,
} : null,
hasConfirmedBirthTime: profile.birth_time_status === "confirmed"
&& typeof profile.active_birth_time === "string",
hasUsableBirthTime: (profile.birth_time_status === "accepted" || profile.birth_time_status === "confirmed")
&& typeof profile.active_birth_time === "string",
rectificationCase,
profile,
birthLocation: {
label: profile.birth_place_label ?? null,
placeType: profile.birth_place_type ?? null,
provider: profile.birth_place_provider ?? null,
providerPlaceId: profile.birth_place_provider_id ?? null,
timezoneId: profile.timezone_id ?? null,
timezoneSource: profile.timezone_source ?? null,
timezoneOffset: profile.timezone_offset ?? null,
latitude: profile.latitude ?? null,
longitude: profile.longitude ?? null,
},
});
} catch (error) {
if (isSupabaseConfigurationError(error)) {
return NextResponse.json({ error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, { status: 503 });
}
return NextResponse.json({ error: "账户服务暂时不可用" }, { status: 500 });
}
}
export async function PATCH(request: Request) {
try {
const supabase = await createServerSupabaseClient();
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const userId = user.id;
const parsedPayload = accountProfilePatchSchema.safeParse(
await request.json().catch(() => null),
);
if (!parsedPayload.success) return NextResponse.json({
error: "账户资料格式不正确",
details: parsedPayload.error.flatten(),
}, { status: 400 });
const payload = parsedPayload.data;
const admin = createAdminSupabaseClient();
let { data: currentProfile, error: currentProfileError } = await admin
.from("profiles")
.select("birth_date,birth_time,reported_birth_time,active_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,birth_time_status,rectification_case_id,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,timezone_id,timezone_source")
.eq("id", userId)
.maybeSingle();
if (currentProfileError && isMissingProfileColumn(currentProfileError)) {
const fallback = await admin
.from("profiles")
.select("birth_date,birth_time,reported_birth_time,active_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,birth_time_status,rectification_case_id,country_code,province_code,city_code,district_code")
.eq("id", userId)
.maybeSingle();
currentProfile = fallback.data ? {
...fallback.data,
latitude: undefined,
longitude: undefined,
timezone_offset: undefined,
birth_place_label: undefined,
birth_place_type: undefined,
birth_place_provider: undefined,
birth_place_provider_id: undefined,
timezone_id: undefined,
timezone_source: undefined,
} : null;
currentProfileError = fallback.error;
}
if (currentProfileError) {
return NextResponse.json({ error: "暂时无法核对现有出生资料" }, { status: 500 });
}
const applicationPatch = resolveAccountBirthTimeApplicationPatch(currentProfile, payload);
const baseProfile = {
id: userId,
...(payload.name !== undefined ? { name: payload.name } : {}),
...(payload.birth_date !== undefined ? { birth_date: payload.birth_date } : {}),
...(payload.reported_birth_time !== undefined
? { reported_birth_time: payload.reported_birth_time }
: {}),
...(payload.birth_time_source !== undefined
? { birth_time_source: payload.birth_time_source }
: {}),
...(payload.birth_time_period !== undefined
? { birth_time_period: payload.birth_time_period }
: {}),
...(payload.birth_time_clue !== undefined
? { birth_time_clue: payload.birth_time_clue }
: {}),
...(payload.uncertainty_before_minutes !== undefined
? { uncertainty_before_minutes: payload.uncertainty_before_minutes }
: {}),
...(payload.uncertainty_after_minutes !== undefined
? { uncertainty_after_minutes: payload.uncertainty_after_minutes }
: {}),
...(payload.country_code !== undefined ? { country_code: payload.country_code } : {}),
...(payload.province_code !== undefined ? { province_code: payload.province_code } : {}),
...(payload.city_code !== undefined ? { city_code: payload.city_code } : {}),
...(payload.district_code !== undefined ? { district_code: payload.district_code } : {}),
...applicationPatch,
updated_at: new Date().toISOString(),
};
const withCoordinates = {
...baseProfile,
...(payload.latitude !== undefined ? { latitude: payload.latitude } : {}),
...(payload.longitude !== undefined ? { longitude: payload.longitude } : {}),
...(payload.timezone_offset !== undefined ? { timezone_offset: payload.timezone_offset } : {}),
...(payload.birth_place_label !== undefined ? { birth_place_label: payload.birth_place_label } : {}),
...(payload.birth_place_type !== undefined ? { birth_place_type: payload.birth_place_type } : {}),
...(payload.birth_place_provider !== undefined ? { birth_place_provider: payload.birth_place_provider } : {}),
...(payload.birth_place_provider_id !== undefined ? { birth_place_provider_id: payload.birth_place_provider_id } : {}),
...(payload.timezone_id !== undefined ? { timezone_id: payload.timezone_id } : {}),
...(payload.timezone_source !== undefined ? { timezone_source: payload.timezone_source } : {}),
};
const withoutCoordinates = baseProfile;
const invalidatesUnconfirmedApplication = Object.keys(applicationPatch).length > 0;
async function writeProfile(values: Record<string, unknown>) {
if (!currentProfile) {
return admin
.from("profiles")
.upsert(values, { onConflict: "id" })
.select("id")
.maybeSingle();
}
let query = admin.from("profiles").update(values).eq("id", userId);
if (invalidatesUnconfirmedApplication) {
query = applyAccountProfileConcurrencyGuards(query, currentProfile);
}
return query.select("id").maybeSingle();
}
let { data, error } = await writeProfile(withCoordinates);
if (error && isMissingProfileColumn(error)) {
const fallback = await writeProfile(withoutCoordinates);
data = fallback.data;
error = fallback.error;
}
if (!error && !data && invalidatesUnconfirmedApplication) {
return NextResponse.json({
error: "出生时间状态已经变化",
message: "最新确认结果已保留,请刷新后重新编辑。",
}, { status: 409 });
}
if (error || !data) {
return NextResponse.json({ error: "暂时无法保存账户资料" }, { status: 500 });
}
return NextResponse.json({ ok: true });
} catch (error) {
if (isSupabaseConfigurationError(error)) {
return NextResponse.json({ error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, { status: 503 });
}
return NextResponse.json({ error: "账户服务暂时不可用" }, { status: 500 });
}
}