Transform meetings page into context page with tabs

Add structured organization context with 5 markdown sections (Product, Architecture, Team, Processes, Customers) editable inline. Meetings are now a tab within the context page. Moved all GraphQL queries from hooks/graph/MeetingGraph.ts into colocated components following new best practices. Updated database schema, backend services, GraphQL resolvers, and MCP API to support the new context fields and structure.

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
Bryan Frimin
2026-03-19 17:42:14 +01:00
parent c2a1843c67
commit a9ebeef0fa
25 changed files with 1156 additions and 380 deletions

View File

@@ -1,111 +0,0 @@
import { useTranslate } from "@probo/i18n";
import { graphql } from "relay-runtime";
import type { MeetingGraphDeleteMutation } from "#/__generated__/core/MeetingGraphDeleteMutation.graphql";
import type { MeetingGraphUpdateMutation } from "#/__generated__/core/MeetingGraphUpdateMutation.graphql";
import { useMutationWithToasts } from "../useMutationWithToasts";
/* eslint-disable relay/unused-fields, relay/must-colocate-fragment-spreads */
export const meetingsQuery = graphql`
query MeetingGraphListQuery($organizationId: ID!) {
organization: node(id: $organizationId) {
id
... on Organization {
canCreateMeeting: permission(action: "core:meeting:create")
}
...MeetingsPageListFragment
}
}
`;
export const meetingNodeQuery = graphql`
query MeetingGraphNodeQuery($meetingId: ID!) {
node(id: $meetingId) {
...MeetingDetailPageMeetingFragment
}
}
`;
export const MeetingsConnectionKey = "MeetingsListQuery_meetings";
const deleteMeetingMutation = graphql`
mutation MeetingGraphDeleteMutation($input: DeleteMeetingInput!) {
deleteMeeting(input: $input) {
deletedMeetingId @deleteRecord
}
}
`;
export function useDeleteMeetingMutation() {
const { __ } = useTranslate();
return useMutationWithToasts<MeetingGraphDeleteMutation>(
deleteMeetingMutation,
{
successMessage: __("Meeting deleted successfully."),
errorMessage: __("Failed to delete meeting"),
},
);
}
const updateMeetingMutation = graphql`
mutation MeetingGraphUpdateMutation($input: UpdateMeetingInput!) {
updateMeeting(input: $input) {
meeting {
id
name
date
minutes
attendees {
id
fullName
}
}
}
}
`;
export function useUpdateMeetingMutation() {
const { __ } = useTranslate();
return useMutationWithToasts<MeetingGraphUpdateMutation>(
updateMeetingMutation,
{
successMessage: __("Meeting updated successfully."),
errorMessage: __("Failed to update meeting"),
},
);
}
const createMeetingMutation = graphql`
mutation MeetingGraphCreateMutation(
$input: CreateMeetingInput!
$connections: [ID!]!
) {
createMeeting(input: $input) {
meetingEdge @prependEdge(connections: $connections) {
node {
id
name
date
minutes
attendees {
id
fullName
}
}
}
}
}
`;
export function useCreateMeetingMutation() {
const { __ } = useTranslate();
return useMutationWithToasts(createMeetingMutation, {
successMessage: __("Meeting created successfully."),
errorMessage: __("Failed to create meeting"),
});
}

View File

