feat(sase): brand picker — Phase F
Pairs with sase.tr#31 (POST /internal/admin/subscriptions/:id/brands).
Admin SDK
- setSubscriptionBrands({ subscriptionId, brandIds, reason, founderId }).
- BrandSetResult type; SASE_ADMIN_ENDPOINTS list updated.
Repo
- listBrands() helper (RO).
- getUser() now returns currentPlanBrandCount + brandIds for the picker.
Route
- POST /api/sase/subscriptions/[subId]/brands — auth + spoke-wired,
brandIds array required, reason ≥ 5 chars. Audit on both paths.
UI (BrandPicker on the Billing tab, below subscription actions)
- Full plan (brandCount=0): shows "all brands automatic" badge list,
no picker.
- Active/trial with brandCount > 0: checkbox grid of all active brands.
Clicking past the plan limit shows an inline error. Visual deltas:
outline "kaldırılacak" badge on rows being removed, default "yeni"
badge on rows being added.
- Cancelled/expired: read-only message.
- Save button enabled only when selection is exactly plan.brandCount,
there's a dirty diff, and a reason ≥ 5 chars is entered. Saves +
router.refresh().
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { headers } from "next/headers";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { createSaseAdmin, saseAdminWired } from "@/lib/admin-sdk/sase";
|
||||
import { writeAudit } from "@/lib/audit";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(
|
||||
req: Request,
|
||||
ctx: { params: Promise<{ subId: string }> },
|
||||
) {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, error: "unauthenticated" }, { status: 401 });
|
||||
}
|
||||
if (!saseAdminWired()) {
|
||||
return NextResponse.json({ ok: false, error: "spoke_not_wired" }, { status: 503 });
|
||||
}
|
||||
|
||||
const { subId } = await ctx.params;
|
||||
const body = (await req.json().catch(() => ({}))) as {
|
||||
brandIds?: string[];
|
||||
reason?: string;
|
||||
};
|
||||
if (!Array.isArray(body.brandIds)) {
|
||||
return NextResponse.json({ ok: false, error: "brandIds_required" }, { status: 400 });
|
||||
}
|
||||
const reason = (body.reason ?? "").trim();
|
||||
if (reason.length < 5) {
|
||||
return NextResponse.json({ ok: false, error: "reason_required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const endpoint = `/api/sase/subscriptions/${subId}/brands`;
|
||||
try {
|
||||
const sdk = createSaseAdmin();
|
||||
const result = await sdk.setSubscriptionBrands({
|
||||
subscriptionId: subId,
|
||||
brandIds: body.brandIds,
|
||||
reason,
|
||||
founderId: session.user.id,
|
||||
});
|
||||
|
||||
await writeAudit({
|
||||
projectKey: "sase",
|
||||
endpoint,
|
||||
method: "POST",
|
||||
requestPayload: {
|
||||
subId,
|
||||
brandCount: body.brandIds.length,
|
||||
reasonLen: reason.length,
|
||||
},
|
||||
responseStatus: 200,
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true, result });
|
||||
} catch (err) {
|
||||
const status = (err as { status?: number } | undefined)?.status ?? 500;
|
||||
const message = err instanceof Error ? err.message : "unknown";
|
||||
await writeAudit({
|
||||
projectKey: "sase",
|
||||
endpoint,
|
||||
method: "POST",
|
||||
requestPayload: { subId, brandCount: body.brandIds.length, reasonLen: reason.length },
|
||||
responseStatus: status,
|
||||
});
|
||||
return NextResponse.json({ ok: false, error: message }, { status });
|
||||
}
|
||||
}
|
||||
222
apps/web/src/app/projects/sase/users/[id]/_brand-picker.tsx
Normal file
222
apps/web/src/app/projects/sase/users/[id]/_brand-picker.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
} from "@/components/ui/card";
|
||||
|
||||
type Brand = { id: string; name: string; slug: string };
|
||||
|
||||
export function BrandPicker({
|
||||
subscriptionId,
|
||||
subscriptionStatus,
|
||||
planBrandCount,
|
||||
currentBrandIds,
|
||||
allBrands,
|
||||
}: {
|
||||
subscriptionId: string;
|
||||
subscriptionStatus: string;
|
||||
planBrandCount: number | null;
|
||||
currentBrandIds: string[];
|
||||
allBrands: Brand[];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [selected, setSelected] = useState<Set<string>>(
|
||||
new Set(currentBrandIds),
|
||||
);
|
||||
const [reason, setReason] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [info, setInfo] = useState<string | null>(null);
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
const canEdit =
|
||||
(subscriptionStatus === "active" || subscriptionStatus === "trial") &&
|
||||
planBrandCount !== null &&
|
||||
planBrandCount > 0;
|
||||
|
||||
// Full plan (brandCount = 0) auto-grants all brands; no picker.
|
||||
if (planBrandCount === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>Marka erişimi</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
<p>Full plan — tüm aktif markalara otomatik erişim ({allBrands.length}).</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{allBrands.map((b) => (
|
||||
<Badge key={b.id} variant="secondary" className="font-mono">
|
||||
{b.slug}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!canEdit) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>Marka erişimi</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm text-muted-foreground">
|
||||
{subscriptionStatus === "cancelled" || subscriptionStatus === "expired"
|
||||
? `'${subscriptionStatus}' subscription — düzenlenemez.`
|
||||
: "Subscription yok ya da plan tanımlı değil."}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function toggle(id: string) {
|
||||
setError(null);
|
||||
setInfo(null);
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
if (next.size >= (planBrandCount ?? 0)) {
|
||||
setError(`Maksimum ${planBrandCount} marka seçebilirsin. Önce birini çıkar.`);
|
||||
return prev;
|
||||
}
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function submit() {
|
||||
setError(null);
|
||||
setInfo(null);
|
||||
if (selected.size !== planBrandCount) {
|
||||
setError(
|
||||
`Tam olarak ${planBrandCount} marka seçilmeli (şu an ${selected.size}).`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (reason.trim().length < 5) {
|
||||
setError("Sebep en az 5 karakter olmalı.");
|
||||
return;
|
||||
}
|
||||
startTransition(async () => {
|
||||
const res = await fetch(`/api/sase/subscriptions/${subscriptionId}/brands`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
brandIds: Array.from(selected),
|
||||
reason: reason.trim(),
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
setError(data.error ?? `HTTP ${res.status}`);
|
||||
return;
|
||||
}
|
||||
setInfo("Markalar güncellendi.");
|
||||
setReason("");
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
const dirty =
|
||||
selected.size !== currentBrandIds.length ||
|
||||
Array.from(selected).some((id) => !currentBrandIds.includes(id));
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>
|
||||
Marka erişimi · seçilen {selected.size}/{planBrandCount}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<ul className="grid grid-cols-2 gap-1 md:grid-cols-3">
|
||||
{allBrands.map((b) => {
|
||||
const isSelected = selected.has(b.id);
|
||||
const isCurrent = currentBrandIds.includes(b.id);
|
||||
return (
|
||||
<li key={b.id}>
|
||||
<label className="flex items-center gap-2 rounded-md border p-2 text-sm hover:bg-muted/30">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => toggle(b.id)}
|
||||
/>
|
||||
<span className="flex-1">{b.name}</span>
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{b.slug}
|
||||
</span>
|
||||
{isCurrent && !isSelected && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
kaldırılacak
|
||||
</Badge>
|
||||
)}
|
||||
{!isCurrent && isSelected && (
|
||||
<Badge variant="default" className="text-xs">
|
||||
yeni
|
||||
</Badge>
|
||||
)}
|
||||
</label>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{dirty && (
|
||||
<Textarea
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="Sebep (zorunlu, audit'e geçer)"
|
||||
rows={2}
|
||||
maxLength={500}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs">
|
||||
{error && <span className="text-destructive">{error}</span>}
|
||||
{info && <span className="text-emerald-600">{info}</span>}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{dirty && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSelected(new Set(currentBrandIds));
|
||||
setReason("");
|
||||
setError(null);
|
||||
}}
|
||||
disabled={pending}
|
||||
>
|
||||
Sıfırla
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={submit}
|
||||
disabled={
|
||||
pending ||
|
||||
!dirty ||
|
||||
selected.size !== planBrandCount ||
|
||||
reason.trim().length < 5
|
||||
}
|
||||
>
|
||||
{pending ? "..." : "Kaydet"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { getUser, listPlans } from "@/lib/sase/users";
|
||||
import { getUser, listBrands, listPlans } from "@/lib/sase/users";
|
||||
import {
|
||||
getUserUsageStats,
|
||||
getUserTimeline,
|
||||
@@ -34,6 +34,7 @@ import { NotesTab } from "./_notes";
|
||||
import { LifecycleButtons, LifecycleStatusBadge } from "./_lifecycle-buttons";
|
||||
import { BillingActions } from "./_billing-actions";
|
||||
import { RefundButton } from "./_refund-button";
|
||||
import { BrandPicker } from "./_brand-picker";
|
||||
import { saseAdminWired } from "@/lib/admin-sdk/sase";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -47,11 +48,12 @@ export default async function SaseUserDetailPage({
|
||||
const user = await getUser(id);
|
||||
if (!user) notFound();
|
||||
|
||||
const [usage, timeline, audit, plans] = await Promise.all([
|
||||
const [usage, timeline, audit, plans, brands] = await Promise.all([
|
||||
getUserUsageStats(id),
|
||||
getUserTimeline(id, 80),
|
||||
getUserAuditTrail(id, 50),
|
||||
listPlans(),
|
||||
listBrands(),
|
||||
]);
|
||||
|
||||
return (
|
||||
@@ -188,6 +190,16 @@ export default async function SaseUserDetailPage({
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{saseAdminWired() && user.subscriptionId && user.subscriptionStatus && (
|
||||
<BrandPicker
|
||||
subscriptionId={user.subscriptionId}
|
||||
subscriptionStatus={user.subscriptionStatus}
|
||||
planBrandCount={user.currentPlanBrandCount}
|
||||
currentBrandIds={user.brandIds}
|
||||
allBrands={brands}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
|
||||
@@ -101,6 +101,23 @@ export type SaseAdmin = {
|
||||
reason: string;
|
||||
founderId: string;
|
||||
}): Promise<RefundResult>;
|
||||
|
||||
/** Replace the brand set on an active/trial subscription. */
|
||||
setSubscriptionBrands(input: {
|
||||
subscriptionId: string;
|
||||
brandIds: string[];
|
||||
reason: string;
|
||||
founderId: string;
|
||||
}): Promise<BrandSetResult>;
|
||||
};
|
||||
|
||||
export type BrandSetResult = {
|
||||
success: boolean;
|
||||
subscriptionId: string;
|
||||
userId: string;
|
||||
planBrandCount: number;
|
||||
previousBrandIds: string[];
|
||||
newBrandIds: string[];
|
||||
};
|
||||
|
||||
export type RefundResult = {
|
||||
@@ -233,6 +250,12 @@ export function createSaseAdmin(): SaseAdmin {
|
||||
reason: input.reason,
|
||||
founderId: input.founderId,
|
||||
}),
|
||||
setSubscriptionBrands: (input) =>
|
||||
client.call("POST", `/internal/admin/subscriptions/${input.subscriptionId}/brands`, {
|
||||
brandIds: input.brandIds,
|
||||
reason: input.reason,
|
||||
founderId: input.founderId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -256,6 +279,7 @@ function notWiredSdk(projectKey: string): SaseAdmin {
|
||||
cancelSubscriptionById: () => Promise.reject(reject()),
|
||||
resumeSubscription: () => Promise.reject(reject()),
|
||||
refundPayment: () => Promise.reject(reject()),
|
||||
setSubscriptionBrands: () => Promise.reject(reject()),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -277,4 +301,5 @@ export const SASE_ADMIN_ENDPOINTS = [
|
||||
"POST /internal/admin/subscriptions/:id/cancel",
|
||||
"POST /internal/admin/subscriptions/:id/resume",
|
||||
"POST /internal/admin/payments/:id/refund",
|
||||
"POST /internal/admin/subscriptions/:id/brands",
|
||||
];
|
||||
|
||||
@@ -189,6 +189,8 @@ export type UserDetail = UserRow & {
|
||||
statusChangedAt: Date | null;
|
||||
subscriptionId: string | null;
|
||||
currentPlanId: string | null;
|
||||
currentPlanBrandCount: number | null;
|
||||
brandIds: string[];
|
||||
payments: Array<{
|
||||
id: string;
|
||||
amount: number;
|
||||
@@ -244,6 +246,8 @@ export async function getUser(id: string): Promise<UserDetail | null> {
|
||||
statusChangedAt: u.statusChangedAt,
|
||||
subscriptionId: sub?.id ?? null,
|
||||
currentPlanId: sub?.planId ?? null,
|
||||
currentPlanBrandCount: sub?.plan.brandCount ?? null,
|
||||
brandIds: sub?.brands.map((b) => b.brandId) ?? [],
|
||||
planName: sub?.plan.name ?? null,
|
||||
planTier: tierFromPlan(sub?.plan.name ?? null, sub?.plan.brandCount ?? null),
|
||||
subscriptionStatus: sub?.status ?? null,
|
||||
@@ -271,6 +275,14 @@ export async function listPlans() {
|
||||
});
|
||||
}
|
||||
|
||||
export async function listBrands() {
|
||||
return saseDb.brand.findMany({
|
||||
where: { isActive: true },
|
||||
orderBy: { name: "asc" },
|
||||
select: { id: true, name: true, slug: true },
|
||||
});
|
||||
}
|
||||
|
||||
function tierFromPlan(name: string | null, brandCount: number | null): string | null {
|
||||
if (!name) return null;
|
||||
const n = name.toLowerCase();
|
||||
|
||||
Reference in New Issue
Block a user