feat(db): mesh data model — meshes, members, invites, audit log
- pgSchema "mesh" with 4 tables isolating the peer mesh domain - Enums: visibility, transport, tier, role - audit_log is metadata-only (E2E encryption enforced at broker/client) - Cascade on mesh delete, soft-delete via archivedAt/revokedAt Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
14
apps/web/src/lib/api/client.tsx
Normal file
14
apps/web/src/lib/api/client.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { hc } from "hono/client";
|
||||
|
||||
import { getBaseUrl } from "./utils";
|
||||
|
||||
import type { AppRouter } from "@turbostarter/api";
|
||||
|
||||
export const { api } = hc<AppRouter>(getBaseUrl(), {
|
||||
headers: {
|
||||
"x-client-platform": "web-client",
|
||||
},
|
||||
init: {
|
||||
credentials: "include",
|
||||
},
|
||||
});
|
||||
13
apps/web/src/lib/api/server.ts
Normal file
13
apps/web/src/lib/api/server.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { hc } from "hono/client";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
import { getBaseUrl } from "./utils";
|
||||
|
||||
import type { AppRouter } from "@turbostarter/api";
|
||||
|
||||
export const { api } = hc<AppRouter>(getBaseUrl(), {
|
||||
headers: async () => ({
|
||||
...Object.fromEntries((await headers()).entries()),
|
||||
"x-client-platform": "web-server",
|
||||
}),
|
||||
});
|
||||
9
apps/web/src/lib/api/utils.ts
Normal file
9
apps/web/src/lib/api/utils.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import env from "env.config";
|
||||
|
||||
export const getBaseUrl = () => {
|
||||
if (typeof window !== "undefined") return window.location.origin;
|
||||
if (env.NEXT_PUBLIC_URL) return env.NEXT_PUBLIC_URL;
|
||||
if (env.VERCEL_URL) return `https://${env.VERCEL_URL}`;
|
||||
// eslint-disable-next-line no-restricted-properties, turbo/no-undeclared-env-vars
|
||||
return `http://localhost:${process.env.PORT ?? 3000}`;
|
||||
};
|
||||
10
apps/web/src/lib/auth/client.ts
Normal file
10
apps/web/src/lib/auth/client.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { createClient } from "@turbostarter/auth/client/web";
|
||||
|
||||
export const authClient = createClient({
|
||||
fetchOptions: {
|
||||
headers: {
|
||||
"x-client-platform": "web-client",
|
||||
},
|
||||
throw: true,
|
||||
},
|
||||
});
|
||||
63
apps/web/src/lib/auth/server.ts
Normal file
63
apps/web/src/lib/auth/server.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { headers } from "next/headers";
|
||||
import { cache } from "react";
|
||||
|
||||
import { auth } from "@turbostarter/auth/server";
|
||||
import { logger } from "@turbostarter/shared/logger";
|
||||
|
||||
const getHeaders = async () => {
|
||||
const newHeaders = new Headers(await headers());
|
||||
newHeaders.set("x-client-platform", "web-server");
|
||||
return newHeaders;
|
||||
};
|
||||
|
||||
export const getSession = cache(async () => {
|
||||
const data = await auth.api.getSession({
|
||||
headers: await getHeaders(),
|
||||
});
|
||||
|
||||
return {
|
||||
session: data?.session ?? null,
|
||||
user: data?.user ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
export const getOrganization = cache(
|
||||
async ({ id, slug }: { slug?: string; id?: string }) => {
|
||||
try {
|
||||
return await auth.api.getFullOrganization({
|
||||
query: {
|
||||
organizationId: id,
|
||||
organizationSlug: slug,
|
||||
},
|
||||
headers: await getHeaders(),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const getInvitation = cache(async ({ id }: { id: string }) => {
|
||||
try {
|
||||
return await auth.api.getInvitation({
|
||||
query: {
|
||||
id,
|
||||
},
|
||||
headers: await getHeaders(),
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
export const getUser = cache(async ({ id }: { id: string }) => {
|
||||
try {
|
||||
return await auth.api.getUser({
|
||||
query: { id },
|
||||
headers: await getHeaders(),
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
35
apps/web/src/lib/constants.ts
Normal file
35
apps/web/src/lib/constants.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
|
||||
/**
|
||||
* Main application navigation items for the sidebar.
|
||||
* Each app has a title (i18n key), href, and icon.
|
||||
*/
|
||||
export const APPS = [
|
||||
{
|
||||
title: "chat",
|
||||
href: pathsConfig.apps.chat.index,
|
||||
icon: Icons.MessagesSquare,
|
||||
},
|
||||
{
|
||||
title: "image",
|
||||
href: pathsConfig.apps.image.index,
|
||||
icon: Icons.Image,
|
||||
},
|
||||
{
|
||||
title: "tts",
|
||||
href: pathsConfig.apps.tts,
|
||||
icon: Icons.AudioLines,
|
||||
},
|
||||
{
|
||||
title: "pdf",
|
||||
href: pathsConfig.apps.pdf.index,
|
||||
icon: Icons.FileText,
|
||||
},
|
||||
{
|
||||
title: "agent",
|
||||
href: pathsConfig.apps.agent,
|
||||
icon: Icons.Sparkles,
|
||||
},
|
||||
] as const;
|
||||
116
apps/web/src/lib/metadata.ts
Normal file
116
apps/web/src/lib/metadata.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { isKey } from "@turbostarter/i18n";
|
||||
import { getTranslation } from "@turbostarter/i18n/server";
|
||||
|
||||
import { appConfig } from "~/config/app";
|
||||
|
||||
import type { TranslationKey } from "@turbostarter/i18n";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
|
||||
type OpenGraphType =
|
||||
| "article"
|
||||
| "book"
|
||||
| "music.song"
|
||||
| "music.album"
|
||||
| "music.playlist"
|
||||
| "music.radio_station"
|
||||
| "profile"
|
||||
| "website"
|
||||
| "video.tv_show"
|
||||
| "video.other"
|
||||
| "video.movie"
|
||||
| "video.episode";
|
||||
|
||||
interface SeoProps {
|
||||
readonly title?: TranslationKey | (string & Record<never, never>);
|
||||
readonly description?: TranslationKey | (string & Record<never, never>);
|
||||
readonly image?: string;
|
||||
readonly url?: string;
|
||||
readonly canonical?: string;
|
||||
readonly type?: OpenGraphType;
|
||||
readonly images?: {
|
||||
url: string;
|
||||
width: number;
|
||||
height: number;
|
||||
alt?: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
const SITE_NAME_SEPARATOR = " | ";
|
||||
export const SITE_NAME_TEMPLATE = `%s${SITE_NAME_SEPARATOR}${appConfig.name}`;
|
||||
|
||||
const DEFAULT_IMAGE = {
|
||||
url: `${appConfig.url}/opengraph-image.png`,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: appConfig.name,
|
||||
};
|
||||
|
||||
export const getMetadata =
|
||||
(
|
||||
{
|
||||
title,
|
||||
description = "common:product.description",
|
||||
url,
|
||||
canonical,
|
||||
images = [DEFAULT_IMAGE],
|
||||
type = "website",
|
||||
} = {} as SeoProps,
|
||||
) =>
|
||||
async ({
|
||||
params,
|
||||
}: {
|
||||
params?: Promise<{ locale: string }>;
|
||||
}): Promise<Metadata> => {
|
||||
const { t, i18n } = await getTranslation({
|
||||
locale: (await params)?.locale,
|
||||
});
|
||||
|
||||
const common = {
|
||||
...(title && {
|
||||
title: isKey(title, i18n) ? (t(title) as string) : title,
|
||||
}),
|
||||
description: isKey(description, i18n)
|
||||
? (t(description) as string)
|
||||
: description,
|
||||
};
|
||||
|
||||
return {
|
||||
...common,
|
||||
openGraph: {
|
||||
...common,
|
||||
url: url ?? canonical ?? appConfig.url,
|
||||
siteName: appConfig.name,
|
||||
type,
|
||||
images,
|
||||
},
|
||||
...{
|
||||
...(canonical && {
|
||||
alternates: {
|
||||
canonical,
|
||||
},
|
||||
}),
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image" as const,
|
||||
images,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const DEFAULT_METADATA: Metadata = {
|
||||
...(await getMetadata()({
|
||||
params: Promise.resolve({ locale: appConfig.locale }),
|
||||
})),
|
||||
title: {
|
||||
template: SITE_NAME_TEMPLATE,
|
||||
default: appConfig.name,
|
||||
},
|
||||
metadataBase: appConfig.url ? new URL(appConfig.url) : null,
|
||||
};
|
||||
|
||||
export const DEFAULT_VIEWPORT: Viewport = {
|
||||
themeColor: [
|
||||
{ media: "(prefers-color-scheme: light)", color: "#ffffff" },
|
||||
{ media: "(prefers-color-scheme: dark)", color: "#030712" },
|
||||
],
|
||||
};
|
||||
30
apps/web/src/lib/providers/analytics.tsx
Normal file
30
apps/web/src/lib/providers/analytics.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { identify, Provider, reset } from "@turbostarter/analytics-web";
|
||||
|
||||
import { authClient } from "~/lib/auth/client";
|
||||
|
||||
export const AnalyticsProvider = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const session = authClient.useSession();
|
||||
|
||||
useEffect(() => {
|
||||
if (session.isPending) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.data?.user) {
|
||||
const { id, email, name } = session.data.user;
|
||||
identify(id, { email, name });
|
||||
} else {
|
||||
reset();
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
return <Provider>{children}</Provider>;
|
||||
};
|
||||
25
apps/web/src/lib/providers/monitoring.tsx
Normal file
25
apps/web/src/lib/providers/monitoring.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { identify } from "@turbostarter/monitoring-web";
|
||||
|
||||
import { authClient } from "~/lib/auth/client";
|
||||
|
||||
export const MonitoringProvider = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const session = authClient.useSession();
|
||||
|
||||
useEffect(() => {
|
||||
if (session.isPending) {
|
||||
return;
|
||||
}
|
||||
|
||||
identify(session.data?.user ?? null);
|
||||
}, [session]);
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
34
apps/web/src/lib/providers/providers.tsx
Normal file
34
apps/web/src/lib/providers/providers.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { NuqsAdapter } from "nuqs/adapters/next/app";
|
||||
import { memo } from "react";
|
||||
|
||||
import { I18nProvider } from "@turbostarter/i18n";
|
||||
|
||||
import { appConfig } from "~/config/app";
|
||||
import { QueryClientProvider } from "~/lib/query/client";
|
||||
|
||||
import { AnalyticsProvider } from "./analytics";
|
||||
import { MonitoringProvider } from "./monitoring";
|
||||
import { ThemeProvider } from "./theme";
|
||||
|
||||
interface ProvidersProps {
|
||||
readonly children: React.ReactNode;
|
||||
readonly locale: string;
|
||||
}
|
||||
|
||||
export const Providers = memo<ProvidersProps>(({ children, locale }) => {
|
||||
return (
|
||||
<I18nProvider locale={locale} defaultLocale={appConfig.locale}>
|
||||
<QueryClientProvider>
|
||||
<NuqsAdapter>
|
||||
<AnalyticsProvider>
|
||||
<MonitoringProvider>
|
||||
<ThemeProvider>{children}</ThemeProvider>
|
||||
</MonitoringProvider>
|
||||
</AnalyticsProvider>
|
||||
</NuqsAdapter>
|
||||
</QueryClientProvider>
|
||||
</I18nProvider>
|
||||
);
|
||||
});
|
||||
|
||||
Providers.displayName = "Providers";
|
||||
50
apps/web/src/lib/providers/theme.tsx
Normal file
50
apps/web/src/lib/providers/theme.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import { ThemeProvider as NextThemeProvider } from "next-themes";
|
||||
import { memo, useEffect } from "react";
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
import { appConfig } from "~/config/app";
|
||||
|
||||
import type { ThemeConfig } from "@turbostarter/ui";
|
||||
|
||||
export const useThemeConfig = create<{
|
||||
config: Omit<ThemeConfig, "mode">;
|
||||
setConfig: (config: Omit<ThemeConfig, "mode">) => void;
|
||||
}>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
config: appConfig.theme,
|
||||
setConfig: (config) => set({ config }),
|
||||
}),
|
||||
{
|
||||
name: "theme-config",
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
interface ThemeProviderProps {
|
||||
readonly children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ThemeProvider = memo<ThemeProviderProps>(({ children }) => {
|
||||
const config = useThemeConfig((s) => s.config);
|
||||
|
||||
useEffect(() => {
|
||||
document.body.dataset.theme = config.color;
|
||||
}, [config.color]);
|
||||
|
||||
return (
|
||||
<NextThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme={appConfig.theme.mode}
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
{children}
|
||||
</NextThemeProvider>
|
||||
);
|
||||
});
|
||||
|
||||
ThemeProvider.displayName = "ThemeProvider";
|
||||
30
apps/web/src/lib/query/client.tsx
Normal file
30
apps/web/src/lib/query/client.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { QueryClientProvider as TanstackQueryClientProvider } from "@tanstack/react-query";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
|
||||
import { createQueryClient } from "./utils";
|
||||
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
let clientQueryClientSingleton: QueryClient | undefined = undefined;
|
||||
export const getQueryClient = () => {
|
||||
if (typeof window === "undefined") {
|
||||
// Server: always make a new query client
|
||||
return createQueryClient();
|
||||
} else {
|
||||
// Browser: use singleton pattern to keep the same query client
|
||||
return (clientQueryClientSingleton ??= createQueryClient());
|
||||
}
|
||||
};
|
||||
|
||||
export function QueryClientProvider(props: { children: React.ReactNode }) {
|
||||
const queryClient = getQueryClient();
|
||||
|
||||
return (
|
||||
<TanstackQueryClientProvider client={queryClient}>
|
||||
{props.children}
|
||||
<ReactQueryDevtools />
|
||||
</TanstackQueryClientProvider>
|
||||
);
|
||||
}
|
||||
5
apps/web/src/lib/query/server.ts
Normal file
5
apps/web/src/lib/query/server.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { cache } from "react";
|
||||
|
||||
import { createQueryClient } from "./utils";
|
||||
|
||||
export const getQueryClient = cache(createQueryClient);
|
||||
33
apps/web/src/lib/query/utils.ts
Normal file
33
apps/web/src/lib/query/utils.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
QueryClient,
|
||||
defaultShouldDehydrateQuery,
|
||||
} from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { logger } from "@turbostarter/shared/logger";
|
||||
|
||||
export const createQueryClient = () =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
// With SSR, we usually want to set some default staleTime
|
||||
// above 0 to avoid refetching immediately on the client
|
||||
staleTime: 60 * 1000,
|
||||
},
|
||||
dehydrate: {
|
||||
shouldDehydrateQuery: (query) =>
|
||||
defaultShouldDehydrateQuery(query) ||
|
||||
query.state.status === "pending",
|
||||
},
|
||||
mutations: {
|
||||
onError: (error: Error | { error: Error }) => {
|
||||
if ("error" in error) {
|
||||
error = error.error;
|
||||
}
|
||||
|
||||
logger.error(error);
|
||||
toast.error(error.message);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user