update and refactor actions and interactions

This commit is contained in:
liquidrinu 2025-05-13 02:04:21 +02:00
parent 3111f56571
commit fc403142c2
36 changed files with 1193 additions and 1200 deletions

@ -1,215 +0,0 @@
// import React, { useEffect, useState, useRef } from 'react';
// import { Button } from 'primereact/button';
// import { Toast } from 'primereact/toast';
// import { api } from '../services/axios';
// import { Dialog } from 'primereact/dialog';
// import { useNavigate } from 'react-router-dom';
// import { useSettingsStore } from '../state/stores/useSettingsStore';
// type UserInfo = {
// role: string;
// tenantId: string | number;
// };
// const ActivateConnectionModal = () => {
// const [loading, setLoading] = useState(false);
// const [showDialog, setShowDialog] = useState(false);
// const toast = useRef<Toast>(null);
// const [fetched, setFetched] = useState(false);
// const [systemName, setSystemName] = useState('');
// const tenantId = localStorage.getItem('tenantId');
// const systemId = localStorage.getItem('systemId');
// const navigate = useNavigate();
// const [userInfo, setUserInfo] = useState<UserInfo>({
// role: '',
// tenantId: -1,
// });
// const [messageShown, setMessageShown] = useState<boolean>(false);
// const { setConnectionId } = useSettingsStore();
// useEffect(() => {
// // TODO: similar logic used in other components, refactor into user context/state/hook
// const storedUser = localStorage.getItem('user');
// const storedSystemName = localStorage.getItem('systemName');
// if (storedUser) {
// const parsedUser = JSON.parse(storedUser);
// let role = parsedUser.roles?.includes('admin')
// ? 'admin'
// : parsedUser.roles?.includes('user')
// ? 'user'
// : '';
// let tenantId =
// role === 'admin' ? localStorage.getItem('tenantId') || -1 : -1;
// let userInfo: UserInfo = {
// role: role,
// tenantId: tenantId,
// };
// setUserInfo(userInfo);
// }
// if (storedSystemName) {
// setSystemName(storedSystemName);
// }
// }, []);
// useEffect(() => {
// if (userInfo.role && userInfo.tenantId !== undefined && !fetched) {
// fetchConnection();
// }
// }, [userInfo]);
// const fetchConnection = async () => {
// setLoading(true);
// try {
// let response: any;
// if (userInfo.role === 'user') {
// response = await api('get', `/connections?systemName=${systemName}`);
// } else if (userInfo.role === 'admin' && userInfo.tenantId) {
// response = await api(
// 'get',
// `/connections?systemName=${systemName}&tenantId=${userInfo.tenantId}`,
// );
// } else {
// throw new Error('Invalid user role or missing tenantId');
// }
// const connections = response.data.data;
// // Localstorage saves undefined as string
// if (tenantId === 'undefined' || systemId === 'undefined') {
// navigate('/systems');
// }
// if (!connections || connections.length === 0) {
// setShowDialog(true);
// } else if (userInfo.role === 'user') {
// setUserInfo((prevUserInfo) => ({
// ...prevUserInfo,
// tenantId: connections[0].tenant_fleks_id,
// }));
// }
// const connectionId = connections[0].id;
// localStorage.setItem('connectionId', connectionId);
// setConnectionId(connectionId);
// navigate(`/dashboard/${systemName}`);
// setLoading(false);
// setFetched(true);
// } catch (error) {
// console.error('Error:', error);
// setLoading(false);
// if (!messageShown) {
// setMessageShown(true);
// }
// }
// };
// const handleActivateClick = async () => {
// setLoading(true);
// const systemId = localStorage.getItem('systemId') || null;
// try {
// let postResponse: any;
// let requestBody: any = {
// system_id: Number(systemId),
// };
// if (!systemId) {
// throw new Error('Missing systemId');
// }
// if (userInfo.role === 'admin' && userInfo.tenantId) {
// requestBody.tenant_id = userInfo.tenantId;
// }
// postResponse = await api('post', '/connections', requestBody);
// if (postResponse.status === 201) {
// const connectionId = postResponse.data.id;
// localStorage.setItem('connectionId', connectionId);
// setConnectionId(connectionId);
// navigate(`/dashboard/${systemName}`);
// setLoading(false);
// setShowDialog(false);
// toast.current.show({
// severity: 'success',
// summary: 'Success',
// detail: 'Connection established successfully.',
// life: 3000,
// });
// } else {
// setLoading(false);
// toast.current.show({
// severity: 'error',
// summary: 'Error',
// detail: 'Failed to establish connection.',
// life: 3000,
// });
// }
// } catch (error) {
// if (error.message === 'Missing systemId') {
// toast.current.show({
// severity: 'error',
// summary: 'Error',
// detail: 'Missing system information. Please verify.',
// life: 3000,
// });
// } else {
// console.error('Error:', error);
// }
// }
// };
// const handleCancelClick = () => {
// setShowDialog(false);
// navigate('/systems');
// };
// return (
// <div className="flex items-center justify-center h-screen overflow-y-hidden bg-gradient-to-br from-purple-500 to-indigo-500">
// <div className="fixed inset-0 flex items-center justify-center overflow-y-hidden">
// <div className="transition-transform" style={{ transform: 'none' }}>
// <Dialog
// visible={showDialog}
// onHide={() => setShowDialog(false)}
// header="Activate Connection"
// footer={
// <div>
// <Button
// label="Activate"
// className="p-button-primary"
// onClick={handleActivateClick}
// disabled={loading}
// />
// <Button
// label="Cancel"
// className="p-button-secondary"
// onClick={handleCancelClick}
// disabled={loading}
// />
// </div>
// }
// >
// {loading ? (
// <p>Performing the post request, please wait...</p>
// ) : (
// <p>Are you sure you want to activate the connection?</p>
// )}
// </Dialog>
// </div>
// </div>
// </div>
// );
// };
// export default ActivateConnectionModal;

