"use client" import { useAuth } from "@lib/auth-context" import { cn } from "@lib/utils" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import type { LucideIcon } from "lucide-react" import { CalendarClock, ChevronDown, ChevronUp, FileText, GitPullRequest, Info, LifeBuoy, ListTodo, Loader2, MessageCircleQuestion, Plus, Radar, Trash2, } from "lucide-react" import { useEffect, useRef, useState } from "react" import { createPortal } from "react-dom" import { toast } from "sonner" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@ui/components/select" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@ui/components/tooltip" import { useHasCompanyBrain } from "@/hooks/use-company-brain" import { useOrgMemberRole } from "@/hooks/use-org-member-role" import { configureSectionToPath } from "@/lib/configure-routes" import { dmSans125ClassName } from "@/lib/fonts" const BACKEND = process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai" const BASE = `${BACKEND}/brain/automations` type Automation = { id: string enabled: boolean title: string channelId: string deliverTo: "channel" | "dm" prompt: string cron: string timezone: string | null createdBy: string | null } type Channel = { id: string; name: string; isPrivate: boolean } type Frequency = "daily" | "weekly" const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] const DEFAULT_PROMPT = "Summarize what's happened recently across the connected tools and channels: open items, unanswered questions, decisions, and anything the team should know. Keep it a short, scannable recap." // Local day/time -> UTC cron; the Date roundtrip carries any day rollover. function toUtcCron( time: string, frequency: Frequency, weekday: number, ): string | null { const [hh, mm] = time.split(":").map(Number) if ( hh === undefined || mm === undefined || Number.isNaN(hh) || Number.isNaN(mm) ) return null const d = new Date() d.setHours(hh, mm, 0, 0) if (frequency === "weekly") d.setDate(d.getDate() + ((weekday - d.getDay() + 7) % 7)) const m = d.getUTCMinutes() const h = d.getUTCHours() return frequency === "daily" ? `${m} ${h} * * *` : `${m} ${h} * * ${d.getUTCDay()}` } function fromUtcCron( cron: string, ): { frequency: Frequency; weekday: number; time: string } | null { const parts = cron.trim().split(/\s+/) if (parts.length !== 5) return null const [min, hr, , , dow] = parts const mm = Number(min) const hh = Number(hr) if (Number.isNaN(mm) || Number.isNaN(hh)) return null const d = new Date() d.setUTCHours(hh, mm, 0, 0) const time = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}` if (dow === "*") return { frequency: "daily", weekday: 1, time } const targetDow = Number(dow) if (Number.isNaN(targetDow)) return null d.setUTCDate(d.getUTCDate() + ((targetDow - d.getUTCDay() + 7) % 7)) return { frequency: "weekly", weekday: d.getDay(), time } } type Draft = { title: string channelId: string deliverTo: "channel" | "dm" prompt: string frequency: Frequency weekday: number time: string enabled: boolean } function toDraft(a: Automation): Draft { const parsed = fromUtcCron(a.cron) return { title: a.title, channelId: a.channelId, deliverTo: a.deliverTo === "dm" ? "dm" : "channel", prompt: a.prompt, frequency: parsed?.frequency ?? "daily", weekday: parsed?.weekday ?? 1, time: parsed?.time ?? "09:00", enabled: a.enabled, } } const emptyDraft = (): Draft => ({ title: "", channelId: "", deliverTo: "channel", prompt: DEFAULT_PROMPT, frequency: "daily", weekday: 1, time: "09:00", enabled: true, }) type Category = "team" | "engineering" | "support" | "product" type Preset = { id: string label: string description: string icon: LucideIcon category: Category requiresApps?: string[] prompt: string frequency: Frequency weekday?: number time: string } const PRESETS: Preset[] = [ { id: "standup", category: "team", label: "Morning checkup", description: "Shipped work, decisions, blockers & open questions from the last 24h.", icon: CalendarClock, prompt: "Give a short standup for this channel: what happened in the last 24 hours across our connected tools and this channel — work shipped, decisions made, blockers, and open questions. Keep it tight and scannable.", frequency: "daily", time: "09:00", }, { id: "weekly-recap", category: "team", label: "Company progress", description: "Decisions, shipped work & unresolved threads from the past week.", icon: CalendarClock, prompt: "Weekly recap for the team: decisions made, work shipped, and unresolved threads across our connected tools and channels over the past 7 days.", frequency: "weekly", weekday: 1, time: "09:00", }, { id: "unanswered", category: "team", label: "Unanswered questions", description: "Questions in this channel from the last 24h with no reply.", icon: MessageCircleQuestion, prompt: "Surface questions asked in this channel in the last 24 hours that haven't gotten a reply yet, so nothing slips through.", frequency: "daily", time: "16:00", }, { id: "prs-review", category: "engineering", label: "PRs awaiting review", description: "Open PRs waiting on review; flags stale ones.", icon: GitPullRequest, requiresApps: ["github"], prompt: "List open pull requests awaiting review. Flag any with no activity for 2+ days. Group by repository.", frequency: "daily", time: "09:30", }, { id: "issue-triage", category: "engineering", label: "Issue triage", description: "New or unassigned issues that need a response.", icon: ListTodo, requiresApps: ["github", "linear"], prompt: "Summarize new or unassigned issues from the last 24 hours that need triage or a response.", frequency: "daily", time: "09:00", }, { id: "customer-signal", category: "support", label: "Customer signal", description: "Recent customer issues & feedback and their status.", icon: LifeBuoy, requiresApps: ["plain", "linear"], prompt: "Recap customer issues and feedback raised recently across our tools and channels, with their current status.", frequency: "daily", time: "09:00", }, { id: "competitor-check", category: "product", label: "Competitor check", description: "What competitors shipped, announced, or changed this week.", icon: Radar, prompt: "Check what our competitors shipped, announced, or changed recently — launches, pricing changes, and anything the team should react to.", frequency: "weekly", weekday: 1, time: "09:00", }, { id: "release-notes", category: "product", label: "Release notes draft", description: "Draft notes from PRs merged since the last digest.", icon: FileText, requiresApps: ["github"], prompt: "Draft release notes from pull requests merged since the last digest, grouped into features, fixes, and chores.", frequency: "weekly", weekday: 5, time: "16:00", }, ] function presetToDraft(p: Preset): Draft { return { title: p.label, channelId: "", deliverTo: "channel", prompt: p.prompt, frequency: p.frequency, weekday: p.weekday ?? 1, time: p.time, enabled: true, } } // Connected-app presets first, universal next, unconnected-app presets last. function sortPresets(connected: Set): Preset[] { const rank = (p: Preset) => { if (!p.requiresApps) return 1 return p.requiresApps.some((a) => connected.has(a)) ? 0 : 2 } return [...PRESETS].sort((a, b) => rank(a) - rank(b)) } function cadenceLabel(p: Preset): string { if (p.frequency === "weekly") return `Weekly · ${WEEKDAYS[p.weekday ?? 1] ?? "Mon"} ${p.time}` return `Daily · ${p.time}` } const controlClass = cn( dmSans125ClassName(), "h-9 w-full rounded-[10px] border border-white/[0.08] bg-[#0D0F14] px-3 text-[13px] text-[#FAFAFA] outline-none disabled:opacity-50", ) const fieldLabel = cn(dmSans125ClassName(), "text-[12px] text-[#9A9A9A]") const selectContentClass = cn( dmSans125ClassName(), "rounded-[10px] border-white/[0.08] bg-[#1B1F24] text-[#FAFAFA] shadow-[0px_8px_24px_rgba(0,0,0,0.5)]", ) const selectItemClass = "cursor-pointer rounded-[8px] text-[13px] text-[#FAFAFA] hover:bg-white/10 hover:text-white data-[highlighted]:bg-white/10 data-[highlighted]:text-white focus:bg-white/10 focus:text-white" const DM_VALUE = "__dm__" function AutomationCard({ initial, id, channels, ownerLabel, personalOnlyApps = [], isAdmin = false, appCatalog = {}, onDone, onCancelNew, onCollapse, }: { initial: Draft id: string | null channels: Channel[] ownerLabel?: string personalOnlyApps?: string[] isAdmin?: boolean appCatalog?: Record onDone: () => void onCancelNew?: () => void onCollapse?: () => void }) { const [draft, setDraft] = useState(initial) const set = (k: K, v: Draft[K]) => setDraft((d) => ({ ...d, [k]: v })) const body = () => { if (!draft.title.trim()) throw new Error("Give the automation a name.") if (draft.deliverTo === "channel" && !draft.channelId) throw new Error("Pick a channel to post to.") const cron = toUtcCron(draft.time, draft.frequency, draft.weekday) if (!cron) throw new Error("Pick a valid time.") return { title: draft.title.trim(), deliverTo: draft.deliverTo, channelId: draft.deliverTo === "dm" ? null : draft.channelId, prompt: draft.prompt.trim() || DEFAULT_PROMPT, cron, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, enabled: draft.enabled, } } const save = useMutation({ mutationFn: async () => { const payload = body() const res = await fetch(id ? `${BASE}/${id}` : `${BASE}/`, { method: id ? "PUT" : "POST", credentials: "include", headers: { "content-type": "application/json" }, body: JSON.stringify(payload), }) if (res.status === 403) throw new Error("You can only manage automations you created.") if (!res.ok) { const b = (await res.json().catch(() => ({}))) as { error?: string } throw new Error(b.error ?? "Couldn't save.") } const b = (await res.json().catch(() => ({}))) as { warnings?: { app: string }[] } return b.warnings ?? [] }, onSuccess: (warnings) => { toast.success("Automation saved.") if (warnings.length) toast.warning( `Heads up: ${warnings.map((w) => w.app).join(", ")} ${warnings.length === 1 ? "is" : "are"} connected personally and won't be available to this channel automation. ${isAdmin ? "Reconnect it for the workspace in Connections." : "Ask an admin to connect it for the workspace."}`, { duration: 10000 }, ) onDone() }, onError: (err) => toast.error(err instanceof Error ? err.message : "Couldn't save."), }) const trigger = useMutation({ mutationFn: async () => { if (!id) throw new Error("Save the automation first.") const res = await fetch(`${BASE}/${id}/run-now`, { method: "POST", credentials: "include", }) const b = (await res.json().catch(() => ({}))) as { ok?: boolean reason?: string error?: string } if (!res.ok || b.ok === false) throw new Error(b.reason ?? b.error ?? "Couldn't run.") }, onSuccess: () => toast.success("Automation triggered — check the channel."), onError: (err) => toast.error(err instanceof Error ? err.message : "Couldn't run."), }) const remove = useMutation({ mutationFn: async () => { if (!id) return const res = await fetch(`${BASE}/${id}`, { method: "DELETE", credentials: "include", }) if (!res.ok) throw new Error("Couldn't delete.") }, onSuccess: () => { toast.success("Automation removed.") onDone() }, onError: (err) => toast.error(err instanceof Error ? err.message : "Couldn't delete."), }) const busy = save.isPending || trigger.isPending || remove.isPending const disabled = busy return (
set("title", e.target.value)} /> {ownerLabel ? ( {ownerLabel} ) : null}
{onCollapse ? ( ) : null}