diff --git a/.env.prod b/.env.prod
new file mode 100644
index 0000000..3304d85
--- /dev/null
+++ b/.env.prod
@@ -0,0 +1 @@
+MYSQL_ROOT_PASSWORD=root
\ No newline at end of file
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 350b13a..c66acf6 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -8,14 +8,14 @@ on:
branches:
- main
-jobs:
+jobs:
build-test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
-
+ # frontend setup
- name: Set up Node
uses: actions/setup-node@v4
with:
@@ -25,11 +25,25 @@ jobs:
working-directory: ./client
run: npm install
+ # backend setup
- name: Set up PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
+ extensions: mbstring, pdo, pdo_sqlite
+ coverage: none
+ ini-values: "pdo_sqlite.journal_mode=WAL"
- name: Install backend deps
working-directory: ./server
run: composer install --no-interaction
+
+ - name: Setup backend environment
+ working-directory: ./server
+ run: |
+ cp .env.example .env
+ php artisan key:generate
+
+ - name: Run backend tests
+ working-directory: ./server
+ run: php artisan test --no-coverage
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1c4a9c8
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+./.env.prod
\ No newline at end of file
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..8bc952f
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 MohammadRostom
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index 8ee02f2..ab89ddd 100644
--- a/README.md
+++ b/README.md
@@ -1,20 +1,23 @@
-
FlowPilot
-
-
-
-
-
+
+
+
+
+
+
- Flowpilot is a one of a kind n8n workflow generator.Generation of production ready, deployable and testable workflows is what Flowpilot strives in.
+ Flowpilot is a one of a kind n8n workflow generator.Generation of production ready, deployable and testable workflows is what Flowpilot strives in.Flowpilot helps coders
+ and none coders alike.Its designed to specifically understand your intent and translate it
+ into a production ready workflow.
-
-Architecture
-
+
+
+
+
-
+
Frontend
React
-
+
Backend
Laravel
+
+
+ Database
+ MySQL
+
-Communication Flow
-
+General Communication Flow
-
High-level data flow between the frontend, backend and database.
-
+
+
+
+Copilot Communication Flow
-
n8n Generation workflow between the frontend, backend, generation module, LLM, vector-db and an open SSE connection.
+
+Sequence Diagram
+
+
+ER Diagram
+
+
+
+
+
+
-System Design
+
+
+
+
+
AI Copilot
+
An intelligent workflow generation assistant that uses LLM technology to understand user intent and automatically generate production-ready n8n workflows.
+
+ Real-time streaming workflow generation with progress tracking
+ Multi-stage generation pipeline with user feedback
+ Workflow history and chat management
+ Trace and debugging support for generated workflows
+ Server-sent events (SSE) connection for live updates
+
+
+
+
+
+
+
+
+
Community Hub
+
A collaborative platform for automation builders to share workflows, tips, and integrations with the n8n community.
+
+ Infinite scroll feed of community posts and workflows
+ Create and share posts with the community
+ Browse and discover shared workflows by other users
+ User profile cards with avatar and metadata
+ Engage with other builders in the n8n ecosystem
+
+
+
+
+
+
+
+
+
+
User Profiles
+
Comprehensive user profile system with detailed information and engagement statistics across the platform.
+
+ Customizable avatars and profile pictures
+ Display user workflows and community posts
+ View follower and following lists
+ Track engagement metrics (likes, imports on workflows/posts)
+ Manage profile settings and preferences
+ Download workflow content and history
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ This project follows a structured Git workflow designed to keep the codebase clean,
+ scalable, and production-ready at all times.
+
+
+
+
+Branching Model
-Sequence Diagram
-
-
- Frontend
- React
-
-
-
- Backend
- Laravel
-
+ Branch
+ Purpose
-
-
-
- Frontend
- React
-
-
-
- Backend
- Laravel
-
+
+ main
+ Production-ready, always stable and deployable
+
+
+ dev
+ Integration branch where completed features are merged
-ER Diagram
+
+No direct commits are made to main .
+
+---
+
+Branch Types
+
+All work is created from dev using task-based branches:
+
+
+
+ Prefix
+ Usage
+
+ feature/New features
+ fix/Bug fixes
+ hotfix/Urgent production fixes
+ refactor/Code improvements
+ chore/Tooling, config, dependencies
+ docs/Documentation updates
+
+
+Naming format: type/short-description
+
+Examples:
+```bash
+feature/user-auth
+fix/login-validation
+refactor/api-layer
+docs/readme-update
+```
+
+---
+
+Commit Philosophy
+
+Each commit should represent one logical change .
+
+We follow the Conventional Commits standard:
+
+```
+type(scope): short description
+```
+
+Examples:
+```bash
+feat(auth): add JWT login endpoint
+fix(api): prevent crash on null response
+refactor(db): extract query builder
+docs(readme): add deployment guide
+```
+
+---
+
+Development Loop
+
+Here’s the exact workflow cycle used in this project:
+
+```bash
+# Start from dev
+git checkout dev
+git pull
+
+# Create a feature branch
+git checkout -b feature/follow-system
+
+# Work and commit in small logical steps
+git commit -m "feat(db): add follows table"
+git commit -m "feat(api): add follow endpoints"
+
+# Merge back into dev
+git checkout dev
+git merge feature/follow-system
+git push
+```
+
+When the release is stable:
+
+```bash
+# Release to production
+git checkout main
+git pull
+git merge dev
+git push
+
+# Sync dev with production
+git checkout dev
+git merge main
+git push
+```
+
+---
+
+Why This Workflow?
+
+This structure ensures:
+
+- Clean and readable commit history
+- Safe collaboration and easier debugging
+- `main` is always deployable
+- Features never break production
+- Scalable long-term development
+
+---
+
-
-
\ No newline at end of file
+ In short: build on dev, ship from main, and keep commits clean and meaningful.
+
+
+
+
+
+
+This project was built using a test-driven and validation-first mindset.
+Every major feature was developed alongside tests to ensure reliability, prevent regressions, and maintain production-grade stability as the system evolved.
+
+Unit tests
+
+
+
+
+MIT LICENSE
+
diff --git a/client/Dockerfile.dev b/client/Dockerfile.dev
new file mode 100644
index 0000000..9e0c55d
--- /dev/null
+++ b/client/Dockerfile.dev
@@ -0,0 +1,6 @@
+FROM node:20-alpine
+WORKDIR /app
+COPY package*.json ./
+RUN npm install
+EXPOSE 3000
+CMD ["npm", "run", "dev"]
diff --git a/client/DockerFile b/client/Dockerfile.prod
similarity index 100%
rename from client/DockerFile
rename to client/Dockerfile.prod
diff --git a/client/package.json b/client/package.json
index a09ea8b..a2eec0d 100644
--- a/client/package.json
+++ b/client/package.json
@@ -7,7 +7,9 @@
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
- "preview": "vite preview"
+ "preview": "vite preview",
+ "docker:dev": "docker compose -f docker-compose.dev.yml up --build",
+ "docker:prod": "docker compose -f docker-compose.prod.yml up --build -d"
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
diff --git a/client/src/App.tsx b/client/src/App.tsx
index 115aab1..d25e41a 100644
--- a/client/src/App.tsx
+++ b/client/src/App.tsx
@@ -32,14 +32,8 @@ import SettingsPage from './Pages/Settings/Settings'
// objectives for today:
/**
- * Use hook to send copilot requests
- * Authenticate all requests
- * Clean copilot backend
- * Clean copilot frontend
+ *
* Create Tests for everything except copilot
- * Create CI CD
- * Deploy
- * Tommorrow we need to start langchain giving us 3/4 days headstart so today everything must be perfectly done and dusted
*/
diff --git a/client/src/Pages/community/Community.tsx b/client/src/Pages/community/Community.tsx
index eb292c5..10f6d64 100644
--- a/client/src/Pages/community/Community.tsx
+++ b/client/src/Pages/community/Community.tsx
@@ -8,11 +8,14 @@ import CreatePostModal from "./components/CreatePostModal";
import { adaptCommunityPost } from "./adapters/CommunityPostAdapter";
import { useAuth } from "../../context/useAuth";
import { Spinner } from "../components/Spinner";
+import { useCreatePost } from "./hook/useCreatePost";
const CommunityPage: React.FC = () => {
const { user, loading } = useAuth();
const [showModal, setShowModal] = useState(false);
+ const createPost = useCreatePost();
+
const handleStartPost = () => setShowModal(true);
@@ -21,7 +24,7 @@ const CommunityPage: React.FC = () => {
}
const {
- posts,
+ pages,
isPending,
isFetchingMore,
hasMore,
@@ -58,9 +61,11 @@ const CommunityPage: React.FC = () => {
))}
- {posts.map((post) => (
-
- ))}
+ {pages.map((page) =>
+ page.data.map((post) => (
+
+ ))
+ )}
@@ -102,6 +107,7 @@ const CommunityPage: React.FC = () => {
setShowModal(false)}
+ createPost={createPost}
/>
>
);
diff --git a/client/src/Pages/community/community.css b/client/src/Pages/community/community.css
index f33d9f9..b71e040 100644
--- a/client/src/Pages/community/community.css
+++ b/client/src/Pages/community/community.css
@@ -541,6 +541,7 @@ body {
/* Post title */
.post-title {
margin-top: 14px;
+ margin-bottom: 14px;
font-size: 18px;
font-weight: 700;
color: #f5b642;
diff --git a/client/src/Pages/community/components/CommentItem.tsx b/client/src/Pages/community/components/CommentItem.tsx
index 146fa6c..2dc8a4d 100644
--- a/client/src/Pages/community/components/CommentItem.tsx
+++ b/client/src/Pages/community/components/CommentItem.tsx
@@ -2,7 +2,6 @@ import { useToggleCommentLike } from "../hook/useToggleCommentLike";
const CommentItem: React.FC<{ comment: any }> = ({ comment }) => {
const toggleLike = useToggleCommentLike(comment.post_id);
- console.log(comment);
return (
{comment.user?.photo_url ? (
diff --git a/client/src/Pages/community/components/CreatePostModal.tsx b/client/src/Pages/community/components/CreatePostModal.tsx
index d784910..5ce38be 100644
--- a/client/src/Pages/community/components/CreatePostModal.tsx
+++ b/client/src/Pages/community/components/CreatePostModal.tsx
@@ -4,33 +4,32 @@ import { useCreatePost } from "../hook/useCreatePost";
type Props = {
isOpen: boolean;
onClose: () => void;
+ createPost: ReturnType
;
};
-const CreatePostModal: React.FC = ({ isOpen, onClose }) => {
+const CreatePostModal: React.FC = ({ isOpen, onClose, createPost }) => {
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [file, setFile] = useState(null);
const [image, setImage] = useState(null);
- const createPost = useCreatePost();
-
if (!isOpen) return null;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
- createPost.mutate({
- title,
- description,
- file: file ?? undefined,
- image: image ?? undefined,
- });
-
setTitle("");
setDescription("");
setFile(null);
setImage(null);
onClose();
+
+ createPost.mutate({
+ title,
+ description,
+ file: file ?? undefined,
+ image: image ?? undefined,
+ });
};
const handleImageDrop = (e: React.DragEvent) => {
@@ -49,7 +48,6 @@ const CreatePostModal: React.FC = ({ isOpen, onClose }) => {
Create New Post
- {/* LEFT COLUMN: Image */}
= ({ isOpen, onClose }) => {
- {/* RIGHT COLUMN: Form */}
)}
-
{post.title &&
{post.title} }
diff --git a/client/src/Pages/community/components/PostCardSkeleton.tsx b/client/src/Pages/community/components/PostCardSkeleton.tsx
index bab5832..be74aae 100644
--- a/client/src/Pages/community/components/PostCardSkeleton.tsx
+++ b/client/src/Pages/community/components/PostCardSkeleton.tsx
@@ -8,8 +8,7 @@ export const PostCardSkeleton = () =>{
-
-
+
diff --git a/client/src/Pages/community/hook/useCreatePost.ts b/client/src/Pages/community/hook/useCreatePost.ts
index 399effe..84da499 100644
--- a/client/src/Pages/community/hook/useCreatePost.ts
+++ b/client/src/Pages/community/hook/useCreatePost.ts
@@ -3,6 +3,8 @@ import { api } from "../../../api/client";
import { returnDataFormat } from "../../utils/returnApiDataFormat";
import { useToast } from "../../../context/toastContext";
import { ToastMessage } from "../../components/toast/toast.types";
+import { useAuth } from "../../../context/useAuth";
+import type { PostDto } from "../types";
interface CreatePostInputs {
title: string;
@@ -14,19 +16,43 @@ interface CreatePostInputs {
export const useCreatePost = () => {
const queryClient = useQueryClient();
const { showToast } = useToast();
+ const { user } = useAuth();
return useMutation({
mutationFn: async (inputs: CreatePostInputs) => createNewPost(inputs),
- onError: (_err, _newPost, context: any) => {
- if (context?.previousPosts) {
- queryClient.setQueryData(["posts"], context.previousPosts);
- }
+ onMutate: async (inputs: CreatePostInputs) => {
+ await queryClient.cancelQueries({ queryKey: ["community-posts"] });
+
+ const previousData = queryClient.getQueryData(["community-posts"]);
+
+ const optimisticPost: PostDto = {
+ id: Date.now(), // Temporary ID
+ author: `${user?.first_name} ${user?.last_name}`.trim(),
+ username: user?.email || "User",
+ avatar: user?.profile_pic || null,
+ title: inputs.title,
+ content: inputs.description || "",
+ photo: "", // Will be updated after server response
+ likes: 0,
+ comments: 0,
+ exports: 0,
+ liked_by_me: false,
+ };
+
+ queryClient.setQueryData(
+ ["community-posts"],
+ prependOptimisticPost(optimisticPost)
+ );
+
+ return { previousData };
},
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ["posts"] });
- showToast("Post released successfully" , ToastMessage.SUCCESS);
+ onError: (_err, _inputs, context: any) => {
+ if(context?.previousData){
+ queryClient.setQueryData(["community-posts"], context.previousData);
+ }
+ showToast("Failed to create post", ToastMessage.ERROR);
},
});
};
@@ -52,3 +78,22 @@ const createNewPost = async (inputs : CreatePostInputs) =>{
return returnDataFormat(resp);
}
+
+
+const prependOptimisticPost =
+ (optimisticPost: PostDto) =>
+ (old: any) => {
+ if (!old) return old;
+
+ return {
+ ...old,
+ pages: old.pages.map((page: any, index: number) =>
+ index === 0
+ ? {
+ ...page,
+ data: [optimisticPost, ...page.data],
+ }
+ : page
+ ),
+ };
+ };
diff --git a/client/src/Pages/community/hook/useFetchPosts.ts b/client/src/Pages/community/hook/useFetchPosts.ts
index 5702195..f76003f 100644
--- a/client/src/Pages/community/hook/useFetchPosts.ts
+++ b/client/src/Pages/community/hook/useFetchPosts.ts
@@ -22,6 +22,7 @@ export function useFetchPosts() {
query.data?.pages.flatMap((page) => page.data) ?? [];
return {
+ pages: query.data?.pages ?? [],
posts,
isPending: query.isPending,
isFetchingMore: query.isFetchingNextPage,
diff --git a/client/src/Pages/community/hook/useToggleLike.ts b/client/src/Pages/community/hook/useToggleLike.ts
index e3cc54b..0602832 100644
--- a/client/src/Pages/community/hook/useToggleLike.ts
+++ b/client/src/Pages/community/hook/useToggleLike.ts
@@ -2,7 +2,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../../../api/client";
import { returnDataFormat } from "../../utils/returnApiDataFormat";
-export function useToggleLike() {
+export function useToggleLike(userId?: string | number) {
const queryClient = useQueryClient();
return useMutation({
@@ -10,27 +10,44 @@ export function useToggleLike() {
onMutate: async () => {
await queryClient.cancelQueries({ queryKey: ["community-posts"] });
+ if (userId) {
+ await queryClient.cancelQueries({ queryKey: ["profile-details", userId] });
+ }
},
onSuccess: (data, postId) => {
queryClient.setQueryData(["community-posts"], (old: any) => {
if (!old) return old;
-
return liveUpdateOnSuccess(old , postId , data);
});
+
+ if (userId) {
+ queryClient.setQueryData(["profile-details", userId], (old: any) => {
+ if (!old?.posts?.items) return old;
+
+ return {
+ ...old,
+ posts: {
+ ...old.posts,
+ items: old.posts.items.map((post: any) =>
+ post.id === postId
+ ? { ...post, likes: data.likes, liked_by_me: data.liked }
+ : post
+ ),
+ },
+ };
+ });
+ }
},
});
}
-
-
const toggleLike = async (postId : number)=>{
const response = await api.post(`auth/community/toggleLike/${postId}`);
return returnDataFormat(response);
}
-
const liveUpdateOnSuccess = (old : any , postId : number , data : any) =>{
return {
...old,
diff --git a/client/src/Pages/copilot/hooks/data/streamResponse.ts b/client/src/Pages/copilot/hooks/data/streamResponse.ts
index 078cb0a..b641c1d 100644
--- a/client/src/Pages/copilot/hooks/data/streamResponse.ts
+++ b/client/src/Pages/copilot/hooks/data/streamResponse.ts
@@ -44,6 +44,23 @@ export const streamCopilotQuestion = (
evt.close();
});
+ evt.addEventListener("postWorkflowSuccess", (e) => {
+ try {
+ const data = JSON.parse(e.data);
+ showToast(data.message ?? "Workflow posted successfully to n8n instance.", "success");
+ }catch{
+ showToast("Workflow posted successfully to n8n instance.", "success");
+ }
+ });
+
+ evt.addEventListener("postWorkflowFailed", (e) => {
+ try {
+ const data = JSON.parse(e.data);
+ showToast(data.message ?? "Failed to post workflow to n8n instance.", "error");
+ }catch{
+ showToast("Failed to post workflow to n8n instance.", "error");
+ }
+ });
evt.onerror = () =>{
onError?.();
diff --git a/client/src/Pages/profile/Profile.tsx b/client/src/Pages/profile/Profile.tsx
index 51b520d..1516bd5 100644
--- a/client/src/Pages/profile/Profile.tsx
+++ b/client/src/Pages/profile/Profile.tsx
@@ -27,7 +27,7 @@ const ProfilePage: React.FC = () => {
isPending,
} = useProfileQuery(userId);
- const { mutate: downloadHistory, isPending: isDownloading } =
+ const { mutate: downloadHistory, isPending: isDownloading, downloadingId } =
useDownloadHistory();
const { mutate: followUser } = useFollowUser();
@@ -95,20 +95,22 @@ const ProfilePage: React.FC = () => {
{tab === "posts" ? (
-
+
) : (
isOwnProfile ? (
downloadHistory(url)}
+ onDownload={(url: string, id: string | number) => downloadHistory(url, id)}
isDownloading={isDownloading}
+ downloadingId={downloadingId}
/>
) : (
isBeingFollowed?.isFollowing && (
downloadHistory(url)}
+ onDownload={(url: string, id: string | number) => downloadHistory(url, id)}
isDownloading={isDownloading}
+ downloadingId={downloadingId}
/>
)
)
diff --git a/client/src/Pages/profile/components/PostsList.tsx b/client/src/Pages/profile/components/PostsList.tsx
index bea51e9..64d47dd 100644
--- a/client/src/Pages/profile/components/PostsList.tsx
+++ b/client/src/Pages/profile/components/PostsList.tsx
@@ -7,9 +7,10 @@ import { SortType } from "../types";
type Props = {
posts: any[];
sortBy: SortType;
+ userId?: string | number;
};
-const PostsList: React.FC = ({ posts, sortBy }) => {
+const PostsList: React.FC = ({ posts, sortBy, userId }) => {
const sortedPosts = useMemo(() => {
const list = [...posts];
if (sortBy === SortType.LIKES) {
@@ -36,6 +37,7 @@ const PostsList: React.FC = ({ posts, sortBy }) => {
post={adaptListPost(p)}
showHeader={false}
showActions={true}
+ userId={userId}
/>
))}
diff --git a/client/src/Pages/profile/components/WorkflowList.tsx b/client/src/Pages/profile/components/WorkflowList.tsx
index 9194e17..7f0edc0 100644
--- a/client/src/Pages/profile/components/WorkflowList.tsx
+++ b/client/src/Pages/profile/components/WorkflowList.tsx
@@ -3,11 +3,12 @@ import { Loader2, Download } from "lucide-react";
type Props = {
workflows: any[];
- onDownload: (url: string) => void;
+ onDownload: (url: string, id: string | number) => void;
isDownloading?: boolean;
+ downloadingId?: string | number | null;
};
-const WorkflowsList: React.FC = ({ workflows, onDownload , isDownloading }) => {
+const WorkflowsList: React.FC = ({ workflows, onDownload , isDownloading, downloadingId }) => {
if (!workflows || workflows.length === 0) {
return No workflows / history available.
;
}
@@ -15,26 +16,31 @@ const WorkflowsList: React.FC = ({ workflows, onDownload , isDownloading
return (
);
diff --git a/client/src/Pages/profile/hook/useFollowUser.ts b/client/src/Pages/profile/hook/useFollowUser.ts
index e4aa1b3..7de24b3 100644
--- a/client/src/Pages/profile/hook/useFollowUser.ts
+++ b/client/src/Pages/profile/hook/useFollowUser.ts
@@ -10,45 +10,89 @@ export const useFollowUser = () =>{
const queryClient = useQueryClient();
const { showToast } = useToast();
return useMutation({
- mutationFn: (param : FollowUserParam) => followUser(param),
+ mutationFn: (userId : FollowUserParam) => followUser(userId),
onMutate: async (userId) => {
+ if (!userId) return;
+
await queryClient.cancelQueries({ queryKey: ["is-being-followed", userId] });
+ await queryClient.cancelQueries({ queryKey: ["profile-details", userId] });
- const previous = queryClient.getQueryData(["is-being-followed", userId]);
+ // Get previous data for rollback
+ const previousFollowStatus = queryClient.getQueryData(["is-being-followed", userId]);
+ const previousProfile = queryClient.getQueryData(["profile-details", userId]);
+ // Update follow status
queryClient.setQueryData(["is-being-followed", userId], (old: any) => {
if (!old) return old;
return {
- ...old,
- isFollowing: !old.isFollowing,
+ ...old,
+ isFollowing: !old.isFollowing,
};
});
- return { previous };
+ // Update followers count in profile
+ queryClient.setQueryData(["profile-details", userId], (old: any) => {
+ if (!old) return old;
+ const currentFollowStatus = queryClient.getQueryData(["is-being-followed", userId]);
+ const isNowFollowing = currentFollowStatus?.isFollowing;
+
+ const followers = old.followers || [];
+ const authUser = queryClient.getQueryData(["profile-details", "me"])?.user;
+
+ if (isNowFollowing && authUser) {
+ // Add current user to followers if following
+ return {
+ ...old,
+ followers: [
+ {
+ id: authUser.id,
+ full_name: `${authUser.first_name} ${authUser.last_name}`.trim(),
+ photo_url: authUser.photo_url,
+ email: authUser.email,
+ },
+ ...followers,
+ ],
+ };
+ } else {
+ // Remove current user from followers if unfollowing
+ return {
+ ...old,
+ followers: followers.filter((f: any) => f.id !== authUser?.id),
+ };
+ }
+ });
+
+ return { previousFollowStatus, previousProfile };
},
onError: (err, userId, context) => {
+ if (!userId) return;
queryClient.setQueryData(
["is-being-followed", userId],
- context?.previous
+ context?.previousFollowStatus
+ );
+ queryClient.setQueryData(
+ ["profile-details", userId],
+ context?.previousProfile
);
handleApiError(err , showToast);
},
onSuccess: (_, userId) => {
- if (!userId) return;
+ if (!userId) return;
- queryClient.invalidateQueries({
- queryKey: ["is-being-followed", userId],
- });
+ queryClient.invalidateQueries({
+ queryKey: ["is-being-followed", userId],
+ });
- queryClient.invalidateQueries({
- queryKey: ["profile-details", userId],
- });
+ queryClient.invalidateQueries({
+ queryKey: ["profile-details", userId],
+ });
},
})
}
-const followUser = async (param: FollowUserParam) =>{
- const res = api.post(`auth/profile/follow/${param}`);
+const followUser = async (userId: FollowUserParam) =>{
+ if (!userId) throw new Error("User ID is required");
+ const res = await api.post(`auth/profile/follow/${userId}`);
return returnDataFormat(res);
}
\ No newline at end of file
diff --git a/client/src/Pages/profile/hook/useGetDownloadContent.ts b/client/src/Pages/profile/hook/useGetDownloadContent.ts
index 566c8db..4b7497f 100644
--- a/client/src/Pages/profile/hook/useGetDownloadContent.ts
+++ b/client/src/Pages/profile/hook/useGetDownloadContent.ts
@@ -1,17 +1,38 @@
import { useMutation } from "@tanstack/react-query"
import { api } from "../../../api/client";
+import { useState } from "react";
export const useDownloadHistory = () => {
- return useMutation({
- mutationFn: downloadHistoryRequest,
+ const [downloadingId, setDownloadingId] = useState(null);
+
+ const mutation = useMutation({
+ mutationFn: (data: { url: string; id: string | number }) => {
+ setDownloadingId(data.id);
+ return downloadHistoryRequest(data.url);
+ },
onSuccess: (blobData) => {
createDownloadFile(blobData)
},
+ onSettled: () => {
+ setDownloadingId(null);
+ },
});
+
+ return {
+ mutate: (url: string, id: string | number) => mutation.mutate({ url, id }),
+ isPending: mutation.isPending,
+ downloadingId,
+ };
};
const downloadHistoryRequest = async (url: string) => {
- const res = await api.get("auth/profile" + url, {
+ // Check if URL is absolute (contains http/https)
+ const isAbsoluteUrl = url.startsWith('http://') || url.startsWith('https://');
+
+ // If absolute URL, use it directly; otherwise, prepend the auth/profile path
+ const endpoint = isAbsoluteUrl ? url : "auth/profile" + url;
+
+ const res = await api.get(endpoint, {
responseType: "blob",
});
diff --git a/client/src/Pages/profile/profile.css b/client/src/Pages/profile/profile.css
index 376a162..8584410 100644
--- a/client/src/Pages/profile/profile.css
+++ b/client/src/Pages/profile/profile.css
@@ -236,7 +236,6 @@
}
.post-header { display:flex; justify-content:space-between; align-items:center; gap:12px; }
-.post-title { margin:0; font-size:16px; }
.post-body { margin:8px 0; color: rgba(255,255,255,0.9); }
.post-stats { display:flex; gap:12px; font-size:13px; opacity:0.95; }
diff --git a/client/src/assets/README-Diagrams/SD-1.png b/client/src/assets/README-Diagrams/SD-1.png
deleted file mode 100644
index b46fddb..0000000
Binary files a/client/src/assets/README-Diagrams/SD-1.png and /dev/null differ
diff --git a/client/src/assets/README-Diagrams/SD-2.png b/client/src/assets/README-Diagrams/SD-2.png
deleted file mode 100644
index 7de96ed..0000000
Binary files a/client/src/assets/README-Diagrams/SD-2.png and /dev/null differ
diff --git a/client/src/assets/README-Diagrams/SD-3.png b/client/src/assets/README-Diagrams/SD-3.png
deleted file mode 100644
index 06656cb..0000000
Binary files a/client/src/assets/README-Diagrams/SD-3.png and /dev/null differ
diff --git a/client/src/assets/README-Diagrams/SD-4.png b/client/src/assets/README-Diagrams/SD-4.png
deleted file mode 100644
index 01d1d21..0000000
Binary files a/client/src/assets/README-Diagrams/SD-4.png and /dev/null differ
diff --git a/client/src/styles/Copilot.css b/client/src/styles/Copilot.css
index 80d1a64..855a63c 100644
--- a/client/src/styles/Copilot.css
+++ b/client/src/styles/Copilot.css
@@ -750,6 +750,10 @@
word-wrap: break-word;
}
+.trace-block li{
+ margin-left: 12px;
+}
+
.kv, .kv-value, .typed-line {
max-width: 100%;
white-space: pre-wrap;
diff --git a/client/vite.config.ts b/client/vite.config.ts
index 8b0f57b..4da55a1 100644
--- a/client/vite.config.ts
+++ b/client/vite.config.ts
@@ -4,4 +4,14 @@ import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
+ server: {
+ host: true,
+ port: 3000,
+ proxy: {
+ '/api': {
+ target: 'http://backend_nginx:80',
+ changeOrigin: true,
+ },
+ },
+ },
})
diff --git a/docker-compose.yml b/docker-compose.dev.yml
similarity index 60%
rename from docker-compose.yml
rename to docker-compose.dev.yml
index c6508c3..0eb487a 100644
--- a/docker-compose.yml
+++ b/docker-compose.dev.yml
@@ -1,18 +1,30 @@
version: "3.9"
services:
+ frontend:
+ build:
+ context: ./client
+ dockerfile: Dockerfile.dev
+ ports:
+ - "3000:3000"
+ volumes:
+ - ./client:/app
+ - node_modules:/app/node_modules
+ environment:
+ - CHOKIDAR_USEPOLLING=true
+ command: npm run dev
+
backend:
build:
context: ./server
- container_name: laravel_backend
volumes:
- ./server:/var/www
- depends_on:
- - db
+ environment:
+ APP_ENV: local
+ APP_DEBUG: "true"
backend_nginx:
image: nginx:alpine
- container_name: laravel_nginx
ports:
- "8000:80"
volumes:
@@ -21,26 +33,12 @@ services:
depends_on:
- backend
- frontend:
- build:
- context: ./client
- container_name: react_frontend
- ports:
- - "3000:80"
-
-
-
-
db:
image: mysql:8.0
- container_name: mysql_db
environment:
MYSQL_DATABASE: laravel
MYSQL_ROOT_PASSWORD: root
- ports:
- - "3306:3306"
- volumes:
- - db_data:/var/lib/mysql
-
volumes:
- db_data:
+ node_modules:
+# run it with: docker compose -f docker-compose.dev.yml up --build
+
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
new file mode 100644
index 0000000..71847e1
--- /dev/null
+++ b/docker-compose.prod.yml
@@ -0,0 +1,43 @@
+version: "3.9"
+
+services:
+ frontend:
+ build:
+ context: ./client
+ dockerfile: Dockerfile.prod
+ ports:
+ - "80:80"
+ restart: always
+
+ backend:
+ build:
+ context: ./server
+ environment:
+ APP_ENV: production
+ APP_DEBUG: "false"
+ restart: always
+
+ backend_nginx:
+ image: nginx:alpine
+ ports:
+ - "8000:80"
+ volumes:
+ - ./server/nginx.conf:/etc/nginx/conf.d/default.conf
+ depends_on:
+ - backend
+ restart: always
+
+ db:
+ env_file:
+ - .env.prod
+ image: mysql:8.0
+ environment:
+ MYSQL_DATABASE: laravel
+ MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
+ volumes:
+ - db_data:/var/lib/mysql
+ restart: always
+
+volumes:
+ db_data:
+#run it with : docker compose -f docker-compose.prod.yml up --build -d
diff --git a/client/src/assets/README-Diagrams/ER-Diagram.png b/readme/README-Diagrams/ER-Diagram.png
similarity index 100%
rename from client/src/assets/README-Diagrams/ER-Diagram.png
rename to readme/README-Diagrams/ER-Diagram.png
diff --git a/client/src/assets/README-Diagrams/GeneratorFlow.png b/readme/README-Diagrams/GeneratorFlow.png
similarity index 100%
rename from client/src/assets/README-Diagrams/GeneratorFlow.png
rename to readme/README-Diagrams/GeneratorFlow.png
diff --git a/client/src/assets/README-Diagrams/High-LevelWorkflow.png b/readme/README-Diagrams/High-LevelWorkflow.png
similarity index 100%
rename from client/src/assets/README-Diagrams/High-LevelWorkflow.png
rename to readme/README-Diagrams/High-LevelWorkflow.png
diff --git a/readme/README-Diagrams/NSD1.png b/readme/README-Diagrams/NSD1.png
new file mode 100644
index 0000000..4951e08
Binary files /dev/null and b/readme/README-Diagrams/NSD1.png differ
diff --git a/readme/README-Diagrams/mysql-removebg-preview.png b/readme/README-Diagrams/mysql-removebg-preview.png
new file mode 100644
index 0000000..b6e7452
Binary files /dev/null and b/readme/README-Diagrams/mysql-removebg-preview.png differ
diff --git a/readme/cards/Arch.svg b/readme/cards/Arch.svg
new file mode 100644
index 0000000..ce48129
--- /dev/null
+++ b/readme/cards/Arch.svg
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/readme/cards/demo.svg b/readme/cards/demo.svg
new file mode 100644
index 0000000..0fa82a0
--- /dev/null
+++ b/readme/cards/demo.svg
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/readme/cards/deployment.svg b/readme/cards/deployment.svg
new file mode 100644
index 0000000..2b42afe
--- /dev/null
+++ b/readme/cards/deployment.svg
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/readme/cards/developmentAndTesting.svg b/readme/cards/developmentAndTesting.svg
new file mode 100644
index 0000000..c38e5c3
--- /dev/null
+++ b/readme/cards/developmentAndTesting.svg
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/readme/cards/github.svg b/readme/cards/github.svg
new file mode 100644
index 0000000..cdd795e
--- /dev/null
+++ b/readme/cards/github.svg
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/readme/cards/projectHighlights.svg b/readme/cards/projectHighlights.svg
new file mode 100644
index 0000000..208df14
--- /dev/null
+++ b/readme/cards/projectHighlights.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/readme/cards/projectOverview.svg b/readme/cards/projectOverview.svg
new file mode 100644
index 0000000..d7a10e4
--- /dev/null
+++ b/readme/cards/projectOverview.svg
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/readme/cards/systemDesign.svg b/readme/cards/systemDesign.svg
new file mode 100644
index 0000000..dcbaf74
--- /dev/null
+++ b/readme/cards/systemDesign.svg
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/readme/cards/title.svg b/readme/cards/title.svg
new file mode 100644
index 0000000..e6274e0
--- /dev/null
+++ b/readme/cards/title.svg
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/readme/demo/Demo-Community.png b/readme/demo/Demo-Community.png
new file mode 100644
index 0000000..400ba1c
Binary files /dev/null and b/readme/demo/Demo-Community.png differ
diff --git a/readme/demo/Demo-Copilot.png b/readme/demo/Demo-Copilot.png
new file mode 100644
index 0000000..5e54e9f
Binary files /dev/null and b/readme/demo/Demo-Copilot.png differ
diff --git a/readme/demo/Demo-Profile.png b/readme/demo/Demo-Profile.png
new file mode 100644
index 0000000..51108f8
Binary files /dev/null and b/readme/demo/Demo-Profile.png differ
diff --git a/readme/demo/demo-copilot-1.gif b/readme/demo/demo-copilot-1.gif
new file mode 100644
index 0000000..8ae7420
Binary files /dev/null and b/readme/demo/demo-copilot-1.gif differ
diff --git a/readme/demo/demo-copilot-2.gif b/readme/demo/demo-copilot-2.gif
new file mode 100644
index 0000000..afb83b0
Binary files /dev/null and b/readme/demo/demo-copilot-2.gif differ
diff --git a/readme/testing/testing-1.png b/readme/testing/testing-1.png
new file mode 100644
index 0000000..ba99b78
Binary files /dev/null and b/readme/testing/testing-1.png differ
diff --git a/readme/testing/testing-2.png b/readme/testing/testing-2.png
new file mode 100644
index 0000000..90c51bd
Binary files /dev/null and b/readme/testing/testing-2.png differ
diff --git a/readme/testing/testing-3.png b/readme/testing/testing-3.png
new file mode 100644
index 0000000..4bddaee
Binary files /dev/null and b/readme/testing/testing-3.png differ
diff --git a/readme/testing/testing-4.png b/readme/testing/testing-4.png
new file mode 100644
index 0000000..abc0237
Binary files /dev/null and b/readme/testing/testing-4.png differ
diff --git a/readme/testing/testing-5.png b/readme/testing/testing-5.png
new file mode 100644
index 0000000..856f6a4
Binary files /dev/null and b/readme/testing/testing-5.png differ
diff --git a/readme/testing/testing-6.png b/readme/testing/testing-6.png
new file mode 100644
index 0000000..3e211a2
Binary files /dev/null and b/readme/testing/testing-6.png differ
diff --git a/readme/testing/testing-7.png b/readme/testing/testing-7.png
new file mode 100644
index 0000000..9e83d10
Binary files /dev/null and b/readme/testing/testing-7.png differ
diff --git a/readme/testing/testing-8.png b/readme/testing/testing-8.png
new file mode 100644
index 0000000..4264798
Binary files /dev/null and b/readme/testing/testing-8.png differ
diff --git a/server/.dockerignore b/server/.dockerignore
new file mode 100644
index 0000000..a92b22b
--- /dev/null
+++ b/server/.dockerignore
@@ -0,0 +1,4 @@
+vendor
+node_modules
+public/storage
+.env
\ No newline at end of file
diff --git a/server/DockerFile b/server/DockerFile
index 989ec55..31f53b8 100644
--- a/server/DockerFile
+++ b/server/DockerFile
@@ -26,12 +26,19 @@ WORKDIR /var/www
COPY . .
# Install Laravel dependencies
-# RUN composer install --no-interaction --prefer-dist --optimize-autoloader (mounted in docker-compose so no need to build it twice)
+RUN composer install --no-dev --optimize-autoloader
# Set permissions
-RUN chown -R www-data:www-data /var/www
-RUN chmod -R 755 /var/www/storage /var/www/bootstrap/cache
+RUN mkdir -p /var/www/storage /var/www/bootstrap/cache \
+ && chown -R www-data:www-data /var/www \
+ && chmod -R 755 /var/www/storage /var/www/bootstrap/cache
+
EXPOSE 9000
CMD ["php-fpm"]
+
+# in prod run :RUN php artisan config:clear \
+# && php artisan config:cache \
+# && php artisan route:cache \
+# && php artisan view:cache
diff --git a/server/app/Http/Controllers/FollowerController.php b/server/app/Http/Controllers/FollowerController.php
index 5779af0..063d63f 100644
--- a/server/app/Http/Controllers/FollowerController.php
+++ b/server/app/Http/Controllers/FollowerController.php
@@ -11,8 +11,8 @@ class FollowerController extends Controller{
public function followUser(Request $request , int $toBeFollowed){
$userId = $request->user()->id;
- ProfileService::toggeleFollow($userId, $toBeFollowed);
- return $this->successResponse([] , "User followed successfully");
+ $result = ProfileService::toggeleFollow($userId, $toBeFollowed);
+ return $this->successResponse($result , "User followed successfully");
}
public function isFollowed(Request $request , int $toBeChecked){
diff --git a/server/app/Http/Controllers/UserCopilotHistoryController.php b/server/app/Http/Controllers/UserCopilotHistoryController.php
index 0bcd86f..bdf0f50 100644
--- a/server/app/Http/Controllers/UserCopilotHistoryController.php
+++ b/server/app/Http/Controllers/UserCopilotHistoryController.php
@@ -5,17 +5,15 @@
use App\Models\UserCopilotHistory;
use App\Models\Message;
use App\Http\Controllers\Controller;
+use App\Service\UserCopilotHistoryService;
use App\Service\UserService;
-use Exception;
use Illuminate\Http\Request;
-use Illuminate\Support\Facades\Log;
class UserCopilotHistoryController extends Controller{
public function index(Request $request){
$userId = $request->user()->id;
- $histories = UserService::getChatHistory($userId);
-
+ $histories = UserCopilotHistoryService::getUserHistories($userId);
return $this->successResponse([
'histories' => $histories,
]);
@@ -23,13 +21,8 @@ public function index(Request $request){
public function show(Request $request , UserCopilotHistory $userCopilotHistory){
$userId = $request->user()->id;
- if ($userCopilotHistory->user_id !== $userId) {
- return $this->errorResponse('History not found', [], 404);
- }
- $userCopilotHistory->load(['messages' => function ($query) {
- $query->orderBy('created_at');
- }]);
+ $userCopilotHistory = UserCopilotHistoryService::getUserCopilotHistoryDetials($userId , $userCopilotHistory);
return $this->successResponse([
'history' => $userCopilotHistory,
@@ -38,30 +31,14 @@ public function show(Request $request , UserCopilotHistory $userCopilotHistory){
public function destroy(Request $request , UserCopilotHistory $userCopilotHistory){
$userId = $request->user()->id;
- if ($userCopilotHistory->user_id !== $userId) {
- return $this->errorResponse('History not found', [], 404);
- }
-
- Message::where('history_id', $userCopilotHistory->id)->delete();
-
- $userCopilotHistory->delete();
+ UserCopilotHistoryService::deleteHistory($userId , $userCopilotHistory);
return $this->successResponse([], 'History deleted');
}
public function download(Request $request , UserCopilotHistory $history){
- if ($history->user_id !== $request->user()->id()){
- abort(403);
- }
-
- $lastMessage = $history->messages()
- ->latest('created_at')
- ->first();
-
- if (!$lastMessage || !$lastMessage->ai_response) {
- abort(404, 'No AI response found');
- }
-
+ $userId = $request->user()->id;
+ $lastMessage = UserCopilotHistoryService::getDownloadableContent($userId, $history);
return response()->json(
$lastMessage->ai_response,
200,
diff --git a/server/app/Models/Message.php b/server/app/Models/Message.php
index 87dc932..0c9ca24 100644
--- a/server/app/Models/Message.php
+++ b/server/app/Models/Message.php
@@ -2,9 +2,12 @@
namespace App\Models;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Message extends Model{
+ use HasFactory;
+
protected $table = "messages";
protected $fillable = [
diff --git a/server/app/Service/AuthService.php b/server/app/Service/AuthService.php
index 17855ee..b53b9a8 100644
--- a/server/app/Service/AuthService.php
+++ b/server/app/Service/AuthService.php
@@ -111,28 +111,6 @@ public static function unlinkGoogle(User $user): void{
}
public static function linkN8nAccount(Model $user , array $data){
- /** @var Response */
- $response = Http::withHeaders([
- 'X-N8N-API-KEY' => $data["api_key"],
- ])->get(rtrim($data["base_url"], '/') . '/api/v1/workflows');
-
- $contentType = $response->header('Content-Type');
-
- if (!str_contains($contentType, 'application/json')) {
- throw new UserFacingException('Invalid n8n response.Consider using a different api key');
- }
-
- $body = $response->json();
-
- if (!isset($body['data']) || !is_array($body['data'])) {
- throw new UserFacingException('Invalid n8n API response.Consider using a different api key');
- }
-
-
- if (!$response->successful()) {
- throw new UserFacingException("Failed to connect to n8n");
- }
-
$user->n8n_base_url = $data["base_url"];
$user->n8n_api_key = $data["api_key"];
$user->save();
@@ -172,12 +150,13 @@ private static function authenticationReturnFormat(array | Model $user , string
private static function createToken(User $user): string{
$now = time();
+ $expirationTime = (int)env('TOKEN_EXPIRATION_TIME', 604800); // 7 days default
$payload = [
'iss' => config('app.url'),
'sub' => $user->id,
'iat' => $now,
- 'exp' => $now + env('TOKEN_EXPIRATION_TIME'), // 7 days
+ 'exp' => $now + $expirationTime,
];
return JWT::encode($payload, self::getJwtSecret(), 'HS256');
diff --git a/server/app/Service/Copilot/GetAnswer.php b/server/app/Service/Copilot/GetAnswer.php
index 4768fce..145ec96 100644
--- a/server/app/Service/Copilot/GetAnswer.php
+++ b/server/app/Service/Copilot/GetAnswer.php
@@ -20,10 +20,10 @@ public static function execute(array $messages , ?callable $stream = null){
$error = self::initializeError($stream);
try{
- $analysis = AnalyzeIntent::analyze($messages , $stage , $trace);// clean
- $points = GetPoints::execute($analysis , $stage , $trace);// clean
- $finalPoints = RankingFlows::rank($analysis, $points , $stage);// clean
- $workflow = LLMService::generateAnswer($analysis, $finalPoints , $stage , $trace);// clean
+ $analysis = AnalyzeIntent::analyze($messages , $stage , $trace);
+ $points = GetPoints::execute($analysis , $stage , $trace);
+ $finalPoints = RankingFlows::rank($analysis, $points , $stage);
+ $workflow = LLMService::generateAnswer($analysis, $finalPoints , $stage , $trace);
$validateWorkflowService = new ValidateFlowLogicService();
$workflow = $validateWorkflowService->execute($workflow , $analysis , $finalPoints ,$stage , $trace);
@@ -47,7 +47,7 @@ private static function initializeStage($stream){
return fn($name) => $stream && $stream("stage", $name);// shorthand (sends chunks were events = 'stage' and payload is the name of the event)
}
- private static function initializeTrace($stream){
+ public static function initializeTrace($stream){
return fn($type, $payload) => $stream && $stream("trace", [// shortand (sends more complex chunks where payload can be anything and events hold the type of the event themselves)
"type" => $type,
"payload" => $payload
diff --git a/server/app/Service/Copilot/LLMService.php b/server/app/Service/Copilot/LLMService.php
index a993e45..c16f2e3 100644
--- a/server/app/Service/Copilot/LLMService.php
+++ b/server/app/Service/Copilot/LLMService.php
@@ -176,13 +176,13 @@ public static function generateWorkflowQdrantPayload(string $json , string $ques
"model" => "gpt-4.1-mini",
"temperature" => 0,
"messages" => [
- ["role"=>"system","content"=>"You are an n8n workflow analyzer"],
+ ["role"=>"system","content"=>"You are an n8n workflow analyzer, You must return only json data as per the user's request."],
["role"=>"user","content"=>$prompt]
]
]);
-
+ Log::debug("Workflow metadata response" , ["response" => $response->json()]);
$metaData = trim($response->json("choices.0.message.content"));
- $metaDataDecoded = json_decode($metaData, true);
+ $metaDataDecoded = json_decode($metaData, true);
if (json_last_error() !== JSON_ERROR_NONE) {
Log::error('Failed to decode metadata JSON', [
diff --git a/server/app/Service/Copilot/PostWorkflow.php b/server/app/Service/Copilot/PostWorkflow.php
new file mode 100644
index 0000000..ff48a68
--- /dev/null
+++ b/server/app/Service/Copilot/PostWorkflow.php
@@ -0,0 +1,67 @@
+n8n_api_key || !$user->n8n_base_url){
+ return;
+ }
+
+ try {
+ $url = rtrim($user->n8n_base_url, '/') . '/rest/workflows';
+ $api_key = $user->n8n_api_key;
+
+ $workflow_json = json_encode($workflow);
+
+ self::postUsingCurl($url, $api_key, $workflow_json , $trace);
+
+ } catch (\Throwable $e) {
+ throw new Exception("Failed to post workflow: " . $e->getMessage());
+ }
+
+ }
+
+ private static function postUsingCurl(string $url, string $api_key,string $workflow_json , ?callable $trace){
+
+ $ch = curl_init($url);
+ self::setCurlOptions($ch, $api_key, $workflow_json);
+
+ $response = curl_exec($ch);
+ $httpcode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+
+ self::handleCurlResponse($ch, $httpcode, $response , $trace);
+
+ curl_close($ch);
+ }
+
+ private static function setCurlOptions($ch , string $api_key, string $workflow_json){
+ curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "POST");
+ curl_setopt($ch, CURLOPT_POSTFIELDS, $workflow_json);
+ curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+ curl_setopt($ch, CURLOPT_HTTPHEADER, array(
+ 'Content-Type: application/json',
+ 'X-N8N-API-KEY: ' . $api_key,
+ 'Content-Length: ' . strlen($workflow_json)
+ ));
+ }
+
+ private static function handleCurlResponse($ch,int $httpcode, $response , ?callable $trace){
+ if($httpcode == 200 || $httpcode == 201){
+ $trace && $trace("postWorkflowSuccess" ,["message" => 'Workflow posted successfully to n8n instance.']);
+ }
+
+ if(curl_errno($ch)){
+ $trace && $trace("postWorkflowFailed" ,["message" => 'Failed to post workflow, please check your credentials and n8n instance status.']);
+ }else{
+ throw new Exception('Failed to post workflow, HTTP Status Code: ' . $httpcode . ', Response: ' . $response);
+ }
+ }
+}
diff --git a/server/app/Service/Copilot/SaveWorkflow.php b/server/app/Service/Copilot/SaveWorkflow.php
index 8bc1f32..5749d96 100644
--- a/server/app/Service/Copilot/SaveWorkflow.php
+++ b/server/app/Service/Copilot/SaveWorkflow.php
@@ -11,7 +11,8 @@
class SaveWorkflow{
public static function save($requestForm){
- $json = json_encode($requestForm->input('workflow'));
+ $json = $requestForm->input('workflow');
+
if(!$json){
throw new Exception("Workflow given not correct json");
}
@@ -51,10 +52,10 @@ public static function save($requestForm){
}
}
- private static function buildPayload(string $json , string $question): array {
- $metaData = LLMService::generateWorkflowQdrantPayload($json , $question); // description, tags, notes, category
+ private static function buildPayload(array $json , string $question): array {
+ $metaData = LLMService::generateWorkflowQdrantPayload(json_encode($json , JSON_UNESCAPED_SLASHES) , $question); // description, tags, notes, category
- $decoded_workflow = json_decode($json , true);
+ $decoded_workflow = $json;
if (!is_array($decoded_workflow)) {
throw new \RuntimeException("Invalid workflow JSON passed to buildPayload");
}
diff --git a/server/app/Service/UserCopilotHistoryService.php b/server/app/Service/UserCopilotHistoryService.php
new file mode 100644
index 0000000..33d37b3
--- /dev/null
+++ b/server/app/Service/UserCopilotHistoryService.php
@@ -0,0 +1,59 @@
+ function ($query) {
+ $query->orderBy('created_at');
+ }])
+ ->where('user_id', $userId)
+ ->orderByDesc('created_at')
+ ->get();
+ }
+
+ public static function getUserCopilotHistoryDetials(int $userId , Model $userCopilotHistory){
+ if($userCopilotHistory->user_id !== $userId){
+ throw new Exception('History not found');
+ }
+
+ $userCopilotHistory->load(['messages' => function ($query) {
+ $query->orderBy('created_at');
+ }]);
+
+ return $userCopilotHistory;
+ }
+
+ public static function deleteHistory(int $userId, Model $userCopilotHistory){
+ if ($userCopilotHistory->user_id !== $userId) {
+ throw new Exception('History not found');
+ }
+
+ Message::where('history_id', $userCopilotHistory->id)->delete();
+
+ $userCopilotHistory->delete();
+ }
+
+ public static function getDownloadableContent(int $userId , Model $history){
+ if($history->user_id !== $userId){
+ abort(403);
+ }
+
+ $lastMessage = $history->messages()
+ ->latest('created_at')
+ ->first();
+
+ if (!$lastMessage || !$lastMessage->ai_response) {
+ abort(404, 'No AI response found');
+ }
+
+ return $lastMessage;
+ }
+
+}
diff --git a/server/app/Service/UserService.php b/server/app/Service/UserService.php
index 541392d..8cc08df 100644
--- a/server/app/Service/UserService.php
+++ b/server/app/Service/UserService.php
@@ -7,18 +7,29 @@
use App\Models\User;
use App\Models\UserCopilotHistory;
use App\Service\Copilot\GetAnswer;
+use App\Service\Copilot\PostWorkflow;
use App\Service\Copilot\SaveWorkflow;
use Exception;
+use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class UserService{
- public static function getCopilotAnswer(array $messages, ?int $userId , ?int $historyId = null , ?callable $stream = null): array{
+ public static function getCopilotAnswer(array $messages, int $userId , ?int $historyId = null , ?callable $stream = null): array{
+
+ $user = User::find($userId);
+ if (!$user) {
+ throw new Exception("User not found");
+ }
$answer = GetAnswer::execute($messages , $stream);
if(!$answer) throw new Exception("Failed to generate n8n workflow");
- $history = self::handleHistoryManagement($userId , $historyId , $messages , $answer);
+
+ $history = self::handleHistoryManagement($user->id , $historyId , $messages , $answer);
+ if(!$history) throw new Exception("Failed to save copilot history");
+
+ PostWorkflow::postWorkflow($answer , $user , $stream);
return [
'answer' => $answer,
@@ -98,16 +109,7 @@ public static function saveCopilotHistories(
$newMessage->save();
}
-
- public static function getChatHistory(int $userId){
- return UserCopilotHistory::with(['messages' => function ($query) {
- $query->orderBy('created_at');
- }])
- ->where('user_id', $userId)
- ->orderByDesc('created_at')
- ->get();
- }
-
+
public static function getFriends(string $name , int $userId){
if(empty($name)){
throw new Exception("Name is empty");
@@ -186,7 +188,10 @@ public static function returnSseHeaders(){
public static function returnFinalWorkflowResult($result){
echo "event: result\n";
echo "data: " . json_encode($result) . "\n\n";
- ob_flush(); flush();
+ if(ob_get_level() > 0){
+ ob_flush();
+ }
+ flush();
}
public static function initializeStream(){
diff --git a/server/config/database.php b/server/config/database.php
index c57fa63..6c5b4c0 100644
--- a/server/config/database.php
+++ b/server/config/database.php
@@ -38,8 +38,8 @@
'prefix' => '',
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
'busy_timeout' => null,
- 'journal_mode' => null,
- 'synchronous' => null,
+ 'journal_mode' => env('APP_ENV') === 'testing' ? 'WAL' : null,
+ 'synchronous' => env('APP_ENV') === 'testing' ? 0 : null,
'transaction_mode' => 'DEFERRED',
],
diff --git a/server/database/factories/MessageFactory.php b/server/database/factories/MessageFactory.php
new file mode 100644
index 0000000..b420f8b
--- /dev/null
+++ b/server/database/factories/MessageFactory.php
@@ -0,0 +1,26 @@
+ UserCopilotHistory::factory(),
+ 'user_message' => fake()->sentence(),
+ 'ai_response' => [
+ 'blocks' => [
+ [
+ 'type' => 'paragraph',
+ 'data' => ['text' => fake()->paragraph()],
+ ],
+ ],
+ ],
+ 'ai_model' => fake()->randomElement(['gpt-4', 'gpt-4-mini', 'gpt-3.5-turbo']),
+ ];
+ }
+}
diff --git a/server/database/factories/PostCommentFactory.php b/server/database/factories/PostCommentFactory.php
index c9f65c3..ac06b79 100644
--- a/server/database/factories/PostCommentFactory.php
+++ b/server/database/factories/PostCommentFactory.php
@@ -10,7 +10,10 @@ class PostCommentFactory extends Factory{// can create comments even if users an
public function definition(): array{
return [
+ 'user_id' => User::factory(),
+ 'post_id' => UserPost::factory(),
'content' => fake()->sentence(),
+ 'likes' => fake()->numberBetween(0, 100),
];
}
}
diff --git a/server/database/factories/UserCopilotHistoryFactory.php b/server/database/factories/UserCopilotHistoryFactory.php
index 35b3940..ccd526e 100644
--- a/server/database/factories/UserCopilotHistoryFactory.php
+++ b/server/database/factories/UserCopilotHistoryFactory.php
@@ -9,10 +9,7 @@ class UserCopilotHistoryFactory extends Factory{
public function definition(): array
{
return [
- 'question' => fake()->sentence(),
- 'ai_model' => 'gpt-4-mini',
- 'ai_description' => fake()->paragraph(),
- 'response' => fake()->paragraph()// for now
+ 'user_id' => User::factory(),
];
}
}
diff --git a/server/database/factories/UserPostFactory.php b/server/database/factories/UserPostFactory.php
index 4a6f24d..1a37436 100644
--- a/server/database/factories/UserPostFactory.php
+++ b/server/database/factories/UserPostFactory.php
@@ -9,6 +9,7 @@ class UserPostFactory extends Factory{// only create posts if you have some user
public function definition(): array{
return [
+ 'user_id' => User::factory(),
'title' => fake()->title(),
'description' => fake()->sentence(),
'photo_url' => fake()->imageUrl(),
diff --git a/server/database/migrations/2026_01_25_125042_change_json_content_to_json.php b/server/database/migrations/2026_01_25_125042_change_json_content_to_json.php
new file mode 100644
index 0000000..ccc2716
--- /dev/null
+++ b/server/database/migrations/2026_01_25_125042_change_json_content_to_json.php
@@ -0,0 +1,28 @@
+json('json_content')->nullable()->change();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('user_posts', function (Blueprint $table) {
+ $table->text('json_content')->change();
+ });
+ }
+};
diff --git a/server/database/migrations/2026_01_25_125649_change_description_content_to_text.php b/server/database/migrations/2026_01_25_125649_change_description_content_to_text.php
new file mode 100644
index 0000000..218b6a2
--- /dev/null
+++ b/server/database/migrations/2026_01_25_125649_change_description_content_to_text.php
@@ -0,0 +1,28 @@
+text('description')->nullable()->change();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('user_posts', function (Blueprint $table) {
+ $table->string('description')->nullable()->change();
+ });
+ }
+};
diff --git a/server/database/migrations/2026_01_25_221543_change_n8n_api_key_to_text_in_users_table.php b/server/database/migrations/2026_01_25_221543_change_n8n_api_key_to_text_in_users_table.php
new file mode 100644
index 0000000..fc9eebc
--- /dev/null
+++ b/server/database/migrations/2026_01_25_221543_change_n8n_api_key_to_text_in_users_table.php
@@ -0,0 +1,28 @@
+text('n8n_api_key')->nullable()->change();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('users', function (Blueprint $table) {
+ $table->string('n8n_api_key')->nullable()->change();
+ });
+ }
+};
diff --git a/server/phpunit.xml b/server/phpunit.xml
index d703241..fcbe864 100644
--- a/server/phpunit.xml
+++ b/server/phpunit.xml
@@ -25,11 +25,16 @@
+
+
+
+
+
diff --git a/server/tests/Feature/ExampleTest.php b/server/tests/Feature/ExampleTest.php
deleted file mode 100644
index 8364a84..0000000
--- a/server/tests/Feature/ExampleTest.php
+++ /dev/null
@@ -1,19 +0,0 @@
-get('/');
-
- $response->assertStatus(200);
- }
-}
diff --git a/server/tests/Feature/n8nGenerator.php b/server/tests/Feature/n8nGenerator.php
new file mode 100644
index 0000000..e69de29
diff --git a/server/tests/Unit/AnalyzeIntentTest.php b/server/tests/Unit/AnalyzeIntentTest.php
new file mode 100644
index 0000000..1d3cc0e
--- /dev/null
+++ b/server/tests/Unit/AnalyzeIntentTest.php
@@ -0,0 +1,81 @@
+assertEquals("gmailnodev25", $result);
+ }
+
+ /**
+ * Test that normalizeNodes removes duplicates and normalizes multiple nodes
+ */
+ public function test_normalize_nodes_removes_duplicates(): void
+ {
+ $input = [
+ "Gmail Node",
+ "GMAIL NODE",
+ "Gmail-Node",
+ "Slack API",
+ "slack-api"
+ ];
+
+ $result = AnalyzeIntent::normalizeNodes($input);
+
+ // Should only have 2 unique entries: gmailnode and slackapi
+ $this->assertCount(2, $result);
+ $this->assertContains("gmailnode", $result);
+ $this->assertContains("slackapi", $result);
+ }
+
+ /**
+ * Test that buildWorkflowEmbeddingQuery constructs proper query string
+ */
+ public function test_build_workflow_embedding_query_with_all_fields(): void
+ {
+ $analysis = [
+ "intent" => "Send emails to contacts",
+ "trigger" => "new-record",
+ "nodes" => ["gmail", "database"]
+ ];
+
+ $question = "How do I send emails when a new record is added?";
+ $result = AnalyzeIntent::buildWorkflowEmbeddingQuery($analysis, $question);
+
+ $this->assertStringContainsString("Send emails to contacts", $result);
+ $this->assertStringContainsString("Triggered by new-record", $result);
+ $this->assertStringContainsString("gmail", $result);
+ $this->assertStringContainsString("database", $result);
+ $this->assertStringContainsString($question, $result);
+ }
+
+ /**
+ * Test that buildWorkflowEmbeddingQuery handles missing optional fields
+ */
+ public function test_build_workflow_embedding_query_with_minimal_fields(): void
+ {
+ $analysis = [
+ "intent" => "Process data",
+ "trigger" => "",
+ "nodes" => []
+ ];
+
+ $question = "Process incoming data";
+ $result = AnalyzeIntent::buildWorkflowEmbeddingQuery($analysis, $question);
+
+ $this->assertStringContainsString("Process data", $result);
+ $this->assertStringContainsString($question, $result);
+ $this->assertStringNotContainsString("Triggered by", $result);
+ }
+}
diff --git a/server/tests/Unit/AuthServiceTest.php b/server/tests/Unit/AuthServiceTest.php
new file mode 100644
index 0000000..b8a340f
--- /dev/null
+++ b/server/tests/Unit/AuthServiceTest.php
@@ -0,0 +1,354 @@
+ 'John',
+ 'lastName' => 'Doe',
+ 'email' => 'john@example.com',
+ 'password' => 'password123',
+ ];
+
+ $result = AuthService::createUser($userData);
+
+ $this->assertArrayHasKey('token', $result);
+ $this->assertArrayHasKey('user', $result);
+ $this->assertEquals('john@example.com', $result['user']['email']);
+ $this->assertEquals('John', $result['user']['first_name']);
+ $this->assertEquals('Doe', $result['user']['last_name']);
+
+ $this->assertDatabaseHas('users', [
+ 'email' => 'john@example.com',
+ 'first_name' => 'John',
+ 'last_name' => 'Doe',
+ ]);
+ }
+
+ /**
+ * Test user creation from Google login
+ */
+ public function test_create_user_from_google(): void
+ {
+ $userData = [
+ 'firstName' => 'Jane',
+ 'lastName' => 'Smith',
+ 'email' => 'jane@example.com',
+ 'password' => 'password123',
+ ];
+
+ $result = AuthService::createUser($userData, 1);
+
+ $this->assertArrayHasKey('token', $result);
+ $this->assertArrayHasKey('user', $result);
+ $this->assertEquals('jane@example.com', $result['user']['email']);
+
+ $user = User::where('email', 'jane@example.com')->first();
+ $this->assertNull($user->password);
+ }
+
+ /**
+ * Test successful login with valid credentials
+ */
+ public function test_login_with_valid_credentials(): void
+ {
+ $password = 'testpassword123';
+ $user = User::factory()->create([
+ 'email' => 'test@example.com',
+ 'password' => Hash::make($password),
+ ]);
+
+ $credentials = [
+ 'email' => 'test@example.com',
+ 'password' => $password,
+ ];
+
+ $result = AuthService::login($credentials);
+
+ $this->assertArrayHasKey('token', $result);
+ $this->assertArrayHasKey('user', $result);
+ $this->assertEquals($user->id, $result['user']['id']);
+ $this->assertEquals('test@example.com', $result['user']['email']);
+ }
+
+ /**
+ * Test login with non-existent user
+ */
+ public function test_login_with_non_existent_user(): void
+ {
+ $credentials = [
+ 'email' => 'nonexistent@example.com',
+ 'password' => 'password123',
+ ];
+
+ $this->expectException(UserFacingException::class);
+ $this->expectExceptionMessage('Invalid credentials');
+
+ AuthService::login($credentials);
+ }
+
+ /**
+ * Test login with incorrect password
+ */
+ public function test_login_with_incorrect_password(): void
+ {
+ User::factory()->create([
+ 'email' => 'test@example.com',
+ 'password' => Hash::make('correctpassword'),
+ ]);
+
+ $credentials = [
+ 'email' => 'test@example.com',
+ 'password' => 'wrongpassword',
+ ];
+
+ $this->expectException(UserFacingException::class);
+ $this->expectExceptionMessage('Invalid credentials');
+
+ AuthService::login($credentials);
+ }
+
+ /**
+ * Test login with Google account that has no password
+ */
+ public function test_login_with_google_account_no_password(): void
+ {
+ User::factory()->create([
+ 'email' => 'google@example.com',
+ 'password' => null,
+ ]);
+
+ $credentials = [
+ 'email' => 'google@example.com',
+ 'password' => 'anypassword',
+ ];
+
+ $this->expectException(UserFacingException::class);
+ $this->expectExceptionMessage('This account uses Google login. Please continue with Google');
+
+ AuthService::login($credentials);
+ }
+
+ /**
+ * Test set password for new user (no existing password)
+ */
+ public function test_set_password_for_new_user(): void
+ {
+ $user = User::factory()->create([
+ 'password' => null,
+ ]);
+
+ $data = [
+ 'new_password' => 'newpassword123',
+ ];
+
+ AuthService::setPassword($user, $data);
+
+ $this->assertTrue(Hash::check('newpassword123', $user->password));
+ }
+
+ /**
+ * Test set password for existing user with correct current password
+ */
+ public function test_set_password_with_correct_current_password(): void
+ {
+ $currentPassword = 'currentpassword123';
+ $user = User::factory()->create([
+ 'password' => Hash::make($currentPassword),
+ ]);
+
+ $data = [
+ 'current_password' => $currentPassword,
+ 'new_password' => 'newpassword456',
+ ];
+
+ AuthService::setPassword($user, $data);
+ $user->refresh();
+
+ $this->assertTrue(Hash::check('newpassword456', $user->password));
+ }
+
+ /**
+ * Test set password with incorrect current password
+ */
+ public function test_set_password_with_incorrect_current_password(): void
+ {
+ $user = User::factory()->create([
+ 'password' => Hash::make('correctpassword'),
+ ]);
+
+ $data = [
+ 'current_password' => 'wrongpassword',
+ 'new_password' => 'newpassword',
+ ];
+
+ $this->expectException(UserFacingException::class);
+ $this->expectExceptionMessage('Current password is incorrect');
+
+ AuthService::setPassword($user, $data);
+ }
+
+ /**
+ * Test set password with missing current password when user has password
+ */
+ public function test_set_password_missing_current_password(): void
+ {
+ $user = User::factory()->create([
+ 'password' => Hash::make('existingpassword'),
+ ]);
+
+ $data = [
+ 'current_password' => null,
+ 'new_password' => 'newpassword',
+ ];
+
+ $this->expectException(UserFacingException::class);
+ $this->expectExceptionMessage('Current password is incorrect');
+
+ AuthService::setPassword($user, $data);
+ }
+
+ /**
+ * Test successful unlink Google account
+ */
+ public function test_unlink_google_account_with_password(): void
+ {
+ $user = User::factory()->create([
+ 'google_id' => 'google789',
+ 'password' => Hash::make('password123'),
+ ]);
+
+ AuthService::unlinkGoogle($user);
+ $user->refresh();
+
+ $this->assertNull($user->google_id);
+ }
+
+ /**
+ * Test unlink Google account without password
+ */
+ public function test_unlink_google_account_without_password(): void
+ {
+ $user = User::factory()->create([
+ 'google_id' => 'google789',
+ 'password' => null,
+ ]);
+
+ $this->expectException(UserFacingException::class);
+ $this->expectExceptionMessage('Set a password before unlinking Google');
+
+ AuthService::unlinkGoogle($user);
+ }
+
+ /**
+ * Test successful N8N account linking
+ */
+ public function test_link_n8n_account_successfully(): void
+ {
+ $user = User::factory()->create();
+
+ Http::fake([
+ 'http://localhost:5678/api/v1/workflows' => Http::response([
+ 'data' => [
+ ['id' => 1, 'name' => 'Workflow 1'],
+ ],
+ ]),
+ ]);
+
+ $data = [
+ 'api_key' => 'valid_api_key',
+ 'base_url' => 'http://localhost:5678',
+ ];
+
+ AuthService::linkN8nAccount($user, $data);
+ $user->refresh();
+
+ $this->assertEquals('valid_api_key', $user->n8n_api_key);
+ $this->assertEquals('http://localhost:5678', $user->n8n_base_url);
+ }
+
+ /**
+ * Test JWT token is valid and contains correct payload
+ */
+ public function test_generated_token_is_valid(): void
+ {
+ $user = User::factory()->create(['id' => 1]);
+
+ $credentials = [
+ 'email' => $user->email,
+ 'password' => 'password123',
+ ];
+
+ $user->update(['password' => Hash::make('password123')]);
+ $result = AuthService::login($credentials);
+
+ $token = $result['token'];
+ $decoded = JWT::decode($token, new Key(env('JWT_SECRET'), 'HS256'));
+
+ $this->assertEquals(1, $decoded->sub);
+ $this->assertEquals(config('app.url'), $decoded->iss);
+ }
+
+ /**
+ * Test authentication return format structure
+ */
+ public function test_authentication_return_format(): void
+ {
+ $user = User::factory()->create([
+ 'first_name' => 'Test',
+ 'last_name' => 'User',
+ 'email' => 'test@example.com',
+ ]);
+
+ $password = 'password123';
+ $user->update(['password' => Hash::make($password)]);
+
+ $credentials = [
+ 'email' => 'test@example.com',
+ 'password' => $password,
+ ];
+
+ $result = AuthService::login($credentials);
+
+ $this->assertArrayHasKey('token', $result);
+ $this->assertArrayHasKey('user', $result);
+ $this->assertArrayHasKey('id', $result['user']);
+ $this->assertArrayHasKey('first_name', $result['user']);
+ $this->assertArrayHasKey('last_name', $result['user']);
+ $this->assertArrayHasKey('email', $result['user']);
+
+ $this->assertEquals($user->id, $result['user']['id']);
+ $this->assertEquals('Test', $result['user']['first_name']);
+ $this->assertEquals('User', $result['user']['last_name']);
+ $this->assertEquals('test@example.com', $result['user']['email']);
+ }
+
+ /**
+ * Helper method to mock Google verification
+ */
+ private function mockGoogleVerification(array $payload): void
+ {
+ $mock = $this->mock(Google_Client::class);
+ $mock->shouldReceive('verifyIdToken')
+ ->andReturn($payload);
+ }
+}
diff --git a/server/tests/Unit/ExampleTest.php b/server/tests/Unit/ExampleTest.php
deleted file mode 100644
index 5773b0c..0000000
--- a/server/tests/Unit/ExampleTest.php
+++ /dev/null
@@ -1,16 +0,0 @@
-assertTrue(true);
- }
-}
diff --git a/server/tests/Unit/GetPointsTest.php b/server/tests/Unit/GetPointsTest.php
new file mode 100644
index 0000000..49d2fcd
--- /dev/null
+++ b/server/tests/Unit/GetPointsTest.php
@@ -0,0 +1,129 @@
+getMethod('parseNodeVersion');
+ $method->setAccessible(true);
+
+ $result = $method->invoke(null, "GmailV2");
+
+ $this->assertEquals("Gmail", $result['base']);
+ $this->assertEquals(2, $result['version']);
+ }
+
+ /**
+ * Test parseNodeVersion handles unversioned class names
+ */
+ public function test_parse_node_version_without_version(): void
+ {
+ $reflection = new \ReflectionClass(GetPoints::class);
+ $method = $reflection->getMethod('parseNodeVersion');
+ $method->setAccessible(true);
+
+ $result = $method->invoke(null, "SlackLegacy");
+
+ $this->assertEquals("SlackLegacy", $result['base']);
+ $this->assertEquals(0, $result['version']);
+ }
+
+ /**
+ * Test keepLatestVersions keeps only the highest version of each node
+ */
+ public function test_keep_latest_versions_filters_old_versions(): void
+ {
+ $reflection = new \ReflectionClass(GetPoints::class);
+ $method = $reflection->getMethod('keepLatestVersions');
+ $method->setAccessible(true);
+
+ $hits = [
+ [
+ 'payload' => [
+ 'class_name' => 'GmailV1',
+ 'node_id' => 'gmail-v1'
+ ]
+ ],
+ [
+ 'payload' => [
+ 'class_name' => 'GmailV3',
+ 'node_id' => 'gmail-v3'
+ ]
+ ],
+ [
+ 'payload' => [
+ 'class_name' => 'GmailV2',
+ 'node_id' => 'gmail-v2'
+ ]
+ ],
+ ];
+
+ $result = $method->invoke(null, $hits);
+
+ // Should only keep GmailV3
+ $this->assertCount(1, $result);
+ $this->assertEquals("GmailV3", $result[0]['payload']['class_name']);
+ }
+
+ /**
+ * Test filterByAdaptiveScore returns top scorer when best >= 0.6
+ */
+ public function test_filter_by_adaptive_score_high_confidence(): void
+ {
+ // Mock HTTP facade to prevent actual calls
+ Http::fake();
+
+ $reflection = new \ReflectionClass(GetPoints::class);
+ $method = $reflection->getMethod('filterByAdaptiveScore');
+ $method->setAccessible(true);
+
+ $hits = [
+ ['score' => 0.85],
+ ['score' => 0.65],
+ ['score' => 0.55],
+ ['score' => 0.45],
+ ];
+
+ $result = $method->invoke(null, $hits, 8);
+
+ // With ratio 0.35 for 0.85, threshold = 0.85 * 0.35 = 0.2975
+ // Should keep all >= 0.2975
+ $this->assertGreaterThan(1, count($result));
+ $this->assertEquals(0.85, $result[0]['score']);
+ }
+
+ /**
+ * Test filterByAdaptiveScore returns only top 1 when best < 0.25
+ */
+ public function test_filter_by_adaptive_score_low_confidence(): void
+ {
+ // Mock HTTP facade to prevent actual calls
+ Http::fake();
+
+ $reflection = new \ReflectionClass(GetPoints::class);
+ $method = $reflection->getMethod('filterByAdaptiveScore');
+ $method->setAccessible(true);
+
+ $hits = [
+ ['score' => 0.20],
+ ['score' => 0.15],
+ ['score' => 0.10],
+ ];
+
+ $result = $method->invoke(null, $hits, 8);
+
+ // Should only keep top 1 when best < 0.25
+ $this->assertCount(1, $result);
+ $this->assertEquals(0.20, $result[0]['score']);
+ }
+}
diff --git a/server/tests/Unit/N8nGeneratorTest.php b/server/tests/Unit/N8nGeneratorTest.php
new file mode 100644
index 0000000..2fc5316
--- /dev/null
+++ b/server/tests/Unit/N8nGeneratorTest.php
@@ -0,0 +1,130 @@
+assertEquals("", $result);
+ }
+
+ /**
+ * Test buildWorkflowContext with single workflow
+ */
+ public function test_buildWorkflowContext_with_single_workflow(): void
+ {
+ $flows = [
+ [
+ 'workflow' => 'Send Email Workflow',
+ 'nodes_used' => ['Gmail', 'HTTP'],
+ 'node_count' => 2,
+ 'raw' => ['id' => 1, 'name' => 'test']
+ ]
+ ];
+
+ $result = WorkflowGeneration::buildWorkflowContext($flows);
+
+ $this->assertStringContainsString('Workflow 1', $result);
+ $this->assertStringContainsString('Send Email Workflow', $result);
+ $this->assertStringContainsString('Gmail', $result);
+ $this->assertStringContainsString('HTTP', $result);
+ $this->assertStringContainsString('Node Count: 2', $result);
+ }
+
+ /**
+ * Test buildWorkflowContext with multiple workflows
+ */
+ public function test_buildWorkflowContext_with_multiple_workflows(): void
+ {
+ $flows = [
+ [
+ 'workflow' => 'First Workflow',
+ 'nodes_used' => ['Node1'],
+ 'node_count' => 1,
+ 'raw' => ['id' => 1]
+ ],
+ [
+ 'workflow' => 'Second Workflow',
+ 'nodes_used' => ['Node2', 'Node3'],
+ 'node_count' => 2,
+ 'raw' => ['id' => 2]
+ ]
+ ];
+
+ $result = WorkflowGeneration::buildWorkflowContext($flows);
+
+ $this->assertStringContainsString('Workflow 1', $result);
+ $this->assertStringContainsString('Workflow 2', $result);
+ $this->assertStringContainsString('First Workflow', $result);
+ $this->assertStringContainsString('Second Workflow', $result);
+ $this->assertStringContainsString('Node1', $result);
+ $this->assertStringContainsString('Node2', $result);
+ }
+
+ /**
+ * Test buildSchemasContext with empty schemas array
+ */
+ public function test_buildSchemasContext_with_empty_schemas(): void
+ {
+ $schemas = [];
+
+ $result = WorkflowGeneration::buildSchemasContext($schemas);
+
+ $this->assertStringContainsString('You may ONLY use the following n8n node operations', $result);
+ $this->assertStringContainsString('Every operation below is valid, ranked, and schema-verified', $result);
+ $this->assertStringContainsString('Do NOT invent nodes, resources, operations, or fields', $result);
+ }
+
+ /**
+ * Test buildSchemasContext with schema operations
+ */
+ public function test_buildSchemasContext_with_schema_operations(): void
+ {
+ $schemas = [
+ [
+ 'schema' => [
+ 'node' => 'Gmail',
+ 'resource' => 'Email',
+ 'operation' => 'Send',
+ 'display' => 'Send Email',
+ 'description' => 'Sends an email via Gmail',
+ 'fields' => [
+ ['name' => 'to', 'type' => 'string', 'required' => true],
+ ['name' => 'subject', 'type' => 'string', 'required' => true]
+ ],
+ 'inputs' => [
+ ['name' => 'Email Data', 'type' => 'object']
+ ],
+ 'outputs' => [
+ ['name' => 'Message ID', 'type' => 'string']
+ ]
+ ]
+ ]
+ ];
+
+ $result = WorkflowGeneration::buildSchemasContext($schemas);
+
+ $this->assertStringContainsString('NODE: Gmail', $result);
+ $this->assertStringContainsString('OPERATION: Email → Send', $result);
+ $this->assertStringContainsString('LABEL: Send Email', $result);
+ $this->assertStringContainsString('DESCRIPTION: Sends an email via Gmail', $result);
+ $this->assertStringContainsString('FIELDS:', $result);
+ $this->assertStringContainsString('to (string, required)', $result);
+ $this->assertStringContainsString('subject (string, required)', $result);
+ $this->assertStringContainsString('INPUTS:', $result);
+ $this->assertStringContainsString('Email Data', $result);
+ $this->assertStringContainsString('OUTPUTS:', $result);
+ $this->assertStringContainsString('Message ID', $result);
+ }
+}
\ No newline at end of file
diff --git a/server/tests/Unit/PostCommentServiceTest.php b/server/tests/Unit/PostCommentServiceTest.php
new file mode 100644
index 0000000..1d40795
--- /dev/null
+++ b/server/tests/Unit/PostCommentServiceTest.php
@@ -0,0 +1,475 @@
+create();
+ $post = UserPost::factory()->create();
+
+ $comment = PostCommentService::postComment(
+ $user->id,
+ 'This is a test comment',
+ $post->id
+ );
+
+ $this->assertInstanceOf(PostComment::class, $comment);
+ $this->assertEquals('This is a test comment', $comment->content);
+ $this->assertEquals($user->id, $comment->user_id);
+ $this->assertEquals($post->id, $comment->post_id);
+ $this->assertEquals(0, $comment->likes);
+
+ $this->assertDatabaseHas('post_comments', [
+ 'user_id' => $user->id,
+ 'post_id' => $post->id,
+ 'content' => 'This is a test comment',
+ 'likes' => 0,
+ ]);
+ }
+
+ /**
+ * Test posting a comment with empty content
+ */
+ public function test_post_comment_with_empty_content(): void
+ {
+ $user = User::factory()->create();
+ $post = UserPost::factory()->create();
+
+ $this->expectException(UserFacingException::class);
+ $this->expectExceptionMessage('Comment is empty');
+
+ PostCommentService::postComment($user->id, '', $post->id);
+ }
+
+ /**
+ * Test posting a comment with null content
+ */
+ public function test_post_comment_with_null_content(): void
+ {
+ $user = User::factory()->create();
+ $post = UserPost::factory()->create();
+
+ $this->expectException(UserFacingException::class);
+ $this->expectExceptionMessage('Comment is empty');
+
+ PostCommentService::postComment($user->id, "", $post->id);
+ }
+
+ /**
+ * Test posting a comment with long content
+ */
+ public function test_post_comment_with_long_content(): void
+ {
+ $user = User::factory()->create();
+ $post = UserPost::factory()->create();
+
+ $longContent = str_repeat('This is a long comment. ', 50);
+
+ $comment = PostCommentService::postComment($user->id, $longContent, $post->id);
+
+ $this->assertEquals($longContent, $comment->content);
+ }
+
+ /**
+ * Test getting comments for a post
+ */
+ public function test_get_comments_for_post(): void
+ {
+ $post = UserPost::factory()->create();
+ $user1 = User::factory()->create();
+ $user2 = User::factory()->create();
+
+ PostComment::factory()->create([
+ 'post_id' => $post->id,
+ 'user_id' => $user1->id,
+ 'content' => 'First comment',
+ 'likes' => 5,
+ ]);
+
+ PostComment::factory()->create([
+ 'post_id' => $post->id,
+ 'user_id' => $user2->id,
+ 'content' => 'Second comment',
+ 'likes' => 3,
+ ]);
+
+ $comments = PostCommentService::getComments($post->id);
+
+ $this->assertCount(2, $comments);
+ $this->assertEquals('First comment', $comments[0]->content);
+ $this->assertEquals('Second comment', $comments[1]->content);
+ }
+
+ /**
+ * Test getting comments ordered by likes descending
+ */
+ public function test_get_comments_ordered_by_likes_descending(): void
+ {
+ $post = UserPost::factory()->create();
+ $user = User::factory()->create();
+
+ PostComment::factory()->create([
+ 'post_id' => $post->id,
+ 'user_id' => $user->id,
+ 'content' => 'Less liked',
+ 'likes' => 2,
+ ]);
+
+ PostComment::factory()->create([
+ 'post_id' => $post->id,
+ 'user_id' => $user->id,
+ 'content' => 'More liked',
+ 'likes' => 10,
+ ]);
+
+ PostComment::factory()->create([
+ 'post_id' => $post->id,
+ 'user_id' => $user->id,
+ 'content' => 'Middle liked',
+ 'likes' => 5,
+ ]);
+
+ $comments = PostCommentService::getComments($post->id);
+
+ $this->assertEquals(10, $comments[0]->likes);
+ $this->assertEquals(5, $comments[1]->likes);
+ $this->assertEquals(2, $comments[2]->likes);
+ }
+
+ /**
+ * Test getting comments with user relationship loaded
+ */
+ public function test_get_comments_with_user_relationship(): void
+ {
+ $post = UserPost::factory()->create();
+ $user = User::factory()->create([
+ 'first_name' => 'John',
+ 'last_name' => 'Doe',
+ ]);
+
+ PostComment::factory()->create([
+ 'post_id' => $post->id,
+ 'user_id' => $user->id,
+ 'content' => 'Test comment',
+ ]);
+
+ $comments = PostCommentService::getComments($post->id);
+
+ $this->assertCount(1, $comments);
+ $this->assertNotNull($comments[0]->user);
+ $this->assertEquals('John', $comments[0]->user->first_name);
+ $this->assertEquals('Doe', $comments[0]->user->last_name);
+ }
+
+ /**
+ * Test getting comments for post with no comments
+ */
+ public function test_get_comments_for_post_with_no_comments(): void
+ {
+ $post = UserPost::factory()->create();
+
+ $comments = PostCommentService::getComments($post->id);
+
+ $this->assertCount(0, $comments);
+ }
+
+ /**
+ * Test getting comments only returns comments for specified post
+ */
+ public function test_get_comments_returns_only_post_comments(): void
+ {
+ $post1 = UserPost::factory()->create();
+ $post2 = UserPost::factory()->create();
+ $user = User::factory()->create();
+
+ PostComment::factory()->create([
+ 'post_id' => $post1->id,
+ 'user_id' => $user->id,
+ 'content' => 'Comment on post 1',
+ ]);
+
+ PostComment::factory()->create([
+ 'post_id' => $post2->id,
+ 'user_id' => $user->id,
+ 'content' => 'Comment on post 2',
+ ]);
+
+ $comments = PostCommentService::getComments($post1->id);
+
+ $this->assertCount(1, $comments);
+ $this->assertEquals('Comment on post 1', $comments[0]->content);
+ }
+
+ /**
+ * Test adding a like to a comment
+ */
+ public function test_toggle_comment_like_adds_new_like(): void
+ {
+ $user = User::factory()->create();
+ $comment = PostComment::factory()->create(['likes' => 5]);
+
+ $result = PostCommentService::toggleCommentLike($user->id, $comment->id);
+
+ $this->assertTrue($result['liked']);
+ $this->assertEquals(6, $result['likes']);
+
+ $this->assertDatabaseHas('comment_likes', [
+ 'comment_id' => $comment->id,
+ 'user_id' => $user->id,
+ ]);
+ }
+
+ /**
+ * Test removing a like from a comment
+ */
+ public function test_toggle_comment_like_removes_existing_like(): void
+ {
+ $user = User::factory()->create();
+ $comment = PostComment::factory()->create(['likes' => 5]);
+
+ CommentsLike::create([
+ 'comment_id' => $comment->id,
+ 'user_id' => $user->id,
+ ]);
+
+ $result = PostCommentService::toggleCommentLike($user->id, $comment->id);
+
+ $this->assertFalse($result['liked']);
+ $this->assertEquals(4, $result['likes']);
+
+ $this->assertDatabaseMissing('comment_likes', [
+ 'comment_id' => $comment->id,
+ 'user_id' => $user->id,
+ ]);
+ }
+
+ /**
+ * Test toggle like increments likes counter
+ */
+ public function test_toggle_like_increments_likes_counter(): void
+ {
+ $user = User::factory()->create();
+ $comment = PostComment::factory()->create(['likes' => 0]);
+
+ PostCommentService::toggleCommentLike($user->id, $comment->id);
+
+ $comment->refresh();
+ $this->assertEquals(1, $comment->likes);
+ }
+
+ /**
+ * Test toggle like decrements likes counter
+ */
+ public function test_toggle_like_decrements_likes_counter(): void
+ {
+ $user = User::factory()->create();
+ $comment = PostComment::factory()->create(['likes' => 3]);
+
+ CommentsLike::create([
+ 'comment_id' => $comment->id,
+ 'user_id' => $user->id,
+ ]);
+
+ PostCommentService::toggleCommentLike($user->id, $comment->id);
+
+ $comment->refresh();
+ $this->assertEquals(2, $comment->likes);
+ }
+
+ /**
+ * Test multiple users can like the same comment
+ */
+ public function test_multiple_users_can_like_same_comment(): void
+ {
+ $user1 = User::factory()->create();
+ $user2 = User::factory()->create();
+ $user3 = User::factory()->create();
+ $comment = PostComment::factory()->create(['likes' => 0]);
+
+ PostCommentService::toggleCommentLike($user1->id, $comment->id);
+ PostCommentService::toggleCommentLike($user2->id, $comment->id);
+ PostCommentService::toggleCommentLike($user3->id, $comment->id);
+
+ $comment->refresh();
+ $this->assertEquals(3, $comment->likes);
+
+ $this->assertDatabaseHas('comment_likes', [
+ 'comment_id' => $comment->id,
+ 'user_id' => $user1->id,
+ ]);
+ $this->assertDatabaseHas('comment_likes', [
+ 'comment_id' => $comment->id,
+ 'user_id' => $user2->id,
+ ]);
+ $this->assertDatabaseHas('comment_likes', [
+ 'comment_id' => $comment->id,
+ 'user_id' => $user3->id,
+ ]);
+ }
+
+ /**
+ * Test user can like and unlike the same comment
+ */
+ public function test_user_can_like_and_unlike_same_comment(): void
+ {
+ $user = User::factory()->create();
+ $comment = PostComment::factory()->create(['likes' => 0]);
+
+ // Like the comment
+ $result1 = PostCommentService::toggleCommentLike($user->id, $comment->id);
+ $this->assertTrue($result1['liked']);
+ $this->assertEquals(1, $result1['likes']);
+
+ // Unlike the comment
+ $result2 = PostCommentService::toggleCommentLike($user->id, $comment->id);
+ $this->assertFalse($result2['liked']);
+ $this->assertEquals(0, $result2['likes']);
+
+ // Like again
+ $result3 = PostCommentService::toggleCommentLike($user->id, $comment->id);
+ $this->assertTrue($result3['liked']);
+ $this->assertEquals(1, $result3['likes']);
+ }
+
+ /**
+ * Test toggle like returns fresh comment likes count
+ */
+ public function test_toggle_like_returns_fresh_likes_count(): void
+ {
+ $user1 = User::factory()->create();
+ $user2 = User::factory()->create();
+ $comment = PostComment::factory()->create(['likes' => 2]);
+
+ CommentsLike::create([
+ 'comment_id' => $comment->id,
+ 'user_id' => $user1->id,
+ ]);
+
+ CommentsLike::create([
+ 'comment_id' => $comment->id,
+ 'user_id' => $user2->id,
+ ]);
+
+ // Toggle like for user2 (remove)
+ $result = PostCommentService::toggleCommentLike($user2->id, $comment->id);
+
+ $this->assertFalse($result['liked']);
+ $this->assertEquals(1, $result['likes']);
+ }
+
+ /**
+ * Test toggle like is transactional
+ */
+ public function test_toggle_like_uses_database_transaction(): void
+ {
+ $user = User::factory()->create();
+ $comment = PostComment::factory()->create(['likes' => 0]);
+
+ $result = PostCommentService::toggleCommentLike($user->id, $comment->id);
+
+ // Verify both operations succeeded
+ $this->assertTrue($result['liked']);
+ $this->assertEquals(1, $result['likes']);
+
+ $this->assertDatabaseHas('comment_likes', [
+ 'comment_id' => $comment->id,
+ 'user_id' => $user->id,
+ ]);
+
+ $comment->refresh();
+ $this->assertEquals(1, $comment->likes);
+ }
+
+ /**
+ * Test posting multiple comments by same user
+ */
+ public function test_post_multiple_comments_by_same_user(): void
+ {
+ $user = User::factory()->create();
+ $post = UserPost::factory()->create();
+
+ $comment1 = PostCommentService::postComment($user->id, 'First comment', $post->id);
+ $comment2 = PostCommentService::postComment($user->id, 'Second comment', $post->id);
+
+ $this->assertNotEquals($comment1->id, $comment2->id);
+ $this->assertEquals($user->id, $comment1->user_id);
+ $this->assertEquals($user->id, $comment2->user_id);
+
+ $this->assertDatabaseHas('post_comments', [
+ 'user_id' => $user->id,
+ 'post_id' => $post->id,
+ 'content' => 'First comment',
+ ]);
+
+ $this->assertDatabaseHas('post_comments', [
+ 'user_id' => $user->id,
+ 'post_id' => $post->id,
+ 'content' => 'Second comment',
+ ]);
+ }
+
+ /**
+ * Test posting comments by different users on same post
+ */
+ public function test_post_comments_by_different_users_on_same_post(): void
+ {
+ $user1 = User::factory()->create();
+ $user2 = User::factory()->create();
+ $post = UserPost::factory()->create();
+
+ $comment1 = PostCommentService::postComment($user1->id, 'User 1 comment', $post->id);
+ $comment2 = PostCommentService::postComment($user2->id, 'User 2 comment', $post->id);
+
+ $comments = PostCommentService::getComments($post->id);
+
+ $this->assertCount(2, $comments);
+ $this->assertContains('User 1 comment', $comments->pluck('content')->toArray());
+ $this->assertContains('User 2 comment', $comments->pluck('content')->toArray());
+ }
+
+ /**
+ * Test comment with special characters
+ */
+ public function test_post_comment_with_special_characters(): void
+ {
+ $user = User::factory()->create();
+ $post = UserPost::factory()->create();
+
+ $specialContent = "This is a comment with special chars: @#$%^&*()_+-=[]{}|;:',.<>?/~`";
+
+ $comment = PostCommentService::postComment($user->id, $specialContent, $post->id);
+
+ $this->assertEquals($specialContent, $comment->content);
+ }
+
+ /**
+ * Test comment with unicode characters
+ */
+ public function test_post_comment_with_unicode_characters(): void
+ {
+ $user = User::factory()->create();
+ $post = UserPost::factory()->create();
+
+ $unicodeContent = "This comment has emojis 🚀 and unicode: café, naïve, résumé";
+
+ $comment = PostCommentService::postComment($user->id, $unicodeContent, $post->id);
+
+ $this->assertEquals($unicodeContent, $comment->content);
+ }
+}
diff --git a/server/tests/Unit/UserCopilotHistoryServiceTest.php b/server/tests/Unit/UserCopilotHistoryServiceTest.php
new file mode 100644
index 0000000..42a9a9f
--- /dev/null
+++ b/server/tests/Unit/UserCopilotHistoryServiceTest.php
@@ -0,0 +1,303 @@
+create();
+ $user2 = User::factory()->create();
+
+ UserCopilotHistory::factory()->create(['user_id' => $user1->id]);
+ UserCopilotHistory::factory()->create(['user_id' => $user1->id]);
+ UserCopilotHistory::factory()->create(['user_id' => $user2->id]);
+
+ $user1Histories = UserCopilotHistoryService::getUserHistories($user1->id);
+ $user2Histories = UserCopilotHistoryService::getUserHistories($user2->id);
+
+ $this->assertCount(2, $user1Histories);
+ $this->assertCount(1, $user2Histories);
+ }
+
+ /**
+ * Test getting histories with messages loaded
+ */
+ public function test_get_user_histories_loads_messages(): void
+ {
+ $user = User::factory()->create();
+ $history = UserCopilotHistory::factory()->create(['user_id' => $user->id]);
+
+ Message::factory()->create(['history_id' => $history->id]);
+ Message::factory()->create(['history_id' => $history->id]);
+
+ $histories = UserCopilotHistoryService::getUserHistories($user->id);
+
+ $this->assertCount(1, $histories);
+ $this->assertTrue($histories[0]->relationLoaded('messages'));
+ $this->assertCount(2, $histories[0]->messages);
+ }
+
+ /**
+ * Test messages are ordered by created_at ascending
+ */
+ public function test_get_user_histories_messages_ordered_by_created_at(): void
+ {
+ $user = User::factory()->create();
+ $history = UserCopilotHistory::factory()->create(['user_id' => $user->id]);
+
+ $message1 = Message::factory()->create(['history_id' => $history->id]);
+ $message2 = Message::factory()->create(['history_id' => $history->id]);
+ $message3 = Message::factory()->create(['history_id' => $history->id]);
+
+ $histories = UserCopilotHistoryService::getUserHistories($user->id);
+ $messages = $histories[0]->messages;
+
+ $this->assertEquals($message1->id, $messages[0]->id);
+ $this->assertEquals($message2->id, $messages[1]->id);
+ $this->assertEquals($message3->id, $messages[2]->id);
+ }
+
+ /**
+ * Test getting empty histories for user with no histories
+ */
+ public function test_get_user_histories_with_no_histories(): void
+ {
+ $user = User::factory()->create();
+
+ $histories = UserCopilotHistoryService::getUserHistories($user->id);
+
+ $this->assertCount(0, $histories);
+ }
+
+ /**
+ * Test getting copilot history details successfully
+ */
+ public function test_get_user_copilot_history_details_successfully(): void
+ {
+ $user = User::factory()->create();
+ $history = UserCopilotHistory::factory()->create(['user_id' => $user->id]);
+
+ Message::factory()->create(['history_id' => $history->id]);
+ Message::factory()->create(['history_id' => $history->id]);
+
+ $result = UserCopilotHistoryService::getUserCopilotHistoryDetials($user->id, $history);
+
+ $this->assertEquals($history->id, $result->id);
+ $this->assertTrue($result->relationLoaded('messages'));
+ $this->assertCount(2, $result->messages);
+ }
+
+ /**
+ * Test getting history details for unauthorized user
+ */
+ public function test_get_user_copilot_history_details_unauthorized(): void
+ {
+ $user1 = User::factory()->create();
+ $user2 = User::factory()->create();
+ $history = UserCopilotHistory::factory()->create(['user_id' => $user1->id]);
+
+ $this->expectException(Exception::class);
+ $this->expectExceptionMessage('History not found');
+
+ UserCopilotHistoryService::getUserCopilotHistoryDetials($user2->id, $history);
+ }
+
+ /**
+ * Test getting history details loads messages ordered
+ */
+ public function test_get_user_copilot_history_details_loads_ordered_messages(): void
+ {
+ $user = User::factory()->create();
+ $history = UserCopilotHistory::factory()->create(['user_id' => $user->id]);
+
+ $message1 = Message::factory()->create(['history_id' => $history->id]);
+ $message2 = Message::factory()->create(['history_id' => $history->id]);
+ $message3 = Message::factory()->create(['history_id' => $history->id]);
+
+ $result = UserCopilotHistoryService::getUserCopilotHistoryDetials($user->id, $history);
+
+ $this->assertEquals($message1->id, $result->messages[0]->id);
+ $this->assertEquals($message2->id, $result->messages[1]->id);
+ $this->assertEquals($message3->id, $result->messages[2]->id);
+ }
+
+ /**
+ * Test getting history details with no messages
+ */
+ public function test_get_user_copilot_history_details_with_no_messages(): void
+ {
+ $user = User::factory()->create();
+ $history = UserCopilotHistory::factory()->create(['user_id' => $user->id]);
+
+ $result = UserCopilotHistoryService::getUserCopilotHistoryDetials($user->id, $history);
+
+ $this->assertCount(0, $result->messages);
+ }
+
+ /**
+ * Test deleting history successfully
+ */
+ public function test_delete_history_successfully(): void
+ {
+ $user = User::factory()->create();
+ $history = UserCopilotHistory::factory()->create(['user_id' => $user->id]);
+
+ Message::factory()->create(['history_id' => $history->id]);
+ Message::factory()->create(['history_id' => $history->id]);
+
+ UserCopilotHistoryService::deleteHistory($user->id, $history);
+
+ $this->assertDatabaseMissing('user_copilot_histories', ['id' => $history->id]);
+ $this->assertDatabaseMissing('messages', ['history_id' => $history->id]);
+ }
+
+ /**
+ * Test deleting history removes all related messages
+ */
+ public function test_delete_history_removes_all_messages(): void
+ {
+ $user = User::factory()->create();
+ $history = UserCopilotHistory::factory()->create(['user_id' => $user->id]);
+
+ $message1 = Message::factory()->create(['history_id' => $history->id]);
+ $message2 = Message::factory()->create(['history_id' => $history->id]);
+ $message3 = Message::factory()->create(['history_id' => $history->id]);
+
+ UserCopilotHistoryService::deleteHistory($user->id, $history);
+
+ $this->assertDatabaseMissing('messages', ['id' => $message1->id]);
+ $this->assertDatabaseMissing('messages', ['id' => $message2->id]);
+ $this->assertDatabaseMissing('messages', ['id' => $message3->id]);
+ }
+
+ /**
+ * Test deleting history for unauthorized user
+ */
+ public function test_delete_history_unauthorized(): void
+ {
+ $user1 = User::factory()->create();
+ $user2 = User::factory()->create();
+ $history = UserCopilotHistory::factory()->create(['user_id' => $user1->id]);
+
+ $this->expectException(Exception::class);
+ $this->expectExceptionMessage('History not found');
+
+ UserCopilotHistoryService::deleteHistory($user2->id, $history);
+ }
+
+ /**
+ * Test deleting history does not delete other user's messages
+ */
+ public function test_delete_history_does_not_delete_other_histories(): void
+ {
+ $user1 = User::factory()->create();
+ $user2 = User::factory()->create();
+
+ $history1 = UserCopilotHistory::factory()->create(['user_id' => $user1->id]);
+ $history2 = UserCopilotHistory::factory()->create(['user_id' => $user2->id]);
+
+ Message::factory()->create(['history_id' => $history1->id]);
+ Message::factory()->create(['history_id' => $history2->id]);
+
+ UserCopilotHistoryService::deleteHistory($user1->id, $history1);
+
+ $this->assertDatabaseMissing('user_copilot_histories', ['id' => $history1->id]);
+ $this->assertDatabaseHas('user_copilot_histories', ['id' => $history2->id]);
+ }
+
+ /**
+ * Test getting downloadable content successfully
+ */
+ public function test_get_downloadable_content_successfully(): void
+ {
+ $user = User::factory()->create();
+ $history = UserCopilotHistory::factory()->create(['user_id' => $user->id]);
+
+ $aiResponse = ['response' => 'This is the AI response'];
+ $message = Message::factory()->create([
+ 'history_id' => $history->id,
+ 'ai_response' => $aiResponse,
+ 'user_message' => 'Test message',
+ ]);
+
+ $result = UserCopilotHistoryService::getDownloadableContent($user->id, $history);
+
+ $this->assertEquals($message->id, $result->id);
+ $this->assertEquals($aiResponse, $result->ai_response);
+ }
+
+ /**
+ * Test getting downloadable content without messages
+ */
+ public function test_get_downloadable_content_without_messages(): void
+ {
+ $user = User::factory()->create();
+ $history = UserCopilotHistory::factory()->create(['user_id' => $user->id]);
+
+ $this->expectException(\Exception::class);
+
+ UserCopilotHistoryService::getDownloadableContent($user->id, $history);
+ }
+
+ /**
+ * Test getting downloadable content with array ai_response
+ */
+ public function test_get_downloadable_content_with_array_ai_response(): void
+ {
+ $user = User::factory()->create();
+ $history = UserCopilotHistory::factory()->create(['user_id' => $user->id]);
+
+ $aiResponse = [
+ 'blocks' => [
+ ['type' => 'paragraph', 'data' => ['text' => 'Sample response']],
+ ],
+ ];
+
+ Message::factory()->create([
+ 'history_id' => $history->id,
+ 'ai_response' => $aiResponse,
+ ]);
+
+ $result = UserCopilotHistoryService::getDownloadableContent($user->id, $history);
+
+ $this->assertEquals($aiResponse, $result->ai_response);
+ $this->assertIsArray($result->ai_response);
+ }
+
+ /**
+ * Test getting downloadable content returns message object
+ */
+ public function test_get_downloadable_content_returns_message_object(): void
+ {
+ $user = User::factory()->create();
+ $history = UserCopilotHistory::factory()->create(['user_id' => $user->id]);
+
+ $message = Message::factory()->create([
+ 'history_id' => $history->id,
+ 'ai_response' => ['response' => 'Test'],
+ 'user_message' => 'User query',
+ 'ai_model' => 'gpt-4',
+ ]);
+
+ $result = UserCopilotHistoryService::getDownloadableContent($user->id, $history);
+
+ $this->assertInstanceOf(Message::class, $result);
+ $this->assertEquals('User query', $result->user_message);
+ $this->assertEquals('gpt-4', $result->ai_model);
+ }
+}
diff --git a/server/tests/Unit/ValidateFlowLogicServiceTest.php b/server/tests/Unit/ValidateFlowLogicServiceTest.php
new file mode 100644
index 0000000..f209c70
--- /dev/null
+++ b/server/tests/Unit/ValidateFlowLogicServiceTest.php
@@ -0,0 +1,100 @@
+service = new ValidateFlowLogicService();
+ }
+ /**
+ * Test that fingerprint is consistent for the same workflow
+ */
+ public function test_fingerprint_consistency(): void
+ {
+ $workflow = [
+ 'nodes' => [
+ ['type' => 'Gmail', 'name' => 'email_node'],
+ ['type' => 'Slack', 'name' => 'slack_node']
+ ],
+ 'connections' => [
+ 'email_node' => ['main' => [[['node' => 'slack_node']]]]
+ ]
+ ];
+
+ // Use reflection to call private method
+ $reflection = new \ReflectionClass(ValidateFlowLogicService::class);
+ $method = $reflection->getMethod('fingerprintWorkflow');
+ $method->setAccessible(true);
+
+ $fingerprint1 = $method->invoke($this->service, $workflow);
+ $fingerprint2 = $method->invoke($this->service, $workflow);
+
+ $this->assertEquals($fingerprint1, $fingerprint2);
+ }
+
+ /**
+ * Test that different workflows produce different fingerprints
+ */
+ public function test_fingerprint_differs_for_different_workflows(): void
+ {
+ $workflow1 = [
+ 'nodes' => [
+ ['type' => 'Gmail', 'name' => 'email_node']
+ ],
+ 'connections' => []
+ ];
+
+ $workflow2 = [
+ 'nodes' => [
+ ['type' => 'Slack', 'name' => 'slack_node']
+ ],
+ 'connections' => []
+ ];
+
+ $reflection = new \ReflectionClass(ValidateFlowLogicService::class);
+ $method = $reflection->getMethod('fingerprintWorkflow');
+ $method->setAccessible(true);
+
+ $fingerprint1 = $method->invoke($this->service, $workflow1);
+ $fingerprint2 = $method->invoke($this->service, $workflow2);
+
+ $this->assertNotEquals($fingerprint1, $fingerprint2);
+ }
+
+ /**
+ * Test that best workflow is tracked correctly
+ */
+ public function test_best_workflow_tracking(): void
+ {
+ $workflow1 = ['nodes' => [['type' => 'Gmail', 'name' => 'node1']], 'connections' => []];
+ $workflow2 = ['nodes' => [['type' => 'Slack', 'name' => 'node2']], 'connections' => []];
+
+ $reflection = new \ReflectionClass(ValidateFlowLogicService::class);
+
+ // Get the updateBestWorkflow method
+ $updateMethod = $reflection->getMethod('updateBestWorkflow');
+ $updateMethod->setAccessible(true);
+
+ // First workflow with score 0.7
+ $updateMethod->invoke($this->service, $workflow1, 0.7);
+
+ // Second workflow with score 0.85 (should become best)
+ $updateMethod->invoke($this->service, $workflow2, 0.85);
+
+ // Verify best workflow property
+ $bestWorkflowProperty = $reflection->getProperty('bestWorkflow');
+ $bestWorkflowProperty->setAccessible(true);
+ $bestWorkflow = $bestWorkflowProperty->getValue($this->service);
+
+ $this->assertEquals($workflow2, $bestWorkflow);
+ }
+}