@ -1,372 +0,0 @@
import React from 'react';
import { DataTable } from 'primereact/datatable';
import { Column } from 'primereact/column';
import { Card } from 'primereact/card';
interface CanvasData {
[key: string]: any;
}
interface CanvasDataTableProps {
data: CanvasData[];
loading: boolean;
error: string | null;
}
const CanvasDataTable: React.FC<CanvasDataTableProps> = ({
data,
loading,
errorimport { Column } from 'primereact/column';
import { FilterMatchMode, FilterOperator } from 'primereact/api';
import { useEffect, useRef, useState } from 'react';
import { api } from '../../services/api.ts';
import TableGroupHeader from '../../shared/components/_V1/TableGroupHeader.tsx';
import TableGroup from '../../shared/components/_V1/TableGroup.tsx';
import { Toast } from 'primereact/toast';
import ConfirmModal from '../../shared/components/modals/ConfirmModal.tsx';
import CreateVoucherModal from './CreateVoucherModal.tsx';
// import EditVoucherModal from './AddVoucherCodeModal.tsx';
import LoadingPage from '../../shared/components/_V1/LoadingPage.tsx';
import DetailModal from './DetailModal.tsx';
import AddVoucherCodeModal, { AddVoucherCodeModalProps } from './AddVoucherCodeModal.tsx';
import { Voucher, useVoucherStore } from '../../state/stores/Mews/useMewsVoucherStore.ts';
import { TSizeOptionValue } from '../../types/DataTable.types.ts';
import Button from '../../shared/components/_V2/Button.tsx';
export default function VoucherTableGroup() {
const { createVoucher, fetchVouchers, updateVoucher, vouchers, addVoucherCode } =
useVoucherStore();
const [filters, setFilters] = useState(null);
const [globalFilterValue, setGlobalFilterValue] = useState('');
const [showCreateVoucherModal, setShowCreateVoucherModal] = useState(false);
const [showConfirmDeleteModal, setShowConfirmDeleteModal] = useState(false);
const [showAddCodeModal, setShowAddCodeModal] = useState(false);
const [currentVoucherForCode, setCurrentVoucherForCode] = useState(null);
const [detailModalVisible, setDetailModalVisible] = useState(false);
const [detailContent, setDetailContent] = useState([]);
const [detailTitle, setDetailTitle] = useState('');
const [selectedVouchers, setSelectedVouchers] = useState<Voucher[]>(null);
const toast = useRef<Toast>(null);
const [isLoading, setIsLoading] = useState(false);
// Change table row size
const sizeOptions: { label: string; value: TSizeOptionValue }[] = [
{ label: 'Small', value: 'small' },
{ label: 'Normal', value: 'normal' },
{ label: 'Large', value: 'large' },
];
const [size, setSize] = useState<TSizeOptionValue>(sizeOptions[0].value);
useEffect(() => {
initFilters();
// fetchVouchers;
handleRefresh();
}, []);
const showToastMessage = (options) => {
toast.current?.show(options);
};
const initFilters = () => {
setFilters({
global: { value: null, matchMode: FilterMatchMode.CONTAINS },
representative: { value: null, matchMode: FilterMatchMode.IN },
createdAt: {
operator: FilterOperator.AND,
constraints: [{ value: null, matchMode: FilterMatchMode.DATE_IS }],
},
});
setGlobalFilterValue('');
};
const clearFilter = () => {
initFilters();
};
const onGlobalFilterChange = (e: { target: { value: any } }) => {
const value = e.target.value;
// @ts-ignore
let _filters = { ...filters };
_filters['global'].value = value;
setFilters(_filters);
setGlobalFilterValue(value);
};
// TODO: refresh data
const handleRefresh = async () => {
setIsLoading(true);
await fetchVouchers();
setIsLoading(false);
};
const formatDate = (value: string | number | Date) => {
const date = new Date(value);
return date.toLocaleDateString('en-US', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
};
const dateBodyTemplate = (rowData: { createdAt: string | number | Date }) => {
return formatDate(rowData.createdAt);
};
const actionButtons = () => {
return (
<div className={'flex gap-3'}>
<Button text={true} onClick={() => setShowCreateVoucherModal(true)}>
Add Voucher
</Button>
{/* <Button
onClick={() => setShowConfirmDeleteModal(true)}
severity={'danger'}
disabled={selectedVouchers == null || selectedVouchers.length === 0}
>
Delete selected
</Button> */}
</div>
);
};
const editButton = (rowData: Voucher) => {
return <Button>action</Button>;
};
const openDetailModal = (content, title) => {
setDetailContent(content);
setDetailTitle(title);
setDetailModalVisible(true);
};
const closeDetailModal = () => {
setDetailModalVisible(false);
};
const handleCreateVoucher = async (newVoucher: Voucher) => {
await createVoucher(newVoucher);
handleRefresh();
setShowCreateVoucherModal(false);
};
const handleDeleteSelectedVouchers = () => {
const URL = '/neppeURL';
const body = selectedVouchers.map((voucher) => {
return {
id: voucher.id,
};
});
api('delete', URL, body);
};
const voucherRatesBodyTemplate = (rowData) => {
if (rowData?.voucherCodes.length < 1) {
return '';
}
const details = rowData.rates.map((rate) => `Name: ${rate.name}, Type: ${rate.type}`);
return (
<Button
label='View Rates'
onClick={() => openDetailModal(details, 'Voucher Rates')}
disabled={isLoading}
/>
);
};
const voucherCodesBodyTemplate = (rowData) => {
const details = rowData.voucherCodes.join(', ');
return (
<div className='flex gap-2'>
<Button
label='View Codes'
onClick={() => openDetailModal([details], 'Voucher Codes')}
disabled={isLoading || rowData.voucherCodes.length === 0}
/>
<Button
label='Add Code'
onClick={() => {
setCurrentVoucherForCode(rowData);
setShowAddCodeModal(true);
}}
disabled={isLoading}
/>
</div>
);
};
const handleAddVoucherCode: AddVoucherCodeModalProps['addVoucherCode'] = async (
voucherId,
code
) => {
try {
await addVoucherCode(voucherId, code);
showToastMessage({
severity: 'success',
summary: 'Code Added',
detail: 'Voucher code has been successfully added.',
});
setShowAddCodeModal(false);
handleRefresh();
} catch (error) {
showToastMessage({
severity: 'error',
summary: 'Error',
detail: 'Failed to add voucher code.',
});
console.error('Failed to add voucher code:', error);
}
};
const renderHeader = () => {
return (
<TableGroupHeader
size={size}
setSize={setSize}
sizeOptions={sizeOptions}
globalFilterValue={globalFilterValue}
onGlobalFilterChange={(e: any) => onGlobalFilterChange(e)}
onRefresh={handleRefresh}
clearFilter={clearFilter}
/>
);
};
const voucherCodeSortFunction = (e) => {
const result = e.data.sort((data1, data2) => {
const codes1 = data1.voucherCodes.length;
const codes2 = data2.voucherCodes.length;
return e.order * (codes1 - codes2);
});
return result;
};
return (
<div className='w-full h-full flex flex-col'>
<CreateVoucherModal
visible={showCreateVoucherModal}
onHide={() => setShowCreateVoucherModal(false)}
// @ts-ignore
onConfirm={handleCreateVoucher}
/>
<AddVoucherCodeModal
visible={showAddCodeModal}
onHide={() => setShowAddCodeModal(false)}
addVoucherCode={handleAddVoucherCode} // Pass the function directly
voucherId={currentVoucherForCode?.id || ''}
/>
<ConfirmModal
visible={showConfirmDeleteModal}
header={`Delete ${selectedVouchers?.length} voucher(s)`}
message={`Are you sure?`}
onHide={() => setShowConfirmDeleteModal(false)}
onConfirm={handleDeleteSelectedVouchers}
confirmLabel={'Confirm'}
cancelLabel={'Cancel'}
onCancel={() => setShowConfirmDeleteModal(false)}
/>
<DetailModal
visible={detailModalVisible}
onHide={closeDetailModal}
title={detailTitle}
details={detailContent}
/>
{isLoading ? (
<LoadingPage />
) : (
<TableGroup
body={vouchers}
size={size}
header={renderHeader}
filters={filters}
rows={10}
paginator
// stripedRows={true}
showGridlines={true}
removableSort={true}
dragSelection={false}
selectionMode={'multiple'}
selection={selectedVouchers}
onSelectionChange={(e: any) => setSelectedVouchers(e.value)}
emptyMessage='No vouchers found.'
>
<Column selectionMode='multiple' headerStyle={{ width: '3rem' }}></Column>
<Column field='id' header={actionButtons} sortable filter style={{ width: '40%' }} />
<Column field='name' header='Name' sortable filter style={{ width: '20%' }} />
<Column field='type' header='Type' sortable filter style={{ width: '20%' }} />
<Column
field='voucherCodes'
header='Voucher Codes'
body={voucherCodesBodyTemplate}
sortable
filter
sortFunction={voucherCodeSortFunction}
className='h-full align-top bg-red'
style={{ width: '20%', textAlign: 'center', lineHeight: '20px' }}
/>
<Column
field='rates'
header='Voucher Rates'
body={voucherRatesBodyTemplate}
sortable
filter
style={{ width: '20%' }}
/>
{/* <Column
field='actions'
header='Actions'
body={editButton}
style={{ width: '15%', textAlign: 'center' }}
/> */}
</TableGroup>
)}
</div>
);
}
}) => {
// Dynamically generate columns based on the first data item
const columns = data.length > 0
? Object.keys(data[0]).map(key => (
<Column
key={key}
field={key}
header={key.charAt(0).toUpperCase() + key.slice(1).replace(/_/g, ' ')}
sortable
/>
))
: [];
return (
<Card className="flex-grow mb-4">
<DataTable
value={data}
loading={loading}
emptyMessage={error || "No data found. Try using the prompt below."}
className="p-datatable-sm"
paginator
rows={10}
rowsPerPageOptions={[10, 25, 50]}
>
{columns}
</DataTable>
</Card>
);
};
export default CanvasDataTable;

@ -1,120 +0,0 @@
import React, { useState } from 'react';
import { Dialog } from 'primereact/dialog';
import { InputText } from 'primereact/inputtext';
import { Button } from 'primereact/button';
import { ScrollPanel } from 'primereact/scrollpanel';
interface Message {
role: 'user' | 'assistant';
content: string;
}
interface ChatGptModalProps {
visible: boolean;
onHide: () => void;
}
const ChatGptModal: React.FC<ChatGptModalProps> = ({ visible, onHide }) => {
const [prompt, setPrompt] = useState('');
const [messages, setMessages] = useState<Message[]>([]);
const [loading, setLoading] = useState(false);
const handleSubmit = async () => {
if (!prompt.trim()) return;
setLoading(true);
const userMessage: Message = { role: 'user', content: prompt };
setMessages(prev => [...prev, userMessage]);
try {
const response = await fetch('/canvas-api/chatgpt/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
data: prompt,
}),
});
const data = await response.json();
if (data.errorMessage) {
throw new Error(data.errorMessage);
}
const assistantMessage: Message = {
role: 'assistant',
content: typeof data.responseText === 'string'
? data.responseText
: JSON.stringify(data.responseText, null, 2)
};
setMessages(prev => [...prev, assistantMessage]);
} catch (error) {
console.error('Error:', error);
const errorMessage: Message = {
role: 'assistant',
content: `Error: ${error instanceof Error ? error.message : 'Failed to get response'}`
};
setMessages(prev => [...prev, errorMessage]);
} finally {
setLoading(false);
setPrompt('');
}
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
};
return (
<Dialog
visible={visible}
onHide={onHide}
header="ChatGPT Assistant"
style={{ width: '50vw' }}
className="chatgpt-modal"
>
<div className="flex flex-col h-[60vh]">
<ScrollPanel style={{ width: '100%', height: 'calc(100% - 60px)' }} className="mb-4">
<div className="flex flex-col gap-4 p-4">
{messages.map((message, index) => (
<div
key={index}
className={`p-3 rounded-lg max-w-[80%] ${message.role === 'user'
? 'bg-blue-100 ml-auto'
: 'bg-gray-100'
}`}
>
<p className="whitespace-pre-wrap">{message.content}</p>
</div>
))}
</div>
</ScrollPanel>
<div className="flex gap-2 mt-auto">
<InputText
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="Type your message..."
className="flex-1"
disabled={loading}
/>
<Button
icon="pi pi-send"
onClick={handleSubmit}
loading={loading}
disabled={!prompt.trim() || loading}
/>
</div>
</div>
</Dialog>
);
};
export default ChatGptModal;

@ -1,37 +0,0 @@
import React from 'react';
import { InputText } from 'primereact/inputtext';
import { Button } from 'primereact/button';
interface PromptInputProps {
value: string;
onChange: (value: string) => void;
onSubmit: () => void;
placeholder?: string;
}
const PromptInput: React.FC<PromptInputProps> = ({
value,
onChange,
onSubmit,
placeholder = "Type your prompt here"
}) => {
return (
<div className="flex items-center gap-2 p-4 border-t">
<InputText
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="flex-grow"
onKeyPress={(e) => e.key === 'Enter' && onSubmit()}
/>
<Button
label="Send"
icon="pi pi-send"
onClick={onSubmit}
disabled={!value.trim()}
/>
</div>
);
};
export default PromptInput;

@ -1,33 +0,0 @@
import React from 'react';
import { Dropdown } from 'primereact/dropdown';
export type SystemType = 'council' | 'fusemind' | 'canvas';
interface SystemSelectorProps {
value: SystemType | null;
onChange: (value: SystemType | null) => void;
}
const systemOptions = [
{ label: 'CouncilAI', value: 'council' },
{ label: 'FuseMind (AI)', value: 'fusemind' },
{ label: 'Canvas API', value: 'canvas' }
];
const SystemSelector: React.FC<SystemSelectorProps> = ({ value, onChange }) => {
return (
<div className="flex flex-col gap-2 p-4 border-b w-80">
<span className="font-semibold mb-1">Apps & Flows</span>
<Dropdown
value={value}
options={systemOptions}
onChange={(e) => onChange(e.value)}
placeholder="Select an integration"
className="w-full"
showClear
/>
</div>
);
};
export default SystemSelector;

@ -1,83 +0,0 @@
import { SystemType } from './SystemSelector';
interface CommandResult {
system: SystemType;
endpoint: string;
params?: Record<string, any>;
courseId?: number;
}
export const parseCommand = (prompt: string, system: SystemType): CommandResult | null => {
const lowerPrompt = prompt.toLowerCase().trim();
if (system === 'canvas') {
// Extract course ID if present (e.g., "get students in course 123")
const courseIdMatch = lowerPrompt.match(/course\s+(\d+)/);
const courseId = courseIdMatch ? parseInt(courseIdMatch[1], 10) : 1; // Default to 1 if not specified
if (lowerPrompt.includes('get all students')) {
return {
system: 'canvas',
endpoint: '/users',
params: {
enrollment_type: ['student'],
per_page: 100
},
courseId
};
}
if (lowerPrompt.includes('get courses')) {
return {
system: 'canvas',
endpoint: '/courses',
params: {
per_page: 100
}
};
}
if (lowerPrompt.includes('get assignments')) {
return {
system: 'canvas',
endpoint: '/assignments',
params: {
per_page: 100
},
courseId
};
}
} else if (system === 'council') {
if (lowerPrompt.includes('get all students')) {
return {
system: 'council',
endpoint: '/students',
params: {
limit: 100
}
};
}
if (lowerPrompt.includes('get courses')) {
return {
system: 'council',
endpoint: '/courses',
params: {
limit: 100
}
};
}
if (lowerPrompt.includes('get assignments')) {
return {
system: 'council',
endpoint: '/assignments',
params: {
limit: 100
}
};
}
}
return null;
};

