diff --git a/app/api/auth/[...nextauth]/auth.config.ts b/app/api/auth/[...nextauth]/auth.config.ts index 5266daef5..37d90d869 100644 --- a/app/api/auth/[...nextauth]/auth.config.ts +++ b/app/api/auth/[...nextauth]/auth.config.ts @@ -133,7 +133,7 @@ export const authOptions: NextAuthOptions = { ...session, authToken: token.authToken, isLoggedIn: true, - userId: token.sub, + userId: token.sub?.toString(), }; } catch (error) { console.error('Session callback failed:', error); diff --git a/app/notebook/components/PublishingForm/components/AuthorsSection.tsx b/app/notebook/components/PublishingForm/components/AuthorsSection.tsx index b23bae6bc..6d9defe22 100644 --- a/app/notebook/components/PublishingForm/components/AuthorsSection.tsx +++ b/app/notebook/components/PublishingForm/components/AuthorsSection.tsx @@ -1,14 +1,27 @@ -import { Users } from 'lucide-react'; +import { Users, X } from 'lucide-react'; import { SectionHeader } from './SectionHeader'; -import { - SearchableMultiSelect, - MultiSelectOption, -} from '@/components/ui/form/SearchableMultiSelect'; -import { useEffect, useState } from 'react'; +import { AutocompleteSelect, SelectOption } from '@/components/ui/form/AutocompleteSelect'; import { useFormContext } from 'react-hook-form'; import { useNotebookContext } from '@/contexts/NotebookContext'; import { getFieldErrorMessage } from '@/utils/form'; import { useUser } from '@/contexts/UserContext'; +import { Button } from '@/components/ui/Button'; +import { OrganizationMember } from '@/types/organization'; +import { useInviteUserToOrg } from '@/hooks/useOrganization'; +import { useOrganizationContext } from '@/contexts/OrganizationContext'; +import { useSession } from 'next-auth/react'; +import { isValidEmail } from '@/utils/validation'; +import { toast } from 'react-hot-toast'; +import { AuthorInfo } from '@/components/ui/AuthorInfo'; + +// Create a type for organization member as author suggestion +interface OrgMemberSuggestion { + id: string; + fullName: string; + profileImage?: string; + email: string; + role: string; +} export function AuthorsSection() { const { @@ -17,49 +30,212 @@ export function AuthorsSection() { formState: { errors }, } = useFormContext(); - const { users, isLoadingUsers, currentNote } = useNotebookContext(); + const { isLoadingUsers, users: orgUsers, refreshUsers } = useNotebookContext(); const { user: currentUser } = useUser(); - const [authorOptions, setAuthorOptions] = useState([]); + const { selectedOrg: organization } = useOrganizationContext(); + const { data: session } = useSession(); + const [{ isLoading: isInvitingUser }, inviteUserToOrg] = useInviteUserToOrg(); const authors = watch('authors') || []; - useEffect(() => { - if (users?.users) { - const options = users.users - .filter((user) => user.name || user.email) - .map((user) => ({ - value: user.authorId.toString(), - label: user.name || user.email || 'Unknown User', - })); - - setAuthorOptions(options); - - // Auto-add admin if no authors are selected yet - if (authors.length === 0) { - const admin = users.users.find((user) => user.id === currentUser?.id.toString()); - - if (admin) { - const adminOption = { - value: admin.authorId.toString(), - label: admin.name || admin.email || 'Unknown User', - }; - setValue('authors', [adminOption], { shouldValidate: true }); - } + // Check if current user is admin + const isCurrentUserAdmin = (() => { + if (!session?.userId || !orgUsers?.users) return false; + const currentUser = orgUsers.users.find((user) => user.id === session.userId.toString()); + return currentUser?.role === 'ADMIN'; + })(); + + // Transform organization users to author suggestions + const orgMemberAuthors: OrgMemberSuggestion[] = + orgUsers?.users?.map((user: OrganizationMember) => ({ + id: user.authorId.toString(), + fullName: user.name, + profileImage: user.avatarUrl, + email: user.email, + role: user.role, + })) || []; + + // Simple filtering function for organization users + const handleSearchAuthors = async ( + query: string + ): Promise[]> => { + if (!query.trim()) { + // Return all org users when no query + return orgMemberAuthors.map((author) => ({ + value: author.id, + label: author.fullName, + data: author, + })); + } + + // Simple case-insensitive filtering by name or email + const filteredAuthors = orgMemberAuthors.filter( + (author) => + author.fullName.toLowerCase().includes(query.toLowerCase()) || + author.email.toLowerCase().includes(query.toLowerCase()) + ); + + return filteredAuthors.map((author) => ({ + value: author.id, + label: author.fullName, + data: author, + })); + }; + + // Handle author selection + const handleAuthorSelect = (selectedOption: SelectOption | null) => { + if (selectedOption) { + // Check if author is already selected + const isAlreadySelected = authors.some( + (author: any) => author.value === selectedOption.value + ); + + if (!isAlreadySelected) { + const newAuthors = [ + ...authors, + { ...selectedOption, image: selectedOption.data?.profileImage }, + ]; + setValue('authors', newAuthors, { shouldValidate: true }); } } - }, [users, setValue, currentNote]); + }; + + // Handle author removal + const handleRemoveAuthor = (authorToRemove: SelectOption) => { + const newAuthors = authors.filter((author: any) => author.value !== authorToRemove.value); + setValue('authors', newAuthors, { shouldValidate: true }); + }; + + // Handle inviting a new user + const handleInviteAuthor = async ( + query: string + ): Promise | null> => { + if (!isValidEmail(query)) { + toast.error('Please enter a valid email address'); + return null; + } + + if (!organization) { + toast.error('Organization not found'); + return null; + } + + if (!isCurrentUserAdmin) { + toast.error('Only admins can invite users'); + return null; + } + + // Check if user is already a member + const isAlreadyMember = orgMemberAuthors.some( + (author) => author.email.toLowerCase() === query.toLowerCase() + ); + if (isAlreadyMember) { + toast.error('This user is already a member of the organization'); + return null; + } + + try { + await inviteUserToOrg(organization.id, query); + toast.success(`Invitation sent to ${query}`); + + // Refresh the organization users list to show the new invite + await refreshUsers(true); + + // Return null since the invited user won't be immediately available for selection + // They'll need to accept the invitation first + return null; + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to invite user'); + return null; + } + }; + + // Render selected author option + const renderAuthorOption = ( + option: SelectOption, + { focus, selected }: { selected: boolean; focus: boolean } + ) => { + const authorData = option.data; + + return ( +
  • + +
  • + ); + }; return (
    Authors - setValue('authors', newAuthors, { shouldValidate: true })} - options={authorOptions} - placeholder={isLoadingUsers ? 'Loading authors...' : 'Search for authors...'} - disabled={isLoadingUsers} - error={getFieldErrorMessage(errors.authors, 'Invalid authors')} - /> + + {/* Author Search */} +
    + + value={null} + onChange={handleAuthorSelect} + onSearch={handleSearchAuthors} + placeholder={isLoadingUsers ? 'Loading authors...' : 'Search for authors...'} + disabled={isLoadingUsers} + error={getFieldErrorMessage(errors.authors, 'Invalid authors')} + debounceMs={300} + minSearchLength={1} + renderOption={renderAuthorOption} + allowCreatingNew={isCurrentUserAdmin} + onCreateNew={handleInviteAuthor} + createNewLabel="Invite: " + /> +
    + + {/* Selected Authors Display */} + {authors.length > 0 && ( +
    +

    Selected Authors:

    +
    + {authors.map((author: any) => { + const isCurrentUser = + author.value.toString() === currentUser?.authorProfile?.id?.toString(); + const orgMember = orgMemberAuthors.find( + (orgMember) => orgMember.id === author.value.toString() + ); + + return ( +
    + + +
    + ); + })} +
    +
    + )}
    ); } diff --git a/app/notebook/components/PublishingForm/components/ContactsSection.tsx b/app/notebook/components/PublishingForm/components/ContactsSection.tsx index 37161448e..fe5310006 100644 --- a/app/notebook/components/PublishingForm/components/ContactsSection.tsx +++ b/app/notebook/components/PublishingForm/components/ContactsSection.tsx @@ -1,15 +1,27 @@ -import { Users } from 'lucide-react'; +import { Users, X } from 'lucide-react'; import { SectionHeader } from './SectionHeader'; -import { - SearchableMultiSelect, - MultiSelectOption, -} from '@/components/ui/form/SearchableMultiSelect'; -import { useCallback } from 'react'; +import { AutocompleteSelect, SelectOption } from '@/components/ui/form/AutocompleteSelect'; import { useFormContext } from 'react-hook-form'; import { useNotebookContext } from '@/contexts/NotebookContext'; import { getFieldErrorMessage } from '@/utils/form'; -import { SearchService } from '@/services/search.service'; -import { AuthorSuggestion } from '@/types/search'; +import { useUser } from '@/contexts/UserContext'; +import { Button } from '@/components/ui/Button'; +import { OrganizationMember } from '@/types/organization'; +import { useInviteUserToOrg } from '@/hooks/useOrganization'; +import { useOrganizationContext } from '@/contexts/OrganizationContext'; +import { useSession } from 'next-auth/react'; +import { isValidEmail } from '@/utils/validation'; +import { toast } from 'react-hot-toast'; +import { AuthorInfo } from '@/components/ui/AuthorInfo'; + +// Create a type for organization member as contact suggestion +interface OrgMemberSuggestion { + id: string; + fullName: string; + profileImage?: string; + email: string; + role: string; +} export function ContactsSection() { const { @@ -18,38 +30,217 @@ export function ContactsSection() { formState: { errors }, } = useFormContext(); + const { isLoadingUsers, users: orgUsers, refreshUsers } = useNotebookContext(); + const { user: currentUser } = useUser(); + const { selectedOrg: organization } = useOrganizationContext(); + const { data: session } = useSession(); + const [{ isLoading: isInvitingUser }, inviteUserToOrg] = useInviteUserToOrg(); + const contacts = watch('contacts') || []; - // Helper function for async people search - const handleAsyncSearch = useCallback(async (query: string): Promise => { + // Check if current user is admin + const isCurrentUserAdmin = (() => { + if (!session?.userId || !orgUsers?.users) return false; + const currentUser = orgUsers.users.find((user) => user.id === session.userId.toString()); + return currentUser?.role === 'ADMIN'; + })(); + + // Transform organization users to contact suggestions + const orgMemberContacts: OrgMemberSuggestion[] = + orgUsers?.users?.map((user: OrganizationMember) => ({ + id: user.id.toString(), + fullName: user.name, + profileImage: user.avatarUrl, + email: user.email, + role: user.role, + })) || []; + + // Simple filtering function for organization users + const handleSearchContacts = async ( + query: string + ): Promise[]> => { + if (!query.trim()) { + // Return all org users when no query + return orgMemberContacts.map((contact) => ({ + value: contact.id, + label: contact.fullName, + data: contact, + })); + } + + // Simple case-insensitive filtering by name or email + const filteredContacts = orgMemberContacts.filter( + (contact) => + contact.fullName.toLowerCase().includes(query.toLowerCase()) || + contact.email.toLowerCase().includes(query.toLowerCase()) + ); + + return filteredContacts.map((contact) => ({ + value: contact.id, + label: contact.fullName, + data: contact, + })); + }; + + // Handle contact selection + const handleContactSelect = (selectedOption: SelectOption | null) => { + if (selectedOption) { + // Check if contact is already selected + const isAlreadySelected = contacts.some( + (contact: any) => contact.value === selectedOption.value + ); + + if (!isAlreadySelected) { + const newContacts = [ + ...contacts, + { ...selectedOption, image: selectedOption.data?.profileImage }, + ]; + setValue('contacts', newContacts, { shouldValidate: true }); + } + } + }; + + // Handle contact removal + const handleRemoveContact = (contactToRemove: SelectOption) => { + const newContacts = contacts.filter((contact: any) => contact.value !== contactToRemove.value); + setValue('contacts', newContacts, { shouldValidate: true }); + }; + + // Handle inviting a new user + const handleInviteContact = async ( + query: string + ): Promise | null> => { + if (!isValidEmail(query)) { + toast.error('Please enter a valid email address'); + return null; + } + + if (!organization) { + toast.error('Organization not found'); + return null; + } + + if (!isCurrentUserAdmin) { + toast.error('Only admins can invite users'); + return null; + } + + // Check if user is already a member + const isAlreadyMember = orgMemberContacts.some( + (contact) => contact.email.toLowerCase() === query.toLowerCase() + ); + if (isAlreadyMember) { + toast.error('This user is already a member of the organization'); + return null; + } + try { - const authorSuggestions: AuthorSuggestion[] = await SearchService.suggestPeople(query); - - // Filter out suggestions without userId and map to options - return authorSuggestions - .filter((author) => author.userId) - .map((author) => ({ - value: author.userId?.toString() || `temp-${Date.now()}`, - label: author.fullName || 'Unknown User', - })); + await inviteUserToOrg(organization.id, query); + toast.success(`Invitation sent to ${query}`); + + // Refresh the organization users list to show the new invite + await refreshUsers(true); + + // Return null since the invited user won't be immediately available for selection + // They'll need to accept the invitation first + return null; } catch (error) { - console.error('Error searching people:', error); - return []; + toast.error(error instanceof Error ? error.message : 'Failed to invite user'); + return null; } - }, []); + }; + + // Render selected contact option + const renderContactOption = ( + option: SelectOption, + { focus, selected }: { selected: boolean; focus: boolean } + ) => { + const contactData = option.data; + + return ( +
  • + +
  • + ); + }; return (
    Contacts - setValue('contacts', newContacts, { shouldValidate: true })} - onAsyncSearch={handleAsyncSearch} - placeholder="Search for contacts..." - error={getFieldErrorMessage(errors.contacts, 'Invalid contacts')} - debounceMs={500} - helperText="Add contacts who will be responsible for managing this RFP. These contacts will receive important updates about the RFP's progress." - /> + + {/* Contact Search */} +
    + + value={null} + onChange={handleContactSelect} + onSearch={handleSearchContacts} + placeholder={isLoadingUsers ? 'Loading contacts...' : 'Search for contacts...'} + disabled={isLoadingUsers} + error={getFieldErrorMessage(errors.contacts, 'Invalid contacts')} + debounceMs={300} + minSearchLength={1} + renderOption={renderContactOption} + allowCreatingNew={isCurrentUserAdmin} + onCreateNew={handleInviteContact} + createNewLabel="Invite: " + /> +
    + + {/* Selected Contacts Display */} + {contacts.length > 0 && ( +
    +

    Selected Contacts:

    +
    + {contacts.map((contact: any) => { + const isCurrentUser = contact.data?.id === currentUser?.id?.toString(); + const orgMember = orgMemberContacts.find( + (orgMember) => orgMember.id === contact.value.toString() + ); + + return ( +
    + + +
    + ); + })} +
    +
    + )} + + {/* Helper Text */} +

    + Add contacts who will be responsible for managing this RFP. These contacts will receive + important updates about the RFP's progress. +

    ); } diff --git a/app/notebook/components/PublishingForm/index.tsx b/app/notebook/components/PublishingForm/index.tsx index 0ce3a4c36..52bdd026b 100644 --- a/app/notebook/components/PublishingForm/index.tsx +++ b/app/notebook/components/PublishingForm/index.tsx @@ -33,6 +33,7 @@ import { useNotebookContext } from '@/contexts/NotebookContext'; import { useAssetUpload } from '@/hooks/useAssetUpload'; import { useNonprofitLink } from '@/hooks/useNonprofitLink'; import { NonprofitConfirmModal } from '@/components/Nonprofit'; +import { useUser } from '@/contexts/UserContext'; // Feature flags for conditionally showing sections const FEATURE_FLAG_RESEARCH_COIN = false; @@ -75,7 +76,8 @@ const getButtonText = ({ }; export function PublishingForm({ bountyAmount, onBountyClick }: PublishingFormProps) { - const { currentNote: note, editor } = useNotebookContext(); + const { currentNote: note, editor, users } = useNotebookContext(); + const { user: currentUser } = useUser(); const searchParams = useSearchParams(); const [isRedirecting, setIsRedirecting] = useState(false); const [{ loading: isUploadingImage }, uploadAsset] = useAssetUpload(); @@ -126,6 +128,7 @@ export function PublishingForm({ bountyAmount, onBountyClick }: PublishingFormPr // 1. note.post data // 2. localStorage data // 3. URL search params + // 4. Auto-add current user as author/contact if none exist useEffect(() => { if (!note) return; @@ -172,6 +175,7 @@ export function PublishingForm({ bountyAmount, onBountyClick }: PublishingFormPr const contactOptions = note.post.grant.contacts.map((contact) => ({ value: contact.id.toString(), label: contact.authorProfile?.fullName || contact.name, + image: contact.authorProfile?.profileImage, })); methods.setValue('contacts', contactOptions); } @@ -192,10 +196,15 @@ export function PublishingForm({ bountyAmount, onBountyClick }: PublishingFormPr } if (note.post.authors && note.post.authors.length > 0) { - const authorOptions = note.post.authors.map((author) => ({ - value: author.authorId.toString(), - label: author.name, - })); + const authorOptions = note.post.authors.map((author) => { + const orgUser = users?.users.find((user) => user.authorId === author.authorId); + + return { + value: author.authorId.toString(), + label: author.name, + image: orgUser?.avatarUrl, + }; + }); methods.setValue('authors', authorOptions); } @@ -238,7 +247,46 @@ export function PublishingForm({ bountyAmount, onBountyClick }: PublishingFormPr methods.setValue('articleType', articleType); } } - }, [note, methods, searchParams]); + + // Priority 4: Auto-add current user as author/contact if none exist + // Only do this if we're using localStorage data or if there's no data at all + if ((storedData || (!note?.post && !storedData)) && users?.users && currentUser) { + const currentAuthor = users.users.find((user) => user.id === currentUser.id.toString()); + + if (currentAuthor) { + const userOption = { + value: currentAuthor.authorId.toString(), + label: currentAuthor.name || currentAuthor.email || 'Unknown User', + data: { + id: currentAuthor.authorId, + fullName: currentAuthor.name || currentAuthor.email || 'Unknown User', + profileImage: currentAuthor.avatarUrl, + userId: currentAuthor.id, + institutions: [], + education: [], + reputationHubs: [], + }, + }; + + // Get current article type to determine whether to add as author or contact + const articleType = methods.getValues('articleType'); + + if (articleType === 'grant') { + // For grants: add as contact if no contacts exist + const currentContacts = methods.getValues('contacts'); + if (currentContacts.length === 0) { + methods.setValue('contacts', [userOption], { shouldValidate: true }); + } + } else { + // For non-grants: add as author if no authors exist + const currentAuthors = methods.getValues('authors'); + if (currentAuthors.length === 0) { + methods.setValue('authors', [userOption], { shouldValidate: true }); + } + } + } + } + }, [note, methods, searchParams, users]); // Add effect to save form data when it changes useEffect(() => { diff --git a/app/notebook/components/PublishingForm/schema.ts b/app/notebook/components/PublishingForm/schema.ts index f11986816..07202019e 100644 --- a/app/notebook/components/PublishingForm/schema.ts +++ b/app/notebook/components/PublishingForm/schema.ts @@ -4,6 +4,7 @@ import { NonprofitOrg } from '@/types/nonprofit'; const optionSchema = z.object({ value: z.string(), label: z.string(), + image: z.string().optional(), }); export const publishingFormSchema = z diff --git a/components/modals/OrganizationSettingsModal.tsx b/components/modals/OrganizationSettingsModal.tsx index 37812aef6..e36deae6d 100644 --- a/components/modals/OrganizationSettingsModal.tsx +++ b/components/modals/OrganizationSettingsModal.tsx @@ -60,7 +60,7 @@ const UserRow = ({ }) => { const isCurrentUser = () => { if (!sessionUserId || user.type === 'invite') return false; - return sessionUserId === user.id.toString(); + return sessionUserId.toString() === user.id.toString(); }; return ( @@ -202,7 +202,7 @@ export function OrganizationSettingsModal({ isOpen, onClose }: OrganizationSetti const isCurrentUserAdmin = (() => { if (!session?.userId || !orgUsers?.users) return false; - const currentUser = orgUsers.users.find((user) => user.id.toString() === session.userId); + const currentUser = orgUsers.users.find((user) => user.id === session.userId.toString()); return currentUser?.role === 'ADMIN'; })(); @@ -405,7 +405,7 @@ export function OrganizationSettingsModal({ isOpen, onClose }: OrganizationSetti {/* Organization Name */}
    -
    +
    + ); +} diff --git a/types/note.ts b/types/note.ts index 908188158..805cda38e 100644 --- a/types/note.ts +++ b/types/note.ts @@ -13,6 +13,7 @@ export type Author = { authorId: number; userId: number; name: string; + profileImage?: string; }; export type Contact = { @@ -73,6 +74,7 @@ export const transformAuthor = createTransformer((raw: any) => ({ authorId: raw.id, userId: raw.user, name: `${raw.first_name || ''} ${raw.last_name || ''}`.trim() || 'Unknown', + profileImage: raw.profile_image, })); export const transformContact = createTransformer((raw) => ({ diff --git a/types/organization.ts b/types/organization.ts index 96715c93b..196b88e5c 100644 --- a/types/organization.ts +++ b/types/organization.ts @@ -43,7 +43,7 @@ function transformOrganizationUser(raw: any, role: OrganizationRole): Organizati name: `${raw.author_profile?.first_name} ${raw.author_profile?.last_name}`.trim(), email: raw.email, role, - avatarUrl: raw.author_profile?.profileImage || undefined, + avatarUrl: raw.author_profile?.profile_image || undefined, }; } diff --git a/types/search.ts b/types/search.ts index c8f2db6d4..16a4cd5ac 100644 --- a/types/search.ts +++ b/types/search.ts @@ -283,7 +283,7 @@ export const transformAuthorSuggestion = (raw: any): AuthorSuggestion => { export const transformAuthorSuggestions = (raw: any): AuthorSuggestion[] => { const authorSuggestions: AuthorSuggestion[] = []; - const suggestions = raw.suggestion_phrases__completion; + const suggestions = raw.suggestion_phrases__completion || raw.suggestion_phrases || []; if (Array.isArray(suggestions)) { suggestions.forEach((suggestion: any) => { diff --git a/types/topic.ts b/types/topic.ts index 3c4f2d6a6..98f3e383c 100644 --- a/types/topic.ts +++ b/types/topic.ts @@ -39,7 +39,7 @@ export const transformTopic = createTransformer((raw: any) => { export const transformTopicSuggestions = (raw: any): Topic[] => { const topicSuggestions: Topic[] = []; - const suggestions = raw.name_suggest__completion; + const suggestions = raw.name_suggest__completion || raw.name_suggest || []; suggestions.forEach((suggestion: any) => { suggestion.options.forEach((option: any) => {