From d1d81fe8abcb487a4abfcda314962e84d0f0c650 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Tue, 22 Jul 2025 19:25:32 +0200 Subject: [PATCH 01/16] Add respond button in profiles --- .../ProfileDetails/AddRelationshipModal.tsx | 2 + .../Profile/ProfileDetails/RespondButton.tsx | 137 ++++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 apps/app/src/components/Profile/ProfileDetails/RespondButton.tsx diff --git a/apps/app/src/components/Profile/ProfileDetails/AddRelationshipModal.tsx b/apps/app/src/components/Profile/ProfileDetails/AddRelationshipModal.tsx index 9272d7275..170bc3306 100644 --- a/apps/app/src/components/Profile/ProfileDetails/AddRelationshipModal.tsx +++ b/apps/app/src/components/Profile/ProfileDetails/AddRelationshipModal.tsx @@ -20,6 +20,7 @@ import { OrganizationAvatar } from '@/components/OrganizationAvatar'; import { AddRelationshipForm } from './AddRelationshipForm'; import { RemoveRelationshipModal } from './RemoveRelationshipModal'; +import { RespondButton } from './RespondButton'; const RemoveRelationshipModalContent = ({ relationship, @@ -122,6 +123,7 @@ export const AddRelationshipModalSuspense = ({ return ( <> + {relationships.length > 1 ? ( { + const { user } = useUser(); + const utils = trpc.useUtils(); + + if (!user?.currentOrganization?.id) { + return null; + } + + // Get pending relationships FROM the profile TO our current organization + const [{ organizations: pendingOrgs }] = + trpc.organization.listPendingRelationships.useSuspenseQuery(); + + // Filter to only show requests from the profile we're viewing + const pendingFromProfile = pendingOrgs.find(org => org.id === profile.id); + + if (!pendingFromProfile?.relationships?.some(r => r.pending)) { + return null; + } + + const approve = trpc.organization.approveRelationship.useMutation({ + onSuccess: () => { + utils.organization.invalidate(); + utils.organization.listPendingRelationships.invalidate(); + utils.organization.listDirectedRelationships.invalidate(); + utils.organization.listRelationships.invalidate(); + toast.success({ + message: 'Relationship approved', + }); + }, + onError: () => { + toast.error({ + message: 'Could not approve relationship', + }); + }, + }); + + const decline = trpc.organization.declineRelationship.useMutation({ + onSuccess: () => { + utils.organization.invalidate(); + utils.organization.listPendingRelationships.invalidate(); + utils.organization.listDirectedRelationships.invalidate(); + utils.organization.listRelationships.invalidate(); + toast.success({ + message: 'Relationship declined', + }); + }, + onError: () => { + toast.error({ + message: 'Could not decline relationship', + }); + }, + }); + + const handleApprove = () => { + if (!user?.currentOrganization?.id) return; + + approve.mutate({ + sourceOrganizationId: profile.id, + targetOrganizationId: user.currentOrganization.id, + }); + }; + + const handleDecline = () => { + if (!user?.currentOrganization?.id || !pendingFromProfile?.relationships) return; + + decline.mutate({ + targetOrganizationId: user.currentOrganization.id, + ids: pendingFromProfile.relationships + .filter(r => r.pending) + .map(r => r.id), + }); + }; + + const dropdownItems = [ + { + id: 'accept', + label: 'Accept', + icon: , + onAction: handleApprove, + }, + { + id: 'decline', + label: 'Decline', + icon: , + onAction: handleDecline, + }, + ]; + + const isPending = approve.isPending || decline.isPending; + + return ( + + ) : ( + 'Respond' + ) + } + items={dropdownItems} + chevronIcon={} + className="bg-primary-teal text-neutral-offWhite min-w-full sm:min-w-fit" + isDisabled={isPending} + /> + ); +}; + +export const RespondButton = ({ + profile, +}: { + profile: Organization; +}) => { + return ( + + + + + + ); +}; \ No newline at end of file From 35f05ffefd240876c7f26253f5a7f707bc6ed4ad Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Wed, 23 Jul 2025 10:52:31 +0200 Subject: [PATCH 02/16] Claude PR Assistant workflow --- .github/workflows/claude.yml | 64 ++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 .github/workflows/claude.yml diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml new file mode 100644 index 000000000..e75d6765f --- /dev/null +++ b/.github/workflows/claude.yml @@ -0,0 +1,64 @@ +name: Claude Code + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned] + pull_request_review: + types: [submitted] + +jobs: + claude: + if: | + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || + (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + actions: read # Required for Claude to read CI results on PRs + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code + id: claude + uses: anthropics/claude-code-action@beta + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + + # This is an optional setting that allows Claude to read CI results on PRs + additional_permissions: | + actions: read + + # Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4) + # model: "claude-opus-4-20250514" + + # Optional: Customize the trigger phrase (default: @claude) + # trigger_phrase: "/claude" + + # Optional: Trigger when specific user is assigned to an issue + # assignee_trigger: "claude-bot" + + # Optional: Allow Claude to run specific commands + # allowed_tools: "Bash(npm install),Bash(npm run build),Bash(npm run test:*),Bash(npm run lint:*)" + + # Optional: Add custom instructions for Claude to customize its behavior for your project + # custom_instructions: | + # Follow our coding standards + # Ensure all new code has tests + # Use TypeScript for new files + + # Optional: Custom environment variables for Claude + # claude_env: | + # NODE_ENV: test + From e97ee3e69563449b707bd42af5838ce737e3d653 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Wed, 23 Jul 2025 10:52:32 +0200 Subject: [PATCH 03/16] Claude Code Review workflow --- .github/workflows/claude-code-review.yml | 78 ++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 .github/workflows/claude-code-review.yml diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml new file mode 100644 index 000000000..82e06e2e2 --- /dev/null +++ b/.github/workflows/claude-code-review.yml @@ -0,0 +1,78 @@ +name: Claude Code Review + +on: + pull_request: + types: [opened, synchronize] + # Optional: Only run on specific file changes + # paths: + # - "src/**/*.ts" + # - "src/**/*.tsx" + # - "src/**/*.js" + # - "src/**/*.jsx" + +jobs: + claude-review: + # Optional: Filter by PR author + # if: | + # github.event.pull_request.user.login == 'external-contributor' || + # github.event.pull_request.user.login == 'new-developer' || + # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' + + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code Review + id: claude-review + uses: anthropics/claude-code-action@beta + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + + # Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4) + # model: "claude-opus-4-20250514" + + # Direct prompt for automated review (no @claude mention needed) + direct_prompt: | + Please review this pull request and provide feedback on: + - Code quality and best practices + - Potential bugs or issues + - Performance considerations + - Security concerns + - Test coverage + + Be constructive and helpful in your feedback. + + # Optional: Use sticky comments to make Claude reuse the same comment on subsequent pushes to the same PR + # use_sticky_comment: true + + # Optional: Customize review based on file types + # direct_prompt: | + # Review this PR focusing on: + # - For TypeScript files: Type safety and proper interface usage + # - For API endpoints: Security, input validation, and error handling + # - For React components: Performance, accessibility, and best practices + # - For tests: Coverage, edge cases, and test quality + + # Optional: Different prompts for different authors + # direct_prompt: | + # ${{ github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' && + # 'Welcome! Please review this PR from a first-time contributor. Be encouraging and provide detailed explanations for any suggestions.' || + # 'Please provide a thorough code review focusing on our coding standards and best practices.' }} + + # Optional: Add specific tools for running tests or linting + # allowed_tools: "Bash(npm run test),Bash(npm run lint),Bash(npm run typecheck)" + + # Optional: Skip review for certain conditions + # if: | + # !contains(github.event.pull_request.title, '[skip-review]') && + # !contains(github.event.pull_request.title, '[WIP]') + From 7c6b60297afab2abecbf42d9c0501b65175c6ae1 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Wed, 23 Jul 2025 11:23:45 +0200 Subject: [PATCH 04/16] Create profile if none exists on a user --- .../api/src/routers/account/getMyAccount.ts | 80 ++++++++++++++++++- 1 file changed, 76 insertions(+), 4 deletions(-) diff --git a/services/api/src/routers/account/getMyAccount.ts b/services/api/src/routers/account/getMyAccount.ts index c3d19aa3e..c4aee2b35 100644 --- a/services/api/src/routers/account/getMyAccount.ts +++ b/services/api/src/routers/account/getMyAccount.ts @@ -1,14 +1,76 @@ import { CommonError, NotFoundError } from '@op/common'; -import { users } from '@op/db/schema'; +import { type db as Database, eq } from '@op/db/client'; +import { EntityType, individuals, profiles, users } from '@op/db/schema'; +import { randomUUID } from 'crypto'; import type { OpenApiMeta } from 'trpc-to-openapi'; import { z } from 'zod'; -import { userEncoder } from '../../encoders'; +import { CommonUser, userEncoder } from '../../encoders'; import withAuthenticated from '../../middlewares/withAuthenticated'; import withDB from '../../middlewares/withDB'; import withRateLimited from '../../middlewares/withRateLimited'; import { loggedProcedure, router } from '../../trpcFactory'; +// Helper function to ensure user has a profile and individual record +const ensureProfileAndIndividual = async ( + db: typeof Database, + user: CommonUser, +) => { + let profileId = user.profileId; + + // Create profile if it doesn't exist and migrate properties + if (!profileId) { + const slug = randomUUID(); + + const [newProfile] = await db + .insert(profiles) + .values({ + type: EntityType.INDIVIDUAL, + name: user.name || user.email || 'Unnamed User', + slug, + email: user.email, + avatarImageId: user.avatarImageId, + bio: user.title, + }) + .returning(); + + if (!newProfile) { + throw new CommonError('Failed to create profile'); + } + + // Update user's profileId + await db + .update(users) + .set({ profileId: newProfile.id }) + // TODO: quick hack to get a patch out. resolve this + // @ts-ignore + .where(() => eq(users.authUserId, user.authUserId)); + + profileId = newProfile.id; + + // Update the user object to reflect the new profile + user.profile = newProfile; + user.profileId = newProfile.id; + } + + // Check if individual record exists + const individualRecord = await db.query.individuals.findFirst({ + where: (table: any, { eq }: any) => eq(table.profileId, profileId), + }); + + // Create individual record if it doesn't exist + if (!individualRecord) { + await db + .insert(individuals) + .values({ + profileId: profileId, + }) + .onConflictDoNothing(); + } + + return user; +}; + const meta: OpenApiMeta = { openapi: { enabled: true, @@ -134,9 +196,19 @@ export const getMyAccount = router({ }, }); - return userEncoder.parse(newUserWithRelations); + const profileUser = await ensureProfileAndIndividual( + db, + newUserWithRelations as CommonUser, + ); + + return userEncoder.parse(profileUser); } - return userEncoder.parse(result); + const profileUser = await ensureProfileAndIndividual( + db, + result as CommonUser, + ); + + return userEncoder.parse(profileUser); }), }); From 234f90d9e6b01897fe2a447a3dc5c6d11f800fde Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Wed, 23 Jul 2025 11:26:13 +0200 Subject: [PATCH 05/16] Fix type issue --- services/api/src/routers/account/getMyAccount.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/services/api/src/routers/account/getMyAccount.ts b/services/api/src/routers/account/getMyAccount.ts index c4aee2b35..9a2215da8 100644 --- a/services/api/src/routers/account/getMyAccount.ts +++ b/services/api/src/routers/account/getMyAccount.ts @@ -42,9 +42,7 @@ const ensureProfileAndIndividual = async ( await db .update(users) .set({ profileId: newProfile.id }) - // TODO: quick hack to get a patch out. resolve this - // @ts-ignore - .where(() => eq(users.authUserId, user.authUserId)); + .where(eq(users.authUserId, user.authUserId)); profileId = newProfile.id; From a49269f1f16c5239579a6523c8a5da8d127503a3 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Wed, 23 Jul 2025 11:53:05 +0200 Subject: [PATCH 06/16] use transaction, fix types --- .../api/src/routers/account/getMyAccount.ts | 195 ++++++++---------- 1 file changed, 87 insertions(+), 108 deletions(-) diff --git a/services/api/src/routers/account/getMyAccount.ts b/services/api/src/routers/account/getMyAccount.ts index 9a2215da8..f85d1cc2e 100644 --- a/services/api/src/routers/account/getMyAccount.ts +++ b/services/api/src/routers/account/getMyAccount.ts @@ -20,44 +20,48 @@ const ensureProfileAndIndividual = async ( // Create profile if it doesn't exist and migrate properties if (!profileId) { - const slug = randomUUID(); - - const [newProfile] = await db - .insert(profiles) - .values({ - type: EntityType.INDIVIDUAL, - name: user.name || user.email || 'Unnamed User', - slug, - email: user.email, - avatarImageId: user.avatarImageId, - bio: user.title, - }) - .returning(); - - if (!newProfile) { - throw new CommonError('Failed to create profile'); - } + await db.transaction(async (tx) => { + const slug = randomUUID(); + + const [newProfile] = await tx + .insert(profiles) + .values({ + type: EntityType.INDIVIDUAL, + name: user.name || + (user.email ? user.email.split('@')[0] : null) || + 'Unnamed User', + slug, + email: user.email, + avatarImageId: user.avatarImageId, + bio: user.title, + }) + .returning(); + + if (!newProfile) { + throw new CommonError('Failed to create profile'); + } - // Update user's profileId - await db - .update(users) - .set({ profileId: newProfile.id }) - .where(eq(users.authUserId, user.authUserId)); + // Update user's profileId + await tx + .update(users) + .set({ profileId: newProfile.id }) + .where(eq(users.authUserId, user.authUserId)); - profileId = newProfile.id; + profileId = newProfile.id; - // Update the user object to reflect the new profile - user.profile = newProfile; - user.profileId = newProfile.id; + // Update the user object to reflect the new profile + user.profile = newProfile; + user.profileId = newProfile.id; + }); } // Check if individual record exists const individualRecord = await db.query.individuals.findFirst({ - where: (table: any, { eq }: any) => eq(table.profileId, profileId), + where: eq(individuals.profileId, profileId!), }); // Create individual record if it doesn't exist - if (!individualRecord) { + if (!individualRecord && profileId) { await db .insert(individuals) .values({ @@ -69,6 +73,53 @@ const ensureProfileAndIndividual = async ( return user; }; +// Reusable function for user query with all relations +const getUserWithRelations = async ( + db: typeof Database, + condition: (table: any, ops: any) => any +) => { + return await db.query.users.findFirst({ + where: condition, + with: { + avatarImage: true, + organizationUsers: { + with: { + organization: { + with: { + profile: { + with: { + avatarImage: true, + }, + }, + }, + }, + }, + }, + currentOrganization: { + with: { + profile: { + with: { + avatarImage: true, + }, + }, + }, + }, + currentProfile: { + with: { + avatarImage: true, + headerImage: true, + }, + }, + profile: { + with: { + avatarImage: true, + headerImage: true, + }, + }, + }, + }); +}; + const meta: OpenApiMeta = { openapi: { enabled: true, @@ -94,46 +145,10 @@ export const getMyAccount = router({ const { db } = ctx.database; const { id, email } = ctx.user; - const result = await db.query.users.findFirst({ - where: (table, { eq }) => eq(table.authUserId, id), - with: { - avatarImage: true, - organizationUsers: { - with: { - organization: { - with: { - profile: { - with: { - avatarImage: true, - }, - }, - }, - }, - }, - }, - currentOrganization: { - with: { - profile: { - with: { - avatarImage: true, - }, - }, - }, - }, - currentProfile: { - with: { - avatarImage: true, - headerImage: true, - }, - }, - profile: { - with: { - avatarImage: true, - headerImage: true, - }, - }, - }, - }); + const result = await getUserWithRelations( + db, + (table, { eq }) => eq(table.authUserId, id) + ); if (!result) { if (!email) { @@ -153,46 +168,10 @@ export const getMyAccount = router({ throw new CommonError('Could not create user'); } - const newUserWithRelations = await db.query.users.findFirst({ - where: (table, { eq }) => eq(table.id, newUser.id), - with: { - avatarImage: true, - organizationUsers: { - with: { - organization: { - with: { - profile: { - with: { - avatarImage: true, - }, - }, - }, - }, - }, - }, - currentOrganization: { - with: { - profile: { - with: { - avatarImage: true, - }, - }, - }, - }, - currentProfile: { - with: { - avatarImage: true, - headerImage: true, - }, - }, - profile: { - with: { - avatarImage: true, - headerImage: true, - }, - }, - }, - }); + const newUserWithRelations = await getUserWithRelations( + db, + (table, { eq }) => eq(table.id, newUser.id) + ); const profileUser = await ensureProfileAndIndividual( db, From 66aced808c77ef91322ade48899172026accdaab Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Wed, 23 Jul 2025 12:30:10 +0200 Subject: [PATCH 07/16] revert user profile migration in lieu of a DB migration --- .../api/src/routers/account/getMyAccount.ts | 217 +++++++----------- 1 file changed, 84 insertions(+), 133 deletions(-) diff --git a/services/api/src/routers/account/getMyAccount.ts b/services/api/src/routers/account/getMyAccount.ts index f85d1cc2e..c3d19aa3e 100644 --- a/services/api/src/routers/account/getMyAccount.ts +++ b/services/api/src/routers/account/getMyAccount.ts @@ -1,125 +1,14 @@ import { CommonError, NotFoundError } from '@op/common'; -import { type db as Database, eq } from '@op/db/client'; -import { EntityType, individuals, profiles, users } from '@op/db/schema'; -import { randomUUID } from 'crypto'; +import { users } from '@op/db/schema'; import type { OpenApiMeta } from 'trpc-to-openapi'; import { z } from 'zod'; -import { CommonUser, userEncoder } from '../../encoders'; +import { userEncoder } from '../../encoders'; import withAuthenticated from '../../middlewares/withAuthenticated'; import withDB from '../../middlewares/withDB'; import withRateLimited from '../../middlewares/withRateLimited'; import { loggedProcedure, router } from '../../trpcFactory'; -// Helper function to ensure user has a profile and individual record -const ensureProfileAndIndividual = async ( - db: typeof Database, - user: CommonUser, -) => { - let profileId = user.profileId; - - // Create profile if it doesn't exist and migrate properties - if (!profileId) { - await db.transaction(async (tx) => { - const slug = randomUUID(); - - const [newProfile] = await tx - .insert(profiles) - .values({ - type: EntityType.INDIVIDUAL, - name: user.name || - (user.email ? user.email.split('@')[0] : null) || - 'Unnamed User', - slug, - email: user.email, - avatarImageId: user.avatarImageId, - bio: user.title, - }) - .returning(); - - if (!newProfile) { - throw new CommonError('Failed to create profile'); - } - - // Update user's profileId - await tx - .update(users) - .set({ profileId: newProfile.id }) - .where(eq(users.authUserId, user.authUserId)); - - profileId = newProfile.id; - - // Update the user object to reflect the new profile - user.profile = newProfile; - user.profileId = newProfile.id; - }); - } - - // Check if individual record exists - const individualRecord = await db.query.individuals.findFirst({ - where: eq(individuals.profileId, profileId!), - }); - - // Create individual record if it doesn't exist - if (!individualRecord && profileId) { - await db - .insert(individuals) - .values({ - profileId: profileId, - }) - .onConflictDoNothing(); - } - - return user; -}; - -// Reusable function for user query with all relations -const getUserWithRelations = async ( - db: typeof Database, - condition: (table: any, ops: any) => any -) => { - return await db.query.users.findFirst({ - where: condition, - with: { - avatarImage: true, - organizationUsers: { - with: { - organization: { - with: { - profile: { - with: { - avatarImage: true, - }, - }, - }, - }, - }, - }, - currentOrganization: { - with: { - profile: { - with: { - avatarImage: true, - }, - }, - }, - }, - currentProfile: { - with: { - avatarImage: true, - headerImage: true, - }, - }, - profile: { - with: { - avatarImage: true, - headerImage: true, - }, - }, - }, - }); -}; - const meta: OpenApiMeta = { openapi: { enabled: true, @@ -145,10 +34,46 @@ export const getMyAccount = router({ const { db } = ctx.database; const { id, email } = ctx.user; - const result = await getUserWithRelations( - db, - (table, { eq }) => eq(table.authUserId, id) - ); + const result = await db.query.users.findFirst({ + where: (table, { eq }) => eq(table.authUserId, id), + with: { + avatarImage: true, + organizationUsers: { + with: { + organization: { + with: { + profile: { + with: { + avatarImage: true, + }, + }, + }, + }, + }, + }, + currentOrganization: { + with: { + profile: { + with: { + avatarImage: true, + }, + }, + }, + }, + currentProfile: { + with: { + avatarImage: true, + headerImage: true, + }, + }, + profile: { + with: { + avatarImage: true, + headerImage: true, + }, + }, + }, + }); if (!result) { if (!email) { @@ -168,24 +93,50 @@ export const getMyAccount = router({ throw new CommonError('Could not create user'); } - const newUserWithRelations = await getUserWithRelations( - db, - (table, { eq }) => eq(table.id, newUser.id) - ); - - const profileUser = await ensureProfileAndIndividual( - db, - newUserWithRelations as CommonUser, - ); + const newUserWithRelations = await db.query.users.findFirst({ + where: (table, { eq }) => eq(table.id, newUser.id), + with: { + avatarImage: true, + organizationUsers: { + with: { + organization: { + with: { + profile: { + with: { + avatarImage: true, + }, + }, + }, + }, + }, + }, + currentOrganization: { + with: { + profile: { + with: { + avatarImage: true, + }, + }, + }, + }, + currentProfile: { + with: { + avatarImage: true, + headerImage: true, + }, + }, + profile: { + with: { + avatarImage: true, + headerImage: true, + }, + }, + }, + }); - return userEncoder.parse(profileUser); + return userEncoder.parse(newUserWithRelations); } - const profileUser = await ensureProfileAndIndividual( - db, - result as CommonUser, - ); - - return userEncoder.parse(profileUser); + return userEncoder.parse(result); }), }); From 0f09e2d3eeecda0a08c1edc91b04c76e7726999f Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Wed, 23 Jul 2025 12:57:02 +0200 Subject: [PATCH 08/16] styling on respond button --- .../Profile/ProfileDetails/RespondButton.tsx | 46 +++++++++---------- packages/ui/src/components/DropDownButton.tsx | 2 + 2 files changed, 23 insertions(+), 25 deletions(-) diff --git a/apps/app/src/components/Profile/ProfileDetails/RespondButton.tsx b/apps/app/src/components/Profile/ProfileDetails/RespondButton.tsx index 60c1102ee..5889cb654 100644 --- a/apps/app/src/components/Profile/ProfileDetails/RespondButton.tsx +++ b/apps/app/src/components/Profile/ProfileDetails/RespondButton.tsx @@ -7,15 +7,11 @@ import { DropDownButton } from '@op/ui/DropDownButton'; import { LoadingSpinner } from '@op/ui/LoadingSpinner'; import { toast } from '@op/ui/Toast'; import { Suspense } from 'react'; -import { LuCheck, LuChevronDown, LuX } from 'react-icons/lu'; +import { LuCheck, LuChevronDown, LuUserPlus, LuX } from 'react-icons/lu'; import ErrorBoundary from '@/components/ErrorBoundary'; -const RespondButtonSuspense = ({ - profile, -}: { - profile: Organization; -}) => { +const RespondButtonSuspense = ({ profile }: { profile: Organization }) => { const { user } = useUser(); const utils = trpc.useUtils(); @@ -28,9 +24,9 @@ const RespondButtonSuspense = ({ trpc.organization.listPendingRelationships.useSuspenseQuery(); // Filter to only show requests from the profile we're viewing - const pendingFromProfile = pendingOrgs.find(org => org.id === profile.id); - - if (!pendingFromProfile?.relationships?.some(r => r.pending)) { + const pendingFromProfile = pendingOrgs.find((org) => org.id === profile.id); + + if (!pendingFromProfile?.relationships?.some((r) => r.pending)) { return null; } @@ -70,7 +66,7 @@ const RespondButtonSuspense = ({ const handleApprove = () => { if (!user?.currentOrganization?.id) return; - + approve.mutate({ sourceOrganizationId: profile.id, targetOrganizationId: user.currentOrganization.id, @@ -78,13 +74,14 @@ const RespondButtonSuspense = ({ }; const handleDecline = () => { - if (!user?.currentOrganization?.id || !pendingFromProfile?.relationships) return; - + if (!user?.currentOrganization?.id || !pendingFromProfile?.relationships) + return; + decline.mutate({ targetOrganizationId: user.currentOrganization.id, ids: pendingFromProfile.relationships - .filter(r => r.pending) - .map(r => r.id), + .filter((r) => r.pending) + .map((r) => r.id), }); }; @@ -92,11 +89,11 @@ const RespondButtonSuspense = ({ { id: 'accept', label: 'Accept', - icon: , + icon: , onAction: handleApprove, }, { - id: 'decline', + id: 'decline', label: 'Decline', icon: , onAction: handleDecline, @@ -107,26 +104,25 @@ const RespondButtonSuspense = ({ return ( ) : ( - 'Respond' + <> + + Respond + ) } items={dropdownItems} - chevronIcon={} - className="bg-primary-teal text-neutral-offWhite min-w-full sm:min-w-fit" + className="min-w-full bg-primary-teal text-neutral-offWhite sm:min-w-fit" isDisabled={isPending} /> ); }; -export const RespondButton = ({ - profile, -}: { - profile: Organization; -}) => { +export const RespondButton = ({ profile }: { profile: Organization }) => { return ( @@ -134,4 +130,4 @@ export const RespondButton = ({ ); -}; \ No newline at end of file +}; diff --git a/packages/ui/src/components/DropDownButton.tsx b/packages/ui/src/components/DropDownButton.tsx index 11bf6e5b8..202d9b8ff 100644 --- a/packages/ui/src/components/DropDownButton.tsx +++ b/packages/ui/src/components/DropDownButton.tsx @@ -12,6 +12,8 @@ const dropdownButtonStyle = tv({ base: 'flex h-10 w-fit items-center justify-center gap-1 rounded-lg border border-solid p-4 text-center text-sm font-normal leading-6 shadow-md outline-none duration-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-lightGray', variants: { color: { + primary: + 'bg-primary-teal text-neutral-offWhite hover:bg-primary-tealBlack pressed:bg-primary-tealBlack pressed:text-neutral-gray2', secondary: 'border-primary-teal bg-white text-primary-teal hover:bg-neutral-50 pressed:bg-white', }, From 6e813bc34e11309108a7aed962a4ccae8c2e2fd1 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Wed, 23 Jul 2025 13:09:22 +0200 Subject: [PATCH 09/16] lint --- .../app/src/components/Profile/ProfileDetails/RespondButton.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/app/src/components/Profile/ProfileDetails/RespondButton.tsx b/apps/app/src/components/Profile/ProfileDetails/RespondButton.tsx index 5889cb654..baa460744 100644 --- a/apps/app/src/components/Profile/ProfileDetails/RespondButton.tsx +++ b/apps/app/src/components/Profile/ProfileDetails/RespondButton.tsx @@ -7,7 +7,7 @@ import { DropDownButton } from '@op/ui/DropDownButton'; import { LoadingSpinner } from '@op/ui/LoadingSpinner'; import { toast } from '@op/ui/Toast'; import { Suspense } from 'react'; -import { LuCheck, LuChevronDown, LuUserPlus, LuX } from 'react-icons/lu'; +import { LuCheck, LuUserPlus, LuX } from 'react-icons/lu'; import ErrorBoundary from '@/components/ErrorBoundary'; From f69af9ec36d3e5b62a193fbd819390481c3b5e62 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Wed, 23 Jul 2025 14:14:52 +0200 Subject: [PATCH 10/16] Add new fields to onboarding form --- .../Onboarding/PersonalDetailsForm.tsx | 309 ++++++++++++------ 1 file changed, 215 insertions(+), 94 deletions(-) diff --git a/apps/app/src/components/Onboarding/PersonalDetailsForm.tsx b/apps/app/src/components/Onboarding/PersonalDetailsForm.tsx index 99d1aa76d..cf2739900 100644 --- a/apps/app/src/components/Onboarding/PersonalDetailsForm.tsx +++ b/apps/app/src/components/Onboarding/PersonalDetailsForm.tsx @@ -1,13 +1,18 @@ +import { DEFAULT_MAX_SIZE } from '@/hooks/useFileUpload'; +import { zodUrl } from '@/utils'; import { trpc } from '@op/api/client'; import { AvatarUploader } from '@op/ui/AvatarUploader'; +import { BannerUploader } from '@op/ui/BannerUploader'; import { LoadingSpinner } from '@op/ui/LoadingSpinner'; +import { Skeleton } from '@op/ui/Skeleton'; import { toast } from '@op/ui/Toast'; -import { ReactNode, useState } from 'react'; +import { ReactNode, Suspense, useState } from 'react'; import { z } from 'zod'; import { useTranslations } from '@/lib/i18n'; import { StepProps } from '../MultiStepForm'; +import { FocusAreasField } from '../Profile/ProfileDetails/FocusAreasField'; import { FormContainer } from '../form/FormContainer'; import { FormHeader } from '../form/FormHeader'; import { getFieldErrorMessage, useAppForm } from '../form/utils'; @@ -15,57 +20,71 @@ import { useOnboardingFormStore } from './useOnboardingFormStore'; type FormFields = z.infer; -export const createValidator = (t: (key: string) => string) => z.object({ - fullName: z - .string({ message: t('Enter your full name') }) - .trim() - .min(1, { - message: t('Enter your full name'), - }) - .max(200, { - message: t('Must be at most 200 characters'), - }), - title: z - .string({ - message: t('Enter your professional title'), - }) - .trim() - .min(1, { - message: t('Enter your professional title'), - }) - .max(200, { - message: t('Must be at most 200 characters'), - }), - profileImageUrl: z.string().optional(), -}); +export const createValidator = (t: (key: string) => string) => + z.object({ + fullName: z + .string({ message: t('Enter your full name') }) + .trim() + .min(1, { + message: t('Enter your full name'), + }) + .max(200, { + message: t('Must be at most 200 characters'), + }), + title: z + .string({ + message: t('Enter your professional title'), + }) + .trim() + .min(1, { + message: t('Enter your professional title'), + }) + .max(200, { + message: t('Must be at most 200 characters'), + }), + email: z + .string() + .trim() + .refine( + (val) => val === '' || z.string().email().safeParse(val).success, + { + message: t('Invalid email'), + }, + ) + .refine((val) => val.length <= 255, { + message: t('Must be at most 255 characters'), + }), + website: zodUrl({ message: t('Enter a valid website address') }), + focusAreas: z + .array( + z.object({ + id: z.string(), + label: z.string(), + }), + ) + .optional(), + profileImageUrl: z.string().optional(), + bannerImageUrl: z.string().optional(), + }); // Fallback validator for external use export const validator = z.object({ fullName: z.string().trim().min(1).max(200), title: z.string().trim().min(1).max(200), + email: z.string().optional(), + website: z.string().optional(), + focusAreas: z + .array( + z.object({ + id: z.string(), + label: z.string(), + }), + ) + .optional(), profileImageUrl: z.string().optional(), + bannerImageUrl: z.string().optional(), }); -const DEFAULT_MAX_SIZE = 4 * 1024 * 1024; // 4MB - -const DEFAULT_ACCEPTED_TYPES = [ - 'image/png', - 'image/gif', - 'image/jpeg', - 'image/webp', - 'application/pdf', -]; -const validateFile = (file: File, t: (key: string, params?: any) => string): string | null => { - if (!DEFAULT_ACCEPTED_TYPES.includes(file.type)) { - const types = DEFAULT_ACCEPTED_TYPES.map((t) => t.split('/')[1]).join(', '); - return t('That file type is not supported. Accepted types: {types}', { types }); - } - - if (file.size > DEFAULT_MAX_SIZE) { - const maxSizeMB = (DEFAULT_MAX_SIZE / 1024 / 1024).toFixed(2); - return t('File too large. Maximum size: {maxSizeMB}MB', { maxSizeMB }); - } - return null; -}; +const acceptedTypes = ['image/gif', 'image/png', 'image/jpeg', 'image/webp']; export const PersonalDetailsForm = ({ onNext, @@ -76,26 +95,107 @@ export const PersonalDetailsForm = ({ (s) => s.setPersonalDetails, ); const t = useTranslations(); + const utils = trpc.useUtils(); const uploadImage = trpc.account.uploadImage.useMutation(); + const uploadBannerImage = trpc.account.uploadBannerImage.useMutation(); const updateProfile = trpc.account.updateUserProfile.useMutation(); + // Get current user's profile ID for the focus areas component + const { data: userAccount } = trpc.account.getMyAccount.useQuery(); + const profileId = userAccount?.profile?.id; + // Hydrate profileImageUrl from store if present, else undefined const [profileImageUrl, setProfileImageUrl] = useState( personalDetails?.profileImageUrl, ); + const [bannerImageUrl, setBannerImageUrl] = useState( + personalDetails?.bannerImageUrl, + ); + + const handleImageUpload = async ( + file: File, + setImageUrl: (url: string | undefined) => void, + uploadMutation: any, + ): Promise => { + const reader = new FileReader(); + + reader.onload = async (e) => { + const base64 = (e.target?.result as string)?.split(',')[1]; + + if (!base64) { + return; + } + + if (!acceptedTypes.includes(file.type)) { + toast.error({ + message: `That file type is not supported. Accepted types: ${acceptedTypes.map((t) => t.split('/')[1]).join(', ')}`, + }); + return; + } + + if (file.size > DEFAULT_MAX_SIZE) { + const maxSizeMB = (DEFAULT_MAX_SIZE / 1024 / 1024).toFixed(2); + toast.error({ + message: `File too large. Maximum size: ${maxSizeMB}MB`, + }); + return; + } + + const dataUrl = `data:${file.type};base64,${base64}`; + setImageUrl(dataUrl); + + const res = await uploadMutation.mutateAsync( + { + file: base64, + fileName: file.name, + mimeType: file.type, + }, + { + onSuccess: () => { + utils.account.getMyAccount.invalidate(); + utils.account.getUserProfiles.invalidate(); + }, + }, + ); + + if (res?.url) { + setImageUrl(res.url); + } + }; + + reader.readAsDataURL(file); + }; // Hydrate form from store if present const form = useAppForm({ - defaultValues: personalDetails, + defaultValues: { + fullName: personalDetails?.fullName ?? '', + title: personalDetails?.title ?? '', + email: personalDetails?.email ?? '', + website: personalDetails?.website ?? '', + focusAreas: personalDetails?.focusAreas ?? [], + profileImageUrl: personalDetails?.profileImageUrl ?? '', + bannerImageUrl: personalDetails?.bannerImageUrl ?? '', + }, validators: { - onSubmit: createValidator(t), + onSubmit: createValidator(t) as any, }, onSubmit: async ({ value }: { value: FormFields }) => { await updateProfile.mutateAsync({ name: value.fullName, - title: value.title, + bio: value.title, + email: value.email || undefined, + website: value.website || undefined, + focusAreas: value.focusAreas || undefined, }); - setPersonalDetails({ ...value, profileImageUrl }); // Persist to store on submit + utils.account.getMyAccount.invalidate(); + utils.account.getUserProfiles.invalidate(); + if (profileId) { + utils.individual.getTermsByProfile.invalidate({ + profileId, + }); + } + setPersonalDetails({ ...value, profileImageUrl, bannerImageUrl }); // Persist to store on submit onNext(value); }, @@ -114,51 +214,25 @@ export const PersonalDetailsForm = ({ {t('Tell us about yourself so others can find you.')} -
+ + {/* Header Images */} +
+ + handleImageUpload(file, setBannerImageUrl, uploadBannerImage) + } + uploading={uploadBannerImage.isPending} + error={uploadBannerImage.error?.message || undefined} + /> => { - const reader = new FileReader(); - - reader.onload = async (e) => { - const validationError = validateFile(file, t); - if (validationError) { - toast.status({ code: 500, message: validationError }); - setProfileImageUrl(undefined); - - throw new Error(validationError); - } - - const base64 = (e.target?.result as string)?.split(',')[1]; - - if (!base64) { - return; - } - - const dataUrl = `data:${file.type};base64,${base64}`; - - setProfileImageUrl(dataUrl); - - const res = await uploadImage - .mutateAsync({ - file: base64, - fileName: file.name, - mimeType: file.type, - }) - .catch((error) => { - toast.status({ code: 500, message: error.message }); - setProfileImageUrl(undefined); - }); - - if (res?.url) { - setProfileImageUrl(res.url); - } - }; - - reader.readAsDataURL(file); - }} + onChange={(file: File) => + handleImageUpload(file, setProfileImageUrl, uploadImage) + } uploading={uploadImage.isPending} error={uploadImage.error?.message || undefined} /> @@ -184,20 +258,67 @@ export const PersonalDetailsForm = ({ children={(field) => ( + )} + /> + ( + + )} + /> + ( + )} /> + {profileId && ( + ( + }> + + + )} + /> + )} - {updateProfile.isPending || uploadImage.isPending ? ( + {updateProfile.isPending || + uploadImage.isPending || + uploadBannerImage.isPending ? ( ) : ( t('Continue') From 01d5a506e463016745c3b1c3a007a1dbb0f4375d Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Thu, 24 Jul 2025 10:26:03 +0200 Subject: [PATCH 11/16] Set lastOrgId no matter what --- services/api/src/routers/account/updateLastOrgId.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/services/api/src/routers/account/updateLastOrgId.ts b/services/api/src/routers/account/updateLastOrgId.ts index 42e3cdf88..647a418bc 100644 --- a/services/api/src/routers/account/updateLastOrgId.ts +++ b/services/api/src/routers/account/updateLastOrgId.ts @@ -1,4 +1,4 @@ -import { users, organizations } from '@op/db/schema'; +import { organizations, users } from '@op/db/schema'; import { TRPCError } from '@trpc/server'; import { eq } from 'drizzle-orm'; import type { OpenApiMeta } from 'trpc-to-openapi'; @@ -51,7 +51,10 @@ export const switchOrganization = router({ result = await db .update(users) - .set({ currentProfileId: organization.profileId }) + .set({ + lastOrgId: organization.id, + currentProfileId: organization.profileId, + }) .where(eq(users.authUserId, id)) .returning(); } catch (error) { @@ -72,4 +75,3 @@ export const switchOrganization = router({ return userEncoder.parse(result[0]); }), }); - From 5eaa2a3afbabeeae5f99b40402a82ca2b2bb2f12 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Thu, 24 Jul 2025 10:47:29 +0200 Subject: [PATCH 12/16] fix lastOrgId switching bug --- services/api/src/routers/organization/inviteUser.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/services/api/src/routers/organization/inviteUser.ts b/services/api/src/routers/organization/inviteUser.ts index cfb746874..9db0b60d0 100644 --- a/services/api/src/routers/organization/inviteUser.ts +++ b/services/api/src/routers/organization/inviteUser.ts @@ -92,12 +92,19 @@ export const inviteUserRouter = router({ // For new organization invites, we don't need the user to be in an organization // For existing organization invites, we do need it - if ((!authUser?.currentProfileId && !authUser?.lastOrgId) || !authUser.currentOrganization) { + if ( + (!authUser?.currentProfileId && !authUser?.lastOrgId) || + (!authUser.currentOrganization && !authUser.currentProfile) + ) { throw new UnauthorizedError( 'User must be associated with an organization to send invites', ); } + const currentProfile = + authUser.currentProfile ?? + (authUser.currentOrganization as any)?.profile; + const results = { successful: [] as string[], failed: [] as { email: string; reason: string }[], @@ -121,8 +128,7 @@ export const inviteUserRouter = router({ inviteType: 'new_organization', personalMessage: personalMessage, inviterOrganizationName: - (authUser?.currentOrganization as any)?.profile?.name || - 'Common', + (currentProfile as any)?.profile?.name || 'Common', } : { invitedBy: authUserId, From 4f6cff133a81506531dec12cb382fd864d58a150 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Thu, 24 Jul 2025 11:19:39 +0200 Subject: [PATCH 13/16] skip auto PR reviews on drafts --- .github/workflows/claude-code-review.yml | 26 +++++++++++------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 82e06e2e2..02bf64342 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -17,14 +17,14 @@ jobs: # github.event.pull_request.user.login == 'external-contributor' || # github.event.pull_request.user.login == 'new-developer' || # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' - + runs-on: ubuntu-latest permissions: contents: read pull-requests: read issues: read id-token: write - + steps: - name: Checkout repository uses: actions/checkout@v4 @@ -39,7 +39,7 @@ jobs: # Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4) # model: "claude-opus-4-20250514" - + # Direct prompt for automated review (no @claude mention needed) direct_prompt: | Please review this pull request and provide feedback on: @@ -47,13 +47,12 @@ jobs: - Potential bugs or issues - Performance considerations - Security concerns - - Test coverage - + Be constructive and helpful in your feedback. # Optional: Use sticky comments to make Claude reuse the same comment on subsequent pushes to the same PR # use_sticky_comment: true - + # Optional: Customize review based on file types # direct_prompt: | # Review this PR focusing on: @@ -61,18 +60,17 @@ jobs: # - For API endpoints: Security, input validation, and error handling # - For React components: Performance, accessibility, and best practices # - For tests: Coverage, edge cases, and test quality - + # Optional: Different prompts for different authors # direct_prompt: | - # ${{ github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' && + # ${{ github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' && # 'Welcome! Please review this PR from a first-time contributor. Be encouraging and provide detailed explanations for any suggestions.' || # 'Please provide a thorough code review focusing on our coding standards and best practices.' }} - + # Optional: Add specific tools for running tests or linting # allowed_tools: "Bash(npm run test),Bash(npm run lint),Bash(npm run typecheck)" - - # Optional: Skip review for certain conditions - # if: | - # !contains(github.event.pull_request.title, '[skip-review]') && - # !contains(github.event.pull_request.title, '[WIP]') + # Optional: Skip review for certain conditions + if: | + !github.event.pull_request.draft && + !contains(github.event.pull_request.title, '[WIP]') From 188388f51b71b3ac61b5ed82d6fc281129d49e33 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Thu, 24 Jul 2025 11:28:26 +0200 Subject: [PATCH 14/16] more workflow limits --- .github/workflows/claude-code-review.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 02bf64342..e49a68853 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -2,7 +2,7 @@ name: Claude Code Review on: pull_request: - types: [opened, synchronize] + types: [opened, synchronize, ready_for_review] # Optional: Only run on specific file changes # paths: # - "src/**/*.ts" @@ -12,8 +12,11 @@ on: jobs: claude-review: - # Optional: Filter by PR author - # if: | + # Filter by PR author + if: | + (github.event.pull_request.author_association == 'MEMBER' || + github.event.pull_request.author_association == 'COLLABORATOR' || + github.event.pull_request.author_association == 'OWNER') # github.event.pull_request.user.login == 'external-contributor' || # github.event.pull_request.user.login == 'new-developer' || # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' From df13d1466817d7648221a2d080c7f3a8b68ba607 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Thu, 24 Jul 2025 11:41:47 +0200 Subject: [PATCH 15/16] skip checks on drafts --- .github/workflows/claude-code-review.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index e49a68853..8c78f803e 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -14,6 +14,8 @@ jobs: claude-review: # Filter by PR author if: | + !github.event.pull_request.draft && + !contains(github.event.pull_request.title, '[WIP]') && (github.event.pull_request.author_association == 'MEMBER' || github.event.pull_request.author_association == 'COLLABORATOR' || github.event.pull_request.author_association == 'OWNER') From 7a952741634978120705ba06568ffa4bd7705e1f Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Thu, 24 Jul 2025 13:15:51 +0200 Subject: [PATCH 16/16] fix type, keep console.log --- apps/app/src/components/Onboarding/PersonalDetailsForm.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/app/src/components/Onboarding/PersonalDetailsForm.tsx b/apps/app/src/components/Onboarding/PersonalDetailsForm.tsx index cf2739900..47ca448d4 100644 --- a/apps/app/src/components/Onboarding/PersonalDetailsForm.tsx +++ b/apps/app/src/components/Onboarding/PersonalDetailsForm.tsx @@ -115,7 +115,7 @@ export const PersonalDetailsForm = ({ const handleImageUpload = async ( file: File, setImageUrl: (url: string | undefined) => void, - uploadMutation: any, + uploadMutation: typeof uploadBannerImage | typeof uploadImage, ): Promise => { const reader = new FileReader();