@ -1,90 +0,0 @@
import React, { useState } from 'react';
import { Button } from 'primereact/button';
import { Column } from 'primereact/column';
import { FilterMatchMode } from 'primereact/api';
import { TSizeOptionValue } from '../../types/DataTable.types';
import { api } from '../../services/api';
import ChatGptModal from './ChatGptModal';
import { DataTable } from 'primereact/datatable';
import { Card } from 'primereact/card';
const CanvasEndpoints: React.FC = () => {
const [showChatGptModal, setShowChatGptModal] = useState(false);
const [endpoints, setEndpoints] = useState<any[]>([]);
const [filters, setFilters] = useState<any>(null);
const [globalFilterValue, setGlobalFilterValue] = useState('');
const [showCreateModal, setShowCreateModal] = useState(false);
const [newEndpoint, setNewEndpoint] = useState({ name: '', method: '', path: '', description: '' });
const sizeOptions: { label: string; value: TSizeOptionValue }[] = [
{ label: 'Small', value: 'small' },
{ label: 'Normal', value: 'normal' },
{ label: 'Large', value: 'large' },
];
const [size, setSize] = useState<TSizeOptionValue>(sizeOptions[0].value);
const fetchEndpoints = async () => {
try {
const response = await api('get', '/api/v1/canvas-api/endpoints');
setEndpoints(response.data);
} catch (error) {
console.error('Failed to fetch endpoints:', error);
}
};
const initFilters = () => {
setFilters({
global: { value: null, matchMode: FilterMatchMode.CONTAINS },
});
setGlobalFilterValue('');
};
const clearFilter = () => {
initFilters();
};
const onGlobalFilterChange = (e: { target: { value: any } }) => {
const value = e.target.value;
let _filters = { ...filters };
_filters['global'].value = value;
setFilters(_filters);
setGlobalFilterValue(value);
};
return (
<div className="p-4">
<Card className="mb-4">
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold mb-2">Canvas Endpoints</h1>
<p className="text-gray-400">Manage and test your Canvas API endpoints</p>
</div>
<div className="flex gap-2">
<Button
label="Create Endpoint"
icon="pi pi-plus"
onClick={() => setShowCreateModal(true)}
/>
<Button
label="Ask ChatGPT"
icon="pi pi-comments"
onClick={() => setShowChatGptModal(true)}
className="p-button-primary"
/>
</div>
</div>
</Card>
<DataTable value={endpoints} paginator rows={10} showGridlines responsiveLayout="scroll" emptyMessage="No endpoints found.">
<Column field="id" header="ID" sortable filter style={{ width: '10%' }} />
<Column field="name" header="Name" sortable filter style={{ width: '20%' }} />
<Column field="method" header="Method" sortable filter style={{ width: '10%' }} />
<Column field="path" header="Path" sortable filter style={{ width: '40%' }} />
</DataTable>
<ChatGptModal
visible={showChatGptModal}
onHide={() => setShowChatGptModal(false)}
/>
</div>
);
};
export default CanvasEndpoints;

@ -1,110 +0,0 @@
// import React, { useState, useEffect, useRef } from 'react';
// import { InputText } from 'primereact/inputtext';
// import { Button } from 'primereact/button';
// import { Toast } from 'primereact/toast';
// import { Dialog } from 'primereact/dialog';
// import { Card } from 'primereact/card';
// import io, { Socket } from 'socket.io-client';
// interface ChatProps {
// serverUrl: string;
// }
// const Chat: React.FC<ChatProps> = ({ serverUrl }) => {
// const [messages, setMessages] = useState<string[]>([]);
// const [inputText, setInputText] = useState('');
// const [showChat, setShowChat] = useState(false);
// const socketRef = useRef<Socket | null>(null);
// const toastRef = useRef<Toast | null>(null);
// useEffect(() => {
// socketRef.current = io(serverUrl);
// socketRef.current.emit('join', 'global');
// socketRef.current.on('connect', () => {
// showToast('success', 'Connected to the chat server');
// });
// socketRef.current.on('disconnect', () => {
// showToast('warn', 'Disconnected from the chat server');
// });
// socketRef.current.on('message', (message: string) => {
// setMessages((prevMessages) => [...prevMessages, message]);
// });
// return () => {
// if (socketRef.current) {
// socketRef.current.disconnect();
// }
// };
// }, [serverUrl]);
// const handleSendMessage = () => {
// if (inputText.trim() === '') {
// return;
// }
// if (socketRef.current) {
// socketRef.current.emit('message', inputText);
// }
// setInputText('');
// };
// const toggleChat = () => {
// setShowChat((prevShowChat) => !prevShowChat);
// };
// const showToast = (severity: any, detail: string) => {
// if (toastRef.current) {
// toastRef.current.show({
// severity,
// summary: severity === 'success' ? 'Success' : 'Warning',
// detail,
// life: 3000,
// });
// }
// };
// const handleInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
// if (event.key === 'Enter') {
// handleSendMessage();
// }
// };
// return (
// <>
// <div className="chat-icon" onClick={toggleChat}>
// <h3>Click to chat</h3>
// </div>
// <Dialog
// visible={showChat}
// onHide={toggleChat}
// className="chat-dialog"
// modal
// >
// <Card title="Chat" className="chat-card">
// <div className="chat-container">
// {messages.map((message, index) => (
// <div key={index} className="message">
// <span className="message-content">{message}</span>
// </div>
// ))}
// </div>
// <div className="input-container">
// <InputText
// value={inputText}
// onChange={(e) => setInputText(e.target.value)}
// onKeyDown={handleInputKeyDown}
// placeholder="Type a message..."
// />
// <Button label="Send" onClick={handleSendMessage} />
// </div>
// </Card>
// </Dialog>
// <Toast ref={toastRef} position="bottom-left" className="bottom-0" />{' '}
// </>
// );
// };
// export default Chat;

@ -1,85 +0,0 @@
import { DataTable } from 'primereact/datatable';
import { Column } from 'primereact/column';
import { useState, useEffect, useCallback } from 'react';
import { useChatStore } from '../state/stores/useChatStore';
interface ChatGPTModalProps {
content?: {
prompt: string;
};
}
interface TableData {
columns: Array<{
field: string;
header: string;
}>;
data: any[];
}
export const ChatGPTModal = ({ content }: ChatGPTModalProps) => {
const [tableData, setTableData] = useState<TableData>({ columns: [], data: [] });
const [loading, setLoading] = useState(false);
const { sendChatRequest } = useChatStore();
useEffect(() => {
console.log('useEffect triggered with content:', content);
if (content?.prompt) {
generateTable();
}
}, [content]);
const generateTable = async () => {
if (!content?.prompt) return;
console.log('Generating table for prompt:', content.prompt);
setLoading(true);
try {
const response = await sendChatRequest('/chat/completions', {
data: `Create a table with data about: ${content.prompt}
Return ONLY a JSON object in this format:
{
"columns": [
{"field": "column1", "header": "Column 1"},
{"field": "column2", "header": "Column 2"}
],
"data": [
{"column1": "value1", "column2": "value2"},
{"column1": "value3", "column2": "value4"}
]
}
Rules:
1. Return ONLY the JSON object, no explanations
2. Make sure field names in data match column fields exactly
3. Choose appropriate column names based on the topic
4. Include at least 5 rows of data`,
responseFormat: 'json'
});
console.log('Got raw response:', response);
if (response?.responseText) {
console.log('Got responseText:', response.responseText);
setTableData(JSON.parse(response.responseText));
} else {
console.error('Invalid response format:', response);
}
} catch (error) {
console.error('Error generating table:', error);
} finally {
setLoading(false);
}
};
return (
<div className="flex flex-column gap-3 p-4">
<DataTable value={tableData.data} loading={loading} className="w-full">
{tableData.columns.map(col => (
<Column key={col.field} field={col.field} header={col.header} />
))}
</DataTable>
</div>
);
};
export default ChatGPTModal;

