mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
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) <noreply@anthropic.com>
This commit is contained in:
parent
d6dbbf22b2
commit
c5ee04ce9e
@ -3,6 +3,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { createClient } from '@/lib/supabase/client';
|
import { createClient } from '@/lib/supabase/client';
|
||||||
|
import { useAuth } from '@/hooks/use-auth';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { MessageTemplate } from '@/types';
|
import { MessageTemplate } from '@/types';
|
||||||
import { Step1ChooseTemplate } from '@/components/broadcasts/step1-choose-template';
|
import { Step1ChooseTemplate } from '@/components/broadcasts/step1-choose-template';
|
||||||
@ -21,6 +22,7 @@ const steps = [
|
|||||||
|
|
||||||
export default function NewBroadcastPage() {
|
export default function NewBroadcastPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { accountId } = useAuth();
|
||||||
const { createAndSendBroadcast, isProcessing, progress } = useBroadcastSending();
|
const { createAndSendBroadcast, isProcessing, progress } = useBroadcastSending();
|
||||||
|
|
||||||
const [currentStep, setCurrentStep] = useState(0);
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
@ -90,9 +92,14 @@ export default function NewBroadcastPage() {
|
|||||||
toast.error('Not signed in.');
|
toast.error('Not signed in.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!accountId) {
|
||||||
|
toast.error('Your profile is not linked to an account.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const { error } = await supabase.from('broadcasts').insert({
|
const { error } = await supabase.from('broadcasts').insert({
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
|
account_id: accountId,
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
template_name: template.name,
|
template_name: template.name,
|
||||||
template_language: template.language ?? 'en_US',
|
template_language: template.language ?? 'en_US',
|
||||||
|
|||||||
@ -26,6 +26,9 @@ export async function POST(
|
|||||||
const { data: copy, error: copyErr } = await admin
|
const { data: copy, error: copyErr } = await admin
|
||||||
.from('automations')
|
.from('automations')
|
||||||
.insert({
|
.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,
|
user_id: user.id,
|
||||||
name: `${original.name} (Copy)`,
|
name: `${original.name} (Copy)`,
|
||||||
description: original.description,
|
description: original.description,
|
||||||
|
|||||||
@ -30,6 +30,22 @@ export async function POST(request: Request) {
|
|||||||
} = await supabase.auth.getUser()
|
} = await supabase.auth.getUser()
|
||||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
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)
|
const body = await request.json().catch(() => null)
|
||||||
if (!body) return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
|
if (!body) return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
|
||||||
|
|
||||||
@ -83,6 +99,7 @@ export async function POST(request: Request) {
|
|||||||
.from('automations')
|
.from('automations')
|
||||||
.insert({
|
.insert({
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
|
account_id: accountId,
|
||||||
name: effectiveName,
|
name: effectiveName,
|
||||||
description: effectiveDescription ?? null,
|
description: effectiveDescription ?? null,
|
||||||
trigger_type: effectiveTriggerType,
|
trigger_type: effectiveTriggerType,
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { createClient } from '@/lib/supabase/client';
|
import { createClient } from '@/lib/supabase/client';
|
||||||
|
import { useAuth } from '@/hooks/use-auth';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import type { Contact, Tag, ContactTag, ContactNote, CustomField, ContactCustomValue, Deal } from '@/types';
|
import type { Contact, Tag, ContactTag, ContactNote, CustomField, ContactCustomValue, Deal } from '@/types';
|
||||||
import {
|
import {
|
||||||
@ -47,6 +48,7 @@ export function ContactDetailView({
|
|||||||
onUpdated,
|
onUpdated,
|
||||||
}: ContactDetailViewProps) {
|
}: ContactDetailViewProps) {
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
|
const { accountId } = useAuth();
|
||||||
|
|
||||||
const [contact, setContact] = useState<Contact | null>(null);
|
const [contact, setContact] = useState<Contact | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@ -244,7 +246,7 @@ export function ContactDetailView({
|
|||||||
data: { session },
|
data: { session },
|
||||||
} = await supabase.auth.getSession();
|
} = await supabase.auth.getSession();
|
||||||
const user = session?.user;
|
const user = session?.user;
|
||||||
if (!user) {
|
if (!user || !accountId) {
|
||||||
toast.error('Not authenticated');
|
toast.error('Not authenticated');
|
||||||
setSavingNote(false);
|
setSavingNote(false);
|
||||||
return;
|
return;
|
||||||
@ -252,6 +254,7 @@ export function ContactDetailView({
|
|||||||
|
|
||||||
const { error } = await supabase.from('contact_notes').insert({
|
const { error } = await supabase.from('contact_notes').insert({
|
||||||
contact_id: contactId,
|
contact_id: contactId,
|
||||||
|
account_id: accountId,
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
note_text: newNote.trim(),
|
note_text: newNote.trim(),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { createClient } from '@/lib/supabase/client';
|
import { createClient } from '@/lib/supabase/client';
|
||||||
|
import { useAuth } from '@/hooks/use-auth';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import type { Contact, Tag, ContactTag } from '@/types';
|
import type { Contact, Tag, ContactTag } from '@/types';
|
||||||
import {
|
import {
|
||||||
@ -34,6 +35,7 @@ export function ContactForm({
|
|||||||
onSaved,
|
onSaved,
|
||||||
}: ContactFormProps) {
|
}: ContactFormProps) {
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
|
const { accountId } = useAuth();
|
||||||
const isEdit = !!contact;
|
const isEdit = !!contact;
|
||||||
|
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
@ -91,6 +93,7 @@ export function ContactForm({
|
|||||||
} = await supabase.auth.getSession();
|
} = await supabase.auth.getSession();
|
||||||
const user = session?.user;
|
const user = session?.user;
|
||||||
if (!user) throw new Error('Not authenticated');
|
if (!user) throw new Error('Not authenticated');
|
||||||
|
if (!accountId) throw new Error('Your profile is not linked to an account.');
|
||||||
|
|
||||||
let contactId = contact?.id;
|
let contactId = contact?.id;
|
||||||
|
|
||||||
@ -111,6 +114,7 @@ export function ContactForm({
|
|||||||
.from('contacts')
|
.from('contacts')
|
||||||
.insert({
|
.insert({
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
|
account_id: accountId,
|
||||||
name: name.trim() || null,
|
name: name.trim() || null,
|
||||||
phone: phone.trim(),
|
phone: phone.trim(),
|
||||||
email: email.trim() || null,
|
email: email.trim() || null,
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useRef } from 'react';
|
import { useState, useRef } from 'react';
|
||||||
import { createClient } from '@/lib/supabase/client';
|
import { createClient } from '@/lib/supabase/client';
|
||||||
|
import { useAuth } from '@/hooks/use-auth';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@ -79,6 +80,7 @@ function parseCSV(text: string): ParsedRow[] {
|
|||||||
|
|
||||||
export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps) {
|
export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps) {
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
|
const { accountId } = useAuth();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const [file, setFile] = useState<File | null>(null);
|
const [file, setFile] = useState<File | null>(null);
|
||||||
@ -127,6 +129,7 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
|
|||||||
} = await supabase.auth.getSession();
|
} = await supabase.auth.getSession();
|
||||||
const user = session?.user;
|
const user = session?.user;
|
||||||
if (!user) throw new Error('Not authenticated');
|
if (!user) throw new Error('Not authenticated');
|
||||||
|
if (!accountId) throw new Error('Your profile is not linked to an account.');
|
||||||
|
|
||||||
let imported = 0;
|
let imported = 0;
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
@ -137,6 +140,7 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
|
|||||||
const chunk = parsedRows.slice(i, i + chunkSize);
|
const chunk = parsedRows.slice(i, i + chunkSize);
|
||||||
const rows = chunk.map((row) => ({
|
const rows = chunk.map((row) => ({
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
|
account_id: accountId,
|
||||||
phone: row.phone,
|
phone: row.phone,
|
||||||
name: row.name || null,
|
name: row.name || null,
|
||||||
email: row.email || null,
|
email: row.email || null,
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { createClient } from "@/lib/supabase/client";
|
import { createClient } from "@/lib/supabase/client";
|
||||||
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { Contact, Deal, ContactNote, Tag } from "@/types";
|
import type { Contact, Deal, ContactNote, Tag } from "@/types";
|
||||||
import {
|
import {
|
||||||
@ -24,6 +25,7 @@ interface ContactSidebarProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ContactSidebar({ contact }: ContactSidebarProps) {
|
export function ContactSidebar({ contact }: ContactSidebarProps) {
|
||||||
|
const { accountId } = useAuth();
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const [deals, setDeals] = useState<Deal[]>([]);
|
const [deals, setDeals] = useState<Deal[]>([]);
|
||||||
const [notes, setNotes] = useState<ContactNote[]>([]);
|
const [notes, setNotes] = useState<ContactNote[]>([]);
|
||||||
@ -86,6 +88,7 @@ export function ContactSidebar({ contact }: ContactSidebarProps) {
|
|||||||
|
|
||||||
const handleAddNote = useCallback(async () => {
|
const handleAddNote = useCallback(async () => {
|
||||||
if (!contact || !newNote.trim()) return;
|
if (!contact || !newNote.trim()) return;
|
||||||
|
if (!accountId) return;
|
||||||
setAddingNote(true);
|
setAddingNote(true);
|
||||||
|
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
@ -98,6 +101,7 @@ export function ContactSidebar({ contact }: ContactSidebarProps) {
|
|||||||
.from("contact_notes")
|
.from("contact_notes")
|
||||||
.insert({
|
.insert({
|
||||||
contact_id: contact.id,
|
contact_id: contact.id,
|
||||||
|
account_id: accountId,
|
||||||
user_id: user?.id,
|
user_id: user?.id,
|
||||||
note_text: newNote.trim(),
|
note_text: newNote.trim(),
|
||||||
})
|
})
|
||||||
@ -109,7 +113,7 @@ export function ContactSidebar({ contact }: ContactSidebarProps) {
|
|||||||
setNewNote("");
|
setNewNote("");
|
||||||
}
|
}
|
||||||
setAddingNote(false);
|
setAddingNote(false);
|
||||||
}, [contact, newNote]);
|
}, [contact, newNote, accountId]);
|
||||||
|
|
||||||
if (!contact) {
|
if (!contact) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { createClient } from "@/lib/supabase/client";
|
import { createClient } from "@/lib/supabase/client";
|
||||||
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
import type {
|
import type {
|
||||||
Contact,
|
Contact,
|
||||||
Conversation,
|
Conversation,
|
||||||
@ -51,6 +52,7 @@ export function DealForm({
|
|||||||
onSaved,
|
onSaved,
|
||||||
}: DealFormProps) {
|
}: DealFormProps) {
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
|
const { accountId } = useAuth();
|
||||||
|
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
const [value, setValue] = useState("");
|
const [value, setValue] = useState("");
|
||||||
@ -185,9 +187,14 @@ export function DealForm({
|
|||||||
setSaving(false);
|
setSaving(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!accountId) {
|
||||||
|
toast.error("Your profile is not linked to an account.");
|
||||||
|
setSaving(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const { error } = await supabase
|
const { error } = await supabase
|
||||||
.from("deals")
|
.from("deals")
|
||||||
.insert({ ...payload, user_id: user.id, status: "open" });
|
.insert({ ...payload, user_id: user.id, account_id: accountId, status: "open" });
|
||||||
if (error) {
|
if (error) {
|
||||||
toast.error("Failed to create deal");
|
toast.error("Failed to create deal");
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
|
|||||||
@ -32,7 +32,7 @@ const PRESET_COLORS = [
|
|||||||
|
|
||||||
export function TagManager() {
|
export function TagManager() {
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
const { user, loading: authLoading } = useAuth();
|
const { user, accountId, loading: authLoading } = useAuth();
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [tags, setTags] = useState<Tag[]>([]);
|
const [tags, setTags] = useState<Tag[]>([]);
|
||||||
@ -82,7 +82,7 @@ export function TagManager() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
if (!user) {
|
if (!user || !accountId) {
|
||||||
toast.error('Not authenticated');
|
toast.error('Not authenticated');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -91,6 +91,7 @@ export function TagManager() {
|
|||||||
.from('tags')
|
.from('tags')
|
||||||
.insert({
|
.insert({
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
|
account_id: accountId,
|
||||||
name: newTagName.trim(),
|
name: newTagName.trim(),
|
||||||
color: selectedColor,
|
color: selectedColor,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { createClient } from '@/lib/supabase/client';
|
import { createClient } from '@/lib/supabase/client';
|
||||||
|
import { useAuth } from '@/hooks/use-auth';
|
||||||
import { Contact, MessageTemplate } from '@/types';
|
import { Contact, MessageTemplate } from '@/types';
|
||||||
|
|
||||||
export type CustomFieldOperator = 'is' | 'is_not' | 'contains';
|
export type CustomFieldOperator = 'is' | 'is_not' | 'contains';
|
||||||
@ -140,6 +141,7 @@ async function fetchCustomValueIndex(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useBroadcastSending(): UseBroadcastSendingReturn {
|
export function useBroadcastSending(): UseBroadcastSendingReturn {
|
||||||
|
const { accountId } = useAuth();
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
const [progress, setProgress] = useState(0);
|
const [progress, setProgress] = useState(0);
|
||||||
|
|
||||||
@ -220,6 +222,9 @@ export function useBroadcastSending(): UseBroadcastSendingReturn {
|
|||||||
if (!user) {
|
if (!user) {
|
||||||
throw new Error('You are not signed in.');
|
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).
|
// De-duplicate by phone within the CSV (users can paste duplicates).
|
||||||
const uniqueByPhone = new Map<string, { phone: string; name?: string }>();
|
const uniqueByPhone = new Map<string, { phone: string; name?: string }>();
|
||||||
@ -249,6 +254,7 @@ export function useBroadcastSending(): UseBroadcastSendingReturn {
|
|||||||
.filter((p) => !byPhone.has(p))
|
.filter((p) => !byPhone.has(p))
|
||||||
.map((phone) => ({
|
.map((phone) => ({
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
|
account_id: accountId,
|
||||||
phone,
|
phone,
|
||||||
name: uniqueByPhone.get(phone)?.name ?? null,
|
name: uniqueByPhone.get(phone)?.name ?? null,
|
||||||
}));
|
}));
|
||||||
@ -326,6 +332,9 @@ export function useBroadcastSending(): UseBroadcastSendingReturn {
|
|||||||
if (!user) {
|
if (!user) {
|
||||||
throw new Error('You are not signed in.');
|
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 ─────────────────────────
|
// ── Step 1: Resolve audience contacts ─────────────────────────
|
||||||
setProgress(5);
|
setProgress(5);
|
||||||
@ -341,6 +350,7 @@ export function useBroadcastSending(): UseBroadcastSendingReturn {
|
|||||||
.from('broadcasts')
|
.from('broadcasts')
|
||||||
.insert({
|
.insert({
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
|
account_id: accountId,
|
||||||
name: payload.name,
|
name: payload.name,
|
||||||
template_name: payload.template.name,
|
template_name: payload.template.name,
|
||||||
template_language: payload.template.language ?? 'en_US',
|
template_language: payload.template.language ?? 'en_US',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user