From c5ee04ce9ef157be72e0e538608c8e200d4a87dc Mon Sep 17 00:00:00 2001 From: Arnas Donauskas Date: Fri, 5 Jun 2026 10:20:54 +0300 Subject: [PATCH] fix(account-sharing): stamp account_id on account-scoped inserts (#205) Migration 017 made domain tables account-scoped: account_id is NOT NULL and INSERT RLS is WITH CHECK (is_account_member(account_id, ...)). Several client- and server-side create flows still inserted only user_id, so on a fresh install they failed with 403 / "new row violates row-level security policy" / not-null violations. Resolve account_id from useAuth() (client) or the caller's profile (server) and include it on every account-scoped insert: - contacts: contact-form, import-modal, broadcast CSV upsert - contact_notes: contact-sidebar, contact-detail-view - tags: tag-manager - broadcasts: broadcasts/new page, use-broadcast-sending - deals: deal-form - automations: POST /api/automations, POST /api/automations/[id]/duplicate Each path also guards on a missing accountId with a clear message instead of letting the insert fail opaquely. pipelines/page.tsx and api/flows/route.ts are intentionally left to the already-open PR #193, which fixes those same paths (and adds tests). Verified: a full sweep of every account-scoped insert/upsert across src/ now includes account_id (except the two paths owned by #193). typecheck, lint (0 errors), and 370 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app/(dashboard)/broadcasts/new/page.tsx | 7 +++++++ src/app/api/automations/[id]/duplicate/route.ts | 3 +++ src/app/api/automations/route.ts | 17 +++++++++++++++++ src/components/contacts/contact-detail-view.tsx | 5 ++++- src/components/contacts/contact-form.tsx | 4 ++++ src/components/contacts/import-modal.tsx | 4 ++++ src/components/inbox/contact-sidebar.tsx | 6 +++++- src/components/pipelines/deal-form.tsx | 9 ++++++++- src/components/settings/tag-manager.tsx | 5 +++-- src/hooks/use-broadcast-sending.ts | 10 ++++++++++ 10 files changed, 65 insertions(+), 5 deletions(-) diff --git a/src/app/(dashboard)/broadcasts/new/page.tsx b/src/app/(dashboard)/broadcasts/new/page.tsx index ec7522f..9763620 100644 --- a/src/app/(dashboard)/broadcasts/new/page.tsx +++ b/src/app/(dashboard)/broadcasts/new/page.tsx @@ -3,6 +3,7 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; import { createClient } from '@/lib/supabase/client'; +import { useAuth } from '@/hooks/use-auth'; import { toast } from 'sonner'; import { MessageTemplate } from '@/types'; import { Step1ChooseTemplate } from '@/components/broadcasts/step1-choose-template'; @@ -21,6 +22,7 @@ const steps = [ export default function NewBroadcastPage() { const router = useRouter(); + const { accountId } = useAuth(); const { createAndSendBroadcast, isProcessing, progress } = useBroadcastSending(); const [currentStep, setCurrentStep] = useState(0); @@ -90,9 +92,14 @@ export default function NewBroadcastPage() { toast.error('Not signed in.'); return; } + if (!accountId) { + toast.error('Your profile is not linked to an account.'); + return; + } const { error } = await supabase.from('broadcasts').insert({ user_id: user.id, + account_id: accountId, name: name.trim(), template_name: template.name, template_language: template.language ?? 'en_US', diff --git a/src/app/api/automations/[id]/duplicate/route.ts b/src/app/api/automations/[id]/duplicate/route.ts index 0c41158..0b76852 100644 --- a/src/app/api/automations/[id]/duplicate/route.ts +++ b/src/app/api/automations/[id]/duplicate/route.ts @@ -26,6 +26,9 @@ export async function POST( const { data: copy, error: copyErr } = await admin .from('automations') .insert({ + // Clone into the same account as the original. account_id is NOT + // NULL post-017, so the INSERT fails the constraint without it. + account_id: original.account_id, user_id: user.id, name: `${original.name} (Copy)`, description: original.description, diff --git a/src/app/api/automations/route.ts b/src/app/api/automations/route.ts index d3125cc..d4c5dc4 100644 --- a/src/app/api/automations/route.ts +++ b/src/app/api/automations/route.ts @@ -30,6 +30,22 @@ export async function POST(request: Request) { } = await supabase.auth.getUser() if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + // Resolve the caller's account_id — `automations.account_id` is NOT + // NULL post-017, so an INSERT without it trips the not-null constraint + // even though the admin client bypasses RLS. + const { data: profile } = await supabase + .from('profiles') + .select('account_id') + .eq('user_id', user.id) + .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) if (!body) return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }) @@ -83,6 +99,7 @@ export async function POST(request: Request) { .from('automations') .insert({ user_id: user.id, + account_id: accountId, name: effectiveName, description: effectiveDescription ?? null, trigger_type: effectiveTriggerType, diff --git a/src/components/contacts/contact-detail-view.tsx b/src/components/contacts/contact-detail-view.tsx index 2b10eab..fb6d232 100644 --- a/src/components/contacts/contact-detail-view.tsx +++ b/src/components/contacts/contact-detail-view.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react'; import { createClient } from '@/lib/supabase/client'; +import { useAuth } from '@/hooks/use-auth'; import { toast } from 'sonner'; import type { Contact, Tag, ContactTag, ContactNote, CustomField, ContactCustomValue, Deal } from '@/types'; import { @@ -47,6 +48,7 @@ export function ContactDetailView({ onUpdated, }: ContactDetailViewProps) { const supabase = createClient(); + const { accountId } = useAuth(); const [contact, setContact] = useState(null); const [loading, setLoading] = useState(false); @@ -244,7 +246,7 @@ export function ContactDetailView({ data: { session }, } = await supabase.auth.getSession(); const user = session?.user; - if (!user) { + if (!user || !accountId) { toast.error('Not authenticated'); setSavingNote(false); return; @@ -252,6 +254,7 @@ export function ContactDetailView({ const { error } = await supabase.from('contact_notes').insert({ contact_id: contactId, + account_id: accountId, user_id: user.id, note_text: newNote.trim(), }); diff --git a/src/components/contacts/contact-form.tsx b/src/components/contacts/contact-form.tsx index e49297d..93d7400 100644 --- a/src/components/contacts/contact-form.tsx +++ b/src/components/contacts/contact-form.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'; import { createClient } from '@/lib/supabase/client'; +import { useAuth } from '@/hooks/use-auth'; import { toast } from 'sonner'; import type { Contact, Tag, ContactTag } from '@/types'; import { @@ -34,6 +35,7 @@ export function ContactForm({ onSaved, }: ContactFormProps) { const supabase = createClient(); + const { accountId } = useAuth(); const isEdit = !!contact; const [name, setName] = useState(''); @@ -91,6 +93,7 @@ export function ContactForm({ } = await supabase.auth.getSession(); const user = session?.user; if (!user) throw new Error('Not authenticated'); + if (!accountId) throw new Error('Your profile is not linked to an account.'); let contactId = contact?.id; @@ -111,6 +114,7 @@ export function ContactForm({ .from('contacts') .insert({ user_id: user.id, + account_id: accountId, name: name.trim() || null, phone: phone.trim(), email: email.trim() || null, diff --git a/src/components/contacts/import-modal.tsx b/src/components/contacts/import-modal.tsx index d2eae9e..2b7e287 100644 --- a/src/components/contacts/import-modal.tsx +++ b/src/components/contacts/import-modal.tsx @@ -2,6 +2,7 @@ import { useState, useRef } from 'react'; import { createClient } from '@/lib/supabase/client'; +import { useAuth } from '@/hooks/use-auth'; import { toast } from 'sonner'; import { Dialog, @@ -79,6 +80,7 @@ function parseCSV(text: string): ParsedRow[] { export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps) { const supabase = createClient(); + const { accountId } = useAuth(); const fileInputRef = useRef(null); const [file, setFile] = useState(null); @@ -127,6 +129,7 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps } = await supabase.auth.getSession(); const user = session?.user; if (!user) throw new Error('Not authenticated'); + if (!accountId) throw new Error('Your profile is not linked to an account.'); let imported = 0; let failed = 0; @@ -137,6 +140,7 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps const chunk = parsedRows.slice(i, i + chunkSize); const rows = chunk.map((row) => ({ user_id: user.id, + account_id: accountId, phone: row.phone, name: row.name || null, email: row.email || null, diff --git a/src/components/inbox/contact-sidebar.tsx b/src/components/inbox/contact-sidebar.tsx index a88e3a0..7005d0e 100644 --- a/src/components/inbox/contact-sidebar.tsx +++ b/src/components/inbox/contact-sidebar.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from "react"; import { createClient } from "@/lib/supabase/client"; +import { useAuth } from "@/hooks/use-auth"; import { cn } from "@/lib/utils"; import type { Contact, Deal, ContactNote, Tag } from "@/types"; import { @@ -24,6 +25,7 @@ interface ContactSidebarProps { } export function ContactSidebar({ contact }: ContactSidebarProps) { + const { accountId } = useAuth(); const [copied, setCopied] = useState(false); const [deals, setDeals] = useState([]); const [notes, setNotes] = useState([]); @@ -86,6 +88,7 @@ export function ContactSidebar({ contact }: ContactSidebarProps) { const handleAddNote = useCallback(async () => { if (!contact || !newNote.trim()) return; + if (!accountId) return; setAddingNote(true); const supabase = createClient(); @@ -98,6 +101,7 @@ export function ContactSidebar({ contact }: ContactSidebarProps) { .from("contact_notes") .insert({ contact_id: contact.id, + account_id: accountId, user_id: user?.id, note_text: newNote.trim(), }) @@ -109,7 +113,7 @@ export function ContactSidebar({ contact }: ContactSidebarProps) { setNewNote(""); } setAddingNote(false); - }, [contact, newNote]); + }, [contact, newNote, accountId]); if (!contact) { return ( diff --git a/src/components/pipelines/deal-form.tsx b/src/components/pipelines/deal-form.tsx index cd77622..5fdb81f 100644 --- a/src/components/pipelines/deal-form.tsx +++ b/src/components/pipelines/deal-form.tsx @@ -3,6 +3,7 @@ import { useState, useEffect } from "react"; import Link from "next/link"; import { createClient } from "@/lib/supabase/client"; +import { useAuth } from "@/hooks/use-auth"; import type { Contact, Conversation, @@ -51,6 +52,7 @@ export function DealForm({ onSaved, }: DealFormProps) { const supabase = createClient(); + const { accountId } = useAuth(); const [title, setTitle] = useState(""); const [value, setValue] = useState(""); @@ -185,9 +187,14 @@ export function DealForm({ setSaving(false); return; } + if (!accountId) { + toast.error("Your profile is not linked to an account."); + setSaving(false); + return; + } const { error } = await supabase .from("deals") - .insert({ ...payload, user_id: user.id, status: "open" }); + .insert({ ...payload, user_id: user.id, account_id: accountId, status: "open" }); if (error) { toast.error("Failed to create deal"); setSaving(false); diff --git a/src/components/settings/tag-manager.tsx b/src/components/settings/tag-manager.tsx index efb6249..0285a0a 100644 --- a/src/components/settings/tag-manager.tsx +++ b/src/components/settings/tag-manager.tsx @@ -32,7 +32,7 @@ const PRESET_COLORS = [ export function TagManager() { const supabase = createClient(); - const { user, loading: authLoading } = useAuth(); + const { user, accountId, loading: authLoading } = useAuth(); const [loading, setLoading] = useState(true); const [tags, setTags] = useState([]); @@ -82,7 +82,7 @@ export function TagManager() { try { setSaving(true); - if (!user) { + if (!user || !accountId) { toast.error('Not authenticated'); return; } @@ -91,6 +91,7 @@ export function TagManager() { .from('tags') .insert({ user_id: user.id, + account_id: accountId, name: newTagName.trim(), color: selectedColor, }); diff --git a/src/hooks/use-broadcast-sending.ts b/src/hooks/use-broadcast-sending.ts index 4929feb..4510146 100644 --- a/src/hooks/use-broadcast-sending.ts +++ b/src/hooks/use-broadcast-sending.ts @@ -2,6 +2,7 @@ import { useState } from 'react'; import { createClient } from '@/lib/supabase/client'; +import { useAuth } from '@/hooks/use-auth'; import { Contact, MessageTemplate } from '@/types'; export type CustomFieldOperator = 'is' | 'is_not' | 'contains'; @@ -140,6 +141,7 @@ async function fetchCustomValueIndex( } export function useBroadcastSending(): UseBroadcastSendingReturn { + const { accountId } = useAuth(); const [isProcessing, setIsProcessing] = useState(false); const [progress, setProgress] = useState(0); @@ -220,6 +222,9 @@ export function useBroadcastSending(): UseBroadcastSendingReturn { if (!user) { throw new Error('You are not signed in.'); } + if (!accountId) { + throw new Error('Your profile is not linked to an account.'); + } // De-duplicate by phone within the CSV (users can paste duplicates). const uniqueByPhone = new Map(); @@ -249,6 +254,7 @@ export function useBroadcastSending(): UseBroadcastSendingReturn { .filter((p) => !byPhone.has(p)) .map((phone) => ({ user_id: user.id, + account_id: accountId, phone, name: uniqueByPhone.get(phone)?.name ?? null, })); @@ -326,6 +332,9 @@ export function useBroadcastSending(): UseBroadcastSendingReturn { if (!user) { throw new Error('You are not signed in.'); } + if (!accountId) { + throw new Error('Your profile is not linked to an account.'); + } // ── Step 1: Resolve audience contacts ───────────────────────── setProgress(5); @@ -341,6 +350,7 @@ export function useBroadcastSending(): UseBroadcastSendingReturn { .from('broadcasts') .insert({ user_id: user.id, + account_id: accountId, name: payload.name, template_name: payload.template.name, template_language: payload.template.language ?? 'en_US',