@ -1,10 +1,10 @@
import { useEffect, useState, useRef } from 'react'; import { useEffect, useState, useRef } from 'react';
import { Column } from 'primereact/column'; import { Column } from 'primereact/column';
import { FilterMatchMode } from 'primereact/api'; import { FilterMatchMode } from 'primereact/api';
import TableGroupHeader from '../shared/components/_V1/TableGroupHeader'; import TableGroupHeader from '../../shared/components/_V1/TableGroupHeader';
import TableGroup from '../shared/components/_V1/TableGroup'; import TableGroup from '../../shared/components/_V1/TableGroup';
import { TSizeOptionValue } from '../types/DataTable.types'; import { TSizeOptionValue } from './DataTable.types';
import { api } from '../services/api'; import { api } from '../../services/api';
import { Button } from 'primereact/button'; import { Button } from 'primereact/button';
import { Dialog } from 'primereact/dialog'; import { Dialog } from 'primereact/dialog';
import { InputText } from 'primereact/inputtext'; import { InputText } from 'primereact/inputtext';
@ -12,13 +12,15 @@ import { Dropdown } from 'primereact/dropdown';
import { ProgressSpinner } from 'primereact/progressspinner'; import { ProgressSpinner } from 'primereact/progressspinner';
import ChatGptModal from './ChatGPTModal'; import ChatGptModal from './ChatGPTModal';
import { Toast } from 'primereact/toast'; import { Toast } from 'primereact/toast';
import { useAuthStore } from '../../state/stores/useAuthStore';
import SettingsMenu, { SettingsMenuItem } from '../../shared/components/SettingsMenu';
export default function CanvasEndpoints() { export default function CanvasEndpoints() {
const [endpoints, setEndpoints] = useState<any[]>([]); const [endpoints, setEndpoints] = useState<any[]>([]);
const [filters, setFilters] = useState<any>(null); const [filters, setFilters] = useState<any>(null);
const [globalFilterValue, setGlobalFilterValue] = useState(''); const [globalFilterValue, setGlobalFilterValue] = useState('');
const [showCreateModal, setShowCreateModal] = useState(false); const [showCreateModal, setShowCreateModal] = useState(false);
const [newEndpoint, setNewEndpoint] = useState({ name: '', method: '', path: '', description: '' }); const [newEndpoint, setNewEndpoint] = useState({ name: '', method: '', path: '', description: '', publicPath: '' });
const sizeOptions: { label: string; value: TSizeOptionValue }[] = [ const sizeOptions: { label: string; value: TSizeOptionValue }[] = [
{ label: 'Small', value: 'small' }, { label: 'Small', value: 'small' },
{ label: 'Normal', value: 'normal' }, { label: 'Normal', value: 'normal' },
@ -42,8 +44,29 @@ export default function CanvasEndpoints() {
const [chatError, setChatError] = useState<string | null>(null); const [chatError, setChatError] = useState<string | null>(null);
const toast = useRef(null); const toast = useRef(null);
const [editEndpointId, setEditEndpointId] = useState<number | null>(null); const [editEndpointId, setEditEndpointId] = useState<number | null>(null);
const [apiKey, setApiKey] = useState<string | null>(null);
const [apiKeyLoading, setApiKeyLoading] = useState(false);
const [apiKeyError, setApiKeyError] = useState<string | null>(null);
const [appId, setAppId] = useState<number | null>(null);
const [showApiKeyModal, setShowApiKeyModal] = useState(false);
const [editByVoiceEndpoint, setEditByVoiceEndpoint] = useState<any>(null);
const [showVoiceModal, setShowVoiceModal] = useState(false);
const [liveTranscript, setLiveTranscript] = useState('');
const [showVoiceInfoModal, setShowVoiceInfoModal] = useState(false);
const [fullTranscript, setFullTranscript] = useState('');
const [showCogMenu, setShowCogMenu] = useState(false);
const cogMenuRef = useRef<any>(null);
useEffect(() => { useEffect(() => {
async function fetchAppId() {
try {
const response = await api('get', '/api/v1/app/by-name/Canvas');
setAppId(response.data.id);
} catch (err) {
console.error('Failed to fetch Canvas app id');
}
}
fetchAppId();
initFilters(); initFilters();
fetchEndpoints(); fetchEndpoints();
}, []); }, []);
@ -86,7 +109,7 @@ export default function CanvasEndpoints() {
const sanitizedEndpoint = { ...newEndpoint, path: sanitizeCanvasPath(newEndpoint.path) }; const sanitizedEndpoint = { ...newEndpoint, path: sanitizeCanvasPath(newEndpoint.path) };
await api('post', '/api/v1/canvas-api/endpoints', sanitizedEndpoint); await api('post', '/api/v1/canvas-api/endpoints', sanitizedEndpoint);
setShowCreateModal(false); setShowCreateModal(false);
setNewEndpoint({ name: '', method: '', path: '', description: '' }); setNewEndpoint({ name: '', method: '', path: '', description: '', publicPath: '' });
fetchEndpoints(); fetchEndpoints();
} catch (error) { } catch (error) {
console.error('Failed to create endpoint:', error); console.error('Failed to create endpoint:', error);
@ -133,15 +156,14 @@ export default function CanvasEndpoints() {
setCallModalGlobalFilterValue(value); setCallModalGlobalFilterValue(value);
}; };
const handleChatPromptSend = async () => { const handleChatPromptSend = async (promptOverride?: string) => {
if (!chatPrompt.trim()) return; const prompt = promptOverride !== undefined ? promptOverride : chatPrompt;
if (!prompt.trim()) return;
setChatMessages(prev => [...prev, { role: 'user', content: prompt }]);
setChatLoading(true); setChatLoading(true);
setChatError(null); setChatError(null);
setChatMessages(prev => [...prev, { role: 'user', content: chatPrompt }]);
try { try {
const formattedRequest = { const formattedRequest = { data: prompt };
data: chatPrompt,
};
const response = await api( const response = await api(
'post', 'post',
'/api/v1/canvas-api/chatgpt/completions', '/api/v1/canvas-api/chatgpt/completions',
@ -211,6 +233,7 @@ export default function CanvasEndpoints() {
method: endpoint.method, method: endpoint.method,
path: endpoint.path, path: endpoint.path,
description: endpoint.description || '', description: endpoint.description || '',
publicPath: endpoint.publicPath || '',
}); });
setShowCreateModal(true); setShowCreateModal(true);
}; };
@ -224,7 +247,7 @@ export default function CanvasEndpoints() {
await api('post', '/api/v1/canvas-api/endpoints', sanitizedEndpoint); await api('post', '/api/v1/canvas-api/endpoints', sanitizedEndpoint);
} }
setShowCreateModal(false); setShowCreateModal(false);
setNewEndpoint({ name: '', method: '', path: '', description: '' }); setNewEndpoint({ name: '', method: '', path: '', description: '', publicPath: '' });
setEditEndpointId(null); setEditEndpointId(null);
fetchEndpoints(); fetchEndpoints();
} catch (error) { } catch (error) {
@ -234,10 +257,280 @@ export default function CanvasEndpoints() {
const handleModalHide = () => { const handleModalHide = () => {
setShowCreateModal(false); setShowCreateModal(false);
setNewEndpoint({ name: '', method: '', path: '', description: '' }); setNewEndpoint({ name: '', method: '', path: '', description: '', publicPath: '' });
setEditEndpointId(null); setEditEndpointId(null);
}; };
const handleApiKey = async () => {
console.log('handleApiKey called');
setApiKeyLoading(true);
setApiKeyError(null);
try {
const user = useAuthStore.getState().user;
console.log('User:', user, 'AppId:', appId);
if (!user) throw new Error('User not logged in');
if (!appId) throw new Error('App ID not loaded');
const response = await api('post', '/api/v1/app/apikey/generate', { userId: user.id, appId });
setApiKey(response.data.apiKey);
} catch (error: any) {
const errorMessage = error.response?.data?.error || error.message || 'Failed to fetch or generate API key';
setApiKeyError(errorMessage);
console.error('API Key Error:', error);
} finally {
setApiKeyLoading(false);
}
};
const fetchApiKey = async () => {
try {
const user = useAuthStore.getState().user;
if (!user || !appId) return;
const response = await api('post', '/api/v1/app/apikey/get', { userId: user.id, appId });
setApiKey(response.data.apiKey || null);
setApiKeyError(null);
} catch (error: any) {
setApiKey(null);
setApiKeyError(error.response?.data?.error || error.message || 'Failed to fetch API key');
}
};
useEffect(() => {
if (showApiKeyModal) {
fetchApiKey();
}
// eslint-disable-next-line
}, [showApiKeyModal, appId]);
const tryDeleteFromSpeech = async (transcript) => {
const lower = transcript.toLowerCase();
if (lower.startsWith('create')) return false; // let normal create/chat logic handle it
if (lower.includes('delete')) {
// Try to match "delete endpoint 5" or "remove endpoint 5"
const idMatch = transcript.match(/delete endpoint (\d+)/i) || transcript.match(/remove endpoint (\d+)/i);
if (idMatch) {
const id = parseInt(idMatch[1], 10);
await handleDeleteEndpoint({ id });
setChatMessages(prev => [...prev, { role: 'assistant', content: `Endpoint with ID ${id} deleted (if it existed).` }]);
return true;
}
// Try to match "delete endpoint called X" or "remove endpoint named X"
const nameMatch = transcript.match(/delete endpoint (called|named) (.+)/i) || transcript.match(/remove endpoint (called|named) (.+)/i);
if (nameMatch) {
const name = nameMatch[2].trim();
const endpoint = endpoints.find(e => e.name.toLowerCase() === name.toLowerCase());
if (endpoint) {
await handleDeleteEndpoint(endpoint);
setChatMessages(prev => [...prev, { role: 'assistant', content: `Endpoint \"${name}\" deleted.` }]);
} else {
setChatMessages(prev => [...prev, { role: 'assistant', content: `No endpoint found with name \"${name}\".` }]);
}
return true;
}
// Try to match "delete endpoint [name]"
const nameAfterEndpointMatch = transcript.match(/delete endpoint ([a-zA-Z0-9 _-]+)/i);
if (nameAfterEndpointMatch) {
const name = nameAfterEndpointMatch[1].trim();
const endpoint = endpoints.find(e => e.name.toLowerCase() === name.toLowerCase());
if (endpoint) {
await handleDeleteEndpoint(endpoint);
setChatMessages(prev => [...prev, { role: 'assistant', content: `Endpoint \"${name}\" deleted.` }]);
} else {
setChatMessages(prev => [...prev, { role: 'assistant', content: `No endpoint found with name \"${name}\".` }]);
}
return true;
}
// If just "delete endpoint" with no id or name, show a message
if (lower.includes('delete endpoint')) {
setChatMessages(prev => [...prev, { role: 'assistant', content: `Please specify the endpoint ID or name to delete.` }]);
return true;
}
}
return false;
};
const tryShowFromSpeech = async (transcript) => {
const lower = transcript.toLowerCase();
const viewKeywords = ['show', 'view', 'open'];
// Find which keyword (if any) is present
const action = viewKeywords.find(kw => lower.includes(kw));
if (!action) return false;
// Try "<action> endpoint 5"
const idMatch = lower.match(/(?:show|view|open) endpoint (\d+)/i);
if (idMatch) {
const id = parseInt(idMatch[1], 10);
const endpoint = endpoints.find(e => e.id === id);
if (endpoint) {
handleCallEndpoint(endpoint);
setChatMessages(prev => [...prev, { role: 'assistant', content: `${action.charAt(0).toUpperCase() + action.slice(1)}ing endpoint with ID ${id}.` }]);
} else {
setChatMessages(prev => [...prev, { role: 'assistant', content: `No endpoint found with ID ${id}.` }]);
}
return true;
}
// Try "<action> endpoint [name]"
const nameAfterEndpointMatch = lower.match(/(?:show|view|open) endpoint ([a-zA-Z0-9 _-]+)/i);
if (nameAfterEndpointMatch) {
const name = nameAfterEndpointMatch[1].trim();
const endpoint = endpoints.find(e => e.name.toLowerCase() === name.toLowerCase());
if (endpoint) {
handleCallEndpoint(endpoint);
setChatMessages(prev => [...prev, { role: 'assistant', content: `${action.charAt(0).toUpperCase() + action.slice(1)}ing endpoint \"${name}\".` }]);
} else {
setChatMessages(prev => [...prev, { role: 'assistant', content: `No endpoint found with name \"${name}\".` }]);
}
return true;
}
// Try "<action> [name] endpoint"
const nameEndpointMatch = lower.match(/(?:show|view|open) (.+) endpoint/i);
if (nameEndpointMatch) {
const name = nameEndpointMatch[1].trim();
const endpoint = endpoints.find(e => e.name.toLowerCase() === name.toLowerCase());
if (endpoint) {
handleCallEndpoint(endpoint);
setChatMessages(prev => [...prev, { role: 'assistant', content: `${action.charAt(0).toUpperCase() + action.slice(1)}ing endpoint \"${name}\".` }]);
} else {
setChatMessages(prev => [...prev, { role: 'assistant', content: `No endpoint found with name \"${name}\".` }]);
}
return true;
}
// Try "<action> [name]"
const nameMatch = lower.match(/(?:show|view|open) (.+)/i);
if (nameMatch) {
const name = nameMatch[1].trim();
const endpoint = endpoints.find(e => e.name.toLowerCase() === name.toLowerCase());
if (endpoint) {
handleCallEndpoint(endpoint);
setChatMessages(prev => [...prev, { role: 'assistant', content: `${action.charAt(0).toUpperCase() + action.slice(1)}ing endpoint \"${name}\".` }]);
} else {
setChatMessages(prev => [...prev, { role: 'assistant', content: `No endpoint found with name \"${name}\".` }]);
}
return true;
}
return false;
};
const trySearchFromSpeech = (transcript) => {
const lower = transcript.toLowerCase().trim();
// Match "search [term]", "find [term]", or "filter by [term]"
const searchMatch = lower.match(/^(search|find|filter by)\s+(.+)$/i);
if (searchMatch) {
const term = searchMatch[2].trim();
setGlobalFilterValue(term);
setChatMessages(prev => [...prev, { role: 'assistant', content: `Filtered endpoints by: "${term}"` }]);
return true;
}
return false;
};
const tryModifyFromSpeech = (transcript) => {
const lower = transcript.toLowerCase();
const match = lower.match(/modify endpoint (\d+)/i);
if (match) {
const id = parseInt(match[1], 10);
const endpoint = endpoints.find(e => e.id === id);
if (endpoint) {
setEditByVoiceEndpoint({ ...endpoint });
setChatMessages(prev => [...prev, { role: 'assistant', content: `Ready to modify endpoint with ID ${id}. Say 'method is GET' or 'path is slash course slash id'.` }]);
} else {
setChatMessages(prev => [...prev, { role: 'assistant', content: `No endpoint found with ID ${id}.` }]);
}
return true;
}
return false;
};
const tryUpdateEditByVoice = async (transcript) => {
if (!editByVoiceEndpoint) return false;
let updated = { ...editByVoiceEndpoint };
let didUpdate = false;
// Method update
const methodMatch = transcript.match(/method is (get|post|put|delete|patch)/i);
if (methodMatch) {
updated.method = methodMatch[1].toUpperCase();
didUpdate = true;
}
// Path update
const pathMatch = transcript.match(/path is (.+)/i);
if (pathMatch) {
let path = pathMatch[1]
.replace(/slash/gi, '/')
.replace(/colon id|id/gi, ':id')
.replace(/\s+/g, '');
updated.path = path;
didUpdate = true;
}
if (didUpdate) {
setEditByVoiceEndpoint(updated);
setChatMessages(prev => [...prev, { role: 'assistant', content: `Updated endpoint: method=${updated.method}, path=${updated.path}` }]);
// Auto-save
await api('put', `/api/v1/canvas-api/endpoints/${updated.id}`, updated);
fetchEndpoints();
setEditByVoiceEndpoint(null);
setChatMessages(prev => [...prev, { role: 'assistant', content: `Endpoint ${updated.id} saved.` }]);
return true;
}
return false;
};
const handleVoiceInput = () => {
// @ts-ignore
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SpeechRecognition) {
alert('Speech recognition not supported in this browser.');
return;
}
setShowVoiceModal(true);
setLiveTranscript('');
setFullTranscript('');
const recognition = new SpeechRecognition();
recognition.lang = 'en-US';
recognition.interimResults = true;
recognition.maxAlternatives = 1;
recognition.onresult = async (event: any) => {
const transcript = event.results[0][0].transcript;
setLiveTranscript(transcript);
setFullTranscript(prev => prev + (prev && !prev.endsWith(' ') ? ' ' : '') + transcript);
setChatPrompt(transcript);
if (event.results[0].isFinal) {
setShowVoiceModal(false);
setLiveTranscript('');
setFullTranscript('');
if (transcript.toLowerCase().includes('open command list')) {
setShowVoiceInfoModal(true);
return;
}
if (await tryDeleteFromSpeech(transcript)) return;
if (await tryShowFromSpeech(transcript)) return;
if (trySearchFromSpeech(transcript)) return;
if (tryModifyFromSpeech(transcript)) return;
if (await tryUpdateEditByVoice(transcript)) return;
handleChatPromptSend(transcript);
}
};
recognition.onerror = (event: any) => {
setShowVoiceModal(false);
setLiveTranscript('');
setFullTranscript('');
alert('Speech recognition error: ' + event.error);
};
recognition.onend = () => {
setShowVoiceModal(false);
setLiveTranscript('');
setFullTranscript('');
};
recognition.start();
};
const renderHeader = () => ( const renderHeader = () => (
<TableGroupHeader <TableGroupHeader
size={size} size={size}
@ -250,16 +543,36 @@ export default function CanvasEndpoints() {
extraButtonsTemplate={() => ( extraButtonsTemplate={() => (
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
label="Create Endpoint"
icon="pi pi-plus" icon="pi pi-plus"
onClick={() => setShowCreateModal(true)} onClick={() => setShowCreateModal(true)}
className="p-button-secondary"
/> />
<Button <Button
label="Ask ChatGPT"
icon="pi pi-comments" icon="pi pi-comments"
onClick={() => setShowChatGptModal(true)} onClick={() => setShowChatGptModal(true)}
className="p-button-primary" className="p-button-primary"
/> />
<SettingsMenu
items={[
{
label: 'Manage API Key',
icon: 'pi pi-key',
command: () => setShowApiKeyModal(true),
},
{
label: 'Voice Command List',
icon: 'pi pi-info-circle',
command: () => setShowVoiceInfoModal(true),
},
]}
buttonClassName="p-button-secondary"
/>
<Button
icon="pi pi-volume-up"
onClick={handleVoiceInput}
className="p-button-secondary"
disabled={chatLoading}
/>
</div> </div>
)} )}
/> />
@ -267,9 +580,9 @@ export default function CanvasEndpoints() {
const renderCallButton = (rowData: any) => ( const renderCallButton = (rowData: any) => (
<div className="flex gap-2"> <div className="flex gap-2">
<Button label="Call" icon="pi pi-play" onClick={() => handleCallEndpoint(rowData)} /> <Button icon="pi pi-play" onClick={() => handleCallEndpoint(rowData)} />
<Button label="Edit" icon="pi pi-pencil" className="p-button-warning" onClick={() => openEditModal(rowData)} /> <Button icon="pi pi-pencil" className="p-button-warning" onClick={() => openEditModal(rowData)} />
<Button label="Delete" icon="pi pi-trash" className="p-button-danger" onClick={() => handleDeleteEndpoint(rowData)} /> <Button icon="pi pi-trash" className="p-button-danger" onClick={() => handleDeleteEndpoint(rowData)} />
</div> </div>
); );
@ -285,12 +598,22 @@ export default function CanvasEndpoints() {
/> />
); );
// Compute filtered endpoints for search-everything
const filteredEndpoints = globalFilterValue
? endpoints.filter(endpoint =>
Object.values(endpoint)
.join(' ')
.toLowerCase()
.includes(globalFilterValue.toLowerCase())
)
: endpoints;
return ( return (
<div className='w-full h-full p-6'> <div className='w-full h-full p-6'>
<h2 className='text-xl font-semibold mb-4'>Canvas Endpoints</h2> <h2 className='text-xl font-semibold mb-4'>Canvas Endpoints</h2>
<Toast ref={toast} position="bottom-right" /> <Toast ref={toast} position="bottom-right" />
<TableGroup <TableGroup
body={endpoints} body={filteredEndpoints}
size={size} size={size}
header={renderHeader} header={renderHeader}
filters={filters} filters={filters}
@ -304,14 +627,15 @@ export default function CanvasEndpoints() {
<Column field='id' header='ID' sortable filter style={{ width: '10%' }} /> <Column field='id' header='ID' sortable filter style={{ width: '10%' }} />
<Column field='name' header='Name' sortable filter style={{ width: '20%' }} /> <Column field='name' header='Name' sortable filter style={{ width: '20%' }} />
<Column field='method' header='Method' sortable filter style={{ width: '10%' }} /> <Column field='method' header='Method' sortable filter style={{ width: '10%' }} />
<Column field='path' header='Path' sortable filter style={{ width: '40%' }} /> <Column field='path' header='Path' sortable filter style={{ width: '25%' }} />
<Column field='publicPath' header='Public Path' sortable filter style={{ width: '15%' }} />
<Column header='Action' body={renderCallButton} style={{ width: '20%' }} /> <Column header='Action' body={renderCallButton} style={{ width: '20%' }} />
</TableGroup> </TableGroup>
<Dialog <Dialog
visible={showCreateModal} visible={showCreateModal}
onHide={handleModalHide} onHide={handleModalHide}
header={editEndpointId ? "Edit Endpoint" : "Create New Endpoint"} header={editEndpointId ? "Edit" : "Create"}
footer={ footer={
<div> <div>
<Button label="Cancel" icon="pi pi-times" onClick={handleModalHide} className="p-button-text" /> <Button label="Cancel" icon="pi pi-times" onClick={handleModalHide} className="p-button-text" />
@ -343,6 +667,10 @@ export default function CanvasEndpoints() {
<label htmlFor="path">Path</label> <label htmlFor="path">Path</label>
<InputText id="path" value={newEndpoint.path} onChange={(e) => setNewEndpoint({ ...newEndpoint, path: e.target.value })} /> <InputText id="path" value={newEndpoint.path} onChange={(e) => setNewEndpoint({ ...newEndpoint, path: e.target.value })} />
</div> </div>
<div className="p-field">
<label htmlFor="publicPath">Public Path</label>
<InputText id="publicPath" value={newEndpoint.publicPath || ''} onChange={(e) => setNewEndpoint({ ...newEndpoint, publicPath: e.target.value })} />
</div>
<div className="p-field"> <div className="p-field">
<label htmlFor="description">Description</label> <label htmlFor="description">Description</label>
<InputText id="description" value={newEndpoint.description} onChange={(e) => setNewEndpoint({ ...newEndpoint, description: e.target.value })} /> <InputText id="description" value={newEndpoint.description} onChange={(e) => setNewEndpoint({ ...newEndpoint, description: e.target.value })} />
@ -450,7 +778,7 @@ export default function CanvasEndpoints() {
/> />
<Button <Button
icon="pi pi-send" icon="pi pi-send"
onClick={handleChatPromptSend} onClick={() => handleChatPromptSend()}
loading={chatLoading} loading={chatLoading}
disabled={!chatPrompt.trim() || chatLoading} disabled={!chatPrompt.trim() || chatLoading}
/> />
@ -458,6 +786,75 @@ export default function CanvasEndpoints() {
{chatError && <div className="text-red-500 mt-2">{chatError}</div>} {chatError && <div className="text-red-500 mt-2">{chatError}</div>}
</div> </div>
</Dialog> </Dialog>
<Dialog
visible={showApiKeyModal}
onHide={() => setShowApiKeyModal(false)}
header="API Key Management"
footer={
<div>
<Button
label={apiKey ? "Regenerate API Key" : "Create API Key"}
icon="pi pi-refresh"
onClick={handleApiKey}
loading={apiKeyLoading}
className="p-button-danger"
/>
<Button
label="Close"
icon="pi pi-times"
onClick={() => setShowApiKeyModal(false)}
className="p-button-text"
/>
</div>
}
>
{apiKey && (
<div>
<div className="mb-2 font-bold">Current API Key:</div>
<div className="font-mono bg-gray-100 p-2 rounded">{apiKey}</div>
<div className="mt-2 text-sm text-red-500">
Regenerating will invalidate the old key.
</div>
</div>
)}
{!apiKey && (
<div>
<div>No API key exists for this app. Click "Create API Key" to generate one.</div>
</div>
)}
{apiKeyError && <div className="text-red-500 mt-2">{apiKeyError}</div>}
</Dialog>
<Dialog
visible={showVoiceModal}
onHide={() => setShowVoiceModal(false)}
header="Listening..."
modal
style={{ minWidth: 400, textAlign: 'center' }}
closable={false}
>
<div style={{ fontSize: 24, minHeight: 60 }}>{fullTranscript || <span className="text-gray-400">Say something</span>}</div>
<div className="mt-4 text-sm text-gray-500">Speak your command or query.</div>
</Dialog>
<Dialog
visible={showVoiceInfoModal}
onHide={() => setShowVoiceInfoModal(false)}
header="Voice Command List"
modal
style={{ minWidth: 400 }}
>
<ul style={{ textAlign: 'left', lineHeight: 2 }}>
<li><b>show/view/open endpoint [name or id]</b> Open an endpoint's table view</li>
<li><b>delete endpoint [name or id]</b> Delete an endpoint</li>
<li><b>search [term]</b> Filter endpoints</li>
<li><b>modify endpoint [id]</b> Start editing an endpoint by voice</li>
<li><b>method is [GET/POST/PUT/DELETE/PATCH]</b> Set method (in edit mode)</li>
<li><b>path is [your path]</b> Set path (in edit mode, say 'slash' for /, 'colon id' for :id)</li>
<li><b>open command list</b> Show this help</li>
</ul>
</Dialog>
</div> </div>
); );
} }

@ -0,0 +1,314 @@
import { DataTable } from 'primereact/datatable';
import { Column } from 'primereact/column';
import { useState, useEffect } from 'react';
import { useChatStore } from '../../state/stores/useChatStore';
import { Dialog } from 'primereact/dialog';
import { generateTablePrompt } from '../../prompts/table-generation';
import { Toast } from 'primereact/toast';
import { useRef } from 'react';
import { InputText } from 'primereact/inputtext';
interface ChatGPTModalProps {
content?: {
prompt: string;
id?: string;
keywords?: string[];
context?: string;
tableSchema?: {
name: string;
fields: Array<{
name: string;
type: string;
description?: string;
}>;
};
};
}
interface TableData {
columns: Array<{
field: string;
header: string;
}>;
data: any[];
metadata?: {
id?: string;
keywords?: string[];
context?: string;
timestamp: string;
actions?: Array<{
action: string;
target: string;
description: string;
}>;
};
}
export const ChatGPTModal = ({ content }: ChatGPTModalProps) => {
const [tableData, setTableData] = useState<TableData>({
columns: [],
data: [],
metadata: {
timestamp: new Date().toISOString()
}
});
const [loading, setLoading] = useState(false);
const [selectedRow, setSelectedRow] = useState<any>(null);
const [showRowDialog, setShowRowDialog] = useState(false);
const [finalPrompt, setFinalPrompt] = useState<string>('');
const { sendChatRequest } = useChatStore();
const toast = useRef<Toast>(null);
const constructPath = (id: string | number, pathname: string): string => {
// Remove any leading/trailing slashes from pathname
const cleanPathname = pathname.replace(/^\/+|\/+$/g, '');
// Construct the path with proper slashes
return `/${id}/${cleanPathname}`.replace(/\/+/g, '/');
};
const findRowById = (id: string | number) => {
const row = tableData.data.find(row => row.id?.toString() === id.toString());
if (row) {
setSelectedRow(row);
setShowRowDialog(true);
showToast('info', `Viewing details for ID: ${id}`);
} else {
showToast('warn', `No row found with ID: ${id}`);
}
};
const handlePublicPath = (prompt: string) => {
// Check for path construction pattern
const pathMatch = prompt.match(/add\s+public\s+path\s+to\s+(\d+)\s+slash\s+([^\s]+)/i);
if (pathMatch) {
const [, id, pathname] = pathMatch;
const constructedPath = constructPath(id, pathname);
// Find the row with the given ID
const rowIndex = tableData.data.findIndex(row => row.id?.toString() === id.toString());
if (rowIndex !== -1) {
// Update the path of the existing row
setTableData(prev => {
const newData = [...prev.data];
newData[rowIndex] = {
...newData[rowIndex],
path: constructedPath
};
// Add an 'add_path' action to metadata
return {
...prev,
data: newData,
metadata: {
...prev.metadata,
actions: [
...(prev.metadata?.actions || []),
{
action: 'add_path',
target: constructedPath,
description: `added public path ${constructedPath} to endpoint ID ${id}`
}
]
}
};
});
showToast('success', `Updated path for ID ${id}: ${constructedPath}`);
findRowById(id);
} else {
showToast('warn', `No endpoint found with ID: ${id}`);
}
return { id, constructedPath };
}
return null;
};
useEffect(() => {
if (content?.prompt) {
setFinalPrompt(content.prompt);
// Handle public path update only, not endpoint creation
handlePublicPath(content.prompt);
generateTable();
}
}, [content]);
const showToast = (severity: 'success' | 'info' | 'warn' | 'error', message: string) => {
toast.current?.show({
severity,
summary: severity.charAt(0).toUpperCase() + severity.slice(1),
detail: message,
life: 3000
});
};
const generateTable = async () => {
if (!content?.prompt) return;
setLoading(true);
try {
const promptText = generateTablePrompt({
prompt: content.prompt,
context: content.context,
keywords: content.keywords,
id: content.id,
tableSchema: content.tableSchema
});
const response = await sendChatRequest('/chat/completions', {
data: promptText,
responseFormat: 'json'
});
if (response?.responseText) {
try {
const parsedData = JSON.parse(response.responseText);
setTableData({
...parsedData,
metadata: {
...parsedData.metadata,
timestamp: new Date().toISOString()
}
});
// Show success message based on the primary action
const primaryAction = parsedData.metadata?.actions?.[0];
if (primaryAction) {
const actionMessage = {
create: 'Successfully created',
delete: 'Successfully deleted',
update: 'Successfully updated',
show: 'Successfully retrieved',
search: 'Search completed',
view: 'Viewing details'
}[primaryAction.action] || 'Operation completed';
showToast('success', `${actionMessage} ${primaryAction.target}`);
}
} catch (error) {
console.error('Error parsing response:', error);
showToast('error', 'Error processing the response');
}
} else {
showToast('error', 'Invalid response format');
}
} catch (error) {
console.error('Error generating table:', error);
showToast('error', 'Error generating table');
} finally {
setLoading(false);
}
};
const handleRowClick = (event: any) => {
const rowData = event.data;
setSelectedRow(rowData);
setShowRowDialog(true);
// Add view action to metadata if not present
if (tableData.metadata?.actions) {
const hasViewAction = tableData.metadata.actions.some(
action => action.action === 'view' && action.target === rowData.id
);
if (!hasViewAction) {
setTableData(prev => ({
...prev,
metadata: {
...prev.metadata,
actions: [
...(prev.metadata?.actions || []),
{
action: 'view',
target: rowData.id || 'selected row',
description: `view details for ${rowData.id || 'selected row'}`
}
]
}
}));
showToast('info', `Viewing details for ${rowData.id || 'selected row'}`);
}
}
};
const renderRowDialog = () => {
if (!selectedRow) return null;
return (
<Dialog
visible={showRowDialog}
onHide={() => setShowRowDialog(false)}
header={`View Details - ${selectedRow.id || 'Selected Row'}`}
style={{ width: '50vw' }}
>
<div className="grid">
{tableData.columns.map(col => (
<div key={col.field} className="col-12 md:col-6 p-2">
<div className="font-bold">{col.header}</div>
<div>{selectedRow[col.field] || 'N/A'}</div>
</div>
))}
</div>
</Dialog>
);
};
return (
<>
<Toast ref={toast} position="top-right" />
<div className="flex flex-column gap-3 p-4 relative">
{finalPrompt && (
<div className="mb-3">
<label className="block text-sm font-medium text-gray-700 mb-1">
Your Request
</label>
<InputText
value={finalPrompt}
readOnly
className="w-full p-2 border rounded"
style={{ backgroundColor: '#f8f9fa' }}
/>
</div>
)}
<DataTable
value={tableData.data}
loading={loading}
className="w-full"
onRowClick={handleRowClick}
selectionMode="single"
rowHover
>
{tableData.columns.map(col => (
<Column
key={col.field}
field={col.field}
header={col.header}
body={(rowData) => (
<div className="cursor-pointer hover:bg-gray-100 p-2">
{rowData[col.field] || 'N/A'}
</div>
)}
/>
))}
</DataTable>
{tableData.metadata && (
<div className="text-sm text-gray-500 mt-2">
<p>ID: {tableData.metadata.id}</p>
<p>Keywords: {tableData.metadata.keywords?.join(', ')}</p>
<p>Context: {tableData.metadata.context}</p>
<p>Generated: {new Date(tableData.metadata.timestamp).toLocaleString()}</p>
{tableData.metadata.actions && tableData.metadata.actions.length > 0 && (
<div className="mt-2">
<p className="font-bold">Detected Actions:</p>
{tableData.metadata.actions.map((action, index) => (
<div key={index} className="ml-2">
<span className="font-semibold">{action.action}:</span> {action.target}
</div>
))}
</div>
)}
</div>
)}
{renderRowDialog()}
</div>
</>
);
};
export default ChatGPTModal;

@ -0,0 +1,65 @@
import { useState } from 'react';
import { Dialog } from 'primereact/dialog';
import { InputText } from 'primereact/inputtext';
import { Button } from 'primereact/button';
interface ParseDocsModalProps {
visible: boolean;
onHide: () => void;
onParsed?: (endpoints: any[]) => void;
}
export function ParseDocsModal({ visible, onHide, onParsed }: ParseDocsModalProps) {
const [url, setUrl] = useState('');
const [loading, setLoading] = useState(false);
const [results, setResults] = useState<any>(null);
const [error, setError] = useState<string | null>(null);
const handleParse = async () => {
setLoading(true);
setError(null);
setResults(null);
try {
const response = await fetch('/api/parse-docs', {
method: 'POST',
body: JSON.stringify({ url }),
headers: { 'Content-Type': 'application/json' }
});
if (!response.ok) throw new Error('Failed to fetch or parse docs');
const data = await response.json();
setResults(data);
if (onParsed && data.restructured) onParsed(data.restructured);
} catch (e: any) {
setError(e.message || 'Unknown error');
} finally {
setLoading(false);
}
};
return (
<Dialog visible={visible} onHide={onHide} header="Parse API Docs" style={{ width: '50vw' }}>
<div>
<InputText
value={url}
onChange={e => setUrl(e.target.value)}
placeholder="Enter documentation URL"
className="w-full mb-3"
/>
<Button label="Parse" onClick={handleParse} loading={loading} disabled={!url} />
{error && <div className="text-red-500 mt-2">{error}</div>}
</div>
{results && (
<div className="mt-4">
<h4>Raw HTML (first 500 chars):</h4>
<pre style={{ maxHeight: 200, overflow: 'auto', background: '#f8f9fa', padding: 8 }}>{results.rawHtml?.slice(0, 500)}...</pre>
<h4>AI Output:</h4>
<pre style={{ maxHeight: 200, overflow: 'auto', background: '#f8f9fa', padding: 8 }}>{JSON.stringify(results.aiOutput, null, 2)}</pre>
<h4>Restructured Output:</h4>
<pre style={{ maxHeight: 200, overflow: 'auto', background: '#f8f9fa', padding: 8 }}>{JSON.stringify(results.restructured, null, 2)}</pre>
</div>
)}
</Dialog>
);
}
export default ParseDocsModal;

@ -0,0 +1,75 @@
export interface ActionKeyword {
action: string;
target: string;
description: string;
}
export const ACTION_KEYWORDS: Record<string, string[]> = {
create: ['create', 'new', 'make'],
delete: ['delete', 'remove', 'drop'],
update: ['update', 'modify', 'change', 'edit'],
show: ['show', 'display', 'list', 'get', 'fetch', 'view'],
search: ['search', 'find', 'query', 'filter'],
view: ['view', 'details', 'info', 'expand']
};
export const detectActions = (prompt: string, keywords: string[]): ActionKeyword[] => {
const actions: ActionKeyword[] = [];
const promptLower = prompt.toLowerCase().trim();
// If prompt starts with 'add', treat as 'add' action
if (promptLower.startsWith('add')) {
// Try to match 'add ... to [id] slash [path]'
const match = promptLower.match(/add.*to\s+(\d+)\s+slash\s+([^\s]+)/i);
if (match) {
const [, id, pathname] = match;
actions.push({
action: 'add',
target: `id:${id} path:/${pathname.replace(/^\/+|\/+$/g, '')}`,
description: `add /${pathname.replace(/^\/+|\/+$/g, '')} to endpoint ID ${id}`
});
} else {
// Fallback: just mark as a generic add action
actions.push({
action: 'add',
target: '',
description: 'add action'
});
}
}
// First check for explicit action-target pairs
const words = promptLower.split(' ');
for (let i = 0; i < words.length - 1; i++) {
const word = words[i];
const nextWord = words[i + 1];
for (const [action, triggers] of Object.entries(ACTION_KEYWORDS)) {
if (triggers.includes(word)) {
actions.push({
action,
target: nextWord,
description: `${action} ${nextWord}`
});
}
}
}
// Then check keywords for additional context
keywords.forEach(keyword => {
const keywordWords = keyword.toLowerCase().split(' ');
for (const [action, triggers] of Object.entries(ACTION_KEYWORDS)) {
if (triggers.some(trigger => keywordWords.includes(trigger))) {
const target = keywordWords.find(word => !triggers.includes(word));
if (target) {
actions.push({
action,
target,
description: `${action} ${target}`
});
}
}
}
});
return actions;
};

@ -6,7 +6,7 @@ import TenantProfile from '../components/TenantProfile';
import Sidebar from './Sidebar'; import Sidebar from './Sidebar';
import LoginModal from '../components/LoginModal'; import LoginModal from '../components/LoginModal';
const DualModalComponent = () => { const DualModalComponent = () => {
const { systemId } = useParams(); const { systemId } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const { isLoggedIn } = useAuthStore(); const { isLoggedIn } = useAuthStore();

@ -0,0 +1,75 @@
import { ActionKeyword, detectActions } from '../constants/actions';
interface TableGenerationPromptParams {
prompt: string;
context?: string;
keywords?: string[];
id?: string;
tableSchema?: {
name: string;
fields: Array<{
name: string;
type: string;
description?: string;
}>;
};
}
export const generateTablePrompt = (params: TableGenerationPromptParams): string => {
const {
prompt,
context = 'General information',
keywords = [],
id = 'auto-generated',
tableSchema
} = params;
const detectedActions = detectActions(prompt, keywords);
return `Create a table with data about: ${prompt}
Context: ${context}
Keywords: ${keywords.join(', ') || 'None specified'}
${tableSchema ? `
Table Schema:
Name: ${tableSchema.name}
Fields:
${tableSchema.fields.map(f => `- ${f.name} (${f.type})${f.description ? `: ${f.description}` : ''}`).join('\n')}
` : ''}
Detected Actions:
${detectedActions.map(action => `- ${action.description}`).join('\n')}
Return ONLY a JSON object in this format:
{
"columns": [
{"field": "column1", "header": "Column 1"},
{"field": "column2", "header": "Column 2"}
],
"data": [
{"column1": "value1", "column2": "value2"},
{"column1": "value3", "column2": "value4"}
],
"metadata": {
"id": "${id}",
"keywords": ${JSON.stringify(keywords)},
"context": "${context}",
"timestamp": "${new Date().toISOString()}",
"actions": ${JSON.stringify(detectedActions)}
}
}
Rules:
1. Return ONLY the JSON object, no explanations
2. Make sure field names in data match column fields exactly
3. Choose appropriate column names based on the topic
4. Include at least 5 rows of data
5. Ensure all data is relevant to the context and keywords
6. Format dates in ISO format if present
7. Use consistent data types within columns
8. If table schema is provided, strictly follow the field names and types
9. Ensure all required fields from the schema are present in the data
10. Format the data according to the specified types in the schema
11. For multiple actions, prioritize the first action mentioned
12. When creating endpoints, include all related actions in the endpoint description
13. For row click actions, use the 'view' action type`;
};

@ -1,10 +1,11 @@
import { createBrowserRouter, Navigate } from 'react-router-dom'; import { createBrowserRouter, Navigate } from 'react-router-dom';
import { useParams } from 'react-router-dom';
import DualModalComponent from './layouts/DualModal'; import DualModalComponent from './layouts/DualModal';
import CouncilAI from './components/CouncilAI/CouncilAI'; import CouncilAI from './components/CouncilAI/CouncilAI';
import FuseMindHome from './components/FuseMind/FuseMindHome'; import FuseMindHome from './components/FuseMind/FuseMindHome';
import { useParams } from 'react-router-dom'; import CanvasEndpoints from './components/canvas-api/CanvasEndpoints';
import CanvasEndpoints from './components/CanvasEndpoints';
const FuseMindHomeWrapper = () => { const FuseMindHomeWrapper = () => {
const { systemId } = useParams(); const { systemId } = useParams();

@ -0,0 +1,37 @@
import { useRef } from 'react';
import { Button } from 'primereact/button';
import { Menu } from 'primereact/menu';
export interface SettingsMenuItem {
label: string;
icon?: string;
command?: () => void;
}
interface SettingsMenuProps {
items: SettingsMenuItem[];
buttonClassName?: string;
}
const SettingsMenu: React.FC<SettingsMenuProps> = ({ items, buttonClassName }) => {
const menuRef = useRef<any>(null);
return (
<>
<Button
icon="pi pi-cog"
aria-label="Settings"
onClick={e => menuRef.current.toggle(e)}
className={buttonClassName || ''}
tooltip="Settings"
/>
<Menu
model={items}
popup
ref={menuRef}
/>
</>
);
};
export default SettingsMenu;

@ -3,7 +3,7 @@ import { SelectButton } from 'primereact/selectbutton';
import { Dispatch, SetStateAction } from 'react'; import { Dispatch, SetStateAction } from 'react';
import Button from '../_V2/Button'; import Button from '../_V2/Button';
import { TSizeOptionValue } from '../../../types/DataTable.types'; import { TSizeOptionValue } from '../../../components/canvas-api/DataTable.types';
interface HeaderProps { interface HeaderProps {
size: any; size: any;

@ -24,14 +24,18 @@ export const useChatStore = create<ChatStore>(() => ({
const formattedRequest = { const formattedRequest = {
data: requestData.data, data: requestData.data,
// model: requestData.model || 'gpt-4o-mini', model: requestData.model || 'gpt-4-mini',
model: requestData.model || 'o4-mini',
response_format: requestData.responseFormat === 'json' ? { type: 'json_object' } : undefined response_format: requestData.responseFormat === 'json' ? { type: 'json_object' } : undefined
}; };
console.log('Sending formatted request:', formattedRequest); console.log('Sending formatted request:', formattedRequest);
const response = await api('post', `/council${endpoint}`, formattedRequest, undefined, 'json', 60, true); const response = await api('post', endpoint, formattedRequest, undefined, 'json', 60, true);
console.log('Got API response:', response); console.log('Got API response:', response);
if (!response || !response.data) {
throw new Error('Invalid response from server');
}
return response.data; return response.data;
} catch (error) { } catch (error) {
console.error(`Failed to send chat request to ${endpoint}:`, error); console.error(`Failed to send chat request to ${endpoint}:`, error);

@ -1,6 +1,8 @@
import { Entity, Property, ManyToOne } from '@mikro-orm/core'; import { Entity, Property, ManyToOne } from '@mikro-orm/core';
import { BaseEntity } from '../_BaseEntity'; import { BaseEntity } from '../_BaseEntity';
import { User } from '../user/_User'; import { User } from '../user/_User';
import { App } from '../app/_App';
import { APIKeyRepository } from '../../repositories/APIKeyRepository';
@Entity() @Entity()
export class APIKey extends BaseEntity { export class APIKey extends BaseEntity {
@ -9,4 +11,9 @@ export class APIKey extends BaseEntity {
@ManyToOne(() => User) @ManyToOne(() => User)
user!: User; user!: User;
@ManyToOne(() => App)
app!: App;
// Each APIKey is now linked to both a user and an app (system)
} }

@ -0,0 +1,59 @@
import { FastifyReply, FastifyRequest } from 'fastify';
import { APIKeyService } from '../../services/APIKeyService';
import { App } from '../../entities/app/_App';
import { User } from '../../entities/user/_User';
import { APIKey } from '../../entities/apikey/_APIKey';
import crypto from 'crypto';
export class APIKeyController {
private apiKeyService: APIKeyService;
constructor(apiKeyService: APIKeyService) {
this.apiKeyService = apiKeyService;
}
async getOrCreateApiKey(request: FastifyRequest<{ Body: { userId: number; appId: number } }>, reply: FastifyReply) {
try {
const { userId, appId } = request.body;
if (!userId || !appId) {
return reply.status(400).send({ error: 'userId and appId are required' });
}
let apiKeyEntity = await this.apiKeyService.getAPIKeyEntityForUserAndApp(userId, appId);
if (!apiKeyEntity) {
// Generate a new API key
const key = crypto.randomBytes(32).toString('hex');
const em = request.em;
// Check if user exists
const user = await em.findOne(User, { id: userId });
if (!user) {
return reply.status(404).send({ error: `User with ID ${userId} not found` });
}
// Check if app exists
const app = await em.findOne(App, { id: appId });
if (!app) {
return reply.status(404).send({ error: `App with ID ${appId} not found` });
}
// Create new API key
apiKeyEntity = new APIKey();
apiKeyEntity.key = key;
apiKeyEntity.user = user;
apiKeyEntity.app = app;
try {
await em.persistAndFlush(apiKeyEntity);
} catch (dbError) {
console.error('Database error while creating API key:', dbError);
return reply.status(500).send({ error: 'Failed to create API key in database' });
}
}
return reply.send({ apiKey: apiKeyEntity.key });
} catch (error) {
console.error('Error in getOrCreateApiKey:', error);
return reply.status(500).send({ error: 'Internal server error while processing API key request' });
}
}
}

@ -5,4 +5,8 @@ export class APIKeyRepository extends EntityRepository<APIKey> {
async findAPIKeyByUserId(userId: number): Promise<APIKey | null> { async findAPIKeyByUserId(userId: number): Promise<APIKey | null> {
return this.findOne({ user: userId }); return this.findOne({ user: userId });
} }
async findAPIKeyByUserAndApp(userId: number, appId: number): Promise<APIKey | null> {
return this.findOne({ user: userId, app: appId });
}
} }

@ -0,0 +1,20 @@
import { FastifyPluginAsync, FastifyRequest, FastifyReply } from 'fastify';
import { APIKeyService } from '../services/APIKeyService';
import { APIKeyController } from '../http/controllers/APIKeyController';
const apiKeyRoutes: FastifyPluginAsync = async (app) => {
app.post('/generate', async (request: FastifyRequest<{ Body: { userId: number; appId: number } }>, reply: FastifyReply) => {
const apiKeyService = new APIKeyService(request.em);
const controller = new APIKeyController(apiKeyService);
return controller.getOrCreateApiKey(request, reply);
});
app.post('/get', async (request: FastifyRequest<{ Body: { userId: number; appId: number } }>, reply: FastifyReply) => {
const apiKeyService = new APIKeyService(request.em);
const apiKey = await apiKeyService.getAPIKeyForUserAndApp(request.body.userId, request.body.appId);
if (!apiKey) return reply.send({ apiKey: null });
return reply.send({ apiKey });
});
};
export default apiKeyRoutes;

@ -0,0 +1,13 @@
import { FastifyPluginAsync, FastifyRequest, FastifyReply } from 'fastify';
import { App } from '../entities/app/_App';
const appRoutes: FastifyPluginAsync = async (app) => {
app.get('/by-name/:name', async (request: FastifyRequest<{ Params: { name: string } }>, reply: FastifyReply) => {
const { name } = request.params;
const appEntity = await request.em.findOne(App, { name });
if (!appEntity) return reply.status(404).send({ error: 'App not found' });
return reply.send({ id: appEntity.id, name: appEntity.name });
});
};
export default appRoutes;

@ -15,4 +15,13 @@ export class APIKeyService {
const apiKey = await this.apiKeyRepository.findAPIKeyByUserId(userId); const apiKey = await this.apiKeyRepository.findAPIKeyByUserId(userId);
return apiKey ? apiKey.key : null; return apiKey ? apiKey.key : null;
} }
async getAPIKeyForUserAndApp(userId: number, appId: number): Promise<string | null> {
const apiKey = await this.apiKeyRepository.findOne({ user: userId, app: appId });
return apiKey ? apiKey.key : null;
}
async getAPIKeyEntityForUserAndApp(userId: number, appId: number): Promise<APIKey | null> {
return this.apiKeyRepository.findOne({ user: userId, app: appId });
}
} }

@ -18,4 +18,7 @@ export class CanvasApiEndpoint extends BaseEntity {
@ManyToOne(() => User, { nullable: true }) @ManyToOne(() => User, { nullable: true })
user?: User; user?: User;
@Property({ nullable: true })
publicPath?: string;
} }

@ -1,14 +1,29 @@
import { FastifyRequest, FastifyReply } from 'fastify'; import { FastifyRequest, FastifyReply } from 'fastify';
import { CanvasApiEndpoint } from '../../entities/CanvasApiEndpoint'; import { CanvasApiEndpoint } from '../../entities/CanvasApiEndpoint';
interface CreateEndpointBody {
name: string;
method: string;
path: string;
description?: string;
user?: any;
}
interface UpdateEndpointBody {
name?: string;
method?: string;
path?: string;
description?: string;
}
export class CanvasApiEndpointController { export class CanvasApiEndpointController {
async getAll(request: FastifyRequest, reply: FastifyReply) { async getAll(request: FastifyRequest, reply: FastifyReply) {
const endpoints = await request.em.find(CanvasApiEndpoint, {}); const endpoints = await request.em.find(CanvasApiEndpoint, {});
return reply.send(endpoints); return reply.send(endpoints);
} }
async create(request: FastifyRequest, reply: FastifyReply) { async create(request: FastifyRequest<{ Body: CreateEndpointBody }>, reply: FastifyReply) {
const { name, method, path, description, user } = request.body as any; const { name, method, path, description, user } = request.body;
if (!name || !method || !path) { if (!name || !method || !path) {
return reply.status(400).send({ error: 'name, method, and path are required' }); return reply.status(400).send({ error: 'name, method, and path are required' });
} }
@ -31,15 +46,15 @@ export class CanvasApiEndpointController {
return reply.send({ success: true }); return reply.send({ success: true });
} }
async update(request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) { async update(request: FastifyRequest<{ Params: { id: string }; Body: UpdateEndpointBody }>, reply: FastifyReply) {
const id = Number(request.params.id); const id = Number(request.params.id);
if (!id) return reply.status(400).send({ error: 'Missing id' }); if (!id) return reply.status(400).send({ error: 'Missing id' });
const endpoint = await request.em.findOne(CanvasApiEndpoint, { id }); const endpoint = await request.em.findOne(CanvasApiEndpoint, { id });
if (!endpoint) return reply.status(404).send({ error: 'Not found' }); if (!endpoint) return reply.status(404).send({ error: 'Not found' });
const { name, method, path, description } = request.body as any; const { name, method, path, description } = request.body;
if (name) endpoint.name = name; if (name) endpoint.name = name;
if (method) endpoint.method = method; if (method) endpoint.method = method;
if (path) endpoint.path = (path as string).replace(/^\/api\/v1/, ''); if (path) endpoint.path = path.replace(/^\/api\/v1/, '');
if (description !== undefined) endpoint.description = description; if (description !== undefined) endpoint.description = description;
await request.em.persistAndFlush(endpoint); await request.em.persistAndFlush(endpoint);
return reply.send(endpoint); return reply.send(endpoint);

@ -11,6 +11,21 @@ interface CanvasProxyRequestInput {
params?: any; params?: any;
} }
interface CreateEndpointBody {
name: string;
method: string;
path: string;
description?: string;
user?: any;
}
interface UpdateEndpointBody {
name?: string;
method?: string;
path?: string;
description?: string;
}
const endpointsRoutes: FastifyPluginAsync = async (app) => { const endpointsRoutes: FastifyPluginAsync = async (app) => {
const controller = new CanvasApiEndpointController(); const controller = new CanvasApiEndpointController();
@ -18,26 +33,26 @@ const endpointsRoutes: FastifyPluginAsync = async (app) => {
return controller.getAll(request, reply); return controller.getAll(request, reply);
}); });
app.post('/endpoints', async (request: FastifyRequest, reply: FastifyReply) => { app.post('/endpoints', async (request: FastifyRequest<{ Body: CreateEndpointBody }>, reply: FastifyReply) => {
return controller.create(request, reply); return controller.create(request, reply);
}); });
app.delete('/endpoints/:id', async (request: FastifyRequest, reply: FastifyReply) => { app.delete('/endpoints/:id', async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
return controller.delete(request, reply); return controller.delete(request, reply);
}); });
app.put('/endpoints/:id', async (request: FastifyRequest, reply: FastifyReply) => { app.put('/endpoints/:id', async (request: FastifyRequest<{ Params: { id: string }; Body: UpdateEndpointBody }>, reply: FastifyReply) => {
return controller.update(request, reply); return controller.update(request, reply);
}); });
// Dynamic proxy route for external Canvas REST API // Dynamic proxy route for external Canvas REST API
app.post('/proxy-external', async (request: FastifyRequest, reply: FastifyReply) => { app.post('/proxy-external', async (request: FastifyRequest<{ Body: CanvasProxyRequestInput }>, reply: FastifyReply) => {
try { try {
let baseUrl = process.env.CANVAS_API_URL || 'https://talnet.instructure.com'; let baseUrl = process.env.CANVAS_API_URL || 'https://talnet.instructure.com';
// Remove trailing /api/v1 if present // Remove trailing /api/v1 if present
baseUrl = baseUrl.replace(/\/api\/v1$/, ''); baseUrl = baseUrl.replace(/\/api\/v1$/, '');
const apiKey = process.env.CANVAS_API_KEY || ''; const apiKey = process.env.CANVAS_API_KEY || '';
const { path, method = 'GET', params } = request.body as CanvasProxyRequestInput; const { path, method = 'GET', params } = request.body;
if (!path) return reply.status(400).send({ error: 'Missing path' }); if (!path) return reply.status(400).send({ error: 'Missing path' });
if (!ALLOWED_METHODS.includes(method as Method)) { if (!ALLOWED_METHODS.includes(method as Method)) {
return reply.status(405).send({ error: `Method ${method} not allowed` }); return reply.status(405).send({ error: `Method ${method} not allowed` });

@ -509,6 +509,16 @@
"primary": false, "primary": false,
"nullable": true, "nullable": true,
"mappedType": "integer" "mappedType": "integer"
},
"public_path": {
"name": "public_path",
"type": "varchar(255)",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"length": 255,
"mappedType": "string"
} }
}, },
"name": "canvas_api_endpoints", "name": "canvas_api_endpoints",
@ -592,6 +602,15 @@
"primary": false, "primary": false,
"nullable": false, "nullable": false,
"mappedType": "integer" "mappedType": "integer"
},
"app_id": {
"name": "app_id",
"type": "int",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "integer"
} }
}, },
"name": "apikey", "name": "apikey",
@ -621,6 +640,18 @@
], ],
"referencedTableName": "public.user", "referencedTableName": "public.user",
"updateRule": "cascade" "updateRule": "cascade"
},
"apikey_app_id_foreign": {
"constraintName": "apikey_app_id_foreign",
"columnNames": [
"app_id"
],
"localTableName": "public.apikey",
"referencedColumnNames": [
"id"
],
"referencedTableName": "public.app",
"updateRule": "cascade"
} }
}, },
"nativeEnums": {} "nativeEnums": {}

@ -11,13 +11,14 @@ export class Migration20240415134130 extends Migration {
this.addSql('create table "tenant_app" ("id" serial primary key, "created_at" timestamptz not null, "updated_at" timestamptz not null, "tenant_id" int not null, "app_id" int not null, "user_id" int not null);'); this.addSql('create table "tenant_app" ("id" serial primary key, "created_at" timestamptz not null, "updated_at" timestamptz not null, "tenant_id" int not null, "app_id" int not null, "user_id" int not null);');
this.addSql('create table "apikey" ("id" serial primary key, "created_at" timestamptz not null, "updated_at" timestamptz not null, "key" varchar(255) not null, "user_id" int not null);'); this.addSql('create table "apikey" ("id" serial primary key, "created_at" timestamptz not null, "updated_at" timestamptz not null, "key" varchar(255) not null, "user_id" int not null, "app_id" int not null);');
this.addSql('alter table "tenant_app" add constraint "tenant_app_tenant_id_foreign" foreign key ("tenant_id") references "tenant" ("id") on update cascade;'); this.addSql('alter table "tenant_app" add constraint "tenant_app_tenant_id_foreign" foreign key ("tenant_id") references "tenant" ("id") on update cascade;');
this.addSql('alter table "tenant_app" add constraint "tenant_app_app_id_foreign" foreign key ("app_id") references "app" ("id") on update cascade;'); this.addSql('alter table "tenant_app" add constraint "tenant_app_app_id_foreign" foreign key ("app_id") references "app" ("id") on update cascade;');
this.addSql('alter table "tenant_app" add constraint "tenant_app_user_id_foreign" foreign key ("user_id") references "user" ("id") on update cascade;'); this.addSql('alter table "tenant_app" add constraint "tenant_app_user_id_foreign" foreign key ("user_id") references "user" ("id") on update cascade;');
this.addSql('alter table "apikey" add constraint "apikey_user_id_foreign" foreign key ("user_id") references "user" ("id") on update cascade;'); this.addSql('alter table "apikey" add constraint "apikey_user_id_foreign" foreign key ("user_id") references "user" ("id") on update cascade;');
this.addSql('alter table "apikey" add constraint "apikey_app_id_foreign" foreign key ("app_id") references "app" ("id") on update cascade;');
} }
async down(): Promise<void> { async down(): Promise<void> {
@ -28,6 +29,7 @@ export class Migration20240415134130 extends Migration {
this.addSql('alter table "tenant_app" drop constraint "tenant_app_user_id_foreign";'); this.addSql('alter table "tenant_app" drop constraint "tenant_app_user_id_foreign";');
this.addSql('alter table "apikey" drop constraint "apikey_user_id_foreign";'); this.addSql('alter table "apikey" drop constraint "apikey_user_id_foreign";');
this.addSql('alter table "apikey" drop constraint "apikey_app_id_foreign";');
this.addSql('drop table if exists "app" cascade;'); this.addSql('drop table if exists "app" cascade;');

@ -6,6 +6,8 @@ export class Migration20250502161232 extends Migration {
this.addSql(`create table "canvas_api_endpoints" ("id" serial primary key, "created_at" timestamptz not null, "updated_at" timestamptz not null, "name" varchar(255) not null, "method" varchar(255) not null, "path" varchar(255) not null, "description" varchar(255) null, "user_id" int null);`); this.addSql(`create table "canvas_api_endpoints" ("id" serial primary key, "created_at" timestamptz not null, "updated_at" timestamptz not null, "name" varchar(255) not null, "method" varchar(255) not null, "path" varchar(255) not null, "description" varchar(255) null, "user_id" int null);`);
this.addSql(`alter table "canvas_api_endpoints" add constraint "canvas_api_endpoints_user_id_foreign" foreign key ("user_id") references "user" ("id") on update cascade on delete set null;`); this.addSql(`alter table "canvas_api_endpoints" add constraint "canvas_api_endpoints_user_id_foreign" foreign key ("user_id") references "user" ("id") on update cascade on delete set null;`);
this.addSql('alter table "canvas_api_endpoints" add column "public_path" varchar(255) null;');
} }
override async down(): Promise<void> { override async down(): Promise<void> {

@ -1,13 +0,0 @@
import { Migration } from '@mikro-orm/migrations';
export class Migration20250505072850 extends Migration {
override async up(): Promise<void> {
this.addSql(`alter table "canvas_api_endpoints" drop column "dummy";`);
}
override async down(): Promise<void> {
this.addSql(`alter table "canvas_api_endpoints" add column "dummy" varchar(255) null;`);
}
}

@ -1,14 +1,17 @@
import { FastifyPluginAsync } from 'fastify'; import { FastifyPluginAsync } from 'fastify';
import userRoutes from './apps/_app/routes/UserRoutes'; import userRoutes from './apps/_app/routes/UserRoutes';
import canvasRoutes from './apps/canvas-api/canvasRouter'; import canvasRoutes from './apps/canvas-api/canvasRouter';
import apiKeyRoutes from './apps/_app/routes/APIKeyRoutes';
import appRoutes from './apps/_app/routes/AppRoutes';
const routesPlugin: FastifyPluginAsync = async (app) => { const routesPlugin: FastifyPluginAsync = async (app) => {
try { try {
/////////////////////////////////////// ///////////////////////////////////////
// TODO: Define routes in separate list // TODO: Define routes in separate list
await app.register(userRoutes, { prefix: '/app/users' }); await app.register(userRoutes, { prefix: '/app/users' });
await app.register(apiKeyRoutes, { prefix: '/app/apikey' });
await app.register(canvasRoutes, { prefix: '/canvas-api' }); await app.register(canvasRoutes, { prefix: '/canvas-api' });
await app.register(appRoutes, { prefix: '/app' });
app.setErrorHandler((error, request, reply) => { app.setErrorHandler((error, request, reply) => {
if (!reply.sent) { if (!reply.sent) {