Skip to content

Commit 2b4be09

Browse files
committed
feat: orgs provider
1 parent 5181852 commit 2b4be09

File tree

9 files changed

+163
-29
lines changed

9 files changed

+163
-29
lines changed

apps/dashboard/app/(main)/organizations/components/organizations-list.tsx

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import relativeTime from "dayjs/plugin/relativeTime";
77
import { useRouter } from "next/navigation";
88
import { useState } from "react";
99
import { toast } from "sonner";
10+
import type { Organization } from "@/components/providers/organizations-provider";
1011
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
1112
import { Badge } from "@/components/ui/badge";
1213
import { Card, CardContent } from "@/components/ui/card";
@@ -16,13 +17,11 @@ import { EmptyState } from "./empty-state";
1617

1718
dayjs.extend(relativeTime);
1819

19-
interface OrganizationsListProps {
20-
organizations: ReturnType<typeof authClient.useListOrganizations>["data"];
21-
activeOrganization: ReturnType<
22-
typeof authClient.useActiveOrganization
23-
>["data"];
20+
type OrganizationsListProps = {
21+
organizations: Organization[] | null | undefined;
22+
activeOrganization: Organization | null | undefined;
2423
isLoading: boolean;
25-
}
24+
};
2625

2726
function OrganizationSkeleton() {
2827
return (

apps/dashboard/app/(main)/organizations/page.tsx

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"use client";
22

3-
import { authClient } from "@databuddy/auth/client";
43
import { Suspense } from "react";
4+
import { useOrganizationsContext } from "@/components/providers/organizations-provider";
55
import { Card, CardContent } from "@/components/ui/card";
66
import { Skeleton } from "@/components/ui/skeleton";
77
import { OrganizationsList } from "./components/organizations-list";
@@ -37,12 +37,8 @@ function OrganizationsSkeleton() {
3737
}
3838

3939
export default function OrganizationsPage() {
40-
const { data: organizations, isPending: isOrganizationsPending } =
41-
authClient.useListOrganizations();
42-
const { data: activeOrganization, isPending: isActiveOrganizationPending } =
43-
authClient.useActiveOrganization();
44-
45-
const isLoading = isOrganizationsPending || isActiveOrganizationPending;
40+
const { organizations, activeOrganization, isLoading } =
41+
useOrganizationsContext();
4642

4743
return (
4844
<div className="flex h-full flex-col">

apps/dashboard/app/providers.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { AutumnProvider } from "autumn-js/react";
77
import { ThemeProvider } from "next-themes";
88
import { NuqsAdapter } from "nuqs/adapters/next/app";
99
import { useState } from "react";
10+
import { OrganizationsProvider } from "@/components/providers/organizations-provider";
1011

1112
const defaultQueryClientOptions = {
1213
defaultOptions: {
@@ -51,7 +52,9 @@ export default function Providers({ children }: { children: React.ReactNode }) {
5152
process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001"
5253
}
5354
>
54-
<NuqsAdapter>{children}</NuqsAdapter>
55+
<OrganizationsProvider>
56+
<NuqsAdapter>{children}</NuqsAdapter>
57+
</OrganizationsProvider>
5558
</AutumnProvider>
5659
</FlagsProviderWrapper>
5760
</QueryClientProvider>

apps/dashboard/components/layout/organization-selector.tsx

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@ import {
88
SpinnerGapIcon,
99
UserIcon,
1010
} from "@phosphor-icons/react";
11-
import { useState } from "react";
11+
import { useEffect, useRef, useState } from "react";
1212
import { toast } from "sonner";
1313
import { CreateOrganizationDialog } from "@/components/organizations/create-organization-dialog";
14+
import { useOrganizationsContext } from "@/components/providers/organizations-provider";
1415
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
1516
import { Button } from "@/components/ui/button";
1617
import {
@@ -125,16 +126,40 @@ function OrganizationSelectorTrigger({
125126
}
126127

127128
export function OrganizationSelector() {
128-
const { data: organizations, isPending: isLoadingOrgs } =
129-
authClient.useListOrganizations();
130-
const { data: activeOrganization, isPending: isLoadingActive } =
131-
authClient.useActiveOrganization();
129+
const { organizations, activeOrganization, isLoading } =
130+
useOrganizationsContext();
132131
const [isOpen, setIsOpen] = useState(false);
133132
const [showCreateDialog, setShowCreateDialog] = useState(false);
134133
const [query, setQuery] = useState("");
135134
const [isSwitching, setIsSwitching] = useState(false);
136135

137-
const isLoading = isLoadingOrgs || isLoadingActive;
136+
const prevStateRef = useRef<{
137+
isLoading: boolean;
138+
organizationsCount: number;
139+
hasActiveOrg: boolean;
140+
activeOrgName?: string;
141+
} | null>(null);
142+
143+
useEffect(() => {
144+
const currentState = {
145+
isLoading,
146+
organizationsCount: organizations.length,
147+
hasActiveOrg: !!activeOrganization,
148+
activeOrgName: activeOrganization?.name,
149+
};
150+
151+
const prevState = prevStateRef.current;
152+
if (
153+
!prevState ||
154+
prevState.isLoading !== currentState.isLoading ||
155+
prevState.organizationsCount !== currentState.organizationsCount ||
156+
prevState.hasActiveOrg !== currentState.hasActiveOrg ||
157+
prevState.activeOrgName !== currentState.activeOrgName
158+
) {
159+
console.log("[OrganizationSelector] State changed:", currentState);
160+
prevStateRef.current = currentState;
161+
}
162+
}, [isLoading, organizations.length, activeOrganization]);
138163

139164
const handleSelectOrganization = async (organizationId: string | null) => {
140165
if (organizationId === activeOrganization?.id) {
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"use client";
2+
3+
import { authClient } from "@databuddy/auth/client";
4+
import { useAtom } from "jotai";
5+
import { type ReactNode, useEffect } from "react";
6+
import {
7+
activeOrganizationAtom,
8+
getOrganizationBySlugAtom,
9+
isLoadingOrganizationsAtom,
10+
organizationsAtom,
11+
} from "@/stores/jotai/organizationsAtoms";
12+
13+
export type Organization = NonNullable<
14+
ReturnType<typeof authClient.useListOrganizations>["data"]
15+
>[number];
16+
17+
export function OrganizationsProvider({ children }: { children: ReactNode }) {
18+
const { data: organizationsData, isPending: isLoadingOrgs } =
19+
authClient.useListOrganizations();
20+
const { data: activeOrganization, isPending: isLoadingActive } =
21+
authClient.useActiveOrganization();
22+
23+
const [, setOrganizations] = useAtom(organizationsAtom);
24+
const [, setActiveOrganization] = useAtom(activeOrganizationAtom);
25+
const [, setIsLoading] = useAtom(isLoadingOrganizationsAtom);
26+
27+
useEffect(() => {
28+
setOrganizations(organizationsData ?? []);
29+
setActiveOrganization(activeOrganization ?? null);
30+
setIsLoading(isLoadingOrgs || isLoadingActive);
31+
}, [
32+
organizationsData,
33+
activeOrganization,
34+
isLoadingOrgs,
35+
isLoadingActive,
36+
setOrganizations,
37+
setActiveOrganization,
38+
setIsLoading,
39+
]);
40+
41+
return <>{children}</>;
42+
}
43+
44+
export function useOrganizationsContext() {
45+
const [organizations] = useAtom(organizationsAtom);
46+
const [activeOrganization] = useAtom(activeOrganizationAtom);
47+
const [isLoading] = useAtom(isLoadingOrganizationsAtom);
48+
const [getOrganizationBySlug] = useAtom(getOrganizationBySlugAtom);
49+
50+
return {
51+
organizations,
52+
activeOrganization,
53+
isLoading,
54+
getOrganization: getOrganizationBySlug,
55+
};
56+
}

apps/dashboard/components/website-dialog.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
"use client";
22

3-
import { authClient } from "@databuddy/auth/client";
43
import type { InferSelectModel, websites } from "@databuddy/db";
54
import { zodResolver } from "@hookform/resolvers/zod";
65
import { LoaderCircle } from "lucide-react";
76
import { useEffect, useRef } from "react";
87
import { useForm } from "react-hook-form";
98
import { toast } from "sonner";
109
import { z } from "zod";
10+
import { useOrganizationsContext } from "@/components/providers/organizations-provider";
1111
import { Button } from "@/components/ui/button";
1212
import {
1313
Dialog,
@@ -70,7 +70,7 @@ export function WebsiteDialog({
7070
onSave,
7171
}: WebsiteDialogProps) {
7272
const isEditing = !!website;
73-
const { data: activeOrganization } = authClient.useActiveOrganization();
73+
const { activeOrganization } = useOrganizationsContext();
7474
const formRef = useRef<HTMLFormElement>(null);
7575

7676
const createWebsiteMutation = useCreateWebsite();

apps/dashboard/hooks/use-websites.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
"use client";
22

3-
import { authClient } from "@databuddy/auth/client";
43
import type { InferSelectModel, websites } from "@databuddy/db";
54
import type { ProcessedMiniChartData } from "@databuddy/shared/types/website";
65
import type { QueryKey } from "@tanstack/react-query";
76
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
7+
import { useOrganizationsContext } from "@/components/providers/organizations-provider";
88
import { orpc } from "@/lib/orpc";
99

1010
export type Website = InferSelectModel<typeof websites>;
@@ -72,8 +72,8 @@ const removeWebsiteFromList = (
7272
};
7373

7474
export function useWebsites() {
75-
const { data: activeOrganization, isPending: isLoadingOrganization } =
76-
authClient.useActiveOrganization();
75+
const { activeOrganization, isLoading: isLoadingOrganization } =
76+
useOrganizationsContext();
7777

7878
const query = useQuery({
7979
...orpc.websites.listWithCharts.queryOptions({
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { atom } from "jotai";
2+
import type { Organization } from "@/components/providers/organizations-provider";
3+
4+
export const organizationsAtom = atom<Organization[]>([]);
5+
export const activeOrganizationAtom = atom<Organization | null>(null);
6+
export const isLoadingOrganizationsAtom = atom<boolean>(true);
7+
8+
export const getOrganizationBySlugAtom = atom((get) => (orgSlug: string) => {
9+
const orgs = get(organizationsAtom);
10+
return orgs.find((org) => org.slug === orgSlug);
11+
});

packages/rpc/src/routers/websites.ts

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
import { websitesApi } from "@databuddy/auth";
2-
import { chQuery } from "@databuddy/db";
2+
import {
3+
and,
4+
chQuery,
5+
eq,
6+
inArray,
7+
isNull,
8+
member,
9+
or,
10+
websites,
11+
} from "@databuddy/db";
312
import { createDrizzleCache, redis } from "@databuddy/redis";
413
import { logger } from "@databuddy/shared/logger";
514
import type { ProcessedMiniChartData } from "@databuddy/shared/types/website";
@@ -176,6 +185,41 @@ export const websitesRouter = {
176185
});
177186
}),
178187

188+
listAll: protectedProcedure.handler(({ context }) => {
189+
const listAllCacheKey = `listAll:${context.user.id}`;
190+
return websiteCache.withCache({
191+
key: listAllCacheKey,
192+
ttl: CACHE_DURATION,
193+
tables: ["websites"],
194+
queryFn: async () => {
195+
// 1. Get user's organization memberships
196+
const userMemberships = await context.db.query.member.findMany({
197+
where: eq(member.userId, context.user.id),
198+
columns: { organizationId: true },
199+
});
200+
const orgIds = userMemberships.map((m) => m.organizationId);
201+
202+
// 2. Build filter: (userId = me AND orgId is null) OR (orgId IN myOrgs)
203+
const personalSites = and(
204+
eq(websites.userId, context.user.id),
205+
isNull(websites.organizationId)
206+
);
207+
208+
const orgSites =
209+
orgIds.length > 0
210+
? inArray(websites.organizationId, orgIds)
211+
: undefined;
212+
213+
const whereClause = orgSites ? or(personalSites, orgSites) : personalSites;
214+
215+
return context.db.query.websites.findMany({
216+
where: whereClause,
217+
orderBy: (table, { desc }) => [desc(table.createdAt)],
218+
});
219+
},
220+
});
221+
}),
222+
179223
listWithCharts: protectedProcedure
180224
.input(z.object({ organizationId: z.string().optional() }).default({}))
181225
.handler(({ context, input }) => {
@@ -202,16 +246,16 @@ export const websitesRouter = {
202246
input.organizationId
203247
);
204248

205-
const websitesList = await context.db.query.websites.findMany({
249+
const websites = await context.db.query.websites.findMany({
206250
where: whereClause,
207251
orderBy: (table, { desc }) => [desc(table.createdAt)],
208252
});
209253

210-
const websiteIds = websitesList.map((site) => site.id);
254+
const websiteIds = websites.map((site) => site.id);
211255
const chartData = await fetchChartData(websiteIds);
212256

213257
return {
214-
websites: websitesList,
258+
websites,
215259
chartData,
216260
};
217261
},

0 commit comments

Comments
 (0)