fix(account-sharing): stamp account_id on flow and pipeline inserts (#210)

`flows.account_id` and `pipelines.account_id` are NOT NULL post-017 with
no DB default, so inserts omitting account_id fail with a not-null
violation (same class as #206 / #205).

- POST /api/flows: resolve the caller's profiles.account_id and stamp it
  on both insert paths (template clone + blank/manual). Closes #210.
- Pipelines page: stamp account_id (from useAuth) on the auto-seed and
  the manual "create pipeline" inserts — same bug, found while auditing
  other account-scoped insert flows.

Audited every insert into the 15 NOT-NULL-account_id tables; all other
sites already stamp account_id. typecheck + 370 tests pass; lint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arnas Donauskas 2026-06-05 16:06:47 +03:00
parent 75ff74730f
commit 48fc10d084
No known key found for this signature in database
2 changed files with 32 additions and 4 deletions

View File

@ -27,6 +27,7 @@ import { Label } from "@/components/ui/label";
import { GitBranch, Plus, ChevronDown, Settings } from "lucide-react"; import { GitBranch, Plus, ChevronDown, Settings } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useCan } from "@/hooks/use-can"; import { useCan } from "@/hooks/use-can";
import { useAuth } from "@/hooks/use-auth";
import { GatedButton } from "@/components/ui/gated-button"; import { GatedButton } from "@/components/ui/gated-button";
// Pipeline creation is admin-class (settings-tier write under // Pipeline creation is admin-class (settings-tier write under
@ -47,6 +48,7 @@ export default function PipelinesPage() {
const supabase = createClient(); const supabase = createClient();
const canEditSettings = useCan("edit-settings"); const canEditSettings = useCan("edit-settings");
const canCreateDeals = useCan("send-messages"); const canCreateDeals = useCan("send-messages");
const { accountId } = useAuth();
const [pipelines, setPipelines] = useState<Pipeline[]>([]); const [pipelines, setPipelines] = useState<Pipeline[]>([]);
const [selectedPipelineId, setSelectedPipelineId] = useState<string>(""); const [selectedPipelineId, setSelectedPipelineId] = useState<string>("");
@ -111,10 +113,12 @@ export default function PipelinesPage() {
} = await supabase.auth.getSession(); } = await supabase.auth.getSession();
const user = session?.user; const user = session?.user;
if (!user) return null; if (!user) return null;
// pipelines.account_id is NOT NULL post-017 with no DB default.
if (!accountId) return null;
const { data: pipeline, error } = await supabase const { data: pipeline, error } = await supabase
.from("pipelines") .from("pipelines")
.insert({ user_id: user.id, name: "Sales Pipeline" }) .insert({ user_id: user.id, account_id: accountId, name: "Sales Pipeline" })
.select() .select()
.single(); .single();
@ -132,7 +136,7 @@ export default function PipelinesPage() {
await supabase.from("pipeline_stages").insert(stagesPayload); await supabase.from("pipeline_stages").insert(stagesPayload);
return pipeline as Pipeline; return pipeline as Pipeline;
}, [supabase]); }, [supabase, accountId]);
// Initial load + seed-if-empty // Initial load + seed-if-empty
useEffect(() => { useEffect(() => {
@ -254,10 +258,16 @@ export default function PipelinesPage() {
setCreating(false); setCreating(false);
return; return;
} }
// pipelines.account_id is NOT NULL post-017 with no DB default.
if (!accountId) {
toast.error("Your profile is not linked to an account.");
setCreating(false);
return;
}
const { data: pipeline, error } = await supabase const { data: pipeline, error } = await supabase
.from("pipelines") .from("pipelines")
.insert({ user_id: user.id, name }) .insert({ user_id: user.id, account_id: accountId, name })
.select() .select()
.single(); .single();

View File

@ -49,7 +49,23 @@ export async function POST(request: Request) {
if (!guard.ok) { if (!guard.ok) {
return NextResponse.json(guard.body, { status: guard.status }) return NextResponse.json(guard.body, { status: guard.status })
} }
const { userId } = guard const { userId, supabase } = guard
// Resolve the caller's account_id — `flows.account_id` is NOT NULL
// post-017, so an INSERT without it trips the not-null constraint
// even though the admin client below bypasses RLS.
const { data: profile } = await supabase
.from('profiles')
.select('account_id')
.eq('user_id', userId)
.single()
const accountId = profile?.account_id as string | undefined
if (!accountId) {
return NextResponse.json(
{ error: 'Your profile is not linked to an account.' },
{ status: 403 },
)
}
const body = (await request.json().catch(() => null)) as const body = (await request.json().catch(() => null)) as
| { | {
@ -85,6 +101,7 @@ export async function POST(request: Request) {
.from('flows') .from('flows')
.insert({ .insert({
user_id: userId, user_id: userId,
account_id: accountId,
name: body.name?.trim() || template.name, name: body.name?.trim() || template.name,
description: template.description, description: template.description,
status: 'draft', status: 'draft',
@ -133,6 +150,7 @@ export async function POST(request: Request) {
.from('flows') .from('flows')
.insert({ .insert({
user_id: userId, user_id: userId,
account_id: accountId,
name: body.name.trim(), name: body.name.trim(),
description: body.description ?? null, description: body.description ?? null,
status: 'draft', status: 'draft',