Skip to content

Commit 70dba59

Browse files
committed
Allow for source editing
1 parent d24dc7d commit 70dba59

2 files changed

Lines changed: 229 additions & 18 deletions

File tree

convex/sourceAdmin.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,17 @@ export const ignoreSender = mutation({
213213
},
214214
});
215215

216+
export const unignoreSource = mutation({
217+
args: {
218+
token: v.string(),
219+
listservId: v.id("listservs"),
220+
},
221+
handler: async (ctx, args) => {
222+
requireAdminToken(args.token);
223+
await ctx.db.patch(args.listservId, { status: "joining", updatedAt: Date.now() });
224+
},
225+
});
226+
216227
export const assignSourceOrganization = mutation({
217228
args: {
218229
token: v.string(),

src/pages/Admin.tsx

Lines changed: 218 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,9 @@ export default function Admin() {
141141
const assignSender = useMutation(api.sourceAdmin.assignSender);
142142
const assignSourceOrg = useMutation(api.sourceAdmin.assignSourceOrganization);
143143
const createOrg = useMutation(api.sourceAdmin.createOrganization);
144+
const updateOrg = useMutation(api.sourceAdmin.updateOrganization);
144145
const ignoreSender = useMutation(api.sourceAdmin.ignoreSender);
146+
const unignoreSource = useMutation(api.sourceAdmin.unignoreSource);
145147
const updateListservStatus = useMutation(api.listservAdmin.updateListservStatus);
146148
const updateJoinStrategy = useMutation(api.listservAdmin.updateJoinStrategy);
147149
const clearConfirmation = useMutation(api.listservAdmin.clearConfirmation);
@@ -268,22 +270,18 @@ export default function Admin() {
268270
onRejectCandidate={(id) =>
269271
act("Candidate rejected.", () => rejectCandidate({ token, candidateId: id }))
270272
}
271-
// Known source (listservs row exists) — assign to existing org
272273
onAssignSource={(listservId, orgId) =>
273274
act("Source assigned.", () => assignSourceOrg({ token, listservId, organizationId: orgId }))
274275
}
275-
// Known source — create new org and assign
276276
onCreateAndAssignSource={(listservId, name, type) =>
277277
act(`${name} created and assigned.`, async () => {
278278
const orgId = await createOrg({ token, name, type });
279279
await assignSourceOrg({ token, listservId, organizationId: orgId });
280280
})
281281
}
282-
// Inbox-only sender — assign to existing org (creates source row)
283282
onAssignInboxSenderToOrg={(senderEmail, orgId) =>
284283
act("Source assigned.", () => assignSender({ token, senderEmail, organizationId: orgId }))
285284
}
286-
// Inbox-only sender — create new org and assign
287285
onCreateAndAssignInboxSender={(senderEmail, name, type, sourceName, sourceType) =>
288286
act(`${name} created and assigned.`, async () => {
289287
const orgId = await createOrg({ token, name, type });
@@ -293,9 +291,15 @@ export default function Admin() {
293291
onIgnoreSender={(senderEmail) =>
294292
act("Sender ignored.", () => ignoreSender({ token, senderEmail }))
295293
}
294+
onUnignoreSource={(listservId) =>
295+
act("Source reactivated.", () => unignoreSource({ token, listservId }))
296+
}
296297
onCreateOrg={(name, type) =>
297298
act(`${name} created.`, () => createOrg({ token, name, type }))
298299
}
300+
onUpdateOrg={(orgId, name, type) =>
301+
act("Organization updated.", () => updateOrg({ token, organizationId: orgId, name, type, status: "active", tags: [] }))
302+
}
299303
/>
300304
)}
301305

@@ -492,7 +496,9 @@ function SourcesTab({
492496
onAssignInboxSenderToOrg,
493497
onCreateAndAssignInboxSender,
494498
onIgnoreSender,
499+
onUnignoreSource,
495500
onCreateOrg,
501+
onUpdateOrg,
496502
}: {
497503
candidates: Candidate[];
498504
listservs: Listserv[];
@@ -505,14 +511,17 @@ function SourcesTab({
505511
onAssignInboxSenderToOrg: (senderEmail: string, orgId: Id<"organizations">) => void;
506512
onCreateAndAssignInboxSender: (senderEmail: string, name: string, type: OrgType, sourceName: string, sourceType: NonNullable<Listserv["sourceType"]>) => void;
507513
onIgnoreSender: (senderEmail: string) => void;
514+
onUnignoreSource: (listservId: Id<"listservs">) => void;
508515
onCreateOrg: (name: string, type: OrgType) => void;
516+
onUpdateOrg: (orgId: Id<"organizations">, name: string, type: OrgType) => void;
509517
}) {
510-
// Known listservs rows that have no org yet and aren't paused/ignored
518+
// Partition listservs into three buckets
511519
const unassignedSources = listservs.filter((s) => !s.organizationId && s.status !== "paused");
520+
const assignedSources = listservs.filter((s) => !!s.organizationId);
521+
const ignoredSources = listservs.filter((s) => s.status === "paused");
512522
const pendingCandidates = candidates.filter((c) => c.status === "candidate");
513523

514-
// Build a unified list: known sources + inbox-only senders, sorted by
515-
// message recency / count so the most active surface first.
524+
// Unified needs-org list: unassigned known sources + inbox-only senders
516525
type UnifiedItem =
517526
| { kind: "known"; source: Listserv; suggestion: ReturnType<typeof suggestFromEmail> }
518527
| { kind: "inbox"; sender: UnassignedSender };
@@ -525,7 +534,6 @@ function SourcesTab({
525534
})),
526535
...unassigned.map((sender) => ({ kind: "inbox" as const, sender })),
527536
];
528-
// Stable sort: inbox senders with more messages first, then by recency
529537
unifiedItems.sort((a, b) => {
530538
const countA = a.kind === "inbox" ? a.sender.count : 0;
531539
const countB = b.kind === "inbox" ? b.sender.count : 0;
@@ -538,7 +546,7 @@ function SourcesTab({
538546

539547
return (
540548
<div className="grid gap-6">
541-
{totalAction === 0 && (
549+
{totalAction === 0 && unifiedItems.length === 0 && (
542550
<div className="rounded-xl border border-green-200 bg-green-50 px-4 py-3 text-[length:var(--font-size-body2)] text-green-800">
543551
All sources are assigned and ready. Messages will be parsed when you run the parser.
544552
</div>
@@ -615,22 +623,65 @@ function SourcesTab({
615623
</Card>
616624
)}
617625

618-
{/* Organizations overview */}
626+
{/* Assigned sources — always visible so assignments can be changed */}
627+
{assignedSources.length > 0 && (
628+
<details className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] px-5 py-4" open={unifiedItems.length === 0}>
629+
<summary className="cursor-pointer select-none font-semibold text-[length:var(--font-size-body2)]">
630+
{assignedSources.length} assigned source{assignedSources.length !== 1 ? "s" : ""}
631+
</summary>
632+
<div className="mt-3 grid gap-2">
633+
{assignedSources.map((src) => {
634+
const org = organizations.find((o) => o._id === src.organizationId);
635+
return (
636+
<AssignedSourceRow
637+
key={src._id}
638+
source={src}
639+
orgName={org?.name ?? "Unknown org"}
640+
organizations={organizations}
641+
onReassign={(orgId) => onAssignSource(src._id, orgId)}
642+
onIgnore={() => onIgnoreSender(src.listEmail)}
643+
/>
644+
);
645+
})}
646+
</div>
647+
</details>
648+
)}
649+
650+
{/* Ignored / paused sources */}
651+
{ignoredSources.length > 0 && (
652+
<details className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] px-5 py-4">
653+
<summary className="cursor-pointer select-none font-semibold text-[length:var(--font-size-body2)] text-[color:var(--color-text-muted)]">
654+
{ignoredSources.length} ignored source{ignoredSources.length !== 1 ? "s" : ""}
655+
</summary>
656+
<div className="mt-3 grid gap-2">
657+
{ignoredSources.map((src) => (
658+
<IgnoredSourceRow
659+
key={src._id}
660+
source={src}
661+
organizations={organizations}
662+
onReactivate={() => onUnignoreSource(src._id)}
663+
onAssign={(orgId) => { onUnignoreSource(src._id); onAssignSource(src._id, orgId); }}
664+
/>
665+
))}
666+
</div>
667+
</details>
668+
)}
669+
670+
{/* Organizations — view, edit, create */}
619671
<Card>
620672
<CardHeader
621673
title={`${organizations.length} organization${organizations.length !== 1 ? "s" : ""}`}
622-
subtitle="Create new organizations here if suggestions don't match."
674+
subtitle="Edit name or type, or create new organizations."
623675
/>
624676
{organizations.length > 0 && (
625677
<div className="mt-4 grid gap-2">
626678
{organizations.map((org) => (
627-
<div key={org._id} className="flex items-center gap-3 rounded-lg bg-[var(--color-neutral-100)] px-3 py-2 text-[length:var(--font-size-body2)]">
628-
<span className="font-semibold">{org.name}</span>
629-
<Tag>{org.type}</Tag>
630-
<span className="ml-auto text-[color:var(--color-text-muted)]">
631-
{listservs.filter((s) => s.organizationId === org._id).length} source{listservs.filter((s) => s.organizationId === org._id).length !== 1 ? "s" : ""}
632-
</span>
633-
</div>
679+
<OrgRow
680+
key={org._id}
681+
org={org}
682+
sourceCount={listservs.filter((s) => s.organizationId === org._id).length}
683+
onUpdate={(name, type) => onUpdateOrg(org._id, name, type)}
684+
/>
634685
))}
635686
</div>
636687
)}
@@ -640,6 +691,155 @@ function SourcesTab({
640691
);
641692
}
642693

694+
function AssignedSourceRow({
695+
source,
696+
orgName,
697+
organizations,
698+
onReassign,
699+
onIgnore,
700+
}: {
701+
source: Listserv;
702+
orgName: string;
703+
organizations: Organization[];
704+
onReassign: (orgId: Id<"organizations">) => void;
705+
onIgnore: () => void;
706+
}) {
707+
const [reassigning, setReassigning] = useState(false);
708+
return (
709+
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] overflow-hidden">
710+
<div className="flex flex-wrap items-center gap-3 px-4 py-3">
711+
<div className="flex-1 min-w-0">
712+
<div className="font-semibold truncate">{source.name}</div>
713+
<div className="text-[length:var(--font-size-body3)] text-[color:var(--color-text-muted)]">
714+
{source.listEmail} · <span className="text-[color:var(--color-neutral-700)]">{orgName}</span>
715+
</div>
716+
</div>
717+
{!reassigning && (
718+
<div className="flex gap-2 shrink-0">
719+
<Btn onClick={() => setReassigning(true)}>Reassign</Btn>
720+
<Btn danger onClick={onIgnore}>Ignore</Btn>
721+
</div>
722+
)}
723+
</div>
724+
{reassigning && (
725+
<div className="border-t border-[var(--color-border)] bg-[var(--color-neutral-100)] px-4 py-3 flex flex-wrap gap-3 items-end">
726+
<label className="flex flex-col gap-1 flex-1 min-w-[180px] text-[length:var(--font-size-body2)] font-semibold">
727+
Reassign to
728+
<select
729+
defaultValue={source.organizationId ?? ""}
730+
onChange={(e) => { if (e.target.value) { onReassign(e.target.value as Id<"organizations">); setReassigning(false); } }}
731+
className={input()}
732+
>
733+
<option value="">Choose…</option>
734+
{organizations.map((org) => (
735+
<option key={org._id} value={org._id}>{org.name}</option>
736+
))}
737+
</select>
738+
</label>
739+
<Btn onClick={() => setReassigning(false)}>Cancel</Btn>
740+
</div>
741+
)}
742+
</div>
743+
);
744+
}
745+
746+
function IgnoredSourceRow({
747+
source,
748+
organizations,
749+
onReactivate,
750+
onAssign,
751+
}: {
752+
source: Listserv;
753+
organizations: Organization[];
754+
onReactivate: () => void;
755+
onAssign: (orgId: Id<"organizations">) => void;
756+
}) {
757+
const [assigning, setAssigning] = useState(false);
758+
return (
759+
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-neutral-100)] overflow-hidden">
760+
<div className="flex flex-wrap items-center gap-3 px-4 py-3">
761+
<div className="flex-1 min-w-0">
762+
<div className="font-semibold truncate text-[color:var(--color-text-muted)]">{source.name}</div>
763+
<div className="text-[length:var(--font-size-body3)] text-[color:var(--color-text-muted)]">{source.listEmail}</div>
764+
</div>
765+
{!assigning && (
766+
<div className="flex gap-2 shrink-0">
767+
<Btn primary onClick={onReactivate}>Reactivate</Btn>
768+
<Btn onClick={() => setAssigning(true)}>Assign org</Btn>
769+
</div>
770+
)}
771+
</div>
772+
{assigning && (
773+
<div className="border-t border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-3 flex flex-wrap gap-3 items-end">
774+
<label className="flex flex-col gap-1 flex-1 min-w-[180px] text-[length:var(--font-size-body2)] font-semibold">
775+
Assign to org
776+
<select
777+
defaultValue=""
778+
onChange={(e) => { if (e.target.value) { onAssign(e.target.value as Id<"organizations">); setAssigning(false); } }}
779+
className={input()}
780+
>
781+
<option value="">Choose…</option>
782+
{organizations.map((org) => (
783+
<option key={org._id} value={org._id}>{org.name}</option>
784+
))}
785+
</select>
786+
</label>
787+
<Btn onClick={() => setAssigning(false)}>Cancel</Btn>
788+
</div>
789+
)}
790+
</div>
791+
);
792+
}
793+
794+
function OrgRow({
795+
org,
796+
sourceCount,
797+
onUpdate,
798+
}: {
799+
org: Organization;
800+
sourceCount: number;
801+
onUpdate: (name: string, type: OrgType) => void;
802+
}) {
803+
const [editing, setEditing] = useState(false);
804+
const [name, setName] = useState(org.name);
805+
const [type, setType] = useState<OrgType>(org.type);
806+
807+
// Reset draft if org prop changes (e.g. after save)
808+
const savedName = org.name;
809+
const savedType = org.type;
810+
811+
return (
812+
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] overflow-hidden">
813+
<div className="flex items-center gap-3 px-4 py-3">
814+
{editing ? (
815+
<>
816+
<input
817+
value={name}
818+
onChange={(e) => setName(e.target.value)}
819+
className={`${input()} flex-1`}
820+
autoFocus
821+
/>
822+
<select value={type} onChange={(e) => setType(e.target.value as OrgType)} className={`${input()} w-36`}>
823+
{ORG_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
824+
</select>
825+
<Btn primary onClick={() => { onUpdate(name.trim() || savedName, type); setEditing(false); }}>Save</Btn>
826+
<Btn onClick={() => { setName(savedName); setType(savedType); setEditing(false); }}>Cancel</Btn>
827+
</>
828+
) : (
829+
<>
830+
<span className="flex-1 font-semibold">{org.name}</span>
831+
<Tag>{org.type}</Tag>
832+
<span className="text-[color:var(--color-text-muted)] text-[length:var(--font-size-body3)]">
833+
{sourceCount} source{sourceCount !== 1 ? "s" : ""}
834+
</span>
835+
<Btn onClick={() => setEditing(true)}>Edit</Btn>
836+
</>
837+
)}
838+
</div>
839+
</div>
840+
);
841+
}
842+
643843
// Unified row for both known sources (listservs row exists, no org) and
644844
// inbox-only senders (no listservs row yet). isNewSource distinguishes them visually.
645845
function UnassignedRow({

0 commit comments

Comments
 (0)