Add meeting and meeting summary objects
Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
committed by
Bryan Frimin
parent
0a9033aaaf
commit
139f5984e2
156
apps/console/src/components/form/PeopleMultiSelectField.tsx
Normal file
156
apps/console/src/components/form/PeopleMultiSelectField.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import { Avatar, Field, Option, Select, Badge, Button, IconCrossLargeX } from "@probo/ui";
|
||||
import { Suspense, useState, type ComponentProps } from "react";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { type Control, Controller, type FieldValues } from "react-hook-form";
|
||||
import { usePeople } from "/hooks/graph/PeopleGraph.ts";
|
||||
import type { Path } from "react-hook-form";
|
||||
|
||||
type Person = {
|
||||
id: string;
|
||||
fullName: string;
|
||||
primaryEmailAddress?: string | null;
|
||||
};
|
||||
|
||||
type Props<T extends FieldValues = FieldValues> = {
|
||||
organizationId: string;
|
||||
control: Control<T>;
|
||||
name: string;
|
||||
label?: string;
|
||||
error?: string;
|
||||
selectedPeople?: Person[];
|
||||
} & ComponentProps<typeof Field>;
|
||||
|
||||
export function PeopleMultiSelectField<T extends FieldValues = FieldValues>({
|
||||
organizationId,
|
||||
control,
|
||||
selectedPeople = [],
|
||||
...props
|
||||
}: Props<T>) {
|
||||
return (
|
||||
<Field {...props}>
|
||||
<Suspense
|
||||
fallback={<Select variant="editor" disabled placeholder="Loading..." />}
|
||||
>
|
||||
<PeopleMultiSelectWithQuery
|
||||
organizationId={organizationId}
|
||||
control={control}
|
||||
name={props.name}
|
||||
disabled={props.disabled}
|
||||
selectedPeople={selectedPeople}
|
||||
/>
|
||||
</Suspense>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
function PeopleMultiSelectWithQuery<T extends FieldValues = FieldValues>(
|
||||
props: Pick<Props<T>, "organizationId" | "control" | "name" | "disabled" | "selectedPeople">
|
||||
) {
|
||||
const { __ } = useTranslate();
|
||||
const { name, organizationId, control, selectedPeople = [] } = props;
|
||||
const people = usePeople(organizationId, { excludeContractEnded: true });
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const allPeople = [...people];
|
||||
selectedPeople.forEach(selectedPerson => {
|
||||
if (!allPeople.find(p => p.id === selectedPerson.id)) {
|
||||
allPeople.push({
|
||||
id: selectedPerson.id,
|
||||
fullName: selectedPerson.fullName,
|
||||
primaryEmailAddress: selectedPerson.primaryEmailAddress ?? "",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Controller
|
||||
control={control}
|
||||
name={name as Path<T>}
|
||||
render={({ field }) => {
|
||||
const selectedPeopleIds = (Array.isArray(field.value) ? field.value : []) as string[];
|
||||
|
||||
const selectedPeople = allPeople.filter(p => selectedPeopleIds.includes(p.id));
|
||||
const availablePeople = allPeople.filter(p => !selectedPeopleIds.includes(p.id));
|
||||
|
||||
const handleAddPerson = (personId: string) => {
|
||||
const newValue = [...selectedPeopleIds, personId];
|
||||
field.onChange(newValue);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleRemovePerson = (personId: string) => {
|
||||
const newValue = selectedPeopleIds.filter((id: string) => id !== personId);
|
||||
field.onChange(newValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{availablePeople.length > 0 && !props.disabled && (
|
||||
<Select
|
||||
disabled={props.disabled}
|
||||
id={name}
|
||||
variant="editor"
|
||||
placeholder={__("Add attendees...")}
|
||||
onValueChange={handleAddPerson}
|
||||
key={`${selectedPeopleIds.length}-${people.length}`}
|
||||
className="w-full"
|
||||
value=""
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
>
|
||||
{availablePeople.map((person) => (
|
||||
<Option key={person.id} value={person.id} className="flex gap-2">
|
||||
<Avatar
|
||||
name={person.fullName}
|
||||
size="s"
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span>{person.fullName}</span>
|
||||
{person.primaryEmailAddress && (
|
||||
<span className="text-xs text-txt-secondary">
|
||||
{person.primaryEmailAddress}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{selectedPeople.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedPeople.map((person) => (
|
||||
<Badge key={person.id} variant="neutral" className="flex items-center gap-2">
|
||||
<Avatar
|
||||
name={person.fullName}
|
||||
size="s"
|
||||
/>
|
||||
<span>{person.fullName}</span>
|
||||
{!props.disabled && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="tertiary"
|
||||
icon={IconCrossLargeX}
|
||||
onClick={() => handleRemovePerson(person.id)}
|
||||
className="h-4 w-4 p-0 hover:bg-transparent"
|
||||
/>
|
||||
)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedPeople.length === 0 && availablePeople.length === 0 && (
|
||||
<div className="text-sm text-txt-secondary py-2">
|
||||
{__("No people available")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
105
apps/console/src/hooks/graph/MeetingGraph.ts
Normal file
105
apps/console/src/hooks/graph/MeetingGraph.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { graphql } from "relay-runtime";
|
||||
import { useMutationWithToasts } from "../useMutationWithToasts";
|
||||
import type { MeetingGraphDeleteMutation } from "./__generated__/MeetingGraphDeleteMutation.graphql";
|
||||
import type { MeetingGraphUpdateMutation } from "./__generated__/MeetingGraphUpdateMutation.graphql";
|
||||
|
||||
export const meetingsQuery = graphql`
|
||||
query MeetingGraphListQuery($organizationId: ID!) {
|
||||
organization: node(id: $organizationId) {
|
||||
id
|
||||
...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"),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
210
apps/console/src/hooks/graph/__generated__/MeetingGraphCreateMutation.graphql.ts
generated
Normal file
210
apps/console/src/hooks/graph/__generated__/MeetingGraphCreateMutation.graphql.ts
generated
Normal file
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* @generated SignedSource<<15692efdcfe280221e1be99acfbf206b>>
|
||||
* @lightSyntaxTransform
|
||||
* @nogrep
|
||||
*/
|
||||
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
|
||||
import { ConcreteRequest } from 'relay-runtime';
|
||||
export type CreateMeetingInput = {
|
||||
attendeeIds?: ReadonlyArray<string> | null | undefined;
|
||||
date: any;
|
||||
minutes?: string | null | undefined;
|
||||
name: string;
|
||||
organizationId: string;
|
||||
};
|
||||
export type MeetingGraphCreateMutation$variables = {
|
||||
connections: ReadonlyArray<string>;
|
||||
input: CreateMeetingInput;
|
||||
};
|
||||
export type MeetingGraphCreateMutation$data = {
|
||||
readonly createMeeting: {
|
||||
readonly meetingEdge: {
|
||||
readonly node: {
|
||||
readonly attendees: ReadonlyArray<{
|
||||
readonly fullName: string;
|
||||
readonly id: string;
|
||||
}>;
|
||||
readonly date: any;
|
||||
readonly id: string;
|
||||
readonly minutes: string | null | undefined;
|
||||
readonly name: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
export type MeetingGraphCreateMutation = {
|
||||
response: MeetingGraphCreateMutation$data;
|
||||
variables: MeetingGraphCreateMutation$variables;
|
||||
};
|
||||
|
||||
const node: ConcreteRequest = (function(){
|
||||
var v0 = {
|
||||
"defaultValue": null,
|
||||
"kind": "LocalArgument",
|
||||
"name": "connections"
|
||||
},
|
||||
v1 = {
|
||||
"defaultValue": null,
|
||||
"kind": "LocalArgument",
|
||||
"name": "input"
|
||||
},
|
||||
v2 = [
|
||||
{
|
||||
"kind": "Variable",
|
||||
"name": "input",
|
||||
"variableName": "input"
|
||||
}
|
||||
],
|
||||
v3 = {
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "id",
|
||||
"storageKey": null
|
||||
},
|
||||
v4 = {
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"concreteType": "MeetingEdge",
|
||||
"kind": "LinkedField",
|
||||
"name": "meetingEdge",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"concreteType": "Meeting",
|
||||
"kind": "LinkedField",
|
||||
"name": "node",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
(v3/*: any*/),
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "name",
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "date",
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "minutes",
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"concreteType": "People",
|
||||
"kind": "LinkedField",
|
||||
"name": "attendees",
|
||||
"plural": true,
|
||||
"selections": [
|
||||
(v3/*: any*/),
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "fullName",
|
||||
"storageKey": null
|
||||
}
|
||||
],
|
||||
"storageKey": null
|
||||
}
|
||||
],
|
||||
"storageKey": null
|
||||
}
|
||||
],
|
||||
"storageKey": null
|
||||
};
|
||||
return {
|
||||
"fragment": {
|
||||
"argumentDefinitions": [
|
||||
(v0/*: any*/),
|
||||
(v1/*: any*/)
|
||||
],
|
||||
"kind": "Fragment",
|
||||
"metadata": null,
|
||||
"name": "MeetingGraphCreateMutation",
|
||||
"selections": [
|
||||
{
|
||||
"alias": null,
|
||||
"args": (v2/*: any*/),
|
||||
"concreteType": "CreateMeetingPayload",
|
||||
"kind": "LinkedField",
|
||||
"name": "createMeeting",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
(v4/*: any*/)
|
||||
],
|
||||
"storageKey": null
|
||||
}
|
||||
],
|
||||
"type": "Mutation",
|
||||
"abstractKey": null
|
||||
},
|
||||
"kind": "Request",
|
||||
"operation": {
|
||||
"argumentDefinitions": [
|
||||
(v1/*: any*/),
|
||||
(v0/*: any*/)
|
||||
],
|
||||
"kind": "Operation",
|
||||
"name": "MeetingGraphCreateMutation",
|
||||
"selections": [
|
||||
{
|
||||
"alias": null,
|
||||
"args": (v2/*: any*/),
|
||||
"concreteType": "CreateMeetingPayload",
|
||||
"kind": "LinkedField",
|
||||
"name": "createMeeting",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
(v4/*: any*/),
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"filters": null,
|
||||
"handle": "prependEdge",
|
||||
"key": "",
|
||||
"kind": "LinkedHandle",
|
||||
"name": "meetingEdge",
|
||||
"handleArgs": [
|
||||
{
|
||||
"kind": "Variable",
|
||||
"name": "connections",
|
||||
"variableName": "connections"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"storageKey": null
|
||||
}
|
||||
]
|
||||
},
|
||||
"params": {
|
||||
"cacheID": "1d0c66e942482f999e3b986af16df678",
|
||||
"id": null,
|
||||
"metadata": {},
|
||||
"name": "MeetingGraphCreateMutation",
|
||||
"operationKind": "mutation",
|
||||
"text": "mutation MeetingGraphCreateMutation(\n $input: CreateMeetingInput!\n) {\n createMeeting(input: $input) {\n meetingEdge {\n node {\n id\n name\n date\n minutes\n attendees {\n id\n fullName\n }\n }\n }\n }\n}\n"
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
(node as any).hash = "27fd6781022144fe6fd247b7e2ce1aaf";
|
||||
|
||||
export default node;
|
||||
115
apps/console/src/hooks/graph/__generated__/MeetingGraphDeleteMutation.graphql.ts
generated
Normal file
115
apps/console/src/hooks/graph/__generated__/MeetingGraphDeleteMutation.graphql.ts
generated
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* @generated SignedSource<<f903f8daaf7c28c3d165a215c98e05b6>>
|
||||
* @lightSyntaxTransform
|
||||
* @nogrep
|
||||
*/
|
||||
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
|
||||
import { ConcreteRequest } from 'relay-runtime';
|
||||
export type DeleteMeetingInput = {
|
||||
meetingId: string;
|
||||
};
|
||||
export type MeetingGraphDeleteMutation$variables = {
|
||||
input: DeleteMeetingInput;
|
||||
};
|
||||
export type MeetingGraphDeleteMutation$data = {
|
||||
readonly deleteMeeting: {
|
||||
readonly deletedMeetingId: string;
|
||||
};
|
||||
};
|
||||
export type MeetingGraphDeleteMutation = {
|
||||
response: MeetingGraphDeleteMutation$data;
|
||||
variables: MeetingGraphDeleteMutation$variables;
|
||||
};
|
||||
|
||||
const node: ConcreteRequest = (function(){
|
||||
var v0 = [
|
||||
{
|
||||
"defaultValue": null,
|
||||
"kind": "LocalArgument",
|
||||
"name": "input"
|
||||
}
|
||||
],
|
||||
v1 = [
|
||||
{
|
||||
"kind": "Variable",
|
||||
"name": "input",
|
||||
"variableName": "input"
|
||||
}
|
||||
],
|
||||
v2 = {
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "deletedMeetingId",
|
||||
"storageKey": null
|
||||
};
|
||||
return {
|
||||
"fragment": {
|
||||
"argumentDefinitions": (v0/*: any*/),
|
||||
"kind": "Fragment",
|
||||
"metadata": null,
|
||||
"name": "MeetingGraphDeleteMutation",
|
||||
"selections": [
|
||||
{
|
||||
"alias": null,
|
||||
"args": (v1/*: any*/),
|
||||
"concreteType": "DeleteMeetingPayload",
|
||||
"kind": "LinkedField",
|
||||
"name": "deleteMeeting",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
(v2/*: any*/)
|
||||
],
|
||||
"storageKey": null
|
||||
}
|
||||
],
|
||||
"type": "Mutation",
|
||||
"abstractKey": null
|
||||
},
|
||||
"kind": "Request",
|
||||
"operation": {
|
||||
"argumentDefinitions": (v0/*: any*/),
|
||||
"kind": "Operation",
|
||||
"name": "MeetingGraphDeleteMutation",
|
||||
"selections": [
|
||||
{
|
||||
"alias": null,
|
||||
"args": (v1/*: any*/),
|
||||
"concreteType": "DeleteMeetingPayload",
|
||||
"kind": "LinkedField",
|
||||
"name": "deleteMeeting",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
(v2/*: any*/),
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"filters": null,
|
||||
"handle": "deleteRecord",
|
||||
"key": "",
|
||||
"kind": "ScalarHandle",
|
||||
"name": "deletedMeetingId"
|
||||
}
|
||||
],
|
||||
"storageKey": null
|
||||
}
|
||||
]
|
||||
},
|
||||
"params": {
|
||||
"cacheID": "f88bdc6e79cef363e7c2aaf91dce1d16",
|
||||
"id": null,
|
||||
"metadata": {},
|
||||
"name": "MeetingGraphDeleteMutation",
|
||||
"operationKind": "mutation",
|
||||
"text": "mutation MeetingGraphDeleteMutation(\n $input: DeleteMeetingInput!\n) {\n deleteMeeting(input: $input) {\n deletedMeetingId\n }\n}\n"
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
(node as any).hash = "4d70861c6a4d4b06c753863cba054fc8";
|
||||
|
||||
export default node;
|
||||
295
apps/console/src/hooks/graph/__generated__/MeetingGraphListQuery.graphql.ts
generated
Normal file
295
apps/console/src/hooks/graph/__generated__/MeetingGraphListQuery.graphql.ts
generated
Normal file
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* @generated SignedSource<<ab4ee2b9dc7e4e5443dea3bd188d6dac>>
|
||||
* @lightSyntaxTransform
|
||||
* @nogrep
|
||||
*/
|
||||
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
|
||||
import { ConcreteRequest } from 'relay-runtime';
|
||||
import { FragmentRefs } from "relay-runtime";
|
||||
export type MeetingGraphListQuery$variables = {
|
||||
organizationId: string;
|
||||
};
|
||||
export type MeetingGraphListQuery$data = {
|
||||
readonly organization: {
|
||||
readonly id: string;
|
||||
readonly " $fragmentSpreads": FragmentRefs<"MeetingsPageListFragment">;
|
||||
};
|
||||
};
|
||||
export type MeetingGraphListQuery = {
|
||||
response: MeetingGraphListQuery$data;
|
||||
variables: MeetingGraphListQuery$variables;
|
||||
};
|
||||
|
||||
const node: ConcreteRequest = (function(){
|
||||
var v0 = [
|
||||
{
|
||||
"defaultValue": null,
|
||||
"kind": "LocalArgument",
|
||||
"name": "organizationId"
|
||||
}
|
||||
],
|
||||
v1 = [
|
||||
{
|
||||
"kind": "Variable",
|
||||
"name": "id",
|
||||
"variableName": "organizationId"
|
||||
}
|
||||
],
|
||||
v2 = {
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "id",
|
||||
"storageKey": null
|
||||
},
|
||||
v3 = {
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "__typename",
|
||||
"storageKey": null
|
||||
},
|
||||
v4 = [
|
||||
{
|
||||
"kind": "Literal",
|
||||
"name": "first",
|
||||
"value": 50
|
||||
},
|
||||
{
|
||||
"kind": "Literal",
|
||||
"name": "orderBy",
|
||||
"value": {
|
||||
"direction": "DESC",
|
||||
"field": "DATE"
|
||||
}
|
||||
}
|
||||
];
|
||||
return {
|
||||
"fragment": {
|
||||
"argumentDefinitions": (v0/*: any*/),
|
||||
"kind": "Fragment",
|
||||
"metadata": null,
|
||||
"name": "MeetingGraphListQuery",
|
||||
"selections": [
|
||||
{
|
||||
"alias": "organization",
|
||||
"args": (v1/*: any*/),
|
||||
"concreteType": null,
|
||||
"kind": "LinkedField",
|
||||
"name": "node",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
(v2/*: any*/),
|
||||
{
|
||||
"args": null,
|
||||
"kind": "FragmentSpread",
|
||||
"name": "MeetingsPageListFragment"
|
||||
}
|
||||
],
|
||||
"storageKey": null
|
||||
}
|
||||
],
|
||||
"type": "Query",
|
||||
"abstractKey": null
|
||||
},
|
||||
"kind": "Request",
|
||||
"operation": {
|
||||
"argumentDefinitions": (v0/*: any*/),
|
||||
"kind": "Operation",
|
||||
"name": "MeetingGraphListQuery",
|
||||
"selections": [
|
||||
{
|
||||
"alias": "organization",
|
||||
"args": (v1/*: any*/),
|
||||
"concreteType": null,
|
||||
"kind": "LinkedField",
|
||||
"name": "node",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
(v3/*: any*/),
|
||||
(v2/*: any*/),
|
||||
{
|
||||
"kind": "InlineFragment",
|
||||
"selections": [
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"concreteType": "OrganizationContext",
|
||||
"kind": "LinkedField",
|
||||
"name": "context",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "summary",
|
||||
"storageKey": null
|
||||
}
|
||||
],
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": (v4/*: any*/),
|
||||
"concreteType": "MeetingConnection",
|
||||
"kind": "LinkedField",
|
||||
"name": "meetings",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"concreteType": "MeetingEdge",
|
||||
"kind": "LinkedField",
|
||||
"name": "edges",
|
||||
"plural": true,
|
||||
"selections": [
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"concreteType": "Meeting",
|
||||
"kind": "LinkedField",
|
||||
"name": "node",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
(v2/*: any*/),
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "name",
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "date",
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"concreteType": "People",
|
||||
"kind": "LinkedField",
|
||||
"name": "attendees",
|
||||
"plural": true,
|
||||
"selections": [
|
||||
(v2/*: any*/),
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "fullName",
|
||||
"storageKey": null
|
||||
}
|
||||
],
|
||||
"storageKey": null
|
||||
},
|
||||
(v3/*: any*/)
|
||||
],
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "cursor",
|
||||
"storageKey": null
|
||||
}
|
||||
],
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"concreteType": "PageInfo",
|
||||
"kind": "LinkedField",
|
||||
"name": "pageInfo",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "endCursor",
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "hasNextPage",
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "hasPreviousPage",
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "startCursor",
|
||||
"storageKey": null
|
||||
}
|
||||
],
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"kind": "ClientExtension",
|
||||
"selections": [
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "__id",
|
||||
"storageKey": null
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"storageKey": "meetings(first:50,orderBy:{\"direction\":\"DESC\",\"field\":\"DATE\"})"
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": (v4/*: any*/),
|
||||
"filters": [
|
||||
"orderBy"
|
||||
],
|
||||
"handle": "connection",
|
||||
"key": "MeetingsListQuery_meetings",
|
||||
"kind": "LinkedHandle",
|
||||
"name": "meetings"
|
||||
}
|
||||
],
|
||||
"type": "Organization",
|
||||
"abstractKey": null
|
||||
}
|
||||
],
|
||||
"storageKey": null
|
||||
}
|
||||
]
|
||||
},
|
||||
"params": {
|
||||
"cacheID": "e320d73109abe57bb9fa89ade5fa0892",
|
||||
"id": null,
|
||||
"metadata": {},
|
||||
"name": "MeetingGraphListQuery",
|
||||
"operationKind": "query",
|
||||
"text": "query MeetingGraphListQuery(\n $organizationId: ID!\n) {\n organization: node(id: $organizationId) {\n __typename\n id\n ...MeetingsPageListFragment\n }\n}\n\nfragment MeetingsPageListFragment on Organization {\n id\n context {\n summary\n }\n meetings(first: 50, orderBy: {field: DATE, direction: DESC}) {\n edges {\n node {\n id\n ...MeetingsPageRowFragment\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n}\n\nfragment MeetingsPageRowFragment on Meeting {\n id\n name\n date\n attendees {\n id\n fullName\n }\n}\n"
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
(node as any).hash = "be13ec2953f71c4955a5f46b93f0131f";
|
||||
|
||||
export default node;
|
||||
162
apps/console/src/hooks/graph/__generated__/MeetingGraphNodeQuery.graphql.ts
generated
Normal file
162
apps/console/src/hooks/graph/__generated__/MeetingGraphNodeQuery.graphql.ts
generated
Normal file
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* @generated SignedSource<<c19b786d81cd6f7adc4da4a4c3ad9346>>
|
||||
* @lightSyntaxTransform
|
||||
* @nogrep
|
||||
*/
|
||||
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
|
||||
import { ConcreteRequest } from 'relay-runtime';
|
||||
import { FragmentRefs } from "relay-runtime";
|
||||
export type MeetingGraphNodeQuery$variables = {
|
||||
meetingId: string;
|
||||
};
|
||||
export type MeetingGraphNodeQuery$data = {
|
||||
readonly node: {
|
||||
readonly " $fragmentSpreads": FragmentRefs<"MeetingDetailPageMeetingFragment">;
|
||||
};
|
||||
};
|
||||
export type MeetingGraphNodeQuery = {
|
||||
response: MeetingGraphNodeQuery$data;
|
||||
variables: MeetingGraphNodeQuery$variables;
|
||||
};
|
||||
|
||||
const node: ConcreteRequest = (function(){
|
||||
var v0 = [
|
||||
{
|
||||
"defaultValue": null,
|
||||
"kind": "LocalArgument",
|
||||
"name": "meetingId"
|
||||
}
|
||||
],
|
||||
v1 = [
|
||||
{
|
||||
"kind": "Variable",
|
||||
"name": "id",
|
||||
"variableName": "meetingId"
|
||||
}
|
||||
],
|
||||
v2 = {
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "id",
|
||||
"storageKey": null
|
||||
};
|
||||
return {
|
||||
"fragment": {
|
||||
"argumentDefinitions": (v0/*: any*/),
|
||||
"kind": "Fragment",
|
||||
"metadata": null,
|
||||
"name": "MeetingGraphNodeQuery",
|
||||
"selections": [
|
||||
{
|
||||
"alias": null,
|
||||
"args": (v1/*: any*/),
|
||||
"concreteType": null,
|
||||
"kind": "LinkedField",
|
||||
"name": "node",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
{
|
||||
"args": null,
|
||||
"kind": "FragmentSpread",
|
||||
"name": "MeetingDetailPageMeetingFragment"
|
||||
}
|
||||
],
|
||||
"storageKey": null
|
||||
}
|
||||
],
|
||||
"type": "Query",
|
||||
"abstractKey": null
|
||||
},
|
||||
"kind": "Request",
|
||||
"operation": {
|
||||
"argumentDefinitions": (v0/*: any*/),
|
||||
"kind": "Operation",
|
||||
"name": "MeetingGraphNodeQuery",
|
||||
"selections": [
|
||||
{
|
||||
"alias": null,
|
||||
"args": (v1/*: any*/),
|
||||
"concreteType": null,
|
||||
"kind": "LinkedField",
|
||||
"name": "node",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "__typename",
|
||||
"storageKey": null
|
||||
},
|
||||
(v2/*: any*/),
|
||||
{
|
||||
"kind": "InlineFragment",
|
||||
"selections": [
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "name",
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "date",
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "minutes",
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"concreteType": "People",
|
||||
"kind": "LinkedField",
|
||||
"name": "attendees",
|
||||
"plural": true,
|
||||
"selections": [
|
||||
(v2/*: any*/),
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "fullName",
|
||||
"storageKey": null
|
||||
}
|
||||
],
|
||||
"storageKey": null
|
||||
}
|
||||
],
|
||||
"type": "Meeting",
|
||||
"abstractKey": null
|
||||
}
|
||||
],
|
||||
"storageKey": null
|
||||
}
|
||||
]
|
||||
},
|
||||
"params": {
|
||||
"cacheID": "a4df3c8c55dc1bf379bba19af16448d7",
|
||||
"id": null,
|
||||
"metadata": {},
|
||||
"name": "MeetingGraphNodeQuery",
|
||||
"operationKind": "query",
|
||||
"text": "query MeetingGraphNodeQuery(\n $meetingId: ID!\n) {\n node(id: $meetingId) {\n __typename\n ...MeetingDetailPageMeetingFragment\n id\n }\n}\n\nfragment MeetingDetailPageMeetingFragment on Meeting {\n id\n name\n date\n minutes\n attendees {\n id\n fullName\n }\n}\n"
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
(node as any).hash = "08844ea87fd5de5c7fe55583373e0468";
|
||||
|
||||
export default node;
|
||||
157
apps/console/src/hooks/graph/__generated__/MeetingGraphUpdateMutation.graphql.ts
generated
Normal file
157
apps/console/src/hooks/graph/__generated__/MeetingGraphUpdateMutation.graphql.ts
generated
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* @generated SignedSource<<3a5a992a984a073bb90922f29120be83>>
|
||||
* @lightSyntaxTransform
|
||||
* @nogrep
|
||||
*/
|
||||
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
|
||||
import { ConcreteRequest } from 'relay-runtime';
|
||||
export type UpdateMeetingInput = {
|
||||
attendeeIds?: ReadonlyArray<string> | null | undefined;
|
||||
date?: any | null | undefined;
|
||||
meetingId: string;
|
||||
minutes?: string | null | undefined;
|
||||
name?: string | null | undefined;
|
||||
};
|
||||
export type MeetingGraphUpdateMutation$variables = {
|
||||
input: UpdateMeetingInput;
|
||||
};
|
||||
export type MeetingGraphUpdateMutation$data = {
|
||||
readonly updateMeeting: {
|
||||
readonly meeting: {
|
||||
readonly attendees: ReadonlyArray<{
|
||||
readonly fullName: string;
|
||||
readonly id: string;
|
||||
}>;
|
||||
readonly date: any;
|
||||
readonly id: string;
|
||||
readonly minutes: string | null | undefined;
|
||||
readonly name: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
export type MeetingGraphUpdateMutation = {
|
||||
response: MeetingGraphUpdateMutation$data;
|
||||
variables: MeetingGraphUpdateMutation$variables;
|
||||
};
|
||||
|
||||
const node: ConcreteRequest = (function(){
|
||||
var v0 = [
|
||||
{
|
||||
"defaultValue": null,
|
||||
"kind": "LocalArgument",
|
||||
"name": "input"
|
||||
}
|
||||
],
|
||||
v1 = {
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "id",
|
||||
"storageKey": null
|
||||
},
|
||||
v2 = [
|
||||
{
|
||||
"alias": null,
|
||||
"args": [
|
||||
{
|
||||
"kind": "Variable",
|
||||
"name": "input",
|
||||
"variableName": "input"
|
||||
}
|
||||
],
|
||||
"concreteType": "UpdateMeetingPayload",
|
||||
"kind": "LinkedField",
|
||||
"name": "updateMeeting",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"concreteType": "Meeting",
|
||||
"kind": "LinkedField",
|
||||
"name": "meeting",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
(v1/*: any*/),
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "name",
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "date",
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "minutes",
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"concreteType": "People",
|
||||
"kind": "LinkedField",
|
||||
"name": "attendees",
|
||||
"plural": true,
|
||||
"selections": [
|
||||
(v1/*: any*/),
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "fullName",
|
||||
"storageKey": null
|
||||
}
|
||||
],
|
||||
"storageKey": null
|
||||
}
|
||||
],
|
||||
"storageKey": null
|
||||
}
|
||||
],
|
||||
"storageKey": null
|
||||
}
|
||||
];
|
||||
return {
|
||||
"fragment": {
|
||||
"argumentDefinitions": (v0/*: any*/),
|
||||
"kind": "Fragment",
|
||||
"metadata": null,
|
||||
"name": "MeetingGraphUpdateMutation",
|
||||
"selections": (v2/*: any*/),
|
||||
"type": "Mutation",
|
||||
"abstractKey": null
|
||||
},
|
||||
"kind": "Request",
|
||||
"operation": {
|
||||
"argumentDefinitions": (v0/*: any*/),
|
||||
"kind": "Operation",
|
||||
"name": "MeetingGraphUpdateMutation",
|
||||
"selections": (v2/*: any*/)
|
||||
},
|
||||
"params": {
|
||||
"cacheID": "e40a6b6cf200c6c90ca61a21c15452c1",
|
||||
"id": null,
|
||||
"metadata": {},
|
||||
"name": "MeetingGraphUpdateMutation",
|
||||
"operationKind": "mutation",
|
||||
"text": "mutation MeetingGraphUpdateMutation(\n $input: UpdateMeetingInput!\n) {\n updateMeeting(input: $input) {\n meeting {\n id\n name\n date\n minutes\n attendees {\n id\n fullName\n }\n }\n }\n}\n"
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
(node as any).hash = "b3d9ca4361201376b0d5ab78ec1ef316";
|
||||
|
||||
export default node;
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
IconRotateCw,
|
||||
IconCircleProgress,
|
||||
IconMedal,
|
||||
IconCalendar1,
|
||||
Layout,
|
||||
SidebarItem,
|
||||
UserDropdown as UserDropdownRoot,
|
||||
@@ -95,6 +96,11 @@ export function MainLayout() {
|
||||
}
|
||||
sidebar={
|
||||
<ul className="space-y-[2px]">
|
||||
<SidebarItem
|
||||
label={__("Meetings")}
|
||||
icon={IconCalendar1}
|
||||
to={`${prefix}/meetings`}
|
||||
/>
|
||||
<SidebarItem
|
||||
label={__("Tasks")}
|
||||
icon={IconInboxEmpty}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { PreloadedQuery } from "react-relay";
|
||||
import { graphql, useFragment, usePreloadedQuery } from "react-relay";
|
||||
import type { MeetingGraphNodeQuery } from "/hooks/graph/__generated__/MeetingGraphNodeQuery.graphql";
|
||||
import { usePageTitle } from "@probo/hooks";
|
||||
import type { MeetingDetailPageMeetingFragment$key } from "./__generated__/MeetingDetailPageMeetingFragment.graphql";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
ActionDropdown,
|
||||
Avatar,
|
||||
Breadcrumb,
|
||||
DropdownItem,
|
||||
IconPencil,
|
||||
IconTrashCan,
|
||||
PageHeader,
|
||||
useConfirm,
|
||||
} from "@probo/ui";
|
||||
import { useOrganizationId } from "/hooks/useOrganizationId";
|
||||
import { sprintf, formatDate } from "@probo/helpers";
|
||||
import { Link, Outlet, useNavigate } from "react-router";
|
||||
import {
|
||||
UpdateMeetingMinutesDialog,
|
||||
type UpdateMeetingMinutesDialogRef,
|
||||
} from "./dialogs/UpdateMeetingMinutesDialog";
|
||||
import { useRef } from "react";
|
||||
import {
|
||||
meetingNodeQuery,
|
||||
useDeleteMeetingMutation,
|
||||
} from "/hooks/graph/MeetingGraph";
|
||||
|
||||
const meetingFragment = graphql`
|
||||
fragment MeetingDetailPageMeetingFragment on Meeting {
|
||||
id
|
||||
name
|
||||
date
|
||||
minutes
|
||||
attendees {
|
||||
id
|
||||
fullName
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
type Props = {
|
||||
queryRef: PreloadedQuery<MeetingGraphNodeQuery>;
|
||||
};
|
||||
|
||||
export default function MeetingDetailPage(props: Props) {
|
||||
const node = usePreloadedQuery(meetingNodeQuery, props.queryRef).node;
|
||||
const meeting = useFragment<MeetingDetailPageMeetingFragment$key>(
|
||||
meetingFragment,
|
||||
node
|
||||
);
|
||||
const { __ } = useTranslate();
|
||||
const organizationId = useOrganizationId();
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (!meeting) {
|
||||
return <div>{__("Meeting not found")}</div>;
|
||||
}
|
||||
|
||||
const [deleteMeeting, isDeleting] = useDeleteMeetingMutation();
|
||||
const confirm = useConfirm();
|
||||
const updateMinutesDialogRef = useRef<UpdateMeetingMinutesDialogRef>(null);
|
||||
|
||||
usePageTitle(meeting.name);
|
||||
|
||||
const handleDelete = () => {
|
||||
confirm(
|
||||
() =>
|
||||
deleteMeeting({
|
||||
variables: {
|
||||
input: { meetingId: meeting.id },
|
||||
},
|
||||
onSuccess: () => {
|
||||
navigate(`/organizations/${organizationId}/meetings`);
|
||||
},
|
||||
}),
|
||||
{
|
||||
message: sprintf(
|
||||
__(
|
||||
'This will permanently delete the meeting "%s". This action cannot be undone.'
|
||||
),
|
||||
meeting.name
|
||||
),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<UpdateMeetingMinutesDialog
|
||||
ref={updateMinutesDialogRef}
|
||||
meeting={meeting}
|
||||
/>
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<Breadcrumb
|
||||
items={[
|
||||
{
|
||||
label: __("Meetings"),
|
||||
to: `/organizations/${organizationId}/meetings`,
|
||||
},
|
||||
{
|
||||
label: meeting.name,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<ActionDropdown variant="secondary">
|
||||
<DropdownItem
|
||||
onClick={() => updateMinutesDialogRef.current?.open()}
|
||||
icon={IconPencil}
|
||||
>
|
||||
{__("Edit minutes")}
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
variant="danger"
|
||||
icon={IconTrashCan}
|
||||
disabled={isDeleting}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
{__("Delete meeting")}
|
||||
</DropdownItem>
|
||||
</ActionDropdown>
|
||||
</div>
|
||||
<PageHeader
|
||||
title={meeting.name}
|
||||
description={formatDate(meeting.date)}
|
||||
/>
|
||||
{meeting.attendees && meeting.attendees.length > 0 && (
|
||||
<div className="flex gap-2 items-center flex-wrap">
|
||||
{meeting.attendees.map((attendee) => (
|
||||
<div key={attendee.id} className="flex gap-2 items-center">
|
||||
<Avatar name={attendee.fullName ?? ""} />
|
||||
<Link
|
||||
to={`/organizations/${organizationId}/people/${attendee.id}`}
|
||||
className="text-sm hover:underline"
|
||||
>
|
||||
{attendee.fullName}
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Outlet context={{ meeting }} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
381
apps/console/src/pages/organizations/meetings/MeetingsPage.tsx
Normal file
381
apps/console/src/pages/organizations/meetings/MeetingsPage.tsx
Normal file
@@ -0,0 +1,381 @@
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
PageHeader,
|
||||
Tbody,
|
||||
Thead,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
Avatar,
|
||||
IconTrashCan,
|
||||
Button,
|
||||
IconPlusLarge,
|
||||
useConfirm,
|
||||
ActionDropdown,
|
||||
DropdownItem,
|
||||
Card,
|
||||
Textarea,
|
||||
Markdown,
|
||||
IconPencil,
|
||||
IconCheckmark1,
|
||||
IconCrossLargeX,
|
||||
} from "@probo/ui";
|
||||
import {
|
||||
useFragment,
|
||||
usePaginationFragment,
|
||||
usePreloadedQuery,
|
||||
type PreloadedQuery,
|
||||
} from "react-relay";
|
||||
import { graphql } from "relay-runtime";
|
||||
import type { MeetingGraphListQuery } from "/hooks/graph/__generated__/MeetingGraphListQuery.graphql";
|
||||
import {
|
||||
meetingsQuery,
|
||||
useDeleteMeetingMutation,
|
||||
} from "/hooks/graph/MeetingGraph";
|
||||
import type { MeetingsPageListFragment$key } from "./__generated__/MeetingsPageListFragment.graphql";
|
||||
import { usePageTitle } from "@probo/hooks";
|
||||
import { formatDate, sprintf } from "@probo/helpers";
|
||||
import { CreateMeetingDialog } from "./dialogs/CreateMeetingDialog";
|
||||
import type { MeetingsPageRowFragment$key } from "./__generated__/MeetingsPageRowFragment.graphql";
|
||||
import { SortableTable, SortableTh } from "/components/SortableTable";
|
||||
import { Link } from "react-router";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useMutationWithToasts } from "/hooks/useMutationWithToasts";
|
||||
import type { MeetingsPage_UpdateSummaryMutation } from "./__generated__/MeetingsPage_UpdateSummaryMutation.graphql";
|
||||
|
||||
const meetingsFragment = graphql`
|
||||
fragment MeetingsPageListFragment on Organization
|
||||
@refetchable(queryName: "MeetingsListQuery")
|
||||
@argumentDefinitions(
|
||||
first: { type: "Int", defaultValue: 50 }
|
||||
order: {
|
||||
type: "MeetingOrder"
|
||||
defaultValue: { field: DATE, direction: DESC }
|
||||
}
|
||||
after: { type: "CursorKey", defaultValue: null }
|
||||
before: { type: "CursorKey", defaultValue: null }
|
||||
last: { type: "Int", defaultValue: null }
|
||||
) {
|
||||
id
|
||||
context {
|
||||
summary
|
||||
}
|
||||
meetings(
|
||||
first: $first
|
||||
after: $after
|
||||
last: $last
|
||||
before: $before
|
||||
orderBy: $order
|
||||
) @connection(key: "MeetingsListQuery_meetings") {
|
||||
__id
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
...MeetingsPageRowFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
type Props = {
|
||||
queryRef: PreloadedQuery<MeetingGraphListQuery>;
|
||||
};
|
||||
|
||||
export default function MeetingsPage(props: Props) {
|
||||
const { __ } = useTranslate();
|
||||
|
||||
const organization = usePreloadedQuery(
|
||||
meetingsQuery,
|
||||
props.queryRef
|
||||
).organization;
|
||||
|
||||
const pagination = usePaginationFragment(
|
||||
meetingsFragment,
|
||||
organization as MeetingsPageListFragment$key
|
||||
);
|
||||
|
||||
const meetingNodes = pagination.data.meetings.edges
|
||||
.map((edge) => edge.node)
|
||||
.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);
|
||||
|
||||
// Update summary text when context.summary changes (but not while editing)
|
||||
useEffect(() => {
|
||||
if (!isEditing && !justSavedRef.current) {
|
||||
const newSummary = pagination.data.context?.summary || "";
|
||||
setSummaryText(newSummary);
|
||||
setDisplayedSummary(newSummary);
|
||||
}
|
||||
// Reset the flag after the effect runs
|
||||
if (justSavedRef.current) {
|
||||
justSavedRef.current = false;
|
||||
}
|
||||
}, [pagination.data.context?.summary, isEditing]);
|
||||
|
||||
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 = () => {
|
||||
const valueToSave = summaryText.trim();
|
||||
const previousValue = pagination.data.context?.summary || "";
|
||||
setDisplayedSummary(valueToSave);
|
||||
justSavedRef.current = true;
|
||||
setIsEditing(false);
|
||||
|
||||
const valueToSend = valueToSave.length > 0 ? valueToSave : "";
|
||||
|
||||
updateSummary({
|
||||
variables: {
|
||||
input: {
|
||||
organizationId: organization.id,
|
||||
summary: valueToSend || null,
|
||||
},
|
||||
},
|
||||
onError: () => {
|
||||
// Roll back optimistic update on error
|
||||
setDisplayedSummary(previousValue);
|
||||
justSavedRef.current = false;
|
||||
},
|
||||
onCompleted: (_response, error) => {
|
||||
if (error) {
|
||||
// Roll back optimistic update on GraphQL error
|
||||
setDisplayedSummary(previousValue);
|
||||
justSavedRef.current = 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={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>
|
||||
<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."
|
||||
)}
|
||||
>
|
||||
<CreateMeetingDialog connectionId={connectionId}>
|
||||
<Button icon={IconPlusLarge}>{__("Add meeting")}</Button>
|
||||
</CreateMeetingDialog>
|
||||
</PageHeader>
|
||||
{meetingNodes.length > 0 ? (
|
||||
<SortableTable {...pagination}>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<SortableTh field="DATE" className="w-40">
|
||||
{__("Date")}
|
||||
</SortableTh>
|
||||
<SortableTh field="NAME" className="min-w-0">
|
||||
{__("Meeting name")}
|
||||
</SortableTh>
|
||||
<Th className="w-60">{__("Attendees")}</Th>
|
||||
<Th className="w-18"></Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{meetingNodes.map((meeting) => (
|
||||
<MeetingRow
|
||||
key={meeting.id}
|
||||
meeting={meeting}
|
||||
organizationId={organization.id}
|
||||
/>
|
||||
))}
|
||||
</Tbody>
|
||||
</SortableTable>
|
||||
) : (
|
||||
<Card padded>
|
||||
<div className="text-center py-12">
|
||||
<h3 className="text-lg font-semibold mb-2">
|
||||
{__("No meetings yet")}
|
||||
</h3>
|
||||
<p className="text-txt-tertiary mb-4">
|
||||
{__("Create your first meeting to get started.")}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const rowFragment = graphql`
|
||||
fragment MeetingsPageRowFragment on Meeting {
|
||||
id
|
||||
name
|
||||
date
|
||||
attendees {
|
||||
id
|
||||
fullName
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
function MeetingRow({
|
||||
meeting: meetingKey,
|
||||
organizationId,
|
||||
}: {
|
||||
meeting: MeetingsPageRowFragment$key;
|
||||
organizationId: string;
|
||||
}) {
|
||||
const meeting = useFragment<MeetingsPageRowFragment$key>(
|
||||
rowFragment,
|
||||
meetingKey
|
||||
);
|
||||
const { __ } = useTranslate();
|
||||
const [deleteMeeting] = useDeleteMeetingMutation();
|
||||
const confirm = useConfirm();
|
||||
|
||||
const handleDelete = () => {
|
||||
confirm(
|
||||
() =>
|
||||
deleteMeeting({
|
||||
variables: {
|
||||
input: { meetingId: meeting.id },
|
||||
},
|
||||
}),
|
||||
{
|
||||
message: sprintf(
|
||||
__(
|
||||
'This will permanently delete the meeting "%s". This action cannot be undone.'
|
||||
),
|
||||
meeting.name
|
||||
),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Tr to={`/organizations/${organizationId}/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>
|
||||
</Td>
|
||||
<Td className="w-60">
|
||||
{meeting.attendees && meeting.attendees.length > 0 ? (
|
||||
<div className="flex gap-2 items-center flex-wrap">
|
||||
{meeting.attendees.map((attendee) => (
|
||||
<div key={attendee.id} className="flex gap-2 items-center">
|
||||
<Avatar name={attendee.fullName ?? ""} />
|
||||
<Link
|
||||
to={`/organizations/${organizationId}/people/${attendee.id}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className="text-sm hover:underline"
|
||||
>
|
||||
{attendee.fullName}
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-txt-tertiary text-sm">
|
||||
{__("No attendees")}
|
||||
</span>
|
||||
)}
|
||||
</Td>
|
||||
<Td noLink width={50} className="text-end w-18">
|
||||
<ActionDropdown>
|
||||
<DropdownItem
|
||||
variant="danger"
|
||||
icon={IconTrashCan}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
</ActionDropdown>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* @generated SignedSource<<3684f6d6c3a9746dcfcb8bb0156d2201>>
|
||||
* @lightSyntaxTransform
|
||||
* @nogrep
|
||||
*/
|
||||
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
|
||||
import { ReaderFragment } from 'relay-runtime';
|
||||
import { FragmentRefs } from "relay-runtime";
|
||||
export type MeetingDetailPageMeetingFragment$data = {
|
||||
readonly attendees: ReadonlyArray<{
|
||||
readonly fullName: string;
|
||||
readonly id: string;
|
||||
}>;
|
||||
readonly date: any;
|
||||
readonly id: string;
|
||||
readonly minutes: string | null | undefined;
|
||||
readonly name: string;
|
||||
readonly " $fragmentType": "MeetingDetailPageMeetingFragment";
|
||||
};
|
||||
export type MeetingDetailPageMeetingFragment$key = {
|
||||
readonly " $data"?: MeetingDetailPageMeetingFragment$data;
|
||||
readonly " $fragmentSpreads": FragmentRefs<"MeetingDetailPageMeetingFragment">;
|
||||
};
|
||||
|
||||
const node: ReaderFragment = (function(){
|
||||
var v0 = {
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "id",
|
||||
"storageKey": null
|
||||
};
|
||||
return {
|
||||
"argumentDefinitions": [],
|
||||
"kind": "Fragment",
|
||||
"metadata": null,
|
||||
"name": "MeetingDetailPageMeetingFragment",
|
||||
"selections": [
|
||||
(v0/*: any*/),
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "name",
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "date",
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "minutes",
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"concreteType": "People",
|
||||
"kind": "LinkedField",
|
||||
"name": "attendees",
|
||||
"plural": true,
|
||||
"selections": [
|
||||
(v0/*: any*/),
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "fullName",
|
||||
"storageKey": null
|
||||
}
|
||||
],
|
||||
"storageKey": null
|
||||
}
|
||||
],
|
||||
"type": "Meeting",
|
||||
"abstractKey": null
|
||||
};
|
||||
})();
|
||||
|
||||
(node as any).hash = "ae1564a2e5115bfffe20359c5288d4eb";
|
||||
|
||||
export default node;
|
||||
370
apps/console/src/pages/organizations/meetings/__generated__/MeetingsListQuery.graphql.ts
generated
Normal file
370
apps/console/src/pages/organizations/meetings/__generated__/MeetingsListQuery.graphql.ts
generated
Normal file
@@ -0,0 +1,370 @@
|
||||
/**
|
||||
* @generated SignedSource<<829d26690dc2a6f09541f02ef8981395>>
|
||||
* @lightSyntaxTransform
|
||||
* @nogrep
|
||||
*/
|
||||
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
|
||||
import { ConcreteRequest } from 'relay-runtime';
|
||||
import { FragmentRefs } from "relay-runtime";
|
||||
export type MeetingOrderField = "CREATED_AT" | "DATE" | "NAME";
|
||||
export type OrderDirection = "ASC" | "DESC";
|
||||
export type MeetingOrder = {
|
||||
direction: OrderDirection;
|
||||
field: MeetingOrderField;
|
||||
};
|
||||
export type MeetingsListQuery$variables = {
|
||||
after?: any | null | undefined;
|
||||
before?: any | null | undefined;
|
||||
first?: number | null | undefined;
|
||||
id: string;
|
||||
last?: number | null | undefined;
|
||||
order?: MeetingOrder | null | undefined;
|
||||
};
|
||||
export type MeetingsListQuery$data = {
|
||||
readonly node: {
|
||||
readonly " $fragmentSpreads": FragmentRefs<"MeetingsPageListFragment">;
|
||||
};
|
||||
};
|
||||
export type MeetingsListQuery = {
|
||||
response: MeetingsListQuery$data;
|
||||
variables: MeetingsListQuery$variables;
|
||||
};
|
||||
|
||||
const node: ConcreteRequest = (function(){
|
||||
var v0 = {
|
||||
"defaultValue": null,
|
||||
"kind": "LocalArgument",
|
||||
"name": "after"
|
||||
},
|
||||
v1 = {
|
||||
"defaultValue": null,
|
||||
"kind": "LocalArgument",
|
||||
"name": "before"
|
||||
},
|
||||
v2 = {
|
||||
"defaultValue": 50,
|
||||
"kind": "LocalArgument",
|
||||
"name": "first"
|
||||
},
|
||||
v3 = {
|
||||
"defaultValue": null,
|
||||
"kind": "LocalArgument",
|
||||
"name": "id"
|
||||
},
|
||||
v4 = {
|
||||
"defaultValue": null,
|
||||
"kind": "LocalArgument",
|
||||
"name": "last"
|
||||
},
|
||||
v5 = {
|
||||
"defaultValue": {
|
||||
"direction": "DESC",
|
||||
"field": "DATE"
|
||||
},
|
||||
"kind": "LocalArgument",
|
||||
"name": "order"
|
||||
},
|
||||
v6 = [
|
||||
{
|
||||
"kind": "Variable",
|
||||
"name": "id",
|
||||
"variableName": "id"
|
||||
}
|
||||
],
|
||||
v7 = {
|
||||
"kind": "Variable",
|
||||
"name": "after",
|
||||
"variableName": "after"
|
||||
},
|
||||
v8 = {
|
||||
"kind": "Variable",
|
||||
"name": "before",
|
||||
"variableName": "before"
|
||||
},
|
||||
v9 = {
|
||||
"kind": "Variable",
|
||||
"name": "first",
|
||||
"variableName": "first"
|
||||
},
|
||||
v10 = {
|
||||
"kind": "Variable",
|
||||
"name": "last",
|
||||
"variableName": "last"
|
||||
},
|
||||
v11 = {
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "__typename",
|
||||
"storageKey": null
|
||||
},
|
||||
v12 = {
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "id",
|
||||
"storageKey": null
|
||||
},
|
||||
v13 = [
|
||||
(v7/*: any*/),
|
||||
(v8/*: any*/),
|
||||
(v9/*: any*/),
|
||||
(v10/*: any*/),
|
||||
{
|
||||
"kind": "Variable",
|
||||
"name": "orderBy",
|
||||
"variableName": "order"
|
||||
}
|
||||
];
|
||||
return {
|
||||
"fragment": {
|
||||
"argumentDefinitions": [
|
||||
(v0/*: any*/),
|
||||
(v1/*: any*/),
|
||||
(v2/*: any*/),
|
||||
(v3/*: any*/),
|
||||
(v4/*: any*/),
|
||||
(v5/*: any*/)
|
||||
],
|
||||
"kind": "Fragment",
|
||||
"metadata": null,
|
||||
"name": "MeetingsListQuery",
|
||||
"selections": [
|
||||
{
|
||||
"alias": null,
|
||||
"args": (v6/*: any*/),
|
||||
"concreteType": null,
|
||||
"kind": "LinkedField",
|
||||
"name": "node",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
{
|
||||
"args": [
|
||||
(v7/*: any*/),
|
||||
(v8/*: any*/),
|
||||
(v9/*: any*/),
|
||||
(v10/*: any*/),
|
||||
{
|
||||
"kind": "Variable",
|
||||
"name": "order",
|
||||
"variableName": "order"
|
||||
}
|
||||
],
|
||||
"kind": "FragmentSpread",
|
||||
"name": "MeetingsPageListFragment"
|
||||
}
|
||||
],
|
||||
"storageKey": null
|
||||
}
|
||||
],
|
||||
"type": "Query",
|
||||
"abstractKey": null
|
||||
},
|
||||
"kind": "Request",
|
||||
"operation": {
|
||||
"argumentDefinitions": [
|
||||
(v0/*: any*/),
|
||||
(v1/*: any*/),
|
||||
(v2/*: any*/),
|
||||
(v4/*: any*/),
|
||||
(v5/*: any*/),
|
||||
(v3/*: any*/)
|
||||
],
|
||||
"kind": "Operation",
|
||||
"name": "MeetingsListQuery",
|
||||
"selections": [
|
||||
{
|
||||
"alias": null,
|
||||
"args": (v6/*: any*/),
|
||||
"concreteType": null,
|
||||
"kind": "LinkedField",
|
||||
"name": "node",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
(v11/*: any*/),
|
||||
(v12/*: any*/),
|
||||
{
|
||||
"kind": "InlineFragment",
|
||||
"selections": [
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"concreteType": "OrganizationContext",
|
||||
"kind": "LinkedField",
|
||||
"name": "context",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "summary",
|
||||
"storageKey": null
|
||||
}
|
||||
],
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": (v13/*: any*/),
|
||||
"concreteType": "MeetingConnection",
|
||||
"kind": "LinkedField",
|
||||
"name": "meetings",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"concreteType": "MeetingEdge",
|
||||
"kind": "LinkedField",
|
||||
"name": "edges",
|
||||
"plural": true,
|
||||
"selections": [
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"concreteType": "Meeting",
|
||||
"kind": "LinkedField",
|
||||
"name": "node",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
(v12/*: any*/),
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "name",
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "date",
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"concreteType": "People",
|
||||
"kind": "LinkedField",
|
||||
"name": "attendees",
|
||||
"plural": true,
|
||||
"selections": [
|
||||
(v12/*: any*/),
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "fullName",
|
||||
"storageKey": null
|
||||
}
|
||||
],
|
||||
"storageKey": null
|
||||
},
|
||||
(v11/*: any*/)
|
||||
],
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "cursor",
|
||||
"storageKey": null
|
||||
}
|
||||
],
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"concreteType": "PageInfo",
|
||||
"kind": "LinkedField",
|
||||
"name": "pageInfo",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "endCursor",
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "hasNextPage",
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "hasPreviousPage",
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "startCursor",
|
||||
"storageKey": null
|
||||
}
|
||||
],
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"kind": "ClientExtension",
|
||||
"selections": [
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "__id",
|
||||
"storageKey": null
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": (v13/*: any*/),
|
||||
"filters": [
|
||||
"orderBy"
|
||||
],
|
||||
"handle": "connection",
|
||||
"key": "MeetingsListQuery_meetings",
|
||||
"kind": "LinkedHandle",
|
||||
"name": "meetings"
|
||||
}
|
||||
],
|
||||
"type": "Organization",
|
||||
"abstractKey": null
|
||||
}
|
||||
],
|
||||
"storageKey": null
|
||||
}
|
||||
]
|
||||
},
|
||||
"params": {
|
||||
"cacheID": "e5e74555d8a277638d2278c5b71b930b",
|
||||
"id": null,
|
||||
"metadata": {},
|
||||
"name": "MeetingsListQuery",
|
||||
"operationKind": "query",
|
||||
"text": "query MeetingsListQuery(\n $after: CursorKey = null\n $before: CursorKey = null\n $first: Int = 50\n $last: Int = null\n $order: MeetingOrder = {field: DATE, direction: DESC}\n $id: ID!\n) {\n node(id: $id) {\n __typename\n ...MeetingsPageListFragment_16fISc\n id\n }\n}\n\nfragment MeetingsPageListFragment_16fISc on Organization {\n id\n context {\n summary\n }\n meetings(first: $first, after: $after, last: $last, before: $before, orderBy: $order) {\n edges {\n node {\n id\n ...MeetingsPageRowFragment\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n}\n\nfragment MeetingsPageRowFragment on Meeting {\n id\n name\n date\n attendees {\n id\n fullName\n }\n}\n"
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
(node as any).hash = "2fe5f058a857ddd4566169d220fb3310";
|
||||
|
||||
export default node;
|
||||
249
apps/console/src/pages/organizations/meetings/__generated__/MeetingsPageListFragment.graphql.ts
generated
Normal file
249
apps/console/src/pages/organizations/meetings/__generated__/MeetingsPageListFragment.graphql.ts
generated
Normal file
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* @generated SignedSource<<5609ac7dd3b3fb18c95cfa91381582cf>>
|
||||
* @lightSyntaxTransform
|
||||
* @nogrep
|
||||
*/
|
||||
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
|
||||
import { ReaderFragment } from 'relay-runtime';
|
||||
import { FragmentRefs } from "relay-runtime";
|
||||
export type MeetingsPageListFragment$data = {
|
||||
readonly context: {
|
||||
readonly summary: string | null | undefined;
|
||||
} | null | undefined;
|
||||
readonly id: string;
|
||||
readonly meetings: {
|
||||
readonly __id: string;
|
||||
readonly edges: ReadonlyArray<{
|
||||
readonly node: {
|
||||
readonly id: string;
|
||||
readonly " $fragmentSpreads": FragmentRefs<"MeetingsPageRowFragment">;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
readonly " $fragmentType": "MeetingsPageListFragment";
|
||||
};
|
||||
export type MeetingsPageListFragment$key = {
|
||||
readonly " $data"?: MeetingsPageListFragment$data;
|
||||
readonly " $fragmentSpreads": FragmentRefs<"MeetingsPageListFragment">;
|
||||
};
|
||||
|
||||
import MeetingsListQuery_graphql from './MeetingsListQuery.graphql';
|
||||
|
||||
const node: ReaderFragment = (function(){
|
||||
var v0 = [
|
||||
"meetings"
|
||||
],
|
||||
v1 = {
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "id",
|
||||
"storageKey": null
|
||||
};
|
||||
return {
|
||||
"argumentDefinitions": [
|
||||
{
|
||||
"defaultValue": null,
|
||||
"kind": "LocalArgument",
|
||||
"name": "after"
|
||||
},
|
||||
{
|
||||
"defaultValue": null,
|
||||
"kind": "LocalArgument",
|
||||
"name": "before"
|
||||
},
|
||||
{
|
||||
"defaultValue": 50,
|
||||
"kind": "LocalArgument",
|
||||
"name": "first"
|
||||
},
|
||||
{
|
||||
"defaultValue": null,
|
||||
"kind": "LocalArgument",
|
||||
"name": "last"
|
||||
},
|
||||
{
|
||||
"defaultValue": {
|
||||
"direction": "DESC",
|
||||
"field": "DATE"
|
||||
},
|
||||
"kind": "LocalArgument",
|
||||
"name": "order"
|
||||
}
|
||||
],
|
||||
"kind": "Fragment",
|
||||
"metadata": {
|
||||
"connection": [
|
||||
{
|
||||
"count": null,
|
||||
"cursor": null,
|
||||
"direction": "bidirectional",
|
||||
"path": (v0/*: any*/)
|
||||
}
|
||||
],
|
||||
"refetch": {
|
||||
"connection": {
|
||||
"forward": {
|
||||
"count": "first",
|
||||
"cursor": "after"
|
||||
},
|
||||
"backward": {
|
||||
"count": "last",
|
||||
"cursor": "before"
|
||||
},
|
||||
"path": (v0/*: any*/)
|
||||
},
|
||||
"fragmentPathInResult": [
|
||||
"node"
|
||||
],
|
||||
"operation": MeetingsListQuery_graphql,
|
||||
"identifierInfo": {
|
||||
"identifierField": "id",
|
||||
"identifierQueryVariableName": "id"
|
||||
}
|
||||
}
|
||||
},
|
||||
"name": "MeetingsPageListFragment",
|
||||
"selections": [
|
||||
(v1/*: any*/),
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"concreteType": "OrganizationContext",
|
||||
"kind": "LinkedField",
|
||||
"name": "context",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "summary",
|
||||
"storageKey": null
|
||||
}
|
||||
],
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"alias": "meetings",
|
||||
"args": [
|
||||
{
|
||||
"kind": "Variable",
|
||||
"name": "orderBy",
|
||||
"variableName": "order"
|
||||
}
|
||||
],
|
||||
"concreteType": "MeetingConnection",
|
||||
"kind": "LinkedField",
|
||||
"name": "__MeetingsListQuery_meetings_connection",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"concreteType": "MeetingEdge",
|
||||
"kind": "LinkedField",
|
||||
"name": "edges",
|
||||
"plural": true,
|
||||
"selections": [
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"concreteType": "Meeting",
|
||||
"kind": "LinkedField",
|
||||
"name": "node",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
(v1/*: any*/),
|
||||
{
|
||||
"args": null,
|
||||
"kind": "FragmentSpread",
|
||||
"name": "MeetingsPageRowFragment"
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "__typename",
|
||||
"storageKey": null
|
||||
}
|
||||
],
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "cursor",
|
||||
"storageKey": null
|
||||
}
|
||||
],
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"concreteType": "PageInfo",
|
||||
"kind": "LinkedField",
|
||||
"name": "pageInfo",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "endCursor",
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "hasNextPage",
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "hasPreviousPage",
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "startCursor",
|
||||
"storageKey": null
|
||||
}
|
||||
],
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"kind": "ClientExtension",
|
||||
"selections": [
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "__id",
|
||||
"storageKey": null
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"storageKey": null
|
||||
}
|
||||
],
|
||||
"type": "Organization",
|
||||
"abstractKey": null
|
||||
};
|
||||
})();
|
||||
|
||||
(node as any).hash = "2fe5f058a857ddd4566169d220fb3310";
|
||||
|
||||
export default node;
|
||||
84
apps/console/src/pages/organizations/meetings/__generated__/MeetingsPageRowFragment.graphql.ts
generated
Normal file
84
apps/console/src/pages/organizations/meetings/__generated__/MeetingsPageRowFragment.graphql.ts
generated
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* @generated SignedSource<<7efb971ec8052cecbd96f86ea6d3af4c>>
|
||||
* @lightSyntaxTransform
|
||||
* @nogrep
|
||||
*/
|
||||
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
|
||||
import { ReaderFragment } from 'relay-runtime';
|
||||
import { FragmentRefs } from "relay-runtime";
|
||||
export type MeetingsPageRowFragment$data = {
|
||||
readonly attendees: ReadonlyArray<{
|
||||
readonly fullName: string;
|
||||
readonly id: string;
|
||||
}>;
|
||||
readonly date: any;
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly " $fragmentType": "MeetingsPageRowFragment";
|
||||
};
|
||||
export type MeetingsPageRowFragment$key = {
|
||||
readonly " $data"?: MeetingsPageRowFragment$data;
|
||||
readonly " $fragmentSpreads": FragmentRefs<"MeetingsPageRowFragment">;
|
||||
};
|
||||
|
||||
const node: ReaderFragment = (function(){
|
||||
var v0 = {
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "id",
|
||||
"storageKey": null
|
||||
};
|
||||
return {
|
||||
"argumentDefinitions": [],
|
||||
"kind": "Fragment",
|
||||
"metadata": null,
|
||||
"name": "MeetingsPageRowFragment",
|
||||
"selections": [
|
||||
(v0/*: any*/),
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "name",
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "date",
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"concreteType": "People",
|
||||
"kind": "LinkedField",
|
||||
"name": "attendees",
|
||||
"plural": true,
|
||||
"selections": [
|
||||
(v0/*: any*/),
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "fullName",
|
||||
"storageKey": null
|
||||
}
|
||||
],
|
||||
"storageKey": null
|
||||
}
|
||||
],
|
||||
"type": "Meeting",
|
||||
"abstractKey": null
|
||||
};
|
||||
})();
|
||||
|
||||
(node as any).hash = "91ba43abb559bed5acdda60b8f61bda9";
|
||||
|
||||
export default node;
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* @generated SignedSource<<60e0c9d7301cff5c1df299e76debb633>>
|
||||
* @lightSyntaxTransform
|
||||
* @nogrep
|
||||
*/
|
||||
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
|
||||
import { ConcreteRequest } from 'relay-runtime';
|
||||
export type UpdateOrganizationContextInput = {
|
||||
organizationId: string;
|
||||
summary?: string | null | undefined;
|
||||
};
|
||||
export type MeetingsPage_UpdateSummaryMutation$variables = {
|
||||
input: UpdateOrganizationContextInput;
|
||||
};
|
||||
export type MeetingsPage_UpdateSummaryMutation$data = {
|
||||
readonly updateOrganizationContext: {
|
||||
readonly context: {
|
||||
readonly summary: string | null | undefined;
|
||||
};
|
||||
};
|
||||
};
|
||||
export type MeetingsPage_UpdateSummaryMutation = {
|
||||
response: MeetingsPage_UpdateSummaryMutation$data;
|
||||
variables: MeetingsPage_UpdateSummaryMutation$variables;
|
||||
};
|
||||
|
||||
const node: ConcreteRequest = (function(){
|
||||
var v0 = [
|
||||
{
|
||||
"defaultValue": null,
|
||||
"kind": "LocalArgument",
|
||||
"name": "input"
|
||||
}
|
||||
],
|
||||
v1 = [
|
||||
{
|
||||
"alias": null,
|
||||
"args": [
|
||||
{
|
||||
"kind": "Variable",
|
||||
"name": "input",
|
||||
"variableName": "input"
|
||||
}
|
||||
],
|
||||
"concreteType": "UpdateOrganizationContextPayload",
|
||||
"kind": "LinkedField",
|
||||
"name": "updateOrganizationContext",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"concreteType": "OrganizationContext",
|
||||
"kind": "LinkedField",
|
||||
"name": "context",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "summary",
|
||||
"storageKey": null
|
||||
}
|
||||
],
|
||||
"storageKey": null
|
||||
}
|
||||
],
|
||||
"storageKey": null
|
||||
}
|
||||
];
|
||||
return {
|
||||
"fragment": {
|
||||
"argumentDefinitions": (v0/*: any*/),
|
||||
"kind": "Fragment",
|
||||
"metadata": null,
|
||||
"name": "MeetingsPage_UpdateSummaryMutation",
|
||||
"selections": (v1/*: any*/),
|
||||
"type": "Mutation",
|
||||
"abstractKey": null
|
||||
},
|
||||
"kind": "Request",
|
||||
"operation": {
|
||||
"argumentDefinitions": (v0/*: any*/),
|
||||
"kind": "Operation",
|
||||
"name": "MeetingsPage_UpdateSummaryMutation",
|
||||
"selections": (v1/*: any*/)
|
||||
},
|
||||
"params": {
|
||||
"cacheID": "e6bead1dde5239f3cfd2fa1440191454",
|
||||
"id": null,
|
||||
"metadata": {},
|
||||
"name": "MeetingsPage_UpdateSummaryMutation",
|
||||
"operationKind": "mutation",
|
||||
"text": "mutation MeetingsPage_UpdateSummaryMutation(\n $input: UpdateOrganizationContextInput!\n) {\n updateOrganizationContext(input: $input) {\n context {\n summary\n }\n }\n}\n"
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
(node as any).hash = "f354a34b18a449f02508c45d0e7d9dc5";
|
||||
|
||||
export default node;
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
Field,
|
||||
Input,
|
||||
Spinner,
|
||||
useDialogRef,
|
||||
} from "@probo/ui";
|
||||
import { graphql } from "relay-runtime";
|
||||
import { useMutation } from "react-relay";
|
||||
import { z } from "zod";
|
||||
import { useFormWithSchema } from "/hooks/useFormWithSchema";
|
||||
import type { CreateMeetingDialogCreateMutation } from "./__generated__/CreateMeetingDialogCreateMutation.graphql";
|
||||
import { useOrganizationId } from "/hooks/useOrganizationId";
|
||||
import { PeopleMultiSelectField } from "/components/form/PeopleMultiSelectField";
|
||||
import { formatDatetime } from "@probo/helpers";
|
||||
|
||||
const createMeetingMutation = graphql`
|
||||
mutation CreateMeetingDialogCreateMutation(
|
||||
$input: CreateMeetingInput!
|
||||
$connections: [ID!]!
|
||||
) {
|
||||
createMeeting(input: $input) {
|
||||
meetingEdge @prependEdge(connections: $connections) {
|
||||
node {
|
||||
id
|
||||
name
|
||||
date
|
||||
minutes
|
||||
attendees {
|
||||
id
|
||||
fullName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
type Props = {
|
||||
children: React.ReactElement;
|
||||
connectionId: string;
|
||||
};
|
||||
|
||||
const meetingSchema = z.object({
|
||||
name: z.string().min(1, "Meeting name is required"),
|
||||
date: z.string().min(1, "Date is required"),
|
||||
attendeeIds: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export function CreateMeetingDialog({ children, connectionId }: Props) {
|
||||
const { __ } = useTranslate();
|
||||
const dialogRef = useDialogRef();
|
||||
const organizationId = useOrganizationId();
|
||||
const [createMeeting, isCreating] =
|
||||
useMutation<CreateMeetingDialogCreateMutation>(createMeetingMutation);
|
||||
const { handleSubmit, register, control } = useFormWithSchema(
|
||||
meetingSchema,
|
||||
{}
|
||||
);
|
||||
|
||||
const onSubmit = handleSubmit((data) => {
|
||||
createMeeting({
|
||||
variables: {
|
||||
input: {
|
||||
organizationId,
|
||||
name: data.name,
|
||||
date: formatDatetime(data.date)!,
|
||||
attendeeIds: data.attendeeIds || null,
|
||||
},
|
||||
connections: [connectionId],
|
||||
},
|
||||
onCompleted: () => {
|
||||
dialogRef.current?.close();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog ref={dialogRef} trigger={children} title={__("Create meeting")}>
|
||||
<form onSubmit={onSubmit}>
|
||||
<DialogContent>
|
||||
<Field label={__("Meeting name")} required>
|
||||
<Input
|
||||
{...register("name")}
|
||||
placeholder={__("Enter meeting name")}
|
||||
autoFocus
|
||||
/>
|
||||
</Field>
|
||||
<Field label={__("Date")} required>
|
||||
<Input
|
||||
{...register("date")}
|
||||
type="date"
|
||||
placeholder={__("Select date")}
|
||||
/>
|
||||
</Field>
|
||||
<PeopleMultiSelectField
|
||||
name="attendeeIds"
|
||||
control={control}
|
||||
organizationId={organizationId}
|
||||
label={__("Attendees")}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
<Button disabled={isCreating} type="submit">
|
||||
{isCreating && <Spinner />}
|
||||
{__("Create meeting")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
Breadcrumb,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
Spinner,
|
||||
Textarea,
|
||||
useDialogRef,
|
||||
} from "@probo/ui";
|
||||
import { forwardRef, useImperativeHandle } from "react";
|
||||
import { z } from "zod";
|
||||
import { useFormWithSchema } from "/hooks/useFormWithSchema";
|
||||
import { useUpdateMeetingMutation } from "/hooks/graph/MeetingGraph";
|
||||
import type { MeetingDetailPageMeetingFragment$data } from "../__generated__/MeetingDetailPageMeetingFragment.graphql";
|
||||
|
||||
type Props = {
|
||||
meeting: MeetingDetailPageMeetingFragment$data;
|
||||
};
|
||||
|
||||
export type UpdateMeetingMinutesDialogRef = {
|
||||
open: () => void;
|
||||
};
|
||||
|
||||
const minutesSchema = z.object({
|
||||
minutes: z.string(),
|
||||
});
|
||||
|
||||
export const UpdateMeetingMinutesDialog = forwardRef<
|
||||
UpdateMeetingMinutesDialogRef,
|
||||
Props
|
||||
>(function UpdateMeetingMinutesDialog({ meeting }, ref) {
|
||||
const { __ } = useTranslate();
|
||||
const dialogRef = useDialogRef();
|
||||
const [updateMeeting, isUpdating] = useUpdateMeetingMutation();
|
||||
const { handleSubmit, register, reset } = useFormWithSchema(minutesSchema, {
|
||||
defaultValues: {
|
||||
minutes: meeting.minutes || "",
|
||||
},
|
||||
});
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
open: () => {
|
||||
reset({
|
||||
minutes: meeting.minutes || "",
|
||||
});
|
||||
dialogRef.current?.open();
|
||||
},
|
||||
}));
|
||||
|
||||
const onSubmit = handleSubmit((data) => {
|
||||
updateMeeting({
|
||||
variables: {
|
||||
input: {
|
||||
meetingId: meeting.id,
|
||||
minutes: data.minutes,
|
||||
},
|
||||
},
|
||||
onSuccess: () => {
|
||||
dialogRef.current?.close();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
ref={dialogRef}
|
||||
title={<Breadcrumb items={[__("Meetings"), __("Edit minutes")]} />}
|
||||
>
|
||||
<form onSubmit={onSubmit}>
|
||||
<DialogContent>
|
||||
<Textarea
|
||||
id="minutes"
|
||||
variant="ghost"
|
||||
autogrow
|
||||
placeholder={__("Add meeting minutes")}
|
||||
aria-label={__("Minutes")}
|
||||
className="p-6"
|
||||
{...register("minutes")}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
<Button disabled={isUpdating} type="submit">
|
||||
{isUpdating && <Spinner />}
|
||||
{__("Update minutes")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* @generated SignedSource<<fb3a05b463e9f6065118d1b4441ce398>>
|
||||
* @lightSyntaxTransform
|
||||
* @nogrep
|
||||
*/
|
||||
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
|
||||
import { ConcreteRequest } from 'relay-runtime';
|
||||
export type CreateMeetingInput = {
|
||||
attendeeIds?: ReadonlyArray<string> | null | undefined;
|
||||
date: any;
|
||||
minutes?: string | null | undefined;
|
||||
name: string;
|
||||
organizationId: string;
|
||||
};
|
||||
export type CreateMeetingDialogCreateMutation$variables = {
|
||||
connections: ReadonlyArray<string>;
|
||||
input: CreateMeetingInput;
|
||||
};
|
||||
export type CreateMeetingDialogCreateMutation$data = {
|
||||
readonly createMeeting: {
|
||||
readonly meetingEdge: {
|
||||
readonly node: {
|
||||
readonly attendees: ReadonlyArray<{
|
||||
readonly fullName: string;
|
||||
readonly id: string;
|
||||
}>;
|
||||
readonly date: any;
|
||||
readonly id: string;
|
||||
readonly minutes: string | null | undefined;
|
||||
readonly name: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
export type CreateMeetingDialogCreateMutation = {
|
||||
response: CreateMeetingDialogCreateMutation$data;
|
||||
variables: CreateMeetingDialogCreateMutation$variables;
|
||||
};
|
||||
|
||||
const node: ConcreteRequest = (function(){
|
||||
var v0 = {
|
||||
"defaultValue": null,
|
||||
"kind": "LocalArgument",
|
||||
"name": "connections"
|
||||
},
|
||||
v1 = {
|
||||
"defaultValue": null,
|
||||
"kind": "LocalArgument",
|
||||
"name": "input"
|
||||
},
|
||||
v2 = [
|
||||
{
|
||||
"kind": "Variable",
|
||||
"name": "input",
|
||||
"variableName": "input"
|
||||
}
|
||||
],
|
||||
v3 = {
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "id",
|
||||
"storageKey": null
|
||||
},
|
||||
v4 = {
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"concreteType": "MeetingEdge",
|
||||
"kind": "LinkedField",
|
||||
"name": "meetingEdge",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"concreteType": "Meeting",
|
||||
"kind": "LinkedField",
|
||||
"name": "node",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
(v3/*: any*/),
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "name",
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "date",
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "minutes",
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"concreteType": "People",
|
||||
"kind": "LinkedField",
|
||||
"name": "attendees",
|
||||
"plural": true,
|
||||
"selections": [
|
||||
(v3/*: any*/),
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "fullName",
|
||||
"storageKey": null
|
||||
}
|
||||
],
|
||||
"storageKey": null
|
||||
}
|
||||
],
|
||||
"storageKey": null
|
||||
}
|
||||
],
|
||||
"storageKey": null
|
||||
};
|
||||
return {
|
||||
"fragment": {
|
||||
"argumentDefinitions": [
|
||||
(v0/*: any*/),
|
||||
(v1/*: any*/)
|
||||
],
|
||||
"kind": "Fragment",
|
||||
"metadata": null,
|
||||
"name": "CreateMeetingDialogCreateMutation",
|
||||
"selections": [
|
||||
{
|
||||
"alias": null,
|
||||
"args": (v2/*: any*/),
|
||||
"concreteType": "CreateMeetingPayload",
|
||||
"kind": "LinkedField",
|
||||
"name": "createMeeting",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
(v4/*: any*/)
|
||||
],
|
||||
"storageKey": null
|
||||
}
|
||||
],
|
||||
"type": "Mutation",
|
||||
"abstractKey": null
|
||||
},
|
||||
"kind": "Request",
|
||||
"operation": {
|
||||
"argumentDefinitions": [
|
||||
(v1/*: any*/),
|
||||
(v0/*: any*/)
|
||||
],
|
||||
"kind": "Operation",
|
||||
"name": "CreateMeetingDialogCreateMutation",
|
||||
"selections": [
|
||||
{
|
||||
"alias": null,
|
||||
"args": (v2/*: any*/),
|
||||
"concreteType": "CreateMeetingPayload",
|
||||
"kind": "LinkedField",
|
||||
"name": "createMeeting",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
(v4/*: any*/),
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"filters": null,
|
||||
"handle": "prependEdge",
|
||||
"key": "",
|
||||
"kind": "LinkedHandle",
|
||||
"name": "meetingEdge",
|
||||
"handleArgs": [
|
||||
{
|
||||
"kind": "Variable",
|
||||
"name": "connections",
|
||||
"variableName": "connections"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"storageKey": null
|
||||
}
|
||||
]
|
||||
},
|
||||
"params": {
|
||||
"cacheID": "cf37e45c7e8cffd8b44cdc85488209e8",
|
||||
"id": null,
|
||||
"metadata": {},
|
||||
"name": "CreateMeetingDialogCreateMutation",
|
||||
"operationKind": "mutation",
|
||||
"text": "mutation CreateMeetingDialogCreateMutation(\n $input: CreateMeetingInput!\n) {\n createMeeting(input: $input) {\n meetingEdge {\n node {\n id\n name\n date\n minutes\n attendees {\n id\n fullName\n }\n }\n }\n }\n}\n"
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
(node as any).hash = "5ba1bb7a0344e0ea19b383af6d75534f";
|
||||
|
||||
export default node;
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useOutletContext } from "react-router";
|
||||
import type { MeetingDetailPageMeetingFragment$data } from "../__generated__/MeetingDetailPageMeetingFragment.graphql";
|
||||
import { Markdown } from "@probo/ui";
|
||||
|
||||
export default function MeetingMinutesTab() {
|
||||
const { meeting } = useOutletContext<{
|
||||
meeting: MeetingDetailPageMeetingFragment$data;
|
||||
}>();
|
||||
|
||||
return (
|
||||
<div>
|
||||
{meeting.minutes ? (
|
||||
<Markdown content={meeting.minutes} />
|
||||
) : (
|
||||
<div className="text-txt-tertiary text-sm">
|
||||
No minutes recorded yet. Click "Edit minutes" to add meeting minutes.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import { taskRoutes } from "./routes/taskRoutes.ts";
|
||||
import { dataRoutes } from "./routes/dataRoutes.ts";
|
||||
import { assetRoutes } from "./routes/assetRoutes.ts";
|
||||
import { auditRoutes } from "./routes/auditRoutes.ts";
|
||||
import { meetingsRoutes } from "./routes/meetingsRoutes.ts";
|
||||
import { trustCenterRoutes } from "./routes/trustCenterRoutes.ts";
|
||||
import { nonconformityRoutes } from "./routes/nonconformityRoutes.ts";
|
||||
import { obligationRoutes } from "./routes/obligationRoutes.ts";
|
||||
@@ -175,6 +176,7 @@ const routes = [
|
||||
...assetRoutes,
|
||||
...dataRoutes,
|
||||
...auditRoutes,
|
||||
...meetingsRoutes,
|
||||
...nonconformityRoutes,
|
||||
...obligationRoutes,
|
||||
...continualImprovementRoutes,
|
||||
|
||||
57
apps/console/src/routes/meetingsRoutes.ts
Normal file
57
apps/console/src/routes/meetingsRoutes.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Fragment } from "react";
|
||||
import { loadQuery } from "react-relay";
|
||||
import type { AppRoute } from "/routes.tsx";
|
||||
import { relayEnvironment } from "/providers/RelayProviders";
|
||||
import { meetingsQuery } from "/hooks/graph/MeetingGraph";
|
||||
import { meetingNodeQuery } from "/hooks/graph/MeetingGraph";
|
||||
import { PageSkeleton } from "/components/skeletons/PageSkeleton";
|
||||
import { redirect } from "react-router";
|
||||
import { lazy } from "@probo/react-lazy";
|
||||
import { LinkCardSkeleton } from "/components/skeletons/LinkCardSkeleton";
|
||||
|
||||
const meetingTabs = (prefix: string) => {
|
||||
return [
|
||||
{
|
||||
path: `${prefix}`,
|
||||
queryLoader: ({ organizationId, meetingId }) => {
|
||||
const basePath = `/organizations/${organizationId}/meetings/${meetingId}`;
|
||||
const redirectPath = `${basePath}/minutes`;
|
||||
throw redirect(redirectPath);
|
||||
},
|
||||
Component: Fragment,
|
||||
},
|
||||
{
|
||||
path: `${prefix}minutes`,
|
||||
fallback: LinkCardSkeleton,
|
||||
Component: lazy(
|
||||
() =>
|
||||
import(
|
||||
"../pages/organizations/meetings/tabs/MeetingMinutesTab"
|
||||
),
|
||||
),
|
||||
},
|
||||
] satisfies AppRoute[];
|
||||
};
|
||||
|
||||
export const meetingsRoutes = [
|
||||
{
|
||||
path: "meetings",
|
||||
fallback: PageSkeleton,
|
||||
queryLoader: ({ organizationId }) =>
|
||||
loadQuery(relayEnvironment, meetingsQuery, { organizationId }),
|
||||
Component: lazy(
|
||||
() => import("/pages/organizations/meetings/MeetingsPage"),
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "meetings/:meetingId",
|
||||
fallback: PageSkeleton,
|
||||
queryLoader: ({ meetingId }) =>
|
||||
loadQuery(relayEnvironment, meetingNodeQuery, { meetingId }),
|
||||
Component: lazy(
|
||||
() => import("../pages/organizations/meetings/MeetingDetailPage"),
|
||||
),
|
||||
children: [...meetingTabs("")],
|
||||
},
|
||||
] satisfies AppRoute[];
|
||||
|
||||
2
go.sum
2
go.sum
@@ -142,8 +142,6 @@ github.com/mattermost/xml-roundtrip-validator v0.1.0/go.mod h1:qccnGMcpgwcNaBnxq
|
||||
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/modelcontextprotocol/go-sdk v1.0.0 h1:Z4MSjLi38bTgLrd/LjSmofqRqyBiVKRyQSJgw8q8V74=
|
||||
github.com/modelcontextprotocol/go-sdk v1.0.0/go.mod h1:nYtYQroQ2KQiM0/SbyEPUWQ6xs4B95gJjEalc9AQyOs=
|
||||
github.com/modelcontextprotocol/go-sdk v1.1.0 h1:Qjayg53dnKC4UZ+792W21e4BpwEZBzwgRW6LrjLWSwA=
|
||||
github.com/modelcontextprotocol/go-sdk v1.1.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
|
||||
2420
package-lock.json
generated
2420
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -66,4 +66,5 @@ const (
|
||||
SAMLConfigurationEntityType uint16 = 42
|
||||
UserAPIKeyEntityType uint16 = 43
|
||||
UserAPIKeyMembershipEntityType uint16 = 44
|
||||
MeetingEntityType uint16 = 45
|
||||
)
|
||||
|
||||
307
pkg/coredata/meeting.go
Normal file
307
pkg/coredata/meeting.go
Normal file
@@ -0,0 +1,307 @@
|
||||
// Copyright (c) 2025 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 coredata
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"go.probo.inc/probo/pkg/page"
|
||||
)
|
||||
|
||||
type (
|
||||
Meeting struct {
|
||||
ID gid.GID `db:"id"`
|
||||
OrganizationID gid.GID `db:"organization_id"`
|
||||
Name string `db:"name"`
|
||||
Date time.Time `db:"date"`
|
||||
Minutes *string `db:"minutes"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
}
|
||||
|
||||
Meetings []*Meeting
|
||||
|
||||
ErrMeetingNotFound struct {
|
||||
Identifier string
|
||||
}
|
||||
|
||||
ErrMeetingAlreadyExists struct {
|
||||
message string
|
||||
}
|
||||
)
|
||||
|
||||
func (e ErrMeetingNotFound) Error() string {
|
||||
return fmt.Sprintf("meeting not found: %s", e.Identifier)
|
||||
}
|
||||
|
||||
func (e ErrMeetingAlreadyExists) Error() string {
|
||||
return e.message
|
||||
}
|
||||
|
||||
func (m Meeting) CursorKey(orderBy MeetingOrderField) page.CursorKey {
|
||||
switch orderBy {
|
||||
case MeetingOrderFieldCreatedAt:
|
||||
return page.NewCursorKey(m.ID, m.CreatedAt)
|
||||
case MeetingOrderFieldDate:
|
||||
return page.NewCursorKey(m.ID, m.Date)
|
||||
case MeetingOrderFieldName:
|
||||
return page.NewCursorKey(m.ID, m.Name)
|
||||
}
|
||||
|
||||
panic(fmt.Sprintf("unsupported order by: %s", orderBy))
|
||||
}
|
||||
|
||||
func (m *Meeting) LoadByID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
meetingID gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
name,
|
||||
date,
|
||||
minutes,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
meetings
|
||||
WHERE
|
||||
%s
|
||||
AND id = @meeting_id
|
||||
LIMIT 1;
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"meeting_id": meetingID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query meetings: %w", err)
|
||||
}
|
||||
|
||||
meeting, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Meeting])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return &ErrMeetingNotFound{Identifier: meetingID.String()}
|
||||
}
|
||||
|
||||
return fmt.Errorf("cannot collect meeting: %w", err)
|
||||
}
|
||||
|
||||
*m = meeting
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Meetings) LoadByOrganizationID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
organizationID gid.GID,
|
||||
cursor *page.Cursor[MeetingOrderField],
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
name,
|
||||
date,
|
||||
minutes,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
meetings
|
||||
WHERE
|
||||
%s
|
||||
AND organization_id = @organization_id
|
||||
AND %s
|
||||
`
|
||||
q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
|
||||
|
||||
args := pgx.NamedArgs{"organization_id": organizationID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
maps.Copy(args, cursor.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query meetings: %w", err)
|
||||
}
|
||||
|
||||
meetings, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Meeting])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect meetings: %w", err)
|
||||
}
|
||||
|
||||
*m = meetings
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Meetings) CountByOrganizationID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
organizationID gid.GID,
|
||||
) (int, error) {
|
||||
q := `
|
||||
SELECT
|
||||
COUNT(*)
|
||||
FROM
|
||||
meetings
|
||||
WHERE
|
||||
%s
|
||||
AND organization_id = @organization_id
|
||||
`
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"organization_id": organizationID,
|
||||
}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
row := conn.QueryRow(ctx, q, args)
|
||||
var count int
|
||||
if err := row.Scan(&count); err != nil {
|
||||
return 0, fmt.Errorf("cannot count meetings: %w", err)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (m *Meeting) Insert(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
) error {
|
||||
q := `
|
||||
INSERT INTO
|
||||
meetings (
|
||||
tenant_id,
|
||||
id,
|
||||
organization_id,
|
||||
name,
|
||||
date,
|
||||
minutes,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES (
|
||||
@tenant_id,
|
||||
@meeting_id,
|
||||
@organization_id,
|
||||
@name,
|
||||
@date,
|
||||
@minutes,
|
||||
@created_at,
|
||||
@updated_at
|
||||
);
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"tenant_id": scope.GetTenantID(),
|
||||
"meeting_id": m.ID,
|
||||
"organization_id": m.OrganizationID,
|
||||
"name": m.Name,
|
||||
"date": m.Date,
|
||||
"minutes": m.Minutes,
|
||||
"created_at": m.CreatedAt,
|
||||
"updated_at": m.UpdatedAt,
|
||||
}
|
||||
_, err := conn.Exec(ctx, q, args)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot insert meeting: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Meeting) Update(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
) error {
|
||||
q := `
|
||||
UPDATE meetings
|
||||
SET
|
||||
name = @name,
|
||||
date = @date,
|
||||
minutes = @minutes,
|
||||
updated_at = @updated_at
|
||||
WHERE %s
|
||||
AND id = @meeting_id
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"meeting_id": m.ID,
|
||||
"name": m.Name,
|
||||
"date": m.Date,
|
||||
"minutes": m.Minutes,
|
||||
"updated_at": m.UpdatedAt,
|
||||
}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
result, err := conn.Exec(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot update meeting: %w", err)
|
||||
}
|
||||
|
||||
if result.RowsAffected() == 0 {
|
||||
return &ErrMeetingNotFound{Identifier: m.ID.String()}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Meeting) Delete(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
) error {
|
||||
q := `
|
||||
DELETE FROM meetings
|
||||
WHERE %s
|
||||
AND id = @meeting_id
|
||||
`
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"meeting_id": m.ID,
|
||||
}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
result, err := conn.Exec(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot delete meeting: %w", err)
|
||||
}
|
||||
|
||||
if result.RowsAffected() == 0 {
|
||||
return &ErrMeetingNotFound{Identifier: m.ID.String()}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
123
pkg/coredata/meeting_attendee.go
Normal file
123
pkg/coredata/meeting_attendee.go
Normal file
@@ -0,0 +1,123 @@
|
||||
// Copyright (c) 2025 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 coredata
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"maps"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
)
|
||||
|
||||
type (
|
||||
MeetingAttendee struct {
|
||||
MeetingID gid.GID `db:"meeting_id"`
|
||||
OrganizationID gid.GID `db:"organization_id"`
|
||||
AttendeeID gid.GID `db:"attendee_id"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
}
|
||||
|
||||
MeetingAttendees []*MeetingAttendee
|
||||
)
|
||||
|
||||
func (ma *MeetingAttendees) LoadByMeetingID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
meetingID gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
meeting_id,
|
||||
attendee_id,
|
||||
organization_id,
|
||||
created_at
|
||||
FROM
|
||||
meeting_attendees
|
||||
WHERE
|
||||
%s
|
||||
AND meeting_id = @meeting_id
|
||||
ORDER BY
|
||||
created_at ASC
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.NamedArgs{"meeting_id": meetingID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query meeting attendees: %w", err)
|
||||
}
|
||||
|
||||
attendees, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[MeetingAttendee])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect meeting attendees: %w", err)
|
||||
}
|
||||
|
||||
*ma = attendees
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ma *MeetingAttendees) Merge(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
meetingID gid.GID,
|
||||
organizationID gid.GID,
|
||||
attendeeIDs []gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
WITH attendee_ids AS (
|
||||
SELECT
|
||||
unnest(@attendee_ids::text[]) AS attendee_id,
|
||||
@tenant_id AS tenant_id,
|
||||
@meeting_id AS meeting_id,
|
||||
@organization_id AS organization_id,
|
||||
@created_at::timestamptz AS created_at
|
||||
)
|
||||
MERGE INTO meeting_attendees AS tgt
|
||||
USING attendee_ids AS src
|
||||
ON tgt.tenant_id = src.tenant_id
|
||||
AND tgt.meeting_id = src.meeting_id
|
||||
AND tgt.attendee_id = src.attendee_id
|
||||
WHEN NOT MATCHED THEN
|
||||
INSERT (tenant_id, meeting_id, attendee_id, organization_id, created_at)
|
||||
VALUES (src.tenant_id, src.meeting_id, src.attendee_id, src.organization_id, src.created_at)
|
||||
WHEN NOT MATCHED BY SOURCE
|
||||
AND tgt.tenant_id = @tenant_id AND tgt.meeting_id = @meeting_id
|
||||
THEN DELETE
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"tenant_id": scope.GetTenantID(),
|
||||
"meeting_id": meetingID,
|
||||
"organization_id": organizationID,
|
||||
"created_at": time.Now(),
|
||||
"attendee_ids": attendeeIDs,
|
||||
}
|
||||
|
||||
_, err := conn.Exec(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot merge meeting attendees: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
57
pkg/coredata/meeting_order_field.go
Normal file
57
pkg/coredata/meeting_order_field.go
Normal file
@@ -0,0 +1,57 @@
|
||||
// Copyright (c) 2025 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 coredata
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type (
|
||||
MeetingOrderField string
|
||||
)
|
||||
|
||||
const (
|
||||
MeetingOrderFieldDate MeetingOrderField = "DATE"
|
||||
MeetingOrderFieldName MeetingOrderField = "NAME"
|
||||
MeetingOrderFieldCreatedAt MeetingOrderField = "CREATED_AT"
|
||||
)
|
||||
|
||||
func (p MeetingOrderField) Column() string {
|
||||
return string(p)
|
||||
}
|
||||
|
||||
func (p MeetingOrderField) String() string {
|
||||
return string(p)
|
||||
}
|
||||
|
||||
func (p MeetingOrderField) IsValid() bool {
|
||||
switch p {
|
||||
case MeetingOrderFieldDate, MeetingOrderFieldName, MeetingOrderFieldCreatedAt:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (p MeetingOrderField) MarshalText() ([]byte, error) {
|
||||
return []byte(p.String()), nil
|
||||
}
|
||||
|
||||
func (p *MeetingOrderField) UnmarshalText(text []byte) error {
|
||||
*p = MeetingOrderField(text)
|
||||
if !p.IsValid() {
|
||||
return fmt.Errorf("%s is not a valid MeetingOrderField", string(text))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
32
pkg/coredata/migrations/20251109T101900Z.sql
Normal file
32
pkg/coredata/migrations/20251109T101900Z.sql
Normal file
@@ -0,0 +1,32 @@
|
||||
CREATE TABLE meetings (
|
||||
id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
date TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
minutes TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE meeting_attendees (
|
||||
meeting_id TEXT NOT NULL REFERENCES meetings(id) ON DELETE CASCADE,
|
||||
attendee_id TEXT NOT NULL REFERENCES peoples(id) ON DELETE CASCADE,
|
||||
organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||
tenant_id TEXT NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
PRIMARY KEY (meeting_id, attendee_id)
|
||||
);
|
||||
|
||||
CREATE TABLE organization_contexts (
|
||||
organization_id TEXT PRIMARY KEY REFERENCES organizations(id) ON DELETE CASCADE,
|
||||
tenant_id TEXT NOT NULL,
|
||||
summary TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO organization_contexts (organization_id, tenant_id, summary, created_at, updated_at)
|
||||
SELECT id AS organization_id, tenant_id, NULL AS summary, NOW() AS created_at, NOW() AS updated_at
|
||||
FROM organizations
|
||||
ON CONFLICT (organization_id) DO NOTHING;
|
||||
162
pkg/coredata/organization_context.go
Normal file
162
pkg/coredata/organization_context.go
Normal file
@@ -0,0 +1,162 @@
|
||||
// Copyright (c) 2025 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 coredata
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
)
|
||||
|
||||
type (
|
||||
OrganizationContext struct {
|
||||
OrganizationID gid.GID `db:"organization_id"`
|
||||
Summary *string `db:"summary"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
}
|
||||
|
||||
ErrOrganizationContextNotFound struct {
|
||||
Identifier string
|
||||
}
|
||||
)
|
||||
|
||||
func (e ErrOrganizationContextNotFound) Error() string {
|
||||
return fmt.Sprintf("organization context not found: %q", e.Identifier)
|
||||
}
|
||||
|
||||
func (oc *OrganizationContext) LoadByOrganizationID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
organizationID gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
organization_id,
|
||||
summary,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
organization_contexts
|
||||
WHERE
|
||||
%s
|
||||
AND organization_id = @organization_id
|
||||
LIMIT 1;
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"organization_id": organizationID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query organization context: %w", err)
|
||||
}
|
||||
|
||||
orgContext, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[OrganizationContext])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return &ErrOrganizationContextNotFound{Identifier: organizationID.String()}
|
||||
}
|
||||
|
||||
return fmt.Errorf("cannot collect organization context: %w", err)
|
||||
}
|
||||
|
||||
*oc = orgContext
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (oc *OrganizationContext) Insert(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
) error {
|
||||
q := `
|
||||
INSERT INTO organization_contexts (
|
||||
organization_id,
|
||||
tenant_id,
|
||||
summary,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (
|
||||
@organization_id,
|
||||
@tenant_id,
|
||||
@summary,
|
||||
@created_at,
|
||||
@updated_at
|
||||
)
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"organization_id": oc.OrganizationID,
|
||||
"tenant_id": scope.GetTenantID(),
|
||||
"summary": oc.Summary,
|
||||
"created_at": oc.CreatedAt,
|
||||
"updated_at": oc.UpdatedAt,
|
||||
}
|
||||
|
||||
_, err := conn.Exec(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot insert organization context: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (oc *OrganizationContext) Update(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
) error {
|
||||
q := `
|
||||
UPDATE organization_contexts
|
||||
SET
|
||||
summary = @summary,
|
||||
updated_at = @updated_at
|
||||
WHERE
|
||||
%s
|
||||
AND organization_id = @organization_id
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"organization_id": oc.OrganizationID,
|
||||
"summary": oc.Summary,
|
||||
"updated_at": oc.UpdatedAt,
|
||||
}
|
||||
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
result, err := conn.Exec(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot update organization context: %w", err)
|
||||
}
|
||||
|
||||
if result.RowsAffected() == 0 {
|
||||
return &ErrOrganizationContextNotFound{Identifier: oc.OrganizationID.String()}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -21,10 +21,10 @@ import (
|
||||
"maps"
|
||||
"time"
|
||||
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"go.probo.inc/probo/pkg/page"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"go.probo.inc/probo/pkg/page"
|
||||
)
|
||||
|
||||
type (
|
||||
@@ -176,6 +176,52 @@ func (p *People) LoadByEmail(
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Peoples) LoadByIDs(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
peopleIDs []gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
kind,
|
||||
full_name,
|
||||
primary_email_address,
|
||||
additional_email_addresses,
|
||||
position,
|
||||
contract_start_date,
|
||||
contract_end_date,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
peoples
|
||||
WHERE
|
||||
%s
|
||||
AND id = ANY(@people_ids)
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.NamedArgs{"people_ids": peopleIDs}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query people: %w", err)
|
||||
}
|
||||
|
||||
peoples, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[People])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect people: %w", err)
|
||||
}
|
||||
|
||||
*p = peoples
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p People) Insert(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
@@ -427,3 +473,72 @@ INNER JOIN signatories ON peoples.id = signatories.signed_by
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Peoples) LoadByMeetingID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
meetingID gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
WITH people_attendees AS (
|
||||
SELECT
|
||||
p.id,
|
||||
p.organization_id,
|
||||
p.kind,
|
||||
p.full_name,
|
||||
p.primary_email_address,
|
||||
p.additional_email_addresses,
|
||||
p.position,
|
||||
p.contract_start_date,
|
||||
p.contract_end_date,
|
||||
p.created_at,
|
||||
p.updated_at,
|
||||
p.tenant_id,
|
||||
ma.created_at AS attendee_created_at
|
||||
FROM
|
||||
peoples p
|
||||
INNER JOIN
|
||||
meeting_attendees ma ON p.id = ma.attendee_id
|
||||
WHERE
|
||||
ma.meeting_id = @meeting_id
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
kind,
|
||||
full_name,
|
||||
primary_email_address,
|
||||
additional_email_addresses,
|
||||
position,
|
||||
contract_start_date,
|
||||
contract_end_date,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
people_attendees
|
||||
WHERE
|
||||
%s
|
||||
ORDER BY
|
||||
attendee_created_at ASC
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.NamedArgs{"meeting_id": meetingID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query people: %w", err)
|
||||
}
|
||||
|
||||
peoples, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[People])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect people: %w", err)
|
||||
}
|
||||
|
||||
*p = peoples
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ func (car *CreateAssetRequest) Validate() error {
|
||||
v := validator.New()
|
||||
|
||||
v.Check(car.OrganizationID, "organization_id", validator.Required(), validator.GID(coredata.OrganizationEntityType))
|
||||
v.Check(car.Name, "name", validator.Required(), validator.SafeText(TitleMaxLength))
|
||||
v.Check(car.Name, "name", validator.Required(), validator.SafeTextNoNewLine(TitleMaxLength))
|
||||
v.Check(car.Amount, "amount", validator.Required(), validator.Min(1))
|
||||
v.Check(car.OwnerID, "owner_id", validator.Required(), validator.GID(coredata.PeopleEntityType))
|
||||
v.Check(car.AssetType, "asset_type", validator.Required(), validator.OneOfSlice(coredata.AssetTypes()))
|
||||
@@ -56,7 +56,7 @@ func (uar *UpdateAssetRequest) Validate() error {
|
||||
v := validator.New()
|
||||
|
||||
v.Check(uar.ID, "id", validator.Required(), validator.GID(coredata.AssetEntityType))
|
||||
v.Check(uar.Name, "name", validator.SafeText(NameMaxLength))
|
||||
v.Check(uar.Name, "name", validator.SafeTextNoNewLine(NameMaxLength))
|
||||
v.Check(uar.Amount, "amount", validator.Min(1))
|
||||
v.Check(uar.OwnerID, "owner_id", validator.GID(coredata.PeopleEntityType))
|
||||
v.Check(uar.AssetType, "asset_type", validator.OneOfSlice(coredata.AssetTypes()))
|
||||
|
||||
@@ -64,7 +64,7 @@ func (car *CreateAuditRequest) Validate() error {
|
||||
|
||||
v.Check(car.OrganizationID, "organization_id", validator.Required(), validator.GID(coredata.OrganizationEntityType))
|
||||
v.Check(car.FrameworkID, "framework_id", validator.Required(), validator.GID(coredata.FrameworkEntityType))
|
||||
v.Check(car.Name, "name", validator.SafeText(TitleMaxLength))
|
||||
v.Check(car.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
|
||||
v.Check(car.ValidUntil, "valid_until", validator.After(car.ValidFrom))
|
||||
v.Check(car.State, "state", validator.OneOfSlice(coredata.AuditStates()))
|
||||
v.Check(car.TrustCenterVisibility, "trust_center_visibility", validator.OneOfSlice(coredata.TrustCenterVisibilities()))
|
||||
@@ -76,7 +76,7 @@ func (uar *UpdateAuditRequest) Validate() error {
|
||||
v := validator.New()
|
||||
|
||||
v.Check(uar.ID, "id", validator.Required(), validator.GID(coredata.AuditEntityType))
|
||||
v.Check(uar.Name, "name", validator.SafeText(TitleMaxLength))
|
||||
v.Check(uar.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
|
||||
v.Check(uar.ValidUntil, "valid_until", validator.After(uar.ValidFrom))
|
||||
v.Check(uar.State, "state", validator.OneOfSlice(coredata.AuditStates()))
|
||||
v.Check(uar.TrustCenterVisibility, "trust_center_visibility", validator.OneOfSlice(coredata.TrustCenterVisibilities()))
|
||||
|
||||
@@ -56,9 +56,9 @@ func (ccr *CreateControlRequest) Validate() error {
|
||||
|
||||
v.Check(ccr.ID, "id", validator.Required(), validator.GID(coredata.ControlEntityType))
|
||||
v.Check(ccr.FrameworkID, "framework_id", validator.Required(), validator.GID(coredata.FrameworkEntityType))
|
||||
v.Check(ccr.Name, "name", validator.Required(), validator.SafeText(TitleMaxLength))
|
||||
v.Check(ccr.Name, "name", validator.Required(), validator.SafeTextNoNewLine(TitleMaxLength))
|
||||
v.Check(ccr.Description, "description", validator.Required(), validator.SafeText(ContentMaxLength))
|
||||
v.Check(ccr.SectionTitle, "section_title", validator.Required(), validator.SafeText(TitleMaxLength))
|
||||
v.Check(ccr.SectionTitle, "section_title", validator.Required(), validator.SafeTextNoNewLine(TitleMaxLength))
|
||||
v.Check(ccr.Status, "status", validator.Required(), validator.OneOfSlice(coredata.ControlStatuses()))
|
||||
v.Check(ccr.ExclusionJustification, "exclusion_justification", validator.Required(), validator.SafeText(TitleMaxLength))
|
||||
|
||||
@@ -69,9 +69,9 @@ func (ucr *UpdateControlRequest) Validate() error {
|
||||
v := validator.New()
|
||||
|
||||
v.Check(ucr.ID, "id", validator.Required(), validator.GID(coredata.ControlEntityType))
|
||||
v.Check(ucr.Name, "name", validator.SafeText(TitleMaxLength))
|
||||
v.Check(ucr.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
|
||||
v.Check(ucr.Description, "description", validator.SafeText(ContentMaxLength))
|
||||
v.Check(ucr.SectionTitle, "section_title", validator.SafeText(TitleMaxLength))
|
||||
v.Check(ucr.SectionTitle, "section_title", validator.SafeTextNoNewLine(TitleMaxLength))
|
||||
v.Check(ucr.Status, "status", validator.OneOfSlice(coredata.ControlStatuses()))
|
||||
v.Check(ucr.ExclusionJustification, "exclusion_justification", validator.SafeText(TitleMaxLength))
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ func (cdr *CreateDatumRequest) Validate() error {
|
||||
v := validator.New()
|
||||
|
||||
v.Check(cdr.OrganizationID, "organization_id", validator.Required(), validator.GID(coredata.OrganizationEntityType))
|
||||
v.Check(cdr.Name, "name", validator.Required(), validator.SafeText(NameMaxLength))
|
||||
v.Check(cdr.Name, "name", validator.SafeTextNoNewLine(NameMaxLength))
|
||||
v.Check(cdr.DataClassification, "data_classification", validator.Required(), validator.OneOfSlice(coredata.DataClassifications()))
|
||||
v.Check(cdr.OwnerID, "owner_id", validator.Required(), validator.GID(coredata.PeopleEntityType))
|
||||
v.CheckEach(cdr.VendorIDs, "vendor_ids", func(index int, item any) {
|
||||
@@ -66,7 +66,7 @@ func (udr *UpdateDatumRequest) Validate() error {
|
||||
v := validator.New()
|
||||
|
||||
v.Check(udr.ID, "id", validator.Required(), validator.GID(coredata.DatumEntityType))
|
||||
v.Check(udr.Name, "name", validator.SafeText(NameMaxLength))
|
||||
v.Check(udr.Name, "name", validator.SafeTextNoNewLine(NameMaxLength))
|
||||
v.Check(udr.DataClassification, "data_classification", validator.OneOfSlice(coredata.DataClassifications()))
|
||||
v.Check(udr.OwnerID, "owner_id", validator.GID(coredata.PeopleEntityType))
|
||||
v.CheckEach(udr.VendorIDs, "vendor_ids", func(index int, item any) {
|
||||
|
||||
@@ -95,7 +95,7 @@ func (cdr *CreateDocumentRequest) Validate() error {
|
||||
v := validator.New()
|
||||
|
||||
v.Check(cdr.OrganizationID, "organization_id", validator.Required(), validator.GID(coredata.OrganizationEntityType))
|
||||
v.Check(cdr.Title, "title", validator.Required(), validator.SafeText(TitleMaxLength))
|
||||
v.Check(cdr.Title, "title", validator.Required(), validator.SafeTextNoNewLine(TitleMaxLength))
|
||||
v.Check(cdr.Content, "content", validator.Required(), validator.NotEmpty(), validator.MaxLen(documentMaxLength))
|
||||
v.Check(cdr.OwnerID, "owner_id", validator.Required(), validator.GID(coredata.PeopleEntityType))
|
||||
v.Check(cdr.Classification, "classification", validator.Required(), validator.OneOfSlice(coredata.DocumentClassifications()))
|
||||
@@ -109,7 +109,7 @@ func (udr *UpdateDocumentRequest) Validate() error {
|
||||
v := validator.New()
|
||||
|
||||
v.Check(udr.DocumentID, "document_id", validator.Required(), validator.GID(coredata.DocumentEntityType))
|
||||
v.Check(udr.Title, "title", validator.SafeText(TitleMaxLength))
|
||||
v.Check(udr.Title, "title", validator.SafeTextNoNewLine(TitleMaxLength))
|
||||
v.Check(udr.OwnerID, "owner_id", validator.GID(coredata.PeopleEntityType))
|
||||
v.Check(udr.Classification, "classification", validator.OneOfSlice(coredata.DocumentClassifications()))
|
||||
v.Check(udr.DocumentType, "document_type", validator.OneOfSlice(coredata.DocumentTypes()))
|
||||
|
||||
@@ -78,7 +78,7 @@ func (cfr *CreateFrameworkRequest) Validate() error {
|
||||
v := validator.New()
|
||||
|
||||
v.Check(cfr.OrganizationID, "organization_id", validator.Required(), validator.GID(coredata.OrganizationEntityType))
|
||||
v.Check(cfr.Name, "name", validator.Required(), validator.SafeText(TitleMaxLength))
|
||||
v.Check(cfr.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
|
||||
v.Check(cfr.Description, "description", validator.SafeText(ContentMaxLength))
|
||||
|
||||
return v.Error()
|
||||
@@ -88,7 +88,7 @@ func (ufr *UpdateFrameworkRequest) Validate() error {
|
||||
v := validator.New()
|
||||
|
||||
v.Check(ufr.ID, "id", validator.Required(), validator.GID(coredata.FrameworkEntityType))
|
||||
v.Check(ufr.Name, "name", validator.SafeText(TitleMaxLength))
|
||||
v.Check(ufr.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
|
||||
v.Check(ufr.Description, "description", validator.SafeText(ContentMaxLength))
|
||||
|
||||
return v.Error()
|
||||
|
||||
@@ -74,7 +74,7 @@ func (cmr *CreateMeasureRequest) Validate() error {
|
||||
v := validator.New()
|
||||
|
||||
v.Check(cmr.OrganizationID, "organization_id", validator.Required(), validator.GID(coredata.OrganizationEntityType))
|
||||
v.Check(cmr.Name, "name", validator.Required(), validator.SafeText(TitleMaxLength))
|
||||
v.Check(cmr.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
|
||||
v.Check(cmr.Description, "description", validator.SafeText(ContentMaxLength))
|
||||
v.Check(cmr.Category, "category", validator.Required(), validator.SafeText(TitleMaxLength))
|
||||
|
||||
@@ -85,7 +85,7 @@ func (umr *UpdateMeasureRequest) Validate() error {
|
||||
v := validator.New()
|
||||
|
||||
v.Check(umr.ID, "id", validator.Required(), validator.GID(coredata.MeasureEntityType))
|
||||
v.Check(umr.Name, "name", validator.SafeText(TitleMaxLength))
|
||||
v.Check(umr.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
|
||||
v.Check(umr.Description, "description", validator.SafeText(ContentMaxLength))
|
||||
v.Check(umr.Category, "category", validator.SafeText(TitleMaxLength))
|
||||
v.Check(umr.State, "state", validator.OneOfSlice(coredata.MeasureStates()))
|
||||
|
||||
319
pkg/probo/meeting_service.go
Normal file
319
pkg/probo/meeting_service.go
Normal file
@@ -0,0 +1,319 @@
|
||||
// Copyright (c) 2025 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 probo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"go.probo.inc/probo/pkg/page"
|
||||
"go.probo.inc/probo/pkg/validator"
|
||||
)
|
||||
|
||||
type MeetingService struct {
|
||||
svc *TenantService
|
||||
}
|
||||
|
||||
type (
|
||||
CreateMeetingRequest struct {
|
||||
OrganizationID gid.GID
|
||||
Name string
|
||||
Date time.Time
|
||||
AttendeeIDs []gid.GID
|
||||
Minutes *string
|
||||
}
|
||||
|
||||
UpdateMeetingRequest struct {
|
||||
MeetingID gid.GID
|
||||
Name *string
|
||||
Date *time.Time
|
||||
AttendeeIDs []gid.GID
|
||||
Minutes **string
|
||||
}
|
||||
)
|
||||
|
||||
func (cmr *CreateMeetingRequest) Validate() error {
|
||||
v := validator.New()
|
||||
|
||||
v.Check(cmr.OrganizationID, "organization_id", validator.Required(), validator.GID(coredata.OrganizationEntityType))
|
||||
v.Check(cmr.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
|
||||
v.Check(cmr.Date, "date", validator.Required())
|
||||
v.CheckEach(cmr.AttendeeIDs, "attendee_ids", func(index int, item any) {
|
||||
v.Check(item, fmt.Sprintf("attendee_ids[%d]", index), validator.Required(), validator.GID(coredata.PeopleEntityType))
|
||||
})
|
||||
v.Check(cmr.Minutes, "minutes", validator.SafeText(ContentMaxLength))
|
||||
|
||||
return v.Error()
|
||||
}
|
||||
|
||||
func (umr *UpdateMeetingRequest) Validate() error {
|
||||
v := validator.New()
|
||||
|
||||
v.Check(umr.MeetingID, "meeting_id", validator.Required(), validator.GID(coredata.MeetingEntityType))
|
||||
v.Check(umr.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
|
||||
v.CheckEach(umr.AttendeeIDs, "attendee_ids", func(index int, item any) {
|
||||
v.Check(item, fmt.Sprintf("attendee_ids[%d]", index), validator.Required(), validator.GID(coredata.PeopleEntityType))
|
||||
})
|
||||
v.Check(umr.Minutes, "minutes", validator.SafeText(ContentMaxLength))
|
||||
|
||||
return v.Error()
|
||||
}
|
||||
|
||||
func (s MeetingService) ListForOrganizationID(
|
||||
ctx context.Context,
|
||||
organizationID gid.GID,
|
||||
cursor *page.Cursor[coredata.MeetingOrderField],
|
||||
) (*page.Page[*coredata.Meeting, coredata.MeetingOrderField], error) {
|
||||
var meetings coredata.Meetings
|
||||
organization := &coredata.Organization{}
|
||||
|
||||
err := s.svc.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
if err := organization.LoadByID(ctx, conn, s.svc.scope, organizationID); err != nil {
|
||||
return fmt.Errorf("cannot load organization: %w", err)
|
||||
}
|
||||
|
||||
err := meetings.LoadByOrganizationID(
|
||||
ctx,
|
||||
conn,
|
||||
s.svc.scope,
|
||||
organization.ID,
|
||||
cursor,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot load meetings: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return page.NewPage(meetings, cursor), nil
|
||||
}
|
||||
|
||||
func (s MeetingService) CountForOrganizationID(
|
||||
ctx context.Context,
|
||||
organizationID gid.GID,
|
||||
) (int, error) {
|
||||
var count int
|
||||
|
||||
err := s.svc.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) (err error) {
|
||||
meetings := &coredata.Meetings{}
|
||||
count, err = meetings.CountByOrganizationID(ctx, conn, s.svc.scope, organizationID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot count meetings: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (s MeetingService) Get(
|
||||
ctx context.Context,
|
||||
meetingID gid.GID,
|
||||
) (*coredata.Meeting, error) {
|
||||
meeting := &coredata.Meeting{}
|
||||
|
||||
err := s.svc.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
return meeting.LoadByID(ctx, conn, s.svc.scope, meetingID)
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return meeting, nil
|
||||
}
|
||||
|
||||
func (s MeetingService) Create(
|
||||
ctx context.Context,
|
||||
req CreateMeetingRequest,
|
||||
) (*coredata.Meeting, error) {
|
||||
if err := req.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("invalid request: %w", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
var meeting *coredata.Meeting
|
||||
organization := &coredata.Organization{}
|
||||
|
||||
err := s.svc.pg.WithTx(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
if err := organization.LoadByID(ctx, conn, s.svc.scope, req.OrganizationID); err != nil {
|
||||
return fmt.Errorf("cannot load organization: %w", err)
|
||||
}
|
||||
|
||||
meeting = &coredata.Meeting{
|
||||
ID: gid.New(organization.ID.TenantID(), coredata.MeetingEntityType),
|
||||
OrganizationID: organization.ID,
|
||||
Name: req.Name,
|
||||
Date: req.Date,
|
||||
Minutes: req.Minutes,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
if err := meeting.Insert(ctx, conn, s.svc.scope); err != nil {
|
||||
return fmt.Errorf("cannot insert meeting: %w", err)
|
||||
}
|
||||
|
||||
if len(req.AttendeeIDs) > 0 {
|
||||
var attendeePeople coredata.Peoples
|
||||
if err := attendeePeople.LoadByIDs(ctx, conn, s.svc.scope, req.AttendeeIDs); err != nil {
|
||||
return fmt.Errorf("cannot load attendees: %w", err)
|
||||
}
|
||||
|
||||
var attendees coredata.MeetingAttendees
|
||||
if err := attendees.Merge(ctx, conn, s.svc.scope, meeting.ID, organization.ID, req.AttendeeIDs); err != nil {
|
||||
return fmt.Errorf("cannot merge meeting attendees: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return meeting, nil
|
||||
}
|
||||
|
||||
func (s MeetingService) GetAttendees(
|
||||
ctx context.Context,
|
||||
meetingID gid.GID,
|
||||
) (coredata.Peoples, error) {
|
||||
var people coredata.Peoples
|
||||
|
||||
err := s.svc.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
return people.LoadByMeetingID(ctx, conn, s.svc.scope, meetingID)
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return people, nil
|
||||
}
|
||||
|
||||
func (s MeetingService) Update(
|
||||
ctx context.Context,
|
||||
req UpdateMeetingRequest,
|
||||
) (*coredata.Meeting, error) {
|
||||
if err := req.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("invalid request: %w", err)
|
||||
}
|
||||
|
||||
meeting := &coredata.Meeting{}
|
||||
|
||||
err := s.svc.pg.WithTx(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
if err := meeting.LoadByID(ctx, conn, s.svc.scope, req.MeetingID); err != nil {
|
||||
return fmt.Errorf("cannot load meeting: %w", err)
|
||||
}
|
||||
|
||||
if req.Name != nil {
|
||||
meeting.Name = *req.Name
|
||||
}
|
||||
if req.Date != nil {
|
||||
meeting.Date = *req.Date
|
||||
}
|
||||
if req.Minutes != nil {
|
||||
meeting.Minutes = *req.Minutes
|
||||
}
|
||||
|
||||
meeting.UpdatedAt = time.Now()
|
||||
|
||||
if err := meeting.Update(ctx, conn, s.svc.scope); err != nil {
|
||||
return fmt.Errorf("cannot update meeting: %w", err)
|
||||
}
|
||||
|
||||
if req.AttendeeIDs != nil {
|
||||
var attendeePeople coredata.Peoples
|
||||
if err := attendeePeople.LoadByIDs(ctx, conn, s.svc.scope, req.AttendeeIDs); err != nil {
|
||||
return fmt.Errorf("cannot load attendees: %w", err)
|
||||
}
|
||||
|
||||
var attendees coredata.MeetingAttendees
|
||||
if err := attendees.Merge(ctx, conn, s.svc.scope, meeting.ID, meeting.OrganizationID, req.AttendeeIDs); err != nil {
|
||||
return fmt.Errorf("cannot merge meeting attendees: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return meeting, nil
|
||||
}
|
||||
|
||||
func (s MeetingService) Delete(
|
||||
ctx context.Context,
|
||||
meetingID gid.GID,
|
||||
) error {
|
||||
meeting := &coredata.Meeting{ID: meetingID}
|
||||
|
||||
err := s.svc.pg.WithTx(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
if err := meeting.LoadByID(ctx, conn, s.svc.scope, meetingID); err != nil {
|
||||
return fmt.Errorf("cannot load meeting: %w", err)
|
||||
}
|
||||
|
||||
if err := meeting.Delete(ctx, conn, s.svc.scope); err != nil {
|
||||
return fmt.Errorf("cannot delete meeting: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -73,12 +73,17 @@ type (
|
||||
Email **string
|
||||
HeadquarterAddress **string
|
||||
}
|
||||
|
||||
UpdateOrganizationContextRequest struct {
|
||||
OrganizationID gid.GID
|
||||
Summary **string
|
||||
}
|
||||
)
|
||||
|
||||
func (cor *CreateOrganizationRequest) Validate() error {
|
||||
v := validator.New()
|
||||
|
||||
v.Check(cor.Name, "name", validator.Required(), validator.SafeText(TitleMaxLength))
|
||||
v.Check(cor.Name, "name", validator.Required(), validator.SafeTextNoNewLine(TitleMaxLength))
|
||||
|
||||
return v.Error()
|
||||
}
|
||||
@@ -87,7 +92,7 @@ func (uor *UpdateOrganizationRequest) Validate() error {
|
||||
v := validator.New()
|
||||
|
||||
v.Check(uor.ID, "id", validator.Required(), validator.GID(coredata.OrganizationEntityType))
|
||||
v.Check(uor.Name, "name", validator.SafeText(TitleMaxLength))
|
||||
v.Check(uor.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
|
||||
v.Check(uor.Description, "description", validator.SafeText(ContentMaxLength))
|
||||
v.Check(uor.WebsiteURL, "website_url", validator.SafeText(2048))
|
||||
v.Check(uor.Email, "email", validator.SafeText(255))
|
||||
@@ -98,6 +103,15 @@ func (uor *UpdateOrganizationRequest) Validate() error {
|
||||
return v.Error()
|
||||
}
|
||||
|
||||
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))
|
||||
|
||||
return v.Error()
|
||||
}
|
||||
|
||||
func (s OrganizationService) Create(
|
||||
ctx context.Context,
|
||||
req CreateOrganizationRequest,
|
||||
@@ -138,6 +152,16 @@ func (s OrganizationService) Create(
|
||||
return fmt.Errorf("cannot insert trust center: %w", err)
|
||||
}
|
||||
|
||||
organizationContext := &coredata.OrganizationContext{
|
||||
OrganizationID: organization.ID,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
if err := organizationContext.Insert(ctx, tx, s.svc.scope); err != nil {
|
||||
return fmt.Errorf("cannot insert organization context: %w", err)
|
||||
}
|
||||
|
||||
if err := s.createProboVendor(ctx, tx, organization, now); err != nil {
|
||||
return fmt.Errorf("cannot create Probo vendor: %w", err)
|
||||
}
|
||||
@@ -178,6 +202,78 @@ func (s OrganizationService) Get(
|
||||
return organization, nil
|
||||
}
|
||||
|
||||
func (s OrganizationService) GetContextSummary(
|
||||
ctx context.Context,
|
||||
organizationID gid.GID,
|
||||
) (*coredata.OrganizationContext, error) {
|
||||
organizationContext := &coredata.OrganizationContext{}
|
||||
|
||||
err := s.svc.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
err := organizationContext.LoadByOrganizationID(
|
||||
ctx,
|
||||
conn,
|
||||
s.svc.scope,
|
||||
organizationID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot load organization context: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return organizationContext, nil
|
||||
}
|
||||
|
||||
func (s OrganizationService) UpdateContext(
|
||||
ctx context.Context,
|
||||
req UpdateOrganizationContextRequest,
|
||||
) (*coredata.OrganizationContext, error) {
|
||||
if err := req.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("invalid request: %w", err)
|
||||
}
|
||||
|
||||
organization := &coredata.Organization{}
|
||||
organizationContext := &coredata.OrganizationContext{}
|
||||
|
||||
err := s.svc.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
if err := organization.LoadByID(ctx, tx, s.svc.scope, req.OrganizationID); err != nil {
|
||||
return fmt.Errorf("cannot load organization: %w", err)
|
||||
}
|
||||
|
||||
if err := organizationContext.LoadByOrganizationID(ctx, tx, s.svc.scope, req.OrganizationID); err != nil {
|
||||
return fmt.Errorf("cannot load organization context: %w", err)
|
||||
}
|
||||
|
||||
if req.Summary != nil {
|
||||
organizationContext.Summary = *req.Summary
|
||||
organizationContext.UpdatedAt = time.Now()
|
||||
|
||||
if err := organizationContext.Update(ctx, tx, s.svc.scope); err != nil {
|
||||
return fmt.Errorf("cannot update organization context: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return organizationContext, nil
|
||||
}
|
||||
|
||||
func (s OrganizationService) Update(
|
||||
ctx context.Context,
|
||||
req UpdateOrganizationRequest,
|
||||
@@ -223,6 +319,10 @@ func (s OrganizationService) Update(
|
||||
organization.HeadquarterAddress = *req.HeadquarterAddress
|
||||
}
|
||||
|
||||
if err := organization.Update(ctx, s.svc.scope, tx); err != nil {
|
||||
return fmt.Errorf("cannot update organization: %w", err)
|
||||
}
|
||||
|
||||
if req.File != nil {
|
||||
fileID := gid.New(s.svc.scope.GetTenantID(), coredata.FileEntityType)
|
||||
objectKey, err := uuid.NewV7()
|
||||
|
||||
@@ -58,7 +58,7 @@ func (cpr *CreatePeopleRequest) Validate() error {
|
||||
v := validator.New()
|
||||
|
||||
v.Check(cpr.OrganizationID, "organization_id", validator.Required(), validator.GID(coredata.OrganizationEntityType))
|
||||
v.Check(cpr.FullName, "full_name", validator.Required(), validator.SafeText(NameMaxLength))
|
||||
v.Check(cpr.FullName, "full_name", validator.SafeTextNoNewLine(NameMaxLength))
|
||||
v.Check(cpr.PrimaryEmailAddress, "primary_email_address", validator.Required(), validator.NotEmpty(), validator.Email())
|
||||
v.CheckEach(cpr.AdditionalEmailAddresses, "additional_email_addresses", func(index int, item any) {
|
||||
v.Check(item, fmt.Sprintf("additional_email_addresses[%d]", index), validator.Required(), validator.NotEmpty(), validator.Email())
|
||||
@@ -76,7 +76,7 @@ func (upr *UpdatePeopleRequest) Validate() error {
|
||||
|
||||
v.Check(upr.ID, "id", validator.Required(), validator.GID(coredata.PeopleEntityType))
|
||||
v.Check(upr.Kind, "kind", validator.OneOfSlice(coredata.PeopleKinds()))
|
||||
v.Check(upr.FullName, "full_name", validator.Required(), validator.SafeText(NameMaxLength))
|
||||
v.Check(upr.FullName, "full_name", validator.SafeTextNoNewLine(NameMaxLength))
|
||||
v.Check(upr.PrimaryEmailAddress, "primary_email_address", validator.NotEmpty(), validator.Email())
|
||||
v.CheckEach(upr.AdditionalEmailAddresses, "additional_email_addresses", func(index int, item any) {
|
||||
v.Check(item, fmt.Sprintf("additional_email_addresses[%d]", index), validator.Required(), validator.NotEmpty(), validator.Email())
|
||||
|
||||
@@ -76,7 +76,7 @@ func (cpar *CreateProcessingActivityRequest) Validate() error {
|
||||
v := validator.New()
|
||||
|
||||
v.Check(cpar.OrganizationID, "organization_id", validator.Required(), validator.GID(coredata.OrganizationEntityType))
|
||||
v.Check(cpar.Name, "name", validator.Required(), validator.SafeText(TitleMaxLength))
|
||||
v.Check(cpar.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
|
||||
v.Check(cpar.Purpose, "purpose", validator.SafeText(TitleMaxLength))
|
||||
v.Check(cpar.DataSubjectCategory, "data_subject_category", validator.SafeText(TitleMaxLength))
|
||||
v.Check(cpar.PersonalDataCategory, "personal_data_category", validator.SafeText(TitleMaxLength))
|
||||
@@ -102,7 +102,7 @@ func (upar *UpdateProcessingActivityRequest) Validate() error {
|
||||
v := validator.New()
|
||||
|
||||
v.Check(upar.ID, "id", validator.Required(), validator.GID(coredata.ProcessingActivityEntityType))
|
||||
v.Check(upar.Name, "name", validator.SafeText(TitleMaxLength))
|
||||
v.Check(upar.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
|
||||
v.Check(upar.Purpose, "purpose", validator.SafeText(TitleMaxLength))
|
||||
v.Check(upar.DataSubjectCategory, "data_subject_category", validator.SafeText(TitleMaxLength))
|
||||
v.Check(upar.PersonalDataCategory, "personal_data_category", validator.SafeText(TitleMaxLength))
|
||||
|
||||
@@ -64,7 +64,7 @@ func (crr *CreateRiskRequest) Validate() error {
|
||||
v := validator.New()
|
||||
|
||||
v.Check(crr.OrganizationID, "organization_id", validator.Required(), validator.GID(coredata.OrganizationEntityType))
|
||||
v.Check(crr.Name, "name", validator.Required(), validator.SafeText(TitleMaxLength))
|
||||
v.Check(crr.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
|
||||
v.Check(crr.Description, "description", validator.Required(), validator.SafeText(ContentMaxLength))
|
||||
v.Check(crr.Category, "category", validator.Required(), validator.SafeText(TitleMaxLength))
|
||||
v.Check(crr.Treatment, "treatment", validator.Required(), validator.OneOfSlice(coredata.RiskTreatments()))
|
||||
@@ -82,7 +82,7 @@ func (urr *UpdateRiskRequest) Validate() error {
|
||||
v := validator.New()
|
||||
|
||||
v.Check(urr.ID, "id", validator.Required(), validator.GID(coredata.RiskEntityType))
|
||||
v.Check(urr.Name, "name", validator.SafeText(TitleMaxLength))
|
||||
v.Check(urr.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
|
||||
v.Check(urr.Description, "description", validator.SafeText(ContentMaxLength))
|
||||
v.Check(urr.Category, "category", validator.SafeText(TitleMaxLength))
|
||||
v.Check(urr.Treatment, "treatment", validator.OneOfSlice(coredata.RiskTreatments()))
|
||||
|
||||
@@ -100,6 +100,7 @@ type (
|
||||
Assets *AssetService
|
||||
Data *DatumService
|
||||
Audits *AuditService
|
||||
Meetings *MeetingService
|
||||
Reports *ReportService
|
||||
TrustCenters *TrustCenterService
|
||||
TrustCenterAccesses *TrustCenterAccessService
|
||||
@@ -211,6 +212,7 @@ func (s *Service) WithTenant(tenantID gid.TenantID) *TenantService {
|
||||
tenantService.Assets = &AssetService{svc: tenantService}
|
||||
tenantService.Data = &DatumService{svc: tenantService}
|
||||
tenantService.Audits = &AuditService{svc: tenantService}
|
||||
tenantService.Meetings = &MeetingService{svc: tenantService}
|
||||
tenantService.Reports = &ReportService{svc: tenantService}
|
||||
tenantService.TrustCenters = &TrustCenterService{svc: tenantService}
|
||||
tenantService.TrustCenterAccesses = &TrustCenterAccessService{svc: tenantService}
|
||||
|
||||
@@ -50,7 +50,7 @@ func (csr *CreateSnapshotRequest) Validate() error {
|
||||
v := validator.New()
|
||||
|
||||
v.Check(csr.OrganizationID, "organization_id", validator.Required(), validator.GID(coredata.OrganizationEntityType))
|
||||
v.Check(csr.Name, "name", validator.Required(), validator.SafeText(TitleMaxLength))
|
||||
v.Check(csr.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
|
||||
v.Check(csr.Description, "description", validator.SafeText(ContentMaxLength))
|
||||
v.Check(csr.Type, "type", validator.Required(), validator.OneOfSlice(coredata.SnapshotsTypes()))
|
||||
|
||||
@@ -61,7 +61,7 @@ func (usr *UpdateSnapshotRequest) Validate() error {
|
||||
v := validator.New()
|
||||
|
||||
v.Check(usr.ID, "id", validator.Required(), validator.GID(coredata.SnapshotEntityType))
|
||||
v.Check(usr.Name, "name", validator.SafeText(TitleMaxLength))
|
||||
v.Check(usr.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
|
||||
v.Check(usr.Description, "description", validator.SafeText(ContentMaxLength))
|
||||
v.Check(usr.Type, "type", validator.OneOfSlice(coredata.SnapshotsTypes()))
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ func (ctr *CreateTaskRequest) Validate() error {
|
||||
|
||||
v.Check(ctr.OrganizationID, "organization_id", validator.Required(), validator.GID(coredata.OrganizationEntityType))
|
||||
v.Check(ctr.MeasureID, "measure_id", validator.GID(coredata.MeasureEntityType))
|
||||
v.Check(ctr.Name, "name", validator.Required(), validator.SafeText(TitleMaxLength))
|
||||
v.Check(ctr.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
|
||||
v.Check(ctr.Description, "description", validator.SafeText(ContentMaxLength))
|
||||
v.Check(ctr.TimeEstimate, "time_estimate", validator.RangeDuration(0, 1000*time.Hour))
|
||||
v.Check(ctr.AssignedToID, "assigned_to_id", validator.GID(coredata.PeopleEntityType))
|
||||
@@ -69,7 +69,7 @@ func (utr *UpdateTaskRequest) Validate() error {
|
||||
v := validator.New()
|
||||
|
||||
v.Check(utr.TaskID, "task_id", validator.Required(), validator.GID(coredata.TaskEntityType))
|
||||
v.Check(utr.Name, "name", validator.SafeText(TitleMaxLength))
|
||||
v.Check(utr.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
|
||||
v.Check(utr.Description, "description", validator.SafeText(ContentMaxLength))
|
||||
v.Check(utr.TimeEstimate, "time_estimate", validator.RangeDuration(0, 1000*time.Hour))
|
||||
v.Check(utr.State, "state", validator.OneOfSlice(coredata.TaskStates()))
|
||||
|
||||
@@ -60,7 +60,7 @@ func (ctcar *CreateTrustCenterAccessRequest) Validate() error {
|
||||
|
||||
v.Check(ctcar.TrustCenterID, "trust_center_id", validator.Required(), validator.GID(coredata.TrustCenterEntityType))
|
||||
v.Check(ctcar.Email, "email", validator.Required(), validator.Email())
|
||||
v.Check(ctcar.Name, "name", validator.Required(), validator.SafeText(TitleMaxLength))
|
||||
v.Check(ctcar.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
|
||||
|
||||
return v.Error()
|
||||
}
|
||||
@@ -69,7 +69,7 @@ func (utcar *UpdateTrustCenterAccessRequest) Validate() error {
|
||||
v := validator.New()
|
||||
|
||||
v.Check(utcar.ID, "id", validator.Required(), validator.GID(coredata.TrustCenterAccessEntityType))
|
||||
v.Check(utcar.Name, "name", validator.SafeText(TitleMaxLength))
|
||||
v.Check(utcar.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
|
||||
v.CheckEach(utcar.DocumentIDs, "document_ids", func(index int, item any) {
|
||||
v.Check(item, fmt.Sprintf("document_ids[%d]", index), validator.Required(), validator.GID(coredata.DocumentEntityType))
|
||||
})
|
||||
|
||||
@@ -60,7 +60,7 @@ func (ctcfr *CreateTrustCenterFileRequest) Validate() error {
|
||||
v := validator.New()
|
||||
|
||||
v.Check(ctcfr.OrganizationID, "organization_id", validator.Required(), validator.GID(coredata.OrganizationEntityType))
|
||||
v.Check(ctcfr.Name, "name", validator.Required(), validator.SafeText(TitleMaxLength))
|
||||
v.Check(ctcfr.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
|
||||
v.Check(ctcfr.Category, "category", validator.Required(), validator.SafeText(TitleMaxLength))
|
||||
v.Check(ctcfr.File, "file", validator.Required())
|
||||
v.Check(ctcfr.TrustCenterVisibility, "trust_center_visibility", validator.Required(), validator.OneOfSlice(coredata.TrustCenterVisibilities()))
|
||||
@@ -72,7 +72,7 @@ func (utcfr *UpdateTrustCenterFileRequest) Validate() error {
|
||||
v := validator.New()
|
||||
|
||||
v.Check(utcfr.ID, "id", validator.Required(), validator.GID(coredata.TrustCenterFileEntityType))
|
||||
v.Check(utcfr.Name, "name", validator.SafeText(TitleMaxLength))
|
||||
v.Check(utcfr.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
|
||||
v.Check(utcfr.Category, "category", validator.SafeText(TitleMaxLength))
|
||||
v.Check(utcfr.TrustCenterVisibility, "trust_center_visibility", validator.OneOfSlice(coredata.TrustCenterVisibilities()))
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ func (ctcrr *CreateTrustCenterReferenceRequest) Validate() error {
|
||||
v := validator.New()
|
||||
|
||||
v.Check(ctcrr.TrustCenterID, "trust_center_id", validator.Required(), validator.GID(coredata.TrustCenterEntityType))
|
||||
v.Check(ctcrr.Name, "name", validator.Required(), validator.SafeText(TitleMaxLength))
|
||||
v.Check(ctcrr.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
|
||||
v.Check(ctcrr.Description, "description", validator.SafeText(ContentMaxLength))
|
||||
v.Check(ctcrr.WebsiteURL, "website_url", validator.Required(), validator.SafeText(2048))
|
||||
|
||||
@@ -72,7 +72,7 @@ func (utcrr *UpdateTrustCenterReferenceRequest) Validate() error {
|
||||
v := validator.New()
|
||||
|
||||
v.Check(utcrr.ID, "id", validator.Required(), validator.GID(coredata.TrustCenterReferenceEntityType))
|
||||
v.Check(utcrr.Name, "name", validator.SafeText(TitleMaxLength))
|
||||
v.Check(utcrr.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
|
||||
v.Check(utcrr.Description, "description", validator.SafeText(ContentMaxLength))
|
||||
v.Check(utcrr.WebsiteURL, "website_url", validator.SafeText(2048))
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ func (utcndar *UploadTrustCenterNDARequest) Validate() error {
|
||||
v := validator.New()
|
||||
|
||||
v.Check(utcndar.TrustCenterID, "trust_center_id", validator.Required(), validator.GID(coredata.TrustCenterEntityType))
|
||||
v.Check(utcndar.FileName, "file_name", validator.Required(), validator.SafeText(TitleMaxLength))
|
||||
v.Check(utcndar.FileName, "file_name", validator.SafeTextNoNewLine(TitleMaxLength))
|
||||
|
||||
return v.Error()
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ type (
|
||||
func (vbaacr *VendorBusinessAssociateAgreementCreateRequest) Validate() error {
|
||||
v := validator.New()
|
||||
|
||||
v.Check(vbaacr.FileName, "file_name", validator.Required(), validator.SafeText(TitleMaxLength))
|
||||
v.Check(vbaacr.FileName, "file_name", validator.SafeTextNoNewLine(TitleMaxLength))
|
||||
v.Check(vbaacr.ValidUntil, "valid_until", validator.After(vbaacr.ValidFrom))
|
||||
|
||||
return v.Error()
|
||||
|
||||
@@ -44,7 +44,7 @@ type (
|
||||
func (vcrcr *VendorComplianceReportCreateRequest) Validate() error {
|
||||
v := validator.New()
|
||||
|
||||
v.Check(vcrcr.ReportName, "report_name", validator.Required(), validator.SafeText(TitleMaxLength))
|
||||
v.Check(vcrcr.ReportName, "report_name", validator.SafeTextNoNewLine(TitleMaxLength))
|
||||
|
||||
return v.Error()
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ func (cvcr *CreateVendorContactRequest) Validate() error {
|
||||
v := validator.New()
|
||||
|
||||
v.Check(cvcr.VendorID, "vendor_id", validator.Required(), validator.GID(coredata.VendorEntityType))
|
||||
v.Check(cvcr.FullName, "full_name", validator.SafeText(TitleMaxLength))
|
||||
v.Check(cvcr.FullName, "fullName", validator.SafeTextNoNewLine(TitleMaxLength))
|
||||
v.Check(cvcr.Email, "email", validator.Email())
|
||||
v.Check(cvcr.Phone, "phone", validator.SafeText(NameMaxLength))
|
||||
v.Check(cvcr.Role, "role", validator.SafeText(TitleMaxLength))
|
||||
@@ -64,7 +64,7 @@ func (uvcr *UpdateVendorContactRequest) Validate() error {
|
||||
v := validator.New()
|
||||
|
||||
v.Check(uvcr.ID, "id", validator.Required(), validator.GID(coredata.VendorContactEntityType))
|
||||
v.Check(uvcr.FullName, "full_name", validator.SafeText(TitleMaxLength))
|
||||
v.Check(uvcr.FullName, "fullName", validator.SafeTextNoNewLine(TitleMaxLength))
|
||||
v.Check(uvcr.Email, "email", validator.Email())
|
||||
v.Check(uvcr.Phone, "phone", validator.SafeText(NameMaxLength))
|
||||
v.Check(uvcr.Role, "role", validator.SafeText(TitleMaxLength))
|
||||
|
||||
@@ -53,7 +53,7 @@ type (
|
||||
func (vdpacr *VendorDataPrivacyAgreementCreateRequest) Validate() error {
|
||||
v := validator.New()
|
||||
|
||||
v.Check(vdpacr.FileName, "file_name", validator.Required(), validator.SafeText(TitleMaxLength))
|
||||
v.Check(vdpacr.FileName, "file_name", validator.SafeTextNoNewLine(TitleMaxLength))
|
||||
v.Check(vdpacr.ValidUntil, "valid_until", validator.After(vdpacr.ValidFrom))
|
||||
|
||||
return v.Error()
|
||||
|
||||
@@ -96,10 +96,10 @@ func (cvr *CreateVendorRequest) Validate() error {
|
||||
v := validator.New()
|
||||
|
||||
v.Check(cvr.OrganizationID, "organization_id", validator.Required(), validator.GID(coredata.OrganizationEntityType))
|
||||
v.Check(cvr.Name, "name", validator.Required(), validator.SafeText(TitleMaxLength))
|
||||
v.Check(cvr.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
|
||||
v.Check(cvr.Description, "description", validator.SafeText(ContentMaxLength))
|
||||
v.Check(cvr.HeadquarterAddress, "headquarter_address", validator.SafeText(ContentMaxLength))
|
||||
v.Check(cvr.LegalName, "legal_name", validator.SafeText(TitleMaxLength))
|
||||
v.Check(cvr.LegalName, "cvr.LegalName", validator.SafeTextNoNewLine(TitleMaxLength))
|
||||
v.Check(cvr.WebsiteURL, "website_url", validator.SafeText(2048))
|
||||
v.Check(cvr.Category, "category", validator.OneOfSlice(coredata.VendorCategories()))
|
||||
v.Check(cvr.PrivacyPolicyURL, "privacy_policy_url", validator.SafeText(2048))
|
||||
@@ -121,10 +121,10 @@ func (uvr *UpdateVendorRequest) Validate() error {
|
||||
v := validator.New()
|
||||
|
||||
v.Check(uvr.ID, "id", validator.Required(), validator.GID(coredata.VendorEntityType))
|
||||
v.Check(uvr.Name, "name", validator.SafeText(TitleMaxLength))
|
||||
v.Check(uvr.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
|
||||
v.Check(uvr.Description, "description", validator.SafeText(ContentMaxLength))
|
||||
v.Check(uvr.HeadquarterAddress, "headquarter_address", validator.SafeText(ContentMaxLength))
|
||||
v.Check(uvr.LegalName, "legal_name", validator.SafeText(TitleMaxLength))
|
||||
v.Check(uvr.LegalName, "uvr.LegalName", validator.SafeTextNoNewLine(TitleMaxLength))
|
||||
v.Check(uvr.WebsiteURL, "website_url", validator.SafeText(2048))
|
||||
v.Check(uvr.Category, "category", validator.OneOfSlice(coredata.VendorCategories()))
|
||||
v.Check(uvr.PrivacyPolicyURL, "privacy_policy_url", validator.SafeText(2048))
|
||||
|
||||
@@ -48,7 +48,7 @@ func (cvsr *CreateVendorServiceRequest) Validate() error {
|
||||
v := validator.New()
|
||||
|
||||
v.Check(cvsr.VendorID, "vendor_id", validator.Required(), validator.GID(coredata.VendorEntityType))
|
||||
v.Check(cvsr.Name, "name", validator.Required(), validator.SafeText(TitleMaxLength))
|
||||
v.Check(cvsr.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
|
||||
v.Check(cvsr.Description, "description", validator.SafeText(ContentMaxLength))
|
||||
|
||||
return v.Error()
|
||||
@@ -58,7 +58,7 @@ func (uvsr *UpdateVendorServiceRequest) Validate() error {
|
||||
v := validator.New()
|
||||
|
||||
v.Check(uvsr.ID, "id", validator.Required(), validator.GID(coredata.VendorServiceEntityType))
|
||||
v.Check(uvsr.Name, "name", validator.SafeText(TitleMaxLength))
|
||||
v.Check(uvsr.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
|
||||
v.Check(uvsr.Description, "description", validator.SafeText(ContentMaxLength))
|
||||
|
||||
return v.Error()
|
||||
|
||||
@@ -395,6 +395,14 @@ enum DocumentOrderField
|
||||
)
|
||||
}
|
||||
|
||||
enum MeetingOrderField
|
||||
@goModel(model: "go.probo.inc/probo/pkg/coredata.MeetingOrderField") {
|
||||
DATE @goEnum(value: "go.probo.inc/probo/pkg/coredata.MeetingOrderFieldDate")
|
||||
NAME @goEnum(value: "go.probo.inc/probo/pkg/coredata.MeetingOrderFieldName")
|
||||
CREATED_AT
|
||||
@goEnum(value: "go.probo.inc/probo/pkg/coredata.MeetingOrderFieldCreatedAt")
|
||||
}
|
||||
|
||||
enum RiskOrderField
|
||||
@goModel(model: "go.probo.inc/probo/pkg/coredata.RiskOrderField") {
|
||||
CREATED_AT
|
||||
@@ -1200,6 +1208,14 @@ input DocumentOrder
|
||||
field: DocumentOrderField!
|
||||
}
|
||||
|
||||
input MeetingOrder
|
||||
@goModel(
|
||||
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.MeetingOrderBy"
|
||||
) {
|
||||
direction: OrderDirection!
|
||||
field: MeetingOrderField!
|
||||
}
|
||||
|
||||
input RiskOrder
|
||||
@goModel(
|
||||
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.RiskOrderBy"
|
||||
@@ -1442,6 +1458,7 @@ type Organization implements Node {
|
||||
websiteUrl: String
|
||||
email: String
|
||||
headquarterAddress: String
|
||||
context: OrganizationContext @goField(forceResolver: true)
|
||||
|
||||
memberships(
|
||||
first: Int
|
||||
@@ -1511,6 +1528,14 @@ type Organization implements Node {
|
||||
filter: DocumentFilter
|
||||
): DocumentConnection! @goField(forceResolver: true)
|
||||
|
||||
meetings(
|
||||
first: Int
|
||||
after: CursorKey
|
||||
last: Int
|
||||
before: CursorKey
|
||||
orderBy: MeetingOrder
|
||||
): MeetingConnection! @goField(forceResolver: true)
|
||||
|
||||
measures(
|
||||
first: Int
|
||||
after: CursorKey
|
||||
@@ -1996,6 +2021,17 @@ type Document implements Node {
|
||||
updatedAt: Datetime!
|
||||
}
|
||||
|
||||
type Meeting implements Node {
|
||||
id: ID!
|
||||
name: String!
|
||||
date: Datetime!
|
||||
minutes: String
|
||||
attendees: [People!]! @goField(forceResolver: true)
|
||||
organization: Organization! @goField(forceResolver: true)
|
||||
createdAt: Datetime!
|
||||
updatedAt: Datetime!
|
||||
}
|
||||
|
||||
type Risk implements Node {
|
||||
id: ID!
|
||||
snapshotId: ID
|
||||
@@ -2478,6 +2514,20 @@ type DocumentEdge {
|
||||
node: Document!
|
||||
}
|
||||
|
||||
type MeetingConnection
|
||||
@goModel(
|
||||
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.MeetingConnection"
|
||||
) {
|
||||
totalCount: Int! @goField(forceResolver: true)
|
||||
edges: [MeetingEdge!]!
|
||||
pageInfo: PageInfo!
|
||||
}
|
||||
|
||||
type MeetingEdge {
|
||||
cursor: CursorKey!
|
||||
node: Meeting!
|
||||
}
|
||||
|
||||
type RiskConnection
|
||||
@goModel(
|
||||
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.RiskConnection"
|
||||
@@ -2677,6 +2727,9 @@ type Mutation {
|
||||
updateOrganization(
|
||||
input: UpdateOrganizationInput!
|
||||
): UpdateOrganizationPayload!
|
||||
updateOrganizationContext(
|
||||
input: UpdateOrganizationContextInput!
|
||||
): UpdateOrganizationContextPayload!
|
||||
deleteOrganizationHorizontalLogo(
|
||||
input: DeleteOrganizationHorizontalLogoInput!
|
||||
): DeleteOrganizationHorizontalLogoPayload!
|
||||
@@ -2895,6 +2948,10 @@ type Mutation {
|
||||
createDocument(input: CreateDocumentInput!): CreateDocumentPayload!
|
||||
updateDocument(input: UpdateDocumentInput!): UpdateDocumentPayload!
|
||||
deleteDocument(input: DeleteDocumentInput!): DeleteDocumentPayload!
|
||||
# Meeting mutations
|
||||
createMeeting(input: CreateMeetingInput!): CreateMeetingPayload!
|
||||
updateMeeting(input: UpdateMeetingInput!): UpdateMeetingPayload!
|
||||
deleteMeeting(input: DeleteMeetingInput!): DeleteMeetingPayload!
|
||||
publishDocumentVersion(
|
||||
input: PublishDocumentVersionInput!
|
||||
): PublishDocumentVersionPayload!
|
||||
@@ -3050,6 +3107,11 @@ input UpdateOrganizationInput {
|
||||
horizontalLogoFile: Upload
|
||||
}
|
||||
|
||||
input UpdateOrganizationContextInput {
|
||||
organizationId: ID!
|
||||
summary: String @goField(omittable: true)
|
||||
}
|
||||
|
||||
input DeleteOrganizationHorizontalLogoInput {
|
||||
organizationId: ID!
|
||||
}
|
||||
@@ -3539,6 +3601,26 @@ input DeleteDocumentInput {
|
||||
documentId: ID!
|
||||
}
|
||||
|
||||
input CreateMeetingInput {
|
||||
organizationId: ID!
|
||||
name: String!
|
||||
date: Datetime!
|
||||
attendeeIds: [ID!]
|
||||
minutes: String
|
||||
}
|
||||
|
||||
input UpdateMeetingInput {
|
||||
meetingId: ID!
|
||||
name: String
|
||||
date: Datetime
|
||||
attendeeIds: [ID!]
|
||||
minutes: String @goField(omittable: true)
|
||||
}
|
||||
|
||||
input DeleteMeetingInput {
|
||||
meetingId: ID!
|
||||
}
|
||||
|
||||
input ConfirmEmailInput {
|
||||
token: String!
|
||||
}
|
||||
@@ -3772,6 +3854,15 @@ type UpdateOrganizationPayload {
|
||||
organization: Organization!
|
||||
}
|
||||
|
||||
type UpdateOrganizationContextPayload {
|
||||
context: OrganizationContext!
|
||||
}
|
||||
|
||||
type OrganizationContext {
|
||||
organizationId: ID!
|
||||
summary: String
|
||||
}
|
||||
|
||||
type DeleteOrganizationHorizontalLogoPayload {
|
||||
organization: Organization!
|
||||
}
|
||||
@@ -4091,6 +4182,18 @@ type DeleteDocumentPayload {
|
||||
deletedDocumentId: ID!
|
||||
}
|
||||
|
||||
type CreateMeetingPayload {
|
||||
meetingEdge: MeetingEdge!
|
||||
}
|
||||
|
||||
type UpdateMeetingPayload {
|
||||
meeting: Meeting!
|
||||
}
|
||||
|
||||
type DeleteMeetingPayload {
|
||||
deletedMeetingId: ID!
|
||||
}
|
||||
|
||||
type ConfirmEmailPayload {
|
||||
success: Boolean!
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
82
pkg/server/api/console/v1/types/meeting.go
Normal file
82
pkg/server/api/console/v1/types/meeting.go
Normal file
@@ -0,0 +1,82 @@
|
||||
// Copyright (c) 2025 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"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"go.probo.inc/probo/pkg/page"
|
||||
)
|
||||
|
||||
type (
|
||||
MeetingOrderBy OrderBy[coredata.MeetingOrderField]
|
||||
|
||||
MeetingConnection struct {
|
||||
TotalCount int
|
||||
Edges []*MeetingEdge
|
||||
PageInfo PageInfo
|
||||
|
||||
Resolver any
|
||||
ParentID gid.GID
|
||||
}
|
||||
)
|
||||
|
||||
func NewMeetingConnection(
|
||||
p *page.Page[*coredata.Meeting, coredata.MeetingOrderField],
|
||||
parentType any,
|
||||
parentID gid.GID,
|
||||
) *MeetingConnection {
|
||||
var edges = make([]*MeetingEdge, len(p.Data))
|
||||
|
||||
for i := range edges {
|
||||
edges[i] = NewMeetingEdge(p.Data[i], p.Cursor.OrderBy.Field)
|
||||
}
|
||||
|
||||
return &MeetingConnection{
|
||||
Edges: edges,
|
||||
PageInfo: *NewPageInfo(p),
|
||||
|
||||
Resolver: parentType,
|
||||
ParentID: parentID,
|
||||
}
|
||||
}
|
||||
|
||||
func NewMeetingEdges(meetings []*coredata.Meeting, orderBy coredata.MeetingOrderField) []*MeetingEdge {
|
||||
edges := make([]*MeetingEdge, len(meetings))
|
||||
|
||||
for i := range edges {
|
||||
edges[i] = NewMeetingEdge(meetings[i], orderBy)
|
||||
}
|
||||
|
||||
return edges
|
||||
}
|
||||
|
||||
func NewMeetingEdge(meeting *coredata.Meeting, orderBy coredata.MeetingOrderField) *MeetingEdge {
|
||||
return &MeetingEdge{
|
||||
Cursor: meeting.CursorKey(orderBy),
|
||||
Node: NewMeeting(meeting),
|
||||
}
|
||||
}
|
||||
|
||||
func NewMeeting(meeting *coredata.Meeting) *Meeting {
|
||||
return &Meeting{
|
||||
ID: meeting.ID,
|
||||
Name: meeting.Name,
|
||||
Date: meeting.Date,
|
||||
Minutes: meeting.Minutes,
|
||||
CreatedAt: meeting.CreatedAt,
|
||||
UpdatedAt: meeting.UpdatedAt,
|
||||
}
|
||||
}
|
||||
26
pkg/server/api/console/v1/types/organization_context.go
Normal file
26
pkg/server/api/console/v1/types/organization_context.go
Normal file
@@ -0,0 +1,26 @@
|
||||
// Copyright (c) 2025 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,
|
||||
Summary: oc.Summary,
|
||||
}
|
||||
}
|
||||
@@ -376,6 +376,18 @@ type CreateMeasurePayload struct {
|
||||
MeasureEdge *MeasureEdge `json:"measureEdge"`
|
||||
}
|
||||
|
||||
type CreateMeetingInput struct {
|
||||
OrganizationID gid.GID `json:"organizationId"`
|
||||
Name string `json:"name"`
|
||||
Date time.Time `json:"date"`
|
||||
AttendeeIds []gid.GID `json:"attendeeIds,omitempty"`
|
||||
Minutes *string `json:"minutes,omitempty"`
|
||||
}
|
||||
|
||||
type CreateMeetingPayload struct {
|
||||
MeetingEdge *MeetingEdge `json:"meetingEdge"`
|
||||
}
|
||||
|
||||
type CreateNonconformityInput struct {
|
||||
OrganizationID gid.GID `json:"organizationId"`
|
||||
ReferenceID string `json:"referenceId"`
|
||||
@@ -446,7 +458,7 @@ type CreateProcessingActivityInput struct {
|
||||
Recipients *string `json:"recipients,omitempty"`
|
||||
Location *string `json:"location,omitempty"`
|
||||
InternationalTransfers bool `json:"internationalTransfers"`
|
||||
TransferSafeguard *coredata.ProcessingActivityTransferSafeguard `json:"transferSafeguards,omitempty"`
|
||||
TransferSafeguards *coredata.ProcessingActivityTransferSafeguard `json:"transferSafeguards,omitempty"`
|
||||
RetentionPeriod *string `json:"retentionPeriod,omitempty"`
|
||||
SecurityMeasures *string `json:"securityMeasures,omitempty"`
|
||||
DataProtectionImpactAssessment coredata.ProcessingActivityDataProtectionImpactAssessment `json:"dataProtectionImpactAssessment"`
|
||||
@@ -841,6 +853,14 @@ type DeleteMeasurePayload struct {
|
||||
DeletedMeasureID gid.GID `json:"deletedMeasureId"`
|
||||
}
|
||||
|
||||
type DeleteMeetingInput struct {
|
||||
MeetingID gid.GID `json:"meetingId"`
|
||||
}
|
||||
|
||||
type DeleteMeetingPayload struct {
|
||||
DeletedMeetingID gid.GID `json:"deletedMeetingId"`
|
||||
}
|
||||
|
||||
type DeleteNonconformityInput struct {
|
||||
NonconformityID gid.GID `json:"nonconformityId"`
|
||||
}
|
||||
@@ -1340,6 +1360,25 @@ type MeasureFilter struct {
|
||||
State *coredata.MeasureState `json:"state,omitempty"`
|
||||
}
|
||||
|
||||
type Meeting struct {
|
||||
ID gid.GID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Date time.Time `json:"date"`
|
||||
Minutes *string `json:"minutes,omitempty"`
|
||||
Attendees []*People `json:"attendees"`
|
||||
Organization *Organization `json:"organization"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (Meeting) IsNode() {}
|
||||
func (this Meeting) GetID() gid.GID { return this.ID }
|
||||
|
||||
type MeetingEdge struct {
|
||||
Cursor page.CursorKey `json:"cursor"`
|
||||
Node *Meeting `json:"node"`
|
||||
}
|
||||
|
||||
type Membership struct {
|
||||
ID gid.GID `json:"id"`
|
||||
UserID gid.GID `json:"userID"`
|
||||
@@ -1432,6 +1471,7 @@ type Organization struct {
|
||||
WebsiteURL *string `json:"websiteUrl,omitempty"`
|
||||
Email *string `json:"email,omitempty"`
|
||||
HeadquarterAddress *string `json:"headquarterAddress,omitempty"`
|
||||
Context *OrganizationContext `json:"context,omitempty"`
|
||||
Memberships *MembershipConnection `json:"memberships"`
|
||||
Invitations *InvitationConnection `json:"invitations"`
|
||||
SlackConnections *SlackConnectionConnection `json:"slackConnections"`
|
||||
@@ -1440,6 +1480,7 @@ type Organization struct {
|
||||
Vendors *VendorConnection `json:"vendors"`
|
||||
Peoples *PeopleConnection `json:"peoples"`
|
||||
Documents *DocumentConnection `json:"documents"`
|
||||
Meetings *MeetingConnection `json:"meetings"`
|
||||
Measures *MeasureConnection `json:"measures"`
|
||||
Risks *RiskConnection `json:"risks"`
|
||||
Tasks *TaskConnection `json:"tasks"`
|
||||
@@ -1467,6 +1508,11 @@ type OrganizationConnection struct {
|
||||
PageInfo *PageInfo `json:"pageInfo"`
|
||||
}
|
||||
|
||||
type OrganizationContext struct {
|
||||
OrganizationID gid.GID `json:"organizationId"`
|
||||
Summary *string `json:"summary,omitempty"`
|
||||
}
|
||||
|
||||
type OrganizationEdge struct {
|
||||
Cursor page.CursorKey `json:"cursor"`
|
||||
Node *Organization `json:"node"`
|
||||
@@ -1956,6 +2002,18 @@ type UpdateMeasurePayload struct {
|
||||
Measure *Measure `json:"measure"`
|
||||
}
|
||||
|
||||
type UpdateMeetingInput struct {
|
||||
MeetingID gid.GID `json:"meetingId"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
Date *time.Time `json:"date,omitempty"`
|
||||
AttendeeIds []gid.GID `json:"attendeeIds,omitempty"`
|
||||
Minutes graphql.Omittable[*string] `json:"minutes,omitempty"`
|
||||
}
|
||||
|
||||
type UpdateMeetingPayload struct {
|
||||
Meeting *Meeting `json:"meeting"`
|
||||
}
|
||||
|
||||
type UpdateNonconformityInput struct {
|
||||
ID gid.GID `json:"id"`
|
||||
ReferenceID *string `json:"referenceId,omitempty"`
|
||||
@@ -1991,6 +2049,15 @@ type UpdateObligationPayload struct {
|
||||
Obligation *Obligation `json:"obligation"`
|
||||
}
|
||||
|
||||
type UpdateOrganizationContextInput struct {
|
||||
OrganizationID gid.GID `json:"organizationId"`
|
||||
Summary graphql.Omittable[*string] `json:"summary,omitempty"`
|
||||
}
|
||||
|
||||
type UpdateOrganizationContextPayload struct {
|
||||
Context *OrganizationContext `json:"context"`
|
||||
}
|
||||
|
||||
type UpdateOrganizationInput struct {
|
||||
OrganizationID gid.GID `json:"organizationId"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
|
||||
@@ -1177,6 +1177,68 @@ func (r *measureConnectionResolver) TotalCount(ctx context.Context, obj *types.M
|
||||
panic(fmt.Errorf("unsupported resolver: %T", obj.Resolver))
|
||||
}
|
||||
|
||||
// Attendees is the resolver for the attendees field.
|
||||
func (r *meetingResolver) Attendees(ctx context.Context, obj *types.Meeting) ([]*types.People, error) {
|
||||
prb := r.ProboService(ctx, obj.ID.TenantID())
|
||||
|
||||
attendees, err := prb.Meetings.GetAttendees(ctx, obj.ID)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("cannot load meeting attendees: %w", err))
|
||||
}
|
||||
|
||||
if len(attendees) == 0 {
|
||||
return []*types.People{}, nil
|
||||
}
|
||||
|
||||
people := make([]*types.People, len(attendees))
|
||||
for i, attendee := range attendees {
|
||||
people[i] = types.NewPeople(attendee)
|
||||
}
|
||||
|
||||
return people, nil
|
||||
}
|
||||
|
||||
// Organization is the resolver for the organization field.
|
||||
func (r *meetingResolver) Organization(ctx context.Context, obj *types.Meeting) (*types.Organization, error) {
|
||||
prb := r.ProboService(ctx, obj.ID.TenantID())
|
||||
|
||||
meeting, err := prb.Meetings.Get(ctx, obj.ID)
|
||||
if err != nil {
|
||||
var errNotFound *coredata.ErrMeetingNotFound
|
||||
if errors.As(err, &errNotFound) {
|
||||
return nil, gqlutils.NotFound(errNotFound)
|
||||
}
|
||||
panic(fmt.Errorf("cannot load meeting: %w", err))
|
||||
}
|
||||
|
||||
organization, err := prb.Organizations.Get(ctx, meeting.OrganizationID)
|
||||
if err != nil {
|
||||
var errNotFound *coredata.ErrOrganizationNotFound
|
||||
if errors.As(err, &errNotFound) {
|
||||
return nil, gqlutils.NotFound(errNotFound)
|
||||
}
|
||||
panic(fmt.Errorf("cannot load organization: %w", err))
|
||||
}
|
||||
|
||||
return types.NewOrganization(organization), nil
|
||||
}
|
||||
|
||||
// TotalCount is the resolver for the totalCount field.
|
||||
func (r *meetingConnectionResolver) TotalCount(ctx context.Context, obj *types.MeetingConnection) (int, error) {
|
||||
prb := r.ProboService(ctx, obj.ParentID.TenantID())
|
||||
|
||||
switch obj.Resolver.(type) {
|
||||
case *organizationResolver:
|
||||
count, err := prb.Meetings.CountForOrganizationID(ctx, obj.ParentID)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("cannot count meetings: %w", err))
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
panic(fmt.Errorf("unsupported resolver: %T", obj.Resolver))
|
||||
}
|
||||
|
||||
// AuthMethod is the resolver for the authMethod field.
|
||||
func (r *membershipResolver) AuthMethod(ctx context.Context, obj *types.Membership) (coredata.UserAuthMethod, error) {
|
||||
session := SessionFromContext(ctx)
|
||||
@@ -1317,6 +1379,25 @@ func (r *mutationResolver) UpdateOrganization(ctx context.Context, input types.U
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UpdateOrganizationContext is the resolver for the updateOrganizationContext field.
|
||||
func (r *mutationResolver) UpdateOrganizationContext(ctx context.Context, input types.UpdateOrganizationContextInput) (*types.UpdateOrganizationContextPayload, error) {
|
||||
prb := r.ProboService(ctx, input.OrganizationID.TenantID())
|
||||
|
||||
req := probo.UpdateOrganizationContextRequest{
|
||||
OrganizationID: input.OrganizationID,
|
||||
Summary: UnwrapOmittable(input.Summary),
|
||||
}
|
||||
|
||||
organizationContext, err := prb.Organizations.UpdateContext(ctx, req)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("cannot update organization context: %w", err))
|
||||
}
|
||||
|
||||
return &types.UpdateOrganizationContextPayload{
|
||||
Context: types.NewOrganizationContext(organizationContext),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DeleteOrganizationHorizontalLogo is the resolver for the deleteOrganizationHorizontalLogo field.
|
||||
func (r *mutationResolver) DeleteOrganizationHorizontalLogo(ctx context.Context, input types.DeleteOrganizationHorizontalLogoInput) (*types.DeleteOrganizationHorizontalLogoPayload, error) {
|
||||
prb := r.ProboService(ctx, input.OrganizationID.TenantID())
|
||||
@@ -2841,6 +2922,71 @@ func (r *mutationResolver) DeleteDocument(ctx context.Context, input types.Delet
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreateMeeting is the resolver for the createMeeting field.
|
||||
func (r *mutationResolver) CreateMeeting(ctx context.Context, input types.CreateMeetingInput) (*types.CreateMeetingPayload, error) {
|
||||
prb := r.ProboService(ctx, input.OrganizationID.TenantID())
|
||||
|
||||
meeting, err := prb.Meetings.Create(
|
||||
ctx,
|
||||
probo.CreateMeetingRequest{
|
||||
OrganizationID: input.OrganizationID,
|
||||
Name: input.Name,
|
||||
Date: input.Date,
|
||||
AttendeeIDs: input.AttendeeIds,
|
||||
Minutes: input.Minutes,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("cannot create meeting: %w", err))
|
||||
}
|
||||
|
||||
return &types.CreateMeetingPayload{
|
||||
MeetingEdge: types.NewMeetingEdge(meeting, coredata.MeetingOrderFieldCreatedAt),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UpdateMeeting is the resolver for the updateMeeting field.
|
||||
func (r *mutationResolver) UpdateMeeting(ctx context.Context, input types.UpdateMeetingInput) (*types.UpdateMeetingPayload, error) {
|
||||
prb := r.ProboService(ctx, input.MeetingID.TenantID())
|
||||
|
||||
var attendeeIDs []gid.GID
|
||||
if input.AttendeeIds != nil {
|
||||
attendeeIDs = input.AttendeeIds
|
||||
}
|
||||
|
||||
meeting, err := prb.Meetings.Update(
|
||||
ctx,
|
||||
probo.UpdateMeetingRequest{
|
||||
MeetingID: input.MeetingID,
|
||||
Name: input.Name,
|
||||
Date: input.Date,
|
||||
AttendeeIDs: attendeeIDs,
|
||||
Minutes: UnwrapOmittable(input.Minutes),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("cannot update meeting: %w", err))
|
||||
}
|
||||
|
||||
return &types.UpdateMeetingPayload{
|
||||
Meeting: types.NewMeeting(meeting),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DeleteMeeting is the resolver for the deleteMeeting field.
|
||||
func (r *mutationResolver) DeleteMeeting(ctx context.Context, input types.DeleteMeetingInput) (*types.DeleteMeetingPayload, error) {
|
||||
prb := r.ProboService(ctx, input.MeetingID.TenantID())
|
||||
|
||||
err := prb.Meetings.Delete(ctx, input.MeetingID)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("cannot delete meeting: %w", err))
|
||||
}
|
||||
|
||||
return &types.DeleteMeetingPayload{
|
||||
DeletedMeetingID: input.MeetingID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// PublishDocumentVersion is the resolver for the publishDocumentVersion field.
|
||||
func (r *mutationResolver) PublishDocumentVersion(ctx context.Context, input types.PublishDocumentVersionInput) (*types.PublishDocumentVersionPayload, error) {
|
||||
prb := r.ProboService(ctx, input.DocumentID.TenantID())
|
||||
@@ -3560,7 +3706,7 @@ func (r *mutationResolver) CreateProcessingActivity(ctx context.Context, input t
|
||||
Recipients: input.Recipients,
|
||||
Location: input.Location,
|
||||
InternationalTransfers: input.InternationalTransfers,
|
||||
TransferSafeguard: input.TransferSafeguard,
|
||||
TransferSafeguard: input.TransferSafeguards,
|
||||
RetentionPeriod: input.RetentionPeriod,
|
||||
SecurityMeasures: input.SecurityMeasures,
|
||||
DataProtectionImpactAssessment: input.DataProtectionImpactAssessment,
|
||||
@@ -4090,6 +4236,18 @@ func (r *organizationResolver) HorizontalLogoURL(ctx context.Context, obj *types
|
||||
return prb.Organizations.GenerateHorizontalLogoURL(ctx, obj.ID, 1*time.Hour)
|
||||
}
|
||||
|
||||
// Context is the resolver for the context field.
|
||||
func (r *organizationResolver) Context(ctx context.Context, obj *types.Organization) (*types.OrganizationContext, error) {
|
||||
prb := r.ProboService(ctx, obj.ID.TenantID())
|
||||
|
||||
orgContext, err := prb.Organizations.GetContextSummary(ctx, obj.ID)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("cannot load organization context: %w", err))
|
||||
}
|
||||
|
||||
return types.NewOrganizationContext(orgContext), nil
|
||||
}
|
||||
|
||||
// Memberships is the resolver for the memberships field.
|
||||
func (r *organizationResolver) Memberships(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.MembershipOrderBy) (*types.MembershipConnection, error) {
|
||||
pageOrderBy := page.OrderBy[coredata.MembershipOrderField]{
|
||||
@@ -4311,6 +4469,31 @@ func (r *organizationResolver) Documents(ctx context.Context, obj *types.Organiz
|
||||
return types.NewDocumentConnection(page, r, obj.ID, documentFilter), nil
|
||||
}
|
||||
|
||||
// Meetings is the resolver for the meetings field.
|
||||
func (r *organizationResolver) Meetings(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.MeetingOrderBy) (*types.MeetingConnection, error) {
|
||||
prb := r.ProboService(ctx, obj.ID.TenantID())
|
||||
|
||||
pageOrderBy := page.OrderBy[coredata.MeetingOrderField]{
|
||||
Field: coredata.MeetingOrderFieldCreatedAt,
|
||||
Direction: page.OrderDirectionDesc,
|
||||
}
|
||||
if orderBy != nil {
|
||||
pageOrderBy = page.OrderBy[coredata.MeetingOrderField]{
|
||||
Field: orderBy.Field,
|
||||
Direction: orderBy.Direction,
|
||||
}
|
||||
}
|
||||
|
||||
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
|
||||
|
||||
page, err := prb.Meetings.ListForOrganizationID(ctx, obj.ID, cursor)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("cannot list organization meetings: %w", err))
|
||||
}
|
||||
|
||||
return types.NewMeetingConnection(page, r, obj.ID), nil
|
||||
}
|
||||
|
||||
// Measures is the resolver for the measures field.
|
||||
func (r *organizationResolver) Measures(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.MeasureOrderBy, filter *types.MeasureFilter) (*types.MeasureConnection, error) {
|
||||
prb := r.ProboService(ctx, obj.ID.TenantID())
|
||||
@@ -5003,6 +5186,17 @@ func (r *queryResolver) Node(ctx context.Context, id gid.GID) (types.Node, error
|
||||
}
|
||||
|
||||
return types.NewTrustCenterAccess(trustCenterAccess), nil
|
||||
case coredata.MeetingEntityType:
|
||||
meeting, err := prb.Meetings.Get(ctx, id)
|
||||
if err != nil {
|
||||
var errNotFound *coredata.ErrMeetingNotFound
|
||||
if errors.As(err, &errNotFound) {
|
||||
return nil, gqlutils.NotFound(errNotFound)
|
||||
}
|
||||
panic(fmt.Errorf("cannot get meeting: %w", err))
|
||||
}
|
||||
|
||||
return types.NewMeeting(meeting), nil
|
||||
default:
|
||||
}
|
||||
|
||||
@@ -6272,6 +6466,14 @@ func (r *Resolver) MeasureConnection() schema.MeasureConnectionResolver {
|
||||
return &measureConnectionResolver{r}
|
||||
}
|
||||
|
||||
// Meeting returns schema.MeetingResolver implementation.
|
||||
func (r *Resolver) Meeting() schema.MeetingResolver { return &meetingResolver{r} }
|
||||
|
||||
// MeetingConnection returns schema.MeetingConnectionResolver implementation.
|
||||
func (r *Resolver) MeetingConnection() schema.MeetingConnectionResolver {
|
||||
return &meetingConnectionResolver{r}
|
||||
}
|
||||
|
||||
// Membership returns schema.MembershipResolver implementation.
|
||||
func (r *Resolver) Membership() schema.MembershipResolver { return &membershipResolver{r} }
|
||||
|
||||
@@ -6449,6 +6651,8 @@ type invitationResolver struct{ *Resolver }
|
||||
type invitationConnectionResolver struct{ *Resolver }
|
||||
type measureResolver struct{ *Resolver }
|
||||
type measureConnectionResolver struct{ *Resolver }
|
||||
type meetingResolver struct{ *Resolver }
|
||||
type meetingConnectionResolver struct{ *Resolver }
|
||||
type membershipResolver struct{ *Resolver }
|
||||
type membershipConnectionResolver struct{ *Resolver }
|
||||
type mutationResolver struct{ *Resolver }
|
||||
|
||||
@@ -63,7 +63,7 @@ func NoHTML() ValidatorFunc {
|
||||
|
||||
// PrintableText validates that a string contains only printable UTF-8 characters.
|
||||
// It rejects:
|
||||
// - Control characters (including null bytes, tabs, line breaks except space)
|
||||
// - Control characters (0x00-0x1F and 0x7F-0x9F, including null bytes and tabs, but allows newlines and carriage returns)
|
||||
// - Unicode direction override characters (RLO, LRO, PDF, etc.)
|
||||
// - Zero-width characters (ZWSP, ZWNJ, ZWJ, etc.)
|
||||
// - Other invisible or formatting characters
|
||||
@@ -71,8 +71,8 @@ func NoHTML() ValidatorFunc {
|
||||
// - Replacement characters
|
||||
//
|
||||
// This validator does NOT check for HTML tags - use NoHTML() for that.
|
||||
// This is ideal for validating titles, full names, display names, and similar text fields
|
||||
// where only printable characters should be allowed.
|
||||
// This validator allows line breaks (newline and carriage return) for multi-line text fields.
|
||||
// Use NoNewLine() or SafeTextNoNewLine() for single-line fields that should reject line breaks.
|
||||
func PrintableText() ValidatorFunc {
|
||||
return func(value any) *ValidationError {
|
||||
actualValue, isNil := dereferenceValue(value)
|
||||
@@ -96,7 +96,12 @@ func PrintableText() ValidatorFunc {
|
||||
continue
|
||||
}
|
||||
|
||||
// Reject control characters (0x00-0x1F and 0x7F-0x9F)
|
||||
// Allow newline (0x0A) and carriage return (0x0D) for multi-line text
|
||||
if r == '\n' || r == '\r' {
|
||||
continue
|
||||
}
|
||||
|
||||
// Reject control characters (0x00-0x1F and 0x7F-0x9F), except newline and carriage return
|
||||
if r < 0x20 || (r >= 0x7F && r < 0xA0) {
|
||||
return newValidationError(ErrorCodeInvalidFormat, fmt.Sprintf("contains invalid control character at position %d", i))
|
||||
}
|
||||
@@ -152,8 +157,46 @@ func PrintableText() ValidatorFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// NoNewLine validates that a string does not contain newline or carriage return characters.
|
||||
// It rejects:
|
||||
// - Newline characters (\n, 0x0A)
|
||||
// - Carriage return characters (\r, 0x0D)
|
||||
//
|
||||
// This is useful for validating single-line fields like names and titles where line breaks
|
||||
// should not be allowed.
|
||||
func NoNewLine() ValidatorFunc {
|
||||
return func(value any) *ValidationError {
|
||||
actualValue, isNil := dereferenceValue(value)
|
||||
if isNil {
|
||||
return nil
|
||||
}
|
||||
|
||||
str, ok := actualValue.(string)
|
||||
if !ok {
|
||||
return newValidationError(ErrorCodeInvalidFormat, "value must be a string")
|
||||
}
|
||||
|
||||
if str == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
for i, r := range str {
|
||||
if r == '\n' {
|
||||
return newValidationError(ErrorCodeInvalidFormat, fmt.Sprintf("contains newline character at position %d", i))
|
||||
}
|
||||
if r == '\r' {
|
||||
return newValidationError(ErrorCodeInvalidFormat, fmt.Sprintf("contains carriage return character at position %d", i))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// SafeText validates that a string is non-empty, bounded, and contains only safe content.
|
||||
// It combines NotEmpty, MaxLen, NoHTML, and PrintableText validators.
|
||||
// This allows newlines and carriage returns for multi-line text fields.
|
||||
// Use SafeTextNoNewLine for single-line field validation that should reject line breaks.
|
||||
func SafeText(maxLen int) ValidatorFunc {
|
||||
validators := []ValidatorFunc{
|
||||
NotEmpty(),
|
||||
@@ -171,3 +214,25 @@ func SafeText(maxLen int) ValidatorFunc {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// SafeTextNoNewLine validates that a string is non-empty, bounded, and contains only safe content
|
||||
// without newlines or carriage returns. It combines NotEmpty, MaxLen, NoHTML, PrintableText, and NoNewLine validators.
|
||||
// This is ideal for validating single-line fields like names, titles, and display names.
|
||||
func SafeTextNoNewLine(maxLen int) ValidatorFunc {
|
||||
validators := []ValidatorFunc{
|
||||
NotEmpty(),
|
||||
MaxLen(maxLen),
|
||||
NoHTML(),
|
||||
PrintableText(),
|
||||
NoNewLine(),
|
||||
}
|
||||
|
||||
return func(value any) *ValidationError {
|
||||
for _, validator := range validators {
|
||||
if err := validator(value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -381,19 +381,27 @@ func TestPrintableText(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid - newline character", func(t *testing.T) {
|
||||
t.Run("valid - newline character", func(t *testing.T) {
|
||||
str := "test\ntext"
|
||||
err := PrintableText()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for newline character")
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for newline character, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid - carriage return", func(t *testing.T) {
|
||||
t.Run("valid - carriage return", func(t *testing.T) {
|
||||
str := "test\rtext"
|
||||
err := PrintableText()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for carriage return")
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for carriage return, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid - multiple newlines", func(t *testing.T) {
|
||||
str := "hello foo\nbar\n\njd"
|
||||
err := PrintableText()(&str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for multiple newlines, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -645,11 +653,19 @@ func TestSafeText(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid - contains newline", func(t *testing.T) {
|
||||
t.Run("valid - contains newline", func(t *testing.T) {
|
||||
str := "test\ntext"
|
||||
err := SafeText(100)(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for newline")
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for newline, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid - contains multiple newlines", func(t *testing.T) {
|
||||
str := "hello foo\nbar\n\njd"
|
||||
err := SafeText(100)(&str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for multiple newlines, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -753,3 +769,157 @@ func TestSafeText(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestNoNewLine(t *testing.T) {
|
||||
t.Run("valid text without newlines", func(t *testing.T) {
|
||||
str := "Product Name 2024"
|
||||
err := NoNewLine()(&str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid - contains newline", func(t *testing.T) {
|
||||
str := "Line 1\nLine 2"
|
||||
err := NoNewLine()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for newline")
|
||||
}
|
||||
if !strings.Contains(err.Message, "newline") {
|
||||
t.Errorf("unexpected error message: %s", err.Message)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid - contains carriage return", func(t *testing.T) {
|
||||
str := "Line 1\rLine 2"
|
||||
err := NoNewLine()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for carriage return")
|
||||
}
|
||||
if !strings.Contains(err.Message, "carriage return") {
|
||||
t.Errorf("unexpected error message: %s", err.Message)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid - contains both newline and carriage return", func(t *testing.T) {
|
||||
str := "Line 1\n\rLine 3"
|
||||
err := NoNewLine()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for newline or carriage return")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil pointer", func(t *testing.T) {
|
||||
var str *string
|
||||
err := NoNewLine()(str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for nil pointer, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty string", func(t *testing.T) {
|
||||
str := ""
|
||||
err := NoNewLine()(&str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for empty string, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSafeTextNoNewLine(t *testing.T) {
|
||||
t.Run("valid text", func(t *testing.T) {
|
||||
str := "Product Name 2024"
|
||||
err := SafeTextNoNewLine(100)(&str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid UTF-8 text", func(t *testing.T) {
|
||||
str := "José García"
|
||||
err := SafeTextNoNewLine(50)(&str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid - contains newline", func(t *testing.T) {
|
||||
str := "Line 1\nLine 2"
|
||||
err := SafeTextNoNewLine(100)(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for newline")
|
||||
}
|
||||
if !strings.Contains(err.Message, "newline") {
|
||||
t.Errorf("unexpected error message: %s", err.Message)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid - contains carriage return", func(t *testing.T) {
|
||||
str := "Line 1\rLine 2"
|
||||
err := SafeTextNoNewLine(100)(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for carriage return")
|
||||
}
|
||||
if !strings.Contains(err.Message, "carriage return") {
|
||||
t.Errorf("unexpected error message: %s", err.Message)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid - empty string", func(t *testing.T) {
|
||||
str := ""
|
||||
err := SafeTextNoNewLine(100)(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for empty string")
|
||||
}
|
||||
if !strings.Contains(err.Message, "empty") && !strings.Contains(err.Message, "required") {
|
||||
t.Errorf("unexpected error message: %s", err.Message)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid - exceeds max length", func(t *testing.T) {
|
||||
str := "This is a very long string that exceeds the maximum length"
|
||||
err := SafeTextNoNewLine(10)(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for exceeding max length")
|
||||
}
|
||||
if !strings.Contains(err.Message, "at most") {
|
||||
t.Errorf("unexpected error message: %s", err.Message)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid - contains HTML tags", func(t *testing.T) {
|
||||
str := "Hello <b>World</b>"
|
||||
err := SafeTextNoNewLine(100)(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for HTML tags")
|
||||
}
|
||||
if !strings.Contains(err.Message, "HTML tags") {
|
||||
t.Errorf("unexpected error message: %s", err.Message)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid - contains tab character", func(t *testing.T) {
|
||||
str := "test\ttext"
|
||||
err := SafeTextNoNewLine(100)(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for tab character")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil pointer", func(t *testing.T) {
|
||||
var str *string
|
||||
err := SafeTextNoNewLine(100)(str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for nil pointer, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("edge case - exactly at max length", func(t *testing.T) {
|
||||
str := "12345"
|
||||
err := SafeTextNoNewLine(5)(&str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for string at max length, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user