@@ -3,7 +3,6 @@ import {
IconBank,
IconBook,
IconBox,
IconCalendar1,
IconCircleProgress,
IconClock,
IconFire3,
@@ -15,6 +14,7 @@ import {
IconMedal,
IconPageCheck,
IconPageTextLine,
IconPageTextSolid,
IconSettingsGear2,
IconShield,
IconStore,
@@ -29,7 +29,7 @@ import { useOrganizationId } from "#/hooks/useOrganizationId";
const fragment = graphql`
fragment SidebarFragment on Organization {
canListMeetings: permission(action: "core:meeting:list")
canGetContext: permission(action: "core:organization-context:get")
canListTasks: permission(action: "core:task:list")
canListMeasures: permission(action: "core:measure:list")
canListRisks: permission(action: "core:risk:list")
@@ -67,11 +67,11 @@ export function Sidebar(props: { fKey: SidebarFragment$key }) {
return (
<ul className="space-y-[2px]">
{organization.canListMeetings && (
{organization.canGetContext && (
<SidebarItem
label={__("Meetings")}
icon={IconCalendar1}
to={`${prefix}/meetings`}
label={__("Context")}
icon={IconPageTextSolid}
to={`${prefix}/context`}
/>
)}
{organization.canListTasks && (

View File

@@ -0,0 +1,30 @@
import { usePageTitle } from "@probo/hooks";
import { useTranslate } from "@probo/i18n";
import { PageHeader, TabLink, Tabs } from "@probo/ui";
import { Outlet } from "react-router";
import { useOrganizationId } from "#/hooks/useOrganizationId";
export default function ContextLayout() {
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const prefix = `/organizations/${organizationId}/context`;
usePageTitle(__("Context"));
return (
<div className="space-y-6">
<PageHeader
title={__("Context")}
description={__(
"Structured company information and meetings for AI assistants and compliance workflows.",
)}
/>
<Tabs>
<TabLink to={`${prefix}/overview`}>{__("Context")}</TabLink>
<TabLink to={`${prefix}/meetings`}>{__("Meetings")}</TabLink>
</Tabs>
<Outlet />
</div>
);
}

View File

@@ -0,0 +1,254 @@
import { useTranslate } from "@probo/i18n";
import {
Button,
Card,
IconCheckmark1,
IconCrossLargeX,
IconPencil,
Markdown,
Textarea,
} from "@probo/ui";
import { useRef, useState } from "react";
import { useFragment } from "react-relay";
import { graphql } from "relay-runtime";
import type { ContextPage_UpdateMutation } from "#/__generated__/core/ContextPage_UpdateMutation.graphql";
import type { ContextPageFragment$key } from "#/__generated__/core/ContextPageFragment.graphql";
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
/* eslint-disable relay/unused-fields */
const fragment = graphql`
fragment ContextPageFragment on Organization {
id
canUpdateContext: permission(action: "core:organization-context:update")
context {
product
architecture
team
processes
customers
}
}
`;
/* eslint-enable relay/unused-fields */
const updateMutation = graphql`
mutation ContextPage_UpdateMutation(
$input: UpdateOrganizationContextInput!
) {
updateOrganizationContext(input: $input) {
context {
organizationId
product
architecture
team
processes
customers
}
}
}
`;
type SectionKey = "product" | "architecture" | "team" | "processes" | "customers";
type SectionConfig = {
key: SectionKey;
title: string;
description: string;
placeholder: string;
};
type Props = {
organization: ContextPageFragment$key;
};
export default function ContextPage(props: Props) {
const { __ } = useTranslate();
const organization = useFragment(fragment, props.organization);
const sections: SectionConfig[] = [
{
key: "product",
title: __("Product"),
description: __("Describe what your product does, its main features, and value proposition."),
placeholder: __("Describe your product in markdown format..."),
},
{
key: "architecture",
title: __("Architecture"),
description: __("Describe your technical architecture, infrastructure, and key design decisions."),
placeholder: __("Describe your architecture in markdown format..."),
},
{
key: "team",
title: __("Team"),
description: __("Describe your team structure, roles, and responsibilities."),
placeholder: __("Describe your team in markdown format..."),
},
{
key: "processes",
title: __("Processes"),
description: __("Describe your key processes, workflows, and operational procedures."),
placeholder: __("Describe your processes in markdown format..."),
},
{
key: "customers",
title: __("Customers"),
description: __("Describe your target market, customer segments, and use cases."),
placeholder: __("Describe your customers in markdown format..."),
},
];
return (
<div className="space-y-6">
{sections.map(section => (
<ContextSection
key={section.key}
section={section}
organizationId={organization.id}
value={organization.context?.[section.key] ?? null}
canEdit={organization.canUpdateContext}
/>
))}
</div>
);
}
function ContextSection({
section,
organizationId,
value,
canEdit,
}: {
section: SectionConfig;
organizationId: string;
value: string | null;
canEdit: boolean;
}) {
const { __ } = useTranslate();
const [isEditing, setIsEditing] = useState(false);
const [text, setText] = useState(value ?? "");
const [displayedValue, setDisplayedValue] = useState(value ?? "");
const justSavedRef = useRef(false);
const [updateContext, isUpdating]
= useMutationWithToasts<ContextPage_UpdateMutation>(
updateMutation,
{
successMessage: __("Context updated successfully"),
errorMessage: __("Failed to update context"),
},
);
const handleSave = async () => {
const valueToSave = text.trim();
const previousValue = value ?? "";
setDisplayedValue(valueToSave);
justSavedRef.current = true;
const valueToSend = valueToSave.length > 0 ? valueToSave : null;
await updateContext({
variables: {
input: {
organizationId,
[section.key]: valueToSend,
},
},
onError: () => {
setDisplayedValue(previousValue);
justSavedRef.current = false;
},
onCompleted: (_, errors) => {
if (errors?.length) {
setDisplayedValue(previousValue);
justSavedRef.current = false;
}
setIsEditing(false);
},
});
};
const handleCancel = () => {
setText(value ?? "");
setIsEditing(false);
};
return (
<Card padded>
{isEditing
? (
<div className="space-y-4">
<div>
<h3 className="text-sm font-semibold">{section.title}</h3>
<p className="text-xs text-txt-tertiary mt-1">
{section.description}
</p>
</div>
<Textarea
value={text}
onChange={e => setText(e.target.value)}
autogrow
className="min-h-32 font-mono text-sm"
placeholder={section.placeholder}
/>
<div className="flex gap-2 justify-end">
<Button
variant="secondary"
icon={IconCrossLargeX}
onClick={handleCancel}
disabled={isUpdating}
>
{__("Cancel")}
</Button>
<Button
icon={IconCheckmark1}
onClick={() => void handleSave()}
disabled={isUpdating}
>
{__("Save")}
</Button>
</div>
</div>
)
: (
<div className="space-y-2">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold">{section.title}</h3>
<p className="text-xs text-txt-tertiary mt-1">
{section.description}
</p>
</div>
{canEdit && (
<Button
variant="quaternary"
icon={IconPencil}
onClick={() => {
setText(value ?? "");
setIsEditing(true);
}}
>
{__("Edit")}
</Button>
)}
</div>
<div className="w-full">
{displayedValue
? (
<div className="prose prose-sm max-w-none w-full [&_.prose]:max-w-none">
<Markdown content={displayedValue} />
</div>
)
: (
<div className="text-txt-tertiary text-sm italic">
{__("No content yet. Click Edit to add one.")}
</div>
)}
</div>
</div>
)}
</Card>
);
}

View File

@@ -0,0 +1,26 @@
import { type PreloadedQuery, usePreloadedQuery } from "react-relay";
import { graphql } from "relay-runtime";
import type { ContextPageLoaderQuery } from "#/__generated__/core/ContextPageLoaderQuery.graphql";
import ContextPage from "./ContextPage";
export const contextPageQuery = graphql`
query ContextPageLoaderQuery($organizationId: ID!) {
organization: node(id: $organizationId) {
... on Organization {
...ContextPageFragment
}
}
}
`;
type Props = {
queryRef: PreloadedQuery<ContextPageLoaderQuery>;
};
export default function ContextPageLoader(props: Props) {
const data = usePreloadedQuery(contextPageQuery, props.queryRef);
return <ContextPage organization={data.organization} />;
}

View File

@@ -16,12 +16,10 @@ import type { PreloadedQuery } from "react-relay";
import { graphql, useFragment, usePreloadedQuery } from "react-relay";
import { Link, Outlet, useNavigate } from "react-router";
import type { MeetingDetailPageDeleteMutation } from "#/__generated__/core/MeetingDetailPageDeleteMutation.graphql";
import type { MeetingDetailPageMeetingFragment$key } from "#/__generated__/core/MeetingDetailPageMeetingFragment.graphql";
import type { MeetingGraphNodeQuery } from "#/__generated__/core/MeetingGraphNodeQuery.graphql";
import {
meetingNodeQuery,
useDeleteMeetingMutation,
} from "#/hooks/graph/MeetingGraph";
import type { MeetingDetailPageQuery } from "#/__generated__/core/MeetingDetailPageQuery.graphql";
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import {
@@ -29,6 +27,14 @@ import {
type UpdateMeetingMinutesDialogRef,
} from "./dialogs/UpdateMeetingMinutesDialog";
export const meetingDetailPageQuery = graphql`
query MeetingDetailPageQuery($meetingId: ID!) {
node(id: $meetingId) {
...MeetingDetailPageMeetingFragment
}
}
`;
const meetingFragment = graphql`
fragment MeetingDetailPageMeetingFragment on Meeting {
id
@@ -45,12 +51,32 @@ const meetingFragment = graphql`
}
`;
const deleteMeetingMutation = graphql`
mutation MeetingDetailPageDeleteMutation($input: DeleteMeetingInput!) {
deleteMeeting(input: $input) {
deletedMeetingId @deleteRecord
}
}
`;
function useDeleteMeetingMutation() {
const { __ } = useTranslate();
return useMutationWithToasts<MeetingDetailPageDeleteMutation>(
deleteMeetingMutation,
{
successMessage: __("Meeting deleted successfully."),
errorMessage: __("Failed to delete meeting"),
},
);
}
type Props = {
queryRef: PreloadedQuery<MeetingGraphNodeQuery>;
queryRef: PreloadedQuery<MeetingDetailPageQuery>;
};
export default function MeetingDetailPage(props: Props) {
const node = usePreloadedQuery(meetingNodeQuery, props.queryRef).node;
const node = usePreloadedQuery(meetingDetailPageQuery, props.queryRef).node;
const meeting = useFragment<MeetingDetailPageMeetingFragment$key>(
meetingFragment,
node,
@@ -75,7 +101,7 @@ export default function MeetingDetailPage(props: Props) {
input: { meetingId: meeting.id },
},
onSuccess: () => {
void navigate(`/organizations/${organizationId}/meetings`);
void navigate(`/organizations/${organizationId}/context/meetings`);
},
}),
{
@@ -101,7 +127,7 @@ export default function MeetingDetailPage(props: Props) {
items={[
{
label: __("Meetings"),
to: `/organizations/${organizationId}/meetings`,
to: `/organizations/${organizationId}/context/meetings`,
},
{
label: meeting.name,

View File

@@ -1,5 +1,4 @@
import { formatDate, sprintf } from "@probo/helpers";
import { usePageTitle } from "@probo/hooks";
import { useTranslate } from "@probo/i18n";
import {
ActionDropdown,
@@ -7,22 +6,15 @@ import {
Button,
Card,
DropdownItem,
IconCheckmark1,
IconCrossLargeX,
IconPencil,
IconPlusLarge,
IconTrashCan,
Markdown,
PageHeader,
Tbody,
Td,
Textarea,
Th,
Thead,
Tr,
useConfirm,
} from "@probo/ui";
import { useRef, useState } from "react";
import {
type PreloadedQuery,
useFragment,
@@ -32,19 +24,27 @@ import {
import { Link } from "react-router";
import { graphql } from "relay-runtime";
import type { MeetingGraphListQuery } from "#/__generated__/core/MeetingGraphListQuery.graphql";
import type { MeetingsPage_UpdateSummaryMutation } from "#/__generated__/core/MeetingsPage_UpdateSummaryMutation.graphql";
import type { MeetingsPageDeleteMutation } from "#/__generated__/core/MeetingsPageDeleteMutation.graphql";
import type { MeetingsPageListFragment$key } from "#/__generated__/core/MeetingsPageListFragment.graphql";
import type { MeetingsPageQuery } from "#/__generated__/core/MeetingsPageQuery.graphql";
import type { MeetingsPageRowFragment$key } from "#/__generated__/core/MeetingsPageRowFragment.graphql";
import { SortableTable, SortableTh } from "#/components/SortableTable";
import {
meetingsQuery,
useDeleteMeetingMutation,
} from "#/hooks/graph/MeetingGraph";
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
import { CreateMeetingDialog } from "./dialogs/CreateMeetingDialog";
export const meetingsPageQuery = graphql`
query MeetingsPageQuery($organizationId: ID!) {
organization: node(id: $organizationId) {
id
... on Organization {
canCreateMeeting: permission(action: "core:meeting:create")
}
...MeetingsPageListFragment
}
}
`;
const meetingsFragment = graphql`
fragment MeetingsPageListFragment on Organization
@refetchable(queryName: "MeetingsListQuery")
@@ -59,9 +59,7 @@ const meetingsFragment = graphql`
last: { type: "Int", defaultValue: null }
) {
id
context {
summary
}
canCreateMeeting: permission(action: "core:meeting:create")
meetings(
first: $first
after: $after
@@ -80,14 +78,34 @@ const meetingsFragment = graphql`
}
`;
const deleteMeetingMutation = graphql`
mutation MeetingsPageDeleteMutation($input: DeleteMeetingInput!) {
deleteMeeting(input: $input) {
deletedMeetingId @deleteRecord
}
}
`;
function useDeleteMeetingMutation() {
const { __ } = useTranslate();
return useMutationWithToasts<MeetingsPageDeleteMutation>(
deleteMeetingMutation,
{
successMessage: __("Meeting deleted successfully."),
errorMessage: __("Failed to delete meeting"),
},
);
}
type Props = {
queryRef: PreloadedQuery<MeetingGraphListQuery>;
queryRef: PreloadedQuery<MeetingsPageQuery>;
};
export default function MeetingsPage(props: Props) {
const { __ } = useTranslate();
const organization = usePreloadedQuery(
meetingsQuery,
meetingsPageQuery,
props.queryRef,
).organization;
@@ -102,151 +120,15 @@ export default function MeetingsPage(props: Props) {
.filter(Boolean);
const connectionId = pagination.data.meetings.__id;
usePageTitle(__("Meetings"));
const [isEditing, setIsEditing] = useState(false);
const summary = pagination.data.context?.summary || "";
const [summaryText, setSummaryText] = useState(summary);
// Local state to track the displayed summary (updated immediately on save)
const [displayedSummary, setDisplayedSummary] = useState(summary);
// Track if we just saved to prevent useEffect from overwriting our update
const justSavedRef = useRef(false);
const updateSummaryMutation = graphql`
mutation MeetingsPage_UpdateSummaryMutation(
$input: UpdateOrganizationContextInput!
) {
updateOrganizationContext(input: $input) {
context {
organizationId
summary
}
}
}
`;
const [updateSummary, isUpdating]
= useMutationWithToasts<MeetingsPage_UpdateSummaryMutation>(
updateSummaryMutation,
{
successMessage: __("Summary updated successfully"),
errorMessage: __("Failed to update summary"),
},
);
const handleSave = async () => {
const valueToSave = summaryText.trim();
const previousValue = pagination.data.context?.summary || "";
setDisplayedSummary(valueToSave);
justSavedRef.current = true;
const valueToSend = valueToSave.length > 0 ? valueToSave : "";
await updateSummary({
variables: {
input: {
organizationId: organization.id,
summary: valueToSend || null,
},
},
onError: () => {
// Roll back optimistic update on error
setDisplayedSummary(previousValue);
justSavedRef.current = false;
},
onCompleted: (_, errors) => {
if (errors?.length) {
// Roll back optimistic update on GraphQL error
setDisplayedSummary(previousValue);
justSavedRef.current = false;
}
setIsEditing(false);
},
});
};
const handleCancel = () => {
setSummaryText(pagination.data.context?.summary || "");
setIsEditing(false);
};
return (
<div className="space-y-6">
<Card padded>
{isEditing
? (
<div className="space-y-4">
<Textarea
value={summaryText}
onChange={e => setSummaryText(e.target.value)}
autogrow
className="min-h-32 font-mono text-sm"
placeholder={__("Enter meetings summary in markdown format")}
/>
<div className="flex gap-2 justify-end">
<Button
variant="secondary"
icon={IconCrossLargeX}
onClick={handleCancel}
disabled={isUpdating}
>
{__("Cancel")}
</Button>
<Button
icon={IconCheckmark1}
onClick={() => void handleSave()}
disabled={isUpdating}
>
{__("Save")}
</Button>
</div>
</div>
)
: (
<div className="space-y-2">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-txt-secondary">
{__("Summary")}
</h3>
{organization.canCreateMeeting && (
<Button
variant="quaternary"
icon={IconPencil}
onClick={() => setIsEditing(true)}
>
{__("Edit")}
</Button>
)}
</div>
<div className="w-full">
{displayedSummary
? (
<div className="prose prose-sm max-w-none w-full [&_.prose]:max-w-none">
<Markdown content={displayedSummary} />
</div>
)
: (
<div className="text-txt-tertiary text-sm italic">
{__("No summary yet. Click Edit to add one.")}
</div>
)}
</div>
</div>
)}
</Card>
<PageHeader
title={__("Meetings")}
description={__(
"Track and manage your organization's meetings and their minutes.",
)}
>
{organization.canCreateMeeting && (
{pagination.data.canCreateMeeting && (
<div className="flex justify-end">
<CreateMeetingDialog connectionId={connectionId}>
<Button icon={IconPlusLarge}>{__("Add meeting")}</Button>
</CreateMeetingDialog>
)}
</PageHeader>
</div>
)}
{meetingNodes.length > 0
? (
<SortableTable {...pagination}>
@@ -336,7 +218,7 @@ function MeetingRow({
};
return (
<Tr to={`/organizations/${organizationId}/meetings/${meeting.id}`}>
<Tr to={`/organizations/${organizationId}/context/meetings/${meeting.id}`}>
<Td className="w-40">{formatDate(meeting.date)}</Td>
<Td className="min-w-0">
<div className="flex gap-4 items-center">{meeting.name}</div>

View File

@@ -10,11 +10,30 @@ import {
useDialogRef,
} from "@probo/ui";
import { forwardRef, useImperativeHandle } from "react";
import { graphql } from "relay-runtime";
import { z } from "zod";
import type { MeetingDetailPageMeetingFragment$data } from "#/__generated__/core/MeetingDetailPageMeetingFragment.graphql";
import { useUpdateMeetingMutation } from "#/hooks/graph/MeetingGraph";
import type { UpdateMeetingMinutesDialogMutation } from "#/__generated__/core/UpdateMeetingMinutesDialogMutation.graphql";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
const updateMeetingMutation = graphql`
mutation UpdateMeetingMinutesDialogMutation($input: UpdateMeetingInput!) {
updateMeeting(input: $input) {
meeting {
id
name
date
minutes
attendees {
id
fullName
}
}
}
}
`;
type Props = {
meeting: MeetingDetailPageMeetingFragment$data;
@@ -34,7 +53,14 @@ export const UpdateMeetingMinutesDialog = forwardRef<
>(function UpdateMeetingMinutesDialog({ meeting }, ref) {
const { __ } = useTranslate();
const dialogRef = useDialogRef();
const [updateMeeting, isUpdating] = useUpdateMeetingMutation();
const [updateMeeting, isUpdating]
= useMutationWithToasts<UpdateMeetingMinutesDialogMutation>(
updateMeetingMutation,
{
successMessage: __("Meeting updated successfully."),
errorMessage: __("Failed to update meeting"),
},
);
const { handleSubmit, register, reset } = useFormWithSchema(minutesSchema, {
defaultValues: {
minutes: meeting.minutes || "",

View File

@@ -19,12 +19,12 @@ import { compliancePageRoutes } from "./pages/organizations/compliance-page/rout
import { CurrentUser } from "./providers/CurrentUser";
import { assetRoutes } from "./routes/assetRoutes";
import { auditRoutes } from "./routes/auditRoutes";
import { contextRoutes } from "./routes/contextRoutes";
import { dataRoutes } from "./routes/dataRoutes";
import { documentsRoutes } from "./routes/documentsRoutes";
import { findingRoutes } from "./routes/findingRoutes";
import { frameworkRoutes } from "./routes/frameworkRoutes";
import { measureRoutes } from "./routes/measureRoutes";
import { meetingsRoutes } from "./routes/meetingsRoutes";
import { obligationRoutes } from "./routes/obligationRoutes";
import { processingActivityRoutes } from "./routes/processingActivityRoutes";
import { rightsRequestRoutes } from "./routes/rightsRequestRoutes";
@@ -222,7 +222,7 @@ const routes = [
...assetRoutes,
...dataRoutes,
...auditRoutes,
...meetingsRoutes,
...contextRoutes,
...findingRoutes,
...obligationRoutes,
...rightsRequestRoutes,

View File

@@ -0,0 +1,114 @@
import { lazy } from "@probo/react-lazy";
import {
type AppRoute,
loaderFromQueryLoader,
withQueryRef,
} from "@probo/routes";
import { Fragment } from "react";
import { loadQuery } from "react-relay";
import { type LoaderFunctionArgs, redirect } from "react-router";
import type { ContextPageLoaderQuery } from "#/__generated__/core/ContextPageLoaderQuery.graphql";
import type { MeetingDetailPageQuery } from "#/__generated__/core/MeetingDetailPageQuery.graphql";
import type { MeetingsPageQuery } from "#/__generated__/core/MeetingsPageQuery.graphql";
import { LinkCardSkeleton } from "#/components/skeletons/LinkCardSkeleton";
import { PageSkeleton } from "#/components/skeletons/PageSkeleton";
import { coreEnvironment } from "#/environments";
import { contextPageQuery } from "#/pages/organizations/context/ContextPageLoader";
import { meetingDetailPageQuery } from "#/pages/organizations/meetings/MeetingDetailPage";
import { meetingsPageQuery } from "#/pages/organizations/meetings/MeetingsPage";
const meetingTabs = (prefix: string) => {
return [
{
path: `${prefix}`,
loader: ({
params: { organizationId, meetingId },
}: LoaderFunctionArgs) => {
const basePath = `/organizations/${organizationId}/context/meetings/${meetingId}`;
const redirectPath = `${basePath}/minutes`;
// eslint-disable-next-line
throw redirect(redirectPath);
},
Component: Fragment,
},
{
path: `${prefix}minutes`,
Fallback: LinkCardSkeleton,
Component: lazy(
() => import("../pages/organizations/meetings/tabs/MeetingMinutesTab"),
),
},
];
};
export const contextRoutes = [
{
path: "context",
Fallback: PageSkeleton,
Component: lazy(
() => import("#/pages/organizations/context/ContextLayout"),
),
children: [
{
path: "",
loader: ({
params: { organizationId },
}: LoaderFunctionArgs) => {
// eslint-disable-next-line
throw redirect(`/organizations/${organizationId}/context/overview`);
},
Component: Fragment,
},
{
path: "overview",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ organizationId }) =>
loadQuery<ContextPageLoaderQuery>(
coreEnvironment,
contextPageQuery,
{ organizationId },
),
),
Component: withQueryRef(
lazy(
() => import("#/pages/organizations/context/ContextPageLoader"),
),
),
},
{
path: "meetings",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ organizationId }) =>
loadQuery<MeetingsPageQuery>(
coreEnvironment,
meetingsPageQuery,
{ organizationId },
),
),
Component: withQueryRef(
lazy(
() => import("#/pages/organizations/meetings/MeetingsPage"),
),
),
},
{
path: "meetings/:meetingId",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ meetingId }) =>
loadQuery<MeetingDetailPageQuery>(
coreEnvironment,
meetingDetailPageQuery,
{ meetingId },
),
),
Component: withQueryRef(
lazy(
() => import("../pages/organizations/meetings/MeetingDetailPage"),
),
),
children: [...meetingTabs("")],
},
],
},
] satisfies AppRoute[];

View File

@@ -1,68 +0,0 @@
import { lazy } from "@probo/react-lazy";
import {
type AppRoute,
loaderFromQueryLoader,
withQueryRef,
} from "@probo/routes";
import { Fragment } from "react";
import { loadQuery } from "react-relay";
import { type LoaderFunctionArgs, redirect } from "react-router";
import type { MeetingGraphListQuery } from "#/__generated__/core/MeetingGraphListQuery.graphql";
import type { MeetingGraphNodeQuery } from "#/__generated__/core/MeetingGraphNodeQuery.graphql";
import { LinkCardSkeleton } from "#/components/skeletons/LinkCardSkeleton";
import { PageSkeleton } from "#/components/skeletons/PageSkeleton";
import { coreEnvironment } from "#/environments";
import { meetingNodeQuery, meetingsQuery } from "#/hooks/graph/MeetingGraph";
const meetingTabs = (prefix: string) => {
return [
{
path: `${prefix}`,
loader: ({
params: { organizationId, meetingId },
}: LoaderFunctionArgs) => {
const basePath = `/organizations/${organizationId}/meetings/${meetingId}`;
const redirectPath = `${basePath}/minutes`;
// eslint-disable-next-line
throw redirect(redirectPath);
},
Component: Fragment,
},
{
path: `${prefix}minutes`,
Fallback: LinkCardSkeleton,
Component: lazy(
() => import("../pages/organizations/meetings/tabs/MeetingMinutesTab"),
),
},
];
};
export const meetingsRoutes = [
{
path: "meetings",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ organizationId }) =>
loadQuery<MeetingGraphListQuery>(coreEnvironment, meetingsQuery, {
organizationId,
}),
),
Component: withQueryRef(
lazy(() => import("#/pages/organizations/meetings/MeetingsPage")),
),
},
{
path: "meetings/:meetingId",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ meetingId }) =>
loadQuery<MeetingGraphNodeQuery>(coreEnvironment, meetingNodeQuery, {
meetingId,
}),
),
Component: withQueryRef(
lazy(() => import("../pages/organizations/meetings/MeetingDetailPage")),
),
children: [...meetingTabs("")],
},
] satisfies AppRoute[];

View File

@@ -145,7 +145,11 @@ func TestOrganization_UpdateContext(t *testing.T) {
updateOrganizationContext(input: $input) {
context {
organizationId
summary
product
architecture
team
processes
customers
}
}
}
@@ -155,7 +159,11 @@ func TestOrganization_UpdateContext(t *testing.T) {
UpdateOrganizationContext struct {
Context struct {
OrganizationID string `json:"organizationId"`
Summary *string `json:"summary"`
Product *string `json:"product"`
Architecture *string `json:"architecture"`
Team *string `json:"team"`
Processes *string `json:"processes"`
Customers *string `json:"customers"`
} `json:"context"`
} `json:"updateOrganizationContext"`
}
@@ -163,14 +171,17 @@ func TestOrganization_UpdateContext(t *testing.T) {
err := owner.Execute(query, map[string]any{
"input": map[string]any{
"organizationId": owner.GetOrganizationID().String(),
"summary": "Our organization provides compliance solutions.",
"product": "Our product provides compliance solutions.",
"architecture": "Microservices architecture on AWS.",
},
}, &result)
require.NoError(t, err)
assert.Equal(t, owner.GetOrganizationID().String(), result.UpdateOrganizationContext.Context.OrganizationID)
require.NotNil(t, result.UpdateOrganizationContext.Context.Summary)
assert.Equal(t, "Our organization provides compliance solutions.", *result.UpdateOrganizationContext.Context.Summary)
require.NotNil(t, result.UpdateOrganizationContext.Context.Product)
assert.Equal(t, "Our product provides compliance solutions.", *result.UpdateOrganizationContext.Context.Product)
require.NotNil(t, result.UpdateOrganizationContext.Context.Architecture)
assert.Equal(t, "Microservices architecture on AWS.", *result.UpdateOrganizationContext.Context.Architecture)
}
func TestOrganization_Get(t *testing.T) {

View File

@@ -0,0 +1,34 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package context
import (
"github.com/spf13/cobra"
"go.probo.inc/probo/pkg/cmd/cmdutil"
"go.probo.inc/probo/pkg/cmd/context/get"
"go.probo.inc/probo/pkg/cmd/context/update"
)
func NewCmdContext(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "context <command>",
Short: "Manage organization context",
}
cmd.AddCommand(get.NewCmdGet(f))
cmd.AddCommand(update.NewCmdUpdate(f))
return cmd
}

155
pkg/cmd/context/get/get.go Normal file
View File

@@ -0,0 +1,155 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package get
import (
"encoding/json"
"fmt"
"github.com/charmbracelet/lipgloss"
"github.com/spf13/cobra"
"go.probo.inc/probo/pkg/cli/api"
"go.probo.inc/probo/pkg/cmd/cmdutil"
)
const getQuery = `
query($id: ID!) {
node(id: $id) {
... on Organization {
id
name
context {
product
architecture
team
processes
customers
}
}
}
}
`
type getResponse struct {
Node *struct {
ID string `json:"id"`
Name string `json:"name"`
Context *struct {
Product *string `json:"product"`
Architecture *string `json:"architecture"`
Team *string `json:"team"`
Processes *string `json:"processes"`
Customers *string `json:"customers"`
} `json:"context"`
} `json:"node"`
}
func NewCmdGet(f *cmdutil.Factory) *cobra.Command {
var (
flagOrg string
flagOutput *string
)
cmd := &cobra.Command{
Use: "get",
Short: "Get organization context",
Example: ` prb context get --org <org-id>`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
if err := cmdutil.ValidateOutputFlag(flagOutput); err != nil {
return err
}
cfg, err := f.Config()
if err != nil {
return err
}
host, hc, err := cfg.DefaultHost()
if err != nil {
return err
}
orgID := flagOrg
if orgID == "" {
orgID = hc.Organization
}
if orgID == "" {
return fmt.Errorf("organization ID is required: pass --org or run `prb auth login`")
}
client := api.NewClient(
host,
hc.Token,
"/api/console/v1/graphql",
cfg.HTTPTimeoutDuration(),
)
data, err := client.Do(
getQuery,
map[string]any{"id": orgID},
)
if err != nil {
return err
}
var resp getResponse
if err := json.Unmarshal(data, &resp); err != nil {
return fmt.Errorf("cannot parse response: %w", err)
}
if resp.Node == nil {
return fmt.Errorf("organization %s not found", orgID)
}
if *flagOutput == cmdutil.OutputJSON {
return cmdutil.PrintJSON(f.IOStreams.Out, resp.Node.Context)
}
ctx := resp.Node.Context
out := f.IOStreams.Out
bold := lipgloss.NewStyle().Bold(true)
label := lipgloss.NewStyle().Foreground(lipgloss.Color("242"))
sections := []struct {
title string
value *string
}{
{"Product", ctx.Product},
{"Architecture", ctx.Architecture},
{"Team", ctx.Team},
{"Processes", ctx.Processes},
{"Customers", ctx.Customers},
}
for _, s := range sections {
_, _ = fmt.Fprintf(out, "%s\n", bold.Render(s.title))
if s.value != nil && *s.value != "" {
_, _ = fmt.Fprintf(out, "%s\n\n", *s.value)
} else {
_, _ = fmt.Fprintf(out, "%s\n\n", label.Render("(empty)"))
}
}
return nil
},
}
cmd.Flags().StringVar(&flagOrg, "org", "", "Organization ID")
flagOutput = cmdutil.AddOutputFlag(cmd)
return cmd
}

View File

@@ -0,0 +1,151 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package update
import (
"encoding/json"
"fmt"
"github.com/spf13/cobra"
"go.probo.inc/probo/pkg/cli/api"
"go.probo.inc/probo/pkg/cmd/cmdutil"
)
const updateMutation = `
mutation($input: UpdateOrganizationContextInput!) {
updateOrganizationContext(input: $input) {
context {
organizationId
product
architecture
team
processes
customers
}
}
}
`
type updateResponse struct {
UpdateOrganizationContext struct {
Context struct {
OrganizationID string `json:"organizationId"`
Product *string `json:"product"`
Architecture *string `json:"architecture"`
Team *string `json:"team"`
Processes *string `json:"processes"`
Customers *string `json:"customers"`
} `json:"context"`
} `json:"updateOrganizationContext"`
}
func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command {
var (
flagOrg string
flagProduct string
flagArchitecture string
flagTeam string
flagProcesses string
flagCustomers string
)
cmd := &cobra.Command{
Use: "update",
Short: "Update organization context",
Example: ` prb context update --org <org-id> --product "We build compliance software"
prb context update --org <org-id> --architecture "Monolith deployed on AWS ECS"`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := f.Config()
if err != nil {
return err
}
host, hc, err := cfg.DefaultHost()
if err != nil {
return err
}
orgID := flagOrg
if orgID == "" {
orgID = hc.Organization
}
if orgID == "" {
return fmt.Errorf("organization ID is required: pass --org or run `prb auth login`")
}
input := map[string]any{
"organizationId": orgID,
}
if cmd.Flags().Changed("product") {
input["product"] = flagProduct
}
if cmd.Flags().Changed("architecture") {
input["architecture"] = flagArchitecture
}
if cmd.Flags().Changed("team") {
input["team"] = flagTeam
}
if cmd.Flags().Changed("processes") {
input["processes"] = flagProcesses
}
if cmd.Flags().Changed("customers") {
input["customers"] = flagCustomers
}
if len(input) == 1 {
return fmt.Errorf("at least one section flag is required (--product, --architecture, --team, --processes, --customers)")
}
client := api.NewClient(
host,
hc.Token,
"/api/console/v1/graphql",
cfg.HTTPTimeoutDuration(),
)
data, err := client.Do(
updateMutation,
map[string]any{"input": input},
)
if err != nil {
return err
}
var resp updateResponse
if err := json.Unmarshal(data, &resp); err != nil {
return fmt.Errorf("cannot parse response: %w", err)
}
_, _ = fmt.Fprintf(
f.IOStreams.Out,
"Updated context for organization %s\n",
resp.UpdateOrganizationContext.Context.OrganizationID,
)
return nil
},
}
cmd.Flags().StringVar(&flagOrg, "org", "", "Organization ID")
cmd.Flags().StringVar(&flagProduct, "product", "", "Product description (markdown)")
cmd.Flags().StringVar(&flagArchitecture, "architecture", "", "Architecture description (markdown)")
cmd.Flags().StringVar(&flagTeam, "team", "", "Team description (markdown)")
cmd.Flags().StringVar(&flagProcesses, "processes", "", "Processes description (markdown)")
cmd.Flags().StringVar(&flagCustomers, "customers", "", "Customers description (markdown)")
return cmd
}

View File

@@ -22,6 +22,7 @@ import (
"go.probo.inc/probo/pkg/cmd/cmdutil"
"go.probo.inc/probo/pkg/cmd/completion"
cmdconfig "go.probo.inc/probo/pkg/cmd/config"
cmdcontext "go.probo.inc/probo/pkg/cmd/context"
"go.probo.inc/probo/pkg/cmd/control"
"go.probo.inc/probo/pkg/cmd/finding"
"go.probo.inc/probo/pkg/cmd/framework"
@@ -68,6 +69,7 @@ func NewCmdRoot(f *cmdutil.Factory) *cobra.Command {
cmd.AddCommand(browse.NewCmdBrowse(f))
cmd.AddCommand(completion.NewCmdCompletion(f))
cmd.AddCommand(cmdconfig.NewCmdConfig(f))
cmd.AddCommand(cmdcontext.NewCmdContext(f))
cmd.AddCommand(control.NewCmdControl(f))
cmd.AddCommand(finding.NewCmdFinding(f))
cmd.AddCommand(framework.NewCmdFramework(f))

View File

@@ -0,0 +1,6 @@
ALTER TABLE organization_contexts ADD COLUMN product TEXT;
ALTER TABLE organization_contexts ADD COLUMN architecture TEXT;
ALTER TABLE organization_contexts ADD COLUMN team TEXT;
ALTER TABLE organization_contexts ADD COLUMN processes TEXT;
ALTER TABLE organization_contexts ADD COLUMN customers TEXT;
ALTER TABLE organization_contexts DROP COLUMN summary;

View File

@@ -29,7 +29,11 @@ import (
type (
OrganizationContext struct {
OrganizationID gid.GID `db:"organization_id"`
Summary *string `db:"summary"`
Product *string `db:"product"`
Architecture *string `db:"architecture"`
Team *string `db:"team"`
Processes *string `db:"processes"`
Customers *string `db:"customers"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
@@ -44,7 +48,11 @@ func (oc *OrganizationContext) LoadByOrganizationID(
q := `
SELECT
organization_id,
summary,
product,
architecture,
team,
processes,
customers,
created_at,
updated_at
FROM
@@ -88,13 +96,21 @@ func (oc *OrganizationContext) Insert(
INSERT INTO organization_contexts (
organization_id,
tenant_id,
summary,
product,
architecture,
team,
processes,
customers,
created_at,
updated_at
) VALUES (
@organization_id,
@tenant_id,
@summary,
@product,
@architecture,
@team,
@processes,
@customers,
@created_at,
@updated_at
)
@@ -103,7 +119,11 @@ INSERT INTO organization_contexts (
args := pgx.StrictNamedArgs{
"organization_id": oc.OrganizationID,
"tenant_id": scope.GetTenantID(),
"summary": oc.Summary,
"product": oc.Product,
"architecture": oc.Architecture,
"team": oc.Team,
"processes": oc.Processes,
"customers": oc.Customers,
"created_at": oc.CreatedAt,
"updated_at": oc.UpdatedAt,
}
@@ -124,7 +144,11 @@ func (oc *OrganizationContext) Update(
q := `
UPDATE organization_contexts
SET
summary = @summary,
product = @product,
architecture = @architecture,
team = @team,
processes = @processes,
customers = @customers,
updated_at = @updated_at
WHERE
%s
@@ -135,7 +159,11 @@ WHERE
args := pgx.StrictNamedArgs{
"organization_id": oc.OrganizationID,
"summary": oc.Summary,
"product": oc.Product,
"architecture": oc.Architecture,
"team": oc.Team,
"processes": oc.Processes,
"customers": oc.Customers,
"updated_at": oc.UpdatedAt,
}

View File

@@ -49,7 +49,11 @@ type (
UpdateOrganizationContextRequest struct {
OrganizationID gid.GID
Summary **string
Product **string
Architecture **string
Team **string
Processes **string
Customers **string
}
)
@@ -72,7 +76,11 @@ func (uocr *UpdateOrganizationContextRequest) Validate() error {
v := validator.New()
v.Check(uocr.OrganizationID, "organization_id", validator.Required(), validator.GID(coredata.OrganizationEntityType))
v.Check(uocr.Summary, "summary", validator.SafeText(30_000))
v.Check(uocr.Product, "product", validator.SafeText(30_000))
v.Check(uocr.Architecture, "architecture", validator.SafeText(30_000))
v.Check(uocr.Team, "team", validator.SafeText(30_000))
v.Check(uocr.Processes, "processes", validator.SafeText(30_000))
v.Check(uocr.Customers, "customers", validator.SafeText(30_000))
return v.Error()
}
@@ -102,7 +110,7 @@ func (s OrganizationService) Get(
return organization, nil
}
func (s OrganizationService) GetContextSummary(
func (s OrganizationService) GetContext(
ctx context.Context,
organizationID gid.GID,
) (*coredata.OrganizationContext, error) {
@@ -154,8 +162,34 @@ func (s OrganizationService) UpdateContext(
return fmt.Errorf("cannot load organization context: %w", err)
}
if req.Summary != nil {
organizationContext.Summary = *req.Summary
updated := false
if req.Product != nil {
organizationContext.Product = *req.Product
updated = true
}
if req.Architecture != nil {
organizationContext.Architecture = *req.Architecture
updated = true
}
if req.Team != nil {
organizationContext.Team = *req.Team
updated = true
}
if req.Processes != nil {
organizationContext.Processes = *req.Processes
updated = true
}
if req.Customers != nil {
organizationContext.Customers = *req.Customers
updated = true
}
if updated {
organizationContext.UpdatedAt = time.Now()
if err := organizationContext.Update(ctx, tx, s.svc.scope); err != nil {

View File

@@ -3846,7 +3846,11 @@ type Mutation {
# Input Types
input UpdateOrganizationContextInput {
organizationId: ID!
summary: String @goField(omittable: true)
product: String @goField(omittable: true)
architecture: String @goField(omittable: true)
team: String @goField(omittable: true)
processes: String @goField(omittable: true)
customers: String @goField(omittable: true)
}
input UpdateTrustCenterInput {
@@ -4753,7 +4757,11 @@ type UpdateOrganizationContextPayload {
type OrganizationContext {
organizationId: ID!
summary: String
product: String
architecture: String
team: String
processes: String
customers: String
}
type UpdateTrustCenterPayload {

View File

@@ -21,6 +21,10 @@ import (
func NewOrganizationContext(oc *coredata.OrganizationContext) *OrganizationContext {
return &OrganizationContext{
OrganizationID: oc.OrganizationID,
Summary: oc.Summary,
Product: oc.Product,
Architecture: oc.Architecture,
Team: oc.Team,
Processes: oc.Processes,
Customers: oc.Customers,
}
}

View File

@@ -2048,7 +2048,11 @@ func (r *mutationResolver) UpdateOrganizationContext(ctx context.Context, input
req := probo.UpdateOrganizationContextRequest{
OrganizationID: input.OrganizationID,
Summary: gqlutils.UnwrapOmittable(input.Summary),
Product: gqlutils.UnwrapOmittable(input.Product),
Architecture: gqlutils.UnwrapOmittable(input.Architecture),
Team: gqlutils.UnwrapOmittable(input.Team),
Processes: gqlutils.UnwrapOmittable(input.Processes),
Customers: gqlutils.UnwrapOmittable(input.Customers),
}
organizationContext, err := prb.Organizations.UpdateContext(ctx, req)
@@ -6387,7 +6391,7 @@ func (r *organizationResolver) Context(ctx context.Context, obj *types.Organizat
prb := r.ProboService(ctx, obj.ID.TenantID())
orgContext, err := prb.Organizations.GetContextSummary(ctx, obj.ID)
orgContext, err := prb.Organizations.GetContext(ctx, obj.ID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot load organization context", log.Error(err))
return nil, gqlutils.Internal(ctx)

View File

@@ -3241,3 +3241,43 @@ func (r *Resolver) UnarchiveDocumentTool(ctx context.Context, req *mcp.CallToolR
Document: types.NewDocument(document, profileIDs(approverPage)),
}, nil
}
func (r *Resolver) GetOrganizationContextTool(ctx context.Context, req *mcp.CallToolRequest, input *types.GetOrganizationContextInput) (*mcp.CallToolResult, types.GetOrganizationContextOutput, error) {
r.MustAuthorize(ctx, input.OrganizationID, probo.ActionOrganizationContextGet)
prb := r.ProboService(ctx, input.OrganizationID)
orgContext, err := prb.Organizations.GetContext(ctx, input.OrganizationID)
if err != nil {
return nil, types.GetOrganizationContextOutput{}, fmt.Errorf("cannot get organization context: %w", err)
}
return nil, types.GetOrganizationContextOutput{
OrganizationContext: types.NewOrganizationContext(orgContext),
}, nil
}
func (r *Resolver) UpdateOrganizationContextTool(ctx context.Context, req *mcp.CallToolRequest, input *types.UpdateOrganizationContextInput) (*mcp.CallToolResult, types.UpdateOrganizationContextOutput, error) {
r.MustAuthorize(ctx, input.OrganizationID, probo.ActionOrganizationContextUpdate)
prb := r.ProboService(ctx, input.OrganizationID)
orgContext, err := prb.Organizations.UpdateContext(
ctx,
probo.UpdateOrganizationContextRequest{
OrganizationID: input.OrganizationID,
Product: &input.Product,
Architecture: &input.Architecture,
Team: &input.Team,
Processes: &input.Processes,
Customers: &input.Customers,
},
)
if err != nil {
return nil, types.UpdateOrganizationContextOutput{}, fmt.Errorf("cannot update organization context: %w", err)
}
return nil, types.UpdateOrganizationContextOutput{
OrganizationContext: types.NewOrganizationContext(orgContext),
}, nil
}

View File

@@ -6323,6 +6323,79 @@ components:
$ref: "#/components/schemas/GID"
description: Deleted applicability statement ID
OrganizationContext:
type: object
required:
- organization_id
properties:
organization_id:
$ref: "#/components/schemas/GID"
description: Organization ID
product:
type: string
description: Product description
architecture:
type: string
description: Architecture description
team:
type: string
description: Team description
processes:
type: string
description: Processes description
customers:
type: string
description: Customers description
GetOrganizationContextInput:
type: object
required:
- organization_id
properties:
organization_id:
$ref: "#/components/schemas/GID"
description: Organization ID
GetOrganizationContextOutput:
type: object
required:
- organization_context
properties:
organization_context:
$ref: "#/components/schemas/OrganizationContext"
UpdateOrganizationContextInput:
type: object
required:
- organization_id
properties:
organization_id:
$ref: "#/components/schemas/GID"
description: Organization ID
product:
type: string
description: Product description
architecture:
type: string
description: Architecture description
team:
type: string
description: Team description
processes:
type: string
description: Processes description
customers:
type: string
description: Customers description
UpdateOrganizationContextOutput:
type: object
required:
- organization_context
properties:
organization_context:
$ref: "#/components/schemas/OrganizationContext"
tools:
- name: listOrganizations
description: List all organizations the user has access to
@@ -7441,3 +7514,20 @@ tools:
$ref: "#/components/schemas/DeleteApplicabilityStatementInput"
outputSchema:
$ref: "#/components/schemas/DeleteApplicabilityStatementOutput"
- name: getOrganizationContext
description: Get the organization context containing structured sections about the company
hints:
readonly: true
idempotent: true
inputSchema:
$ref: "#/components/schemas/GetOrganizationContextInput"
outputSchema:
$ref: "#/components/schemas/GetOrganizationContextOutput"
- name: updateOrganizationContext
description: Update the organization context sections (product, architecture, team, processes, customers)
hints:
readonly: false
inputSchema:
$ref: "#/components/schemas/UpdateOrganizationContextInput"
outputSchema:
$ref: "#/components/schemas/UpdateOrganizationContextOutput"

View File

@@ -0,0 +1,30 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package types
import (
"go.probo.inc/probo/pkg/coredata"
)
func NewOrganizationContext(oc *coredata.OrganizationContext) *OrganizationContext {
return &OrganizationContext{
OrganizationID: oc.OrganizationID,
Product: oc.Product,
Architecture: oc.Architecture,
Team: oc.Team,
Processes: oc.Processes,
Customers: oc.Customers,
}
}