Skip to content

Commit 344fcd5

Browse files
authored
Merge pull request #2389 from trycompai/main
[comp] Production Deploy
2 parents 449a604 + 7dc6bf9 commit 344fcd5

7 files changed

Lines changed: 402 additions & 167 deletions

File tree

apps/app/src/actions/organization/accept-invitation.ts

Lines changed: 19 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
'use server';
22

33
import { createTrainingVideoEntries } from '@/lib/db/employee';
4+
import { auth } from '@/utils/auth';
45
import { db } from '@db';
56
import { revalidatePath, revalidateTag } from 'next/cache';
7+
import { headers } from 'next/headers';
68
import { z } from 'zod';
79
import { authActionClientWithoutOrg } from '../safe-action';
810
import type { ActionResponse } from '../types';
@@ -72,32 +74,32 @@ export const completeInvitation = authActionClientWithoutOrg
7274
});
7375

7476
if (existingMembership) {
75-
if (ctx.session.activeOrganizationId !== invitation.organizationId) {
76-
await db.session.update({
77-
where: { id: ctx.session.id },
77+
// Reactivate member before setting active org, since better-auth
78+
// validates membership status when setting the active organization.
79+
if (existingMembership.deactivated) {
80+
await db.member.update({
81+
where: { id: existingMembership.id },
7882
data: {
79-
activeOrganizationId: invitation.organizationId,
83+
deactivated: false,
84+
role: invitation.role,
8085
},
8186
});
8287
}
8388

89+
if (ctx.session.activeOrganizationId !== invitation.organizationId) {
90+
await auth.api.setActiveOrganization({
91+
headers: await headers(),
92+
body: { organizationId: invitation.organizationId },
93+
});
94+
}
95+
8496
await db.invitation.update({
8597
where: { id: invitation.id },
8698
data: {
8799
status: 'accepted',
88100
},
89101
});
90102

91-
if (existingMembership.deactivated) {
92-
await db.member.update({
93-
where: { id: existingMembership.id },
94-
data: {
95-
deactivated: false,
96-
role: invitation.role,
97-
},
98-
});
99-
}
100-
101103
revalidatePath(`/${invitation.organization.id}`);
102104
revalidateTag(`user_${user.id}`, 'max');
103105

@@ -135,13 +137,9 @@ export const completeInvitation = authActionClientWithoutOrg
135137
},
136138
});
137139

138-
await db.session.update({
139-
where: {
140-
id: ctx.session.id,
141-
},
142-
data: {
143-
activeOrganizationId: invitation.organizationId,
144-
},
140+
await auth.api.setActiveOrganization({
141+
headers: await headers(),
142+
body: { organizationId: invitation.organizationId },
145143
});
146144

147145
revalidatePath(`/${invitation.organization.id}`);

apps/app/src/app/(app)/[orgId]/layout.test.tsx

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ vi.mock('@/lib/api-server', () => ({
3131
}));
3232
vi.mock('@/lib/permissions', () => ({
3333
canAccessApp: vi.fn().mockReturnValue(true),
34+
parseRolesString: vi.fn().mockReturnValue(['owner']),
3435
}));
3536
vi.mock('@/lib/permissions.server', () => ({
3637
resolveUserPermissions: vi.fn().mockResolvedValue([]),
@@ -44,7 +45,7 @@ vi.mock('next/dynamic', () => ({
4445
vi.mock('@aws-sdk/client-s3', () => ({ GetObjectCommand: vi.fn() }));
4546
vi.mock('@aws-sdk/s3-request-presigner', () => ({ getSignedUrl: vi.fn() }));
4647

47-
import { createMockSession, setupAuthMocks } from '@/test-utils/mocks/auth';
48+
import { createMockSession, mockAuthApi, setupAuthMocks } from '@/test-utils/mocks/auth';
4849
import { mockDb } from '@/test-utils/mocks/db';
4950

5051
const { default: Layout } = await import('./layout');
@@ -70,10 +71,10 @@ describe('Layout activeOrganizationId sync', () => {
7071
deactivated: false,
7172
});
7273
mockDb.onboarding.findFirst.mockResolvedValue(null);
73-
mockDb.session.update.mockResolvedValue({});
74+
mockAuthApi.setActiveOrganization.mockResolvedValue({});
7475
});
7576

76-
it('should update session directly in DB when session org differs from URL org', async () => {
77+
it('should call setActiveOrganization via auth API when session org differs from URL org', async () => {
7778
setupAuthMocks({
7879
session: createMockSession({ id: sessionId, activeOrganizationId: 'org_other' }),
7980
});
@@ -83,13 +84,13 @@ describe('Layout activeOrganizationId sync', () => {
8384
params: Promise.resolve({ orgId: requestedOrgId }),
8485
});
8586

86-
expect(mockDb.session.update).toHaveBeenCalledWith({
87-
where: { id: sessionId },
88-
data: { activeOrganizationId: requestedOrgId },
87+
expect(mockAuthApi.setActiveOrganization).toHaveBeenCalledWith({
88+
headers: expect.anything(),
89+
body: { organizationId: requestedOrgId },
8990
});
9091
});
9192

92-
it('should update session when activeOrganizationId is null', async () => {
93+
it('should call setActiveOrganization when activeOrganizationId is null', async () => {
9394
setupAuthMocks({
9495
session: createMockSession({ id: sessionId, activeOrganizationId: null }),
9596
});
@@ -99,13 +100,13 @@ describe('Layout activeOrganizationId sync', () => {
99100
params: Promise.resolve({ orgId: requestedOrgId }),
100101
});
101102

102-
expect(mockDb.session.update).toHaveBeenCalledWith({
103-
where: { id: sessionId },
104-
data: { activeOrganizationId: requestedOrgId },
103+
expect(mockAuthApi.setActiveOrganization).toHaveBeenCalledWith({
104+
headers: expect.anything(),
105+
body: { organizationId: requestedOrgId },
105106
});
106107
});
107108

108-
it('should NOT update session when session org matches URL org', async () => {
109+
it('should NOT call setActiveOrganization when session org matches URL org', async () => {
109110
setupAuthMocks({
110111
session: createMockSession({ id: sessionId, activeOrganizationId: requestedOrgId }),
111112
});
@@ -115,14 +116,27 @@ describe('Layout activeOrganizationId sync', () => {
115116
params: Promise.resolve({ orgId: requestedOrgId }),
116117
});
117118

119+
expect(mockAuthApi.setActiveOrganization).not.toHaveBeenCalled();
120+
});
121+
122+
it('should not do a direct DB session update', async () => {
123+
setupAuthMocks({
124+
session: createMockSession({ id: sessionId, activeOrganizationId: 'org_other' }),
125+
});
126+
127+
await Layout({
128+
children: null,
129+
params: Promise.resolve({ orgId: requestedOrgId }),
130+
});
131+
118132
expect(mockDb.session.update).not.toHaveBeenCalled();
119133
});
120134

121-
it('should continue rendering even if session update fails', async () => {
135+
it('should continue rendering even if setActiveOrganization fails', async () => {
122136
setupAuthMocks({
123137
session: createMockSession({ id: sessionId, activeOrganizationId: 'org_other' }),
124138
});
125-
mockDb.session.update.mockRejectedValue(new Error('db update failed'));
139+
mockAuthApi.setActiveOrganization.mockRejectedValue(new Error('API call failed'));
126140

127141
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
128142

apps/app/src/app/(app)/[orgId]/layout.tsx

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -68,16 +68,13 @@ export default async function Layout({
6868
}
6969

7070
// Sync activeOrganizationId if it doesn't match the URL's orgId.
71-
// Direct DB update instead of HTTP call to avoid race conditions:
72-
// Next.js renders layouts and pages in parallel, so child pages may call
73-
// serverApi before an HTTP-based sync completes. A direct DB write is faster
74-
// and membership has already been validated above.
71+
// Uses better-auth's API so both server and client-side session state stay in sync.
7572
const currentActiveOrgId = session.session.activeOrganizationId;
7673
if (!currentActiveOrgId || currentActiveOrgId !== requestedOrgId) {
7774
try {
78-
await db.session.update({
79-
where: { id: session.session.id },
80-
data: { activeOrganizationId: requestedOrgId },
75+
await auth.api.setActiveOrganization({
76+
headers: requestHeaders,
77+
body: { organizationId: requestedOrgId },
8178
});
8279
} catch (error) {
8380
console.error('[Layout] Failed to sync activeOrganizationId:', error);
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { render, screen } from '@testing-library/react';
2+
import { beforeEach, describe, expect, it, vi } from 'vitest';
3+
4+
// Mock the task API hooks
5+
const mockRefreshAttachments = vi.fn();
6+
const mockUploadAttachment = vi.fn();
7+
const mockGetDownloadUrl = vi.fn();
8+
const mockDeleteAttachment = vi.fn();
9+
10+
vi.mock('@/hooks/use-tasks-api', () => ({
11+
useTaskAttachments: vi.fn(),
12+
useTaskAttachmentActions: vi.fn(() => ({
13+
uploadAttachment: mockUploadAttachment,
14+
getDownloadUrl: mockGetDownloadUrl,
15+
deleteAttachment: mockDeleteAttachment,
16+
})),
17+
}));
18+
19+
// Mock UI components to simplify rendering
20+
vi.mock('@trycompai/ui/button', () => ({
21+
Button: ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) => (
22+
<button {...props}>{children}</button>
23+
),
24+
}));
25+
vi.mock('@trycompai/ui/dialog', () => ({
26+
Dialog: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
27+
DialogContent: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
28+
DialogDescription: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
29+
DialogFooter: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
30+
DialogHeader: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
31+
DialogTitle: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
32+
}));
33+
34+
import { useTaskAttachments } from '@/hooks/use-tasks-api';
35+
import { TaskBody } from './TaskBody';
36+
37+
const mockUseTaskAttachments = vi.mocked(useTaskAttachments);
38+
39+
describe('TaskBody', () => {
40+
beforeEach(() => {
41+
vi.clearAllMocks();
42+
});
43+
44+
it('should show upload dropzone even when attachments are loading', () => {
45+
mockUseTaskAttachments.mockReturnValue({
46+
data: undefined,
47+
error: undefined,
48+
isLoading: true,
49+
mutate: mockRefreshAttachments,
50+
isValidating: false,
51+
});
52+
53+
render(<TaskBody taskId="tsk_123" />);
54+
55+
expect(screen.getByText('Drag and drop files here')).toBeInTheDocument();
56+
});
57+
58+
it('should show upload dropzone when attachments data is undefined (SWR key is null)', () => {
59+
mockUseTaskAttachments.mockReturnValue({
60+
data: undefined,
61+
error: undefined,
62+
isLoading: false,
63+
mutate: mockRefreshAttachments,
64+
isValidating: false,
65+
});
66+
67+
render(<TaskBody taskId="tsk_123" />);
68+
69+
expect(screen.getByText('Drag and drop files here')).toBeInTheDocument();
70+
});
71+
72+
it('should show upload dropzone when attachments have loaded successfully', () => {
73+
mockUseTaskAttachments.mockReturnValue({
74+
data: { data: [], status: 200 } as never,
75+
error: undefined,
76+
isLoading: false,
77+
mutate: mockRefreshAttachments,
78+
isValidating: false,
79+
});
80+
81+
render(<TaskBody taskId="tsk_123" />);
82+
83+
expect(screen.getByText('Drag and drop files here')).toBeInTheDocument();
84+
});
85+
86+
it('should show upload dropzone when attachments fail to load', () => {
87+
mockUseTaskAttachments.mockReturnValue({
88+
data: undefined,
89+
error: new Error('Failed to fetch'),
90+
isLoading: false,
91+
mutate: mockRefreshAttachments,
92+
isValidating: false,
93+
});
94+
95+
render(<TaskBody taskId="tsk_123" />);
96+
97+
expect(screen.getByText('Drag and drop files here')).toBeInTheDocument();
98+
expect(screen.getByText('Failed to load attachments. Please try again.')).toBeInTheDocument();
99+
});
100+
101+
it('should show loading skeletons while attachments are loading', () => {
102+
mockUseTaskAttachments.mockReturnValue({
103+
data: undefined,
104+
error: undefined,
105+
isLoading: true,
106+
mutate: mockRefreshAttachments,
107+
isValidating: false,
108+
});
109+
110+
const { container } = render(<TaskBody taskId="tsk_123" />);
111+
112+
const skeletons = container.querySelectorAll('.animate-pulse');
113+
expect(skeletons.length).toBe(3);
114+
});
115+
116+
it('should not show loading skeletons when attachments have loaded', () => {
117+
mockUseTaskAttachments.mockReturnValue({
118+
data: { data: [], status: 200 } as never,
119+
error: undefined,
120+
isLoading: false,
121+
mutate: mockRefreshAttachments,
122+
isValidating: false,
123+
});
124+
125+
const { container } = render(<TaskBody taskId="tsk_123" />);
126+
127+
const skeletons = container.querySelectorAll('.animate-pulse');
128+
expect(skeletons.length).toBe(0);
129+
});
130+
});

0 commit comments

Comments
 (0)