feat: address review on segments: gate the add-to-campaign and from-segment actions behind the manage-campaigns permission on the segment page, the segments list and the campaign Leads tab so the dashboard never offers an enrolment the API would refuse, trim the segment package and migration comments to the one invariant they carry, and drop the em dash from the editor comment

This commit is contained in:
Matthew Meszaros
2026-08-30 00:11:19 -07:00
parent 50663ef652
commit 3bd9325bee
6 changed files with 15 additions and 12 deletions
+2 -3
View File
@@ -1,6 +1,5 @@
// Package segment manages saved contact audiences (issue #266). A segment is
// a filter definition plus manual overrides; membership is computed at read
// time by the repository's SQL compiler, so nothing here schedules work.
// Package segment manages saved contact audiences; membership is computed at
// read time, so nothing here schedules work.
package segment
import (
@@ -1,8 +1,5 @@
-- Contact segments (issue #266): saved, reusable audiences. A segment is a
-- filter tree over contacts (properties, categories, campaign activity,
-- engagement, other segments) plus per-contact manual overrides. Membership
-- is evaluated at read time, so it is always current and never needs a
-- recompute job; only the definition and the overrides are stored.
-- Contact segments (issue #266). Membership is evaluated at read time; only
-- the definition and the manual overrides are stored.
CREATE TABLE public.segments (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
organization_id uuid NOT NULL REFERENCES public.organizations(id) ON DELETE CASCADE,
+3 -1
View File
@@ -37,6 +37,8 @@ function SegmentDetail() {
const navigate = useNavigate();
const confirm = useConfirm();
const write = useWriteGuard("MANAGE_CONTACTS");
// Enrolment writes campaign leads, so it takes the campaign permission.
const campaigns = useWriteGuard("MANAGE_CAMPAIGNS");
const segment = useSegment(id);
const fields = useSegmentFields();
const remove = useDeleteSegment();
@@ -104,7 +106,7 @@ function SegmentDetail() {
</button>
<button
type="button"
onClick={() => setCampaignOpen(true)}
onClick={campaigns.guard(() => setCampaignOpen(true))}
className="h-7 px-2.5 rounded-md bg-sky-600 hover:bg-sky-700 text-white text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors"
>
<MegaphoneIcon className="w-3 h-3" />
+3 -1
View File
@@ -38,6 +38,8 @@ function SegmentsList() {
// Menu items and topbar actions take a bare () => void, so give the
// permission guard an empty event to swallow.
const guarded = (fn: () => void) => () => write.guard(fn)({});
const campaigns = useWriteGuard("MANAGE_CAMPAIGNS");
const campaignGuarded = (fn: () => void) => () => campaigns.guard(fn)({});
const segments = useSegments();
const remove = useDeleteSegment();
const create = useCreateSegment();
@@ -198,7 +200,7 @@ function SegmentsList() {
<PopoverMenuContent minWidth={180}>
<PopoverMenuItem onSelect={() => navigate(`/app/segments/${s.id}`)}>View contacts</PopoverMenuItem>
<PopoverMenuItem onSelect={guarded(() => openEdit(s))}>Edit conditions</PopoverMenuItem>
<PopoverMenuItem onSelect={() => setCampaignFor(s)}>Add to campaign</PopoverMenuItem>
<PopoverMenuItem onSelect={campaignGuarded(() => setCampaignFor(s))}>Add to campaign</PopoverMenuItem>
<PopoverMenuItem onSelect={guarded(() => duplicate(s))}>Duplicate</PopoverMenuItem>
<PopoverMenuSeparator />
<PopoverMenuItem onSelect={guarded(() => askDelete(s))}>Delete</PopoverMenuItem>
@@ -40,6 +40,7 @@ import {
} from "lucide-react";
import { useConfirm } from "@/hooks/context/confirm";
import { useWriteGuard } from "@/hooks/usePermission";
import useSearchContacts from "@/lib/api/hooks/app/contacts/useSearchContacts";
import type SearchContacts from "@/lib/api/models/app/contacts/SearchContacts";
import useDeleteContacts from "@/lib/api/hooks/app/contacts/useDeleteContacts";
@@ -105,6 +106,8 @@ export default function ContactsTable({
}) {
const confirm = useConfirm();
const segmentMembers = useSetSegmentMembers();
// Enrolling a segment writes campaign leads, so it takes the campaign permission.
const campaignWrite = useWriteGuard("MANAGE_CAMPAIGNS");
const [selected, setSelected] = React.useState<string[]>([]);
const [del, setDelete] = React.useState<boolean>(false);
const [filtersOpen, setFiltersOpen] = React.useState<boolean>(false);
@@ -341,7 +344,7 @@ export default function ContactsTable({
<TopbarAction
variant="ghost"
icon={<LayersIcon className="w-3 h-3" />}
onClick={() => setFromSegmentOpen(true)}
onClick={() => campaignWrite.guard(() => setFromSegmentOpen(true))({})}
>
From segment
</TopbarAction>
@@ -1,4 +1,4 @@
// SegmentEditor right-side drawer that creates or edits a segment: name,
// SegmentEditor: right-side drawer that creates or edits a segment: name,
// color, all/any match and the condition list, with a live "matches N
// contacts" preview fed by POST /segments/preview.