From 1a4f55599b09b30a7ee41bea641cd5693874f79a Mon Sep 17 00:00:00 2001 From: raineer Date: Tue, 12 May 2026 20:55:15 +0800 Subject: [PATCH 1/3] project members --- .../projects/_components/ProjectEditModal.tsx | 57 +++++++++++++------ apps/codebility/app/home/projects/actions.ts | 41 ++++++++++--- 2 files changed, 74 insertions(+), 24 deletions(-) diff --git a/apps/codebility/app/home/projects/_components/ProjectEditModal.tsx b/apps/codebility/app/home/projects/_components/ProjectEditModal.tsx index bcf3bfec5..247f15bfa 100644 --- a/apps/codebility/app/home/projects/_components/ProjectEditModal.tsx +++ b/apps/codebility/app/home/projects/_components/ProjectEditModal.tsx @@ -286,7 +286,6 @@ const ProjectEditModal = () => { ); if (subLeadMember) { - // Primary: find full Codev object from users list const subLeadFromUsers = users.find( (user) => user.id === subLeadMember.codev_id, ); @@ -294,7 +293,6 @@ const ProjectEditModal = () => { if (subLeadFromUsers) { setCurrentSubLead(subLeadFromUsers); } else if (subLeadMember.codev) { - // Fallback: build minimal Codev from embedded data (same pattern as team leader) setCurrentSubLead({ id: subLeadMember.codev_id, first_name: subLeadMember.codev.first_name, @@ -307,17 +305,49 @@ const ProjectEditModal = () => { } as unknown as Codev); } } else { - // No sublead on this project — reset to null when reopening modal setCurrentSubLead(null); } // ───────────────────────────────────────────────────────────────────────── // ── Regular members ────────────────────────────────────────────────────── - const memberIds = projectMembers - .filter((pm: any) => pm.role === "member") - .map((pm: any) => pm.codev_id); + // FIX (project-members-update-conflict): Previously used + // users.filter(u => memberIds.includes(u.id)) + // which silently excluded RLS-filtered members from selectedMembers. + // On submit, those missing members were deleted from project_members DB. + // Fix: use pm.codev fallback (same pattern as team leader / sublead above) + // so every member in DB is represented in the form payload on save. + const memberPMs = projectMembers.filter((pm: any) => pm.role === "member"); + + const resolvedMembers = memberPMs + .map((pm: any) => { + // Try full Codev object from users first + const fromUsers = users.find((u) => u.id === pm.codev_id); + if (fromUsers) return fromUsers; + + // Fallback: construct minimal Codev from embedded codev data. + // Handles users absent from getProjectCodevs() due to RLS filtering. + if (pm.codev) { + return { + id: pm.codev_id, + first_name: pm.codev.first_name, + last_name: pm.codev.last_name, + image_url: pm.codev.image_url || null, + display_position: pm.codev.display_position || null, + email_address: pm.codev.email_address || "", + positions: [], + tech_stacks: [], + } as unknown as Codev; + } + + // pm.codev is null — RLS dropped the codev row even from the two-query + // approach. Log a warning; skip this member to avoid a null in the array. + console.warn(`⚠️ Could not resolve member codev data for: ${pm.codev_id}`); + return null; + }) + .filter(Boolean) as Codev[]; - setSelectedMembers(users.filter((user) => memberIds.includes(user.id))); + setSelectedMembers(resolvedMembers); + // ───────────────────────────────────────────────────────────────────────── }, [fullProjectData, users, isModalOpen]); const handleImageChange = async (e: ChangeEvent) => { @@ -721,11 +751,9 @@ const ProjectEditModal = () => { value={currentSubLead?.id || "none"} onChange={(value) => { if (!value || value === "none") { - // User selected "None" — clear the sublead setCurrentSubLead(null); return; } - // Find in users first, fall back to project_members embedded data const subLeadFromUsers = users.find((u) => u.id === value); if (subLeadFromUsers) { setCurrentSubLead(subLeadFromUsers); @@ -764,7 +792,7 @@ const ProjectEditModal = () => { users={users.filter( (user) => user.id !== currentTeamLeader?.id && - user.id !== currentSubLead?.id, // exclude sublead from member pool + user.id !== currentSubLead?.id, )} selectedMembers={selectedMembers} onMemberAdd={(member) => @@ -775,7 +803,7 @@ const ProjectEditModal = () => { } excludeMembers={[ ...(currentTeamLeader ? [currentTeamLeader.id] : []), - ...(currentSubLead ? [currentSubLead.id] : []), // ── CBP-116 + ...(currentSubLead ? [currentSubLead.id] : []), ]} showLabel={false} /> @@ -891,15 +919,12 @@ const ProjectEditModal = () => { form.set("status", selectedStatus); - // ── CBP-116: Build project members array including sublead if set ──────── + // ── CBP-116: Build project members array including sublead if set ──────── const projectMembers = [ { codev_id: currentTeamLeader.id, role: "team_leader" }, - // Only include sublead row if a sublead was selected ...(currentSubLead ? [{ codev_id: currentSubLead.id, role: "sublead" }] : []), - // Filter sublead out of members — they may be an existing member promoted - // to sublead. Without this filter they'd get two rows: sublead + member. ...selectedMembers .filter((member) => member.id !== currentSubLead?.id) .map((member) => ({ codev_id: member.id, role: "member" })), @@ -913,7 +938,7 @@ const ProjectEditModal = () => { queryClient.invalidateQueries({ queryKey: ["teamLead", data.id] }); queryClient.invalidateQueries({ queryKey: ["members", data.id] }); queryClient.invalidateQueries({ queryKey: ["projectFull", data.id] }); - queryClient.invalidateQueries({ queryKey: ["subLead", data.id] }); // ── CBP-116 + queryClient.invalidateQueries({ queryKey: ["subLead", data.id] }); toast.success("Project updated successfully!"); onClose(); } else { diff --git a/apps/codebility/app/home/projects/actions.ts b/apps/codebility/app/home/projects/actions.ts index 38c278d4d..209b4bd7a 100644 --- a/apps/codebility/app/home/projects/actions.ts +++ b/apps/codebility/app/home/projects/actions.ts @@ -726,7 +726,14 @@ export const getMembers = async ( }; /** - * Update project members with joined_at preservation. + * Update project members from My Team page with joined_at and sublead preservation. + * + * ── FIX (project-members-update-conflict) ──────────────────────────────────── + * This function is called from TeamDetailView (My Team page) which has no sublead UI. + * Without preservation, the delete-reinsert wipes the sublead row every time a team + * lead manages members from My Team. Fix: fetch the existing sublead row before + * deletion, then re-insert it after the new members are written. + * ───────────────────────────────────────────────────────────────────────────── */ export const updateProjectMembers = async ( projectId: string, @@ -736,17 +743,23 @@ export const updateProjectMembers = async ( const supabase = await createClientServerComponent(); try { + // Fetch all existing rows: need joined_at for every member AND the sublead row const { data: existingMembers, error: fetchError } = await supabase .from("project_members") - .select("codev_id, joined_at") + .select("codev_id, joined_at, role") .eq("project_id", projectId); if (fetchError) throw fetchError; + // Preserve joined_at timestamps const joinedAtMap = new Map( existingMembers?.map(m => [m.codev_id, m.joined_at]) ?? [] ); + // Capture sublead row before deletion — My Team has no sublead UI so it + // would otherwise be permanently lost on every member update + const existingSubLead = existingMembers?.find(m => m.role === "sublead"); + const { error: deleteError } = await supabase .from("project_members") .delete() @@ -754,12 +767,24 @@ export const updateProjectMembers = async ( if (deleteError) throw deleteError; - const memberInserts = members.map((member) => ({ - project_id: projectId, - codev_id: member.id, - role: member.id === teamLeaderId ? "team_leader" : "member", - joined_at: joinedAtMap.get(member.id) ?? new Date().toISOString(), - })); + const memberInserts = [ + // Team lead + regular members + ...members.map((member) => ({ + project_id: projectId, + codev_id: member.id, + role: member.id === teamLeaderId ? "team_leader" : "member", + joined_at: joinedAtMap.get(member.id) ?? new Date().toISOString(), + })), + // Re-insert sublead if one existed — preserves their joined_at too + ...(existingSubLead + ? [{ + project_id: projectId, + codev_id: existingSubLead.codev_id, + role: "sublead" as const, + joined_at: existingSubLead.joined_at, + }] + : []), + ]; const { error: insertError } = await supabase .from("project_members") From 4c714e54ee578902b90189ae0ad40df843488415 Mon Sep 17 00:00:00 2001 From: raineer Date: Fri, 15 May 2026 16:53:33 +0800 Subject: [PATCH 2/3] fix merge conflicts --- .../projects/_components/ProjectEditModal.tsx | 46 +++-- apps/codebility/app/home/projects/actions.ts | 163 +++++++++++++----- 2 files changed, 142 insertions(+), 67 deletions(-) diff --git a/apps/codebility/app/home/projects/_components/ProjectEditModal.tsx b/apps/codebility/app/home/projects/_components/ProjectEditModal.tsx index 247f15bfa..047c3f75f 100644 --- a/apps/codebility/app/home/projects/_components/ProjectEditModal.tsx +++ b/apps/codebility/app/home/projects/_components/ProjectEditModal.tsx @@ -1,6 +1,6 @@ "use client"; -import { ChangeEvent, useEffect, useMemo, useState } from "react"; +import { ChangeEvent, useEffect, useMemo, useRef, useState } from "react"; import dynamic from "next/dynamic"; import Image from "next/image"; import { @@ -104,13 +104,13 @@ const ProjectEditModal = () => { } = useForm({ mode: "onChange" }); const { data: users = [], isLoading: isUsersLoading } = useQuery({ - queryKey: ["projectCodevs"], + queryKey: ["projectCodevs", "v3"], queryFn: async () => { const result = await getProjectCodevs(); return result || []; }, - staleTime: 10 * 60 * 1000, - refetchOnWindowFocus: false, + staleTime: 2 * 60 * 1000, + refetchOnWindowFocus: true, }); const { data: clients = [], isLoading: isClientsLoading } = useQuery({ @@ -179,14 +179,11 @@ const ProjectEditModal = () => { // getProjectCodevs() may exclude users due to RLS or internal_status filtering. // We use fullProjectData.project_members (from getProjectByID join) as the // source of truth — it always returns codev data regardless of RLS. - // This covers the team leader (CBP-95) and any member used as sublead (CBP-116). const enhancedUserOptions = useMemo(() => { if (!fullProjectData?.project_members) return userOptions; - // Build a set of codev_ids already present in userOptions for O(1) lookup const existingIds = new Set(userOptions.map((opt) => opt.value)); - // Collect all project members missing from userOptions const missingMembers = fullProjectData.project_members .filter((pm: any) => pm.codev && !existingIds.has(pm.codev_id)) .map((pm: any) => ({ @@ -199,7 +196,6 @@ const ProjectEditModal = () => { if (missingMembers.length === 0) return userOptions; - // Inject missing members at the top so they are always findable return [...missingMembers, ...userOptions]; }, [userOptions, fullProjectData]); @@ -246,8 +242,19 @@ const ProjectEditModal = () => { // Pre-populate team leader, sublead, and members from fullProjectData. // fullProjectData comes from getProjectByID which includes project_members. // The list page query does not join project_members so we cannot use data directly. + // + // IMPORTANT: Only run when modal opens (isModalOpen changes to true) to avoid + // resetting selectedMembers when users list refetches during editing. + const hasInitialized = useRef(false); + useEffect(() => { - if (!fullProjectData || !isModalOpen) return; + if (!fullProjectData || !isModalOpen) { + hasInitialized.current = false; + return; + } + + // Skip if already initialized and users are available + if (hasInitialized.current && users.length > 0) return; const projectMembers = fullProjectData.project_members; if (!projectMembers?.length) return; @@ -314,18 +321,14 @@ const ProjectEditModal = () => { // users.filter(u => memberIds.includes(u.id)) // which silently excluded RLS-filtered members from selectedMembers. // On submit, those missing members were deleted from project_members DB. - // Fix: use pm.codev fallback (same pattern as team leader / sublead above) - // so every member in DB is represented in the form payload on save. + // Fix: use pm.codev fallback so every member in DB is in the form payload. const memberPMs = projectMembers.filter((pm: any) => pm.role === "member"); const resolvedMembers = memberPMs .map((pm: any) => { - // Try full Codev object from users first const fromUsers = users.find((u) => u.id === pm.codev_id); if (fromUsers) return fromUsers; - // Fallback: construct minimal Codev from embedded codev data. - // Handles users absent from getProjectCodevs() due to RLS filtering. if (pm.codev) { return { id: pm.codev_id, @@ -339,14 +342,15 @@ const ProjectEditModal = () => { } as unknown as Codev; } - // pm.codev is null — RLS dropped the codev row even from the two-query - // approach. Log a warning; skip this member to avoid a null in the array. console.warn(`⚠️ Could not resolve member codev data for: ${pm.codev_id}`); return null; }) .filter(Boolean) as Codev[]; setSelectedMembers(resolvedMembers); + + // Mark as initialized to prevent resets when users list refetches + hasInitialized.current = true; // ───────────────────────────────────────────────────────────────────────── }, [fullProjectData, users, isModalOpen]); @@ -715,9 +719,8 @@ const ProjectEditModal = () => { {/* ── CBP-116: Sublead selector ───────────────────────────────────── - Optional — no asterisk, no toast validation required. - Excluded from members list and excluded from team leader list. - Clearing the select (empty string value) sets sublead to null. + Optional. Sourced from fullProjectData.project_members to bypass + RLS filtering that drops some users from getProjectCodevs(). ──────────────────────────────────────────────────────────────────── */}
@@ -759,7 +758,6 @@ const ProjectEditModal = () => { setCurrentSubLead(subLeadFromUsers); return; } - // Fallback: construct from fullProjectData (handles RLS-filtered users) const subLeadMember = fullProjectData?.project_members?.find( (pm: any) => pm.codev_id === value, ); diff --git a/apps/codebility/app/home/projects/actions.ts b/apps/codebility/app/home/projects/actions.ts index 209b4bd7a..007c9cb0b 100644 --- a/apps/codebility/app/home/projects/actions.ts +++ b/apps/codebility/app/home/projects/actions.ts @@ -740,6 +740,11 @@ export const updateProjectMembers = async ( members: Codev[], teamLeaderId: string, ): Promise<{ success: boolean; error?: string }> => { + console.log('🔧 [updateProjectMembers] Server-side update starting'); + console.log(' Project ID:', projectId); + console.log(' Members to add:', members.length); + console.log(' Team Leader ID:', teamLeaderId); + const supabase = await createClientServerComponent(); try { @@ -751,6 +756,8 @@ export const updateProjectMembers = async ( if (fetchError) throw fetchError; + console.log(' Existing members in DB:', existingMembers?.length ?? 0); + // Preserve joined_at timestamps const joinedAtMap = new Map( existingMembers?.map(m => [m.codev_id, m.joined_at]) ?? [] @@ -767,6 +774,8 @@ export const updateProjectMembers = async ( if (deleteError) throw deleteError; + console.log(' Old members deleted, preparing inserts...'); + const memberInserts = [ // Team lead + regular members ...members.map((member) => ({ @@ -786,16 +795,22 @@ export const updateProjectMembers = async ( : []), ]; + console.log(' Inserting members:', memberInserts.length); + console.log(' Member IDs:', memberInserts.map(m => m.codev_id)); + console.log(' Roles:', memberInserts.map(m => m.role)); + const { error: insertError } = await supabase .from("project_members") .insert(memberInserts); if (insertError) throw insertError; + console.log('✅ [updateProjectMembers] Successfully inserted', memberInserts.length, 'members'); + revalidatePath("/projects"); return { success: true }; } catch (error) { - console.error("Error updating project members:", error); + console.error("❌ [updateProjectMembers] Error updating project members:", error); return { success: false, error: error instanceof Error ? error.message : "Unknown error occurred", @@ -803,10 +818,27 @@ export const updateProjectMembers = async ( } }; +/** + * ─── PATCH: Fix RLS filtering issue ────────────────────────────────────────── + * Use two-query approach to avoid PostgREST join RLS filtering that silently + * drops users. Same pattern as getProjectByID() and getMembers(). + * + * IMPORTANT: Uses anon client (no auth) to bypass RLS policies that may filter + * role_id field. This matches the landing page behavior where all mentors are visible. + * + * Updated to match Add Members Modal filtering logic - includes users based on + * both role_id AND internal_status to ensure GRADUATED users are included. + * ───────────────────────────────────────────────────────────────────────────── + */ export const getProjectCodevs = async (filters = {}): Promise => { - const supabase = await createClientServerComponent(); + // Use anon client to avoid RLS filtering of role_id field (same as landing page) + const { createClient } = await import("@supabase/supabase-js"); + const supabase = createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! + ); - let query = supabase.from("codev").select(` + const selectFields = ` id, first_name, last_name, @@ -816,54 +848,99 @@ export const getProjectCodevs = async (filters = {}): Promise => { tech_stacks, display_position, internal_status, - project_members!codev_id ( - project:project_id ( - id, - name - ), - role, - joined_at - ) - `); + role_id + `; + + // Step 1: Fetch users using same logic as Add Members Modal "smart filter" + // Smart filter: role_id = 5 (Mentor) OR role_id = 1 (Admin) OR internal_status = GRADUATED + const queries = [ + supabase.from("codev").select(selectFields).eq("role_id", 5), + supabase.from("codev").select(selectFields).eq("role_id", 1), + supabase.from("codev").select(selectFields).eq("internal_status", "GRADUATED"), + supabase.from("codev").select(selectFields).in("internal_status", ["TRAINING", "INTERN", "ONBOARDING"]), + ]; + + const results = await Promise.all(queries); - Object.entries(filters).forEach(([key, value]) => { - if (value !== undefined) { - query = query.eq(key, value); + // Combine all results and deduplicate by id + const codevMap = new Map(); + + results.forEach(({ data, error }, index) => { + if (error) { + console.error(`Error fetching codevs (query ${index}):`, error); + } else if (data) { + data.forEach(codev => { + if (!codevMap.has(codev.id)) { + codevMap.set(codev.id, codev); + } + }); } }); - const { data, error } = await query; + let codevs = Array.from(codevMap.values()); - if (error) { - console.error("Error fetching Codevs:", error); - throw new Error("Failed to fetch Codevs"); + // Apply additional filters if provided + if (Object.keys(filters).length > 0) { + codevs = codevs.filter(codev => { + return Object.entries(filters).every(([key, value]) => { + if (value === undefined) return true; + return codev[key] === value; + }); + }); } - return ( - data?.map((codev: any) => { - const projects = (codev.project_members || []).map( - (pm: DbProjectMember) => { - const project: Project & { role: string; joined_at: string } = { - id: pm.project.id, - name: pm.project.name, - role: pm.role, - joined_at: pm.joined_at, - status: pm.project.status, - kanban_display: pm.project.kanban_display, - public_display: pm.project.public_display, - }; - return project; - }, - ); + if (codevs.length === 0) { + return []; + } - return { - ...codev, - positions: codev.positions || [], - tech_stacks: codev.tech_stacks || [], - projects, - } as Codev; - }) || [] - ); + // Step 2: Fetch project_members separately for all codevs + const codevIds = codevs.map(c => c.id); + + const { data: projectMembers, error: pmError } = await supabase + .from("project_members") + .select(` + codev_id, + project_id, + role, + joined_at, + project:project_id ( + id, + name, + status, + kanban_display, + public_display + ) + `) + .in("codev_id", codevIds); + + if (pmError) { + console.error("Error fetching project members:", pmError); + } + + // Step 3: Merge project_members data back into codevs + return codevs.map((codev: any) => { + const codevProjectMembers = projectMembers?.filter(pm => pm.codev_id === codev.id) || []; + + const projects = codevProjectMembers.map((pm: any) => { + const project: Project & { role: string; joined_at: string } = { + id: pm.project.id, + name: pm.project.name, + role: pm.role, + joined_at: pm.joined_at, + status: pm.project.status, + kanban_display: pm.project.kanban_display, + public_display: pm.project.public_display, + }; + return project; + }); + + return { + ...codev, + positions: codev.positions || [], + tech_stacks: codev.tech_stacks || [], + projects, + } as Codev; + }); }; export const getProjectClients = async (): Promise => { From 5d7bc0a7931251079d28b153b8c8f7d5210bc0e3 Mon Sep 17 00:00:00 2001 From: raineer Date: Fri, 15 May 2026 22:21:38 +0800 Subject: [PATCH 3/3] merge conflict fixed --- .../projects/_components/ProjectEditModal.tsx | 2150 +++++++++-------- apps/codebility/app/home/projects/actions.ts | 88 +- 2 files changed, 1078 insertions(+), 1160 deletions(-) diff --git a/apps/codebility/app/home/projects/_components/ProjectEditModal.tsx b/apps/codebility/app/home/projects/_components/ProjectEditModal.tsx index 22acc8c45..eb549ad2d 100644 --- a/apps/codebility/app/home/projects/_components/ProjectEditModal.tsx +++ b/apps/codebility/app/home/projects/_components/ProjectEditModal.tsx @@ -1,1127 +1,1129 @@ -"use client"; - -import { ChangeEvent, useEffect, useMemo, useRef, useState } from "react"; -import dynamic from "next/dynamic"; -import Image from "next/image"; -import { - getProjectByID, - getProjectCategories, - getProjectClients, - getProjectCodevs, - updateProject, -} from "@/app/home/projects/actions"; -import ProjectAvatar from "@/components/ProjectAvatar"; -import { CustomSelect } from "@/components/ui/CustomSelect"; -import { - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { MemberSelection } from "@/components/ui/MemberSelection"; -import { SelectMemberModal } from "@/components/ui/SelectMemberModal"; -import { Skeleton } from "@/components/ui/skeleton/skeleton"; -import { useModal as useGlobalModal } from "@/hooks/use-modal"; -import { useModal } from "@/hooks/use-modal-projects"; -import { useTechStackStore } from "@/hooks/use-techstack"; -import { Client, Codev, Project, SkillCategory } from "@/types/home/codev"; -import { uploadImage } from "@/utils/uploadImage"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { useForm } from "react-hook-form"; -import toast from "react-hot-toast"; - -import { Button } from "@codevs/ui/button"; -import { Input } from "@codevs/ui/input"; - -const ImageCrop = dynamic(() => import("./ImageCrop"), { - ssr: false, - loading: () => , -}); - -const PROJECT_STATUSES = [ - { id: "pending", value: "pending", label: "Pending" }, - { id: "inprogress", value: "inprogress", label: "In Progress" }, - { id: "completed", value: "completed", label: "Completed" }, -]; - -export interface ProjectFormData { - name: string; - description?: string; - tagline?: string; - key_features?: string; - gallery?: string; - github_link?: string; - website_url?: string; - figma_link?: string; - start_date: string; - category_ids?: number[]; - client_id?: string; - status?: string; - main_image?: string; - tech_stack?: string[]; +"use server"; + +import { revalidatePath } from "next/cache"; +import { Client, Codev, Project } from "@/types/home/codev"; +import { deleteImage, getImagePath } from "@/utils/uploadImage"; +import { createClientServerComponent } from "@/utils/supabase/server"; +import { invalidateCache } from "@/lib/server/redis-cache"; +import { cacheKeys } from "@/lib/server/redis-cache-keys"; + +interface DbProjectMember { + project: { + id: string; + name: string; + status: string; + kanban_display: boolean; + public_display: boolean + }; + role: string; + joined_at: string; } -const ProjectEditModal = () => { - const { isOpen, onClose, type, data } = useModal(); - const { onOpen: openGlobalModal } = useGlobalModal(); - const { - stack: selectedTechStack, - clearStack, - setStack, - } = useTechStackStore(); - const isModalOpen = isOpen && type === "projectEditModal"; - const queryClient = useQueryClient(); - - // Data states - const [selectedCategoryIds, setSelectedCategoryIds] = useState([]); - const [currentTeamLeader, setCurrentTeamLeader] = useState(null); - // ── CBP-116: sublead state ──────────────────────────────────────────────── - const [currentSubLead, setCurrentSubLead] = useState(null); - // ───────────────────────────────────────────────────────────────────────── - const [selectedMembers, setSelectedMembers] = useState([]); - - // Modal states for member selection - const [teamLeaderModalOpen, setTeamLeaderModalOpen] = useState(false); - const [subLeadModalOpen, setSubLeadModalOpen] = useState(false); - - // Image states - const [projectImage, setProjectImage] = useState(null); - const [openImageCropper, setOpenImageCropper] = useState(false); - const [croppedImage, setCroppedImage] = useState(null); - const [croppedFile, setCroppedFile] = useState(null); - const [imageLoaded, setImageLoaded] = useState(false); - - // Gallery states - const [galleryImages, setGalleryImages] = useState<{ url: string; file: File }[]>([]); - - // Loading states - const [isLoading, setIsLoading] = useState(false); - - const [selectedStatus, setSelectedStatus] = useState(data?.status || "pending"); - - const { - register, - handleSubmit, - setValue, - reset, - formState: { errors }, - } = useForm({ mode: "onChange" }); - - const { data: users = [], isLoading: isUsersLoading } = useQuery({ -<<<<<<< HEAD - queryKey: ["projectCodevs", "v3"], -======= - queryKey: ["projectCodevs", "v3"], // v2: Added role_id field + anon client ->>>>>>> 8a73b995cf8189a2945ead225875c9a3e76f8ee8 - queryFn: async () => { - const result = await getProjectCodevs(); - return result || []; - }, -<<<<<<< HEAD - staleTime: 2 * 60 * 1000, - refetchOnWindowFocus: true, -======= - staleTime: 2 * 60 * 1000, // Reduce to 2 minutes for fresher data - refetchOnWindowFocus: true, // Refetch when window focused to get latest users ->>>>>>> 8a73b995cf8189a2945ead225875c9a3e76f8ee8 - }); +interface ProjectMemberData { + codev_id: string; + role: string; +} - const { data: clients = [], isLoading: isClientsLoading } = useQuery({ - queryKey: ["projectClients"], - queryFn: async () => { - const result = await getProjectClients(); - return result || []; - }, - staleTime: 10 * 60 * 1000, - refetchOnWindowFocus: false, - }); +export interface SimpleMemberData { + id: string; + first_name: string; + last_name: string; + email_address: string; + display_position: string | null; + image_url: string | null; + role: string; + joined_at: string; +} - const { data: categories = [], isLoading: isCategoriesLoading } = useQuery({ - queryKey: ["projectCategories"], - queryFn: async () => { - const result = await getProjectCategories(); - return result || []; - }, - staleTime: 10 * 60 * 1000, - refetchOnWindowFocus: false, - }); +interface ProjectMemberResponse { + role: string; + joined_at: string; + codev: { + id: string; + first_name: string; + last_name: string; + email_address: string; + display_position: string | null; + image_url: string | null; + }; +} - // Fetch full project data (with project_members) when modal opens. - // The projects list query does NOT join project_members, so we need this - // separate fetch to pre-populate team leader, sublead, and members. - const { data: fullProjectData, isLoading: isFullProjectLoading } = useQuery({ - queryKey: ["projectFull", data?.id], - queryFn: async () => { - if (!data?.id) return null; - const result = await getProjectByID(data.id); - return result; - }, - enabled: !!data?.id && isModalOpen, - staleTime: 0, - refetchOnWindowFocus: false, - }); +interface DbProjectMemberResponse { + role: string; + joined_at: string; + codev: { + id: string; + first_name: string; + last_name: string; + email_address: string; + display_position: string | null; + image_url: string | null; + }; +} - const isSelectDataLoading = - isUsersLoading || isClientsLoading || isCategoriesLoading || isFullProjectLoading; - - const userOptions = useMemo( - () => - users.map((user) => ({ - id: user.id, - value: user.id, - label: `${user.first_name} ${user.last_name}`, - subLabel: user.display_position, - imageUrl: user.image_url, - })), - [users], - ); +export async function getUserProjects(): Promise<{ + error: any; + data: { project: Project; role: string }[] | null; +}> { + const supabase = await createClientServerComponent(); + try { + const { + data: { user }, + error: userError, + } = await supabase.auth.getUser(); + if (userError || !user) { + console.error("Error fetching user:", userError); + return { error: { message: "User not authenticated" }, data: null }; + } - const clientOptions = useMemo( - () => - clients.map((client) => ({ - id: client.id, - value: client.id, - label: client.name, - subLabel: client.name, - imageUrl: client.company_logo, - })), - [clients], - ); + const { data: codevData, error: codevError } = await supabase + .from("codev") + .select("id") + .eq("email_address", user.email) + .single(); - // Inject ANY project member absent from getProjectCodevs() into dropdown options. - // getProjectCodevs() may exclude users due to RLS or internal_status filtering. - // We use fullProjectData.project_members (from getProjectByID join) as the - // source of truth — it always returns codev data regardless of RLS. - const enhancedUserOptions = useMemo(() => { - if (!fullProjectData?.project_members) return userOptions; - - const existingIds = new Set(userOptions.map((opt) => opt.value)); - - const missingMembers = fullProjectData.project_members - .filter((pm: any) => pm.codev && !existingIds.has(pm.codev_id)) - .map((pm: any) => ({ - id: pm.codev_id, - value: pm.codev_id, - label: `${pm.codev.first_name} ${pm.codev.last_name}`, - subLabel: pm.codev.display_position || "", - imageUrl: pm.codev.image_url || null, - })); + if (codevError || !codevData) { + console.error("Error fetching codev_id:", codevError); + return { error: { message: "User profile not found" }, data: null }; + } - if (missingMembers.length === 0) return userOptions; - - return [...missingMembers, ...userOptions]; - }, [userOptions, fullProjectData]); - - // Populate basic form fields when modal opens - useEffect(() => { - if (data && isModalOpen) { - setImageLoaded(false); - setValue("name", data.name || ""); - setValue("description", data.description || ""); - setValue("tagline", data.tagline || ""); - setValue( - "key_features", - data.key_features && Array.isArray(data.key_features) - ? data.key_features.join(", ") - : "", - ); - setValue("github_link", data.github_link || ""); - setValue("website_url", data.website_url || ""); - setValue("figma_link", data.figma_link || ""); - setValue("client_id", data.client_id || ""); - - if (data.categories && Array.isArray(data.categories)) { - setSelectedCategoryIds(data.categories.map((cat: any) => cat.id)); - } else { - setSelectedCategoryIds([]); + const userCodevId = codevData.id; + + interface DbProject { + id: string; + name: string; + status: string | null; + kanban_display: boolean | null; + meeting_link: string | null; + public_display: boolean | null; + } + + interface ProjectMember { + project_id: string; + role: string; + project: DbProject; + } + + const { data: projectMembers, error: projectMembersError } = await supabase + .from("project_members") + .select( + ` + project_id, + role, + project:project_id ( + id, + name, + status, + kanban_display, + public_display, + meeting_link + ) + `, + ) + .eq("codev_id", userCodevId) + .in("role", ["team_leader", "member", "sublead"]) as { data: ProjectMember[] | null; error: any }; + + if (projectMembersError) { + console.error("Error fetching user projects:", projectMembersError); + return { error: { message: "Failed to fetch user projects" }, data: null }; + } + + if (!projectMembers || projectMembers.length === 0) { + return { error: null, data: null }; + } + + const userProjects = projectMembers.map((pm) => ({ + project: { + id: pm.project.id, + name: pm.project.name, + status: pm.project.status || "pending", + kanban_display: pm.project.kanban_display ?? false, + public_display: pm.project.public_display ?? false, + meeting_link: pm.project.meeting_link ?? null, + } as Project, + role: pm.role, + })); + + return { error: null, data: userProjects }; + } catch (error) { + console.error("Unexpected error fetching user projects:", error); + return { error: { message: "Unexpected error occurred" }, data: null }; + } +} + +export async function createProject( + formData: FormData, + selectedMembers: Codev[], + teamLeaderId: string, +) { + const supabase = await createClientServerComponent(); + + try { + const techStackData = formData.get("tech_stack"); + let techStack: string[] | null = null; + if (techStackData) { + try { + techStack = JSON.parse(techStackData as string) as string[]; + } catch (error) { + console.warn("Invalid tech stack data:", error); } + } - setValue("status", data.status || "pending"); - if (data.start_date) { - setValue("start_date", data.start_date); + const categoryIdsData = formData.get("category_ids"); + let categoryIds: number[] = []; + if (categoryIdsData) { + try { + categoryIds = JSON.parse(categoryIdsData as string) as number[]; + } catch (error) { + console.warn("Invalid category IDs data:", error); } - setProjectImage(data.main_image || null); - setCroppedImage(data.main_image || null); - setSelectedStatus(data.status || "pending"); - - if (data.tech_stack && Array.isArray(data.tech_stack)) { - setStack(data.tech_stack); - } else { - clearStack(); + } + + const keyFeaturesData = formData.get("key_features"); + let keyFeatures: string[] | null = null; + if (keyFeaturesData) { + try { + keyFeatures = JSON.parse(keyFeaturesData as string) as string[]; + } catch (error) { + console.warn("Invalid key_features data:", error); } } - }, [data, setValue, isModalOpen, setStack, clearStack]); - - // Pre-populate team leader, sublead, and members from fullProjectData. - // fullProjectData comes from getProjectByID which includes project_members. - // The list page query does not join project_members so we cannot use data directly. - // - // IMPORTANT: Only run when modal opens (isModalOpen changes to true) to avoid - // resetting selectedMembers when users list refetches during editing. - const hasInitialized = useRef(false); - - useEffect(() => { - if (!fullProjectData || !isModalOpen) { - hasInitialized.current = false; - return; + + const galleryData = formData.get("gallery"); + let gallery: string[] | null = null; + if (galleryData) { + try { + gallery = JSON.parse(galleryData as string) as string[]; + } catch (error) { + console.warn("Invalid gallery data:", error); + } } - // Skip if already initialized and users are available - if (hasInitialized.current && users.length > 0) return; + const { data: project, error: projectError } = await supabase + .from("projects") + .insert({ + name: formData.get("name"), + description: formData.get("description"), + tagline: formData.get("tagline"), + key_features: keyFeatures, + gallery: gallery, + github_link: formData.get("github_link"), + website_url: formData.get("website_url"), + figma_link: formData.get("figma_link"), + client_id: formData.get("client_id"), + start_date: formData.get("start_date"), + main_image: formData.get("main_image"), + tech_stack: techStack, + status: "pending", + }) + .select() + .single(); - const projectMembers = fullProjectData.project_members; - if (!projectMembers?.length) return; + if (projectError) throw projectError; - // ── Team leader ────────────────────────────────────────────────────────── - const teamLeaderMember = projectMembers.find( - (pm: any) => pm.role === "team_leader", - ); + if (categoryIds.length > 0) { + const categoryInserts = categoryIds.map((categoryId) => ({ + project_id: project.id, + category_id: categoryId, + })); - if (teamLeaderMember) { - const leaderFromUsers = users.find( - (user) => user.id === teamLeaderMember.codev_id, - ); + const { error: categoryError } = await supabase + .from("project_categories") + .insert(categoryInserts); - if (leaderFromUsers) { - setCurrentTeamLeader(leaderFromUsers); - } else if (teamLeaderMember.codev) { - // Fallback: build minimal Codev from embedded data when the leader - // is absent from getProjectCodevs() (RLS / internal_status filtered). - setCurrentTeamLeader({ - id: teamLeaderMember.codev_id, - first_name: teamLeaderMember.codev.first_name, - last_name: teamLeaderMember.codev.last_name, - image_url: teamLeaderMember.codev.image_url || null, - display_position: teamLeaderMember.codev.display_position || null, - email_address: teamLeaderMember.codev.email_address || "", - positions: [], - tech_stacks: [], - } as unknown as Codev); + if (categoryError) { + console.error("Error inserting project categories:", categoryError); } } - // ── CBP-116: Sublead pre-population ─────────────────────────────────────── - const subLeadMember = projectMembers.find( - (pm: any) => pm.role === "sublead", - ); + const memberInserts = [ + { + project_id: project.id, + codev_id: teamLeaderId, + role: "team_leader", + joined_at: new Date().toISOString(), + }, + ...selectedMembers + .filter((member) => member.id !== teamLeaderId) + .map((member) => ({ + project_id: project.id, + codev_id: member.id, + role: "member", + joined_at: new Date().toISOString(), + })), + ]; + + const { error: membersError } = await supabase + .from("project_members") + .insert(memberInserts); + + if (membersError) throw membersError; + + const { error: boardError } = await supabase.from("kanban_boards").insert({ + name: `${project.name} Board`, + project_id: project.id, + description: `Default board for ${project.name}`, + }); + + if (boardError) throw boardError; + + await invalidateCache(cacheKeys.projects.all); + + revalidatePath("/projects"); + revalidatePath("/services"); + return { success: true, data: project }; + } catch (error) { + console.error("Error creating project:", error); + return { + success: false, + error: + error instanceof Error ? error.message : "Failed to create project", + }; + } +} - if (subLeadMember) { - const subLeadFromUsers = users.find( - (user) => user.id === subLeadMember.codev_id, - ); +export async function updateStatus( + status: string, + projectId: string, +) { + const supabase = await createClientServerComponent(); + const { error: projectError } = await supabase + .from("projects") + .update({ status: status }) + .eq("id", projectId) + .select(); + + if (projectError) + console.error("Error in updating projects and kanban board:", projectError); + + await invalidateCache(cacheKeys.projects.all); + + revalidatePath("/home/projects"); + revalidatePath("/services"); - if (subLeadFromUsers) { - setCurrentSubLead(subLeadFromUsers); - } else if (subLeadMember.codev) { - setCurrentSubLead({ - id: subLeadMember.codev_id, - first_name: subLeadMember.codev.first_name, - last_name: subLeadMember.codev.last_name, - image_url: subLeadMember.codev.image_url || null, - display_position: subLeadMember.codev.display_position || null, - email_address: subLeadMember.codev.email_address || "", - positions: [], - tech_stacks: [], - } as unknown as Codev); + return { success: true, projectId, status }; +} + +export async function updateKanbanDisplaySwitch( + kanbanDisplay: boolean, + projectId: string, +) { + const supabase = await createClientServerComponent(); + const { error: projectError } = await supabase + .from("projects") + .update({ kanban_display: kanbanDisplay }) + .eq("id", projectId) + .select(); + + if (projectError) + console.error("Error in updating projects and kanban board:", projectError); + + return { success: true, projectId, kanbanDisplay }; +} + +export async function updatePublicDisplaySwitch( + publicDisplay: boolean, + projectId: string, +) { + const supabase = await createClientServerComponent(); + const { error: projectError } = await supabase + .from("projects") + .update({ public_display: publicDisplay }) + .eq("id", projectId) + .select(); + + if (projectError) + console.error("Error in updating projects and kanban board:", projectError); + + await invalidateCache(cacheKeys.projects.all); + + revalidatePath("/home/projects"); + revalidatePath("/services"); + + return { success: true, projectId, publicDisplay }; +} + +export async function updateProject(projectId: string, formData: FormData) { + const supabase = await createClientServerComponent(); + + try { + const projectMembersData = formData.get("project_members"); + const projectMembers = projectMembersData + ? (JSON.parse(projectMembersData as string) as ProjectMemberData[]) + : null; + + const techStackData = formData.get("tech_stack"); + let techStack: string[] | null = null; + if (techStackData) { + try { + techStack = JSON.parse(techStackData as string) as string[]; + } catch (error) { + console.warn("Invalid tech stack data:", error); } - } else { - setCurrentSubLead(null); } - // ───────────────────────────────────────────────────────────────────────── - - // ── Regular members ────────────────────────────────────────────────────── - // FIX (project-members-update-conflict): Previously used - // users.filter(u => memberIds.includes(u.id)) - // which silently excluded RLS-filtered members from selectedMembers. - // On submit, those missing members were deleted from project_members DB. - // Fix: use pm.codev fallback so every member in DB is in the form payload. - const memberPMs = projectMembers.filter((pm: any) => pm.role === "member"); - -<<<<<<< HEAD - const resolvedMembers = memberPMs - .map((pm: any) => { - const fromUsers = users.find((u) => u.id === pm.codev_id); - if (fromUsers) return fromUsers; - - if (pm.codev) { - return { - id: pm.codev_id, - first_name: pm.codev.first_name, - last_name: pm.codev.last_name, - image_url: pm.codev.image_url || null, - display_position: pm.codev.display_position || null, - email_address: pm.codev.email_address || "", - positions: [], - tech_stacks: [], - } as unknown as Codev; - } - console.warn(`⚠️ Could not resolve member codev data for: ${pm.codev_id}`); - return null; + const categoryIdsData = formData.get("category_ids"); + let categoryIds: number[] | null = null; + if (categoryIdsData) { + try { + categoryIds = JSON.parse(categoryIdsData as string) as number[]; + } catch (error) { + console.warn("Invalid category IDs data:", error); + } + } + + const keyFeaturesData = formData.get("key_features"); + let keyFeatures: string[] | null = null; + if (keyFeaturesData) { + try { + keyFeatures = JSON.parse(keyFeaturesData as string) as string[]; + } catch (error) { + console.warn("Invalid key_features data:", error); + } + } + + const galleryData = formData.get("gallery"); + let gallery: string[] | null = null; + if (galleryData) { + try { + gallery = JSON.parse(galleryData as string) as string[]; + } catch (error) { + console.warn("Invalid gallery data:", error); + } + } + + const updateData: any = {}; + for (const [key, value] of formData.entries()) { + if ( + key !== "project_members" && + key !== "tech_stack" && + key !== "category_ids" && + key !== "key_features" && + key !== "gallery" + ) { + updateData[key] = value; + } + } + + if (techStack) updateData.tech_stack = techStack; + if (keyFeatures) updateData.key_features = keyFeatures; + if (gallery) updateData.gallery = gallery; + + const { data: projectData, error: projectError } = await supabase + .from("projects") + .update({ + ...updateData, + updated_at: new Date().toISOString(), }) - .filter(Boolean) as Codev[]; - - setSelectedMembers(resolvedMembers); - - // Mark as initialized to prevent resets when users list refetches - hasInitialized.current = true; - // ───────────────────────────────────────────────────────────────────────── -======= - setSelectedMembers(users.filter((user) => memberIds.includes(user.id))); - - // Mark as initialized to prevent resets when users list refetches - hasInitialized.current = true; ->>>>>>> 8a73b995cf8189a2945ead225875c9a3e76f8ee8 - }, [fullProjectData, users, isModalOpen]); - - const handleImageChange = async (e: ChangeEvent) => { - try { - setImageLoaded(false); - const file = e.target.files?.[0]; - if (!file) return; - const objectUrl = URL.createObjectURL(file); - setProjectImage(objectUrl); - setCroppedImage(objectUrl); - setCroppedFile(file); - setOpenImageCropper(true); - } catch (error) { - toast.error("Failed to upload image"); + .eq("id", projectId) + .select(); + + if (projectError) { + console.error("Supabase error updating project:", projectError); + throw projectError; } - }; - const handleRemoveImage = () => { - setProjectImage(null); - setValue("main_image", undefined); - setCroppedImage(null); - setCroppedFile(null); - setImageLoaded(false); - }; + if (projectMembers && Array.isArray(projectMembers)) { + const { data: existingMembers, error: membersError } = await supabase + .from("project_members") + .select("codev_id, joined_at") + .eq("project_id", projectId); - const resetForm = () => { - reset(); - setProjectImage(null); - setCroppedImage(null); - setCroppedFile(null); - setGalleryImages([]); - setSelectedMembers([]); - setCurrentTeamLeader(null); - setCurrentSubLead(null); // ── CBP-116 - setImageLoaded(false); - clearStack(); - onClose(); - }; + if (membersError) throw membersError; - // Subcomponent: ImageUploadSection - const ImageUploadSection = () => ( -
-
- {croppedImage ? ( -
- {!imageLoaded && ( -
- -
- )} - Project Preview setOpenImageCropper(true)} - onLoad={() => setImageLoaded(true)} - /> -
- Click to edit -
-
- ) : ( -
- -

No image selected

-
- )} -
- -
- - - {projectImage && ( - - )} -
- - -
- ); + const joinedAtMap = new Map( + existingMembers?.map(m => [m.codev_id, m.joined_at]) ?? [] + ); - // Subcomponent: ProjectDetailsSection - const ProjectDetailsSection = () => ( -
-
-
- - - {errors.name && ( -

- ⚠ - {errors.name.message} -

- )} -
- -
- - -
- -
- -