Your Profile
signOut({ callbackUrl: "/" })}
- className="block w-full px-4 py-2 text-sm text-left text-text-primary hover:bg-card-hover"
+ className="text-text-primary hover:bg-card-hover block w-full px-4 py-2 text-left text-sm"
>
Sign out
diff --git a/apps/web/src/components/auth/permission/client/gate.tsx b/apps/web/src/components/auth/permission/client/gate.tsx
new file mode 100644
index 0000000..f34d4d8
--- /dev/null
+++ b/apps/web/src/components/auth/permission/client/gate.tsx
@@ -0,0 +1,181 @@
+"use client";
+
+import { ReactNode, Children, isValidElement, useEffect } from "react";
+import { useRouter, usePathname } from "next/navigation";
+import { PermissionIdentifier } from "@repo/db/client";
+import { usePermissions } from "../utils/usePermissions";
+import { isPublicPath } from "../utils/path-utils";
+import { logger } from "~/lib/utils/logger";
+
+// Define prop types for each slot component
+interface AuthorizedProps {
+ children: ReactNode;
+}
+
+interface UnauthorizedProps {
+ children: ReactNode;
+ redirectTo?: string;
+}
+
+interface NotLoggedInProps {
+ children: ReactNode;
+ redirectTo?: string;
+}
+
+// Main component props
+interface ClientPermissionGateProps {
+ /**
+ * The permission to check for.
+ */
+ permission?: PermissionIdentifier;
+
+ /**
+ * The permissions to check for, needs any to be true.
+ */
+ permissions?: PermissionIdentifier[];
+
+ /**
+ * Paths that are exempt from the permission check.
+ */
+ publicPaths?: string[];
+
+ /**
+ * Whether to allow guest access (non-authenticated users)
+ */
+ allowGuests?: boolean;
+
+ /**
+ * The children to render
+ */
+ children: ReactNode;
+}
+
+/**
+ * Client-side permission gate component that checks user permissions
+ * and renders appropriate content based on authorization status.
+ * This creates UI-level access control but does not provide actual security.
+ */
+export function ClientPermissionGate({
+ permission,
+ permissions,
+ publicPaths,
+ allowGuests = false,
+ children,
+}: ClientPermissionGateProps) {
+ // Get permission state from context
+ const { hasPermission, hasAnyPermission, isGuest, isLoading } =
+ usePermissions();
+ const pathname = usePathname();
+ const router = useRouter();
+
+ // Initialize variables to store content and redirects
+ let authorizedContent: ReactNode = null;
+ let unauthorizedContent: ReactNode | null = null;
+ let unauthorizedRedirect: string | undefined;
+ let notLoggedInContent: ReactNode | null = null;
+ let notLoggedInRedirect: string | undefined;
+ let shouldRender = true;
+ let isAuthorized = false;
+ const isLoggedIn = !isGuest;
+
+ // Process each child to find the appropriate slot components
+ Children.forEach(children, (child) => {
+ if (!isValidElement(child)) return;
+
+ // Type assertion with specific component types
+ if (child.type === Authorized) {
+ const props = child.props as AuthorizedProps;
+ authorizedContent = props.children;
+ } else if (child.type === Unauthorized) {
+ const props = child.props as UnauthorizedProps;
+ unauthorizedContent = props.children;
+ unauthorizedRedirect = props.redirectTo;
+ } else if (child.type === NotLoggedIn) {
+ const props = child.props as NotLoggedInProps;
+ notLoggedInContent = props.children;
+ notLoggedInRedirect = props.redirectTo;
+ }
+ });
+
+ // Validate parameters - we set a flag but don't return early
+ if (permission && permissions) {
+ logger.error(
+ "ClientPermissionGate requires either 'permission' or 'permissions' prop, not both."
+ );
+ shouldRender = false;
+ }
+ if (!permission && !permissions) {
+ logger.error(
+ "ClientPermissionGate requires either 'permission' or 'permissions' prop."
+ );
+ shouldRender = false;
+ }
+
+ // Check if path is public
+ const isPublic = isPublicPath(pathname, publicPaths);
+
+ // Determine if user is authorized
+ if (isPublic) {
+ isAuthorized = true;
+ } else if (isGuest && !allowGuests) {
+ isAuthorized = false;
+ } else if (permission) {
+ isAuthorized = hasPermission(permission);
+ } else if (permissions) {
+ isAuthorized = hasAnyPermission(permissions);
+ }
+
+ // Handle redirects with useEffect - this is now unconditional
+ useEffect(() => {
+ if (!isLoading && shouldRender) {
+ if (!isLoggedIn && notLoggedInRedirect) {
+ router.push(notLoggedInRedirect);
+ } else if (isLoggedIn && !isAuthorized && unauthorizedRedirect) {
+ router.push(unauthorizedRedirect);
+ }
+ }
+ }, [
+ isLoading,
+ shouldRender,
+ isLoggedIn,
+ isAuthorized,
+ notLoggedInRedirect,
+ unauthorizedRedirect,
+ router,
+ ]);
+
+ // Return early if validation failed
+ if (!shouldRender) {
+ return null;
+ }
+
+ // Return the appropriate content based on authorization status
+ if (isLoading) {
+ // Optional: Return a loading state or early null to prevent flashes
+ return null;
+ } else if (isAuthorized) {
+ return <>{authorizedContent}>;
+ } else if (isLoggedIn) {
+ return unauthorizedContent ? <>{unauthorizedContent}> : null;
+ } else {
+ return notLoggedInContent ? <>{notLoggedInContent}> : null;
+ }
+}
+
+// Slot components for ClientPermissionGate
+function Authorized({ children }: AuthorizedProps) {
+ return <>{children}>;
+}
+
+function Unauthorized({ children }: UnauthorizedProps) {
+ return <>{children}>;
+}
+
+function NotLoggedIn({ children }: NotLoggedInProps) {
+ return <>{children}>;
+}
+
+// Attach slot components to ClientPermissionGate
+ClientPermissionGate.Authorized = Authorized;
+ClientPermissionGate.Unauthorized = Unauthorized;
+ClientPermissionGate.NotLoggedIn = NotLoggedIn;
diff --git a/apps/web/src/components/auth/permission/client/index.ts b/apps/web/src/components/auth/permission/client/index.ts
new file mode 100644
index 0000000..6ce32a4
--- /dev/null
+++ b/apps/web/src/components/auth/permission/client/index.ts
@@ -0,0 +1,35 @@
+/**
+ * Permission System Entry Point
+ *
+ * This file exports all permission-related components for client.
+ */
+
+// Re-export components with clear namespacing
+import { ClientPermissionGate } from "./gate";
+import { ClientRequirePermission } from "./require";
+import { PermissionProvider } from "../provider";
+import { usePermissions } from "../utils/usePermissions";
+
+// Provider components
+export {
+ /**
+ * Context provider for client-side permission state
+ * Place at the root of your app to provide permission context
+ */
+ PermissionProvider,
+
+ /**
+ * Hook for accessing permissions in client components
+ */
+ usePermissions,
+
+ /**
+ * Client-side permission gate component with slots for authorized/unauthorized states
+ */
+ ClientPermissionGate,
+
+ /**
+ * Client-side simplified permission check
+ */
+ ClientRequirePermission,
+};
diff --git a/apps/web/src/components/auth/permission/client/require.tsx b/apps/web/src/components/auth/permission/client/require.tsx
new file mode 100644
index 0000000..3d90b25
--- /dev/null
+++ b/apps/web/src/components/auth/permission/client/require.tsx
@@ -0,0 +1,105 @@
+"use client";
+
+import { ReactNode, useEffect } from "react";
+import { usePathname } from "next/navigation";
+import { PermissionIdentifier } from "@repo/db/client";
+import { usePermissions } from "../utils/usePermissions";
+import { isPublicPath } from "../utils/path-utils";
+import { logger } from "~/lib/utils/logger";
+
+interface ClientRequirePermissionProps {
+ /**
+ * The single permission to check for.
+ * Use either this or `permissions`, not both.
+ */
+ permission?: PermissionIdentifier;
+
+ /**
+ * An array of permissions to check for. Access is granted if the user has *any* of these.
+ * Use either this or `permission`, not both.
+ */
+ permissions?: PermissionIdentifier[];
+
+ /**
+ * Paths that are exempt from the permission check.
+ */
+ publicPaths?: string[];
+
+ /**
+ * Whether to allow guest access (non-authenticated users)
+ */
+ allowGuests?: boolean;
+
+ /**
+ * Content to show when user is authorized
+ */
+ children: ReactNode;
+
+ /**
+ * Content to show when user is not authorized
+ */
+ fallback?: ReactNode;
+}
+
+/**
+ * Simplified client-side permission component that only renders content
+ * if the user is authorized (based on single permission or any in a list).
+ * Returns fallback or null otherwise.
+ */
+export function ClientRequirePermission({
+ permission,
+ permissions,
+ publicPaths,
+ allowGuests = false,
+ children,
+ fallback = null,
+}: ClientRequirePermissionProps) {
+ // --- Validation (Client-side warning) ---
+ useEffect(() => {
+ if (permission && permissions) {
+ logger.warn(
+ "ClientRequirePermission received both 'permission' and 'permissions' props. Use only one."
+ );
+ }
+ if (!permission && !permissions) {
+ logger.warn(
+ "ClientRequirePermission requires either 'permission' or 'permissions' prop."
+ );
+ }
+ }, [permission, permissions]);
+
+ // --- Get Context Data ---
+ const { hasPermission, hasAnyPermission, isGuest, isLoading } =
+ usePermissions();
+ const pathname = usePathname();
+
+ // --- Loading State ---
+ if (isLoading) {
+ return fallback ? <>{fallback}> : null;
+ }
+
+ // --- Path Check ---
+ const isPublic = isPublicPath(pathname, publicPaths);
+
+ // --- Authorization Check ---
+ let isAuthorized = false;
+ if (isPublic) {
+ isAuthorized = true;
+ } else if (isGuest && !allowGuests) {
+ isAuthorized = false;
+ } else {
+ // Check based on provided props
+ if (permission) {
+ isAuthorized = hasPermission(permission);
+ } else if (permissions) {
+ isAuthorized = hasAnyPermission(permissions);
+ } else {
+ // Should not happen due to validation, but default to false
+ isAuthorized = false;
+ }
+ }
+
+ // --- Rendering ---
+ // Only render children if authorized, otherwise show fallback
+ return isAuthorized ? <>{children}> : fallback ? <>{fallback}> : null;
+}
diff --git a/apps/web/src/components/auth/permission/provider.tsx b/apps/web/src/components/auth/permission/provider.tsx
new file mode 100644
index 0000000..37da17b
--- /dev/null
+++ b/apps/web/src/components/auth/permission/provider.tsx
@@ -0,0 +1,95 @@
+"use client";
+
+import { ReactNode, useState, useEffect } from "react";
+import { useSession } from "next-auth/react";
+import { useTRPC } from "~/server/client";
+import { useQuery } from "@tanstack/react-query";
+import { checkPermission } from "~/lib/permissions/client";
+import { PermissionIdentifier } from "@repo/db/client";
+import { PermissionContext, Permission } from "./utils/context";
+import { logger } from "~/lib/utils/logger";
+
+// Define the permissions data structure returned by the API
+interface PermissionsData {
+ permissions: Permission[];
+ permissionNames: PermissionIdentifier[];
+ permissionMap: Record
;
+}
+
+// Provider component
+export function PermissionProvider({ children }: { children: ReactNode }) {
+ const [permissionsData, setPermissionsData] = useState({
+ permissions: [],
+ permissionNames: [],
+ permissionMap: {} as Record,
+ });
+
+ const [isLoading, setIsLoading] = useState(true);
+ const { status: authStatus } = useSession();
+ const trpc = useTRPC();
+
+ // Determine if user is a guest
+ const isGuest = authStatus === "unauthenticated";
+
+ // Query user permissions using tRPC
+ const { data: authUserData, refetch: refetchAuthUser } = useQuery(
+ trpc.auth.getMyPermissions.queryOptions()
+ );
+
+ // Combine data and refetch based on auth status
+ const data = authUserData;
+ const refetch = refetchAuthUser;
+
+ // Update state when data changes
+ useEffect(() => {
+ if (data) {
+ setPermissionsData(data as unknown as PermissionsData);
+ setIsLoading(false);
+ }
+
+ logger.debug("permissionsData", data);
+ }, [data]);
+
+ // Reload function - will fetch either guest or user permissions based on auth state
+ const reloadPermissions = async () => {
+ setIsLoading(true);
+ await refetch();
+ };
+
+ // Helper function to check if user has a permission
+ const hasPermission = (permission: PermissionIdentifier) => {
+ // Handle loading state - if permissions haven't loaded, assume no permission
+ if (isLoading) return false;
+
+ // Use the client-side utility
+ return checkPermission(permissionsData.permissionMap, permission);
+ };
+
+ // Helper function to check if user has any of the provided permissions
+ const hasAnyPermission = (permissions: PermissionIdentifier[]) => {
+ // Handle empty array or loading state
+ if (permissions.length === 0 || isLoading) return false;
+
+ // Check each permission, return true on first match
+ return permissions.some((permission) =>
+ checkPermission(permissionsData.permissionMap, permission)
+ );
+ };
+
+ // Value to provide
+ const value = {
+ ...permissionsData,
+ isLoading: isLoading || authStatus === "loading",
+ isGuest,
+ hasPermission,
+ hasAnyPermission,
+ reloadPermissions,
+ isAuthenticated: authStatus === "authenticated",
+ };
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/apps/web/src/components/auth/permission/server-gate.tsx b/apps/web/src/components/auth/permission/server-gate.tsx
new file mode 100644
index 0000000..2f759a1
--- /dev/null
+++ b/apps/web/src/components/auth/permission/server-gate.tsx
@@ -0,0 +1,164 @@
+import { ReactNode } from "react";
+import { redirect } from "next/navigation";
+import { getServerAuthSession } from "~/lib/auth";
+import { authorizationService } from "~/lib/services";
+import { PermissionIdentifier } from "@repo/db/client";
+
+// --- Helper function to check if current path is public ---
+function isPublicPath(pathname: string, publicPaths?: string[]): boolean {
+ if (!publicPaths || publicPaths.length === 0) {
+ return false;
+ }
+
+ return publicPaths.some((publicPath) => {
+ if (publicPath.endsWith("*")) {
+ // Wildcard match: check if pathname starts with the prefix
+ const prefix = publicPath.slice(0, -1);
+ // Ensure the prefix match is at a path segment boundary or exact match
+ return pathname.startsWith(prefix);
+ } else {
+ // Exact match
+ return pathname === publicPath;
+ }
+ });
+}
+
+// --- Permission Gate Component ---
+interface PermissionGateServerProps {
+ /**
+ * The permission to check for.
+ */
+ permission?: PermissionIdentifier;
+
+ /**
+ * The permissions to check for, needs any to be true.
+ */
+ permissions?: PermissionIdentifier[];
+
+ /**
+ * The current pathname (from usePathname() in parent)
+ */
+ pathname: string;
+
+ /**
+ * Paths that are exempt from the permission check.
+ */
+ publicPaths?: string[];
+
+ /**
+ * Whether to allow guest access (non-authenticated users)
+ */
+ allowGuests?: boolean;
+
+ /**
+ * Content to show when user is authorized
+ */
+ authorized: ReactNode;
+
+ /**
+ * Content to show when user is unauthorized (but logged in)
+ */
+ unauthorized?: ReactNode;
+
+ /**
+ * Content to show when user is not logged in
+ */
+ notLoggedIn?: ReactNode;
+
+ /**
+ * Optional redirect path when unauthorized
+ */
+ redirectUnauthorized?: string;
+
+ /**
+ * Optional redirect path when not logged in
+ */
+ redirectNotLoggedIn?: string;
+}
+
+/**
+ * Server-side only permission gate component that returns the appropriate
+ * content based on user permissions.
+ * This component completely handles permission checking on the server and
+ * only returns the approved content to the client.
+ */
+export async function ServerPermissionGate({
+ permission,
+ permissions,
+ pathname,
+ publicPaths,
+ allowGuests = false,
+ authorized,
+ unauthorized,
+ notLoggedIn,
+ redirectUnauthorized,
+ redirectNotLoggedIn,
+}: PermissionGateServerProps) {
+ if (permission && permissions) {
+ throw new Error(
+ "ServerPermissionGate requires either 'permission' or 'permissions' prop, not both."
+ );
+ }
+ if (!permission && !permissions) {
+ throw new Error(
+ "ServerPermissionGate requires either 'permission' or 'permissions' prop."
+ );
+ }
+
+ // Check if path is public
+ const isPublic = isPublicPath(pathname, publicPaths);
+ if (isPublic) {
+ return <>{authorized}>;
+ }
+
+ // Get session and check auth status
+ const session = await getServerAuthSession();
+ const isLoggedIn = !!session?.user;
+ let isAuthorized = false;
+
+ // If user is logged in, check permissions normally
+ if (isLoggedIn && session?.user) {
+ const userId = parseInt(session.user.id);
+ if (permission) {
+ isAuthorized = await authorizationService.hasPermission(
+ userId,
+ permission
+ );
+ } else if (permissions) {
+ isAuthorized = await authorizationService.hasAnyPermission(
+ userId,
+ permissions
+ );
+ }
+ }
+ // If guests are allowed, check permissions for the guest user (undefined userId)
+ else if (allowGuests) {
+ // Check permissions for guest user (undefined userId)
+ if (permission) {
+ isAuthorized = await authorizationService.hasPermission(
+ undefined,
+ permission
+ );
+ } else if (permissions) {
+ isAuthorized = await authorizationService.hasAnyPermission(
+ undefined,
+ permissions
+ );
+ }
+ }
+
+ // Return the appropriate content based on authorization status
+ if (isAuthorized) {
+ return <>{authorized}>;
+ } else if (isLoggedIn) {
+ if (redirectUnauthorized) {
+ redirect(redirectUnauthorized);
+ }
+ return unauthorized ? <>{unauthorized}> : null;
+ } else {
+ if (redirectNotLoggedIn) {
+ redirect(redirectNotLoggedIn);
+ }
+ return notLoggedIn ? <>{notLoggedIn}> : null;
+ }
+}
diff --git a/apps/web/src/components/auth/permission/server/gate.tsx b/apps/web/src/components/auth/permission/server/gate.tsx
new file mode 100644
index 0000000..606fef2
--- /dev/null
+++ b/apps/web/src/components/auth/permission/server/gate.tsx
@@ -0,0 +1,186 @@
+import { ReactNode, Children, isValidElement } from "react";
+import { redirect } from "next/navigation";
+import { getServerAuthSession } from "~/lib/auth";
+import { authorizationService } from "~/lib/services";
+import { PermissionIdentifier } from "@repo/db";
+import { isPublicPath } from "../utils/path-utils";
+import { headers } from "next/headers";
+
+// Define prop types for each slot component
+interface AuthorizedProps {
+ children: ReactNode;
+}
+
+interface UnauthorizedProps {
+ children: ReactNode;
+ redirectTo?: string;
+}
+
+interface NotLoggedInProps {
+ children: ReactNode;
+ redirectTo?: string;
+}
+
+// Main component props
+interface PermissionGateProps {
+ /**
+ * The permission to check for.
+ */
+ permission?: PermissionIdentifier;
+
+ /**
+ * The permissions to check for, needs any to be true.
+ */
+ permissions?: PermissionIdentifier[];
+
+ /**
+ * Paths that are exempt from the permission check.
+ */
+ publicPaths?: string[];
+
+ /**
+ * Whether to allow guest access (non-authenticated users)
+ */
+ allowGuests?: boolean;
+
+ /**
+ * The children to render
+ */
+ children: ReactNode;
+}
+
+/**
+ * Server-side permission gate component that checks user permissions
+ * and renders appropriate content based on authorization status.
+ * This component completely handles permission checking on the server.
+ */
+export async function PermissionGate({
+ permission,
+ permissions,
+ publicPaths,
+ allowGuests = false,
+ children,
+}: PermissionGateProps) {
+ // Validate parameters
+ if (permission && permissions) {
+ throw new Error(
+ "PermissionGate requires either 'permission' or 'permissions' prop, not both."
+ );
+ }
+ if (!permission && !permissions) {
+ throw new Error(
+ "PermissionGate requires either 'permission' or 'permissions' prop."
+ );
+ }
+
+ let isPublic = false;
+
+ if (publicPaths) {
+ // Get the current pathname from the request headers
+ const headersList = await headers();
+ // Try to get the path from various headers
+ const referer = headersList.get("referer") || "";
+ const xUrl = headersList.get("x-url") || "";
+ const pathname = referer ? new URL(referer).pathname : xUrl ? xUrl : "/";
+
+ isPublic = isPublicPath(pathname, publicPaths);
+ }
+
+ // Get session and check auth status
+ const session = await getServerAuthSession();
+ const isLoggedIn = !!session?.user;
+ let isAuthorized = false;
+
+ // If path is public, user is authorized
+ if (isPublic) {
+ isAuthorized = true;
+ }
+ // If user is logged in, check permissions normally
+ else if (isLoggedIn && session?.user) {
+ const userId = parseInt(session.user.id);
+ if (permission) {
+ isAuthorized = await authorizationService.hasPermission(
+ userId,
+ permission
+ );
+ } else if (permissions) {
+ isAuthorized = await authorizationService.hasAnyPermission(
+ userId,
+ permissions
+ );
+ }
+ }
+ // If guests are allowed, check permissions for the guest user
+ else if (allowGuests) {
+ if (permission) {
+ isAuthorized = await authorizationService.hasPermission(
+ undefined,
+ permission
+ );
+ } else if (permissions) {
+ isAuthorized = await authorizationService.hasAnyPermission(
+ undefined,
+ permissions
+ );
+ }
+ }
+
+ // Find and render the appropriate child component
+ let authorizedContent: ReactNode = null;
+ let unauthorizedContent: ReactNode | null = null;
+ let unauthorizedRedirect: string | undefined;
+ let notLoggedInContent: ReactNode | null = null;
+ let notLoggedInRedirect: string | undefined;
+
+ // Process each child to find the appropriate slot components
+ Children.forEach(children, (child) => {
+ if (!isValidElement(child)) return;
+
+ // Type checking with specific component types
+ if (child.type === Authorized) {
+ const props = child.props as AuthorizedProps;
+ authorizedContent = props.children;
+ } else if (child.type === Unauthorized) {
+ const props = child.props as UnauthorizedProps;
+ unauthorizedContent = props.children;
+ unauthorizedRedirect = props.redirectTo;
+ } else if (child.type === NotLoggedIn) {
+ const props = child.props as NotLoggedInProps;
+ notLoggedInContent = props.children;
+ notLoggedInRedirect = props.redirectTo;
+ }
+ });
+
+ // Return the appropriate content based on authorization status
+ if (isAuthorized) {
+ return <>{authorizedContent}>;
+ } else if (isLoggedIn) {
+ if (unauthorizedRedirect) {
+ redirect(unauthorizedRedirect);
+ }
+ return unauthorizedContent ? <>{unauthorizedContent}> : null;
+ } else {
+ if (notLoggedInRedirect) {
+ redirect(notLoggedInRedirect);
+ }
+ return notLoggedInContent ? <>{notLoggedInContent}> : null;
+ }
+}
+
+// Slot components for PermissionGate
+function Authorized({ children }: AuthorizedProps) {
+ return <>{children}>;
+}
+
+function Unauthorized({ children }: UnauthorizedProps) {
+ return <>{children}>;
+}
+
+function NotLoggedIn({ children }: NotLoggedInProps) {
+ return <>{children}>;
+}
+
+// Attach slot components to PermissionGate
+PermissionGate.Authorized = Authorized;
+PermissionGate.Unauthorized = Unauthorized;
+PermissionGate.NotLoggedIn = NotLoggedIn;
diff --git a/apps/web/src/components/auth/permission/server/index.ts b/apps/web/src/components/auth/permission/server/index.ts
new file mode 100644
index 0000000..d73774e
--- /dev/null
+++ b/apps/web/src/components/auth/permission/server/index.ts
@@ -0,0 +1,11 @@
+/**
+ * Permission System Entry Point
+ *
+ * This file exports all permission-related components for server.
+ */
+
+// Re-export components with clear namespacing
+import { PermissionGate } from "./gate";
+import { RequirePermission } from "./require";
+
+export { PermissionGate, RequirePermission };
diff --git a/apps/web/src/components/auth/permission/server/require.tsx b/apps/web/src/components/auth/permission/server/require.tsx
new file mode 100644
index 0000000..7b5dbbd
--- /dev/null
+++ b/apps/web/src/components/auth/permission/server/require.tsx
@@ -0,0 +1,101 @@
+import { ReactNode } from "react";
+import { headers } from "next/headers";
+import { getServerAuthSession } from "~/lib/auth";
+import { authorizationService } from "~/lib/services";
+import { PermissionIdentifier } from "@repo/db";
+import { isPublicPath } from "../utils/path-utils";
+
+interface RequirePermissionProps {
+ /**
+ * The single permission to check for.
+ * Use either this or `permissions`, not both.
+ */
+ permission?: PermissionIdentifier;
+
+ /**
+ * An array of permissions to check for. Access is granted if the user has *any* of these.
+ * Use either this or `permission`, not both.
+ */
+ permissions?: PermissionIdentifier[];
+
+ /**
+ * Paths that are exempt from the permission check.
+ */
+ publicPaths?: string[];
+
+ /**
+ * Whether to allow guest access (non-authenticated users)
+ */
+ allowGuests?: boolean;
+
+ /**
+ * Content to show when user is authorized
+ */
+ children: ReactNode;
+}
+
+/**
+ * Simplified server-side permission component that only renders content
+ * if the user is authorized (based on single permission or any in a list).
+ * Returns null otherwise.
+ */
+export async function RequirePermission({
+ permission,
+ permissions,
+ publicPaths,
+ allowGuests = false,
+ children,
+}: RequirePermissionProps) {
+ // --- Validation ---
+ if (permission && permissions) {
+ throw new Error(
+ "RequirePermission requires either 'permission' or 'permissions' prop, not both."
+ );
+ }
+ if (!permission && !permissions) {
+ throw new Error(
+ "RequirePermission requires either 'permission' or 'permissions' prop."
+ );
+ }
+
+ // --- Path Check ---
+ let isPublic = false;
+ if (publicPaths) {
+ const headersList = await headers();
+ // Try various headers to get the path
+ const referer = headersList.get("referer") || "";
+ const xUrl = headersList.get("x-url") || "";
+ const pathname = referer ? new URL(referer).pathname : xUrl ? xUrl : "/";
+ isPublic = isPublicPath(pathname, publicPaths);
+ }
+
+ // If path is public, always render children
+ if (isPublic) {
+ return <>{children}>;
+ }
+
+ // --- Authorization Check ---
+ const session = await getServerAuthSession();
+ const isLoggedIn = !!session?.user;
+ let isAuthorized = false;
+ const userId = session?.user ? parseInt(session.user.id) : undefined;
+
+ // Check permissions based on whether user is logged in or guest access is allowed
+ if (isLoggedIn || (allowGuests && userId === undefined)) {
+ if (permission) {
+ isAuthorized = await authorizationService.hasPermission(
+ userId, // undefined for guests if allowGuests is true
+ permission
+ );
+ } else if (permissions) {
+ isAuthorized = await authorizationService.hasAnyPermission(
+ userId, // undefined for guests if allowGuests is true
+ permissions
+ );
+ }
+ }
+
+ // --- Rendering ---
+ // Only render children if authorized (and not public, handled above)
+ return isAuthorized ? <>{children}> : null;
+}
diff --git a/apps/web/src/components/auth/permission/utils/context.ts b/apps/web/src/components/auth/permission/utils/context.ts
new file mode 100644
index 0000000..e1d701e
--- /dev/null
+++ b/apps/web/src/components/auth/permission/utils/context.ts
@@ -0,0 +1,68 @@
+"use client";
+
+import { createContext, useContext } from "react";
+import { PermissionIdentifier } from "@repo/db/client";
+
+// Define permission type with typed identifier
+export interface Permission {
+ id: number;
+ name: PermissionIdentifier;
+ module: string;
+ resource: string;
+ action: string;
+ description: string | null;
+}
+
+// Define the permissions data structure
+export interface PermissionContextValue {
+ // Raw permissions data
+ permissions: Permission[];
+
+ // Array of permission names
+ permissionNames: PermissionIdentifier[];
+
+ // Map for easy lookup
+ permissionMap: Record;
+
+ // Loading state
+ isLoading: boolean;
+
+ // Authenticated state
+ isAuthenticated: boolean;
+
+ // Guest state (not authenticated)
+ isGuest: boolean;
+
+ // Helper functions
+ hasPermission: (permission: PermissionIdentifier) => boolean;
+ hasAnyPermission: (permissions: PermissionIdentifier[]) => boolean;
+
+ // Optional reload function
+ reloadPermissions: () => Promise;
+}
+
+// Create the context with a default empty state
+export const PermissionContext = createContext({
+ permissions: [],
+ permissionNames: [],
+ permissionMap: {} as Record,
+ isLoading: true,
+ isAuthenticated: false,
+ isGuest: false,
+ hasPermission: () => false,
+ hasAnyPermission: () => false,
+ reloadPermissions: async () => {},
+});
+
+// Custom hook to use the context
+export function usePermissionContext() {
+ const context = useContext(PermissionContext);
+
+ if (context === undefined) {
+ throw new Error(
+ "usePermissionContext must be used within a PermissionProvider"
+ );
+ }
+
+ return context;
+}
diff --git a/apps/web/src/components/auth/permission/utils/path-utils.ts b/apps/web/src/components/auth/permission/utils/path-utils.ts
new file mode 100644
index 0000000..6d6ffcf
--- /dev/null
+++ b/apps/web/src/components/auth/permission/utils/path-utils.ts
@@ -0,0 +1,28 @@
+/**
+ * Helper function to check if current path is public.
+ * Supports exact matches and wildcard pattern matches (path/*).
+ *
+ * @param pathname The current pathname to check
+ * @param publicPaths Array of public paths or patterns
+ * @returns true if the path matches any public path or pattern
+ */
+export function isPublicPath(
+ pathname: string,
+ publicPaths?: string[]
+): boolean {
+ if (!publicPaths || publicPaths.length === 0) {
+ return false;
+ }
+
+ return publicPaths.some((publicPath) => {
+ if (publicPath.endsWith("*")) {
+ // Wildcard match: check if pathname starts with the prefix
+ const prefix = publicPath.slice(0, -1);
+ // Ensure the prefix match is at a path segment boundary or exact match
+ return pathname.startsWith(prefix);
+ } else {
+ // Exact match
+ return pathname === publicPath;
+ }
+ });
+}
diff --git a/apps/web/src/components/auth/permission/utils/usePermissions.ts b/apps/web/src/components/auth/permission/utils/usePermissions.ts
new file mode 100644
index 0000000..02b2feb
--- /dev/null
+++ b/apps/web/src/components/auth/permission/utils/usePermissions.ts
@@ -0,0 +1,13 @@
+"use client";
+
+import { usePermissionContext } from "./context";
+
+/**
+ * Custom hook to access and check permissions on the client side.
+ * Must be used within a component that is a descendant of the PermissionProvider.
+ *
+ * @returns Permission context with helper functions
+ */
+export function usePermissions() {
+ return usePermissionContext();
+}
diff --git a/apps/web/src/components/layout/AdminButton.tsx b/apps/web/src/components/layout/AdminButton.tsx
new file mode 100644
index 0000000..787c2c1
--- /dev/null
+++ b/apps/web/src/components/layout/AdminButton.tsx
@@ -0,0 +1,35 @@
+"use client";
+
+import Link from "next/link";
+import { useSession } from "next-auth/react";
+
+export function AdminButton() {
+ const { data: session } = useSession();
+
+ // Only show the button if the user is an admin
+ if (!session?.user?.isAdmin) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+ Admin
+
+ );
+}
diff --git a/apps/web/src/components/layout/AdminLayout.tsx b/apps/web/src/components/layout/AdminLayout.tsx
new file mode 100644
index 0000000..f433400
--- /dev/null
+++ b/apps/web/src/components/layout/AdminLayout.tsx
@@ -0,0 +1,325 @@
+"use client";
+
+import { ReactNode, useState } from "react";
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import { env } from "~/env";
+
+interface AdminLayoutProps {
+ children: ReactNode;
+}
+
+export function AdminLayout({ children }: AdminLayoutProps) {
+ const pathname = usePathname();
+ const [collapsed, setCollapsed] = useState(false);
+
+ const isActive = (path: string) => {
+ return pathname === path || pathname?.startsWith(`${path}/`);
+ };
+
+ return (
+
+ {/* Sidebar */}
+
+
+
+ {!collapsed && (
+
+ NextWiki Admin
+
+ )}
+
setCollapsed(!collapsed)}
+ className="hover:bg-background-level2 text-text-secondary rounded-md p-1"
+ >
+ {collapsed ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+
+
+
+ {/* Dashboard */}
+
+
+
+
+ {!collapsed && Dashboard }
+
+
+ {/* Wiki Pages */}
+
+
+
+
+ {!collapsed && Wiki Pages }
+
+
+ {/* Users */}
+
+
+
+
+ {!collapsed && Users }
+
+
+ {/* Groups */}
+
+
+
+
+ {!collapsed && Groups }
+
+
+ {/* Assets */}
+
+
+
+
+ {!collapsed && Assets }
+
+
+ {/* Permission Example */}
+ {env.NEXT_PUBLIC_NODE_ENV === "development" && (
+
+
+
+
+
+ {!collapsed && Permission Example }
+
+ )}
+
+ {/* Settings */}
+
+
+
+
+
+ {!collapsed && Settings }
+
+
+
+
+
+
+
+
+
+ {!collapsed &&
Back to Wiki }
+
+
+
+
+
+ {/* Main content */}
+
+ {/* Top bar */}
+
+
+
Admin Panel
+
+
+ Welcome, Admin
+
+
+
+
+
+ {/* Content */}
+
{children}
+
+
+ );
+}
diff --git a/apps/web/src/components/layout/Header.tsx b/apps/web/src/components/layout/Header.tsx
new file mode 100644
index 0000000..98745b8
--- /dev/null
+++ b/apps/web/src/components/layout/Header.tsx
@@ -0,0 +1,23 @@
+import { UserMenu } from "../auth/UserMenu";
+import { Suspense } from "react";
+import { ThemeToggle } from "~/components/layout/theme-toggle";
+import { AdminButton } from "~/components/layout/AdminButton";
+import { RandomNumberDisplay } from "../wiki/RandomNumberDisplay";
+import { env } from "~/env";
+
+export function Header() {
+ return (
+
+
+ Loading search bar...
}>
+
+
+
+
-
+
Loading...
}>
-
{children}
+
{children}
diff --git a/src/components/layout/MinimalLayout.tsx b/apps/web/src/components/layout/MinimalLayout.tsx
similarity index 88%
rename from src/components/layout/MinimalLayout.tsx
rename to apps/web/src/components/layout/MinimalLayout.tsx
index 6711304..478e618 100644
--- a/src/components/layout/MinimalLayout.tsx
+++ b/apps/web/src/components/layout/MinimalLayout.tsx
@@ -7,7 +7,7 @@ interface MinimalLayoutProps {
export function MinimalLayout({ children, header = true }: MinimalLayoutProps) {
return (
-
+
{header && }
{children}
diff --git a/apps/web/src/components/layout/SearchModal.tsx b/apps/web/src/components/layout/SearchModal.tsx
new file mode 100644
index 0000000..7503c28
--- /dev/null
+++ b/apps/web/src/components/layout/SearchModal.tsx
@@ -0,0 +1,360 @@
+"use client";
+
+import { useState, useEffect, useRef } from "react";
+import { useTRPC } from "~/server/client";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { useSearchParams, useRouter } from "next/navigation";
+import { Modal } from "@repo/ui";
+import {
+ Search,
+ FileText,
+ ChevronLeft,
+ ChevronRight,
+ CornerDownLeft,
+} from "lucide-react";
+import { cn } from "~/lib/utils";
+import { ScrollArea } from "@repo/ui";
+
+// Utility to highlight text
+function highlightText(text: string, query: string) {
+ if (!query || !text) return text;
+
+ const parts = text.split(new RegExp(`(${query})`, "gi"));
+
+ return parts.map((part, i) =>
+ part.toLowerCase() === query.toLowerCase() ? (
+
+ {part}
+
+ ) : (
+ part
+ )
+ );
+}
+
+export function SearchModal({
+ isOpen,
+ onClose,
+}: {
+ isOpen: boolean;
+ onClose: () => void;
+}) {
+ const searchParams = useSearchParams();
+ const router = useRouter();
+ const initialHighlight = searchParams.get("highlight") || "";
+ const inputRef = useRef
(null);
+ const [focusedIndex, setFocusedIndex] = useState(-1);
+ const listRef = useRef(null); // Ref for the list for scrolling
+
+ const [searchQuery, setSearchQuery] = useState(initialHighlight);
+ const [currentPage, setCurrentPage] = useState(1);
+ const resultsPerPage = 10;
+
+ // Focus input when modal opens
+ useEffect(() => {
+ if (isOpen && inputRef.current) {
+ inputRef.current.focus();
+ setFocusedIndex(-1); // Reset focus index on open
+ }
+ }, [isOpen]);
+
+ const trpc = useTRPC();
+ const queryClient = useQueryClient();
+
+ const { data: searchData, isLoading } = useQuery(
+ trpc.search.search.queryOptions(
+ {
+ query: searchQuery,
+ page: currentPage,
+ pageSize: resultsPerPage,
+ },
+ {
+ enabled: isOpen && !!searchQuery && searchQuery.length >= 1,
+ placeholderData: (prev) => prev,
+ staleTime: 5 * 60 * 1000, // Keep results fresh for 5 minutes
+ }
+ )
+ );
+
+ const prefetchNextPage = () => {
+ if (paginationMeta?.hasNextPage) {
+ queryClient.prefetchQuery(
+ trpc.search.search.queryOptions({
+ query: searchQuery,
+ page: currentPage + 1,
+ pageSize: resultsPerPage,
+ })
+ );
+ }
+ };
+
+ // Extract items and metadata from the paginated response
+ const paginatedResults = searchData?.items || [];
+ const paginationMeta = searchData?.meta;
+
+ // Navigate to search result and close modal
+ const handleResultClick = (path: string) => {
+ router.push(`/${path}?highlight=${encodeURIComponent(searchQuery)}`);
+ onClose();
+ };
+
+ // Reset pagination when search query changes
+ useEffect(() => {
+ setCurrentPage(1);
+ setFocusedIndex(-1); // Reset focus on new search
+ }, [searchQuery]);
+
+ // Clear search on close
+ useEffect(() => {
+ if (!isOpen) {
+ setSearchQuery("");
+ setCurrentPage(1);
+ setFocusedIndex(-1);
+ }
+ }, [isOpen]);
+
+ // Scroll focused item into view
+ useEffect(() => {
+ if (focusedIndex >= 0 && listRef.current) {
+ const focusedElement = listRef.current.children[
+ focusedIndex
+ ] as HTMLLIElement;
+ if (focusedElement) {
+ focusedElement.scrollIntoView({
+ block: "nearest",
+ inline: "nearest",
+ });
+ }
+ }
+ }, [focusedIndex]);
+
+ // Keyboard navigation
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (!isOpen || paginatedResults.length === 0) return;
+
+ if (e.key === "ArrowDown") {
+ e.preventDefault();
+ setFocusedIndex((prev) => (prev + 1) % paginatedResults.length);
+ } else if (e.key === "ArrowUp") {
+ e.preventDefault();
+ setFocusedIndex(
+ (prev) =>
+ (prev - 1 + paginatedResults.length) % paginatedResults.length
+ );
+ } else if (e.key === "Enter") {
+ e.preventDefault();
+ if (focusedIndex >= 0 && focusedIndex < paginatedResults.length) {
+ if (!paginatedResults[focusedIndex]) {
+ throw new Error("Result is undefined");
+ }
+ if (!paginatedResults[focusedIndex].path) {
+ throw new Error("Path is undefined");
+ }
+ handleResultClick(paginatedResults[focusedIndex].path);
+ }
+ }
+ };
+
+ document.addEventListener("keydown", handleKeyDown);
+ return () => document.removeEventListener("keydown", handleKeyDown);
+ }, [isOpen, paginatedResults, focusedIndex]);
+
+ // Only render the modal when isOpen is true
+ if (!isOpen) return null;
+
+ return (
+
+ {/* Search Input Area */}
+
+
+ setSearchQuery(e.target.value)}
+ />
+
+
+ {/* Results Area - Scrolls */}
+ {/* TODO: Scrollarea doesn't work overflow-y-auto takes over */}
+
+ {isLoading && !paginatedResults.length ? (
+
+ {/* Simplified Loader */}
+
+
+
+
+
+ ) : paginationMeta && paginationMeta.totalItems > 0 ? (
+
+ {" "}
+ {/* Padding around list, space between items */}
+ {paginatedResults.map((result, index) => (
+ handleResultClick(result.path)}
+ onMouseEnter={() => setFocusedIndex(index)} // Update focus on hover
+ >
+
+
+
+ {/* Path/Category */}
+
+ {result.path.split("/").slice(0, -1).join(" / ") ||
+ "Wiki Home"}
+
+ {/* Title */}
+
+ {highlightText(result.title, searchQuery)}
+
+ {/* Excerpt */}
+
+ {highlightText(result.excerpt, searchQuery)}...
+
+
+
+ {/* Enter Icon */}
+ {focusedIndex === index && (
+
+ )}
+
+ ))}
+
+ ) : searchQuery.length >= 1 ? (
+
+
No results found
+
+ Try narrowing your search?
+
+
+ ) : (
+
+
Search for pages or content
+
+ Start typing to see results.
+
+
+ )}
+
+
+ {/* Footer / Pagination Area - Simplified */}
+ {(paginationMeta && paginationMeta.totalItems > 0) || isLoading ? ( // Show footer even when loading if results were previously shown
+
+ {paginationMeta && paginationMeta.totalItems > 0 ? (
+
+ {paginationMeta.totalItems} result
+ {paginationMeta.totalItems !== 1 ? "s" : ""}
+
+ ) : (
+
+ Loading...
+
+ )}
+
+ {/* Shortcut Info */}
+
+ Navigate:
+
+ ↑
+
+
+ ↓
+
+ Open:
+
+ ↵
+
+ Close:
+
+ Esc
+
+
+
+ {paginationMeta && paginationMeta.totalPages > 1 ? (
+
+ {/* Pagination Buttons */}
+
setCurrentPage((prev) => Math.max(prev - 1, 1))}
+ disabled={!paginationMeta.hasPreviousPage}
+ className={cn(
+ "flex items-center rounded p-1.5 text-xs",
+ !paginationMeta.hasPreviousPage
+ ? "text-text-secondary/50 cursor-not-allowed opacity-50"
+ : "hover:bg-background-level2 text-text-secondary hover:text-text-primary"
+ )}
+ aria-label="Previous page"
+ >
+
+
+
+
+ Page {paginationMeta.currentPage} of {paginationMeta.totalPages}
+
+
+
+ setCurrentPage((prev) =>
+ Math.min(prev + 1, paginationMeta.totalPages)
+ )
+ }
+ onFocus={prefetchNextPage}
+ onMouseEnter={prefetchNextPage}
+ disabled={!paginationMeta.hasNextPage}
+ className={cn(
+ "flex items-center rounded p-1.5 text-xs",
+ !paginationMeta.hasNextPage
+ ? "text-text-secondary/50 cursor-not-allowed opacity-50"
+ : "hover:bg-background-level2 text-text-secondary hover:text-text-primary"
+ )}
+ aria-label="Next page"
+ >
+
+
+
+ ) : (
+ // Placeholder to balance the flex layout if pagination isn't shown but results are
+
+ )}
+
+ ) : null}
+
+ );
+}
diff --git a/apps/web/src/components/layout/Sidebar.tsx b/apps/web/src/components/layout/Sidebar.tsx
new file mode 100644
index 0000000..7fe67ce
--- /dev/null
+++ b/apps/web/src/components/layout/Sidebar.tsx
@@ -0,0 +1,740 @@
+"use client";
+
+import Link from "next/link";
+import { useEffect, useState, useRef } from "react";
+import { useTRPC } from "~/server/client";
+import { useQuery } from "@tanstack/react-query";
+import {
+ Folder,
+ File,
+ Loader2,
+ ChevronRight,
+ ChevronDown,
+ Search,
+} from "lucide-react";
+import { usePathname, useRouter } from "next/navigation";
+import { ScrollArea } from "@repo/ui";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@repo/ui";
+import { Button } from "@repo/ui";
+import { SearchModal } from "./SearchModal";
+import { ClientRequirePermission } from "~/components/auth/permission/client";
+
+interface FolderNode {
+ name: string;
+ path: string;
+ type: "folder" | "page";
+ children: FolderNode[];
+ id?: number;
+ title?: string;
+ updatedAt?: Date | string | null;
+}
+
+// TODO: Fix vertical lines not being connected and one depth missing from compressed layout
+
+// Constants for depth control
+// FIXME: This compression is not working as expected (doesnt show right compressed folders)
+const MAX_VISIBLE_DEPTH = 4; // Maximum levels to show in normal view
+const COMPRESS_THRESHOLD = 3; // Min number of hidden levels to enable compression
+const INDENTATION_WIDTH = 4; // Width of each indentation level
+
+// Recursive component for tree items
+function WikiTreeItem({
+ item,
+ activeItemPath,
+ expandedFolders,
+ toggleFolder,
+ allNodes,
+ depth = 0,
+}: {
+ item: FolderNode;
+ activeItemPath: string | null;
+ expandedFolders: Set;
+ toggleFolder: (e: React.MouseEvent, folder: FolderNode) => void;
+ allNodes: Map;
+ depth?: number;
+}) {
+ const isActive = item.path === activeItemPath;
+ const isExpanded = expandedFolders.has(item.path);
+ const hasChildren = item.children && item.children.length > 0;
+
+ // Check if this is the parent of the active item
+ const activeParentPath = (activeItemPath ?? "")
+ .split("/")
+ .slice(0, (activeItemPath ?? "").split("/").length - 1)
+ .join("/");
+ const isParentOfActive = activeParentPath === item.path;
+
+ // Check if this is a sibling of active item (shares same parent)
+ const activeSiblingParentPath = (activeItemPath ?? "")
+ .split("/")
+ .slice(0, (activeItemPath ?? "").split("/").length - 1)
+ .join("/");
+ const itemParentPath = item.path
+ .split("/")
+ .slice(0, item.path.split("/").length - 1)
+ .join("/");
+ const isSiblingOfActive =
+ itemParentPath === activeSiblingParentPath && itemParentPath !== "";
+
+ // Child of active check
+ const isChildOfActive =
+ activeItemPath && item.path.startsWith(activeItemPath + "/");
+
+ // Check if this item is in the ancestry line of the active item
+ const isInActivePath = activeItemPath?.startsWith(item.path + "/") || false;
+
+ // Determine if this node should be visible normally
+ const shouldRenderNormally =
+ depth < MAX_VISIBLE_DEPTH ||
+ isActive ||
+ isChildOfActive ||
+ isParentOfActive ||
+ isSiblingOfActive;
+
+ // If not in active path and not normally visible, skip
+ if (!shouldRenderNormally && !isInActivePath) {
+ return null;
+ }
+
+ // Special handling for deep nesting in active path
+ if (
+ depth >= MAX_VISIBLE_DEPTH &&
+ isInActivePath &&
+ !isActive &&
+ !isParentOfActive
+ ) {
+ // Find the next relevant node in the active path
+ if (activeItemPath) {
+ // Find the next visible node in the ancestry chain
+ const nextVisibleAncestor = findNextVisibleAncestor(
+ item,
+ activeItemPath,
+ allNodes
+ );
+
+ if (nextVisibleAncestor) {
+ return (
+
+
+
+ {/* Add vertical connecting lines for each level of depth */}
+ {Array.from({ length: depth }).map((_, i) => (
+
+ ))}
+
+
+
+ {/* Display the compressed path if needed */}
+ {getCompressedPath(item.path, nextVisibleAncestor.path)}
+
+
+
+
+ {/* Render the next visible ancestor */}
+
+
+
+
+
+
+
+ {nextVisibleAncestor.title || nextVisibleAncestor.name}
+
+
+
+ );
+ }
+ }
+ }
+
+ return (
+
+
+
+ {/* Add vertical connecting lines for each level of depth */}
+ {Array.from({ length: depth }).map((_, i) => (
+
+ ))}
+
+
+
+
+
+ {item.type === "folder" && hasChildren ? (
+
toggleFolder(e, item)}
+ className="mr-0.5 flex flex-shrink-0 items-center justify-center px-0.5 py-1.5 focus:outline-none"
+ >
+ {isExpanded ? (
+
+ ) : (
+
+ )}
+
+ ) : (
+
+ )}
+
+
+ {item.type === "folder" ? (
+
+ ) : (
+
+ )}
+
{item.title || item.name}
+
+
+
+
+ {item.title || item.name}
+
+
+
+
+ {/* Recursively render children if expanded */}
+ {isExpanded && hasChildren && (
+
+ {item.children.map((child) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+// Helper function to find the next visible ancestor in the path
+function findNextVisibleAncestor(
+ currentNode: FolderNode,
+ activeItemPath: string,
+ allNodes: Map
+): FolderNode | null {
+ // Get path segments for the active item
+ const activeSegments = activeItemPath.split("/");
+ const currentSegments = currentNode.path.split("/");
+
+ // Find where paths diverge
+ let commonPrefixLength = 0;
+ while (
+ commonPrefixLength < currentSegments.length &&
+ commonPrefixLength < activeSegments.length &&
+ currentSegments[commonPrefixLength] === activeSegments[commonPrefixLength]
+ ) {
+ commonPrefixLength++;
+ }
+
+ // Look for the next meaningful node in the path to active
+ // Which would be either:
+ // 1. The parent of the active item
+ // 2. A node that's at most 2 levels down from current
+ let nextNodePath = "";
+
+ // Try the parent of active first
+ const activeParentPath = activeSegments
+ .slice(0, activeSegments.length - 1)
+ .join("/");
+ if (
+ allNodes.has(activeParentPath) &&
+ activeParentPath.startsWith(currentNode.path)
+ ) {
+ return allNodes.get(activeParentPath)!;
+ }
+
+ // Otherwise find a node 1-2 levels down from current toward active
+ const targetDepth = Math.min(
+ commonPrefixLength + 2,
+ activeSegments.length - 1
+ );
+ nextNodePath = activeSegments.slice(0, targetDepth).join("/");
+
+ if (allNodes.has(nextNodePath)) {
+ return allNodes.get(nextNodePath)!;
+ }
+
+ return null;
+}
+
+// Get a nicely formatted compressed path between two nodes
+function getCompressedPath(startPath: string, endPath: string): string {
+ if (!endPath.startsWith(startPath)) {
+ return "...";
+ }
+
+ const startSegments = startPath.split("/");
+ const endSegments = endPath.split("/");
+
+ // Get all the segments that are skipped
+ const skippedSegments = endSegments.slice(
+ startSegments.length,
+ endSegments.length
+ );
+
+ if (skippedSegments.length === 0) {
+ return "";
+ } else if (skippedSegments.length === 1) {
+ // If only one segment is skipped, just show it
+ return skippedSegments[0] || "";
+ } else if (skippedSegments.length <= COMPRESS_THRESHOLD) {
+ // For 2-3 segments, show all of them joined with /
+ return skippedSegments.join("/");
+ } else {
+ // For many segments, use fish shell style abbreviation
+ return skippedSegments.map((s) => s[0]).join("/");
+ }
+}
+
+// Calculate the relative depth for rendering the next ancestor
+function calculateRelativeDepth(
+ currentPath: string,
+ nextPath: string,
+ currentDepth: number
+): number {
+ const currentSegments = currentPath.split("/").length;
+ const nextSegments = nextPath.split("/").length;
+
+ // Calculate segment difference
+ const segmentDifference = nextSegments - currentSegments;
+
+ // Limit the depth increase to a maximum of 2 levels regardless of how many we've skipped
+ // This keeps the indentation reasonable after compression
+ const maxDepthIncrease = Math.min(2, segmentDifference);
+
+ // Adjust depth based on how many levels we've jumped, but with a cap
+ return currentDepth + maxDepthIncrease;
+}
+
+export function Sidebar() {
+ const [rootItems, setRootItems] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [activeItemPath, setActiveItemPath] = useState(null);
+ const [expandedFolders, setExpandedFolders] = useState>(
+ new Set()
+ );
+ const [allNodes, setAllNodes] = useState>(new Map());
+ const [isScrollable, setIsScrollable] = useState(false);
+ const [isAtBottom, setIsAtBottom] = useState(false);
+ const [isSearchModalOpen, setIsSearchModalOpen] = useState(false);
+ const scrollAreaContainerRef = useRef(null);
+ const scrollViewportRef = useRef(null);
+
+ // Get current pathname and router
+ const pathname = usePathname();
+ const router = useRouter();
+ const [isMac, setIsMac] = useState(false);
+
+ // Detect OS for keyboard shortcut display
+ useEffect(() => {
+ setIsMac(
+ typeof window !== "undefined" &&
+ window.navigator.userAgent.includes("Mac")
+ );
+ }, []);
+
+ // Handle keyboard shortcuts
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if ((e.metaKey || e.ctrlKey) && e.key === "k") {
+ e.preventDefault();
+ setIsSearchModalOpen(true);
+ }
+ };
+
+ document.addEventListener("keydown", handleKeyDown);
+ return () => document.removeEventListener("keydown", handleKeyDown);
+ }, []);
+
+ // Remove leading slash for comparison with path values
+ const currentPath = pathname?.startsWith("/")
+ ? pathname.substring(1)
+ : pathname;
+
+ const trpc = useTRPC();
+
+ // Fetch top-level folder structure
+ const { data: folderStructure } = useQuery(
+ trpc.wiki.getFolderStructure.queryOptions()
+ );
+
+ useEffect(() => {
+ if (folderStructure) {
+ // Extract top-level items (direct children of root)
+ setRootItems(folderStructure.children || []);
+ setIsLoading(false);
+
+ // Highlight the current active path
+ setActiveItemPath(currentPath || null);
+
+ // Create a map of all nodes for quick lookup
+ const nodeMap = new Map();
+ populateNodeMap(folderStructure, nodeMap);
+ setAllNodes(nodeMap);
+
+ // Find any parent folders of the current path to auto-expand them
+ if (currentPath) {
+ findAndExpandParentFolders(folderStructure, currentPath);
+ }
+ }
+ }, [folderStructure, currentPath]);
+
+ // Effect to check scrollability and attach scroll listener
+ useEffect(() => {
+ const container = scrollAreaContainerRef.current;
+ if (!container) return;
+
+ // Find the actual viewport element within the ScrollArea component
+ const viewport = container.querySelector(
+ "[data-radix-scroll-area-viewport]"
+ );
+
+ if (!viewport) {
+ setIsScrollable(false);
+ setIsAtBottom(true); // If no viewport, assume we are at the bottom
+ return;
+ }
+
+ // Assign viewport to ref for listener attachment
+ scrollViewportRef.current = viewport;
+
+ const handleScroll = () => {
+ if (!scrollViewportRef.current) return;
+ const { scrollTop, scrollHeight, clientHeight } =
+ scrollViewportRef.current;
+
+ // Check if scrollable (with tolerance)
+ const canScroll = scrollHeight > clientHeight + 1;
+ setIsScrollable(canScroll);
+
+ // Check if scrolled to bottom (with tolerance)
+ const atBottom = scrollTop + clientHeight >= scrollHeight - 1;
+ setIsAtBottom(atBottom);
+ };
+
+ // Initial check
+ handleScroll();
+
+ // Add scroll listener to the viewport
+ viewport.addEventListener("scroll", handleScroll);
+ // Also check on window resize as it might change scrollability/position
+ window.addEventListener("resize", handleScroll);
+
+ // Cleanup listeners
+ return () => {
+ viewport.removeEventListener("scroll", handleScroll);
+ window.removeEventListener("resize", handleScroll);
+ };
+ // Rerun when items load or change, as this affects scrollHeight
+ }, [rootItems, isLoading]);
+
+ // Build a map of all nodes for quick access
+ const populateNodeMap = (node: FolderNode, map: Map) => {
+ if (node.path) {
+ map.set(node.path, node);
+ }
+
+ if (node.children) {
+ for (const child of node.children) {
+ populateNodeMap(child, map);
+ }
+ }
+ };
+
+ // Find all parent folders of current path and expand them
+ const findAndExpandParentFolders = (structure: FolderNode, path: string) => {
+ if (!path) return;
+
+ // Split the path by slashes to get all segments
+ const pathSegments = path.split("/");
+ let currentPath = "";
+
+ // For each segment, build up the path and check if it's a folder
+ for (let i = 0; i < pathSegments.length; i++) {
+ if (i > 0) currentPath += "/";
+ currentPath += pathSegments[i];
+
+ // Find this node in the tree structure
+ const node = findNodeByPath(structure, currentPath);
+
+ // If it's a folder, expand it
+ if (node && node.type === "folder") {
+ setExpandedFolders((prev) => new Set([...prev, node.path]));
+ }
+ }
+ };
+
+ // Helper to find a node by path
+ const findNodeByPath = (
+ structure: FolderNode,
+ path: string
+ ): FolderNode | null => {
+ if (structure.path === path) return structure;
+
+ const findNode = (node: FolderNode): FolderNode | null => {
+ if (node.path === path) return node;
+
+ if (node.children) {
+ for (const child of node.children) {
+ const found = findNode(child);
+ if (found) return found;
+ }
+ }
+
+ return null;
+ };
+
+ for (const item of structure.children || []) {
+ const found = findNode(item);
+ if (found) return found;
+ }
+
+ return null;
+ };
+
+ // Toggle folder expansion when clicking on a folder
+ const toggleFolder = (e: React.MouseEvent, folder: FolderNode) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ setExpandedFolders((prev) => {
+ const newSet = new Set(prev);
+ if (newSet.has(folder.path)) {
+ newSet.delete(folder.path);
+ } else {
+ newSet.add(folder.path);
+ }
+ return newSet;
+ });
+
+ // Navigate to the folder page
+ router.push(`/${folder.path}`);
+ };
+
+ return (
+
+
+
+
+ NextWiki
+
+
+
+ {/* Search Modal */}
+
setIsSearchModalOpen(false)}
+ />
+
+ {/* Visual Search Bar */}
+
+
setIsSearchModalOpen(true)}
+ >
+
+
Search wiki...
+
+
+ {isMac ? "⌘K" : "Ctrl+K"}
+
+
+
+
+
+
+
+
+
+
+ Home
+
+
+
+
+
+ All Pages
+
+
+
+
+
+ Tags
+
+
+
+ {/* Horizontal separator */}
+
+
+ {/* Top Level Wiki Structure */}
+
+
+ Wiki Structure
+
+
+ {isLoading ? (
+
+
+
+ ) : rootItems.length === 0 ? (
+
No pages yet
+ ) : (
+
+
+ {/* File tree with smart rendering */}
+ {rootItems.map((item) => (
+
+ ))}
+
+
+ )}
+
+
+ {/* Create Page Button */}
+
+
+
+
+
+
+
+ New Page
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/layout/theme-toggle.tsx b/apps/web/src/components/layout/theme-toggle.tsx
similarity index 83%
rename from src/components/layout/theme-toggle.tsx
rename to apps/web/src/components/layout/theme-toggle.tsx
index 1fc6b89..b460861 100644
--- a/src/components/layout/theme-toggle.tsx
+++ b/apps/web/src/components/layout/theme-toggle.tsx
@@ -2,12 +2,8 @@
import React, { useState } from "react";
import { Moon, Sun, MonitorSmartphone } from "lucide-react";
-import { Button } from "~/components/ui/button";
-import {
- Popover,
- PopoverTrigger,
- PopoverContent,
-} from "~/components/ui/popover";
+import { Button } from "@repo/ui";
+import { Popover, PopoverTrigger, PopoverContent } from "@repo/ui";
import { useTheme } from "~/providers/theme-provider";
export function ThemeToggle() {
@@ -31,7 +27,7 @@ export function ThemeToggle() {
-
+
Light
@@ -56,11 +52,11 @@ export function ThemeToggle() {
setTheme("system");
setOpen(false);
}}
- className="justify-center flex-1"
+ className="flex-1 justify-center"
color={theme === "system" ? "primary" : "neutral"}
aria-label="System Theme"
>
-
+
Auto
@@ -71,11 +67,11 @@ export function ThemeToggle() {
setTheme("dark");
setOpen(false);
}}
- className="justify-center flex-1"
+ className="flex-1 justify-center"
color={theme === "dark" ? "primary" : "neutral"}
aria-label="Dark Mode"
>
-
+
Dark
diff --git a/apps/web/src/components/wiki/AllPagesList.tsx b/apps/web/src/components/wiki/AllPagesList.tsx
new file mode 100644
index 0000000..97fbeca
--- /dev/null
+++ b/apps/web/src/components/wiki/AllPagesList.tsx
@@ -0,0 +1,88 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { useTRPC } from "~/server/client";
+import { useQuery } from "@tanstack/react-query";
+import { WikiPageList } from "./WikiPageList";
+
+// Define Sort types locally or import if defined elsewhere
+type SortField = "title" | "updatedAt";
+type SortOrder = "asc" | "desc";
+
+// Component to fetch, manage state, and display the list of all wiki pages
+export function AllPagesList() {
+ const [sortBy, setSortBy] = useState("updatedAt"); // Default sort
+ const [sortOrder, setSortOrder] = useState("desc"); // Default order
+ const [searchQuery, setSearchQuery] = useState("");
+ const [debouncedSearch, setDebouncedSearch] = useState("");
+
+ // Debounce search input
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ setDebouncedSearch(searchQuery);
+ }, 300);
+
+ return () => clearTimeout(timer);
+ }, [searchQuery]);
+
+ const trpc = useTRPC();
+
+ // Fetch pages using tRPC query based on current state
+ const { data, isLoading } = useQuery(
+ trpc.wiki.list.queryOptions({
+ limit: 50, // Or make limit dynamic/configurable if needed
+ search: debouncedSearch,
+ sortBy,
+ sortOrder,
+ })
+ );
+
+ // Handle sort changes requested by WikiPageList
+ const handleSort = (field: SortField) => {
+ if (sortBy === field) {
+ // If already sorting by this field, reverse the order
+ setSortOrder(sortOrder === "asc" ? "desc" : "asc");
+ } else {
+ // Otherwise, switch to the new field and default to ascending order
+ setSortBy(field);
+ setSortOrder("asc");
+ }
+ };
+
+ return (
+
+ {/* Search Input */}
+
+
+
+
+
+
+ setSearchQuery(e.target.value)}
+ />
+
+
+
+ {/* Render the presentational list component */}
+
+
+ );
+}
diff --git a/apps/web/src/components/wiki/AssetManager.tsx b/apps/web/src/components/wiki/AssetManager.tsx
new file mode 100644
index 0000000..1bc42cb
--- /dev/null
+++ b/apps/web/src/components/wiki/AssetManager.tsx
@@ -0,0 +1,685 @@
+import { useEffect, useState } from "react";
+import { Button } from "@repo/ui";
+import {
+ Trash2,
+ Upload,
+ Image,
+ File,
+ Download,
+ Search,
+ GridIcon,
+ ListIcon,
+} from "lucide-react";
+import { useTRPC } from "~/server/client";
+import { useNotification } from "~/lib/hooks/useNotification";
+import { formatFileSize } from "~/lib/utils";
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import { Modal } from "@repo/ui";
+import { Input } from "@repo/ui";
+import { PaginationInput, PaginationMeta } from "~/lib/utils/pagination";
+import { ScrollArea } from "@repo/ui";
+import { logger } from "~/lib/utils/logger";
+
+export interface AssetType {
+ id: string;
+ name: string | null;
+ description: string | null;
+ fileName: string;
+ fileType: string;
+ fileSize: number;
+ data: string;
+ uploadedById: number;
+ pageId?: number | null;
+ createdAt: string | null;
+ uploadedBy?: {
+ id: number;
+ name: string | null;
+ };
+}
+
+interface AssetManagerProps {
+ pageId?: number;
+ onSelectAsset?: (
+ assetUrl: string,
+ assetName: string,
+ fileType: string
+ ) => void;
+ onClose?: () => void;
+ isOpen?: boolean;
+}
+
+type ViewMode = "grid" | "list";
+
+export const AssetManager: React.FC = ({
+ pageId,
+ onSelectAsset,
+ onClose,
+ isOpen = false,
+}) => {
+ const [assets, setAssets] = useState([]);
+ const [selectedAsset, setSelectedAsset] = useState(null);
+ const [isUploading, setIsUploading] = useState(false);
+ const [isDeleting, setIsDeleting] = useState(false);
+ const [searchTerm, setSearchTerm] = useState("");
+ const [pagination, setPagination] = useState({
+ page: 1,
+ pageSize: 20,
+ });
+ const [meta, setMeta] = useState(null);
+ const [viewMode, setViewMode] = useState("grid");
+ const [debouncedSearch, setDebouncedSearch] = useState("");
+ const [isEditing, setIsEditing] = useState(false);
+ const [editedName, setEditedName] = useState("");
+ const [editedDescription, setEditedDescription] = useState("");
+
+ const trpc = useTRPC();
+ const queryClient = useQueryClient();
+ const notification = useNotification();
+
+ const assetsQueryKey = trpc.assets.getPaginated.queryKey();
+
+ // Debounce search input
+ useEffect(() => {
+ const handler = setTimeout(() => {
+ setDebouncedSearch(searchTerm);
+ setPagination({ ...pagination, page: 1 }); // Reset to first page when search changes
+ }, 100);
+
+ return () => {
+ clearTimeout(handler);
+ };
+ }, [searchTerm]);
+
+ // TRPC queries and mutations
+ const { data: paginatedAssets, refetch: refetchAssets } = useQuery(
+ trpc.assets.getPaginated.queryOptions({
+ page: pagination.page,
+ pageSize: pagination.pageSize,
+ search: debouncedSearch,
+ })
+ );
+
+ const deleteMutation = useMutation(
+ trpc.assets.delete.mutationOptions({
+ onSuccess: () => {
+ notification.success("Asset deleted");
+ refetchAssets();
+ },
+ onError: () => {
+ notification.error("Error deleting asset");
+ },
+ })
+ );
+
+ const uploadMutation = useMutation(
+ trpc.assets.upload.mutationOptions({
+ onSuccess: () => {
+ notification.success("Asset uploaded");
+ refetchAssets();
+ },
+ onError: () => {
+ notification.error("Error uploading asset");
+ },
+ })
+ );
+
+ const updateAssetMutation = useMutation(
+ trpc.assets.update.mutationOptions({
+ onSuccess: () => {
+ notification.success("Asset updated");
+ refetchAssets();
+ setIsEditing(false);
+ },
+ onError: () => {
+ notification.error("Error updating asset");
+ },
+ })
+ );
+
+ useEffect(() => {
+ if (paginatedAssets) {
+ setAssets(paginatedAssets.items as AssetType[]);
+ setMeta(paginatedAssets.meta);
+ }
+ }, [paginatedAssets]);
+
+ const handleUpload = async (e: React.ChangeEvent) => {
+ if (!e.target.files || e.target.files.length === 0) return;
+
+ setIsUploading(true);
+
+ try {
+ const file = e.target.files[0];
+ const reader = new FileReader();
+
+ if (!file) {
+ throw new Error("File is undefined");
+ }
+
+ reader.onloadend = async () => {
+ // Get the base64 data from the FileReader
+ const base64Data = reader.result?.toString() || "";
+
+ // Upload using TRPC
+ await uploadMutation.mutateAsync({
+ fileName: file.name,
+ fileType: file.type,
+ fileSize: file.size,
+ data: base64Data,
+ pageId: pageId || null,
+ });
+ };
+
+ reader.readAsDataURL(file);
+ } catch (error) {
+ logger.error("Upload error:", error);
+ } finally {
+ setIsUploading(false);
+ queryClient.invalidateQueries({ queryKey: assetsQueryKey });
+ refetchAssets();
+ }
+ };
+
+ const handleDeleteAsset = async (assetId: string) => {
+ if (confirm("Are you sure you want to delete this asset?")) {
+ setIsDeleting(true);
+ try {
+ await deleteMutation.mutateAsync({ id: assetId });
+ if (selectedAsset?.id === assetId) {
+ setSelectedAsset(null);
+ }
+ } finally {
+ setIsDeleting(false);
+ }
+ }
+ };
+
+ const handleSelectAsset = (asset: AssetType) => {
+ setSelectedAsset(asset);
+ if (onSelectAsset) {
+ onSelectAsset(`/api/assets/${asset.id}`, asset.fileName, asset.fileType);
+ if (onClose) {
+ onClose();
+ }
+ }
+ };
+
+ const handleDownloadAsset = (asset: AssetType) => {
+ // Create a temporary anchor element to trigger download
+ const link = document.createElement("a");
+ // Use the asset API endpoint URL instead of raw data
+ link.href = `/api/assets/${asset.id}`;
+ // Set download attribute to force download behavior
+ link.download = asset.fileName;
+ // Set target to ensure new tab doesn't open
+ link.target = "_blank";
+ // Set rel for security
+ link.rel = "noopener noreferrer";
+ // Trigger click programmatically
+ link.click();
+ };
+
+ const handlePageChange = (page: number) => {
+ setPagination({ ...pagination, page });
+ };
+
+ const toggleViewMode = () => {
+ setViewMode(viewMode === "grid" ? "list" : "grid");
+ };
+
+ const handleStartEdit = () => {
+ if (selectedAsset) {
+ setEditedName(selectedAsset.name || "");
+ setEditedDescription(selectedAsset.description || "");
+ setIsEditing(true);
+ }
+ };
+
+ const handleCancelEdit = () => {
+ setIsEditing(false);
+ };
+
+ const handleSaveEdit = async () => {
+ if (selectedAsset) {
+ try {
+ await updateAssetMutation.mutateAsync({
+ id: selectedAsset.id,
+ name: editedName || null,
+ description: editedDescription || null,
+ });
+ } catch (error) {
+ logger.error("Error updating asset:", error);
+ }
+ }
+ };
+
+ /**
+ * Displays file information (name and size)
+ */
+ const FileInfo = ({ asset }: { asset: AssetType }) => (
+
+ {asset.fileName} • {formatFileSize(asset.fileSize)}
+
+ );
+
+ // Asset preview component
+ /**
+ * Renders a preview of an asset based on its file type
+ * @param asset - The asset to preview
+ * @returns JSX.Element - The appropriate preview component
+ */
+ const AssetPreview = ({ asset }: { asset: AssetType }) => {
+ // Image preview
+ if (asset.fileType.startsWith("image/")) {
+ return (
+
+
+
+
+ );
+ }
+
+ // PDF preview
+ if (asset.fileType === "application/pdf") {
+ return (
+
+ );
+ }
+
+ // Default file preview
+ return (
+
+
+
+
+ );
+ };
+
+ // Pagination component
+ const Pagination = () => {
+ if (!meta) return null;
+
+ const currentPage = meta.currentPage;
+ const totalPages = meta.totalPages;
+
+ return (
+
+
+ {meta.totalItems} {meta.totalItems === 1 ? "asset" : "assets"} • Page{" "}
+ {currentPage} of {totalPages || 1}
+
+
+ handlePageChange(currentPage - 1)}
+ disabled={!meta.hasPreviousPage}
+ >
+ Previous
+
+ handlePageChange(currentPage + 1)}
+ disabled={!meta.hasNextPage}
+ >
+ Next
+
+
+
+ );
+ };
+
+ // Grid view for assets
+ const GridView = () => (
+
+ {assets.map((asset) => (
+
setSelectedAsset(asset)}
+ >
+
+ {
+ e.stopPropagation();
+ handleDeleteAsset(asset.id);
+ }}
+ className="bg-error/30 text-error hover:bg-error/50 m-1 rounded-full p-2"
+ disabled={isDeleting}
+ >
+
+
+
+
+ {asset.fileType.startsWith("image/") ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+ {asset.name || asset.fileName}
+
+
+
+ ))}
+
+ );
+
+ // List view for assets
+ const ListView = () => (
+
+ {assets.map((asset) => (
+
setSelectedAsset(asset)}
+ >
+
+ {asset.fileType.startsWith("image/") ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+
+
+ {asset.name || asset.fileName}
+
+ {asset.description && (
+
+ | {asset.description}
+
+ )}
+
+
+ {formatFileSize(asset.fileSize)} •{" "}
+ {new Date(asset.createdAt || "").toLocaleDateString()}
+
+
+
+ {
+ e.stopPropagation();
+ handleDeleteAsset(asset.id);
+ }}
+ className="bg-error/30 text-error hover:bg-error/50 rounded-full p-1"
+ disabled={isDeleting}
+ >
+
+
+
+
+ ))}
+
+ );
+
+ return (
+ isOpen && (
+ {})}
+ >
+
+
+
Asset Manager
+
+
+
+ {isUploading ? "Uploading..." : "Upload"}
+
+
+
+
+
+
+ {/* Assets list */}
+
+
+
Available Assets
+
+
+
+ setSearchTerm(e.target.value)}
+ className="w-64 pl-9"
+ />
+
+
+ {viewMode === "grid" ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {assets.length === 0 ? (
+
+ No assets available
+
+ ) : viewMode === "grid" ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ {/* Asset preview */}
+
+ {selectedAsset ? (
+ <>
+
+
Preview
+
+ handleDownloadAsset(selectedAsset)}
+ >
+
+ Download
+
+ handleDeleteAsset(selectedAsset.id)}
+ disabled={isDeleting}
+ >
+
+ Delete
+
+ {onSelectAsset && !isEditing && (
+ handleSelectAsset(selectedAsset)}
+ >
+ Select
+
+ )}
+
+
+
+
+
+
+
+ Name
+
+ {isEditing ? (
+
setEditedName(e.target.value)}
+ placeholder="Enter asset name"
+ className="w-full"
+ />
+ ) : (
+
+ {selectedAsset.name || (
+
+ No name set
+
+ )}
+
+ )}
+
+
+
+
+ Description
+
+ {isEditing ? (
+
+
+ {isEditing && (
+
+
+ Cancel
+
+
+ {updateAssetMutation.status === "pending"
+ ? "Saving..."
+ : "Save"}
+
+
+ )}
+
+
+ Filename: {selectedAsset.fileName}
+
+
+ Uploaded by: {" "}
+ {selectedAsset.uploadedBy?.name || "Unknown"}
+
+
+ Uploaded on: {" "}
+ {new Date(
+ selectedAsset.createdAt || ""
+ ).toLocaleDateString()}
+
+
+
+ >
+ ) : (
+
+
+
Select an asset to preview
+
+ )}
+
+
+
+
+ )
+ );
+};
diff --git a/apps/web/src/components/wiki/BackButton.tsx b/apps/web/src/components/wiki/BackButton.tsx
new file mode 100644
index 0000000..159dd81
--- /dev/null
+++ b/apps/web/src/components/wiki/BackButton.tsx
@@ -0,0 +1,27 @@
+import { ArrowLeftIcon } from "@heroicons/react/24/outline";
+import { Button } from "@repo/ui";
+import { useRouter } from "next/navigation";
+import { cn } from "~/lib/utils";
+
+interface BackButtonProps {
+ className?: string;
+ variant?: "default" | "outlined";
+}
+
+export function BackButton({
+ className,
+ variant = "default",
+}: BackButtonProps) {
+ const router = useRouter();
+
+ return (
+ router.back()}
+ variant={variant}
+ className={cn("mt-4 rounded-full", className)}
+ >
+
+ Go back
+
+ );
+}
diff --git a/src/components/wiki/Breadcrumbs.tsx b/apps/web/src/components/wiki/Breadcrumbs.tsx
similarity index 85%
rename from src/components/wiki/Breadcrumbs.tsx
rename to apps/web/src/components/wiki/Breadcrumbs.tsx
index 39b5ab6..c18a137 100644
--- a/src/components/wiki/Breadcrumbs.tsx
+++ b/apps/web/src/components/wiki/Breadcrumbs.tsx
@@ -15,9 +15,9 @@ export function Breadcrumbs({ path, className = "" }: BreadcrumbsProps) {
-
+
Home
@@ -31,7 +31,7 @@ export function Breadcrumbs({ path, className = "" }: BreadcrumbsProps) {
const items = [
// Home link
{
- label: ,
+ label: ,
path: "/",
key: "home",
},
@@ -52,7 +52,7 @@ export function Breadcrumbs({ path, className = "" }: BreadcrumbsProps) {
{items.map((item, index) => (
{index > 0 && (
-
+
)}
{
+ const pathname = usePathname();
+
+ return (
+
+
+ Create This Page
+
+
+ );
+};
diff --git a/src/components/wiki/MarkdownProse.tsx b/apps/web/src/components/wiki/MarkdownProse.tsx
similarity index 92%
rename from src/components/wiki/MarkdownProse.tsx
rename to apps/web/src/components/wiki/MarkdownProse.tsx
index 13f00c0..5c6e661 100644
--- a/src/components/wiki/MarkdownProse.tsx
+++ b/apps/web/src/components/wiki/MarkdownProse.tsx
@@ -18,7 +18,7 @@ export function MarkdownProse({
{
- notification.success("Folder created successfully");
- onClose();
- router.push(`/${data.path}`);
- },
- onError: (error) => {
- setIsProcessing(false);
- notification.error(`Failed to create folder: ${error.message}`);
- },
- });
+ const createFolderMutation = useMutation(
+ trpc.wiki.createFolder.mutationOptions({
+ onSuccess: (data) => {
+ notification.success("Folder created successfully");
+ onClose();
+ router.push(`/${data.path}`);
+ },
+ onError: (error) => {
+ setIsProcessing(false);
+ notification.error(`Failed to create folder: ${error.message}`);
+ },
+ })
+ );
// Move page mutation
- const movePageMutation = trpc.wiki.movePages.useMutation({
- onSuccess: () => {
- notification.success("Page moved successfully");
- onClose();
- const newPath = pageName
- ? selectedPath
- ? `${selectedPath}/${pageName}`
- : pageName
- : selectedPath;
- router.push(`/${newPath}`);
- },
- onError: (error) => {
- setIsProcessing(false);
- notification.error(`Failed to move page: ${error.message}`);
- },
- });
+ const movePageMutation = useMutation(
+ trpc.wiki.movePages.mutationOptions({
+ onSuccess: () => {
+ notification.success("Page moved successfully");
+ onClose();
+ const newPath = pageName
+ ? selectedPath
+ ? `${selectedPath}/${pageName}`
+ : pageName
+ : selectedPath;
+ router.push(`/${newPath}`);
+ },
+ onError: (error) => {
+ setIsProcessing(false);
+ notification.error(`Failed to move page: ${error.message}`);
+ },
+ })
+ );
useEffect(() => {
if (isOpen) {
@@ -172,20 +185,20 @@ export function PageLocationEditor({
return (
-
+
{mode === "create" ? "Create New Wiki Page" : `Move: ${pageTitle}`}
-
+
{mode === "move" && (
-
+
Current path: {initialPath}
)}
-
+
{mode === "create"
? "Select Location"
: "Select Destination Folder"}
@@ -195,12 +208,12 @@ export function PageLocationEditor({
{mode === "create" ? "Page Name" : "New Name"}
{conflict && (
-
+
A page already exists at this location. Please choose a
different name.
@@ -208,7 +221,7 @@ export function PageLocationEditor({
{selectedPath && (
-
+
{selectedPath}/
)}
@@ -217,7 +230,7 @@ export function PageLocationEditor({
id="pageName"
value={pageName}
onChange={(e) => setPageName(e.target.value.toLowerCase())}
- className={`flex-1 min-w-0 block w-full px-3 py-2 rounded-md border border-border-default focus:outline-none focus:ring-primary focus:border-primary ${
+ className={`border-border-default focus:ring-primary focus:border-primary block w-full min-w-0 flex-1 rounded-md border px-3 py-2 focus:outline-none ${
selectedPath ? "rounded-l-none" : ""
}`}
placeholder={
@@ -230,11 +243,11 @@ export function PageLocationEditor({
{/* Folder options section - only shown when moving a page with children */}
{mode === "move" && hasChildren && (
-
-
+
+
This page has child pages
-
+
-
+
{moveRecursively
? "All child pages will be moved to maintain the hierarchy."
: "Only this page will be moved, which could create gaps in your wiki structure."}
@@ -253,7 +266,7 @@ export function PageLocationEditor({
{mode === "create" && (
-
+
Create Type
-
+
Parent Folder
diff --git a/apps/web/src/components/wiki/RandomNumberDisplay.tsx b/apps/web/src/components/wiki/RandomNumberDisplay.tsx
new file mode 100644
index 0000000..07deddb
--- /dev/null
+++ b/apps/web/src/components/wiki/RandomNumberDisplay.tsx
@@ -0,0 +1,84 @@
+"use client";
+
+import { useSubscription } from "@trpc/tanstack-react-query";
+import type { TRPCClientErrorLike } from "@trpc/client";
+import React, { useEffect, useState } from "react";
+import { useTRPC } from "~/server/client";
+import type { AppRouter } from "~/server/routers";
+import { useNotification } from "~/lib/hooks/useNotification";
+import { logger } from "~/lib/utils/logger";
+
+/**
+ * Displays a random number received from a tRPC subscription (Header Version).
+ */
+export function RandomNumberDisplay() {
+ const [randomNumber, setRandomNumber] = useState
(null);
+ const [error, setError] = useState(null);
+ const [connectionState, setConnectionState] = useState(null);
+ const trpc = useTRPC();
+ const notification = useNotification();
+
+ const test = useSubscription(
+ trpc.wiki.randomNumber.subscriptionOptions(
+ undefined, // Pass input first (undefined for this procedure)
+ {
+ // Pass options object second
+ onData: (data: { randomNumber: number; completed: boolean }) => {
+ logger.debug("Received random number:", data);
+ setRandomNumber(data.randomNumber);
+ if (data.completed) {
+ setConnectionState("completed");
+ }
+ setError(null); // Clear error on successful data reception
+ },
+ onError: (err: TRPCClientErrorLike) => {
+ notification.error("Subscription error: " + err.message);
+ setRandomNumber(null); // Clear number on error
+ setConnectionState("error");
+ },
+ onStarted: () => {
+ logger.debug("Subscription started");
+ setError(null);
+ },
+ }
+ )
+ );
+
+ useEffect(() => {
+ // Effect for logging status changes
+ logger.debug("Subscription status:", test.status);
+ setConnectionState(test.status);
+ }, [test.status]);
+
+ let indicatorBgClass = "bg-muted-foreground"; // Default pending/initial
+ let displayText = "...";
+
+ if (error) {
+ indicatorBgClass = "bg-error-500";
+ displayText = error;
+ } else if (connectionState === "completed") {
+ indicatorBgClass = "bg-success-500"; // Green checkmark for completed
+ displayText = "Done"; // Show checkmark when completed
+ } else if (
+ randomNumber !== null &&
+ (connectionState === "success" || connectionState === "pending")
+ ) {
+ // It can be 'pending' but already received data via onData before status updates
+ indicatorBgClass = "bg-success-500"; // Green for active success
+ displayText = randomNumber.toFixed(2);
+ } else if (
+ (connectionState === "success" || connectionState === "pending") &&
+ randomNumber === null
+ ) {
+ // Pending/Success but no data yet
+ indicatorBgClass = "bg-muted-foreground"; // Use gray for pending before first data or if success but no data yet
+ displayText = "...";
+ }
+
+ return (
+
+
+ {displayText}
+
+ );
+}
diff --git a/src/components/wiki/WikiBrowser.tsx b/apps/web/src/components/wiki/WikiBrowser.tsx
similarity index 73%
rename from src/components/wiki/WikiBrowser.tsx
rename to apps/web/src/components/wiki/WikiBrowser.tsx
index aef44de..775ced8 100644
--- a/src/components/wiki/WikiBrowser.tsx
+++ b/apps/web/src/components/wiki/WikiBrowser.tsx
@@ -4,10 +4,12 @@ import { useState, useRef } from "react";
import Link from "next/link";
import { WikiFolderTree } from "./WikiFolderTree";
import { SearchIcon, PlusIcon } from "lucide-react";
-import { trpc } from "~/lib/trpc/client";
+import { useTRPC } from "~/server/client";
+import { useQuery } from "@tanstack/react-query";
import { PageLocationEditor } from "./PageLocationEditor";
-import { Button } from "~/components/ui/button";
-import { SkeletonText } from "~/components/ui/skeleton";
+import { Button } from "@repo/ui";
+import { SkeletonText } from "@repo/ui";
+import { ClientRequirePermission } from "../auth/permission/client";
interface WikiBrowserProps {
/**
@@ -33,9 +35,11 @@ export function WikiBrowser({ initialSearch = "" }: WikiBrowserProps) {
}, 300);
};
+ const trpc = useTRPC();
+
// Fetch search results if search is active
- const { data: searchResults, isLoading: isSearching } =
- trpc.wiki.list.useQuery(
+ const { data: searchResults, isLoading: isSearching } = useQuery(
+ trpc.wiki.list.queryOptions(
{
limit: 20,
search: debouncedSearch,
@@ -45,31 +49,34 @@ export function WikiBrowser({ initialSearch = "" }: WikiBrowserProps) {
{
enabled: debouncedSearch.length > 0,
}
- );
+ )
+ );
return (
{/* Search bar */}
-
-
-
+
+
+
handleSearchChange(e.target.value)}
/>
-
setIsCreateModalOpen(true)}
- className="ml-4"
- size="default"
- >
-
- New Page
-
+
+ setIsCreateModalOpen(true)}
+ className="ml-4"
+ size="default"
+ >
+
+ New Page
+
+
{/* Search results */}
@@ -88,7 +95,7 @@ export function WikiBrowser({ initialSearch = "" }: WikiBrowserProps) {
No results found for “{debouncedSearch}”
) : (
-
+
{searchResults?.pages.map((page) => (
@@ -96,7 +103,7 @@ export function WikiBrowser({ initialSearch = "" }: WikiBrowserProps) {
href={`/${page.path}`}
className="block px-5 py-4 hover:bg-slate-50"
>
-
+
{page.title}
diff --git a/apps/web/src/components/wiki/WikiEditor.tsx b/apps/web/src/components/wiki/WikiEditor.tsx
new file mode 100644
index 0000000..3fc82f3
--- /dev/null
+++ b/apps/web/src/components/wiki/WikiEditor.tsx
@@ -0,0 +1,979 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { useState, useEffect, useRef, useCallback } from "react";
+import { useTRPC } from "~/server/client";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { useNotification } from "~/lib/hooks/useNotification";
+import { MarkdownProse } from "./MarkdownProse";
+import CodeMirror, {
+ EditorView,
+ type ReactCodeMirrorRef,
+} from "@uiw/react-codemirror";
+import { markdown, markdownLanguage } from "@codemirror/lang-markdown";
+import { languages } from "@codemirror/language-data";
+import {
+ syntaxHighlighting,
+ defaultHighlightStyle,
+ HighlightStyle,
+} from "@codemirror/language";
+import { tags } from "@lezer/highlight";
+import { xcodeLight, xcodeDark } from "@uiw/codemirror-theme-xcode";
+import { HighlightedMarkdown } from "~/lib/markdown/client";
+import { AssetManager } from "./AssetManager";
+import { Button } from "@repo/ui";
+import { Badge } from "@repo/ui";
+import { Tabs, TabsList, TabsTrigger, TabsContent } from "@repo/ui";
+import {
+ Popover,
+ PopoverTrigger,
+ PopoverContent,
+ PopoverAnchor,
+} from "@repo/ui";
+import { Input } from "@repo/ui";
+import { Label } from "@repo/ui";
+import { ThemeToggle } from "../layout/theme-toggle";
+import {
+ X,
+ ChevronDown,
+ Image,
+ File,
+ Save,
+ ArrowLeft,
+ Loader2,
+} from "lucide-react";
+import { Command, CommandList, CommandItem, CommandEmpty } from "@repo/ui";
+import { logger } from "~/lib/utils/logger";
+
+const MAX_VIEWABLE_FILE_SIZE_MB = 10;
+
+// Basic debounce hook implementation
+function useDebounce
(value: T, delay: number): T {
+ const [debouncedValue, setDebouncedValue] = useState(value);
+
+ useEffect(() => {
+ const handler = setTimeout(() => {
+ setDebouncedValue(value);
+ }, delay);
+
+ return () => {
+ clearTimeout(handler);
+ };
+ }, [value, delay]);
+
+ return debouncedValue;
+}
+
+// Custom highlight style for markdown
+// TODO: Try to use nested styles
+const markdownHighlightStyle = HighlightStyle.define([
+ { tag: tags.heading, fontWeight: "bold", color: "var(--color-primary)" },
+ { tag: tags.heading1, fontSize: "1.6em", color: "var(--color-accent)" },
+ { tag: tags.heading2, fontSize: "1.4em", color: "var(--color-accent)" },
+ { tag: tags.heading3, fontSize: "1.2em", color: "var(--color-secondary)" },
+ { tag: tags.strong, fontWeight: "bold", color: "var(--color-complementary)" },
+ {
+ tag: tags.emphasis,
+ fontStyle: "italic",
+ color: "var(--color-text-secondary)",
+ },
+ { tag: tags.link, color: "var(--color-complementary)" },
+ { tag: tags.url, color: "var(--color-complementary)" },
+ { tag: tags.escape, color: "var(--color-complementary)" },
+ { tag: tags.list, color: "var(--color-text-secondary)" },
+ { tag: tags.quote, color: "var(--color-primary)" },
+ { tag: tags.comment, color: "var(--color-accent)" },
+ {
+ tag: tags.monospace,
+ color: "var(--color-text-primary)",
+ backgroundColor: "var(--color-background-level3)",
+ },
+ { tag: tags.meta, color: "var(--color-text-secondary)" },
+]);
+
+// Enhanced extensions for better markdown handling
+const editorExtensions = [
+ markdown({
+ base: markdownLanguage,
+ codeLanguages: languages,
+ }),
+ syntaxHighlighting(markdownHighlightStyle),
+ syntaxHighlighting(defaultHighlightStyle),
+];
+
+interface WikiEditorProps {
+ mode: "create" | "edit";
+ pageId?: number;
+ initialTitle?: string;
+ initialContent?: string;
+ initialTags?: string[];
+ pagePath?: string;
+}
+
+export function WikiEditor({
+ mode = "create",
+ pageId,
+ initialTitle = "",
+ initialContent = "",
+ initialTags = [],
+ pagePath = "",
+}: WikiEditorProps) {
+ const router = useRouter();
+ const notification = useNotification();
+ const [title, setTitle] = useState(initialTitle);
+ const [content, setContent] = useState(initialContent);
+ const [tagInput, setTagInput] = useState("");
+ const [tags, setTags] = useState(initialTags);
+ const [isLocked, setIsLocked] = useState(mode === "create");
+ const [tagSuggestions, setTagSuggestions] = useState([]);
+ const [showSuggestions, setShowSuggestions] = useState(false);
+ const [isSaving, setIsSaving] = useState(false);
+ const [activeTab, setActiveTab] = useState("editor");
+ const [showAssetManager, setShowAssetManager] = useState(false);
+ const [editingTitle, setEditingTitle] = useState(false);
+ const [unsavedChanges, setUnsavedChanges] = useState(false);
+ const editorRef = useRef(null);
+ const fileInputRef = useRef(null);
+ const titleInputRef = useRef(null);
+ const popoverContentRef = useRef(null);
+ const lockAcquiredRef = useRef(false);
+ const isDarkMode =
+ typeof window !== "undefined"
+ ? document.documentElement.classList.contains("dark")
+ : false;
+ const trpc = useTRPC();
+
+ const debouncedTagInput = useDebounce(tagInput, 300);
+
+ const queryClient = useQueryClient();
+ const assetsQueryKey = trpc.assets.getPaginated.queryKey();
+
+ // TRPC mutation for uploading assets
+ const uploadAssetMutation = useMutation(
+ trpc.assets.upload.mutationOptions({
+ onSuccess: () => {
+ // Logic moved to notification.promise success handler
+ // We might still need some non-notification logic here in the future,
+ // but for now, it's handled by the promise.
+ },
+ onError: (error) => {
+ // Error handling is now primarily managed by notification.promise
+ logger.error("Error uploading asset:", error);
+ },
+ })
+ );
+
+ // Upload file to server using TRPC mutation
+ const handleFileUpload = useCallback(
+ async (file: File) => {
+ if (!file) return;
+
+ try {
+ const reader = new FileReader();
+ reader.onloadend = () => {
+ const base64Data = reader.result as string;
+
+ // Use notification.promise
+ notification.promise(
+ uploadAssetMutation.mutateAsync({
+ fileName: file.name,
+ fileType: file.type,
+ fileSize: file.size,
+ data: base64Data, // Pass the base64 data URI
+ pageId: pageId || null, // Ensure pageId is number or null
+ }),
+ {
+ loading: `Uploading ${file.name}...`,
+ success: (asset) => {
+ // Insert markdown link for the uploaded asset
+ const isViewable =
+ asset.fileType.startsWith("image/") ||
+ asset.fileType.startsWith("video/");
+ const fileSizeMB = asset.fileSize / (1024 * 1024);
+
+ let assetMarkdown = "";
+ if (isViewable) {
+ if (fileSizeMB <= MAX_VIEWABLE_FILE_SIZE_MB) {
+ assetMarkdown = ``; // Image link
+ } else {
+ notification.info(
+ `Asset ${asset.fileName} is too large to view inline. We will display a link instead.`
+ );
+ assetMarkdown = `[${asset.fileName}](/api/assets/${asset.id})`; // Standard link
+ }
+ } else {
+ assetMarkdown = `[${asset.fileName}](/api/assets/${asset.id})`; // Standard link
+ }
+
+ // Insert at current cursor position or append to content
+ // TODO: Implement inserting at cursor position using editorRef
+ setContent((current) => current + "\n" + assetMarkdown + "\n");
+ setUnsavedChanges(true);
+ return `Asset ${asset.fileName} uploaded successfully`; // Return success message
+ },
+ error: (error) => {
+ logger.error("Error uploading asset:", error);
+ return `Failed to upload asset: ${error.message}`; // Return error message
+ },
+ }
+ );
+ };
+ reader.readAsDataURL(file); // Read as data URL
+ } catch (error) {
+ logger.error("Error uploading image:", error);
+ notification.error("Failed to upload image");
+ } finally {
+ queryClient.invalidateQueries({ queryKey: assetsQueryKey });
+ }
+ },
+ [pageId, notification, uploadAssetMutation, queryClient, assetsQueryKey] // Added dependencies
+ );
+
+ // CodeMirror event handler extension for paste and drop
+ const cmEventHandlers = EditorView.domEventHandlers({
+ paste: (event: ClipboardEvent, view: EditorView) => {
+ logger.debug("CodeMirror paste event triggered");
+ logger.debug("Pasted into view:", view); // Example use of view to satisfy linter
+ const files = Array.from(event.clipboardData?.files || []);
+ const items = Array.from(event.clipboardData?.items || []);
+
+ // Optional: Log detected items for debugging
+ logger.debug(
+ "CM Clipboard Files:",
+ files.map((f) => ({ name: f.name, type: f.type, size: f.size }))
+ );
+ logger.debug(
+ "CM Clipboard Items:",
+ items.map((item) => ({ kind: item.kind, type: item.type }))
+ );
+
+ // Prioritize actual files
+ if (files.length > 0) {
+ // Check if the first file is suitable (could be image or other type)
+ const fileToUpload = files[0];
+ if (fileToUpload) {
+ logger.log("CM: Found file in files:", fileToUpload.name);
+ event.preventDefault();
+ handleFileUpload(fileToUpload);
+ return true; // Indicate we handled the event
+ }
+ }
+
+ // Fallback: check items
+ const imageItem = items.find(
+ (item) => item.kind === "file" // Find any file item, regardless of type
+ );
+
+ if (imageItem) {
+ logger.log("CM: Found file item (kind=file)", imageItem.type);
+ event.preventDefault();
+ const file = imageItem.getAsFile();
+ if (file) {
+ logger.log("CM: Got file from image item:", file.name);
+ handleFileUpload(file); // Use the renamed file upload handler
+ return true; // Indicate we handled the event
+ }
+ }
+ logger.log(
+ "CM: No suitable file found in clipboard data, allowing default paste."
+ );
+ return false; // Allow default paste if no image found/handled
+ },
+ drop: (event: DragEvent, view: EditorView) => {
+ logger.log("CodeMirror drop event triggered");
+ logger.log("Dropped onto view:", view); // Example use of view to satisfy linter
+ event.preventDefault();
+ const file = event.dataTransfer?.files[0];
+ // Handle any dropped file type
+ if (file) {
+ logger.log("CM: Handling dropped image:", file.name);
+ handleFileUpload(file); // Use the renamed file upload handler
+ return true; // Indicate we handled the event
+ }
+ return false; // Allow default drop if not handled
+ },
+ });
+
+ // Content change tracking
+ useEffect(() => {
+ if (content !== initialContent) {
+ setUnsavedChanges(true);
+ }
+ }, [content, initialContent]);
+
+ // Create page mutation
+ const createPageMutation = useMutation(
+ trpc.wiki.create.mutationOptions({
+ onSuccess: (data) => {
+ notification.success("Page created successfully");
+ setUnsavedChanges(false);
+ // Navigate to new page
+ router.push(`/${data.path}`);
+ },
+ onError: (error) => {
+ setIsSaving(false);
+ notification.error(`Failed to create page: ${error.message}`);
+ },
+ })
+ );
+
+ // Update page mutation
+ const updatePageMutation = useMutation(
+ trpc.wiki.update.mutationOptions({
+ onSuccess: () => {
+ notification.success("Page updated successfully");
+ setUnsavedChanges(false);
+ if (pagePath) {
+ router.push(`/${pagePath}`);
+ } else {
+ router.push("/wiki");
+ }
+ },
+ onError: (error) => {
+ setIsSaving(false);
+ notification.error(`Failed to update page: ${error.message}`);
+ },
+ })
+ );
+
+ // Lock management (only for edit mode)
+ const acquireLockMutation = useMutation(
+ trpc.wiki.acquireLock.mutationOptions({
+ onSuccess: (data) => {
+ if (data && "success" in data && data.success) {
+ setIsLocked(true);
+ notification.success("You now have edit access to this page");
+ } else if (data && "page" in data && data.page?.lockedById) {
+ // Lock acquisition failed because someone else has the lock
+ notification.error("This page is being edited by another user");
+ // Navigate back
+ navigateBack();
+ } else {
+ // Generic failure
+ notification.error(
+ "Could not acquire edit lock. Please try again later."
+ );
+ // Navigate back
+ navigateBack();
+ }
+ },
+ onError: (error) => {
+ notification.error(`Failed to acquire edit lock: ${error.message}`);
+ // If we can't get the lock, go back to the page
+ navigateBack();
+ },
+ })
+ );
+
+ const releaseLockMutation = useMutation(
+ trpc.wiki.releaseLock.mutationOptions({
+ onSuccess: () => {
+ notification.success("Lock released successfully");
+ },
+ })
+ );
+
+ // Add refresh lock mutation
+ const refreshLockMutation = useMutation(
+ trpc.wiki.refreshLock.mutationOptions({
+ onError: (error) => {
+ notification.error(`Lock expired: ${error.message}`);
+ // Lock expired, go back to the wiki page
+ navigateBack();
+ },
+ })
+ );
+
+ // Navigate back helper
+ const navigateBack = useCallback(() => {
+ router.refresh();
+ if (pagePath) {
+ router.push(`/${pagePath}`);
+ } else {
+ router.push("/wiki");
+ }
+ }, [router, pagePath]);
+
+ // Acquire lock on component mount (only in edit mode)
+ useEffect(() => {
+ if (mode === "edit" && pageId && !lockAcquiredRef.current) {
+ lockAcquiredRef.current = true;
+ acquireLockMutation.mutate({ id: pageId });
+ }
+
+ // Release lock when component unmounts
+ return () => {
+ if (isLocked && pageId) {
+ releaseLockMutation.mutate({ id: pageId });
+ }
+ };
+ }, [mode, pageId, isLocked]);
+
+ // Add lock refresh interval (only in edit mode)
+ useEffect(() => {
+ let refreshInterval: NodeJS.Timeout | undefined;
+
+ if (mode === "edit" && isLocked && pageId) {
+ // Refresh the lock every 5 minutes to prevent timeout
+ refreshInterval = setInterval(
+ () => {
+ refreshLockMutation.mutate({ id: pageId });
+ },
+ 5 * 60 * 1000
+ ); // 5 minutes
+ }
+
+ return () => {
+ if (refreshInterval) {
+ clearInterval(refreshInterval);
+ }
+ };
+ }, [mode, isLocked, pageId]);
+
+ // Fetch tag suggestions when debounced input changes
+ const { data: fetchedSuggestions } = useQuery({
+ ...trpc.tags.search.queryOptions({
+ query: debouncedTagInput,
+ limit: 3, // Limit suggestions on the backend,
+ }),
+ enabled: debouncedTagInput.length > 0 && showSuggestions, // Other options merged here
+ });
+
+ // Update suggestions state when fetched data changes
+ useEffect(() => {
+ if (fetchedSuggestions) {
+ // Define expected tag type for suggestions
+ type TagSuggestion = { id: number; name: string };
+
+ // Filter out tags already added
+ setTagSuggestions(
+ (fetchedSuggestions as TagSuggestion[])
+ .map((tag: TagSuggestion) => tag.name)
+ .filter((name: string) => !tags.includes(name))
+ );
+ } else {
+ // Clear suggestions if fetched data is undefined (e.g., query disabled or loading)
+ setTagSuggestions([]);
+ }
+ }, [fetchedSuggestions, tags]);
+
+ // Focus title input when editing title
+ useEffect(() => {
+ if (editingTitle && titleInputRef.current) {
+ titleInputRef.current.focus();
+ }
+ }, [editingTitle]);
+
+ // Tag management
+ const handleAddTag = () => {
+ if (tagInput.trim() && !tags.includes(tagInput.trim())) {
+ setTags([...tags, tagInput.trim()]);
+ setTagInput("");
+ setTagSuggestions([]);
+ setShowSuggestions(false);
+ setUnsavedChanges(true);
+ }
+ };
+
+ const handleRemoveTag = (tagToRemove: string) => {
+ setTags(tags.filter((tag) => tag !== tagToRemove));
+ setUnsavedChanges(true);
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ handleAddTag();
+ }
+ };
+
+ // Title management
+ const handleTitleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ setEditingTitle(false);
+ } else if (e.key === "Escape") {
+ setEditingTitle(false);
+ }
+ };
+
+ const handleTitleBlur = () => {
+ setEditingTitle(false);
+ };
+
+ const handleTitleChange = (e: React.ChangeEvent) => {
+ setTitle(e.target.value);
+ setUnsavedChanges(true);
+ };
+
+ // Save & cancel handlers
+ const handleSave = (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!title.trim()) {
+ notification.error("Please enter a title for the page");
+ return;
+ }
+
+ setIsSaving(true);
+
+ if (mode === "create") {
+ createPageMutation.mutate({
+ title,
+ content,
+ path: pagePath,
+ isPublished: true,
+ tags,
+ });
+ } else if (mode === "edit" && pageId) {
+ updatePageMutation.mutate({
+ id: pageId,
+ path: pagePath,
+ title,
+ content,
+ isPublished: true,
+ tags,
+ });
+ }
+ };
+
+ const handleCancel = () => {
+ // Ask for confirmation if there are unsaved changes
+ if (unsavedChanges) {
+ if (
+ !window.confirm(
+ "You have unsaved changes. Are you sure you want to leave?"
+ )
+ ) {
+ return;
+ }
+ }
+
+ // Release the lock if in edit mode
+ if (mode === "edit" && isLocked && pageId) {
+ releaseLockMutation.mutate(
+ { id: pageId },
+ {
+ onSuccess: () => {
+ navigateBack();
+ },
+ }
+ );
+ } else {
+ // If no lock to release, just navigate back
+ navigateBack();
+ }
+ };
+
+ // Handle file input change
+ const handleFileInputChange = (e: React.ChangeEvent) => {
+ const files = e.target.files;
+ if (files && files.length > 0) {
+ if (!files[0]) {
+ throw new Error("File is undefined");
+ }
+ void handleFileUpload(files[0]); // Use the renamed file upload handler
+ }
+ };
+
+ // Trigger file input click
+ const triggerFileUpload = () => {
+ if (fileInputRef.current) {
+ fileInputRef.current.click();
+ }
+ };
+
+ // Handle asset selection from asset manager
+ const handleAssetSelect = (
+ assetUrl: string,
+ assetName: string,
+ fileType: string
+ ) => {
+ // Format for image vs other assets
+ const isImage = fileType.startsWith("image/");
+ const markdownLink = isImage
+ ? ``
+ : `[${assetName}](${assetUrl})`;
+
+ // Insert at cursor position or append to content
+ setContent((current) => current + "\n" + markdownLink + "\n");
+ setUnsavedChanges(true);
+
+ // Close the asset manager
+ setShowAssetManager(false);
+ };
+
+ const getEditorTheme = () => {
+ return isDarkMode ? xcodeDark : xcodeLight;
+ };
+
+ // If we're waiting for lock acquisition in edit mode, show loading
+ if (mode === "edit" && !isLocked) {
+ return (
+
+
+
+
+ Acquiring edit lock...
+
+
+ Please wait while we secure exclusive access to edit this page
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Header Bar */}
+
+
+
+
+
+ Back
+
+
+ {editingTitle ? (
+
+ ) : (
+
setEditingTitle(true)}
+ title="Click to edit title"
+ >
+ {title || "Untitled"}
+
+ )}
+
+ {mode === "edit" && isLocked && (
+
+ Editing
+
+ )}
+
+ {pagePath && (
+
+ {pagePath}
+
+ )}
+
+ {unsavedChanges && (
+
+ Unsaved Changes
+
+ )}
+
+
+
+ {/* Asset Management Button with Dropdown */}
+
+
+
+ Assets
+
+
+
+
+
+
+
+ Upload Image
+
+ setShowAssetManager(true)}
+ className="flex items-center justify-start gap-2"
+ >
+
+ Asset Manager
+
+
+
+
+
+ {/* Hidden File Input - Kept for triggerFileUpload functionality */}
+
+
+
+ {isSaving ? (
+ <>
+
+ Saving
+ >
+ ) : (
+ <>
+
+ Save
+ >
+ )}
+
+
+ {/* Theme Toggle */}
+
+
+
+
+
+ {/* Metadata Section */}
+
+
+
+ Tags:
+
+
+
+ {/* Tag Input with Suggestions Popover */}
+
+
+
+ Add
+
+
+
+ {tags.length > 0 ? (
+
+ {tags.map((tag) => (
+
+ {tag}
+ handleRemoveTag(tag)}
+ className="hover:bg-primary-100 hover:text-primary-700 rounded-full"
+ >
+
+
+
+ ))}
+
+ ) : (
+
+ No tags
+
+ )}
+
+
+
+ {/* Editor Tabs and Content */}
+
+
+
+
+
+ Editor
+
+
+ Preview
+
+
+ Split View
+
+
+
+
+ {/* Editor Tab */}
+
+
+ setContent(value)}
+ className="h-full overflow-hidden"
+ placeholder="Write your content using Markdown..."
+ theme={getEditorTheme()}
+ lang="markdown"
+ />
+
+
+
+ {/* Preview Tab */}
+
+
+
+
+
+
+
+
+ {/* Split View Tab */}
+
+
+ {/* Editor Panel */}
+
+ setContent(value)}
+ className="h-full overflow-hidden"
+ placeholder="Write your content using Markdown..."
+ theme={getEditorTheme()}
+ />
+
+
+ {/* Preview Panel */}
+
+
+
+
+
+
+ {/* Asset Manager Modal */}
+
setShowAssetManager(false)}
+ onSelectAsset={(assetUrl, assetName, fileType) =>
+ handleAssetSelect(assetUrl, assetName, fileType)
+ }
+ pageId={pageId}
+ />
+
+ );
+}
diff --git a/src/components/wiki/WikiFolderTree.tsx b/apps/web/src/components/wiki/WikiFolderTree.tsx
similarity index 72%
rename from src/components/wiki/WikiFolderTree.tsx
rename to apps/web/src/components/wiki/WikiFolderTree.tsx
index 5eafa16..d2dede2 100644
--- a/src/components/wiki/WikiFolderTree.tsx
+++ b/apps/web/src/components/wiki/WikiFolderTree.tsx
@@ -11,9 +11,11 @@ import {
PencilIcon,
MoveIcon,
} from "lucide-react";
-import { trpc } from "~/lib/trpc/client";
-import Modal from "~/components/ui/modal";
+import { useTRPC } from "~/server/client";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { Modal } from "@repo/ui";
import { PageLocationEditor } from "./PageLocationEditor";
+import { ClientRequirePermission } from "../auth/permission/client";
// Recursive interface for folder structure
interface FolderNode {
@@ -134,11 +136,13 @@ export function WikiFolderTree({
const [showMoveModal, setShowMoveModal] = useState(false);
const [renameConflict, setRenameConflict] = useState(false);
- const utils = trpc.useUtils();
+ const trpc = useTRPC();
+ const queryClient = useQueryClient();
// Fetch the complete folder structure
- const { data: folderStructure, isLoading } =
- trpc.wiki.getFolderStructure.useQuery();
+ const { data: folderStructure, isLoading } = useQuery(
+ trpc.wiki.getFolderStructure.queryOptions()
+ );
// Helper function to get parent path
const getParentPath = (path: string): string => {
@@ -151,17 +155,25 @@ export function WikiFolderTree({
? getParentPath(renamingNode.path)
: "";
- const getSubfoldersForRenameQuery = trpc.wiki.getSubfolders.useQuery(
- { path: parentPathForRename },
- { enabled: showRenameModal && renamingNode !== null }
+ const getSubfoldersForRenameQuery = useQuery(
+ trpc.wiki.getSubfolders.queryOptions(
+ { path: parentPathForRename },
+ { enabled: showRenameModal && renamingNode !== null }
+ )
);
+ const folderStructureQueryKey = trpc.wiki.getFolderStructure.queryKey();
+
// Create a mutation for updating a wiki page
- const updateMutation = trpc.wiki.update.useMutation({
- onSuccess: () => {
- utils.wiki.getFolderStructure.invalidate();
- },
- });
+ const updateMutation = useMutation(
+ trpc.wiki.update.mutationOptions({
+ onSuccess: () => {
+ queryClient.invalidateQueries({
+ queryKey: folderStructureQueryKey,
+ });
+ },
+ })
+ );
// Auto-expand folders in the current path
useEffect(() => {
@@ -174,11 +186,14 @@ export function WikiFolderTree({
// For each part of the path, expand its parent folder
for (let i = 0; i < pathParts.length; i++) {
- if (pathParts[i]) {
+ const part = pathParts[i];
+ if (part) {
+ // Build the path segment by segment
currentPathBuild = currentPathBuild
- ? `${currentPathBuild}/${pathParts[i]}`
- : pathParts[i];
+ ? `${currentPathBuild}/${part}`
+ : part;
+ // Mark this segment as expanded
newExpandedFolders[currentPathBuild] = true;
}
}
@@ -314,13 +329,13 @@ export function WikiFolderTree({
{hasChildren && !isWithinOpenDepth && (
toggleFolder(node.path, e)}
- className="p-1 mr-1 rounded focus:outline-none hover:bg-background-level2"
+ className="hover:bg-background-level2 mr-1 rounded p-1 focus:outline-none"
title={isExpanded ? "Collapse folder" : "Expand folder"}
>
{isExpanded ? (
-
+
) : (
-
+
)}
)}
@@ -329,7 +344,7 @@ export function WikiFolderTree({
{isFolder ? (
) : (
-
+
)}
-
-
+
+
{node.title || node.name}
-
+
{node.path}
@@ -355,7 +370,7 @@ export function WikiFolderTree({
{showPageCount && pageCount > 0 && (
{pageCount}p
@@ -363,7 +378,7 @@ export function WikiFolderTree({
{folderCount > 0 && (
{folderCount}f
@@ -373,31 +388,37 @@ export function WikiFolderTree({
{showActions && node.path !== "" && (
-
-
handleRename(node, e)}
- className="p-0.5 opacity-0 group-hover:opacity-100 transition-opacity"
- title="Rename"
- >
-
-
- {showMove && (
+
+
handleMove(node, e)}
- className="p-0.5 opacity-0 group-hover:opacity-100 transition-opacity"
- title="Move"
+ onClick={(e) => handleRename(node, e)}
+ className="p-0.5 opacity-0 transition-opacity group-hover:opacity-100"
+ title="Rename"
>
-
+
- )}
+
+
+ {showMove && (
+ handleMove(node, e)}
+ className="p-0.5 opacity-0 transition-opacity group-hover:opacity-100"
+ title="Move"
+ >
+
+
+ )}
+
-
handleNewFolder(node.path, e)}
- className="p-0.5 opacity-0 group-hover:opacity-100 transition-opacity"
- title="Create new folder"
- >
-
-
+
+ handleNewFolder(node.path, e)}
+ className="p-0.5 opacity-0 transition-opacity group-hover:opacity-100"
+ title="Create new folder"
+ >
+
+
+
)}
>
@@ -405,11 +426,11 @@ export function WikiFolderTree({
return (
-
+
{mode === "navigation" ? (
) : (
{!hideHeader && (
-
-
{title}
- {showActions && (
-
handleNewFolder("", e)}
- className="p-1 rounded-md hover:bg-background-level2"
- title="Create new root folder"
- >
-
-
- )}
+
+
{title}
+
+ {showActions && (
+ handleNewFolder("", e)}
+ className="hover:bg-background-level2 rounded-md p-1"
+ title="Create new root folder"
+ >
+
+
+ )}
+
)}
-
+
{renderFolderStructure()}
{(!folderStructure ||
(folderStructure.children.length === 0 && !showOnlyChildren)) && (
@@ -517,24 +540,24 @@ export function WikiFolderTree({
{/* Legend */}
{showLegend && (
-
+
-
-
+
+
Real folder
-
-
+
+
Virtual folder
-
-
+
+
1p
Page count
-
-
+
+
1f
Subfolder count
@@ -557,7 +580,9 @@ export function WikiFolderTree({
}));
}
// Refresh the folder structure after creation
- utils.wiki.getFolderStructure.invalidate();
+ queryClient.invalidateQueries({
+ queryKey: folderStructureQueryKey,
+ });
}}
initialPath={newFolderPath}
/>
@@ -575,10 +600,10 @@ export function WikiFolderTree({
Rename {renamingNode.type === "folder" ? "Folder" : "Page"}
-
+
Current Name
-
+
{renamingNode.title || renamingNode.name}
@@ -586,7 +611,7 @@ export function WikiFolderTree({
New Name
@@ -604,7 +629,7 @@ export function WikiFolderTree({
setNewName(e.target.value);
setRenameConflict(false);
}}
- className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 ${
+ className={`w-full rounded-md border px-3 py-2 focus:outline-none focus:ring-2 ${
renameConflict
? "border-red-500 focus:ring-red-200"
: "focus:ring-primary"
@@ -615,13 +640,13 @@ export function WikiFolderTree({
setShowRenameModal(false)}
- className="px-3 py-1.5 text-sm font-medium rounded-md text-text-secondary hover:bg-background-level2 transition-colors border border-border-default"
+ className="text-text-secondary hover:bg-background-level2 border-border-default rounded-md border px-3 py-1.5 text-sm font-medium transition-colors"
>
Cancel
Rename
diff --git a/apps/web/src/components/wiki/WikiLockInfo.tsx b/apps/web/src/components/wiki/WikiLockInfo.tsx
new file mode 100644
index 0000000..7721975
--- /dev/null
+++ b/apps/web/src/components/wiki/WikiLockInfo.tsx
@@ -0,0 +1,122 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { useTRPC } from "~/server/client";
+import { useMutation } from "@tanstack/react-query";
+import { useNotification } from "~/lib/hooks/useNotification";
+import { formatDistanceToNow } from "date-fns";
+import { Button } from "@repo/ui";
+
+interface WikiLockInfoProps {
+ pageId: number;
+ isLocked: boolean;
+ lockedByName: string | null;
+ lockedUntil?: string | null;
+ isCurrentUserLockOwner: boolean;
+ editPath: string;
+}
+
+export function WikiLockInfo({
+ pageId,
+ isLocked,
+ lockedByName,
+ lockedUntil,
+ isCurrentUserLockOwner,
+ editPath,
+}: WikiLockInfoProps) {
+ const router = useRouter();
+ const notification = useNotification();
+ const trpc = useTRPC();
+
+ // Force lock release mutation
+ const releaseLockMutation = useMutation(
+ trpc.wiki.releaseLock.mutationOptions({
+ onSuccess: () => {
+ notification.success("Lock released successfully");
+ router.refresh();
+ },
+ onError: (error) => {
+ notification.error(`Failed to release lock: ${error.message}`);
+ },
+ })
+ );
+
+ // Handle edit button click
+ const handleEdit = () => {
+ router.push(editPath);
+ };
+
+ // Handle release lock button click
+ const handleReleaseLock = () => {
+ releaseLockMutation.mutate({ id: pageId });
+ };
+
+ if (!isLocked) {
+ return (
+
+
+
+
+
+ Unlocked
+
+
+ Edit
+
+
+ );
+ }
+
+ // If locked, render the lock status and relevant actions
+ return (
+
+
+
+ {isCurrentUserLockOwner
+ ? "You are currently editing this page"
+ : `This page is being edited by ${lockedByName || "another user"}`}
+
+ {lockedUntil && new Date(lockedUntil) > new Date() && (
+
+ Lock expires{" "}
+ {formatDistanceToNow(new Date(lockedUntil), { addSuffix: true })}
+
+ )}
+
+
+
+ {isCurrentUserLockOwner ? (
+ <>
+
+ Continue Editing
+
+
+ Release Lock
+
+ >
+ ) : null}
+
+
+ );
+}
diff --git a/apps/web/src/components/wiki/WikiPage.tsx b/apps/web/src/components/wiki/WikiPage.tsx
new file mode 100644
index 0000000..51feb83
--- /dev/null
+++ b/apps/web/src/components/wiki/WikiPage.tsx
@@ -0,0 +1,360 @@
+"use client";
+
+import { formatDistanceToNow } from "date-fns";
+import Link from "next/link";
+import { ReactNode, useState, useEffect } from "react";
+import { WikiLockInfo } from "./WikiLockInfo";
+import { MoveIcon, PencilIcon } from "lucide-react";
+import { useRouter } from "next/navigation";
+import { WikiSubfolders } from "./WikiSubfolders";
+import { Breadcrumbs } from "./Breadcrumbs";
+import { useTRPC } from "~/server/client";
+import { useQuery, useMutation } from "@tanstack/react-query";
+import { Modal } from "@repo/ui";
+import { PageLocationEditor } from "./PageLocationEditor";
+import { ClientRequirePermission } from "~/components/auth/permission/client";
+import { ScrollArea } from "@repo/ui";
+import { Button } from "@repo/ui";
+interface WikiPageProps {
+ id: number;
+ title: string;
+ content: ReactNode;
+ createdBy?: { name: string; id: number };
+ updatedBy?: { name: string; id: number };
+ lockedBy?: { name: string; id: number } | null;
+ lockedAt?: Date | null;
+ lockExpiresAt?: Date | null;
+ createdAt: Date;
+ updatedAt: Date;
+ tags?: { id: number; name: string }[];
+ path: string;
+ currentUserId?: number;
+}
+
+export function WikiPage({
+ id,
+ title,
+ content,
+ createdBy,
+ updatedBy,
+ lockedBy,
+ lockExpiresAt,
+ createdAt,
+ updatedAt,
+ tags = [],
+ path,
+ currentUserId,
+}: WikiPageProps) {
+ const router = useRouter();
+ const [hasSubpages, setHasSubpages] = useState(false);
+ const [showRenameModal, setShowRenameModal] = useState(false);
+ const [newName, setNewName] = useState("");
+ const [showMoveModal, setShowMoveModal] = useState(false);
+ const [renameConflict, setRenameConflict] = useState(false);
+ const trpc = useTRPC();
+
+ // Check specific page permissions if needed
+ const { data: hasPageUpdatePermission } = useQuery(
+ trpc.auth.hasPagePermission.queryOptions(
+ { pageId: id, permission: "wiki:page:update" },
+ { enabled: !!id }
+ )
+ );
+
+ // Get utility functions for trpc
+ // const utils = trpc.useUtils();
+
+ // Create a mutation for updating a wiki page
+ const updateMutation = useMutation(
+ trpc.wiki.update.mutationOptions({
+ onSuccess: () => {
+ // utils.wiki.getFolderStructure.invalidate();
+ router.refresh();
+ },
+ })
+ );
+
+ // Determine if the page is currently locked
+ const isLocked = Boolean(
+ lockedBy && lockExpiresAt && new Date(lockExpiresAt) > new Date()
+ );
+
+ // Determine if the current user is the lock owner
+ const isCurrentUserLockOwner = Boolean(
+ currentUserId && lockedBy && lockedBy.id === currentUserId
+ );
+
+ // Handle rename action
+ const handleRename = (e: React.MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setNewName(title);
+ setRenameConflict(false);
+ setShowRenameModal(true);
+ };
+
+ // Handle rename submission
+ const renameNode = () => {
+ if (!newName.trim()) return;
+ setRenameConflict(false);
+
+ // Check if we can update the title
+ if (id) {
+ updateMutation.mutate({
+ id: id,
+ path: path,
+ title: newName,
+ content: undefined, // Keep existing content
+ isPublished: undefined, // Keep existing publish state
+ });
+ }
+
+ setShowRenameModal(false);
+ };
+
+ // Handle move action
+ const handleMove = (e: React.MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setShowMoveModal(true);
+ };
+
+ // Check if the current page has subpages
+ const { data: folderStructure } = useQuery(
+ trpc.wiki.getFolderStructure.queryOptions()
+ );
+
+ useEffect(() => {
+ // Define the FolderNode interface to match the server type
+ interface FolderNode {
+ name: string;
+ path: string;
+ type: "folder" | "page";
+ children: FolderNode[];
+ id?: number;
+ title?: string;
+ updatedAt?: Date | string | null;
+ isPublished?: boolean | null;
+ }
+
+ // Helper function to find node by path
+ const findNodeByPath = (
+ nodePath: string,
+ tree: FolderNode | null
+ ): FolderNode | null => {
+ if (!tree) return null;
+ if (tree.path === nodePath) return tree;
+
+ for (const child of tree.children || []) {
+ const found = findNodeByPath(nodePath, child);
+ if (found) return found;
+ }
+
+ return null;
+ };
+
+ if (folderStructure && path) {
+ const node = findNodeByPath(path, folderStructure);
+ setHasSubpages(
+ Boolean(node && node.children && node.children.length > 0)
+ );
+ }
+ }, [folderStructure, path]);
+
+ return (
+
+
+ {/* Main content */}
+
+
+ {/* Breadcrumbs */}
+
+
+
+
{title}
+
+ {/* Combined Actions and Lock Info Area */}
+
+ {" "}
+ {/* Rename/Move Icons */}
+
+ {hasPageUpdatePermission && (
+
+ {" "}
+ {/* Container for icons */}
+
+
+
+
+
+
+
+ )}
+
+ {/* Lock status - Moved here */}
+
+ {hasPageUpdatePermission && (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ Updated {formatDistanceToNow(updatedAt, { addSuffix: true })}
+ {updatedBy ? ` by ${updatedBy.name}` : ""}
+
+
+ Created {formatDistanceToNow(createdAt, { addSuffix: true })}
+ {createdBy ? ` by ${createdBy.name}` : ""}
+
+
+
+ {tags.length > 0 && (
+
+ {tags.map((tag) => (
+
+ {tag.name}
+
+ ))}
+
+ )}
+
+
+ {/* FIXME: Temporary fix for content overflow */}
+
{content}
+
+
+ {/* Subfolders sidebar - only shown if page has subpages */}
+ {hasSubpages && (
+
+
+
+ )}
+
+ {/* Rename Modal */}
+ {showRenameModal && (
+
setShowRenameModal(false)}
+ size="md"
+ closeOnEscape={true}
+ showCloseButton={true}
+ className="w-full"
+ >
+
+
Rename Page
+
+
+ Current Name
+
+
+ {title}
+
+
+
+
+
+ New Name
+
+ {renameConflict && (
+
+ Name already exists
+
+ )}
+
+
{
+ setNewName(e.target.value);
+ setRenameConflict(false);
+ }}
+ className={`border-border-light bg-background-level1 w-full rounded-md border px-3 py-2 text-sm focus:outline-none focus:ring-1 ${
+ renameConflict
+ ? "border-red-500 focus:ring-red-200"
+ : "focus:ring-primary"
+ }`}
+ placeholder="New title"
+ />
+
+
+ setShowRenameModal(false)}
+ className="text-text-secondary hover:bg-background-level2 border-border-light rounded-md border px-3 py-1.5 text-sm font-medium transition-colors"
+ >
+ Cancel
+
+
+ Rename
+
+
+
+
+ )}
+
+ {/* Move Modal using PageLocationEditor */}
+ {showMoveModal && (
+
{
+ setShowMoveModal(false);
+ }}
+ initialPath={path}
+ pageId={id}
+ pageTitle={title}
+ initialName={title.split("/").pop() || title}
+ />
+ )}
+
+
+ );
+}
diff --git a/apps/web/src/components/wiki/WikiPageList.tsx b/apps/web/src/components/wiki/WikiPageList.tsx
new file mode 100644
index 0000000..b3e2ab9
--- /dev/null
+++ b/apps/web/src/components/wiki/WikiPageList.tsx
@@ -0,0 +1,165 @@
+"use client";
+
+import { formatDistanceToNow } from "date-fns";
+import Link from "next/link";
+import { Skeleton } from "@repo/ui";
+import type { inferRouterOutputs } from "@trpc/server";
+import type { AppRouter } from "~/server/routers";
+
+type RouterOutput = inferRouterOutputs;
+type WikiPageListItem = RouterOutput["wiki"]["list"]["pages"][number];
+
+type SortField = "title" | "updatedAt";
+type SortOrder = "asc" | "desc";
+
+interface WikiPageListProps {
+ pages: WikiPageListItem[];
+ isLoading: boolean;
+ sortBy?: SortField;
+ sortOrder?: SortOrder;
+ onSortChange?: (field: SortField) => void;
+}
+
+export function WikiPageList({
+ pages,
+ isLoading,
+ sortBy,
+ sortOrder,
+ onSortChange,
+}: WikiPageListProps) {
+ const handleSort = (field: SortField) => {
+ if (onSortChange) {
+ onSortChange(field);
+ }
+ };
+
+ return (
+
+
+
+
+
+ handleSort("title")}
+ aria-disabled={!onSortChange}
+ >
+
+ Page Title
+ {onSortChange && sortBy === "title" && (
+
+
+
+ )}
+
+
+ handleSort("updatedAt")}
+ aria-disabled={!onSortChange}
+ >
+
+ Last Updated
+ {onSortChange && sortBy === "updatedAt" && (
+
+
+
+ )}
+
+
+
+
+
+ {isLoading ? (
+ Array(5)
+ .fill(0)
+ .map((_, index) => (
+
+
+
+
+
+
+
+
+ ))
+ ) : pages.length === 0 ? (
+
+
+ No pages found.
+
+
+ ) : (
+ pages.map((page) => (
+
+
+
+ {page.title}
+
+ {page.tags && page.tags.length > 0 && (
+
+ {page.tags.map(
+ (tagItem: WikiPageListItem["tags"][number]) => (
+
+ {tagItem.tag.name}
+
+ )
+ )}
+
+ )}
+
+
+ {page.updatedAt
+ ? formatDistanceToNow(new Date(page.updatedAt), {
+ addSuffix: true,
+ })
+ : "Never"}
+ {page.updatedBy ? ` by ${page.updatedBy.name}` : ""}
+
+
+ ))
+ )}
+
+
+
+
+ );
+}
diff --git a/src/components/wiki/WikiSubfolders.tsx b/apps/web/src/components/wiki/WikiSubfolders.tsx
similarity index 100%
rename from src/components/wiki/WikiSubfolders.tsx
rename to apps/web/src/components/wiki/WikiSubfolders.tsx
diff --git a/apps/web/src/config/AppConfig.ts b/apps/web/src/config/AppConfig.ts
new file mode 100644
index 0000000..1d0423b
--- /dev/null
+++ b/apps/web/src/config/AppConfig.ts
@@ -0,0 +1,30 @@
+// Add these type definitions at the top
+type ThemeMode = "light" | "dark" | null;
+
+// FIXME: Update this configuration file based on your project information
+const AppConfig = {
+ name: "t3-custom-template",
+ website_name: "T3 Custom Template",
+ website_description: "T3 Custom Template",
+ reservedRoutes: [
+ "/api/trpc(.*)",
+ "/api/assets(.*)",
+ "/api/auth(.*)",
+ "/api(.*)",
+ "/wiki(.*)",
+ "/create(.*)",
+ "/tags(.*)",
+ "/admin(.*)",
+ "/dashboard(.*)",
+ "/login(.*)",
+ "/register(.*)",
+ "/profile(.*)",
+ ],
+ forceThemeMode: "dark" as ThemeMode,
+ maxNotifications: 5,
+ initialAppConfig: {
+ allowPublicViewers: false,
+ },
+};
+
+export default AppConfig;
diff --git a/apps/web/src/env.ts b/apps/web/src/env.ts
new file mode 100644
index 0000000..1d6fd25
--- /dev/null
+++ b/apps/web/src/env.ts
@@ -0,0 +1,68 @@
+import { createEnv } from "@t3-oss/env-nextjs";
+import { z } from "zod";
+
+export const env = createEnv({
+ /**
+ * Server-side environment variables schema
+ * Ensures app isn't built with invalid env vars
+ */
+ server: {
+ NODE_ENV: z.enum(["development", "production"]),
+ DATABASE_URL: z.string().url(),
+ NEXTAUTH_SECRET: z.string().min(1),
+ NEXTAUTH_URL: z.string().url().optional(),
+ GITHUB_CLIENT_ID: z.string().min(1),
+ GITHUB_CLIENT_SECRET: z.string().min(1),
+ GOOGLE_CLIENT_ID: z.string().min(1),
+ GOOGLE_CLIENT_SECRET: z.string().min(1),
+ OVERRIDE_MAX_LOG_LEVEL: z
+ .enum(["DEBUG", "INFO", "WARN", "ERROR"])
+ .optional(),
+ PROCESS_ORIGIN: z.enum(["WSS", "PROD", "NEXT"]).optional(),
+ },
+
+ /**
+ * Client-side environment variables schema
+ * Expose to client by prefixing with `NEXT_PUBLIC_`
+ */
+ client: {
+ NEXT_PUBLIC_DEV_MODE: z.enum(["true", "false"]).default("false"),
+ NEXT_PUBLIC_NODE_ENV: z
+ .enum(["development", "production"])
+ .default("development"),
+ // NEXT_PUBLIC_CLIENTVAR: z.string(),
+ },
+
+ /**
+ * Manual destructuring of process.env for Next.js edge runtimes and client-side
+ */
+ runtimeEnv: {
+ // Server-side env vars
+ NEXT_PUBLIC_DEV_MODE: process.env.NEXT_PUBLIC_DEV_MODE,
+ NEXT_PUBLIC_NODE_ENV: process.env.NODE_ENV,
+ NODE_ENV: process.env.NODE_ENV,
+ DATABASE_URL: process.env.DATABASE_URL,
+ NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
+ NEXTAUTH_URL: process.env.NEXTAUTH_URL,
+ GITHUB_CLIENT_ID: process.env.GITHUB_CLIENT_ID,
+ GITHUB_CLIENT_SECRET: process.env.GITHUB_CLIENT_SECRET,
+ GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
+ GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET,
+ OVERRIDE_MAX_LOG_LEVEL: process.env.OVERRIDE_MAX_LOG_LEVEL,
+ PROCESS_ORIGIN: process.env.PROCESS_ORIGIN,
+ // Client-side env vars
+ // NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
+ },
+
+ /**
+ * Skip env validation during build/dev with SKIP_ENV_VALIDATION
+ * Useful for Docker builds
+ */
+ skipValidation: !!process.env.SKIP_ENV_VALIDATION,
+
+ /**
+ * Treat empty strings as undefined
+ * `SOME_VAR: z.string()` with `SOME_VAR=''` will throw error
+ */
+ emptyStringAsUndefined: true,
+});
diff --git a/apps/web/src/instrumentation.ts b/apps/web/src/instrumentation.ts
new file mode 100644
index 0000000..7c92b56
--- /dev/null
+++ b/apps/web/src/instrumentation.ts
@@ -0,0 +1,39 @@
+import { sql } from "drizzle-orm";
+
+/**
+ * This function is executed once per server start in Node.js.
+ * @see https://nextjs.org/docs/app/building-your-application/optimizing/instrumentation
+ */
+export async function register() {
+ // Ensure this only runs on the server during startup
+ if (process.env.NEXT_RUNTIME === "nodejs") {
+ // Dynamically import db functions only when needed (Node.js runtime)
+ const { db, runRawSqlMigration } = await import("@repo/db");
+ const { createLogger } = await import("@repo/logger");
+ const logger = createLogger("INSTRUMENTATION");
+
+ logger.info("Performing startup checks...");
+
+ try {
+ // Check if pg_trgm extension is enabled
+ const result = await db.execute(
+ sql`SELECT 1 FROM pg_extension WHERE extname = 'pg_trgm'`
+ );
+
+ if (result.rows.length > 0) {
+ logger.info("✅ pg_trgm extension check passed.");
+ } else {
+ logger.error(
+ '❌ Critical: pg_trgm extension is not enabled in the database. Features like fuzzy search may not work correctly. Please ensure the extension is enabled in your PostgreSQL instance (e.g., run "CREATE EXTENSION IF NOT EXISTS pg_trgm;"). For local development using the setup script, this should be handled automatically.'
+ );
+ }
+ } catch (error) {
+ logger.error(
+ "❌ Failed to check for pg_trgm extension. Database connection or permissions might be incorrect.",
+ error
+ );
+ }
+
+ logger.info("Startup checks completed.");
+ }
+}
diff --git a/apps/web/src/lib/auth.ts b/apps/web/src/lib/auth.ts
new file mode 100644
index 0000000..5ad3749
--- /dev/null
+++ b/apps/web/src/lib/auth.ts
@@ -0,0 +1,136 @@
+import { db } from "@repo/db";
+import { users, userGroups, groups } from "@repo/db";
+import { NextAuthOptions, getServerSession } from "next-auth";
+import { Adapter } from "next-auth/adapters";
+import GitHubProvider from "next-auth/providers/github";
+import GoogleProvider from "next-auth/providers/google";
+import CredentialsProvider from "next-auth/providers/credentials";
+import { eq, and } from "drizzle-orm";
+import { compare } from "bcryptjs";
+import { DrizzleAdapter } from "@auth/drizzle-adapter";
+import { env } from "~/env";
+
+// Helper function to handle provider import differences between environments
+// Ignore any type errors here, we know the providers are valid
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+function createProvider(provider: any, options: any) {
+ // Use provider directly if it's a function, otherwise use provider.default
+ return typeof provider === "function"
+ ? provider(options)
+ : provider.default(options);
+}
+
+// Create auth options with the adapter set directly
+export const authOptions: NextAuthOptions = {
+ adapter: DrizzleAdapter(db) as Adapter,
+ session: {
+ strategy: "jwt",
+ },
+ pages: {
+ signIn: "/login",
+ signOut: "/logout",
+ error: "/auth/error",
+ verifyRequest: "/auth/verify-request",
+ newUser: "/auth/new-user",
+ },
+ providers: [
+ createProvider(GitHubProvider, {
+ clientId: env.GITHUB_CLIENT_ID || "",
+ clientSecret: env.GITHUB_CLIENT_SECRET || "",
+ }),
+ createProvider(GoogleProvider, {
+ clientId: env.GOOGLE_CLIENT_ID || "",
+ clientSecret: env.GOOGLE_CLIENT_SECRET || "",
+ }),
+ createProvider(CredentialsProvider, {
+ name: "Credentials",
+ credentials: {
+ email: { label: "Email", type: "text" },
+ password: { label: "Password", type: "password" },
+ },
+ async authorize(credentials: Record | undefined) {
+ if (!credentials?.email || !credentials?.password) {
+ return null;
+ }
+
+ // Check against real database users
+ const user = await db.query.users.findFirst({
+ where: eq(users.email, credentials.email),
+ });
+
+ if (!user || !user.password) {
+ return null;
+ }
+
+ const passwordMatch = await compare(
+ credentials.password,
+ user.password
+ );
+ if (!passwordMatch) {
+ return null;
+ }
+
+ // Check if user is in Administrators group
+ const adminGroup = await db.query.groups.findFirst({
+ where: eq(groups.name, "Administrators"),
+ });
+
+ let isAdmin = false;
+ if (adminGroup) {
+ const adminGroupMembership = await db.query.userGroups.findFirst({
+ where: and(
+ eq(userGroups.userId, user.id),
+ eq(userGroups.groupId, adminGroup.id)
+ ),
+ });
+ isAdmin = !!adminGroupMembership;
+ }
+
+ return {
+ id: user.id.toString(),
+ name: user.name,
+ email: user.email,
+ isAdmin: isAdmin,
+ };
+ },
+ }),
+ ],
+ callbacks: {
+ async session({ session, token }) {
+ if (session.user && token.sub) {
+ session.user.id = token.sub;
+ }
+ if (token.isAdmin !== undefined && session.user) {
+ session.user.isAdmin = token.isAdmin;
+ }
+ return session;
+ },
+ async jwt({ token, user }) {
+ if (user) {
+ token.sub = user.id;
+ // @ts-expect-error - we know isAdmin exists on our custom user
+ token.isAdmin = user.isAdmin || false;
+ } else if (token.sub) {
+ // Check admin status on each token refresh to keep it updated
+ const adminGroup = await db.query.groups.findFirst({
+ where: eq(groups.name, "Administrators"),
+ });
+
+ let isAdmin = false;
+ if (adminGroup) {
+ const adminGroupMembership = await db.query.userGroups.findFirst({
+ where: and(
+ eq(userGroups.userId, parseInt(token.sub)),
+ eq(userGroups.groupId, adminGroup.id)
+ ),
+ });
+ isAdmin = !!adminGroupMembership;
+ token.isAdmin = isAdmin;
+ }
+ }
+ return token;
+ },
+ },
+};
+
+export const getServerAuthSession = () => getServerSession(authOptions);
diff --git a/src/lib/hooks/useLocalStorage.ts b/apps/web/src/lib/hooks/useLocalStorage.ts
similarity index 88%
rename from src/lib/hooks/useLocalStorage.ts
rename to apps/web/src/lib/hooks/useLocalStorage.ts
index db61fe5..24c3ecd 100644
--- a/src/lib/hooks/useLocalStorage.ts
+++ b/apps/web/src/lib/hooks/useLocalStorage.ts
@@ -1,6 +1,7 @@
"use client";
import { useState, useEffect } from "react";
+import { logger } from "~/lib/utils/logger";
export function useLocalStorage(
key: string,
@@ -19,7 +20,7 @@ export function useLocalStorage(
// Parse stored json or if none return initialValue
return item ? JSON.parse(item) : initialValue;
} catch (error) {
- console.error("Error reading from localStorage:", error);
+ logger.error("Error reading from localStorage:", error);
return initialValue;
}
});
@@ -39,7 +40,7 @@ export function useLocalStorage(
window.localStorage.setItem(key, JSON.stringify(valueToStore));
}
} catch (error) {
- console.error("Error writing to localStorage:", error);
+ logger.error("Error writing to localStorage:", error);
}
};
@@ -50,7 +51,7 @@ export function useLocalStorage(
try {
setStoredValue(JSON.parse(e.newValue));
} catch (error) {
- console.error("Error parsing localStorage change:", error);
+ logger.error("Error parsing localStorage change:", error);
}
}
};
diff --git a/src/lib/hooks/useNotification.ts b/apps/web/src/lib/hooks/useNotification.ts
similarity index 61%
rename from src/lib/hooks/useNotification.ts
rename to apps/web/src/lib/hooks/useNotification.ts
index 02fcb07..3ce45a0 100644
--- a/src/lib/hooks/useNotification.ts
+++ b/apps/web/src/lib/hooks/useNotification.ts
@@ -1,10 +1,8 @@
import { toast, ToasterProps } from "sonner";
-// type NotificationType = "default" | "success" | "error" | "warning" | "info";
-
export function useNotification() {
const defaultOptions = {
- position: "top-center" as const,
+ position: "bottom-right" as const,
closeButton: true,
duration: 4000,
};
@@ -54,12 +52,46 @@ export function useNotification() {
});
};
+ const custom = (
+ message: string | React.ReactNode,
+ options?: ToasterProps
+ ) => {
+ return toast(message, {
+ ...defaultOptions,
+ ...options,
+ });
+ };
+
+ const promise = (
+ promise: Promise | (() => Promise),
+ messages: {
+ loading: string | React.ReactNode;
+ success:
+ | string
+ | React.ReactNode
+ | ((data: T) => React.ReactNode | string);
+ error:
+ | string
+ | React.ReactNode
+ | ((error: Error) => React.ReactNode | string);
+ },
+ options?: ToasterProps
+ ) => {
+ return toast.promise(promise, {
+ ...defaultOptions,
+ ...messages,
+ ...options,
+ });
+ };
+
return {
success,
error,
warning,
info,
loading,
+ custom,
+ promise,
dismiss: toast.dismiss,
};
}
diff --git a/apps/web/src/lib/markdown/README.md b/apps/web/src/lib/markdown/README.md
new file mode 100644
index 0000000..32cdca9
--- /dev/null
+++ b/apps/web/src/lib/markdown/README.md
@@ -0,0 +1,103 @@
+# Markdown Rendering System
+
+This library provides a unified approach to rendering markdown content both on the server and client side.
+
+## Core Components
+
+### `HighlightedContent` - Universal Renderer (Recommended)
+
+The recommended component for most use cases. It can handle both pre-rendered HTML from the server and client-side markdown rendering.
+
+```tsx
+import { HighlightedContent, renderMarkdownToHtml } from "~/lib/markdown";
+
+// Server component that fetches content
+export async function WikiPage({ pageId }: { pageId: string }) {
+ const page = await getWikiPage(pageId);
+
+ // Pre-render the content on the server
+ const renderedHtml = renderMarkdownToHtml(page.content);
+
+ return (
+
+ );
+}
+```
+
+### For Client-Only Rendering (e.g., Live Preview)
+
+Use `HighlightedMarkdown` for pure client-side rendering:
+
+```tsx
+import { HighlightedMarkdown } from "~/lib/markdown";
+
+// In an editor component with live preview
+export function MarkdownEditor({ content }: { content: string }) {
+ return (
+
+
+
+ );
+}
+```
+
+## How It Works
+
+1. **Factory Pattern**: Both renderers use the same configuration through `createClientMarkdownProcessor` and `createServerMarkdownProcessor`
+2. **Consistent Plugins**: All remark/rehype plugins are shared
+3. **Component Mapping**: All HTML elements are mapped to the same React components
+
+## Plugin Organization
+
+This library organizes plugins in a structured way:
+
+1. **Shared Plugins**: Located in `src/lib/markdown/plugins/`
+2. **Server-Only Plugins**: Located in `src/lib/markdown/plugins/server-only/`
+
+### Adding Server-Only Plugins
+
+To create a server-only plugin:
+
+1. Create a new file in the `plugins/server-only/` directory
+2. Export your plugin as the default export
+3. No additional configuration needed - it will be automatically loaded on the server
+
+Example:
+
+```ts
+// src/lib/markdown/plugins/server-only/myServerPlugin.ts
+import type { Plugin } from "unified";
+
+interface MyPluginOptions {
+ // Your options here
+}
+
+const myServerPlugin: Plugin<[MyPluginOptions?], any> = (options = {}) => {
+ return (tree) => {
+ // Plugin implementation
+ };
+};
+
+export default myServerPlugin;
+```
+
+The system will automatically detect and load this plugin when running on the server.
+
+## Best Practices
+
+1. **Always use `HighlightedContent` when possible**, providing both the original markdown and pre-rendered HTML
+2. Use consistent CSS classes on the wrapper elements
+3. For live previews, use `HighlightedMarkdown` directly
+4. For server-rendered content, use `renderMarkdownToHtml` and pass the result to `HighlightedContent`
+5. Place server-only plugins in the `plugins/server-only/` directory to ensure they're automatically loaded
+
+## Testing for Consistency
+
+To ensure your rendered content looks the same on both server and client:
+
+1. Compare snapshots of server-rendered and client-rendered output
+2. Test with complex markdown including various elements (tables, code, etc.)
+3. Verify styling is consistent across both render methods
diff --git a/apps/web/src/lib/markdown/client-factory.ts b/apps/web/src/lib/markdown/client-factory.ts
new file mode 100644
index 0000000..edf8d7c
--- /dev/null
+++ b/apps/web/src/lib/markdown/client-factory.ts
@@ -0,0 +1,89 @@
+/**
+ * Client-only markdown processor factory
+ * This file is specifically for client-side use and avoids any server-only imports
+ */
+
+import type { Components } from "react-markdown";
+import type { PluggableList } from "unified";
+import remarkGfm from "remark-gfm";
+import remarkBreaks from "remark-breaks";
+import remarkEmoji from "remark-emoji";
+import remarkDirective from "remark-directive";
+import remarkDirectiveRehype from "remark-directive-rehype";
+import rehypeHighlight from "rehype-highlight";
+import { customPlugins } from "./plugins";
+import { markdownOptions } from "./core/config";
+import { markdownComponents } from "./components";
+
+/**
+ * Remark plugins safe for client-side use
+ */
+export const clientRemarkPlugins: PluggableList = [
+ remarkGfm,
+ remarkBreaks,
+ remarkEmoji,
+ remarkDirective,
+ remarkDirectiveRehype,
+ ...customPlugins,
+];
+
+/**
+ * Rehype plugins safe for client-side use
+ */
+export const clientRehypePlugins: PluggableList = [
+ rehypeHighlight,
+ // Add other client-safe rehype plugins here
+];
+
+export interface ClientMarkdownProcessorOptions {
+ /**
+ * Remark plugins to use
+ */
+ remarkPlugins: PluggableList;
+
+ /**
+ * Rehype plugins to use
+ */
+ rehypePlugins: PluggableList;
+
+ /**
+ * React components for client-side rendering
+ */
+ components: Components;
+
+ /**
+ * Whether to allow HTML in markdown
+ */
+ allowDangerousHtml: boolean;
+
+ /**
+ * Whether to use breaks
+ */
+ breaks: boolean;
+
+ /**
+ * Sanitization level
+ */
+ sanitizationLevel: "permissive" | "standard" | "strict";
+
+ /**
+ * Whether emoji shortcodes are enabled
+ */
+ enableEmojis: boolean;
+}
+
+/**
+ * Creates a client-side only markdown processor configuration
+ * @returns Configuration object with client-safe options
+ */
+export function createClientMarkdownProcessor(): ClientMarkdownProcessorOptions {
+ return {
+ remarkPlugins: clientRemarkPlugins,
+ rehypePlugins: clientRehypePlugins,
+ components: markdownComponents,
+ allowDangerousHtml: markdownOptions.allowHtml,
+ breaks: markdownOptions.breaks,
+ sanitizationLevel: markdownOptions.sanitizationLevel,
+ enableEmojis: markdownOptions.enableEmojis,
+ };
+}
diff --git a/apps/web/src/lib/markdown/client.tsx b/apps/web/src/lib/markdown/client.tsx
new file mode 100644
index 0000000..c71ad64
--- /dev/null
+++ b/apps/web/src/lib/markdown/client.tsx
@@ -0,0 +1,255 @@
+"use client";
+
+import { useEffect, useRef } from "react";
+import ReactMarkdown from "react-markdown";
+import { useSearchParams, useRouter, usePathname } from "next/navigation";
+import { highlightTextInDOM, clearHighlightsFromDOM } from "./utils/highlight";
+import { createClientMarkdownProcessor } from "./client-factory";
+import { MarkdownProse } from "~/components/wiki/MarkdownProse";
+
+// Get client-side markdown configuration
+// This is guaranteed to only use browser-safe code
+const clientMarkdownConfig = createClientMarkdownProcessor();
+
+interface HighlightedMarkdownProps {
+ content: string;
+ className?: string;
+}
+
+/**
+ * Client-side markdown renderer with highlighting capabilities
+ */
+export function HighlightedMarkdown({
+ content,
+ className,
+}: HighlightedMarkdownProps) {
+ const contentRef = useRef(null);
+ const searchParams = useSearchParams();
+ const pathname = usePathname();
+ const highlightTerm = searchParams.get("highlight");
+ const router = useRouter();
+ const isInitialRender = useRef(true);
+
+ useEffect(() => {
+ if (contentRef.current) {
+ if (highlightTerm) {
+ // Execute the highlighting after rendering the markdown
+ const firstHighlight = highlightTextInDOM(
+ contentRef.current,
+ highlightTerm
+ );
+
+ // Scroll to the first highlight with a small delay to ensure the DOM has updated
+ if (
+ firstHighlight &&
+ (isInitialRender.current ||
+ !document.activeElement?.contains(firstHighlight))
+ ) {
+ setTimeout(() => {
+ firstHighlight.scrollIntoView({
+ behavior: "smooth",
+ block: "center",
+ });
+ }, 100);
+ }
+ } else {
+ // Clear highlights if the highlight parameter is removed
+ clearHighlightsFromDOM(contentRef.current);
+ }
+ }
+
+ isInitialRender.current = false;
+ }, [highlightTerm, content]);
+
+ // Method to clear highlights
+ const clearHighlights = () => {
+ // Using Next.js Router and searchParams
+ const params = new URLSearchParams(searchParams.toString());
+ params.delete("highlight");
+
+ // Get the current path and construct a new URL without the highlight parameter
+ const newUrl =
+ pathname + (params.toString() ? `?${params.toString()}` : "");
+
+ // Manually clear highlights from DOM to ensure immediate visual feedback
+ if (contentRef.current) {
+ clearHighlightsFromDOM(contentRef.current);
+ }
+
+ // Use router.push instead of replace to ensure the page updates properly
+ router.push(newUrl, { scroll: false });
+ };
+
+ return (
+ <>
+ {highlightTerm && (
+
+
+ Showing results for: {highlightTerm}
+
+
+ Clear highlights
+
+
+ )}
+
+ >
+ );
+}
+
+interface HighlightedContentProps {
+ content: string;
+ renderedHtml?: string | null;
+ className?: string;
+}
+
+/**
+ * Client-side component that handles highlighting for both pre-rendered HTML and markdown content
+ */
+export function HighlightedContent({
+ content,
+ renderedHtml,
+ className,
+}: HighlightedContentProps) {
+ const contentRef = useRef(null);
+ const searchParams = useSearchParams();
+ const pathname = usePathname();
+ const highlightTerm = searchParams.get("highlight");
+ const router = useRouter();
+ const isInitialRender = useRef(true);
+
+ useEffect(() => {
+ if (contentRef.current) {
+ if (highlightTerm) {
+ // Execute the highlighting after rendering the content
+ const firstHighlight = highlightTextInDOM(
+ contentRef.current,
+ highlightTerm
+ );
+
+ // Scroll to the first highlight with a small delay to ensure the DOM has updated
+ if (
+ firstHighlight &&
+ (isInitialRender.current ||
+ !document.activeElement?.contains(firstHighlight))
+ ) {
+ setTimeout(() => {
+ firstHighlight.scrollIntoView({
+ behavior: "smooth",
+ block: "center",
+ });
+ }, 100);
+ }
+ } else {
+ // Clear highlights if the highlight parameter is removed
+ clearHighlightsFromDOM(contentRef.current);
+ }
+ }
+
+ isInitialRender.current = false;
+ }, [highlightTerm, content, renderedHtml]);
+
+ // Handle internal link clicks for pre-rendered content
+ useEffect(() => {
+ const handleLinkClick = (e: MouseEvent) => {
+ const target = e.target as HTMLElement;
+ const anchor = target.closest("a");
+
+ if (!anchor) return;
+
+ const href = anchor.getAttribute("href");
+ if (!href) return;
+
+ // Check if this is an internal link (has internal-link class)
+ const isInternalLink = anchor.classList.contains("internal-link");
+
+ // Only handle internal links with client-side navigation
+ if (isInternalLink) {
+ e.preventDefault();
+ router.push(href);
+ }
+
+ const isExternalLink = anchor.classList.contains("external-link");
+ if (isExternalLink) {
+ e.preventDefault();
+ window.open(href, "_blank");
+ }
+ };
+
+ // Only add this listener if we're using pre-rendered HTML
+ if (renderedHtml && contentRef.current) {
+ contentRef.current.addEventListener("click", handleLinkClick);
+ }
+
+ return () => {
+ if (contentRef.current) {
+ contentRef.current.removeEventListener("click", handleLinkClick);
+ }
+ };
+ }, [router, renderedHtml]);
+
+ // Method to clear highlights
+ const clearHighlights = () => {
+ // Using Next.js Router and searchParams
+ const params = new URLSearchParams(searchParams.toString());
+ params.delete("highlight");
+
+ // Get the current path and construct a new URL without the highlight parameter
+ const newUrl =
+ pathname + (params.toString() ? `?${params.toString()}` : "");
+
+ // Manually clear highlights from DOM to ensure immediate visual feedback
+ if (contentRef.current) {
+ clearHighlightsFromDOM(contentRef.current);
+ }
+
+ // Use router.push instead of replace to ensure the page updates properly
+ router.push(newUrl, { scroll: false });
+ };
+
+ return (
+ <>
+ {highlightTerm && (
+
+
+ Showing results for: {highlightTerm}
+
+
+ Clear highlights
+
+
+ )}
+
+
+ {renderedHtml ? (
+
+ ) : (
+
+ {content}
+
+ )}
+
+
+ >
+ );
+}
diff --git a/src/components/wiki/markdown/components/CodeComponent.tsx b/apps/web/src/lib/markdown/components/CodeComponent.tsx
similarity index 89%
rename from src/components/wiki/markdown/components/CodeComponent.tsx
rename to apps/web/src/lib/markdown/components/CodeComponent.tsx
index 2270b16..8c3cf1e 100644
--- a/src/components/wiki/markdown/components/CodeComponent.tsx
+++ b/apps/web/src/lib/markdown/components/CodeComponent.tsx
@@ -26,7 +26,7 @@ export const codeComponent: Components["code"] = ({
diff --git a/apps/web/src/lib/markdown/components/LinkComponent.tsx b/apps/web/src/lib/markdown/components/LinkComponent.tsx
new file mode 100644
index 0000000..626e518
--- /dev/null
+++ b/apps/web/src/lib/markdown/components/LinkComponent.tsx
@@ -0,0 +1,18 @@
+import { cn } from "~/lib/utils";
+import type { Components } from "react-markdown";
+import React from "react";
+
+export const linkComponent: Components["a"] = ({
+ node,
+ className,
+ children,
+ ...props
+}) => {
+ void node;
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/src/components/wiki/markdown/components/ListComponent.tsx b/apps/web/src/lib/markdown/components/ListComponent.tsx
similarity index 100%
rename from src/components/wiki/markdown/components/ListComponent.tsx
rename to apps/web/src/lib/markdown/components/ListComponent.tsx
diff --git a/src/components/wiki/markdown/components/ListItemComponent.tsx b/apps/web/src/lib/markdown/components/ListItemComponent.tsx
similarity index 100%
rename from src/components/wiki/markdown/components/ListItemComponent.tsx
rename to apps/web/src/lib/markdown/components/ListItemComponent.tsx
diff --git a/src/components/wiki/markdown/components/index.ts b/apps/web/src/lib/markdown/components/index.ts
similarity index 59%
rename from src/components/wiki/markdown/components/index.ts
rename to apps/web/src/lib/markdown/components/index.ts
index 422e867..7d612aa 100644
--- a/src/components/wiki/markdown/components/index.ts
+++ b/apps/web/src/lib/markdown/components/index.ts
@@ -1,11 +1,19 @@
+/**
+ * Re-exports all custom React components for markdown rendering
+ */
+
import { codeComponent } from "./CodeComponent";
import { listItemComponent } from "./ListItemComponent";
import { listComponent } from "./ListComponent";
+import { linkComponent } from "./LinkComponent";
import type { Components } from "react-markdown";
-// Combine all custom components
+/**
+ * Collection of all custom components for client-side markdown rendering
+ */
export const markdownComponents: Components = {
code: codeComponent,
ul: listComponent,
li: listItemComponent,
+ a: linkComponent,
};
diff --git a/apps/web/src/lib/markdown/core/config.ts b/apps/web/src/lib/markdown/core/config.ts
new file mode 100644
index 0000000..20af63d
--- /dev/null
+++ b/apps/web/src/lib/markdown/core/config.ts
@@ -0,0 +1,31 @@
+/**
+ * Shared configuration settings for markdown processing
+ */
+
+/**
+ * Options for markdown processing
+ */
+export const markdownOptions = {
+ /**
+ * Whether to allow HTML in markdown
+ */
+ allowHtml: true,
+
+ /**
+ * Whether to automatically add line breaks
+ */
+ breaks: true,
+
+ /**
+ * HTML sanitization level
+ * - 'permissive': Allow all HTML
+ * - 'standard': Block potentially dangerous tags
+ * - 'strict': Allow only safe tags
+ */
+ sanitizationLevel: "standard" as "permissive" | "standard" | "strict",
+
+ /**
+ * Whether to allow emoji shortcodes
+ */
+ enableEmojis: true,
+};
diff --git a/apps/web/src/lib/markdown/core/plugins.ts b/apps/web/src/lib/markdown/core/plugins.ts
new file mode 100644
index 0000000..1ac903f
--- /dev/null
+++ b/apps/web/src/lib/markdown/core/plugins.ts
@@ -0,0 +1,109 @@
+/**
+ * Central configuration for all markdown plugins used in the application
+ * This is the single source of truth for both client-side and server-side rendering
+ */
+
+import remarkGfm from "remark-gfm";
+import remarkBreaks from "remark-breaks";
+import remarkEmoji from "remark-emoji";
+import remarkDirective from "remark-directive";
+import remarkDirectiveRehype from "remark-directive-rehype";
+import rehypeHighlight from "rehype-highlight";
+import type { PluggableList } from "unified";
+import { logger } from "~/lib/utils/logger";
+
+// Import custom plugins
+import { customPlugins } from "../plugins";
+
+// Conditional server imports - only load if not in browser
+const isServer = typeof window === "undefined";
+
+/**
+ * Remark plugins to be applied during markdown processing
+ */
+export const remarkPlugins: PluggableList = [
+ remarkGfm,
+ remarkBreaks,
+ remarkEmoji,
+ remarkDirective,
+ remarkDirectiveRehype,
+ ...customPlugins,
+];
+
+/**
+ * Try to load a plugin from a given path
+ * @param importedPlugin - The imported plugin to try to load
+ * @returns The loaded plugin or null if it fails
+ */
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+async function tryLoadPlugin(importedPlugin: Promise) {
+ if (!isServer) return [];
+
+ try {
+ const pluginModule = await importedPlugin;
+ if (pluginModule.default) {
+ return pluginModule.default;
+ }
+ return null;
+ } catch (error) {
+ logger.error(`Failed to load plugin from ${importedPlugin}:`, error);
+ return null;
+ }
+}
+
+/**
+ * Dynamically load all server-only rehype plugins
+ * This automatically imports all plugins from the server-only directory
+ */
+export async function loadServerRehypePlugins(): Promise {
+ if (!isServer) return [];
+
+ try {
+ // Pre-defined list of server-only plugins to import
+ // This avoids using fs which is not available in Next.js client bundles
+ const serverPlugins: PluggableList = [];
+ const plugins = await Promise.all([
+ tryLoadPlugin(import("../plugins/server-only/rehypeWikiLinks")),
+ tryLoadPlugin(import("../plugins/server-only/loggerPlugin")),
+ // Add additional server-only plugins here as they are created
+ ]);
+
+ for (const plugin of plugins) {
+ if (plugin) {
+ serverPlugins.push(plugin);
+ }
+ }
+
+ return serverPlugins;
+ } catch (error) {
+ logger.error("Failed to load server-only plugins:", error);
+ return [];
+ }
+}
+
+/**
+ * Rehype plugins to be applied during HTML processing
+ * Basic plugins that work in both client and server
+ */
+export const baseRehypePlugins: PluggableList = [rehypeHighlight];
+
+/**
+ * Get rehype plugins for the target environment
+ * For server, this will dynamically load additional plugins
+ *
+ * @param target Whether this is for "client" or "server" rendering
+ */
+export async function getRehypePlugins(
+ target: "client" | "server"
+): Promise {
+ // Always include base plugins
+ const plugins = [...baseRehypePlugins];
+
+ // Add server-only plugins when on the server
+ if (target === "server" && isServer) {
+ const serverPlugins = await loadServerRehypePlugins();
+ plugins.push(...serverPlugins);
+ }
+
+ return plugins;
+}
diff --git a/apps/web/src/lib/markdown/index.ts b/apps/web/src/lib/markdown/index.ts
new file mode 100644
index 0000000..db0be62
--- /dev/null
+++ b/apps/web/src/lib/markdown/index.ts
@@ -0,0 +1,17 @@
+/**
+ * Unified markdown processing library
+ * Provides both server-side and client-side rendering with the same plugins
+ */
+
+// Export core configuration
+export * from "./core/plugins";
+export * from "./core/config";
+export * from "./client-factory";
+
+// Export renderers
+export { HighlightedMarkdown, HighlightedContent } from "./client";
+export { renderMarkdownToHtml } from "./server";
+
+// Export components and utilities
+export { markdownComponents } from "./components";
+export { highlightTextInDOM, clearHighlightsFromDOM } from "./utils/highlight";
diff --git a/apps/web/src/lib/markdown/plugins/index.ts b/apps/web/src/lib/markdown/plugins/index.ts
new file mode 100644
index 0000000..8a31333
--- /dev/null
+++ b/apps/web/src/lib/markdown/plugins/index.ts
@@ -0,0 +1,13 @@
+/**
+ * Exports all custom markdown plugins
+ */
+
+import remarkAttrs from "./remarkAttrs";
+
+/**
+ * Collection of all custom plugins used in the application
+ */
+export const customPlugins = [
+ remarkAttrs,
+ // Add other custom plugins here
+];
diff --git a/src/components/wiki/markdown/plugins/remarkAttrs.ts b/apps/web/src/lib/markdown/plugins/remarkAttrs.ts
similarity index 100%
rename from src/components/wiki/markdown/plugins/remarkAttrs.ts
rename to apps/web/src/lib/markdown/plugins/remarkAttrs.ts
diff --git a/apps/web/src/lib/markdown/plugins/server-only/loggerPlugin.ts b/apps/web/src/lib/markdown/plugins/server-only/loggerPlugin.ts
new file mode 100644
index 0000000..ce6591d
--- /dev/null
+++ b/apps/web/src/lib/markdown/plugins/server-only/loggerPlugin.ts
@@ -0,0 +1,62 @@
+/**
+ * Example server-only plugin that logs when content is processed
+ * This demonstrates how server-only plugins are automatically loaded
+ */
+
+import { visit } from "unist-util-visit";
+import type { Plugin } from "unified";
+import { logger } from "~/lib/utils/logger";
+
+interface LoggerPluginOptions {
+ /**
+ * Log level to use
+ * @default 'info'
+ */
+ level?: "info" | "debug" | "warn";
+
+ /**
+ * Whether to log detailed statistics
+ * @default false
+ */
+ detailed?: boolean;
+}
+
+/**
+ * Server-only plugin that logs information about processed markdown
+ */
+const loggerPlugin: Plugin<[LoggerPluginOptions?], undefined> = (
+ options = {}
+) => {
+ const { level = "info", detailed = false } = options;
+
+ return (tree) => {
+ // Count different node types
+ const counts: Record = {};
+
+ visit(tree, (node) => {
+ if (!node.type) return;
+ counts[node.type] = (counts[node.type] || 0) + 1;
+ });
+
+ // Log the results
+ const logMethod =
+ level === "warn"
+ ? logger.warn
+ : level === "debug"
+ ? logger.debug
+ : logger.info;
+
+ const totalNodes = Object.values(counts).reduce(
+ (sum, count) => sum + count,
+ 0
+ );
+
+ logMethod(`[Server Markdown] Processed document with ${totalNodes} nodes`);
+
+ if (detailed) {
+ logMethod("[Server Markdown] Node type breakdown:", counts);
+ }
+ };
+};
+
+export default loggerPlugin;
diff --git a/apps/web/src/lib/markdown/plugins/server-only/rehypeWikiLinks.ts b/apps/web/src/lib/markdown/plugins/server-only/rehypeWikiLinks.ts
new file mode 100644
index 0000000..5422281
--- /dev/null
+++ b/apps/web/src/lib/markdown/plugins/server-only/rehypeWikiLinks.ts
@@ -0,0 +1,336 @@
+/**
+ * Rehype plugin for processing wiki links
+ * Server-side only implementation that checks link existence and adds appropriate classes
+ *
+ * NOTE: This plugin can only be used in a server context - it requires database access.
+ * It is dynamically imported via the server-only plugin loader in core/plugins.ts.
+ */
+
+import { visit } from "unist-util-visit";
+import type { Plugin } from "unified";
+import type { Element, Root } from "hast";
+import { db } from "@repo/db";
+import { wikiPages } from "@repo/db";
+import { inArray } from "drizzle-orm";
+import { logger } from "~/lib/utils/logger";
+
+// Cache configuration
+const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minute cache lifetime
+
+// Time-based cache implementation
+interface CacheEntry {
+ value: boolean;
+ timestamp: number;
+}
+
+// Cache of page existence to avoid repeated queries
+const pageExistenceCache = new Map();
+
+/**
+ * Check if multiple pages exist in a single database query
+ * @param paths Array of paths to check
+ * @returns Map of path to existence boolean
+ */
+async function bulkCheckPagesExistence(
+ paths: string[]
+): Promise> {
+ // If no paths, return empty map
+ if (paths.length === 0) return new Map();
+
+ // Query the database for all paths at once
+ const existingPages = await db.query.wikiPages.findMany({
+ where: inArray(
+ wikiPages.path,
+ paths.map((path) => (path.startsWith("/") ? path.slice(1) : path))
+ ),
+ columns: { path: true },
+ });
+
+ // Create a map of path -> exists
+ const existingPathsSet = new Set(
+ existingPages.map((page) => `/${page.path}`)
+ );
+ const result = new Map();
+
+ // Fill the result map with existence status
+ for (const path of paths) {
+ result.set(path, existingPathsSet.has(path));
+ }
+
+ return result;
+}
+
+/**
+ * Checks if a wiki page exists in the database by its path
+ * Uses caching to reduce database queries
+ *
+ * @param pathsToCheck Paths to check for existence
+ * @returns Map of path to existence boolean
+ */
+async function checkPagesExistence(
+ pathsToCheck: string[]
+): Promise> {
+ const now = Date.now();
+ const result = new Map();
+ const pathsToQuery: string[] = [];
+
+ // Check cache first and collect uncached or expired paths
+ for (const path of pathsToCheck) {
+ const cacheEntry = pageExistenceCache.get(path);
+
+ if (cacheEntry && now - cacheEntry.timestamp < CACHE_TTL_MS) {
+ // Cache hit and not expired
+ result.set(path, cacheEntry.value);
+ } else {
+ // Cache miss or expired
+ pathsToQuery.push(path);
+ }
+ }
+
+ // If all paths were cached, return early
+ if (pathsToQuery.length === 0) return result;
+
+ // Query database for all uncached/expired paths
+ const freshData = await bulkCheckPagesExistence(pathsToQuery);
+
+ // Update cache and merge with cached results
+ for (const [path, exists] of freshData.entries()) {
+ // Update cache
+ pageExistenceCache.set(path, {
+ value: exists,
+ timestamp: now,
+ });
+
+ // Add to result
+ result.set(path, exists);
+ }
+
+ return result;
+}
+
+/**
+ * Clear the page existence cache
+ */
+export function clearPageExistenceCache(): void {
+ pageExistenceCache.clear();
+}
+
+export enum LinkType {
+ IMAGE = "image",
+ VIDEO = "video",
+ AUDIO = "audio",
+ PAGE = "page",
+ DOCUMENT = "document",
+ EXTERNAL = "external",
+ ASSET = "asset",
+}
+
+/**
+ * Allowed link extensions for each link type
+ */
+export const allowedLinkExtensions = {
+ [LinkType.IMAGE]: ["png", "jpg", "jpeg", "gif", "svg", "webp"],
+ [LinkType.VIDEO]: ["mp4", "webm", "mov", "avi", "mkv"],
+ [LinkType.AUDIO]: ["mp3", "wav", "ogg", "m4a", "flac"],
+ [LinkType.DOCUMENT]: ["pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx"],
+};
+
+/**
+ * Get the type of a link based on its href
+ * @param href The href of the link
+ * @returns The type of the link
+ */
+export function getLinkType(href: string): LinkType {
+ // Check for asset links first
+ if (href.startsWith("/api/assets/")) {
+ return LinkType.ASSET;
+ }
+
+ // If the link doesn't contain a dot (and isn't an asset link), it can't be an external link
+ if (!href.includes(".")) {
+ // Treat links without extensions and not starting with /api/assets/ as internal pages
+ return LinkType.PAGE;
+ }
+
+ // Check for file extensions if it contains a dot but wasn't an asset link
+ const extension = href.split(".").pop()?.toLowerCase();
+ if (extension) {
+ if (allowedLinkExtensions[LinkType.IMAGE].includes(extension)) {
+ return LinkType.IMAGE;
+ }
+ if (allowedLinkExtensions[LinkType.AUDIO].includes(extension)) {
+ return LinkType.AUDIO;
+ }
+ if (allowedLinkExtensions[LinkType.VIDEO].includes(extension)) {
+ return LinkType.VIDEO;
+ }
+ if (allowedLinkExtensions[LinkType.DOCUMENT].includes(extension)) {
+ return LinkType.DOCUMENT;
+ }
+ }
+
+ // If it contains a dot but doesn't match known internal types, assume external
+ // Or if it doesn't have an extension but contains a dot (e.g., domain name without path)
+ // Only classify as external if it contains '://' or starts with '//' or 'www.'
+ if (
+ href.includes("://") ||
+ href.startsWith("//") ||
+ href.startsWith("www.")
+ ) {
+ return LinkType.EXTERNAL;
+ }
+
+ // Fallback for relative paths with dots but no recognized extension (treat as page)
+ // Example: ../some/other/page
+ return LinkType.PAGE;
+}
+
+/**
+ * Render an internal link to a full format
+ * @param href The href of the link
+ * @param currentPage The current page
+ * @returns The rendered link
+ *
+ * @example: link with href: path/to/page on page original/path -> /original/path/path/to/page
+ * Note: See how links are stored without leading slash but we need to add it for the href to work from the root
+ */
+export function renderInternalLink(href: string, currentPage: string) {
+ if (href.startsWith("/")) {
+ return href;
+ }
+
+ // Special case for root page
+ if (currentPage === "") {
+ return `/${href}`;
+ }
+
+ return `/${currentPage}/${href}`;
+}
+
+/**
+ * Plugin options
+ */
+export interface RehypeWikiLinksOptions {
+ /**
+ * Class to add to links that point to existing pages
+ * @default 'wiki-link-exists'
+ */
+ existsClass?: string;
+
+ /**
+ * Class to add to links that point to non-existing pages
+ * @default 'wiki-link-missing'
+ */
+ missingClass?: string;
+
+ /**
+ * Class to add to internal links
+ * @default 'internal-link'
+ */
+ internalLinksClass?: string;
+
+ /**
+ * Class to add to asset links
+ * @default 'asset-link'
+ */
+ assetLinkClass?: string;
+
+ /**
+ * Current page path for resolving relative links
+ */
+ currentPagePath?: string;
+}
+
+/**
+ * Creates a rehype plugin to process wiki links
+ * This is an async plugin that checks page existence in the database
+ */
+export const rehypeWikiLinks: Plugin<[RehypeWikiLinksOptions?], Root> = (
+ options = {}
+) => {
+ const internalLinksClass = options.internalLinksClass || "internal-link";
+ const existsClass = options.existsClass || "wiki-link-exists";
+ const missingClass = options.missingClass || "wiki-link-missing";
+ const assetLinkClass = options.assetLinkClass || "asset-link";
+ const currentPagePath = options.currentPagePath || "";
+
+ return async function transformer(tree: Root): Promise {
+ // Collect all wiki links
+ const wikiLinks: Array<{
+ node: Element;
+ path: string;
+ }> = [];
+ const assetLinks: Element[] = [];
+
+ // Find all internal links and asset links
+ visit(tree, "element", (node: Element) => {
+ if (
+ node.tagName === "a" &&
+ node.properties &&
+ typeof node.properties.href === "string"
+ // node.properties.href.startsWith("/")
+ ) {
+ const href = node.properties.href;
+ const type = getLinkType(href);
+ if (type === LinkType.PAGE) {
+ const path = renderInternalLink(href, currentPagePath);
+ logger.debug(
+ `${href} -> ${path} (currentPagePath: ${currentPagePath})`
+ );
+ wikiLinks.push({ node, path });
+ } else if (type === LinkType.ASSET) {
+ assetLinks.push(node);
+ }
+ }
+ });
+
+ // If no wiki links, return early
+ if (wikiLinks.length === 0 && assetLinks.length === 0) return;
+
+ // Get unique paths to check
+ const uniquePaths = Array.from(new Set(wikiLinks.map((link) => link.path)));
+
+ // Batch check all paths in a single query
+ const existenceMap = await checkPagesExistence(uniquePaths);
+
+ // Apply classes to all links
+ for (const { node, path } of wikiLinks) {
+ const exists = existenceMap.get(path) || false;
+
+ // Add class to the node
+ const className = `${internalLinksClass} ${
+ exists ? existsClass : missingClass
+ }`;
+ if (!node.properties) node.properties = {};
+
+ // Update the href to the fully resolved path
+ node.properties.href = path;
+
+ if (Array.isArray(node.properties.className)) {
+ node.properties.className.push(className);
+ } else if (typeof node.properties.className === "string") {
+ node.properties.className = [node.properties.className, className];
+ } else {
+ node.properties.className = [className];
+ }
+ }
+
+ // Apply class to all asset links
+ for (const node of assetLinks) {
+ if (!node.properties) node.properties = {};
+ const className = assetLinkClass;
+ if (Array.isArray(node.properties.className)) {
+ node.properties.className.push(className);
+ } else if (typeof node.properties.className === "string") {
+ node.properties.className = [node.properties.className, className];
+ } else {
+ node.properties.className = [className];
+ }
+ }
+ };
+};
+
+// Re-export the cache clearing function
+export { clearPageExistenceCache as invalidatePageExistenceCache };
+
+export default rehypeWikiLinks;
diff --git a/apps/web/src/lib/markdown/server-factory.ts b/apps/web/src/lib/markdown/server-factory.ts
new file mode 100644
index 0000000..d368b03
--- /dev/null
+++ b/apps/web/src/lib/markdown/server-factory.ts
@@ -0,0 +1,119 @@
+/**
+ * Server-only markdown processor factory
+ * This file can import server-only modules and plugins
+ */
+
+import type { PluggableList } from "unified";
+import remarkGfm from "remark-gfm";
+import remarkBreaks from "remark-breaks";
+import remarkEmoji from "remark-emoji";
+import remarkDirective from "remark-directive";
+import remarkDirectiveRehype from "remark-directive-rehype";
+import rehypeHighlight from "rehype-highlight";
+import { customPlugins } from "./plugins";
+import { markdownOptions } from "./core/config";
+import { logger } from "~/lib/utils/logger";
+
+/**
+ * Remark plugins for server-side use
+ */
+export const serverRemarkPlugins: PluggableList = [
+ remarkGfm,
+ remarkBreaks,
+ remarkEmoji,
+ remarkDirective,
+ remarkDirectiveRehype,
+ ...customPlugins,
+];
+
+/**
+ * Base rehype plugins for server-side use
+ */
+export const baseRehypePlugins: PluggableList = [
+ rehypeHighlight,
+ // Add other base rehype plugins here
+];
+
+export interface ServerMarkdownProcessorOptions {
+ /**
+ * Remark plugins to use
+ */
+ remarkPlugins: PluggableList;
+
+ /**
+ * Rehype plugins to use
+ */
+ rehypePlugins: PluggableList;
+
+ /**
+ * Whether to allow HTML in markdown
+ */
+ allowDangerousHtml: boolean;
+
+ /**
+ * Whether to use breaks
+ */
+ breaks: boolean;
+
+ /**
+ * Sanitization level
+ */
+ sanitizationLevel: "permissive" | "standard" | "strict";
+
+ /**
+ * Whether emoji shortcodes are enabled
+ */
+ enableEmojis: boolean;
+}
+
+/**
+ * Load server-only rehype plugins
+ * These might depend on database access or other server features
+ */
+export async function loadServerRehypePlugins(): Promise {
+ try {
+ // Pre-defined list of server-only plugins to import
+ const serverPlugins: PluggableList = [];
+
+ // Dynamic imports using Promise.all
+ const plugins = await Promise.all([
+ import("./plugins/server-only/rehypeWikiLinks")
+ .then((m) => m.default)
+ .catch(() => null),
+ import("./plugins/server-only/loggerPlugin")
+ .then((m) => m.default)
+ .catch(() => null),
+ // Add additional server-only plugins here as they are created
+ ]);
+
+ // Add loaded plugins to the list
+ for (const plugin of plugins) {
+ if (plugin) {
+ serverPlugins.push(plugin);
+ }
+ }
+
+ return serverPlugins;
+ } catch (error) {
+ logger.error("Failed to load server-only plugins:", error);
+ return [];
+ }
+}
+
+/**
+ * Creates a server-side markdown processor configuration
+ * @returns Configuration object with server plugins
+ */
+export async function createServerMarkdownProcessor(): Promise {
+ // Load server-only plugins
+ const serverPlugins = await loadServerRehypePlugins();
+
+ return {
+ remarkPlugins: serverRemarkPlugins,
+ rehypePlugins: [...baseRehypePlugins, ...serverPlugins],
+ allowDangerousHtml: markdownOptions.allowHtml,
+ breaks: markdownOptions.breaks,
+ sanitizationLevel: markdownOptions.sanitizationLevel,
+ enableEmojis: markdownOptions.enableEmojis,
+ };
+}
diff --git a/apps/web/src/lib/markdown/server.ts b/apps/web/src/lib/markdown/server.ts
new file mode 100644
index 0000000..f389c7e
--- /dev/null
+++ b/apps/web/src/lib/markdown/server.ts
@@ -0,0 +1,92 @@
+/**
+ * Server-side markdown rendering utility
+ * Uses the same rendering pipeline as the client-side HighlightedMarkdown component
+ */
+
+import { unified } from "unified";
+import remarkParse from "remark-parse";
+import rehypeStringify from "rehype-stringify";
+import remarkRehype from "remark-rehype";
+import { createServerMarkdownProcessor } from "./server-factory";
+import { logger } from "~/lib/utils/logger";
+
+/**
+ * Renders Markdown content to HTML string for server-side rendering
+ *
+ * @param content The markdown content to render
+ * @param pagePath The path of the page to render the markdown for
+ * @returns Promise resolving to the rendered HTML string
+ */
+export async function renderMarkdownToHtml(
+ content: string,
+ pagePath?: string
+): Promise {
+ // Await the server configuration first
+ const serverConfig = await createServerMarkdownProcessor();
+
+ // Access the already loaded plugins from the resolved config
+ let rehypePlugins = serverConfig.rehypePlugins;
+
+ // If we have a page path, configure any wiki link plugins with the path
+ if (pagePath) {
+ try {
+ // Try to load the rehypeWikiLinks module dynamically
+ const rehypeWikiLinksModule = await import(
+ "./plugins/server-only/rehypeWikiLinks"
+ ).catch((e) => {
+ logger.error("Failed to import rehypeWikiLinks:", e);
+ return { default: null };
+ });
+
+ if (rehypeWikiLinksModule.default) {
+ // Replace any existing wiki links plugin with configured version
+ rehypePlugins = rehypePlugins.map((plugin) => {
+ // Skip non-array items and plugins that aren't wikilinks
+ if (
+ !Array.isArray(plugin) &&
+ plugin === rehypeWikiLinksModule.default
+ ) {
+ // Replace with configured version
+ return [
+ rehypeWikiLinksModule.default,
+ { currentPagePath: pagePath },
+ ];
+ }
+ // Also check array plugins
+ if (
+ Array.isArray(plugin) &&
+ plugin[0] === rehypeWikiLinksModule.default
+ ) {
+ // Merge existing options with the page path
+ return [
+ rehypeWikiLinksModule.default,
+ { ...(plugin[1] || {}), currentPagePath: pagePath },
+ ];
+ }
+ return plugin;
+ });
+ }
+ } catch (error) {
+ logger.error("Failed to load rehypeWikiLinks plugin:", error);
+ }
+ }
+
+ // Create the processing pipeline
+ const processor = unified()
+ .use(remarkParse)
+ .use(serverConfig.remarkPlugins)
+ // Convert to rehype (HTML), allow dangerous HTML
+ .use(remarkRehype, {
+ allowDangerousHtml: serverConfig.allowDangerousHtml,
+ })
+ // Add rehype plugins
+ .use(rehypePlugins)
+ // Convert to string
+ .use(rehypeStringify, {
+ allowDangerousHtml: serverConfig.allowDangerousHtml,
+ });
+
+ // Process the content asynchronously
+ const file = await processor.process(content);
+ return String(file);
+}
diff --git a/src/components/wiki/markdown/utils/highlight.ts b/apps/web/src/lib/markdown/utils/highlight.ts
similarity index 65%
rename from src/components/wiki/markdown/utils/highlight.ts
rename to apps/web/src/lib/markdown/utils/highlight.ts
index b42ccbc..8ae75fc 100644
--- a/src/components/wiki/markdown/utils/highlight.ts
+++ b/apps/web/src/lib/markdown/utils/highlight.ts
@@ -1,8 +1,21 @@
-// Utility to highlight occurrences of a text
-export function highlightTextInDOM(rootNode: HTMLElement, searchText: string) {
- if (!searchText || searchText.trim() === "") return;
+/**
+ * Utilities for text highlighting in the DOM
+ */
+
+/**
+ * Highlights occurrences of a text within a DOM node
+ * @param rootNode The root DOM node to search within
+ * @param searchText The text to highlight
+ * @returns The first highlighted element or null if none were created
+ */
+export function highlightTextInDOM(
+ rootNode: HTMLElement,
+ searchText: string
+): HTMLElement | null {
+ if (!searchText || searchText.trim() === "") return null;
const searchTextLower = searchText.toLowerCase();
+ let firstHighlight: HTMLElement | null = null;
// Create a TreeWalker to iterate through all text nodes
const walker = document.createTreeWalker(rootNode, NodeFilter.SHOW_TEXT, {
@@ -43,6 +56,11 @@ export function highlightTextInDOM(rootNode: HTMLElement, searchText: string) {
"rounded-sm bg-accent text-accent-foreground highlight-flash";
mark.textContent = part;
replacements.push(mark);
+
+ // Store the first highlight we create
+ if (!firstHighlight) {
+ firstHighlight = mark;
+ }
} else if (part) {
// Keep non-matching parts as text nodes
replacements.push(document.createTextNode(part));
@@ -56,27 +74,25 @@ export function highlightTextInDOM(rootNode: HTMLElement, searchText: string) {
}
}
- // Replace identified nodes with highlighted versions
- for (const { node, replacements } of nodesToReplace) {
- const parent = node.parentNode;
- if (parent) {
- const fragment = document.createDocumentFragment();
- replacements.forEach((replacement) => fragment.appendChild(replacement));
- parent.replaceChild(fragment, node);
+ // Replace nodes with their highlighted versions
+ nodesToReplace.forEach(({ node, replacements }) => {
+ if (node.parentNode) {
+ // Insert all replacement nodes
+ replacements.forEach((replacement) => {
+ node.parentNode!.insertBefore(replacement, node);
+ });
+ // Remove the original node
+ node.parentNode.removeChild(node);
}
- }
+ });
- // Scroll to first highlight if exists
- const firstHighlight = rootNode.querySelector("mark");
- if (firstHighlight) {
- // Slight delay to ensure DOM is updated
- setTimeout(() => {
- firstHighlight.scrollIntoView({ behavior: "smooth", block: "center" });
- }, 100);
- }
+ return firstHighlight;
}
-// Helper function to clear highlight marks from DOM
+/**
+ * Clears all highlight marks from a DOM node
+ * @param rootNode The root DOM node to clear highlights from
+ */
export function clearHighlightsFromDOM(rootNode: HTMLElement) {
// Find all mark elements
const marks = rootNode.querySelectorAll("mark.highlight-flash");
diff --git a/apps/web/src/lib/markdown/utils/testing.ts b/apps/web/src/lib/markdown/utils/testing.ts
new file mode 100644
index 0000000..a0c9b52
--- /dev/null
+++ b/apps/web/src/lib/markdown/utils/testing.ts
@@ -0,0 +1,146 @@
+/**
+ * Testing utilities for markdown rendering consistency
+ */
+
+import { createClientMarkdownProcessor } from "../client-factory";
+import { renderMarkdownToHtml } from "../server";
+import { logger } from "~/lib/utils/logger";
+
+/**
+ * Normalizes HTML structure to account for differences between server-rendered HTML
+ * and React component output (e.g., extra attributes, whitespace).
+ * Relies on the JSDOM environment provided by Jest.
+ * @param html The raw HTML string
+ * @returns A normalized HTML string
+ */
+export function normalizeHtml(html: string): string {
+ if (typeof document === "undefined") {
+ // This should not happen in a Jest JSDOM environment
+ // but provides a safeguard
+ logger.warn("normalizeHtml called outside of JSDOM environment");
+ return html.trim();
+ }
+
+ // Create a temporary div to parse and normalize the HTML
+ const container = document.createElement("div");
+ container.innerHTML = html;
+
+ // Function to recursively clean attributes and normalize whitespace
+ const cleanNode = (node: Node) => {
+ if (node.nodeType === Node.ELEMENT_NODE) {
+ const element = node as globalThis.Element; // Use globalThis.Element to avoid ambiguity
+ // Remove potentially problematic/inconsistent attributes
+ ["data-reactroot", "style", "class"].forEach((attr) => {
+ if (!element.hasAttribute(attr) || element.getAttribute(attr) === "") {
+ element.removeAttribute(attr);
+ }
+ });
+ // Remove any attributes starting with data- or on (event handlers)
+ const attrsToRemove = Array.from(element.attributes).filter(
+ (attr) => attr.name.startsWith("data-") || attr.name.startsWith("on")
+ );
+ attrsToRemove.forEach((attr) => element.removeAttribute(attr.name));
+
+ // Sort attributes for consistency (optional but helpful)
+ const sortedAttrs = Array.from(element.attributes).sort((a, b) =>
+ a.name.localeCompare(b.name)
+ );
+ // Remove all current attributes before adding sorted ones
+ while (element.attributes.length > 0) {
+ element.removeAttribute(element.attributes[0]?.name || "");
+ }
+ // Add attributes back in sorted order
+ sortedAttrs.forEach((attr) =>
+ element.setAttribute(attr.name, attr.value)
+ );
+ } else if (node.nodeType === Node.TEXT_NODE) {
+ // Normalize whitespace in text nodes
+ node.nodeValue = node.nodeValue?.replace(/\s+/g, " ").trim() || "";
+ // Remove empty text nodes
+ if (!node.nodeValue) {
+ node.parentNode?.removeChild(node);
+ return; // Skip to next node after removal
+ }
+ } else if (node.nodeType === Node.COMMENT_NODE) {
+ // Remove comments
+ node.parentNode?.removeChild(node);
+ return; // Skip to next node after removal
+ }
+
+ // Recursively clean child nodes
+ Array.from(node.childNodes).forEach(cleanNode);
+ };
+
+ cleanNode(container);
+
+ // Return the cleaned innerHTML, potentially trimming leading/trailing whitespace
+ return container.innerHTML.trim();
+}
+
+/**
+ * Compares server-rendered and client-rendered markdown output for consistency
+ *
+ * @param markdown The markdown content to test
+ * @returns Object containing both outputs and whether they are consistent
+ */
+export async function compareRenderedOutput(markdown: string) {
+ // Get server-rendered HTML
+ const serverHtml = await renderMarkdownToHtml(markdown);
+
+ // Get the configuration used for client rendering
+ const clientConfig = createClientMarkdownProcessor();
+
+ return {
+ serverOutput: normalizeHtml(serverHtml),
+ // This is a placeholder - in real tests you would render the actual component
+ clientOutput: `Client would render with: ${clientConfig.remarkPlugins.length} remark plugins`,
+ // In real tests, you would compare the normalized outputs
+ isConsistent: false, // Placeholder
+
+ // Instructions for real test implementation
+ testImplementationNotes: `
+ In actual tests, use:
+ 1. jest-dom or testing-library to render the React component
+ 2. Extract HTML using container.innerHTML
+ 3. Use the normalizeHtml function to prepare both outputs
+ 4. Compare the normalized HTML strings
+ `,
+ };
+}
+
+/**
+ * Test cases covering different markdown features to ensure consistency
+ */
+export const consistencyTestCases = [
+ {
+ name: "Basic text formatting",
+ markdown: "**Bold text** and *italic text* and ~~strikethrough~~",
+ },
+ {
+ name: "Headings",
+ markdown: "# Heading 1\n## Heading 2\n### Heading 3",
+ },
+ {
+ name: "Links",
+ markdown: "[Link text](https://example.com) and [Internal link](/page)",
+ },
+ {
+ name: "Lists",
+ markdown:
+ "- Item 1\n- Item 2\n - Nested item\n1. Ordered item 1\n2. Ordered item 2",
+ },
+ {
+ name: "Code blocks",
+ markdown: "```typescript\nconst x: number = 42;\n```",
+ },
+ {
+ name: "Tables",
+ markdown:
+ "| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |",
+ },
+ {
+ name: "Custom class attributes",
+ markdown:
+ "# Heading with class {.custom-class}\n\nParagraph with class {.text-class}",
+ },
+];
diff --git a/apps/web/src/lib/permissions/client.ts b/apps/web/src/lib/permissions/client.ts
new file mode 100644
index 0000000..7503dde
--- /dev/null
+++ b/apps/web/src/lib/permissions/client.ts
@@ -0,0 +1,67 @@
+"use client";
+
+/**
+ * Client utilities for permissions
+ * These utilities should be safe to use in client components
+ */
+import {
+ type PermissionIdentifier,
+ validatePermissionId,
+ getAllPermissionIds,
+} from "@repo/db/client";
+import { logger } from "~/lib/utils/logger";
+
+/**
+ * Client-side function to check if a permission identifier is valid
+ */
+export function isValidPermissionId(id: string): id is PermissionIdentifier {
+ return validatePermissionId(id);
+}
+
+/**
+ * Get a list of all permission identifiers (client-safe)
+ */
+export function getPermissionList(): PermissionIdentifier[] {
+ return getAllPermissionIds();
+}
+
+/**
+ * Client-side helper to check permission map
+ */
+export function checkPermission(
+ permissionMap: Record | undefined,
+ permission: PermissionIdentifier
+): boolean {
+ if (!permissionMap) return false;
+
+ if (!validatePermissionId(permission)) {
+ logger.warn(`Invalid permission identifier: ${permission}`);
+ return false;
+ }
+
+ return !!permissionMap[permission];
+}
+
+/**
+ * Client-side helper to check if user has any of the specified permissions
+ */
+export function checkAnyPermission(
+ permissionMap: Record | undefined,
+ permissions: PermissionIdentifier[]
+): boolean {
+ if (!permissionMap || permissions.length === 0) return false;
+
+ // Check if any invalid permissions
+ const invalidPermissions = permissions.filter(
+ (p) => !validatePermissionId(p)
+ );
+ if (invalidPermissions.length > 0) {
+ logger.warn(
+ `Invalid permission identifiers: ${invalidPermissions.join(", ")}`
+ );
+ return false;
+ }
+
+ // Return true if any permission is found in the map
+ return permissions.some((permission) => !!permissionMap[permission]);
+}
diff --git a/apps/web/src/lib/permissions/server.ts b/apps/web/src/lib/permissions/server.ts
new file mode 100644
index 0000000..83398b8
--- /dev/null
+++ b/apps/web/src/lib/permissions/server.ts
@@ -0,0 +1,11 @@
+/**
+ * Server-only permissions exports
+ * This file should ONLY be imported in server components or API routes
+ */
+
+// Export validation functions that access the database
+export {
+ validatePermissionsDatabase,
+ fixPermissionsDatabase,
+ logValidationResults,
+} from "./validation";
diff --git a/apps/web/src/lib/permissions/validation.ts b/apps/web/src/lib/permissions/validation.ts
new file mode 100644
index 0000000..9882814
--- /dev/null
+++ b/apps/web/src/lib/permissions/validation.ts
@@ -0,0 +1,145 @@
+/**
+ * SERVER-ONLY PERMISSIONS VALIDATION
+ * This file should never be imported directly from client components
+ * Only import from server components or API routes
+ */
+import { db } from "@repo/db";
+import { permissions } from "@repo/db";
+import { eq } from "drizzle-orm";
+import { getAllPermissions, createPermissionId } from "@repo/db";
+import { logger } from "~/lib/utils/logger";
+
+/**
+ * Validates permissions in the database against the registry
+ * Returns an object containing the validation results
+ */
+export async function validatePermissionsDatabase() {
+ // Get all permissions from the database
+ const dbPermissions = await db.query.permissions.findMany();
+ const registryPermissions = getAllPermissions();
+
+ // Find permissions that are in the registry but missing from the database
+ const missing = registryPermissions.filter((expected) => {
+ const name = createPermissionId(expected);
+ return !dbPermissions.some((p) => p.name === name);
+ });
+
+ // Find permissions that are in the database but not in the registry
+ const extras = dbPermissions.filter((dbPerm) => {
+ // Check if the permission name exists in the registry
+ return !registryPermissions.some(
+ (regPerm) => createPermissionId(regPerm) === dbPerm.name
+ );
+ });
+
+ // Find permissions that have different descriptions
+ const mismatched = dbPermissions.filter((dbPerm) => {
+ const regPerm = registryPermissions.find(
+ (p) => createPermissionId(p) === dbPerm.name
+ );
+ return regPerm && regPerm.description !== dbPerm.description;
+ });
+
+ return {
+ isValid:
+ missing.length === 0 && extras.length === 0 && mismatched.length === 0,
+ missing,
+ extras,
+ mismatched,
+ dbPermissions,
+ registryPermissions,
+ };
+}
+
+/**
+ * Fixes permissions in the database to match the registry
+ * Adds missing permissions, updates mismatched descriptions, and optionally removes extras
+ */
+export async function fixPermissionsDatabase(removeExtras = false) {
+ const validation = await validatePermissionsDatabase();
+ const results = {
+ added: 0,
+ updated: 0,
+ removed: 0,
+ };
+
+ // Add missing permissions
+ for (const permission of validation.missing) {
+ await db.insert(permissions).values({
+ module: permission.module,
+ resource: permission.resource,
+ action: permission.action,
+ description: permission.description,
+ });
+ results.added++;
+ }
+
+ // Update mismatched descriptions
+ for (const dbPerm of validation.mismatched) {
+ const regPerm = validation.registryPermissions.find(
+ (p) => createPermissionId(p) === dbPerm.name
+ );
+
+ if (regPerm) {
+ await db
+ .update(permissions)
+ .set({ description: regPerm.description })
+ .where(eq(permissions.id, dbPerm.id));
+ results.updated++;
+ }
+ }
+
+ // Remove extras if requested
+ if (removeExtras) {
+ for (const extra of validation.extras) {
+ await db.delete(permissions).where(eq(permissions.id, extra.id));
+ results.removed++;
+ }
+ }
+
+ return results;
+}
+
+/**
+ * Logs the result of a permissions database validation
+ */
+export function logValidationResults(
+ validation: Awaited>
+) {
+ if (validation.isValid) {
+ logger.log("✅ Permissions database is valid!");
+ return;
+ }
+
+ if (validation.missing.length > 0) {
+ logger.warn(`⚠️ Found ${validation.missing.length} missing permissions:`);
+ validation.missing.forEach((p) => {
+ logger.warn(` - ${createPermissionId(p)}: ${p.description}`);
+ });
+ }
+
+ if (validation.mismatched.length > 0) {
+ logger.warn(
+ `⚠️ Found ${validation.mismatched.length} permissions with mismatched descriptions:`
+ );
+ validation.mismatched.forEach((dbPerm) => {
+ const regPerm = validation.registryPermissions.find(
+ (p) => createPermissionId(p) === dbPerm.name
+ );
+ if (regPerm) {
+ logger.warn(` - ${dbPerm.name}:`);
+ logger.warn(` DB: ${dbPerm.description}`);
+ logger.warn(` Registry: ${regPerm.description}`);
+ }
+ });
+ }
+
+ if (validation.extras.length > 0) {
+ logger.warn(
+ `⚠️ Found ${validation.extras.length} extra permissions in the database:`
+ );
+ validation.extras.forEach((p) => {
+ logger.warn(` - ${p.name}: ${p.description}`);
+ });
+ }
+}
diff --git a/apps/web/src/lib/services/assets.ts b/apps/web/src/lib/services/assets.ts
new file mode 100644
index 0000000..9c867f9
--- /dev/null
+++ b/apps/web/src/lib/services/assets.ts
@@ -0,0 +1,175 @@
+import { db } from "@repo/db";
+import { assets, assetsToPages } from "@repo/db";
+import { eq, like, ilike, and, sql, SQL } from "drizzle-orm";
+import {
+ PaginationInput,
+ getPaginationParams,
+ createPaginatedResponse,
+} from "../utils/pagination";
+
+export interface AssetSearchFilters {
+ search?: string;
+ fileType?: string;
+ pageId?: number | null;
+}
+
+export const assetService = {
+ /**
+ * Get an asset by ID
+ */
+ async getById(id: string) {
+ return db.query.assets.findFirst({
+ where: eq(assets.id, id),
+ });
+ },
+
+ /**
+ * Get all assets
+ */
+ async getAll() {
+ return db.query.assets.findMany({
+ orderBy: (assets, { desc }) => [desc(assets.createdAt)],
+ with: {
+ uploadedBy: true,
+ },
+ });
+ },
+
+ /**
+ * Get paginated assets with optional search and filters
+ */
+ async getPaginated(
+ pagination: PaginationInput,
+ filters?: AssetSearchFilters
+ ) {
+ const { take, skip } = getPaginationParams(pagination);
+
+ // Build where conditions
+ const whereConditions: SQL[] = [];
+
+ if (filters?.search) {
+ const searchTerm = `%${filters.search}%`;
+ whereConditions.push(
+ sql`(${ilike(assets.fileName, searchTerm)} OR
+ ${ilike(assets.name || "", searchTerm)} OR
+ ${ilike(assets.description || "", searchTerm)})`
+ );
+ }
+
+ if (filters?.fileType) {
+ whereConditions.push(like(assets.fileType, `${filters.fileType}%`));
+ }
+
+ // Handle pageId filtering using assetsToPages junction table if needed
+ // Currently commented out as the schema suggests many-to-many relationship
+ // This would require a join on the assetsToPages table
+
+ // Get total count
+ const whereClause =
+ whereConditions.length > 0 ? and(...whereConditions) : undefined;
+
+ const totalItems = await db
+ .select({ count: sql`count(*)` })
+ .from(assets)
+ .where(whereClause)
+ .then((result) => {
+ if (!result[0]?.count) {
+ throw new Error("Failed to count assets");
+ }
+ return Number(result[0].count);
+ });
+
+ // Get paginated assets
+ const items = await db.query.assets.findMany({
+ where: whereClause,
+ orderBy: (assets, { desc }) => [desc(assets.createdAt)],
+ limit: take,
+ offset: skip,
+ with: {
+ uploadedBy: true,
+ },
+ });
+
+ return createPaginatedResponse(items, pagination, totalItems);
+ },
+
+ /**
+ * Get assets for a page using the junction table
+ */
+ async getByPageId(pageId: number) {
+ // Using the junction table to get assets for a page
+ const assetsForPage = await db
+ .select()
+ .from(assets)
+ .innerJoin(assetsToPages, eq(assets.id, assetsToPages.assetId))
+ .where(eq(assetsToPages.pageId, pageId))
+ .orderBy(assets.createdAt);
+
+ // Map the results to return just the assets
+ return assetsForPage.map((row) => row.assets);
+ },
+
+ /**
+ * Create a new asset
+ */
+ async create(data: {
+ fileName: string;
+ fileType: string;
+ fileSize: number;
+ data: string;
+ uploadedById: number;
+ name?: string | null;
+ description?: string | null;
+ pageId?: number | null;
+ }) {
+ // Extract pageId to handle separately
+ const { pageId, ...assetData } = data;
+
+ // Insert asset
+ const [asset] = await db.insert(assets).values(assetData).returning();
+
+ if (!asset) {
+ throw new Error("Failed to create asset");
+ }
+
+ // If pageId is provided, create relationship in junction table
+ if (pageId) {
+ await db.insert(assetsToPages).values({
+ assetId: asset.id,
+ pageId: pageId,
+ });
+ }
+
+ return asset;
+ },
+
+ /**
+ * Update an asset
+ */
+ async update(
+ id: string,
+ data: {
+ name?: string | null;
+ description?: string | null;
+ }
+ ) {
+ const [updatedAsset] = await db
+ .update(assets)
+ .set(data)
+ .where(eq(assets.id, id))
+ .returning();
+
+ return updatedAsset;
+ },
+
+ /**
+ * Delete an asset
+ */
+ async delete(id: string) {
+ // First delete entries from junction table if any
+ await db.delete(assetsToPages).where(eq(assetsToPages.assetId, id));
+
+ // Then delete the asset
+ return db.delete(assets).where(eq(assets.id, id));
+ },
+};
diff --git a/apps/web/src/lib/services/authorization.ts b/apps/web/src/lib/services/authorization.ts
new file mode 100644
index 0000000..2431051
--- /dev/null
+++ b/apps/web/src/lib/services/authorization.ts
@@ -0,0 +1,469 @@
+import { db } from "@repo/db";
+import {
+ userGroups,
+ groupPermissions,
+ permissions,
+ pagePermissions,
+ groups,
+} from "@repo/db";
+import { eq, and, inArray, or, isNull } from "drizzle-orm";
+import { PermissionIdentifier, validatePermissionId } from "@repo/db";
+import { logger } from "~/lib/utils/logger";
+
+/**
+ * Authorization Service
+ *
+ * Provides functions for checking user permissions
+ */
+export const authorizationService = {
+ /**
+ * Get guest group ID from the database
+ * This is cached to avoid repeated database lookups
+ */
+ _cachedGuestGroupId: null as number | null,
+
+ async getGuestGroupId(): Promise {
+ // If we already have the ID cached, return it
+ if (this._cachedGuestGroupId !== null) {
+ return this._cachedGuestGroupId;
+ }
+
+ // Look up the "Guests" group in the database
+ const guestGroup = await db.query.groups.findFirst({
+ where: eq(groups.name, "Guests"),
+ columns: { id: true },
+ });
+
+ // Cache the result (even if null)
+ this._cachedGuestGroupId = guestGroup?.id ?? null;
+ return this._cachedGuestGroupId;
+ },
+
+ /**
+ * Reset the cached guest group ID (useful after seeding)
+ */
+ resetGuestGroupCache() {
+ this._cachedGuestGroupId = null;
+ },
+
+ /**
+ * Get all groups for a user
+ */
+ async getUserGroups(userId: number | undefined) {
+ // If userId is undefined, return the guest group if available
+ if (userId === undefined) {
+ const guestGroupId = await this.getGuestGroupId();
+ if (guestGroupId) {
+ const guestGroup = await db.query.groups.findFirst({
+ where: eq(groups.id, guestGroupId),
+ });
+ return guestGroup ? [guestGroup] : [];
+ }
+ return [];
+ }
+
+ const userGroupsData = await db.query.userGroups.findMany({
+ where: eq(userGroups.userId, userId),
+ with: {
+ group: true,
+ },
+ });
+
+ return userGroupsData.map((ug) => ug.group);
+ },
+
+ /**
+ * Get all permission IDs for a user based on their group memberships
+ * Note: This only considers direct group permissions, not module/action permissions.
+ * For comprehensive checks, use hasPermission.
+ */
+ async getUserPermissionIds(userId: number | undefined) {
+ // If userId is undefined, use the guest group
+ if (userId === undefined) {
+ const guestGroupId = await this.getGuestGroupId();
+ if (!guestGroupId) {
+ return [];
+ }
+
+ // Get permissions for the guest group
+ const guestGroupPermissions = await db.query.groupPermissions.findMany({
+ where: eq(groupPermissions.groupId, guestGroupId),
+ columns: { permissionId: true },
+ });
+
+ return guestGroupPermissions.map((gp) => gp.permissionId);
+ }
+
+ // Get all groups the user belongs to
+ const userGroupsData = await db.query.userGroups.findMany({
+ where: eq(userGroups.userId, userId),
+ columns: { groupId: true }, // Only need group IDs
+ });
+
+ if (userGroupsData.length === 0) {
+ return [];
+ }
+
+ const groupIds = userGroupsData.map((ug) => ug.groupId);
+
+ // Get all permissions for those groups
+ const groupPermissionsData = await db.query.groupPermissions.findMany({
+ where: inArray(groupPermissions.groupId, groupIds),
+ columns: { permissionId: true }, // Only need permission IDs
+ });
+
+ // Return unique permission IDs
+ return [...new Set(groupPermissionsData.map((gp) => gp.permissionId))];
+ },
+
+ /**
+ * Get all permissions for a user based on their group memberships
+ * Note: This only considers direct group permissions, not module/action permissions.
+ * For comprehensive checks, use hasPermission.
+ */
+ async getUserPermissions(userId: number | undefined) {
+ const permissionIds = await this.getUserPermissionIds(userId);
+
+ if (permissionIds.length === 0) {
+ return [];
+ }
+
+ // Get permission details
+ return db.query.permissions.findMany({
+ where: inArray(permissions.id, permissionIds),
+ });
+ },
+
+ /**
+ * Check if a user has a specific permission.
+ * This function considers group memberships, specific permissions assigned
+ * to those groups, and any module/action permissions applied to those groups.
+ * Permission is granted if *at least one* of the user's groups grants the
+ * permission AND does not have a relevant module or action permission.
+ *
+ * For non-authenticated users (userId is undefined), it checks the guest group.
+ */
+ async hasPermission(
+ userId: number | undefined,
+ permissionName: PermissionIdentifier
+ ): Promise {
+ // Validate permission format
+ if (!validatePermissionId(permissionName)) {
+ logger.error(`Invalid permission format: ${permissionName}`);
+ return false;
+ }
+
+ // 1. Parse permission name and find the corresponding permission ID
+ const [module, resource, action] = permissionName.split(":");
+ if (!module || !resource || !action) {
+ logger.error(`Invalid permission format: ${permissionName}`);
+ return false; // Invalid format
+ }
+
+ const permission = await db.query.permissions.findFirst({
+ where: and(
+ eq(permissions.module, module),
+ eq(permissions.resource, resource),
+ eq(permissions.action, action)
+ ),
+ columns: { id: true }, // Only need the ID
+ });
+
+ if (!permission) {
+ // logger.warn(`Permission not found: ${permissionName}`);
+ return false; // Permission doesn't exist in the system
+ }
+ const permissionId = permission.id;
+
+ // For non-authenticated users (userId is undefined), check the guest group
+ if (userId === undefined) {
+ const guestGroupId = await this.getGuestGroupId();
+ if (!guestGroupId) {
+ return false; // No guest group defined
+ }
+
+ // Get the guest group with its permissions
+ const guestGroup = await db.query.groups.findFirst({
+ where: eq(groups.id, guestGroupId),
+ with: {
+ groupPermissions: {
+ columns: { permissionId: true },
+ },
+ groupModulePermissions: {
+ columns: { module: true },
+ },
+ groupActionPermissions: {
+ columns: { action: true },
+ },
+ },
+ });
+
+ if (!guestGroup) {
+ return false;
+ }
+
+ // Check if the guest group has this permission
+ const hasGroupPermission = guestGroup.groupPermissions.some(
+ (gp) => gp.permissionId === permissionId
+ );
+
+ if (!hasGroupPermission) {
+ return false;
+ }
+
+ // Check module permissions
+ const hasModulePermission = guestGroup.groupModulePermissions.some(
+ (mp) => mp.module === module
+ );
+ if (
+ !hasModulePermission &&
+ guestGroup.groupModulePermissions.length > 0
+ ) {
+ return false;
+ }
+
+ // Check action permissions
+ const hasActionPermission = guestGroup.groupActionPermissions.some(
+ (ap) => ap.action === action
+ );
+ if (
+ !hasActionPermission &&
+ guestGroup.groupActionPermissions.length > 0
+ ) {
+ return false;
+ }
+
+ // If we get here, the guest has permission
+ return true;
+ }
+
+ // 2. Get all groups the user belongs to, including their permissions and permissions
+ const userGroupsData = await db.query.userGroups.findMany({
+ where: eq(userGroups.userId, userId),
+ with: {
+ group: {
+ with: {
+ // Eagerly load necessary related data for checking
+ groupPermissions: {
+ columns: { permissionId: true }, // Only need permissionId
+ // Optimization: Could filter here if DB supports it well
+ // where: eq(groupPermissions.permissionId, permissionId)
+ },
+ groupModulePermissions: {
+ columns: { module: true }, // Only need module name
+ // Optimization: Could filter here
+ // where: eq(groupModulePermissions.module, module)
+ },
+ groupActionPermissions: {
+ columns: { action: true }, // Only need action name
+ // Optimization: Could filter here
+ // where: eq(groupActionPermissions.action, action)
+ },
+ },
+ },
+ },
+ });
+
+ if (userGroupsData.length === 0) {
+ return false; // User belongs to no groups
+ }
+
+ // 3. Iterate through the user's groups and apply logic
+ for (const ug of userGroupsData) {
+ const group = ug.group;
+ // Should always have group due to inner join nature of 'with', but check defensively
+ if (!group) continue;
+
+ // Does this group grant the specific permission?
+ const hasGroupPermission = group.groupPermissions.some(
+ (gp) => gp.permissionId === permissionId
+ );
+
+ if (!hasGroupPermission) {
+ continue; // This group doesn't grant the required permission, try next group
+ }
+
+ // Is this group restricted for the required module?
+ // If the group has no module permissions, it is unrestricted
+ const hasModulePermission = group.groupModulePermissions.some(
+ (mp) => mp.module === module
+ );
+ if (!hasModulePermission && group.groupModulePermissions.length > 0) {
+ continue; // Module restricted for this group, try next group
+ }
+
+ // Is this group restricted for the required action?
+ // If the group has no action permissions, it is unrestricted
+ const hasActionPermission = group.groupActionPermissions.some(
+ (ap) => ap.action === action
+ );
+ if (!hasActionPermission && group.groupActionPermissions.length > 0) {
+ continue; // Action restricted for this group, try next group
+ }
+
+ // If we reach here:
+ // - The user is in this group.
+ // - This group has the required permission.
+ // - This group does have a module permission for the required module or no module permissions.
+ // - This group does have an action permission for the required action or no action permissions.
+ // Therefore, the user has the permission via this group.
+ return true;
+ }
+
+ // 4. If loop completes, no group grants the permission without permissions
+ return false;
+ },
+
+ /**
+ * Check if a user has any of the specified permissions.
+ * Returns true if the user has at least one of the permissions.
+ * For non-authenticated users (userId is undefined), it checks the guest group.
+ */
+ async hasAnyPermission(
+ userId: number | undefined,
+ permissionNames: PermissionIdentifier[]
+ ): Promise {
+ if (!permissionNames.length) {
+ return false;
+ }
+
+ // Check each permission and return true on the first match
+ for (const permission of permissionNames) {
+ const hasPermission = await this.hasPermission(userId, permission);
+ if (hasPermission) {
+ return true;
+ }
+ }
+
+ return false;
+ },
+
+ /**
+ * Check if a user has permission to access a specific page
+ * For non-authenticated users (userId is undefined), it checks the guest group.
+ */
+ async hasPagePermission(
+ userId: number | undefined,
+ pageId: number,
+ permissionName: PermissionIdentifier
+ ) {
+ // Validate permission format
+ if (!validatePermissionId(permissionName)) {
+ logger.error(`Invalid permission format: ${permissionName}`);
+ return false;
+ }
+
+ // Parse the permission name to get module, resource, and action
+ const [module, resource, action] = permissionName.split(":");
+
+ if (!module || !resource || !action) {
+ return false;
+ }
+
+ // Get the permission ID using the specific components
+ const permission = await db.query.permissions.findFirst({
+ where: and(
+ eq(permissions.module, module),
+ eq(permissions.resource, resource),
+ eq(permissions.action, action)
+ ),
+ columns: { id: true }, // Only need the ID
+ });
+
+ if (!permission) {
+ return false;
+ }
+ const permissionId = permission.id;
+
+ // Special handling for non-authenticated users
+ if (userId === undefined) {
+ const guestGroupId = await this.getGuestGroupId();
+ if (!guestGroupId) {
+ return false; // No guest group defined
+ }
+
+ // Check for explicit page permissions for guest group
+ const pagePermissionData = await db.query.pagePermissions.findMany({
+ where: and(
+ eq(pagePermissions.pageId, pageId),
+ eq(pagePermissions.permissionId, permissionId),
+ or(
+ eq(pagePermissions.groupId, guestGroupId),
+ isNull(pagePermissions.groupId)
+ )
+ ),
+ columns: { permissionType: true },
+ });
+
+ // If there are explicit page permissions, use those
+ if (pagePermissionData.length > 0) {
+ const hasDenyPermission = pagePermissionData.some(
+ (pp) => pp.permissionType === "deny"
+ );
+
+ if (hasDenyPermission) {
+ return false; // Explicit deny takes precedence
+ }
+
+ const hasAllowPermission = pagePermissionData.some(
+ (pp) => pp.permissionType === "allow"
+ );
+ if (hasAllowPermission) {
+ return true; // Explicit allow
+ }
+ }
+
+ // Otherwise, check regular guest permissions
+ return this.hasPermission(undefined, permissionName);
+ }
+
+ // Get all groups the user belongs to (only need IDs here)
+ const userGroupsData = await db.query.userGroups.findMany({
+ where: eq(userGroups.userId, userId),
+ columns: { groupId: true },
+ });
+
+ const groupIds = userGroupsData.map((ug) => ug.groupId);
+
+ // Check for explicit page permissions first (they override group permissions)
+ // Query needs to check for permissions specific to the user's groups OR global page permissions (groupId is null)
+ const pagePermissionData = await db.query.pagePermissions.findMany({
+ where: and(
+ eq(pagePermissions.pageId, pageId),
+ eq(pagePermissions.permissionId, permissionId),
+ groupIds.length > 0 // Only include group-specific check if user is in groups
+ ? or(
+ inArray(pagePermissions.groupId, groupIds),
+ isNull(pagePermissions.groupId) // null group ID means it applies to all users
+ )
+ : isNull(pagePermissions.groupId) // If user has no groups, only check global page perms
+ ),
+ columns: { permissionType: true }, // Only need type
+ });
+
+ // If there are explicit page permissions, use those
+ if (pagePermissionData.length > 0) {
+ // Check if any is a "deny" permission
+ const hasDenyPermission = pagePermissionData.some(
+ (pp) => pp.permissionType === "deny"
+ );
+
+ if (hasDenyPermission) {
+ return false; // Explicit deny takes precedence
+ }
+
+ // If there's at least one "allow" permission (and no deny), return true
+ // Note: An empty result means no explicit allow/deny, so we fall through
+ const hasAllowPermission = pagePermissionData.some(
+ (pp) => pp.permissionType === "allow"
+ );
+ if (hasAllowPermission) {
+ return true; // Explicit allow
+ }
+ }
+
+ // Otherwise (no explicit page allow/deny found), fall back to general permissions check
+ // This now uses the optimized and corrected hasPermission logic
+ return this.hasPermission(userId, permissionName);
+ },
+};
diff --git a/apps/web/src/lib/services/groups.ts b/apps/web/src/lib/services/groups.ts
new file mode 100644
index 0000000..b847b2f
--- /dev/null
+++ b/apps/web/src/lib/services/groups.ts
@@ -0,0 +1,445 @@
+import { db } from "@repo/db";
+import {
+ groups,
+ userGroups,
+ groupPermissions,
+ groupModulePermissions,
+ groupActionPermissions,
+} from "@repo/db";
+import { eq, and, inArray } from "drizzle-orm";
+import type { groups as groupsTable } from "@repo/db";
+import { logger } from "../utils/logger";
+
+type Group = typeof groupsTable.$inferSelect;
+
+/**
+ * Group Service
+ *
+ * Handles operations related to user groups and their permissions
+ */
+export const groupService = {
+ /**
+ * Get all groups
+ */
+ async getAll() {
+ return db.query.groups.findMany({
+ orderBy: (groups, { asc }) => [asc(groups.name)],
+ });
+ },
+
+ /**
+ * Get a group by ID
+ */
+ async getById(id: number) {
+ return db.query.groups.findFirst({
+ where: eq(groups.id, id),
+ });
+ },
+
+ /**
+ * Get all users in a group
+ */
+ async getGroupUsers(groupId: number) {
+ const results = await db.query.userGroups.findMany({
+ where: eq(userGroups.groupId, groupId),
+ with: {
+ user: true,
+ },
+ });
+
+ return results.map((result) => result.user);
+ },
+
+ /**
+ * Get all permissions for a group
+ */
+ async getGroupPermissions(groupId: number) {
+ const results = await db.query.groupPermissions.findMany({
+ where: eq(groupPermissions.groupId, groupId),
+ with: {
+ permission: true,
+ },
+ });
+
+ return results.map((result) => result.permission);
+ },
+
+ /**
+ * Create a new group
+ */
+ async create({ name, description }: { name: string; description?: string }) {
+ const result = await db
+ .insert(groups)
+ .values({
+ name,
+ description,
+ })
+ .returning();
+
+ return result[0];
+ },
+
+ /**
+ * Update an existing group
+ */
+ async update(
+ id: number,
+ {
+ name,
+ description,
+ }: {
+ name?: string;
+ description?: string;
+ }
+ ) {
+ const result = await db
+ .update(groups)
+ .set({
+ name,
+ description,
+ updatedAt: new Date(),
+ })
+ .where(eq(groups.id, id))
+ .returning();
+
+ return result[0];
+ },
+
+ /**
+ * Delete a group
+ */
+ async delete(id: number) {
+ // Delete associated records first
+ await db.delete(userGroups).where(eq(userGroups.groupId, id));
+ await db.delete(groupPermissions).where(eq(groupPermissions.groupId, id));
+
+ const result = await db.delete(groups).where(eq(groups.id, id)).returning();
+
+ return result[0];
+ },
+
+ /**
+ * Add users to a group
+ */
+ async addUsers(groupId: number, userIds: number[]) {
+ // Ensure the group exists
+ const group = await this.getById(groupId);
+ if (!group) {
+ throw new Error(`Group with id ${groupId} not found`);
+ }
+
+ // Get existing user-group associations
+ const existingAssociations = await db.query.userGroups.findMany({
+ where: and(
+ eq(userGroups.groupId, groupId),
+ inArray(userGroups.userId, userIds)
+ ),
+ });
+
+ const existingUserIds = existingAssociations.map((assoc) => assoc.userId);
+
+ // Filter out users already in the group
+ const newUserIds = userIds.filter(
+ (userId) => !existingUserIds.includes(userId)
+ );
+
+ if (newUserIds.length === 0) {
+ return { added: 0 };
+ }
+
+ // Add users to the group
+ await db.insert(userGroups).values(
+ newUserIds.map((userId) => ({
+ userId,
+ groupId,
+ }))
+ );
+
+ return { added: newUserIds.length };
+ },
+
+ /**
+ * Remove users from a group
+ */
+ async removeUsers(groupId: number, userIds: number[]) {
+ await db
+ .delete(userGroups)
+ .where(
+ and(
+ eq(userGroups.groupId, groupId),
+ inArray(userGroups.userId, userIds)
+ )
+ );
+
+ return { removed: userIds.length };
+ },
+
+ /**
+ * Add permissions to a group
+ */
+ async addPermissions(groupId: number, permissionIds: number[]) {
+ // Ensure the group exists
+ const group = await this.getById(groupId);
+ if (!group) {
+ throw new Error(`Group with id ${groupId} not found`);
+ }
+
+ // Get all existing permissions for this group
+ const existingPermissions = await this.getGroupPermissions(groupId);
+ const existingPermissionIds = existingPermissions.map((p) => p.id);
+
+ // Identify permissions to remove (permissions that exist but aren't in the new list)
+ const permissionIdsToRemove = existingPermissionIds.filter(
+ (id) => !permissionIds.includes(id)
+ );
+
+ // Remove permissions that were unchecked
+ if (permissionIdsToRemove.length > 0) {
+ await this.removePermissions(groupId, permissionIdsToRemove);
+ }
+
+ // Identify new permissions to add
+ const permissionIdsToAdd = permissionIds.filter(
+ (id) => !existingPermissionIds.includes(id)
+ );
+
+ if (permissionIdsToAdd.length === 0) {
+ return { added: 0, removed: permissionIdsToRemove.length };
+ }
+
+ // Add new permissions to the group
+ await db.insert(groupPermissions).values(
+ permissionIdsToAdd.map((permissionId) => ({
+ permissionId,
+ groupId,
+ }))
+ );
+
+ return {
+ added: permissionIdsToAdd.length,
+ removed: permissionIdsToRemove.length,
+ };
+ },
+
+ /**
+ * Remove permissions from a group
+ */
+ async removePermissions(groupId: number, permissionIds: number[]) {
+ await db
+ .delete(groupPermissions)
+ .where(
+ and(
+ eq(groupPermissions.groupId, groupId),
+ inArray(groupPermissions.permissionId, permissionIds)
+ )
+ );
+
+ return { removed: permissionIds.length };
+ },
+
+ /**
+ * Finds a group by its name.
+ * @param name - The name of the group.
+ * @returns The group object or null if not found.
+ */
+ async findByName(name: string) {
+ const result = await db.query.groups.findFirst({
+ where: eq(groups.name, name),
+ });
+ return result ?? null;
+ },
+
+ /**
+ * Adds a user to a specific group.
+ * Uses the 'userGroups' table.
+ * @param userId - The ID of the user (assuming number type based on userGroups schema).
+ * @param groupId - The ID of the group.
+ * @returns True if the user was added, false otherwise (e.g., already exists).
+ */
+ async addUserToGroup(userId: number, groupId: number): Promise {
+ try {
+ await db
+ .insert(userGroups)
+ .values({ userId, groupId })
+ .onConflictDoNothing();
+ return true;
+ } catch (error) {
+ logger.error(`Error adding user ${userId} to group ${groupId}:`, error);
+ return false;
+ }
+ },
+
+ /**
+ * Removes a user from a specific group.
+ * Uses the 'userGroups' table.
+ * @param userId - The ID of the user (assuming number type).
+ * @param groupId - The ID of the group.
+ * @returns True if the user was removed, false otherwise.
+ */
+ async removeUserFromGroup(userId: number, groupId: number): Promise {
+ try {
+ const result = await db
+ .delete(userGroups)
+ .where(
+ and(eq(userGroups.userId, userId), eq(userGroups.groupId, groupId))
+ );
+ return (result?.rowCount ?? 0) > 0;
+ } catch (error) {
+ logger.error(
+ `Error removing user ${userId} from group ${groupId}:`,
+ error
+ );
+ return false;
+ }
+ },
+
+ /**
+ * Gets all groups a user belongs to.
+ * Uses the 'userGroups' table.
+ * @param userId - The ID of the user (assuming number type).
+ * @returns An array of group objects.
+ */
+ async getUserGroups(userId: number): Promise {
+ const userGroupRelations = await db.query.userGroups.findMany({
+ where: eq(userGroups.userId, userId),
+ with: {
+ group: true,
+ },
+ });
+ return userGroupRelations.map((ug) => ug.group as Group);
+ },
+
+ /**
+ * Add module permissions to a group
+ */
+ async addModulePermissions(groupId: number, modules: string[]) {
+ // Ensure the group exists
+ const group = await this.getById(groupId);
+ if (!group) {
+ throw new Error(`Group with id ${groupId} not found`);
+ }
+
+ // Get existing module permissions
+ const existingModulePermissions = await this.getModulePermissions(groupId);
+ const existingModules = existingModulePermissions.map((p) => p.module);
+
+ // Remove modules that are not in the new list
+ const modulesToRemove = existingModules.filter(
+ (module) => !modules.includes(module)
+ );
+ if (modulesToRemove.length > 0) {
+ await this.removeModulePermissions(groupId, modulesToRemove);
+ }
+
+ // Find modules that need to be added (not already existing)
+ const modulesToAdd = modules.filter(
+ (module) => !existingModules.includes(module)
+ );
+
+ if (modulesToAdd.length === 0) {
+ return { added: 0, removed: modulesToRemove.length };
+ }
+
+ // Add new module permissions
+ await db.insert(groupModulePermissions).values(
+ modulesToAdd.map((module) => ({
+ groupId,
+ module,
+ }))
+ );
+
+ return { added: modulesToAdd.length, removed: modulesToRemove.length };
+ },
+
+ /**
+ * Add action permissions to a group
+ */
+ async addActionPermissions(groupId: number, actions: string[]) {
+ // Ensure the group exists
+ const group = await this.getById(groupId);
+ if (!group) {
+ throw new Error(`Group with id ${groupId} not found`);
+ }
+
+ // Get existing action permissions
+ const existingActionPermissions = await this.getActionPermissions(groupId);
+ const existingActions = existingActionPermissions.map((p) => p.action);
+
+ // Remove actions that are not in the new list
+ const actionsToRemove = existingActions.filter(
+ (action) => !actions.includes(action)
+ );
+ if (actionsToRemove.length > 0) {
+ await this.removeActionPermissions(groupId, actionsToRemove);
+ }
+
+ // Find actions that need to be added (not already existing)
+ const actionsToAdd = actions.filter(
+ (action) => !existingActions.includes(action)
+ );
+
+ if (actionsToAdd.length === 0) {
+ return { added: 0, removed: actionsToRemove.length };
+ }
+
+ // Add new action permissions
+ await db.insert(groupActionPermissions).values(
+ actionsToAdd.map((action) => ({
+ groupId,
+ action,
+ }))
+ );
+
+ return { added: actionsToAdd.length, removed: actionsToRemove.length };
+ },
+
+ /**
+ * Get module permissions for a group
+ */
+ async getModulePermissions(groupId: number) {
+ return db.query.groupModulePermissions.findMany({
+ where: eq(groupModulePermissions.groupId, groupId),
+ });
+ },
+
+ /**
+ * Get action permissions for a group
+ */
+ async getActionPermissions(groupId: number) {
+ return db.query.groupActionPermissions.findMany({
+ where: eq(groupActionPermissions.groupId, groupId),
+ });
+ },
+
+ /**
+ * Remove module permissions from a group
+ */
+ async removeModulePermissions(groupId: number, modules: string[]) {
+ await db
+ .delete(groupModulePermissions)
+ .where(
+ and(
+ eq(groupModulePermissions.groupId, groupId),
+ inArray(groupModulePermissions.module, modules)
+ )
+ );
+
+ return { removed: modules.length };
+ },
+
+ /**
+ * Remove action permissions from a group
+ */
+ async removeActionPermissions(groupId: number, actions: string[]) {
+ await db
+ .delete(groupActionPermissions)
+ .where(
+ and(
+ eq(groupActionPermissions.groupId, groupId),
+ inArray(groupActionPermissions.action, actions)
+ )
+ );
+
+ return { removed: actions.length };
+ },
+};
diff --git a/src/lib/services/index.ts b/apps/web/src/lib/services/index.ts
similarity index 68%
rename from src/lib/services/index.ts
rename to apps/web/src/lib/services/index.ts
index 8da9fd5..2595284 100644
--- a/src/lib/services/index.ts
+++ b/apps/web/src/lib/services/index.ts
@@ -4,6 +4,10 @@ import { tagService } from "./tags";
import { searchService } from "./search";
import { lockService } from "./locks";
import { assetService } from "./assets";
+import { permissionService } from "./permissions";
+import { groupService } from "./groups";
+import { authorizationService } from "./authorization";
+import { markdownService } from "./markdown";
/**
* Database Services
@@ -50,6 +54,26 @@ export const dbService = {
* Asset management operations
*/
assets: assetService,
+
+ /**
+ * Permission management operations
+ */
+ permissions: permissionService,
+
+ /**
+ * Group management operations
+ */
+ groups: groupService,
+
+ /**
+ * Authorization operations
+ */
+ auth: authorizationService,
+
+ /**
+ * Markdown processing operations
+ */
+ markdown: markdownService,
};
// Export individual services for direct use
@@ -60,4 +84,8 @@ export {
searchService,
lockService,
assetService,
+ permissionService,
+ groupService,
+ authorizationService,
+ markdownService,
};
diff --git a/src/lib/services/locks.ts b/apps/web/src/lib/services/locks.ts
similarity index 89%
rename from src/lib/services/locks.ts
rename to apps/web/src/lib/services/locks.ts
index f606c03..6f38200 100644
--- a/src/lib/services/locks.ts
+++ b/apps/web/src/lib/services/locks.ts
@@ -1,6 +1,7 @@
-import { db } from "~/lib/db";
-import { wikiPages } from "~/lib/db/schema";
+import { db } from "@repo/db";
+import { wikiPages } from "@repo/db";
import { eq, sql } from "drizzle-orm";
+import { logger } from "~/lib/utils/logger";
// Software lock timeout in minutes
const LOCK_TIMEOUT_MINUTES = 5;
@@ -68,6 +69,11 @@ export const lockService = {
.where(eq(wikiPages.id, pageId))
.returning();
+ if (!updatedPage) {
+ // If the update didn't return a page (e.g., concurrent delete)
+ return { success: false, page: null };
+ }
+
return { success: true, page: updatedPage };
}
@@ -104,11 +110,16 @@ export const lockService = {
.where(eq(wikiPages.id, pageId))
.returning();
+ if (!updatedPage) {
+ // If the update didn't return a page (e.g., concurrent delete)
+ return { success: false, page: null };
+ }
+
// Return success - hardware lock will be released when transaction commits
return { success: true, page: updatedPage };
} catch (err) {
// If we get an error with NOWAIT, it means the row is hardware-locked
- console.log("Failed to acquire hardware lock:", err);
+ logger.log("Failed to acquire hardware lock:", err);
// We need to throw here to properly abort this transaction
// but still allow outer catch block to handle it
@@ -116,7 +127,7 @@ export const lockService = {
}
});
} catch (txError) {
- console.log("Transaction error during lock acquisition:", txError);
+ logger.log("Transaction error during lock acquisition:", txError);
// Recheck the page state after transaction failure
try {
@@ -137,12 +148,12 @@ export const lockService = {
// Return the current page state
return { success: false, page: pageAfterTx };
} catch (finalError) {
- console.error("Final error checking lock state:", finalError);
+ logger.error("Final error checking lock state:", finalError);
return { success: false, page: page };
}
}
} catch (error) {
- console.error("Error during lock acquisition:", error);
+ logger.error("Error during lock acquisition:", error);
return { success: false, page: null };
}
},
@@ -193,7 +204,7 @@ export const lockService = {
return { isLocked: true, lockedByUserId: null };
}
} catch (error) {
- console.error("Error checking lock status:", error);
+ logger.error("Error checking lock status:", error);
return { isLocked: true, lockedByUserId: null }; // Assume locked on error for safety
}
},
@@ -239,10 +250,15 @@ export const lockService = {
.where(eq(wikiPages.id, pageId))
.returning();
+ if (!updatedPage) {
+ // If the update didn't return a page
+ return { success: false, page: null };
+ }
+
return { success: true, page: updatedPage };
});
} catch (error) {
- console.error("Failed to refresh lock:", error);
+ logger.error("Failed to refresh lock:", error);
return { success: false, page: null };
}
},
@@ -281,7 +297,7 @@ export const lockService = {
return result;
} catch (error) {
- console.error("Failed to release lock:", error);
+ logger.error("Failed to release lock:", error);
return false;
}
},
diff --git a/apps/web/src/lib/services/markdown.ts b/apps/web/src/lib/services/markdown.ts
new file mode 100644
index 0000000..c732a45
--- /dev/null
+++ b/apps/web/src/lib/services/markdown.ts
@@ -0,0 +1,95 @@
+/**
+ * Markdown service for rendering and processing markdown content
+ */
+
+import { renderMarkdownToHtml as baseRenderMarkdownToHtml } from "~/lib/markdown";
+import { db } from "@repo/db";
+import { wikiPages } from "@repo/db";
+import { eq } from "drizzle-orm";
+import { invalidatePageExistenceCache } from "~/lib/markdown/plugins/server-only/rehypeWikiLinks";
+import { logger } from "~/lib/utils/logger";
+
+/**
+ * Renders markdown content to HTML with enhanced wiki features
+ * - Checks for internal links and marks non-existent pages (via rehype plugin)
+ * - Updates the database with the rendered HTML
+ *
+ * @param content Markdown content to render
+ * @param pageId Optional ID of the page for updating the database
+ * @param pagePath Optional path of the current page for link resolution
+ * @returns The rendered HTML string
+ */
+export async function renderWikiMarkdownToHtml(
+ content: string,
+ pageId?: number,
+ pagePath?: string
+): Promise {
+ // Render the markdown content to HTML (the rehype plugin will process links automatically)
+ // If pagePath is provided, strip any leading slash to match database paths format
+ const normalizedPagePath = pagePath?.startsWith("/")
+ ? pagePath.substring(1)
+ : pagePath;
+ const renderedHtml = await baseRenderMarkdownToHtml(
+ content,
+ normalizedPagePath
+ );
+
+ // If page ID is provided, update the database with the rendered HTML
+ // We do not await this as it is not needed for the function to return
+ if (pageId) {
+ void db
+ .update(wikiPages)
+ .set({
+ renderedHtml,
+ renderedHtmlUpdatedAt: new Date(),
+ })
+ .where(eq(wikiPages.id, pageId))
+ .catch((error) => {
+ logger.error("Error updating rendered HTML for page", pageId, error);
+ });
+ }
+
+ return renderedHtml;
+}
+
+/**
+ * Updates all rendered HTML for all wiki pages in the database
+ * Useful for refreshing after changes to rendering logic
+ */
+export async function rebuildAllRenderedHtml(): Promise {
+ // Invalidate the page existence cache for a full refresh
+ invalidatePageExistenceCache();
+
+ logger.log("Rebuilding all rendered HTML");
+
+ const allPages = await db.query.wikiPages.findMany({
+ columns: {
+ id: true,
+ content: true,
+ path: true,
+ },
+ });
+
+ logger.log("Found", allPages.length, "pages to rebuild");
+
+ for (const page of allPages) {
+ if (page.content) {
+ logger.debug("Rebuilding rendered HTML for page", page.id);
+ await renderWikiMarkdownToHtml(page.content, page.id, page.path);
+ }
+ }
+}
+
+/**
+ * Hook to invalidate wiki link cache after page creation/update/deletion
+ * Call this when pages are created/updated/deleted to ensure link status is updated
+ */
+export function invalidateWikiLinkCache(): void {
+ invalidatePageExistenceCache();
+}
+
+export const markdownService = {
+ renderWikiMarkdownToHtml,
+ rebuildAllRenderedHtml,
+ invalidateWikiLinkCache,
+};
diff --git a/apps/web/src/lib/services/permissions.ts b/apps/web/src/lib/services/permissions.ts
new file mode 100644
index 0000000..c54b117
--- /dev/null
+++ b/apps/web/src/lib/services/permissions.ts
@@ -0,0 +1,101 @@
+import { db } from "@repo/db";
+import { permissions } from "@repo/db";
+import { eq } from "drizzle-orm";
+
+/**
+ * Permission Service
+ *
+ * Handles operations related to system permissions
+ */
+export const permissionService = {
+ /**
+ * Get all permissions in the system
+ */
+ async getAll() {
+ return db.query.permissions.findMany({
+ orderBy: (permissions, { asc }) => [
+ asc(permissions.module),
+ asc(permissions.action),
+ ],
+ });
+ },
+
+ /**
+ * Get a permission by ID
+ */
+ async getById(id: number) {
+ return db.query.permissions.findFirst({
+ where: eq(permissions.id, id),
+ });
+ },
+
+ /**
+ * Create a new permission
+ */
+ async create({
+ description,
+ module,
+ resource,
+ action,
+ }: {
+ description?: string;
+ module: string;
+ resource: string;
+ action: string;
+ }) {
+ const result = await db
+ .insert(permissions)
+ .values({
+ description,
+ module,
+ resource,
+ action,
+ })
+ .returning();
+
+ return result[0];
+ },
+
+ /**
+ * Update an existing permission
+ */
+ async update(
+ id: number,
+ {
+ description,
+ module,
+ resource,
+ action,
+ }: {
+ description?: string;
+ module?: string;
+ resource?: string;
+ action?: string;
+ }
+ ) {
+ const result = await db
+ .update(permissions)
+ .set({
+ description,
+ module,
+ resource,
+ action,
+ })
+ .where(eq(permissions.id, id))
+ .returning();
+
+ return result[0];
+ },
+
+ /**
+ * Delete a permission by ID
+ */
+ async delete(id: number) {
+ const result = await db
+ .delete(permissions)
+ .where(eq(permissions.id, id))
+ .returning();
+
+ return result[0];
+ },
+};
diff --git a/apps/web/src/lib/services/search.ts b/apps/web/src/lib/services/search.ts
new file mode 100644
index 0000000..e4e77c2
--- /dev/null
+++ b/apps/web/src/lib/services/search.ts
@@ -0,0 +1,403 @@
+import { db } from "@repo/db";
+import { wikiPages } from "@repo/db";
+import { sql, count as drizzleCount } from "drizzle-orm";
+import { PaginationInput, getPaginationParams } from "~/lib/utils/pagination";
+import { logger } from "~/lib/utils/logger";
+
+// Define the structure of a search result item
+export interface SearchResultItem {
+ id: number;
+ title: string;
+ path: string;
+ updatedAt: Date | null;
+ excerpt: string;
+ relevance: number;
+ similarity_title?: number; // Optional fields from similarity searches
+ similarity_content?: number; // Optional fields from similarity searches
+}
+
+/**
+ * Search service - handles all search-related database operations
+ */
+export const searchService = {
+ /**
+ * Search whole wiki content, using a multi-layer approach.
+ * 1. Vector search with tsquery
+ * 2. Title search with like (case-insensitive)
+ * 3. Content search with like (case-insensitive)
+ * 4. Fuzzy matching using trigram similarity for typos
+ */
+ search: async (query: string) => {
+ // Properly format the vector query for PostgreSQL
+ // Split by spaces and add :* to each word, then join with '&' (AND operator)
+ const vectorQueryTerms = query
+ .trim()
+ .split(/\s+/)
+ .filter(Boolean)
+ .map((term) => `${term}:*`)
+ .join(" & ");
+
+ // Fallback to basic search if the query is empty after processing
+ const vectorQuery = vectorQueryTerms || `${query}:*`;
+
+ // Use try/catch to handle potential tsquery syntax errors
+ try {
+ const results = await db
+ .select({
+ id: wikiPages.id,
+ title: wikiPages.title,
+ content: wikiPages.content,
+ path: wikiPages.path,
+ createdAt: wikiPages.createdAt,
+ updatedAt: wikiPages.updatedAt,
+ excerpt: sql`
+ CASE
+ WHEN ${wikiPages.content} ILIKE ${"%" + query + "%"}
+ THEN substring(${wikiPages.content} from
+ greatest(1, position(lower(${query}) in lower(${
+ wikiPages.content
+ })) - 100)
+ for 300)
+ ELSE substring(${wikiPages.content} from 1 for 200)
+ END`.as("excerpt"),
+ relevance: sql`
+ CASE
+ WHEN ${
+ wikiPages.search
+ } @@ to_tsquery('english', ${vectorQuery}) THEN 4
+ WHEN ${wikiPages.title} ILIKE ${"%" + query + "%"} THEN 3
+ WHEN ${wikiPages.content} ILIKE ${"%" + query + "%"} THEN 2
+ WHEN similarity(${
+ wikiPages.title
+ }, ${query}) > 0.3 OR similarity(${
+ wikiPages.content
+ }, ${query}) > 0.3 THEN 1
+ ELSE 0
+ END`.as("relevance"),
+ similarity_title:
+ sql`similarity(${wikiPages.title}, ${query})`.as(
+ "similarity_title"
+ ),
+ similarity_content:
+ sql`similarity(${wikiPages.content}, ${query})`.as(
+ "similarity_content"
+ ),
+ })
+ .from(wikiPages)
+ .where(
+ sql`${wikiPages.search} @@ to_tsquery('english', ${vectorQuery})
+ OR ${wikiPages.title} ILIKE ${"%" + query + "%"}
+ OR ${wikiPages.content} ILIKE ${"%" + query + "%"}
+ OR similarity(${wikiPages.title}, ${query}) > 0.3
+ OR similarity(${wikiPages.content}, ${query}) > 0.3`
+ )
+ .orderBy(
+ sql`relevance DESC, similarity_title DESC, similarity_content DESC`
+ );
+
+ return results;
+ } catch (error) {
+ logger.error("Vector search error:", error);
+
+ // Fallback to similarity search
+ try {
+ // Try with trigram similarity
+ const results = await db
+ .select({
+ id: wikiPages.id,
+ title: wikiPages.title,
+ content: wikiPages.content,
+ path: wikiPages.path,
+ createdAt: wikiPages.createdAt,
+ updatedAt: wikiPages.updatedAt,
+ excerpt: sql`
+ CASE
+ WHEN ${wikiPages.content} ILIKE ${"%" + query + "%"}
+ THEN substring(${wikiPages.content} from
+ greatest(1, position(lower(${query}) in lower(${
+ wikiPages.content
+ })) - 100)
+ for 300)
+ ELSE substring(${wikiPages.content} from 1 for 200)
+ END`.as("excerpt"),
+ relevance: sql`
+ CASE
+ WHEN ${wikiPages.title} ILIKE ${"%" + query + "%"} THEN 3
+ WHEN ${wikiPages.content} ILIKE ${"%" + query + "%"} THEN 2
+ WHEN similarity(${
+ wikiPages.title
+ }, ${query}) > 0.3 OR similarity(${
+ wikiPages.content
+ }, ${query}) > 0.3 THEN 1
+ ELSE 0
+ END`.as("relevance"),
+ similarity_title:
+ sql`similarity(${wikiPages.title}, ${query})`.as(
+ "similarity_title"
+ ),
+ similarity_content:
+ sql`similarity(${wikiPages.content}, ${query})`.as(
+ "similarity_content"
+ ),
+ })
+ .from(wikiPages)
+ .where(
+ sql`${wikiPages.title} ILIKE ${"%" + query + "%"}
+ OR ${wikiPages.content} ILIKE ${"%" + query + "%"}
+ OR similarity(${wikiPages.title}, ${query}) > 0.3
+ OR similarity(${wikiPages.content}, ${query}) > 0.3`
+ )
+ .orderBy(
+ sql`relevance DESC, similarity_title DESC, similarity_content DESC`
+ );
+
+ return results;
+ } catch (similarityError) {
+ logger.error("Similarity search error:", similarityError);
+
+ // Last resort - just use ILIKE with no trigram
+ const results = await db
+ .select({
+ id: wikiPages.id,
+ title: wikiPages.title,
+ content: wikiPages.content,
+ path: wikiPages.path,
+ createdAt: wikiPages.createdAt,
+ updatedAt: wikiPages.updatedAt,
+ excerpt: sql`
+ CASE
+ WHEN ${wikiPages.content} ILIKE ${"%" + query + "%"}
+ THEN substring(${wikiPages.content} from
+ greatest(1, position(lower(${query}) in lower(${
+ wikiPages.content
+ })) - 100)
+ for 300)
+ ELSE substring(${wikiPages.content} from 1 for 200)
+ END`.as("excerpt"),
+ relevance: sql`
+ CASE
+ WHEN ${wikiPages.title} ILIKE ${"%" + query + "%"} THEN 2
+ WHEN ${wikiPages.content} ILIKE ${"%" + query + "%"} THEN 1
+ ELSE 0
+ END`.as("relevance"),
+ })
+ .from(wikiPages)
+ .where(
+ sql`${wikiPages.title} ILIKE ${"%" + query + "%"} OR ${
+ wikiPages.content
+ } ILIKE ${"%" + query + "%"}`
+ )
+ .orderBy(sql`relevance DESC`);
+
+ return results;
+ }
+ }
+ },
+
+ /**
+ * Paginated search
+ */
+ searchPaginated: async (
+ query: string,
+ pagination: PaginationInput
+ ): Promise<{ items: SearchResultItem[]; totalItems: number }> => {
+ const { take, skip } = getPaginationParams(pagination);
+
+ // Shared WHERE clause logic
+ const createWhereClause = (vectorQuery?: string) => {
+ const baseConditions = sql`
+ ${wikiPages.title} ILIKE ${"%" + query + "%"}
+ OR ${wikiPages.content} ILIKE ${"%" + query + "%"}
+ OR similarity(${wikiPages.title}, ${query}) > 0.3
+ OR similarity(${wikiPages.content}, ${query}) > 0.3
+ `;
+ if (vectorQuery) {
+ return sql`(${wikiPages.search} @@ to_tsquery('english', ${vectorQuery})) OR (${baseConditions})`;
+ }
+ return baseConditions;
+ };
+
+ // Shared ORDER BY clause logic
+ const orderByClause = sql`relevance DESC, similarity_title DESC, similarity_content DESC`;
+
+ // Try vector search first
+ try {
+ const vectorQueryTerms = query
+ .trim()
+ .split(/\s+/)
+ .filter(Boolean)
+ .map((term) => `${term}:*`)
+ .join(" & ");
+ const vectorQuery = vectorQueryTerms || `${query}:*`;
+ const whereClause = createWhereClause(vectorQuery);
+
+ const selectFields = {
+ id: wikiPages.id,
+ title: wikiPages.title,
+ path: wikiPages.path,
+ updatedAt: wikiPages.updatedAt,
+ excerpt: sql`CASE WHEN ${wikiPages.content} ILIKE ${
+ "%" + query + "%"
+ } THEN substring(${
+ wikiPages.content
+ } from greatest(1, position(lower(${query}) in lower(${
+ wikiPages.content
+ })) - 100) for 300) ELSE substring(${
+ wikiPages.content
+ } from 1 for 200) END`.as("excerpt"),
+ relevance: sql`CASE WHEN ${
+ wikiPages.search
+ } @@ to_tsquery('english', ${vectorQuery}) THEN 4 WHEN ${
+ wikiPages.title
+ } ILIKE ${"%" + query + "%"} THEN 3 WHEN ${wikiPages.content} ILIKE ${
+ "%" + query + "%"
+ } THEN 2 WHEN similarity(${
+ wikiPages.title
+ }, ${query}) > 0.3 OR similarity(${
+ wikiPages.content
+ }, ${query}) > 0.3 THEN 1 ELSE 0 END`.as("relevance"),
+ similarity_title:
+ sql`similarity(${wikiPages.title}, ${query})`.as(
+ "similarity_title"
+ ),
+ similarity_content:
+ sql`similarity(${wikiPages.content}, ${query})`.as(
+ "similarity_content"
+ ),
+ };
+
+ // Get total count
+ const countResult = await db
+ .select({ count: drizzleCount() })
+ .from(wikiPages)
+ .where(whereClause);
+ const totalItems = countResult[0]?.count ?? 0;
+
+ // Get paginated items
+ const items = await db
+ .select(selectFields)
+ .from(wikiPages)
+ .where(whereClause)
+ .orderBy(orderByClause)
+ .limit(take)
+ .offset(skip);
+
+ return { items, totalItems };
+ } catch (error) {
+ logger.error("Vector search error (paginated):", error);
+
+ // Fallback to similarity search
+ try {
+ const whereClause = createWhereClause(); // No vector query here
+ const selectFields = {
+ id: wikiPages.id,
+ title: wikiPages.title,
+ path: wikiPages.path,
+ updatedAt: wikiPages.updatedAt,
+ excerpt: sql`CASE WHEN ${wikiPages.content} ILIKE ${
+ "%" + query + "%"
+ } THEN substring(${
+ wikiPages.content
+ } from greatest(1, position(lower(${query}) in lower(${
+ wikiPages.content
+ })) - 100) for 300) ELSE substring(${
+ wikiPages.content
+ } from 1 for 200) END`.as("excerpt"),
+ relevance: sql`
+ CASE
+ WHEN ${wikiPages.title} ILIKE ${"%" + query + "%"} THEN 3
+ WHEN ${wikiPages.content} ILIKE ${"%" + query + "%"} THEN 2
+ WHEN similarity(${
+ wikiPages.title
+ }, ${query}) > 0.3 OR similarity(${
+ wikiPages.content
+ }, ${query}) > 0.3 THEN 1
+ ELSE 0
+ END`.as("relevance"),
+ similarity_title:
+ sql`similarity(${wikiPages.title}, ${query})`.as(
+ "similarity_title"
+ ),
+ similarity_content:
+ sql`similarity(${wikiPages.content}, ${query})`.as(
+ "similarity_content"
+ ),
+ };
+
+ // Get total count
+ const countResult = await db
+ .select({ count: drizzleCount() })
+ .from(wikiPages)
+ .where(whereClause);
+ const totalItems = countResult[0]?.count ?? 0;
+
+ // Get paginated items
+ const items = await db
+ .select(selectFields)
+ .from(wikiPages)
+ .where(whereClause)
+ .orderBy(orderByClause)
+ .limit(take)
+ .offset(skip);
+
+ return { items, totalItems };
+ } catch (similarityError) {
+ logger.error("Similarity search error (paginated):", similarityError);
+
+ // Last resort - just use ILIKE with no trigram
+ const whereClause = sql`
+ ${wikiPages.title} ILIKE ${"%" + query + "%"}
+ OR ${wikiPages.content} ILIKE ${"%" + query + "%"}
+ `;
+ const selectFields = {
+ id: wikiPages.id,
+ title: wikiPages.title,
+ path: wikiPages.path,
+ updatedAt: wikiPages.updatedAt,
+ excerpt: sql`CASE WHEN ${wikiPages.content} ILIKE ${
+ "%" + query + "%"
+ } THEN substring(${
+ wikiPages.content
+ } from greatest(1, position(lower(${query}) in lower(${
+ wikiPages.content
+ })) - 100) for 300) ELSE substring(${
+ wikiPages.content
+ } from 1 for 200) END`.as("excerpt"),
+ relevance: sql`
+ CASE
+ WHEN ${wikiPages.title} ILIKE ${"%" + query + "%"} THEN 2
+ WHEN ${wikiPages.content} ILIKE ${"%" + query + "%"} THEN 1
+ ELSE 0
+ END`.as("relevance"),
+ };
+
+ // Get total count
+ const countResult = await db
+ .select({ count: drizzleCount() })
+ .from(wikiPages)
+ .where(whereClause);
+ const totalItems = countResult[0]?.count ?? 0;
+
+ // Get paginated items
+ const items = await db
+ .select(selectFields)
+ .from(wikiPages)
+ .where(whereClause)
+ .orderBy(sql`relevance DESC`)
+ .limit(take)
+ .offset(skip);
+
+ return { items, totalItems };
+ }
+ }
+ },
+
+ /**
+ * Before using this search functionality, make sure to enable the pg_trgm extension in PostgreSQL:
+ * CREATE EXTENSION IF NOT EXISTS pg_trgm;
+ *
+ * And add trigram indexes:
+ * CREATE INDEX IF NOT EXISTS trgm_idx_title ON wiki_pages USING GIN (title gin_trgm_ops);
+ * CREATE INDEX IF NOT EXISTS trgm_idx_content ON wiki_pages USING GIN (content gin_trgm_ops);
+ */
+};
diff --git a/src/lib/services/tags.ts b/apps/web/src/lib/services/tags.ts
similarity index 66%
rename from src/lib/services/tags.ts
rename to apps/web/src/lib/services/tags.ts
index 820ee7a..e7529d1 100644
--- a/src/lib/services/tags.ts
+++ b/apps/web/src/lib/services/tags.ts
@@ -1,6 +1,6 @@
-import { db } from "~/lib/db";
-import { wikiTags, wikiPageToTag } from "~/lib/db/schema";
-import { eq } from "drizzle-orm";
+import { db } from "@repo/db";
+import { wikiTags, wikiPageToTag } from "@repo/db";
+import { eq, ilike } from "drizzle-orm";
/**
* Tag service - handles all tag-related database operations
@@ -49,13 +49,36 @@ export const tagService = {
with: {
pages: {
with: {
- page: true,
+ page: {
+ with: {
+ updatedBy: true,
+ lockedBy: true,
+ tags: {
+ with: {
+ tag: true,
+ },
+ },
+ },
+ },
},
},
},
});
},
+ /**
+ * Search for tags by name (case-insensitive)
+ * @param query The search query string
+ * @param limit Maximum number of results to return
+ */
+ async searchByName(query: string, limit = 10) {
+ return db.query.wikiTags.findMany({
+ where: ilike(wikiTags.name, `%${query}%`), // Use ilike for case-insensitive matching
+ orderBy: (tags, { asc }) => [asc(tags.name)],
+ limit: limit,
+ });
+ },
+
/**
* Create a new tag
*/
diff --git a/apps/web/src/lib/services/users.ts b/apps/web/src/lib/services/users.ts
new file mode 100644
index 0000000..de65c0c
--- /dev/null
+++ b/apps/web/src/lib/services/users.ts
@@ -0,0 +1,94 @@
+import { db } from "@repo/db";
+import { users } from "@repo/db";
+import { sql, eq } from "drizzle-orm";
+
+/**
+ * User service - handles all user-related database operations
+ */
+export const userService = {
+ /**
+ * Get total count of users in the system
+ */
+ async count(): Promise {
+ const result = await db.select({ count: sql`count(*)` }).from(users);
+ if (!result[0]?.count) {
+ throw new Error("Failed to count users");
+ }
+ return Number(result[0].count);
+ },
+
+ /**
+ * Get a user by their ID
+ */
+ async getById(id: number) {
+ return db.query.users.findFirst({
+ where: (users) => eq(users.id, id),
+ });
+ },
+
+ /**
+ * Get a list of all users
+ */
+ async getAll() {
+ return db.query.users.findMany({
+ with: {
+ userGroups: {
+ with: {
+ group: true,
+ },
+ },
+ },
+ });
+ },
+
+ /**
+ * Get user groups by ID
+ */
+ async getUserGroups(id: number) {
+ return db.query.users.findFirst({
+ where: (users) => eq(users.id, id),
+ with: {
+ userGroups: {
+ with: {
+ group: true,
+ },
+ },
+ },
+ });
+ },
+
+ /**
+ * Find a user by their email address.
+ * @param email - The email address to search for.
+ * @returns The user object or undefined if not found.
+ */
+ async findByEmail(email: string) {
+ return db.query.users.findFirst({
+ where: eq(users.email, email),
+ });
+ },
+
+ /**
+ * Create a new user.
+ * @param data - User data (name, email, hashedPassword).
+ * @returns The newly created user object.
+ */
+ async create(
+ data: Omit<
+ typeof users.$inferInsert,
+ "id" | "createdAt" | "updatedAt" | "emailVerified"
+ >
+ ) {
+ const result = await db
+ .insert(users)
+ .values({
+ ...data,
+ // No need for isAdmin default anymore as it's determined by group membership
+ })
+ .returning(); // Return the created user
+
+ return result[0]; // Drizzle returns an array
+ },
+};
+
+export type User = typeof users.$inferSelect;
diff --git a/src/lib/services/wiki.ts b/apps/web/src/lib/services/wiki.ts
similarity index 52%
rename from src/lib/services/wiki.ts
rename to apps/web/src/lib/services/wiki.ts
index d87aca5..239c30a 100644
--- a/src/lib/services/wiki.ts
+++ b/apps/web/src/lib/services/wiki.ts
@@ -1,8 +1,14 @@
-import { db } from "~/lib/db";
-import { wikiPages, wikiPageRevisions } from "~/lib/db/schema";
-import { desc, eq } from "drizzle-orm";
-import { sql } from "drizzle-orm";
+import { db } from "@repo/db";
+import {
+ wikiPages,
+ wikiPageRevisions,
+ wikiPageToTag,
+ wikiTags,
+} from "@repo/db";
+import { desc, eq, sql } from "drizzle-orm";
import { lockService } from "~/lib/services";
+import { Transaction } from "~/types/db";
+import { logger } from "~/lib/utils/logger";
/**
* Wiki service - handles all wiki-related database operations
@@ -13,11 +19,23 @@ export const wikiService = {
*/
async getByPath(path: string) {
return db.query.wikiPages.findFirst({
+ columns: {
+ search: false, // Exclude search vector
+ lockedById: false, // Exclude raw foreign key if lockedBy object is included
+ createdById: false,
+ updatedById: false,
+ },
where: eq(wikiPages.path, path),
with: {
- createdBy: true,
- updatedBy: true,
- lockedBy: true,
+ createdBy: {
+ columns: { id: true, name: true, email: true, image: true },
+ },
+ updatedBy: {
+ columns: { id: true, name: true, email: true, image: true },
+ },
+ lockedBy: {
+ columns: { id: true, name: true, email: true, image: true },
+ },
tags: {
with: {
tag: true,
@@ -46,22 +64,36 @@ export const wikiService = {
content?: string;
isPublished?: boolean;
userId: number;
+ tags?: string[];
}) {
- const { path, title, content, isPublished, userId } = data;
-
- const [page] = await db
- .insert(wikiPages)
- .values({
- path: path.toLowerCase(),
- title,
- content,
- isPublished: isPublished ?? false,
- createdById: userId,
- updatedById: userId,
- })
- .returning();
+ const { path, title, content, isPublished, userId, tags = [] } = data;
+
+ // Use a transaction to create the page and associate tags
+ return await db.transaction(async (tx) => {
+ // Create the page
+ const [page] = await tx
+ .insert(wikiPages)
+ .values({
+ path: path.toLowerCase(),
+ title,
+ content,
+ isPublished: isPublished ?? false,
+ createdById: userId,
+ updatedById: userId,
+ })
+ .returning();
- return page;
+ if (!page) {
+ throw new Error("Failed to create page");
+ }
+
+ // If there are tags to add, process them
+ if (tags.length > 0) {
+ await this.updatePageTags(tx, page.id, tags);
+ }
+
+ return page;
+ });
},
/**
@@ -75,35 +107,44 @@ export const wikiService = {
content?: string;
isPublished?: boolean;
userId: number;
+ tags?: string[];
}
) {
- const { path, title, content, isPublished, userId } = data;
-
- // Create a page revision before updating
- const page = await this.getById(id);
- if (page) {
- await db.insert(wikiPageRevisions).values({
- pageId: id,
- content: page.content || "",
- createdById: userId,
- });
- }
+ const { path, title, content, isPublished, userId, tags } = data;
+
+ // Use a transaction to update the page and handle tags
+ return await db.transaction(async (tx) => {
+ // Create a page revision before updating
+ const page = await this.getById(id);
+ if (page) {
+ await tx.insert(wikiPageRevisions).values({
+ pageId: id,
+ content: page.content || "",
+ createdById: userId,
+ });
+ }
+
+ // Update the page
+ const [updatedPage] = await tx
+ .update(wikiPages)
+ .set({
+ path: path.toLowerCase(),
+ title,
+ content,
+ isPublished,
+ updatedById: userId,
+ updatedAt: new Date(),
+ })
+ .where(eq(wikiPages.id, id))
+ .returning();
- // Update the page
- const [updatedPage] = await db
- .update(wikiPages)
- .set({
- path: path.toLowerCase(),
- title,
- content,
- isPublished,
- updatedById: userId,
- updatedAt: new Date(),
- })
- .where(eq(wikiPages.id, id))
- .returning();
+ // If tags were provided, update the page's tags
+ if (tags !== undefined) {
+ await this.updatePageTags(tx, id, tags);
+ }
- return updatedPage;
+ return updatedPage;
+ });
},
/**
@@ -111,11 +152,23 @@ export const wikiService = {
*/
async getById(id: number) {
return db.query.wikiPages.findFirst({
+ columns: {
+ search: false, // Exclude search vector
+ lockedById: false, // Exclude raw foreign key if lockedBy object is included
+ createdById: false,
+ updatedById: false,
+ },
where: eq(wikiPages.id, id),
with: {
- createdBy: true,
- updatedBy: true,
- lockedBy: true,
+ createdBy: {
+ columns: { id: true, name: true, email: true, image: true },
+ },
+ updatedBy: {
+ columns: { id: true, name: true, email: true, image: true },
+ },
+ lockedBy: {
+ columns: { id: true, name: true, email: true, image: true },
+ },
tags: {
with: {
tag: true,
@@ -152,29 +205,29 @@ export const wikiService = {
params;
try {
- // First fetch all pages to check if they exist and aren't locked by others
- const pagesToMove = await Promise.all(
- pageIds.map(async (pageId) => {
- const page = await this.getById(pageId);
- if (!page) {
- throw new Error(`Page with ID ${pageId} not found`);
- }
+ let updatedPageIds: number[] = [];
+
+ await db.transaction(async (tx) => {
+ // First fetch all pages to check if they exist and aren't locked by others
+ const pagesToMove = await Promise.all(
+ pageIds.map(async (pageId) => {
+ const page = await this.getById(pageId);
+ if (!page) {
+ throw new Error(`Page with ID ${pageId} not found`);
+ }
- // Check if page is locked by another user
- // We'll reuse dbService.locks.isLocked here to be consistent
- const { isLocked, lockedByUserId } = await lockService.isLocked(
- pageId
- );
- if (isLocked && lockedByUserId !== userId) {
- throw new Error(`Page "${page.title}" is locked by another user`);
- }
+ // Check if page is locked by another user
+ // We'll reuse dbService.locks.isLocked here to be consistent
+ const { isLocked, lockedByUserId } =
+ await lockService.isLocked(pageId);
+ if (isLocked && lockedByUserId !== userId) {
+ throw new Error(`Page "${page.title}" is locked by another user`);
+ }
- return page;
- })
- );
+ return page;
+ })
+ );
- // Now use a transaction to move all pages atomically
- return await db.transaction(async (tx) => {
// Set a reasonable timeout
await tx.execute(sql`SET LOCAL statement_timeout = 10000`);
@@ -318,14 +371,20 @@ export const wikiService = {
}
// Transaction will automatically commit and release hardware locks
- return updatedPages;
+ // Return only the IDs of the updated pages
+ updatedPageIds = updatedPages.map((p) => {
+ if (!p) {
+ throw new Error("Updated page is undefined");
+ }
+ return p.id;
+ });
} catch (error) {
// In case of error, explicitly release any software locks
// that might have been acquired outside this transaction
for (const pageId of acquiredLocks) {
await lockService.releaseLock(pageId, userId).catch(() => {
// Ignore errors in cleanup
- console.warn(
+ logger.warn(
`Failed to release lock for page ${pageId} during error recovery`
);
});
@@ -334,9 +393,88 @@ export const wikiService = {
throw error;
}
});
+
+ // After the transaction, fetch the cleaned-up data for the moved pages
+ const finalUpdatedPages = await Promise.all(
+ updatedPageIds.map((id: number) => this.getById(id))
+ );
+
+ // Filter out any nulls in case a page couldn't be fetched (shouldn't happen)
+ return finalUpdatedPages.filter((p) => p !== null) as NonNullable<
+ (typeof finalUpdatedPages)[number]
+ >[];
} catch (error) {
- console.error("Error in movePages service:", error);
+ logger.error("Error in movePages service:", error);
throw error;
}
},
+
+ /**
+ * Helper method to update a page's tags
+ * @param tx Database transaction
+ * @param pageId ID of the page to update tags for
+ * @param tagNames Array of tag names to set on the page
+ */
+ async updatePageTags(tx: Transaction, pageId: number, tagNames: string[]) {
+ // Step 1: Get existing tag IDs for this page
+ const existingTagAssociations: Array<{
+ tagId: number;
+ tag: { id: number; name: string };
+ }> = await tx.query.wikiPageToTag.findMany({
+ where: eq(wikiPageToTag.pageId, pageId),
+ with: {
+ tag: true,
+ },
+ });
+
+ const existingTagNames = existingTagAssociations.map(
+ (assoc) => assoc.tag.name
+ );
+
+ // Step 2: Determine which tags to add and which to remove
+ const tagsToAdd = tagNames.filter(
+ (name) => !existingTagNames.includes(name)
+ );
+ const tagsToRemove: typeof existingTagAssociations =
+ existingTagAssociations.filter(
+ (assoc) => !tagNames.includes(assoc.tag.name)
+ );
+
+ // Step 3: Remove tags that are no longer associated with the page
+ if (tagsToRemove.length > 0) {
+ for (const assoc of tagsToRemove) {
+ await tx
+ .delete(wikiPageToTag)
+ .where(
+ eq(wikiPageToTag.tagId, assoc.tagId) &&
+ eq(wikiPageToTag.pageId, pageId)
+ );
+ }
+ }
+
+ // Step 4: Add new tags
+ if (tagsToAdd.length > 0) {
+ for (const tagName of tagsToAdd) {
+ // Get or create the tag
+ let tag = await tx.query.wikiTags.findFirst({
+ where: eq(wikiTags.name, tagName),
+ });
+
+ if (!tag) {
+ // Create new tag if it doesn't exist
+ const [newTag] = await tx
+ .insert(wikiTags)
+ .values({ name: tagName })
+ .returning();
+ tag = newTag;
+ }
+
+ // Add association between page and tag
+ await tx
+ .insert(wikiPageToTag)
+ .values({ pageId, tagId: tag.id })
+ .onConflictDoNothing(); // Ignore if already exists
+ }
+ }
+ },
};
diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts
new file mode 100644
index 0000000..4c3a522
--- /dev/null
+++ b/apps/web/src/lib/utils.ts
@@ -0,0 +1,24 @@
+import { type ClassValue, clsx } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+/**
+ * Combines multiple class names using clsx and tailwind-merge
+ */
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
+
+/**
+ * Formats a file size in bytes to a human-readable string
+ * @param bytes File size in bytes
+ * @returns Formatted string (e.g., "1.5 MB")
+ */
+export function formatFileSize(bytes: number): string {
+ if (bytes === 0) return "0 Bytes";
+
+ const k = 1024;
+ const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
+}
diff --git a/apps/web/src/lib/utils/logger.ts b/apps/web/src/lib/utils/logger.ts
new file mode 100644
index 0000000..a1dfb1a
--- /dev/null
+++ b/apps/web/src/lib/utils/logger.ts
@@ -0,0 +1,186 @@
+import { format } from "date-fns";
+import { env } from "../../env"; // Re-introduce env
+
+// Define log levels and their order
+enum LogLevel {
+ DEBUG = "DEBUG",
+ INFO = "INFO",
+ WARN = "WARN",
+ ERROR = "ERROR",
+}
+
+// Remove user's override logic for now, stick to NODE_ENV based default
+// let OVERRIDE_MAX_LOG_LEVEL: LogLevel | undefined;
+// if (typeof window === "undefined") {
+// OVERRIDE_MAX_LOG_LEVEL = LogLevel.INFO;
+// }
+
+const LogLevelOrder: Record = {
+ [LogLevel.DEBUG]: 1,
+ [LogLevel.INFO]: 2,
+ [LogLevel.WARN]: 3,
+ [LogLevel.ERROR]: 4,
+};
+
+// ANSI color codes
+const colors = {
+ reset: "\x1b[0m",
+ cyan: "\x1b[36m",
+ green: "\x1b[32m",
+ yellow: "\x1b[33m",
+ red: "\x1b[31m",
+ magenta: "\x1b[35m",
+ gray: "\x1b[90m",
+ blue: "\x1b[34m",
+ orange: "\x1b[38;5;208m",
+ purple: "\x1b[38;5;129m",
+};
+
+const levelColors: Record = {
+ [LogLevel.DEBUG]: colors.purple,
+ [LogLevel.INFO]: colors.green,
+ [LogLevel.WARN]: colors.yellow,
+ [LogLevel.ERROR]: colors.red,
+};
+
+// Assign specific colors to known origins
+const originColors: Record = {
+ WSS: colors.magenta,
+ PROD: colors.cyan,
+ NEXT: colors.blue, // From user's package.json change
+ CLIENT: colors.orange, // Specific color for client-side logs
+ APP: colors.green,
+};
+
+const defaultOriginColor = colors.gray; // Fallback color
+
+// Safe check for TTY, defaults to false in non-Node.js environments
+const useColors =
+ typeof process !== "undefined" && process.stdout?.isTTY === true;
+
+// Get server-side origin using env (only checked when on server)
+const getServerProcessOrigin = (): string | undefined => {
+ // env.PROCESS_ORIGIN should only be accessed server-side
+ if (typeof window === "undefined") {
+ const origin = env.PROCESS_ORIGIN;
+ if (origin === "WSS" || origin === "PROD" || origin === "NEXT") {
+ return origin;
+ }
+ }
+ return undefined;
+};
+
+// Formats only the prefix part of the log message
+const formatLogPrefix = (level: LogLevel, origin: string): string => {
+ const timestamp = format(new Date(), "yyyy-MM-dd HH:mm:ss.SSS");
+ const levelColor = levelColors[level] || colors.reset;
+ const originColor = originColors[origin.toUpperCase()] || defaultOriginColor;
+
+ if (useColors) {
+ return `${colors.gray}[${timestamp}]${
+ colors.reset
+ } ${levelColor}${level.padEnd(5)}${
+ colors.reset
+ } ${originColor}[${origin.toUpperCase()}]${colors.reset}`;
+ } else {
+ return `[${timestamp}] ${level.padEnd(5)} [${origin.toUpperCase()}]`;
+ }
+};
+
+export interface Logger {
+ debug: (message: string, ...args: unknown[]) => void;
+ info: (message: string, ...args: unknown[]) => void;
+ warn: (message: string, ...args: unknown[]) => void;
+ error: (message: string, ...args: unknown[]) => void;
+ log: (message: string, ...args: unknown[]) => void; // Alias for info
+}
+
+interface CreateLoggerOptions {
+ maxLevel?: LogLevel;
+}
+
+/**
+ * Creates a new logger instance associated with a specific origin.
+ */
+const createLogger = (
+ origin_param?: string,
+ options?: CreateLoggerOptions
+): Logger => {
+ const defaultMaxLevel =
+ env.NEXT_PUBLIC_NODE_ENV === "production" ? LogLevel.INFO : LogLevel.DEBUG;
+
+ const maxLevel = options?.maxLevel || defaultMaxLevel;
+ const maxLevelOrder = LogLevelOrder[maxLevel];
+
+ // Determine origin: param -> server detect -> client detect -> fallback
+ const origin = origin_param
+ ? origin_param
+ : typeof window === "undefined"
+ ? (getServerProcessOrigin() ?? "APP") // Server: try detect, else APP
+ : "CLIENT"; // Client: always CLIENT
+
+ const logFn =
+ (level: LogLevel) =>
+ (message: string, ...args: unknown[]) => {
+ const currentLevelOrder = LogLevelOrder[level];
+
+ if (currentLevelOrder < maxLevelOrder) {
+ return;
+ }
+
+ // Format only the prefix
+ const prefix = formatLogPrefix(level, origin);
+
+ // Pass prefix and original arguments to console methods
+ switch (level) {
+ case LogLevel.DEBUG:
+ case LogLevel.INFO:
+ console.log(prefix, message, ...args);
+ break;
+ case LogLevel.WARN:
+ console.warn(prefix, message, ...args);
+ break;
+ case LogLevel.ERROR:
+ console.error(prefix, message, ...args);
+ break;
+ }
+ };
+
+ return {
+ debug: logFn(LogLevel.DEBUG),
+ info: logFn(LogLevel.INFO),
+ warn: logFn(LogLevel.WARN),
+ error: logFn(LogLevel.ERROR),
+ log: logFn(LogLevel.INFO),
+ };
+};
+
+// --- Singleton Logger Instances ---
+
+/**
+ * Logger instance specifically for the WebSocket Development Server.
+ */
+export const wssLogger = createLogger("WSS");
+
+/**
+ * Logger instance specifically for the Production Server / Next.js SSR.
+ */
+export const prodLogger = createLogger("PROD");
+
+/**
+ * Logger instance specifically for the Next.js Development/Build process.
+ */
+export const nextLogger = createLogger("NEXT");
+
+/**
+ * Logger instance specifically for client-side browser code.
+ */
+export const clientLogger = createLogger("CLIENT");
+
+/**
+ * A default logger instance. Automatically detects if running on Server (WSS/PROD/NEXT)
+ * or Client (CLIENT) and uses the appropriate origin.
+ * Falls back to 'APP' if server origin cannot be detected.
+ * Useful for shared code where the context isn't immediately obvious.
+ */
+export const logger = createLogger(); // Will now auto-detect server/client origin
diff --git a/apps/web/src/lib/utils/pagination.ts b/apps/web/src/lib/utils/pagination.ts
new file mode 100644
index 0000000..6eec2ce
--- /dev/null
+++ b/apps/web/src/lib/utils/pagination.ts
@@ -0,0 +1,130 @@
+/**
+ * Pagination input interface
+ * @description Standard pagination parameters for list endpoints
+ */
+export interface PaginationInput {
+ page: number;
+ pageSize: number;
+}
+
+/**
+ * Pagination metadata interface
+ * @description Provides pagination context for paginated responses
+ */
+export interface PaginationMeta {
+ currentPage: number;
+ itemCount: number;
+ itemsPerPage: number;
+ totalItems: number;
+ totalPages: number;
+ hasNextPage: boolean;
+ hasPreviousPage: boolean;
+}
+
+/**
+ * Paginated response interface
+ * @description Standard response structure for paginated data
+ */
+export interface PaginatedResponse {
+ items: T[];
+ meta: PaginationMeta;
+}
+
+/**
+ * Default pagination values
+ */
+export const DEFAULT_PAGE = 1;
+export const DEFAULT_PAGE_SIZE = 20;
+export const MAX_PAGE_SIZE = 100;
+
+/**
+ * Calculates pagination parameters for database queries
+ * @param pagination Pagination input
+ * @returns take and skip parameters for the database query
+ */
+export function getPaginationParams(pagination: PaginationInput): {
+ take: number;
+ skip: number;
+} {
+ const page = pagination.page < 1 ? DEFAULT_PAGE : pagination.page;
+ const pageSize =
+ pagination.pageSize < 1
+ ? DEFAULT_PAGE_SIZE
+ : pagination.pageSize > MAX_PAGE_SIZE
+ ? MAX_PAGE_SIZE
+ : pagination.pageSize;
+
+ return {
+ take: pageSize,
+ skip: (page - 1) * pageSize,
+ };
+}
+
+/**
+ * Creates pagination metadata
+ * @param pagination Pagination input
+ * @param totalItems Total number of items
+ * @param itemCount Optional count of items in the current page
+ * @returns Pagination metadata
+ */
+export function createPaginationMeta(
+ pagination: PaginationInput,
+ totalItems: number,
+ itemCount?: number
+): PaginationMeta {
+ const { page, pageSize } = pagination;
+ const totalPages = Math.ceil(totalItems / pageSize);
+
+ return {
+ currentPage: page,
+ itemCount:
+ itemCount !== undefined
+ ? itemCount
+ : Math.min(pageSize, totalItems - (page - 1) * pageSize),
+ itemsPerPage: pageSize,
+ totalItems,
+ totalPages,
+ hasNextPage: page < totalPages,
+ hasPreviousPage: page > 1,
+ };
+}
+
+/**
+ * Creates a paginated response
+ * @param items Array of items for the current page
+ * @param pagination Pagination input
+ * @param totalItems Total number of items
+ * @returns Paginated response
+ */
+export function createPaginatedResponse(
+ items: T[],
+ pagination: PaginationInput,
+ totalItems: number
+): PaginatedResponse {
+ return {
+ items,
+ meta: createPaginationMeta(pagination, totalItems, items.length),
+ };
+}
+
+/**
+ * Zod schema for pagination input validation
+ */
+export const paginationSchema = () => ({
+ page: z
+ .number()
+ .int()
+ .min(1)
+ .default(DEFAULT_PAGE)
+ .describe("Page number (starts from 1)"),
+ pageSize: z
+ .number()
+ .int()
+ .min(1)
+ .max(MAX_PAGE_SIZE)
+ .default(DEFAULT_PAGE_SIZE)
+ .describe("Number of items per page"),
+});
+
+// Need to import zod at the top
+import { z } from "zod";
diff --git a/apps/web/src/lib/utils/server-auth-helpers.ts b/apps/web/src/lib/utils/server-auth-helpers.ts
new file mode 100644
index 0000000..e71453e
--- /dev/null
+++ b/apps/web/src/lib/utils/server-auth-helpers.ts
@@ -0,0 +1,73 @@
+import { getServerSession } from "next-auth";
+import { NextResponse } from "next/server";
+import { authOptions } from "~/lib/auth";
+import { authorizationService } from "~/lib/services";
+import { db } from "@repo/db";
+import type { PermissionIdentifier } from "@repo/db";
+import { logger } from "~/lib/utils/logger";
+
+interface PermissionCheckResult {
+ authorized: boolean;
+ userId?: number;
+ errorResponse?: NextResponse;
+}
+
+/**
+ * Checks if the current server-side session user has the specified permission.
+ * Returns an object indicating authorization status, the user ID if authorized,
+ * or an appropriate NextResponse object if unauthorized or forbidden.
+ */
+export async function checkServerPermission(
+ permissionName: PermissionIdentifier
+): Promise {
+ const session = await getServerSession(authOptions);
+
+ if (!session?.user?.id) {
+ return {
+ authorized: false,
+ errorResponse: new NextResponse("Unauthorized", { status: 401 }),
+ };
+ }
+
+ const userId = parseInt(session.user.id as string, 10);
+
+ // Validate user ID is a number
+ if (isNaN(userId)) {
+ logger.error("Invalid user ID in session:", session.user.id);
+ return {
+ authorized: false,
+ errorResponse: new NextResponse("Unauthorized - Invalid User ID", {
+ status: 401,
+ }),
+ };
+ }
+
+ const canAccess = await authorizationService.hasPermission(
+ userId,
+ permissionName
+ );
+
+ if (!canAccess) {
+ // Check if user exists at all before denying, helps differentiate 401 vs 403
+ const userExists = await db.query.users.findFirst({
+ where: (users, { eq }) => eq(users.id, userId),
+ columns: { id: true },
+ });
+ if (!userExists) {
+ return {
+ authorized: false,
+ errorResponse: new NextResponse("Unauthorized", { status: 401 }),
+ };
+ }
+ return {
+ authorized: false,
+ errorResponse: new NextResponse("Forbidden", { status: 403 }),
+ };
+ }
+
+ // Authorized
+ return {
+ authorized: true,
+ userId: userId,
+ };
+}
diff --git a/apps/web/src/providers/index.tsx b/apps/web/src/providers/index.tsx
new file mode 100644
index 0000000..b4cebd1
--- /dev/null
+++ b/apps/web/src/providers/index.tsx
@@ -0,0 +1,50 @@
+"use client";
+
+import { ReactNode } from "react";
+import { TRPCClientProvider } from "~/server/providers";
+import { ThemeProvider } from "~/providers/theme-provider";
+import { ModalProvider } from "@repo/ui";
+import { PermissionProvider } from "~/components/auth/permission/client";
+import { Toaster } from "sonner";
+import { useTheme } from "~/providers/theme-provider";
+import { AuthProvider } from "~/components/auth/AuthProvider";
+
+interface ProvidersProps {
+ children: ReactNode;
+}
+
+// Create an inner component to render the Toaster and use the theme hook
+function ToasterWithTheme() {
+ const { theme } = useTheme();
+
+ return (
+
+ );
+}
+
+export function Providers({ children }: ProvidersProps) {
+ return (
+
+
+
+
+
+ {children}
+
+
+
+
+
+
+ );
+}
diff --git a/src/providers/theme-provider.tsx b/apps/web/src/providers/theme-provider.tsx
similarity index 100%
rename from src/providers/theme-provider.tsx
rename to apps/web/src/providers/theme-provider.tsx
diff --git a/apps/web/src/server/client.ts b/apps/web/src/server/client.ts
new file mode 100644
index 0000000..dfcf333
--- /dev/null
+++ b/apps/web/src/server/client.ts
@@ -0,0 +1,5 @@
+import { createTRPCContext } from "@trpc/tanstack-react-query";
+import type { AppRouter } from "~/server/routers";
+
+export const { TRPCProvider, useTRPC, useTRPCClient } =
+ createTRPCContext();
diff --git a/apps/web/src/server/context.ts b/apps/web/src/server/context.ts
new file mode 100644
index 0000000..4490401
--- /dev/null
+++ b/apps/web/src/server/context.ts
@@ -0,0 +1,67 @@
+import type { CreateNextContextOptions } from "@trpc/server/adapters/next";
+import type { CreateWSSContextFnOptions } from "@trpc/server/adapters/ws";
+import type { FetchCreateContextFnOptions } from "@trpc/server/adapters/fetch"; // Import Fetch options
+import { getServerSession, Session } from "next-auth";
+
+// import type { IncomingMessage } from "http"; // Not currently used
+// Socket type might be needed later for WS handling, keep for now
+// import type { Socket } from "net";
+import { authOptions, getServerAuthSession } from "~/lib/auth";
+import { logger } from "~/lib/utils/logger";
+
+/**
+ * Creates context for an incoming request
+ * @see https://trpc.io/docs/v11/context
+ */
+export const createContext = async (
+ opts:
+ | CreateNextContextOptions
+ | CreateWSSContextFnOptions
+ | FetchCreateContextFnOptions // Add Fetch options
+): Promise<{ session: Session | null }> => {
+ // Add explicit return type promise
+ let session: Session | null = null;
+
+ // Check for Next.js API route context (has req, res, and req.query)
+ // Note: instanceof Object check might be needed if req could be primitive
+ if (
+ "req" in opts &&
+ "res" in opts &&
+ typeof opts.req === "object" &&
+ opts.req !== null &&
+ "query" in opts.req
+ ) {
+ logger.log("Creating context for Next.js Pages API route");
+ // Type assertion needed as we've confirmed the structure
+ session = await getServerAuthSession();
+ // Check for Fetch API context (has req but not res, used in App Router)
+ } else if ("req" in opts && !("res" in opts)) {
+ logger.log("Creating context for Fetch API (App Router)");
+ // In App Router Route Handlers, getServerSession(authOptions) usually works
+ session = await getServerSession(authOptions);
+ // If session is null, you might need to manually extract/verify cookies/headers
+ // from opts.req (type Request) if authOptions alone isn't sufficient.
+ // Check for WebSocket context (has req and res, but req lacks typical NextApiRequest props)
+ } else if ("req" in opts && "res" in opts) {
+ logger.warn(
+ "Creating context for WebSocket connection (Session retrieval not implemented)"
+ );
+ // Placeholder for WebSocket session logic
+ // Needs specific handling (e.g., cookie parsing from opts.req which is http.IncomingMessage)
+ } else {
+ logger.error("Unknown context type:", opts);
+ // Optionally throw an error or return a default context
+ // throw new Error("Could not determine context type");
+ }
+
+ logger.log(
+ "createContext created for",
+ session?.user?.name ?? "unknown user"
+ );
+
+ return {
+ session,
+ };
+};
+
+export type Context = Awaited>;
diff --git a/apps/web/src/server/index.ts b/apps/web/src/server/index.ts
new file mode 100644
index 0000000..d2f8dbb
--- /dev/null
+++ b/apps/web/src/server/index.ts
@@ -0,0 +1,204 @@
+import { initTRPC, TRPCError } from "@trpc/server";
+import type { Session } from "next-auth";
+import { authorizationService } from "~/lib/services/authorization";
+import { PermissionIdentifier, validatePermissionId } from "@repo/db";
+import type { TRPCPanelMeta } from "trpc-ui";
+import { Context } from "./context";
+import { logger } from "~/lib/utils/logger";
+
+// Initialize tRPC server instance
+const t = initTRPC.context().meta().create();
+
+// Create middlewares, procedures, and routers
+export const middleware = t.middleware;
+export const router = t.router;
+export const mergeRouters = t.mergeRouters;
+export const publicProcedure = t.procedure;
+
+// Create protected procedure that requires authentication
+const isAuthenticated = middleware(async ({ ctx, next }) => {
+ if (!ctx.session?.user) {
+ throw new TRPCError({
+ code: "UNAUTHORIZED",
+ message: "You must be logged in to perform this action",
+ });
+ }
+
+ return next({
+ ctx: {
+ session: ctx.session as Session,
+ },
+ });
+});
+
+export const protectedProcedure = t.procedure.use(isAuthenticated);
+
+// Allow guest access middleware
+// This middleware doesn't throw if user isn't logged in, but passes undefined userId to permission checks
+const allowGuests = middleware(async ({ ctx, next }) => {
+ if (ctx.session?.user) {
+ // User is logged in, use their session
+ return next({
+ ctx: {
+ session: ctx.session as Session,
+ },
+ });
+ }
+
+ // User is not logged in (guest), continue with empty session
+ return next({
+ ctx: {
+ // Pass undefined session so that the permission middleware will check guest group
+ guestAccess: true,
+ },
+ });
+});
+
+export const guestProcedure = t.procedure.use(allowGuests);
+
+// Create middleware that checks for a specific permission
+// allowGuests parameter controls whether to allow guest access
+export function withPermission(
+ permissionName: PermissionIdentifier,
+ allowGuests = false
+) {
+ if (!validatePermissionId(permissionName)) {
+ logger.error(`Invalid permission identifier: ${permissionName}`);
+ throw new Error(`Invalid permission identifier: ${permissionName}`);
+ }
+
+ return middleware(async ({ ctx, next }) => {
+ // Handle guest access differently - userId will be undefined
+ const userId = ctx.session?.user
+ ? parseInt(ctx.session.user.id)
+ : undefined;
+
+ // If guest access is not allowed and user is not logged in
+ if (!allowGuests && userId === undefined) {
+ logger.error("User is not logged in");
+ throw new TRPCError({
+ code: "UNAUTHORIZED",
+ message: "You must be logged in to perform this action",
+ });
+ }
+
+ const hasPermission = await authorizationService.hasPermission(
+ userId,
+ permissionName
+ );
+
+ if (!hasPermission) {
+ logger.error(`User does not have permission: ${permissionName}`);
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: `You don't have the required permission: ${permissionName}`,
+ });
+ }
+
+ if (allowGuests && userId === undefined) {
+ return next({
+ ctx: {
+ guestAccess: true,
+ },
+ });
+ }
+
+ return next({
+ ctx: {
+ session: ctx.session as Session,
+ },
+ });
+ });
+}
+
+// Create middleware that checks for any of the specified permissions
+// allowGuests parameter controls whether to allow guest access
+export function withAnyPermission(
+ permissionNames: PermissionIdentifier[],
+ allowGuests = false
+) {
+ // Validate all permission IDs
+ const invalidPermissions = permissionNames.filter(
+ (p) => !validatePermissionId(p)
+ );
+ if (invalidPermissions.length > 0) {
+ throw new Error(
+ `Invalid permission identifiers: ${invalidPermissions.join(", ")}`
+ );
+ }
+
+ if (permissionNames.length === 0) {
+ throw new Error(
+ "No permissions specified for withAnyPermission middleware"
+ );
+ }
+
+ return middleware(async ({ ctx, next }) => {
+ // Handle guest access differently - userId will be undefined
+ const userId = ctx.session?.user
+ ? parseInt(ctx.session.user.id)
+ : undefined;
+
+ // If guest access is not allowed and user is not logged in
+ if (!allowGuests && userId === undefined) {
+ throw new TRPCError({
+ code: "UNAUTHORIZED",
+ message: "You must be logged in to perform this action",
+ });
+ }
+
+ const hasAnyPermission = await authorizationService.hasAnyPermission(
+ userId,
+ permissionNames
+ );
+
+ if (!hasAnyPermission) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: `You don't have any of the required permissions: ${permissionNames.join(
+ ", "
+ )}`,
+ });
+ }
+
+ return next({
+ ctx: ctx.session
+ ? {
+ session: ctx.session as Session,
+ }
+ : {
+ guestAccess: true,
+ },
+ });
+ });
+}
+
+// Create a procedure that requires a specific permission
+export function permissionProtectedProcedure(
+ permissionName: PermissionIdentifier,
+ allowGuests = false
+) {
+ return protectedProcedure.use(withPermission(permissionName, allowGuests));
+}
+
+// Create a procedure that requires any of the specified permissions
+export function permissionAnyProtectedProcedure(
+ permissionNames: PermissionIdentifier[],
+ allowGuests = false
+) {
+ return protectedProcedure.use(
+ withAnyPermission(permissionNames, allowGuests)
+ );
+}
+
+// Create a procedure that allows guest access and checks permission against guest group
+export function permissionGuestProcedure(permissionName: PermissionIdentifier) {
+ return guestProcedure.use(withPermission(permissionName, true));
+}
+
+// Create a procedure that allows guest access and checks any of the permissions
+export function permissionAnyGuestProcedure(
+ permissionNames: PermissionIdentifier[]
+) {
+ return guestProcedure.use(withAnyPermission(permissionNames, true));
+}
diff --git a/apps/web/src/server/prodServer.ts b/apps/web/src/server/prodServer.ts
new file mode 100644
index 0000000..2e887cb
--- /dev/null
+++ b/apps/web/src/server/prodServer.ts
@@ -0,0 +1,51 @@
+import next from "next";
+import { createServer } from "node:http";
+import { parse } from "node:url";
+import type { Socket } from "net";
+
+import { WebSocketServer } from "ws";
+import { applyWSSHandler } from "@trpc/server/adapters/ws";
+import { logger } from "../lib/utils/logger";
+
+import { createContext } from "./context";
+import { appRouter } from "./routers";
+
+const port = parseInt(process.env.PORT || "3000", 10);
+const dev = process.env.NODE_ENV !== "production";
+const app = next({ dev });
+const handle = app.getRequestHandler();
+
+void app.prepare().then(() => {
+ const server = createServer(async (req, res) => {
+ if (!req.url) return;
+ const parsedUrl = parse(req.url, true);
+ await handle(req, res, parsedUrl);
+ });
+ const wss = new WebSocketServer({ server });
+ const handler = applyWSSHandler({ wss, router: appRouter, createContext });
+
+ process.on("SIGTERM", () => {
+ logger.info("SIGTERM received, broadcasting reconnect notification...");
+ handler.broadcastReconnectNotification();
+ });
+
+ server.on("upgrade", (req, socket, head) => {
+ wss.handleUpgrade(req, socket as Socket, head, (ws) => {
+ wss.emit("connection", ws, req);
+ });
+ });
+
+ // Keep the next.js upgrade handler from being added to our custom server
+ // so sockets stay open even when not HMR.
+ const originalOn = server.on.bind(server);
+ server.on = function (event, listener) {
+ return event !== "upgrade" ? originalOn(event, listener) : server;
+ };
+ server.listen(port);
+
+ logger.info(
+ `> Server listening at http://localhost:${port} as ${
+ dev ? "development" : process.env.NODE_ENV
+ }`
+ );
+});
diff --git a/apps/web/src/server/providers.tsx b/apps/web/src/server/providers.tsx
new file mode 100644
index 0000000..3553ba2
--- /dev/null
+++ b/apps/web/src/server/providers.tsx
@@ -0,0 +1,72 @@
+"use client";
+
+import { PropsWithChildren, useState } from "react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { TRPCProvider } from "./client";
+import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
+import { createWSClient, httpBatchLink, wsLink, splitLink } from "@trpc/client";
+import { createTRPCClient } from "@trpc/client";
+import { AppRouter } from "./routers";
+
+function makeQueryClient() {
+ return 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,
+ refetchOnMount: false,
+ refetchOnWindowFocus: false,
+ refetchOnReconnect: false,
+ },
+ },
+ });
+}
+
+let browserQueryClient: QueryClient | undefined = undefined;
+function getQueryClient() {
+ if (typeof window === "undefined") {
+ // Server: always make a new query client
+ return makeQueryClient();
+ } else {
+ // Browser: make a new query client if we don't already have one
+ // This is very important, so we don't re-make a new client if React
+ // suspends during the initial render. This may not be needed if we
+ // have a suspense boundary BELOW the creation of the query client
+ if (!browserQueryClient) browserQueryClient = makeQueryClient();
+ return browserQueryClient;
+ }
+}
+
+export function TRPCClientProvider({ children }: PropsWithChildren) {
+ const wsClient = createWSClient({
+ url: "ws://localhost:3001",
+ });
+ const queryClient = getQueryClient();
+ const [trpcClient] = useState(() =>
+ createTRPCClient({
+ links: [
+ splitLink({
+ condition(op) {
+ return op.type === "subscription";
+ },
+ true: wsLink({
+ client: wsClient,
+ }),
+ false: httpBatchLink({
+ url: "http://localhost:3000/api/trpc",
+ }),
+ }),
+ ],
+ })
+ );
+
+ return (
+
+
+ {children}
+
+
+
+ );
+}
diff --git a/apps/web/src/server/routers/assets.ts b/apps/web/src/server/routers/assets.ts
new file mode 100644
index 0000000..4e26eb2
--- /dev/null
+++ b/apps/web/src/server/routers/assets.ts
@@ -0,0 +1,177 @@
+import { z } from "zod";
+import { assetService } from "~/lib/services";
+import { permissionProtectedProcedure, router } from "~/server";
+import { TRPCError } from "@trpc/server";
+import { paginationSchema } from "~/lib/utils/pagination";
+import { logger } from "~/lib/utils/logger";
+
+const FILE_SIZE_LIMIT_MB = 100; // 100MB
+
+export const assetsRouter = router({
+ getAll: permissionProtectedProcedure("assets:asset:read")
+ .input(z.object({}).optional())
+ .query(async () => {
+ return assetService.getAll();
+ }),
+
+ getPaginated: permissionProtectedProcedure("assets:asset:read")
+ .input(
+ z.object({
+ ...paginationSchema(),
+ search: z.string().optional(),
+ fileType: z.string().optional(),
+ pageId: z.number().nullable().optional(),
+ })
+ )
+ .query(async ({ input }) => {
+ const { page, pageSize, search, fileType, pageId } = input;
+
+ return assetService.getPaginated(
+ { page, pageSize },
+ { search, fileType, pageId }
+ );
+ }),
+
+ getByPageId: permissionProtectedProcedure("assets:asset:read")
+ .input(z.object({ pageId: z.number() }))
+ .query(async ({ input }) => {
+ return assetService.getByPageId(input.pageId);
+ }),
+
+ getById: permissionProtectedProcedure("assets:asset:read")
+ .input(z.object({ id: z.string().uuid() }))
+ .query(async ({ input }) => {
+ return assetService.getById(input.id);
+ }),
+
+ upload: permissionProtectedProcedure("assets:asset:create")
+ .input(
+ z.object({
+ fileName: z.string(),
+ fileType: z.string(),
+ fileSize: z.number(),
+ data: z.string(), // Base64 encoded data
+ name: z.string().nullable().optional(),
+ description: z.string().nullable().optional(),
+ pageId: z.number().nullable().optional(),
+ })
+ )
+ .mutation(async ({ input, ctx }) => {
+ try {
+ const userId = parseInt(ctx.session.user.id as string, 10);
+
+ // Size validation
+ if (input.fileSize > FILE_SIZE_LIMIT_MB * 1024 * 1024) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: `File size exceeds ${FILE_SIZE_LIMIT_MB}MB limit`,
+ });
+ }
+
+ // Strip data URI prefix if present
+ let base64Data = input.data;
+ const prefixMatch = input.data.match(/^data:.*;base64,/);
+ if (prefixMatch) {
+ base64Data = input.data.substring(prefixMatch[0].length);
+ }
+
+ const asset = await assetService.create({
+ fileName: input.fileName,
+ fileType: input.fileType,
+ fileSize: input.fileSize,
+ data: base64Data,
+ name: input.name,
+ description: input.description,
+ uploadedById: userId,
+ pageId: input.pageId,
+ });
+
+ return asset;
+ } catch (error) {
+ logger.error("[ASSET UPLOAD ERROR]", error);
+
+ // If the error is already a TRPCError, re-throw it to preserve the specific code/message
+ if (error instanceof TRPCError) {
+ throw error;
+ }
+
+ // Otherwise, wrap it in a generic internal server error
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Failed to upload asset",
+ cause: error,
+ });
+ }
+ }),
+
+ update: permissionProtectedProcedure("assets:asset:update")
+ .input(
+ z.object({
+ id: z.string().uuid(),
+ name: z.string().nullable().optional(),
+ description: z.string().nullable().optional(),
+ })
+ )
+ .mutation(async ({ input, ctx }) => {
+ const userId = parseInt(ctx.session.user.id as string, 10);
+ const isAdmin = ctx.session.user.isAdmin === true;
+
+ // Check if the user has permission to update this asset
+ const asset = await assetService.getById(input.id);
+ if (!asset) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Asset not found",
+ });
+ }
+
+ // Only allow the user who uploaded the asset or admins to update it
+ if (!isAdmin && asset.uploadedById !== userId) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "You don't have permission to update this asset",
+ });
+ }
+
+ try {
+ const updatedAsset = await assetService.update(input.id, {
+ name: input.name,
+ description: input.description,
+ });
+ return updatedAsset;
+ } catch (error) {
+ logger.error("[ASSET UPDATE ERROR]", error);
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Failed to update asset",
+ cause: error,
+ });
+ }
+ }),
+
+ delete: permissionProtectedProcedure("assets:asset:delete")
+ .input(z.object({ id: z.string() }))
+ .mutation(async ({ input, ctx }) => {
+ const userId = parseInt(ctx.session.user.id as string, 10);
+ const isAdmin = ctx.session.user.isAdmin === true;
+
+ // Check if the user has permission to delete this asset
+ const asset = await assetService.getById(input.id);
+ if (!asset) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Asset not found",
+ });
+ }
+
+ // Only allow the user who uploaded the asset or admins to delete it
+ if (!isAdmin && asset.uploadedById !== userId) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "You don't have permission to delete this asset",
+ });
+ }
+
+ return assetService.delete(input.id);
+ }),
+});
diff --git a/apps/web/src/server/routers/auth.ts b/apps/web/src/server/routers/auth.ts
new file mode 100644
index 0000000..f1b244e
--- /dev/null
+++ b/apps/web/src/server/routers/auth.ts
@@ -0,0 +1,143 @@
+/**
+ * Auth router - Server only
+ */
+import { z } from "zod";
+import {
+ router,
+ protectedProcedure,
+ guestProcedure,
+ publicProcedure,
+} from "~/server";
+import { authorizationService } from "~/lib/services";
+import { PermissionIdentifier, validatePermissionId } from "@repo/db";
+
+// Create a Zod schema for permission identifier
+const permissionIdentifierSchema = z.string().refine(
+ (val): val is PermissionIdentifier => {
+ return validatePermissionId(val);
+ },
+ {
+ message:
+ "Invalid permission identifier format. Expected format: module:resource:action",
+ }
+);
+
+export const authRouter = router({
+ // Get the current user's permissions
+ getMyPermissions: publicProcedure.query(async ({ ctx }) => {
+ let userId = undefined;
+ if (ctx.session) {
+ userId = parseInt(ctx.session.user.id);
+ }
+
+ // Get all permissions for the current user
+ const permissions = await authorizationService.getUserPermissions(userId);
+
+ // Return permissions in a convenient format for the frontend
+ return {
+ // Return the full permission objects
+ permissions,
+
+ // Return an array of permission names (e.g. ["wiki:page:read", "wiki:page:create"])
+ permissionNames: permissions.map((p) => p.name as PermissionIdentifier),
+
+ // Return a map of permissions for easy checking (e.g. {"wiki:page:read": true})
+ permissionMap: permissions.reduce(
+ (acc, p) => {
+ if (validatePermissionId(p.name)) {
+ acc[p.name] = true;
+ }
+ return acc;
+ },
+ {} as Record
+ ),
+ };
+ }),
+
+ // Check if the current user has a specific permission
+ hasPermission: protectedProcedure
+ .input(z.object({ permission: permissionIdentifierSchema }))
+ .query(async ({ ctx, input }) => {
+ const userId = parseInt(ctx.session.user.id);
+ const hasPermission = await authorizationService.hasPermission(
+ userId,
+ input.permission
+ );
+ return hasPermission;
+ }),
+
+ // Check if the current user has any of the specified permissions
+ hasAnyPermission: protectedProcedure
+ .input(z.object({ permissions: z.array(permissionIdentifierSchema) }))
+ .query(async ({ ctx, input }) => {
+ const userId = parseInt(ctx.session.user.id);
+ const hasAnyPermission = await authorizationService.hasAnyPermission(
+ userId,
+ input.permissions
+ );
+ return hasAnyPermission;
+ }),
+
+ // Check if the current user has access to a specific page
+ hasPagePermission: protectedProcedure
+ .input(
+ z.object({
+ pageId: z.number(),
+ permission: permissionIdentifierSchema,
+ })
+ )
+ .query(async ({ ctx, input }) => {
+ const userId = parseInt(ctx.session.user.id);
+ const hasPermission = await authorizationService.hasPagePermission(
+ userId,
+ input.pageId,
+ input.permission
+ );
+ return hasPermission;
+ }),
+
+ // Create a procedure to get guest permissions
+ getGuestPermissions: guestProcedure
+ .meta({
+ description: "Get permissions for guest users",
+ })
+ .query(async () => {
+ // Get guest permissions using the authorization service
+ const guestGroupId = await authorizationService.getGuestGroupId();
+
+ if (!guestGroupId) {
+ // Return empty permissions if no guest group defined
+ return {
+ permissions: [],
+ permissionNames: [],
+ permissionMap: {},
+ };
+ }
+
+ // Guest users have userId undefined
+ const permissions =
+ await authorizationService.getUserPermissions(undefined);
+
+ // Collect permission names
+ const permissionNames = permissions.map(
+ (p) => `${p.module}:${p.resource}:${p.action}` as PermissionIdentifier
+ );
+
+ // Create a map for easy lookup
+ const permissionMap = permissions.reduce(
+ (acc, permission) => {
+ const id =
+ `${permission.module}:${permission.resource}:${permission.action}` as PermissionIdentifier;
+ acc[id] = true;
+ return acc;
+ },
+ {} as Record
+ );
+
+ return {
+ permissions,
+ permissionNames,
+ permissionMap,
+ };
+ }),
+});
diff --git a/apps/web/src/server/routers/groups.ts b/apps/web/src/server/routers/groups.ts
new file mode 100644
index 0000000..475bb3d
--- /dev/null
+++ b/apps/web/src/server/routers/groups.ts
@@ -0,0 +1,274 @@
+import { z } from "zod";
+import { TRPCError } from "@trpc/server";
+import { router, permissionProtectedProcedure } from "~/server";
+import { dbService } from "~/lib/services";
+import { logger } from "~/lib/utils/logger";
+
+export const groupsRouter = router({
+ // Get all groups
+ getAll: permissionProtectedProcedure("system:groups:read").query(async () => {
+ const groups = await dbService.groups.getAll();
+ return groups;
+ }),
+
+ // Get a single group by ID
+ getById: permissionProtectedProcedure("system:groups:read")
+ .input(z.object({ id: z.number() }))
+ .query(async ({ input }) => {
+ const group = await dbService.groups.getById(input.id);
+ if (!group) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Group not found",
+ });
+ }
+ return group;
+ }),
+
+ // Get all users in a group
+ getGroupUsers: permissionProtectedProcedure("system:groups:read")
+ .input(z.object({ groupId: z.number() }))
+ .query(async ({ input }) => {
+ const users = await dbService.groups.getGroupUsers(input.groupId);
+ return users;
+ }),
+
+ // Get all permissions for a group
+ getGroupPermissions: permissionProtectedProcedure("system:groups:read")
+ .input(z.object({ groupId: z.number() }))
+ .query(async ({ input }) => {
+ const permissions = await dbService.groups.getGroupPermissions(
+ input.groupId
+ );
+ return permissions;
+ }),
+
+ // Create a new group
+ create: permissionProtectedProcedure("system:groups:create")
+ .input(
+ z.object({
+ name: z.string().min(3).max(100),
+ description: z.string().optional(),
+ })
+ )
+ .mutation(async ({ input }) => {
+ const newGroup = await dbService.groups.create(input);
+ return newGroup;
+ }),
+
+ // Update a group
+ update: permissionProtectedProcedure("system:groups:update")
+ .input(
+ z.object({
+ id: z.number(),
+ name: z.string().min(3).max(100).optional(),
+ description: z.string().optional(),
+ })
+ )
+ .mutation(async ({ input }) => {
+ const { id, ...data } = input;
+ const group = await dbService.groups.update(id, data);
+ if (!group) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Group not found",
+ });
+ }
+ return group;
+ }),
+
+ // Delete a group
+ delete: permissionProtectedProcedure("system:groups:delete")
+ .input(z.object({ id: z.number() }))
+ .mutation(async ({ input }) => {
+ const groupToCheck = await dbService.groups.getById(input.id);
+ if (!groupToCheck) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Group not found",
+ });
+ }
+
+ if (groupToCheck.isSystem) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "Cannot delete a system group.",
+ });
+ }
+
+ const group = await dbService.groups.delete(input.id);
+ if (!group) {
+ logger.warn(
+ `Attempted to delete group ${input.id} which might have failed or was already gone.`
+ );
+ }
+ return { success: true };
+ }),
+
+ // Add users to a group
+ addUsers: permissionProtectedProcedure("system:groups:update")
+ .input(
+ z.object({
+ groupId: z.number(),
+ userIds: z.array(z.number()),
+ })
+ )
+ .mutation(async ({ input }) => {
+ const result = await dbService.groups.addUsers(
+ input.groupId,
+ input.userIds
+ );
+ return result;
+ }),
+
+ // Remove users from a group
+ removeUsers: permissionProtectedProcedure("system:groups:update")
+ .input(
+ z.object({
+ groupId: z.number(),
+ userIds: z.array(z.number()),
+ })
+ )
+ .mutation(async ({ input, ctx }) => {
+ const currentUserId = parseInt(ctx.session.user.id);
+
+ if (input.userIds.includes(currentUserId)) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "You cannot remove yourself from a group.",
+ });
+ }
+
+ const result = await dbService.groups.removeUsers(
+ input.groupId,
+ input.userIds
+ );
+ return result;
+ }),
+
+ // Add permissions to a group
+ addPermissions: permissionProtectedProcedure("system:groups:update")
+ .input(
+ z.object({
+ groupId: z.number(),
+ permissionIds: z.array(z.number()),
+ })
+ )
+ .mutation(async ({ input }) => {
+ const result = await dbService.groups.addPermissions(
+ input.groupId,
+ input.permissionIds
+ );
+ return result;
+ }),
+
+ // Remove permissions from a group
+ removePermissions: permissionProtectedProcedure("system:groups:update")
+ .input(
+ z.object({
+ groupId: z.number(),
+ permissionIds: z.array(z.number()),
+ })
+ )
+ .mutation(async ({ input }) => {
+ const result = await dbService.groups.removePermissions(
+ input.groupId,
+ input.permissionIds
+ );
+ return result;
+ }),
+
+ // Add module permissions to a group
+ addModulePermissions: permissionProtectedProcedure("system:groups:update")
+ .input(
+ z.object({
+ groupId: z.number(),
+ permissions: z.array(
+ z.object({
+ module: z.string(),
+ isAllowed: z.boolean(),
+ })
+ ),
+ })
+ )
+ .mutation(async ({ input }) => {
+ const result = await dbService.groups.addModulePermissions(
+ input.groupId,
+ input.permissions.map((p) => p.module)
+ );
+ return result;
+ }),
+
+ // Add action permissions to a group
+ addActionPermissions: permissionProtectedProcedure("system:groups:update")
+ .input(
+ z.object({
+ groupId: z.number(),
+ permissions: z.array(
+ z.object({
+ action: z.string(),
+ isAllowed: z.boolean(),
+ })
+ ),
+ })
+ )
+ .mutation(async ({ input }) => {
+ const result = await dbService.groups.addActionPermissions(
+ input.groupId,
+ input.permissions.map((p) => p.action)
+ );
+ return result;
+ }),
+
+ // Get module permissions for a group
+ getModulePermissions: permissionProtectedProcedure("system:groups:read")
+ .input(z.object({ groupId: z.number() }))
+ .query(async ({ input }) => {
+ const permissions = await dbService.groups.getModulePermissions(
+ input.groupId
+ );
+ return permissions;
+ }),
+
+ // Get action permissions for a group
+ getActionPermissions: permissionProtectedProcedure("system:groups:read")
+ .input(z.object({ groupId: z.number() }))
+ .query(async ({ input }) => {
+ const permissions = await dbService.groups.getActionPermissions(
+ input.groupId
+ );
+ return permissions;
+ }),
+
+ // Remove module permissions from a group
+ removeModulePermissions: permissionProtectedProcedure("system:groups:update")
+ .input(
+ z.object({
+ groupId: z.number(),
+ modules: z.array(z.string()),
+ })
+ )
+ .mutation(async ({ input }) => {
+ const result = await dbService.groups.removeModulePermissions(
+ input.groupId,
+ input.modules
+ );
+ return result;
+ }),
+
+ // Remove action permissions from a group
+ removeActionPermissions: permissionProtectedProcedure("system:groups:update")
+ .input(
+ z.object({
+ groupId: z.number(),
+ actions: z.array(z.string()),
+ })
+ )
+ .mutation(async ({ input }) => {
+ const result = await dbService.groups.removeActionPermissions(
+ input.groupId,
+ input.actions
+ );
+ return result;
+ }),
+});
diff --git a/src/lib/trpc/routers/index.ts b/apps/web/src/server/routers/index.ts
similarity index 56%
rename from src/lib/trpc/routers/index.ts
rename to apps/web/src/server/routers/index.ts
index a8722d5..07adb6a 100644
--- a/src/lib/trpc/routers/index.ts
+++ b/apps/web/src/server/routers/index.ts
@@ -3,12 +3,22 @@ import { wikiRouter } from "./wiki";
import { userRouter } from "./user";
import { searchRouter } from "./search";
import { assetsRouter } from "./assets";
+import { permissionsRouter } from "./permissions";
+import { groupsRouter } from "./groups";
+import { usersRouter } from "./users";
+import { authRouter } from "./auth";
+import { tagsRouter } from "./tags";
export const appRouter = router({
wiki: wikiRouter,
user: userRouter,
+ users: usersRouter,
search: searchRouter,
assets: assetsRouter,
+ permissions: permissionsRouter,
+ groups: groupsRouter,
+ auth: authRouter,
+ tags: tagsRouter,
});
// Export type router type signature,
diff --git a/apps/web/src/server/routers/permissions.ts b/apps/web/src/server/routers/permissions.ts
new file mode 100644
index 0000000..8d2d7d9
--- /dev/null
+++ b/apps/web/src/server/routers/permissions.ts
@@ -0,0 +1,101 @@
+import { z } from "zod";
+import { TRPCError } from "@trpc/server";
+import { router, permissionProtectedProcedure } from "~/server";
+import { dbService } from "~/lib/services";
+
+export const permissionsRouter = router({
+ // Get all permissions
+ getAll: permissionProtectedProcedure("system:permissions:read").query(
+ async () => {
+ const permissions = await dbService.permissions.getAll();
+ return permissions;
+ }
+ ),
+
+ // Get all unique modules
+ getModules: permissionProtectedProcedure("system:permissions:read").query(
+ async () => {
+ const permissions = await dbService.permissions.getAll();
+ const modules = [...new Set(permissions.map((p) => p.module))];
+ return modules;
+ }
+ ),
+
+ // Get all unique actions
+ getActions: permissionProtectedProcedure("system:permissions:read").query(
+ async () => {
+ const permissions = await dbService.permissions.getAll();
+ const actions = [...new Set(permissions.map((p) => p.action))];
+ return actions;
+ }
+ ),
+
+ // Get a permission by ID
+ getById: permissionProtectedProcedure("system:settings:read")
+ .input(z.object({ id: z.number() }))
+ .query(async ({ input }) => {
+ const permission = await dbService.permissions.getById(input.id);
+ if (!permission) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Permission not found",
+ });
+ }
+ return permission;
+ }),
+
+ // Create a new permission (very restricted operation)
+ create: permissionProtectedProcedure("system:settings:update")
+ .input(
+ z.object({
+ name: z.string().min(3).max(100),
+ description: z.string().optional(),
+ module: z.string().min(2).max(50),
+ action: z.string().min(2).max(50),
+ })
+ )
+ .mutation(async ({ input }) => {
+ const newPermission = await dbService.permissions.create({
+ ...input,
+ resource: input.name,
+ });
+ return newPermission;
+ }),
+
+ // Update a permission
+ update: permissionProtectedProcedure("system:settings:update")
+ .input(
+ z.object({
+ id: z.number(),
+ name: z.string().min(3).max(100).optional(),
+ description: z.string().optional(),
+ module: z.string().min(2).max(50).optional(),
+ action: z.string().min(2).max(50).optional(),
+ })
+ )
+ .mutation(async ({ input }) => {
+ const { id, ...data } = input;
+ const permission = await dbService.permissions.update(id, data);
+ if (!permission) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Permission not found",
+ });
+ }
+ return permission;
+ }),
+
+ // Delete a permission
+ delete: permissionProtectedProcedure("system:settings:update")
+ .input(z.object({ id: z.number() }))
+ .mutation(async ({ input }) => {
+ const permission = await dbService.permissions.delete(input.id);
+ if (!permission) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Permission not found",
+ });
+ }
+ return { success: true };
+ }),
+});
diff --git a/apps/web/src/server/routers/search.ts b/apps/web/src/server/routers/search.ts
new file mode 100644
index 0000000..1fd0e03
--- /dev/null
+++ b/apps/web/src/server/routers/search.ts
@@ -0,0 +1,29 @@
+import { z } from "zod";
+import { router, permissionGuestProcedure } from "..";
+import { dbService } from "~/lib/services";
+import {
+ paginationSchema,
+ createPaginatedResponse,
+} from "~/lib/utils/pagination";
+
+export const searchRouter = router({
+ search: permissionGuestProcedure("wiki:page:read")
+ .input(
+ z.object({
+ query: z.string().min(1),
+ ...paginationSchema(), // Include page and pageSize
+ })
+ )
+ .query(async ({ input }) => {
+ const { query, page, pageSize } = input;
+
+ // Get paginated results and total count from the service
+ const { items, totalItems } = await dbService.search.searchPaginated(
+ query,
+ { page, pageSize }
+ );
+
+ // Create the standard paginated response
+ return createPaginatedResponse(items, { page, pageSize }, totalItems);
+ }),
+});
diff --git a/apps/web/src/server/routers/tags.ts b/apps/web/src/server/routers/tags.ts
new file mode 100644
index 0000000..b369b2a
--- /dev/null
+++ b/apps/web/src/server/routers/tags.ts
@@ -0,0 +1,39 @@
+import { z } from "zod";
+import { tagService } from "~/lib/services";
+import { protectedProcedure, router } from "~/server";
+import { TRPCError } from "@trpc/server";
+import { logger } from "~/lib/utils/logger";
+
+/**
+ * tRPC router for tag-related operations.
+ */
+export const tagsRouter = router({
+ /**
+ * Search for tags by name.
+ * Requires authentication.
+ */
+ search: protectedProcedure
+ .input(
+ z.object({
+ query: z.string(),
+ limit: z.number().int().positive().optional().default(10),
+ })
+ )
+ .query(async ({ input }) => {
+ try {
+ if (!input.query.trim()) {
+ // Return empty array if query is empty or whitespace
+ return [];
+ }
+ const tags = await tagService.searchByName(input.query, input.limit);
+ return tags;
+ } catch (error) {
+ logger.error("[TAG SEARCH ERROR]", error);
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Failed to search for tags",
+ cause: error,
+ });
+ }
+ }),
+});
diff --git a/apps/web/src/server/routers/user.ts b/apps/web/src/server/routers/user.ts
new file mode 100644
index 0000000..b2601c9
--- /dev/null
+++ b/apps/web/src/server/routers/user.ts
@@ -0,0 +1,175 @@
+import {
+ publicProcedure,
+ protectedProcedure,
+ permissionProtectedProcedure,
+ router,
+} from "..";
+import { dbService } from "~/lib/services";
+import { hash } from "bcryptjs";
+import { z } from "zod";
+import { TRPCError } from "@trpc/server";
+import { logger } from "~/lib/utils/logger";
+
+// TODO: Move to users router
+
+export const saltRounds = 10;
+
+// Define validation schema for registration
+const registerSchema = z.object({
+ name: z.string().min(1, "Name is required"),
+ email: z.string().email("Invalid email address"),
+ password: z.string().min(8, "Password must be at least 8 characters"),
+});
+
+export const userRouter = router({
+ // Get the total count of users
+ count: permissionProtectedProcedure("system:users:read").query(async () => {
+ return await dbService.users.count();
+ }),
+
+ // Get current user profile (using session)
+ me: protectedProcedure.query(async ({ ctx }) => {
+ const userIdString = ctx.session.user.id;
+ const userId = parseInt(userIdString, 10);
+
+ if (isNaN(userId)) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Invalid user ID format.",
+ });
+ }
+
+ const user = await dbService.users.getById(userId);
+
+ if (!user) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "User not found",
+ });
+ }
+
+ const groups = await dbService.groups.getUserGroups(user.id);
+
+ return {
+ id: user.id,
+ name: user.name,
+ email: user.email,
+ image: user.image,
+ createdAt: user.createdAt,
+ updatedAt: user.updatedAt,
+ groups: groups.map((g) => ({ id: g.id, name: g.name })),
+ };
+ }),
+
+ // Register a new user
+ register: publicProcedure
+ .input(registerSchema)
+ .mutation(async ({ input }) => {
+ try {
+ // 1. Check if it's the first user using dbService
+ const userCount = await dbService.users.count();
+ const isFirstUser = userCount === 0;
+
+ // 2. Check if email already exists using dbService
+ const existingUser = await dbService.users.findByEmail(input.email);
+
+ if (existingUser) {
+ throw new TRPCError({
+ code: "CONFLICT",
+ message: "User with this email already exists",
+ });
+ }
+
+ // 3. Hash the password
+ const hashedPassword = await hash(input.password, saltRounds);
+
+ // 4. Create the user using dbService
+ const newUser = await dbService.users.create({
+ name: input.name,
+ email: input.email,
+ password: hashedPassword,
+ });
+
+ if (!newUser) {
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Failed to create user.",
+ });
+ }
+
+ // 5. If it's the first user, assign to Administrators group
+ if (isFirstUser) {
+ const adminGroup =
+ await dbService.groups.findByName("Administrators");
+ if (adminGroup) {
+ const added = await dbService.groups.addUserToGroup(
+ newUser.id,
+ adminGroup.id
+ );
+ if (added) {
+ logger.log(
+ `First user ${newUser.email} automatically assigned to Administrators group.`
+ );
+ } else {
+ logger.error(
+ `Failed to assign first user ${newUser.email} to Administrators group.`
+ );
+ }
+ } else {
+ logger.error(
+ "CRITICAL: Administrators group not found during first user registration! Seeding might have failed."
+ );
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message:
+ "Failed to assign administrative privileges. Administrator group not found.",
+ });
+ }
+ } else {
+ // For non-first users, add them to the Viewers group
+ const viewerGroup = await dbService.groups.findByName("Viewers");
+ if (viewerGroup) {
+ const added = await dbService.groups.addUserToGroup(
+ newUser.id,
+ viewerGroup.id
+ );
+ if (added) {
+ logger.log(
+ `New user ${newUser.email} automatically assigned to Viewers group.`
+ );
+ } else {
+ logger.error(
+ `Failed to assign new user ${newUser.email} to Viewers group.`
+ );
+ }
+ } else {
+ logger.error(
+ "CRITICAL: Viewers group not found during user registration! Seeding might have failed."
+ );
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message:
+ "Failed to assign user to Viewers group. Viewers group not found.",
+ });
+ }
+ }
+
+ // Return relevant user info (excluding password)
+ return {
+ id: newUser.id,
+ name: newUser.name,
+ email: newUser.email,
+ isFirstUser: isFirstUser,
+ };
+ } catch (error) {
+ if (error instanceof TRPCError) {
+ throw error;
+ }
+ logger.error("Registration error:", error);
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "An error occurred during registration",
+ });
+ }
+ }),
+});
diff --git a/apps/web/src/server/routers/users.ts b/apps/web/src/server/routers/users.ts
new file mode 100644
index 0000000..307e42f
--- /dev/null
+++ b/apps/web/src/server/routers/users.ts
@@ -0,0 +1,34 @@
+import { z } from "zod";
+import { TRPCError } from "@trpc/server";
+import { router, permissionProtectedProcedure } from "~/server";
+import { dbService } from "~/lib/services";
+
+export const usersRouter = router({
+ // Get all users
+ getAll: permissionProtectedProcedure("system:users:read").query(async () => {
+ const users = await dbService.users.getAll();
+ return users;
+ }),
+
+ // Get a single user by ID
+ getById: permissionProtectedProcedure("system:users:read")
+ .input(z.object({ id: z.number() }))
+ .query(async ({ input }) => {
+ const user = await dbService.users.getById(input.id);
+ if (!user) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "User not found",
+ });
+ }
+ return user;
+ }),
+
+ // Get user groups by ID
+ getUserGroups: permissionProtectedProcedure("system:users:read")
+ .input(z.object({ id: z.number() }))
+ .query(async ({ input }) => {
+ const userGroups = await dbService.users.getUserGroups(input.id);
+ return userGroups;
+ }),
+});
diff --git a/src/lib/trpc/routers/wiki.ts b/apps/web/src/server/routers/wiki.ts
similarity index 74%
rename from src/lib/trpc/routers/wiki.ts
rename to apps/web/src/server/routers/wiki.ts
index 1fa45a3..ad2db64 100644
--- a/src/lib/trpc/routers/wiki.ts
+++ b/apps/web/src/server/routers/wiki.ts
@@ -1,9 +1,15 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { desc, eq, like, gt, and, sql } from "drizzle-orm";
-import { db, wikiPages, wikiPageRevisions } from "~/lib/db";
-import { publicProcedure, protectedProcedure, router } from "..";
+import { db, wikiPages } from "@repo/db";
+import {
+ permissionGuestProcedure,
+ permissionProtectedProcedure,
+ publicProcedure,
+ router,
+} from "..";
import { dbService, wikiService } from "~/lib/services";
+import { logger } from "~/lib/utils/logger";
// Wiki page input validation schema
const pageInputSchema = z.object({
@@ -11,12 +17,65 @@ const pageInputSchema = z.object({
title: z.string().min(1).max(255),
content: z.string().optional(),
isPublished: z.boolean().optional(),
+ tags: z.array(z.string()).optional(),
});
export const wikiRouter = router({
+ randomNumber: publicProcedure.subscription(async function* (opts?) {
+ let idx = 0;
+ logger.log("Starting randomNumber subscription...");
+ const signal = opts?.signal;
+
+ while (true) {
+ if (signal?.aborted) {
+ logger.log("Subscription aborted by client.");
+ break;
+ }
+
+ yield { randomNumber: Math.random(), completed: idx >= 10 };
+ idx++;
+
+ if (idx > 10) {
+ logger.log("Subscription completing normally (10 iterations).");
+ return;
+ }
+
+ try {
+ await new Promise((resolve, reject) => {
+ const timeout = setTimeout(resolve, 500);
+ if (signal) {
+ signal.addEventListener("abort", () => {
+ clearTimeout(timeout);
+ reject(new Error("Subscription aborted during wait."));
+ });
+ }
+ });
+ } catch (error: unknown) {
+ if (
+ signal?.aborted ||
+ (error instanceof Error && error.message.includes("aborted"))
+ ) {
+ logger.log("Subscription aborted during 500ms wait.");
+ break;
+ }
+ logger.error("Unexpected error during wait:", error);
+ throw error;
+ }
+ }
+ }),
+
// Get a page by path
- getByPath: publicProcedure
- .input(z.object({ path: z.string() }))
+ getByPath: permissionGuestProcedure("wiki:page:read")
+ .meta({ description: "Fetches a specific wiki page by its full path." })
+ .input(
+ z.object({
+ path: z
+ .string()
+ .describe(
+ "The full path to the wiki page (e.g., 'folder/subfolder/page-name')"
+ ),
+ })
+ )
.query(async ({ input }) => {
const page = await db.query.wikiPages.findFirst({
where: eq(wikiPages.path, input.path),
@@ -43,7 +102,7 @@ export const wikiRouter = router({
}),
// Check if a page exists at a path without throwing an error
- pageExists: publicProcedure
+ pageExists: permissionProtectedProcedure("wiki:page:read")
.input(z.object({ path: z.string() }))
.query(async ({ input }) => {
const page = await db.query.wikiPages.findFirst({
@@ -55,29 +114,43 @@ export const wikiRouter = router({
}),
// Create a new page
- create: protectedProcedure
+ create: permissionProtectedProcedure("wiki:page:create")
.input(pageInputSchema)
.mutation(async ({ input, ctx }) => {
- const { path, title, content, isPublished } = input;
+ const { path, title, content, isPublished, tags } = input;
const userId = parseInt(ctx.session.user.id);
- const [page] = await db
- .insert(wikiPages)
- .values({
- path,
- title,
- content,
- isPublished: isPublished ?? false,
- createdById: userId,
- updatedById: userId,
- })
- .returning();
+ // Let the service handle the page creation and tag associations
+ const createdPage = await wikiService.create({
+ path,
+ title,
+ content,
+ isPublished: isPublished ?? false,
+ userId,
+ tags,
+ });
- return page;
+ if (!createdPage) {
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Failed to create page",
+ });
+ }
+
+ // Re-fetch the page using the cleaned-up service method
+ const page = await wikiService.getById(createdPage.id);
+ if (!page) {
+ // Should not happen, but handle defensively
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Failed to retrieve created page",
+ });
+ }
+ return page; // Return the cleaned-up page data
}),
// Acquire a lock on a page for editing
- acquireLock: protectedProcedure
+ acquireLock: permissionProtectedProcedure("wiki:page:update")
.input(z.object({ id: z.number() }))
.mutation(async ({ input, ctx }) => {
const { id } = input;
@@ -118,7 +191,7 @@ export const wikiRouter = router({
}),
// Release a software lock on a page
- releaseLock: protectedProcedure
+ releaseLock: permissionProtectedProcedure("wiki:page:update")
.input(z.object({ id: z.number() }))
.mutation(async ({ input, ctx }) => {
const { id } = input;
@@ -128,7 +201,7 @@ export const wikiRouter = router({
const success = await dbService.locks.releaseLock(id, userId);
if (!success) {
- console.warn(
+ logger.warn(
`Lock for page ${id} could not be released - may be held by a different user`
);
}
@@ -149,7 +222,7 @@ export const wikiRouter = router({
}),
// Refresh a software lock to prevent timeout
- refreshLock: protectedProcedure
+ refreshLock: permissionProtectedProcedure("wiki:page:update")
.input(z.object({ id: z.number() }))
.mutation(async ({ input, ctx }) => {
const { id } = input;
@@ -178,14 +251,14 @@ export const wikiRouter = router({
}),
// Update an existing page
- update: protectedProcedure
+ update: permissionProtectedProcedure("wiki:page:update")
.input(
pageInputSchema.extend({
id: z.number(),
})
)
.mutation(async ({ input, ctx }) => {
- const { id, path, title, content, isPublished } = input;
+ const { id, path, title, content, isPublished, tags } = input;
const userId = parseInt(ctx.session.user.id);
// First, check if the user has a valid software lock
@@ -198,54 +271,36 @@ export const wikiRouter = router({
});
}
- // Use a transaction with a hardware lock for the update operation
try {
- return await db.transaction(async (tx) => {
- // Set a short timeout for the hardware lock operation
- await tx.execute(sql`SET LOCAL statement_timeout = 3000`);
-
- // Acquire a hardware lock for the update
- const result = await tx.execute(
- sql`SELECT * FROM wiki_pages WHERE id = ${id} FOR UPDATE NOWAIT`
- );
-
- if (result.rows.length === 0) {
- throw new TRPCError({
- code: "NOT_FOUND",
- message: "Page not found",
- });
- }
-
- const page = result.rows[0] as typeof wikiPages.$inferSelect;
+ // Use the service method which now handles tags as well
+ const updatedPageRaw = await wikiService.update(id, {
+ path,
+ title,
+ content,
+ isPublished,
+ userId,
+ tags,
+ });
- // Create a page revision
- await tx.insert(wikiPageRevisions).values({
- pageId: id,
- content: page.content || "",
- createdById: userId,
+ if (!updatedPageRaw) {
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Failed to update page",
});
+ }
- // Update the page and clear the software lock
- const [updatedPage] = await tx
- .update(wikiPages)
- .set({
- path,
- title,
- content,
- isPublished,
- updatedById: userId,
- updatedAt: new Date(),
- lockedById: null,
- lockedAt: null,
- lockExpiresAt: null,
- })
- .where(eq(wikiPages.id, id))
- .returning();
-
- return updatedPage;
- });
+ // Re-fetch the page using the cleaned-up service method
+ const page = await wikiService.getById(updatedPageRaw.id);
+ if (!page) {
+ // Should not happen, but handle defensively
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Failed to retrieve updated page",
+ });
+ }
+ return page; // Return the cleaned-up page data
} catch (error) {
- console.error("Failed to update page:", error);
+ logger.error("Failed to update page:", error);
if (
error instanceof Error &&
@@ -266,7 +321,7 @@ export const wikiRouter = router({
}),
// List pages (paginated) with lock information
- list: publicProcedure
+ list: permissionGuestProcedure("wiki:page:read")
.input(
z.object({
limit: z.number().min(1).max(100).default(10),
@@ -316,9 +371,31 @@ export const wikiRouter = router({
where: whereConditions,
orderBy: [orderBy],
limit: limit + 1,
+ columns: {
+ id: true,
+ path: true,
+ title: true,
+ content: false,
+ renderedHtml: false,
+ editorType: true,
+ isPublished: true,
+ createdAt: true,
+ updatedAt: true,
+ renderedHtmlUpdatedAt: false,
+ search: false,
+ lockedById: false,
+ createdById: false,
+ updatedById: false,
+ lockedAt: true,
+ lockExpiresAt: true,
+ },
with: {
- updatedBy: true,
- lockedBy: true,
+ updatedBy: {
+ columns: { id: true, name: true, email: true, image: true },
+ },
+ lockedBy: {
+ columns: { id: true, name: true, email: true, image: true },
+ },
tags: {
with: {
tag: true,
@@ -343,7 +420,7 @@ export const wikiRouter = router({
}),
// Delete a page
- delete: protectedProcedure
+ delete: permissionProtectedProcedure("wiki:page:delete")
.input(z.object({ id: z.number() }))
.mutation(async ({ input, ctx }) => {
const { id } = input;
@@ -386,7 +463,7 @@ export const wikiRouter = router({
return deleted;
});
} catch (error) {
- console.error("Failed to delete page:", error);
+ logger.error("Failed to delete page:", error);
if (
error instanceof Error &&
@@ -407,26 +484,28 @@ export const wikiRouter = router({
}),
// Get folder structure
- getFolderStructure: publicProcedure.query(async () => {
- // Get all pages from database
- const pages = await db.query.wikiPages.findMany({
- orderBy: [wikiPages.path],
- columns: {
- id: true,
- path: true,
- title: true,
- updatedAt: true,
- isPublished: true,
- },
- });
-
- // Build folder structure
- const folderStructure = buildFolderStructure(pages);
- return folderStructure;
- }),
+ getFolderStructure: permissionGuestProcedure("wiki:page:read").query(
+ async () => {
+ // Get all pages from database
+ const pages = await db.query.wikiPages.findMany({
+ orderBy: [wikiPages.path],
+ columns: {
+ id: true,
+ path: true,
+ title: true,
+ updatedAt: true,
+ isPublished: true,
+ },
+ });
+
+ // Build folder structure
+ const folderStructure = buildFolderStructure(pages);
+ return folderStructure;
+ }
+ ),
// Get subfolders for a specific path
- getSubfolders: publicProcedure
+ getSubfolders: permissionGuestProcedure("wiki:page:read")
.input(
z.object({
path: z.string().optional(),
@@ -453,7 +532,7 @@ export const wikiRouter = router({
}),
// Move or rename pages
- movePages: protectedProcedure
+ movePages: permissionProtectedProcedure("wiki:page:move")
.input(
z.object({
pageIds: z.array(z.number()),
@@ -506,7 +585,7 @@ export const wikiRouter = router({
}
// Default error handling
- console.error("Error in movePages:", error);
+ logger.error("Error in movePages:", error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "An unexpected error occurred",
@@ -515,7 +594,7 @@ export const wikiRouter = router({
}),
// Create a new folder (this actually creates an empty index page in the folder)
- createFolder: protectedProcedure
+ createFolder: permissionProtectedProcedure("wiki:page:create")
.input(
z.object({
path: z.string().min(1),
@@ -542,7 +621,7 @@ export const wikiRouter = router({
}
// Create the page at the requested path
- const [page] = await db
+ const [createdFolderPage] = await db
.insert(wikiPages)
.values({
path: folderPath,
@@ -554,12 +633,28 @@ export const wikiRouter = router({
})
.returning();
- return page;
+ if (!createdFolderPage) {
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Failed to create folder page",
+ });
+ }
+
+ // Re-fetch the page using the cleaned-up service method
+ const page = await wikiService.getById(createdFolderPage.id);
+ if (!page) {
+ // Should not happen, but handle defensively
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Failed to retrieve created folder page",
+ });
+ }
+ return page; // Return the cleaned-up page data
}),
});
// Helper function to build folder structure
-interface FolderNode {
+export interface FolderNode {
name: string;
path: string;
type: "folder" | "page";
@@ -570,7 +665,7 @@ interface FolderNode {
isPublished?: boolean | null;
}
-interface FolderNodeArray extends Omit {
+export interface FolderNodeArray extends Omit {
children: FolderNodeArray[];
}
@@ -617,6 +712,12 @@ function buildFolderStructure(
const segment = pathSegments[i];
const isLastSegment = i === pathSegments.length - 1;
+ if (!segment) {
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Failed to create page",
+ });
+ }
// Update current path
currentPath = currentPath ? `${currentPath}/${segment}` : segment;
@@ -658,6 +759,12 @@ function buildFolderStructure(
}
for (const childKey in node.children) {
+ if (!node.children[childKey]) {
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Failed to create page",
+ });
+ }
markFoldersWithChildren(node.children[childKey]);
}
}
@@ -727,6 +834,8 @@ function getSubfoldersForPath(
if (!firstSegmentMatch) continue;
const firstSegment = firstSegmentMatch[1];
+ if (!firstSegment) continue;
+
const hasChildren = relativePath.includes("/");
const isDirectChild = !relativePath
.slice(firstSegment.length + 1)
diff --git a/apps/web/src/server/wssDevServer.ts b/apps/web/src/server/wssDevServer.ts
new file mode 100644
index 0000000..54fe6a8
--- /dev/null
+++ b/apps/web/src/server/wssDevServer.ts
@@ -0,0 +1,43 @@
+import { createContext } from "./context";
+import { appRouter } from "./routers";
+import { applyWSSHandler } from "@trpc/server/adapters/ws";
+import { logger } from "../lib/utils/logger";
+import { WebSocketServer } from "ws";
+
+const wss = new WebSocketServer({
+ port: 3001,
+});
+
+const handler = applyWSSHandler({
+ wss,
+ router: appRouter,
+ createContext,
+ keepAlive: {
+ enabled: true,
+ // server ping message interval in milliseconds
+ pingMs: 10000,
+ // connection is terminated if pong message is not received in this many milliseconds
+ pongWaitMs: 3000,
+ },
+});
+
+wss.on("connection", (ws) => {
+ logger.info(`➕ Connection established (${wss.clients.size} total)`);
+ ws.once("close", () => {
+ logger.info(`➖ Connection closed (${wss.clients.size} total)`);
+ });
+});
+logger.info("WebSocket Server listening on ws://localhost:3001");
+
+process.on("SIGTERM", () => {
+ logger.info("SIGTERM received, shutting down WebSocket server...");
+ handler.broadcastReconnectNotification();
+ wss.close();
+});
+
+// Handler for Ctrl+C
+process.on("SIGINT", () => {
+ logger.info("SIGINT received, shutting down WebSocket server...");
+ handler.broadcastReconnectNotification();
+ wss.close();
+});
diff --git a/apps/web/src/types/db.d.ts b/apps/web/src/types/db.d.ts
new file mode 100644
index 0000000..cb70ec9
--- /dev/null
+++ b/apps/web/src/types/db.d.ts
@@ -0,0 +1,3 @@
+import { PgTransaction } from "drizzle-orm/pg-core";
+
+export type Transaction = PgTransaction;
diff --git a/src/types/next-auth.d.ts b/apps/web/src/types/next-auth.d.ts
similarity index 100%
rename from src/types/next-auth.d.ts
rename to apps/web/src/types/next-auth.d.ts
diff --git a/apps/web/src/types/react-markdown.d.ts b/apps/web/src/types/react-markdown.d.ts
new file mode 100644
index 0000000..e69de29
diff --git a/apps/web/tailwind.config.ts b/apps/web/tailwind.config.ts
new file mode 100644
index 0000000..ab1127f
--- /dev/null
+++ b/apps/web/tailwind.config.ts
@@ -0,0 +1,11 @@
+// tailwind config is required for editor support
+
+import type { Config } from "tailwindcss";
+import sharedConfig from "@repo/tailwind-config";
+
+const config: Pick = {
+ content: ["./src/**/*.tsx"],
+ presets: [sharedConfig],
+};
+
+export default config;
diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json
new file mode 100644
index 0000000..32503c6
--- /dev/null
+++ b/apps/web/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "extends": "@repo/typescript-config/nextjs.json",
+ "compilerOptions": {
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "paths": {
+ "~/*": ["./src/*"]
+ }
+ },
+ "include": [
+ "**/*.ts",
+ "**/*.tsx",
+ "next-env.d.ts",
+ "next.config.js",
+ ".next/types/**/*.ts"
+ ],
+ "exclude": ["node_modules", ".next"]
+}
diff --git a/apps/web/tsconfig.server.json b/apps/web/tsconfig.server.json
new file mode 100644
index 0000000..7b9796e
--- /dev/null
+++ b/apps/web/tsconfig.server.json
@@ -0,0 +1,12 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "dist",
+ "module": "Preserve",
+ "target": "ES2024",
+ "lib": ["ES2024", "DOM"],
+ "isolatedModules": false,
+ "noEmit": false
+ },
+ "include": ["next-env.d.ts", "src/**/*.ts", "src/**/*.tsx"]
+}
diff --git a/docs/permissions.md b/docs/permissions.md
new file mode 100644
index 0000000..dbcdf66
--- /dev/null
+++ b/docs/permissions.md
@@ -0,0 +1,286 @@
+# Custom Permission System Documentation
+
+This document outlines the custom permission system used in NextWiki, providing components for controlling access based on user permissions and authentication status, both on the server and client side.
+
+## General Overview
+
+The permission system is built upon several core concepts:
+
+1. **Permission Registry (`src/lib/permissions/registry.ts`):** A central TypeScript file defining all available permissions in the system using a structured format (`module:resource:action`, e.g., `wiki:page:read`). This registry is the single source of truth for what permissions _can_ exist.
+2. **Database Schema (`src/lib/db/schema.ts`):**
+ - `permissions`: Stores the permissions defined in the registry, adding a unique ID.
+ - `groups`: Defines user groups (e.g., Administrators, Editors, Viewers, Guests).
+ - `userGroups`: Links users to groups (many-to-many).
+ - `groupPermissions`: Links groups to specific permissions (many-to-many).
+ - `groupModulePermissions`, `groupActionPermissions`: Allow broader permissions based on module or action for a group (experimental/potentially less used).
+ - `pagePermissions`: Allows overriding permissions on a per-page basis (not fully covered in this doc).
+3. **Seeding (`src/lib/db/seeds/permissions.ts`):** Scripts to populate the `permissions` table from the registry and create default groups (`Administrators`, `Editors`, `Viewers`, `Guests`) with sensible default permission assignments.
+4. **Services:**
+ - `permissionService (`src/lib/services/permissions.ts`): Basic CRUD for the `permissions` table.
+ - `authorizationService` (`src/lib/services/authorization.ts`): The core logic for checking if a user (or guest) has a specific permission based on their group memberships and the permissions assigned to those groups. Handles fetching user groups and querying relevant permission tables.
+5. **Components (`src/components/auth/permission/`):** React components (both Server and Client) that utilize the `authorizationService` or the client-side `PermissionProvider` context to enforce access control declaratively in the UI.
+
+## Server-Side Components
+
+These components run on the server and provide the primary mechanism for enforcing access control before rendering a page or layout.
+
+### `PermissionGate`
+
+**Location:** `src/components/auth/permission/server/gate.tsx`
+
+This is an asynchronous Server Component designed for robust permission checks, typically used in layout files (`src/app/layout.tsx`) or higher-order components to gate access to entire sections or pages based on server-side session and permission data. It automatically detects the current pathname using request headers.
+
+**Props:**
+
+- `permission` (Optional `PermissionIdentifier`): The specific permission required to access the `Authorized` slot.
+- `permissions` (Optional `PermissionIdentifier[]`): An array of permissions. Access is granted if the user has _any_ of these permissions. Use either `permission` or `permissions`, not both.
+- `publicPaths` (Optional `string[]`): An array of pathnames (e.g., `'/login'`, `'/register'`, `'/api/*'`) that should always be accessible, regardless of the permission check or authentication status. Supports wildcards. Automatically checks against the current path from headers.
+- `allowGuests` (Optional `boolean`, default: `false`): If `true`, allows unauthenticated users access if they have the required guest permissions defined in the system.
+- `children`: Must contain the slot components (`Authorized`, `Unauthorized`, `NotLoggedIn`).
+
+**Slots (Used as Children):**
+
+These components are used _inside_ `PermissionGate` to define what content is shown or action is taken:
+
+- **`PermissionGate.Authorized`**: Renders its children if the user is authorized (has permission or path is public).
+- **`PermissionGate.Unauthorized`**: Renders its children if the user is logged in BUT lacks the required permission (and the path is not public).
+ - `redirectTo` (Optional `string`): If provided, performs a server-side redirect to this path instead of rendering children.
+- **`PermissionGate.NotLoggedIn`**: Renders its children if the user is not logged in (and the path is not public).
+ - `redirectTo` (Optional `string`): If provided, performs a server-side redirect to this path instead of rendering children.
+
+**Usage Example (`src/app/layout.tsx`):**
+
+```tsx
+import { PermissionGate } from "~/components/auth/permission/server";
+import { LogOutButton } from "~/components/auth/LogOutButton";
+import RootLayoutContent from "./RootLayoutContent"; // Example main content
+
+// ...
+
+
+
+ {children}
+
+
+ {/* Fallback content if redirect isn't used, or displayed briefly */}
+ Access Denied Content
+
+
+ {/* Fallback content if redirect isn't used, or displayed briefly */}
+ Redirecting to Login...
+
+ ;
+```
+
+### `RequirePermission`
+
+**Location:** `src/components/auth/permission/server/require.tsx`
+
+A simpler asynchronous Server Component for conditionally rendering a specific piece of UI based on permission checks. It uses the `authorizationService` directly and automatically detects the current pathname using request headers if `publicPaths` is used.
+
+**Props:**
+
+- `permission` (Optional `PermissionIdentifier`): The single permission required. Use either this or `permissions`, not both.
+- `permissions` (Optional `PermissionIdentifier[]`): An array of permissions. Access is granted if the user has _any_ of these. Use either this or `permission`, not both.
+- `publicPaths` (Optional `string[]`): An array of pathnames exempt from the permission check. If provided, the component will automatically check the current path from headers against this list.
+- `allowGuests` (Optional `boolean`, default: `false`): Considers guest permissions.
+- `children`: The content to render if permission is granted (or path is public).
+
+**Behavior:** Renders `children` if the user has the required permission(s) or the current path is in `publicPaths`. Otherwise, it renders `null`. Throws an error if both `permission` and `permissions` are provided, or if neither is provided.
+
+**Usage Example (Inside a Server Component):**
+
+```tsx
+import { RequirePermission } from "~/components/auth/permission/server";
+
+async function MyServerComponent() {
+ // No need to manually get pathname for publicPaths check
+ return (
+
+
Admin Section
+
+ You can update settings.
+ {/* */}
+
+
+ You can view users OR groups.
+ {/* / */}
+
+
+ );
+}
+```
+
+## Client-Side Permission Management
+
+Client-side components rely on a provider to fetch and distribute permission state.
+
+### `PermissionProvider`
+
+**Location:** `src/components/auth/permission/provider.tsx` (Exported via `client/index.ts`)
+
+This Client Component fetches the current user's permissions using tRPC (`auth.getMyPermissions`) based on their `next-auth` session status. It wraps the application (typically in `src/providers/index.tsx`) and makes permission data available via React Context.
+
+**Setup (`src/providers/index.tsx`):**
+
+```tsx
+"use client";
+// ... other imports
+import { PermissionProvider } from "~/components/auth/permission/client";
+
+export function Providers({ children }: ProvidersProps) {
+ return (
+
+
+
+
+ {" "}
+ {/* <--- PermissionProvider wraps content */}
+
+ {children}
+
+
+
+
+
+
+ );
+}
+```
+
+### `usePermissions` Hook
+
+**Location:** `src/components/auth/permission/utils/usePermissions.ts`
+
+This hook is the standard way to access the permission context provided by `PermissionProvider`. It **must** be used within a Client Component that is a descendant of `PermissionProvider`.
+
+**Return Values (Context Data):**
+
+- `permissions`: Raw array of user's permission objects (`Permission[]`).
+- `permissionNames`: Array of permission identifier strings (`PermissionIdentifier[]`).
+- `permissionMap`: A Record for efficient lookups (`Record`).
+- `isLoading`: Boolean indicating if session status or permissions are still being fetched.
+- `isGuest`: Boolean, `true` if the user is unauthenticated (`useSession` status is "unauthenticated").
+- `isAuthenticated`: Boolean, `true` if the user is authenticated (`useSession` status is "authenticated").
+- `hasPermission(permission: PermissionIdentifier)`: Function to check if the user has a specific permission. Returns `false` while loading.
+- `hasAnyPermission(permissions: PermissionIdentifier[])`: Function to check if the user has at least one of the specified permissions. Returns `false` while loading or if the input array is empty.
+- `reloadPermissions()`: Function to manually trigger a refetch of user permissions.
+
+**Usage Example (Inside a Client Component):**
+
+```tsx
+"use client";
+
+import { usePermissions } from "~/components/auth/permission/utils/usePermissions";
+import { Button } from "~/components/ui/button";
+import { Skeleton } from "~/components/ui/skeleton";
+
+function MyToolbar() {
+ // Consumes context from PermissionProvider higher up the tree
+ const { hasPermission, isLoading, isAuthenticated } = usePermissions();
+
+ if (isLoading) {
+ return ; // Show loading state
+ }
+
+ if (!isAuthenticated) {
+ return Please log in to see actions.
;
+ }
+
+ return (
+
+ {hasPermission("admin:users:create") ? (
+ Create User
+ ) : (
+
+ Create User
+
+ )}
+ {/* Other toolbar items */}
+
+ );
+}
+```
+
+## Client-Side Components
+
+These Client Components utilize the `PermissionProvider` context for dynamically showing/hiding UI elements based on permissions. They provide _UI-level control only_ and do not offer true security on their own (sensitive data/actions should always be protected server-side).
+
+### `ClientPermissionGate`
+
+**Location:** `src/components/auth/permission/client/gate.tsx`
+
+A client-side equivalent to `PermissionGate`, using the same slot-based approach (`Authorized`, `Unauthorized`, `NotLoggedIn`). It renders content based on the permissions available in the `PermissionContext`. Useful for controlling larger UI sections on the client without needing server-side checks for every interaction.
+
+**Props:**
+
+- `permission` (Optional `PermissionIdentifier`): Check for a single permission.
+- `permissions` (Optional `PermissionIdentifier[]`): Check if the user has _any_ permission in the list. Use either `permission` or `permissions`.
+- `publicPaths` (Optional `string[]`): Client-side check using `usePathname`. If the current path matches, the `Authorized` slot is rendered regardless of permissions.
+- `allowGuests` (Optional `boolean`, default: `false`): If `true`, considers guest status from the context.
+- `children`: Must contain the slot components (`Authorized`, `Unauthorized`, `NotLoggedIn`).
+
+**Slots (Used as Children):**
+
+- **`ClientPermissionGate.Authorized`**: Renders children if authorized based on context/props.
+- **`ClientPermissionGate.Unauthorized`**: Renders children if authenticated but lacks permission (and path not public).
+ - `redirectTo` (Optional `string`): If provided, performs a _client-side_ redirect using `useRouter` instead of rendering children.
+- **`ClientPermissionGate.NotLoggedIn`**: Renders children if unauthenticated (and path not public).
+ - `redirectTo` (Optional `string`): If provided, performs a _client-side_ redirect instead of rendering children.
+
+### `ClientRequirePermission`
+
+**Location:** `src/components/auth/permission/client/require.tsx`
+
+A simpler Client Component for conditionally rendering a specific piece of UI based on permission checks using the `usePermissions` context.
+
+**Props:**
+
+- `permission` (Optional `PermissionIdentifier`): The single permission required. Use either this or `permissions`, not both.
+- `permissions` (Optional `PermissionIdentifier[]`): An array of permissions. Access is granted if the user has _any_ of these. Use either this or `permission`, not both.
+- `publicPaths` (Optional `string[]`): Client-side path check using `usePathname`.
+- `allowGuests` (Optional `boolean`, default: `false`): Considers guest status from context.
+- `children`: The content to render if permission is granted (or path is public).
+- `fallback` (Optional `ReactNode`): Content to render if permission is denied or during loading. Defaults to `null`.
+
+**Behavior:** Renders `children` if the user has the required permission(s) from context or the current path is in `publicPaths`. Renders `fallback` (or `null`) otherwise. Logs a warning if both `permission` and `permissions` are provided, or if neither is provided.
+
+**Usage Example:**
+
+```tsx
+import { ClientRequirePermission } from "~/components/auth/permission/client";
+import { DeleteButton } from "./DeleteButton";
+import { EditButton } from "./EditButton";
+
+function PageActions({ pageId }: { pageId: string }) {
+ return (
+
+
+
+
+
+
+
+
+ );
+}
+```
+
+## Relationship & Use Cases
+
+- **`PermissionGate` (Server):** Use in layouts (`layout.tsx`) for primary, secure access control to pages/routes. Handles initial load protection and server-side redirects. Best for gating entire pages/sections.
+- **`RequirePermission` (Server):** Use within Server Components when you need to conditionally render smaller parts of the UI based on permissions, without the complexity of slots or redirects. Automatically checks path if `publicPaths` is used.
+- **`PermissionProvider` (Client):** Essential setup component. Wrap your application (in `src/providers/index.tsx`) to enable client-side permission checks. Fetches and provides the context.
+- **`usePermissions` (Client Hook):** Use within Client Components (that are descendants of `PermissionProvider`) to get fine-grained access to permission state (`isLoading`, `isGuest`, `hasPermission`, etc.) for conditional logic or rendering.
+- **`ClientPermissionGate` (Client):** Use within Client Components when you need the slot-based pattern (`Authorized`, `Unauthorized`, `NotLoggedIn`) for larger UI sections, potentially with client-side redirects. UI only.
+- **`ClientRequirePermission` (Client):** Use within Client Components for the common case of simply showing/hiding a small element (like a button or menu item) based on a single permission or a set of permissions (any match). UI only.
+
+**Security Note:** Client-side checks (`usePermissions`, `ClientPermissionGate`, `ClientRequirePermission`) are for UI convenience only. Always enforce critical security rules and data access on the server-side using `PermissionGate`, `RequirePermission`, or directly within your API routes/server actions using `authorizationService`.
diff --git a/drizzle.config.ts b/drizzle.config.ts
deleted file mode 100644
index 3a3bcb5..0000000
--- a/drizzle.config.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { defineConfig } from 'drizzle-kit';
-
-export default defineConfig({
- schema: './src/lib/db/schema.ts',
- out: './drizzle',
- dialect: 'postgresql',
- dbCredentials: {
- url: process.env.DATABASE_URL || 'postgresql://user:password@localhost:5432/nextwiki',
- },
- verbose: true,
- strict: true,
-});
\ No newline at end of file
diff --git a/eslint.config.mjs b/eslint.config.mjs
deleted file mode 100644
index c85fb67..0000000
--- a/eslint.config.mjs
+++ /dev/null
@@ -1,16 +0,0 @@
-import { dirname } from "path";
-import { fileURLToPath } from "url";
-import { FlatCompat } from "@eslint/eslintrc";
-
-const __filename = fileURLToPath(import.meta.url);
-const __dirname = dirname(__filename);
-
-const compat = new FlatCompat({
- baseDirectory: __dirname,
-});
-
-const eslintConfig = [
- ...compat.extends("next/core-web-vitals", "next/typescript"),
-];
-
-export default eslintConfig;
diff --git a/package.json b/package.json
index 34be7f7..1d3a06e 100644
--- a/package.json
+++ b/package.json
@@ -1,82 +1,35 @@
{
"name": "next-wiki",
- "version": "0.1.0",
"private": true,
- "packageManager": "pnpm@9.14.2",
+ "type": "module",
"scripts": {
- "dev": "next dev --turbopack",
- "build": "next build",
- "start": "next start",
- "lint": "next lint",
- "db:generate": "drizzle-kit generate",
- "db:push": "drizzle-kit push",
- "db:migrate": "tsx src/lib/db/migrate.ts",
- "db:setup": "node scripts/setup-db.js",
- "watch-css": "tailwindcss -i ./src/styles/globals.css -o ./public/output.css --watch"
- },
- "dependencies": {
- "@auth/drizzle-adapter": "^1.8.0",
- "@codemirror/lang-markdown": "^6.3.2",
- "@codemirror/state": "^6.5.2",
- "@neondatabase/serverless": "^0.10.4",
- "@radix-ui/react-popover": "^1.1.6",
- "@radix-ui/react-tabs": "^1.1.3",
- "@tailwindcss/cli": "^4.0.15",
- "@tailwindcss/typography": "^0.5.16",
- "@tanstack/react-query": "^5.69.0",
- "@trpc/client": "^11.0.0",
- "@trpc/next": "^11.0.0",
- "@trpc/react-query": "^11.0.0",
- "@trpc/server": "^11.0.0",
- "@types/pg": "^8.11.11",
- "@uiw/codemirror-theme-tokyo-night-storm": "^4.23.10",
- "@uiw/codemirror-themes": "^4.23.10",
- "@uiw/react-codemirror": "^4.23.10",
- "bcrypt": "^5.1.1",
- "date-fns": "^4.1.0",
- "dotenv": "^16.4.7",
- "drizzle-orm": "^0.41.0",
- "highlight.js": "^11.11.1",
- "next": "15.2.3",
- "next-auth": "^4.24.11",
- "pg": "^8.14.1",
- "react": "^19.0.0",
- "react-dom": "^19.0.0",
- "react-markdown": "^10.1.0",
- "rehype-highlight": "^7.0.2",
- "remark-breaks": "^4.0.0",
- "remark-directive": "^4.0.0",
- "remark-directive-rehype": "^0.4.2",
- "remark-emoji": "^5.0.1",
- "remark-gfm": "^4.0.1",
- "sonner": "^2.0.1",
- "trpc": "^0.10.4",
- "unist": "^0.0.1",
- "unist-util-visit": "^5.0.0",
- "zod": "^3.24.2"
+ "build": "turbo run build",
+ "dev": "turbo run dev",
+ "dev:web": "turbo watch dev --filter=web",
+ "dev:backend": "turbo watch dev --filter=backend",
+ "start": "turbo run start",
+ "start:web": "turbo run start --filter=web",
+ "lint": "turbo run lint",
+ "format": "prettier --write \"**/*.{js,jsx,ts,tsx,json,md,css}\"",
+ "check-types": "turbo run check-types",
+ "test": "turbo run test",
+ "test:watch": "turbo run test:watch",
+ "test:cov": "turbo run test:cov",
+ "test:debug": "turbo run test:debug",
+ "test:e2e": "turbo run test:e2e",
+ "gen:package": "turbo gen package",
+ "db:seed": "turbo run db:seed",
+ "db:setup": "turbo run db:setup"
},
"devDependencies": {
- "@eslint/eslintrc": "^3",
- "@radix-ui/react-slot": "^1.1.2",
- "@shadcn/ui": "^0.0.4",
- "@tailwindcss/postcss": "^4",
- "@types/bcrypt": "^5.0.2",
- "@types/node": "^20",
- "@types/react": "^19",
- "@types/react-dom": "^19",
- "@types/unist": "^3.0.3",
- "autoprefixer": "^10.4.21",
- "class-variance-authority": "^0.7.1",
- "clsx": "^2.1.1",
- "drizzle-kit": "^0.30.5",
- "env-cmd": "^10.1.0",
- "eslint": "^9",
- "eslint-config-next": "15.2.3",
- "lucide-react": "^0.483.0",
- "postcss": "^8.5.3",
- "tailwind-merge": "^3.0.2",
- "tailwindcss": "^4.0.15",
- "tsx": "^4.19.3",
- "typescript": "^5"
+ "@turbo/gen": "^2.5.0",
+ "prettier": "^3.5.3",
+ "prettier-plugin-tailwindcss": "^0.6.11",
+ "turbo": "^2.5.0",
+ "typescript": "5.8.2"
+ },
+ "packageManager": "pnpm@10.9.0",
+ "engines": {
+ "node": ">=18"
}
}
diff --git a/packages/auth/eslint.config.mjs b/packages/auth/eslint.config.mjs
new file mode 100644
index 0000000..9b56f93
--- /dev/null
+++ b/packages/auth/eslint.config.mjs
@@ -0,0 +1,4 @@
+import { config } from "@repo/eslint-config/base";
+
+/** @type {import("eslint").Linter.Config} */
+export default config;
diff --git a/packages/auth/package.json b/packages/auth/package.json
new file mode 100644
index 0000000..3f3934c
--- /dev/null
+++ b/packages/auth/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "@repo/auth",
+ "version": "0.0.0",
+ "type": "module",
+ "private": true,
+ "exports": {
+ ".": "./src/index.ts"
+ },
+ "types": "./src/index.ts",
+ "scripts": {
+ "lint": "eslint . --max-warnings 0",
+ "clean": "rm -rf .turbo node_modules dist"
+ },
+ "dependencies": {},
+ "devDependencies": {
+ "@repo/eslint-config": "workspace:*",
+ "@repo/typescript-config": "workspace:*",
+ "eslint": "^9.6.0",
+ "typescript": "^5.5.3"
+ }
+}
diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts
new file mode 100644
index 0000000..c5f1107
--- /dev/null
+++ b/packages/auth/src/index.ts
@@ -0,0 +1,2 @@
+// Export your package components here
+export const name = "auth";
diff --git a/packages/auth/tsconfig.json b/packages/auth/tsconfig.json
new file mode 100644
index 0000000..452c81c
--- /dev/null
+++ b/packages/auth/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "@repo/typescript-config/base.json",
+ "compilerOptions": {
+ "outDir": "dist"
+ },
+ "include": ["src/**/*.ts", "src/**/*.tsx"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/packages/db/drizzle.config.ts b/packages/db/drizzle.config.ts
new file mode 100644
index 0000000..f1167fb
--- /dev/null
+++ b/packages/db/drizzle.config.ts
@@ -0,0 +1,15 @@
+import "dotenv/config"; // Load .env file
+import { defineConfig } from "drizzle-kit";
+
+export default defineConfig({
+ schema: "./src/schema/index.ts",
+ out: "./drizzle",
+ dialect: "postgresql",
+ dbCredentials: {
+ url:
+ process.env.DATABASE_URL ||
+ "postgresql://user:password@localhost:5432/nextwiki",
+ },
+ verbose: true,
+ strict: true,
+});
diff --git a/packages/db/eslint.config.mjs b/packages/db/eslint.config.mjs
new file mode 100644
index 0000000..9b56f93
--- /dev/null
+++ b/packages/db/eslint.config.mjs
@@ -0,0 +1,4 @@
+import { config } from "@repo/eslint-config/base";
+
+/** @type {import("eslint").Linter.Config} */
+export default config;
diff --git a/packages/db/package.json b/packages/db/package.json
new file mode 100644
index 0000000..4f03cc8
--- /dev/null
+++ b/packages/db/package.json
@@ -0,0 +1,56 @@
+{
+ "name": "@repo/db",
+ "version": "0.0.0",
+ "type": "module",
+ "private": true,
+ "exports": {
+ ".": {
+ "import": "./dist/index.js",
+ "require": "./dist/index.cjs",
+ "types": "./dist/index.d.ts"
+ },
+ "./client": {
+ "import": "./dist/client.js",
+ "require": "./dist/client.cjs",
+ "types": "./dist/client.d.ts"
+ },
+ "./schema": "./dist/schema/index.js"
+ },
+ "main": "./dist/index.cjs",
+ "module": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "scripts": {
+ "lint": "eslint . --max-warnings 0",
+ "clean": "rm -rf .turbo node_modules dist",
+ "build": "tsup",
+ "dev": "tsup --watch",
+ "db:generate": "drizzle-kit generate",
+ "db:setup": "tsx scripts/setup-db.ts",
+ "db:migrate": "tsx src/migrate.ts",
+ "db:push": "drizzle-kit push",
+ "db:studio": "drizzle-kit studio",
+ "db:seed": "tsx src/seeds/run.ts",
+ "_db:seed:permissions": "tsx src/seeds/permissions.ts",
+ "_db:seed:custom": "tsx src/seeds/custom-seeds.ts"
+ },
+ "dependencies": {
+ "@neondatabase/serverless": "^1.0.0",
+ "bcryptjs": "^3.0.2",
+ "dotenv": "^16.5.0",
+ "drizzle-orm": "^0.42.0",
+ "gray-matter": "^4.0.3",
+ "pg": "^8.14.1",
+ "server-only": "^0.0.1"
+ },
+ "devDependencies": {
+ "@repo/eslint-config": "workspace:*",
+ "@repo/typescript-config": "workspace:*",
+ "@types/node": "^22.14.1",
+ "@types/pg": "^8.11.13",
+ "drizzle-kit": "^0.31.0",
+ "eslint": "^9.6.0",
+ "tsup": "^8.0.0",
+ "tsx": "^4.7.0",
+ "typescript": "^5.5.3"
+ }
+}
diff --git a/scripts/README.md b/packages/db/scripts/README.md
similarity index 76%
rename from scripts/README.md
rename to packages/db/scripts/README.md
index 110fc34..67032c8 100644
--- a/scripts/README.md
+++ b/packages/db/scripts/README.md
@@ -4,21 +4,22 @@ This directory contains utility scripts for setting up and managing your NextWik
## Database Setup Scripts
-### `setup-db.js` (Node.js)
+### `setup-db.ts` (TypeScript)
-A Node.js script that creates a PostgreSQL database in Docker and configures your `.env` file with the connection string.
+A TypeScript script that creates a PostgreSQL database in Docker and configures your `.env` file with the connection string.
```bash
# Run with npm script
-npm run db:setup
+pnpm run db:setup
# Or directly
-node scripts/setup-db.js
+tsx scripts/setup-db.ts
```
### `setup-db.sh` (Bash)
-A bash script that does the same as the Node.js version but for users who prefer shell scripts.
+A bash script that does the same as the TypeScript version but for users who prefer shell scripts.
+Note: This script as of now does not migrate or seed the database.
```bash
# Make sure it's executable first (only needed once)
@@ -57,4 +58,4 @@ After the script completes successfully, you should:
- **Stop the container**: `docker stop nextwiki-postgres`
- **Start the container**: `docker start nextwiki-postgres`
- **Remove the container**: `docker rm nextwiki-postgres` (only do this if you want to reset everything)
-- **View container logs**: `docker logs nextwiki-postgres`
\ No newline at end of file
+- **View container logs**: `docker logs nextwiki-postgres`
diff --git a/scripts/setup-db.sh b/packages/db/scripts/setup-db.sh
similarity index 100%
rename from scripts/setup-db.sh
rename to packages/db/scripts/setup-db.sh
diff --git a/packages/db/scripts/setup-db.ts b/packages/db/scripts/setup-db.ts
new file mode 100644
index 0000000..8205aab
--- /dev/null
+++ b/packages/db/scripts/setup-db.ts
@@ -0,0 +1,342 @@
+#!/usr/bin/env node
+
+import { execSync } from "child_process";
+import * as fs from "fs";
+import * as path from "path";
+import pg from "pg";
+import * as url from "url";
+
+// Colors for terminal output
+const colors = {
+ green: "\x1b[32m",
+ blue: "\x1b[34m",
+ red: "\x1b[31m",
+ reset: "\x1b[0m",
+};
+
+// Docker container configuration
+const config = {
+ containerName: "nextwiki-postgres",
+ dbUser: "nextwiki",
+ dbPassword: "nextwiki_password",
+ dbName: "nextwiki",
+ dbPort: "5432",
+ hostPort: "5432", // Use the same port for host and container for simplicity
+};
+
+// Utility to print colored messages
+const print = {
+ info: (message: string) =>
+ console.log(`${colors.blue}${message}${colors.reset}`),
+ success: (message: string) =>
+ console.log(`${colors.green}${message}${colors.reset}`),
+ error: (message: string) =>
+ console.log(`${colors.red}${message}${colors.reset}`),
+ normal: (message: string) => console.log(message),
+};
+
+// Utility to run shell commands
+function runCommand(
+ command: string,
+ ignoreError = false,
+ showOutput = true
+): string | null {
+ try {
+ print.normal(`Executing: ${command}`);
+ const output = execSync(command, { encoding: "utf8", stdio: "pipe" }); // Use inherit to show logs in console
+ if (showOutput) {
+ print.normal(output);
+ }
+ return output;
+ } catch (error: unknown) {
+ print.error(`Command failed: ${command}`);
+ // Stderr/Stdout are displayed directly with "inherit", no need to print here
+ // if (error instanceof Error && "stderr" in error) {
+ // print.error(`Stderr: ${error.stderr}`);
+ // }
+ // if (error instanceof Error && "stdout" in error) {
+ // print.error(`Stdout: ${error.stdout}`); // Log stdout on error too
+ // }
+ if (!ignoreError) {
+ throw error;
+ }
+ return null;
+ }
+}
+
+// Check if a command exists in the PATH
+function commandExists(command: string): boolean {
+ try {
+ // Use 'command -v' which is more portable than 'which'
+ execSync(`command -v ${command}`, { stdio: "ignore" });
+ return true;
+ } catch (error) {
+ void error;
+ return false;
+ }
+}
+
+// Check if Docker container exists
+function containerExists(name: string): boolean {
+ try {
+ // Use execSync directly to capture output
+ const output = execSync('docker ps -a --format "{{.Names}}"', {
+ encoding: "utf8",
+ stdio: "pipe",
+ });
+ return !!output && output.split("\n").includes(name);
+ } catch (error) {
+ void error;
+ // If docker command fails (e.g., docker not running), treat as container not existing
+ print.error("Error checking if container exists. Is Docker running?");
+ return false;
+ }
+}
+
+// Check if Docker container is running
+function containerRunning(name: string): boolean {
+ try {
+ // Use execSync directly to capture output
+ const output = execSync('docker ps --format "{{.Names}}"', {
+ encoding: "utf8",
+ stdio: "pipe",
+ });
+ return !!output && output.split("\n").includes(name);
+ } catch (error) {
+ void error;
+ // If docker command fails, treat as container not running
+ print.error("Error checking if container is running. Is Docker running?");
+ return false;
+ }
+}
+
+// Update .env file with database connection string
+function updateEnvFile(connectionString: string): void {
+ // ESM-compatible way to get directory name
+ const __filename = url.fileURLToPath(import.meta.url);
+ const __dirname = path.dirname(__filename);
+
+ const rootDir = path.resolve(__dirname, "..");
+ const envPath = path.join(rootDir, ".env");
+ const envExamplePath = path.join(rootDir, ".env.example");
+ const dbUrlKey = "DATABASE_URL";
+ const dbUrlLine = `${dbUrlKey}=${connectionString}`;
+
+ print.normal(` Resolved root directory: ${rootDir}`);
+ print.normal(` Target .env path: ${envPath}`);
+
+ print.normal(`Ensuring ${dbUrlKey} is set in ${envPath}`);
+
+ try {
+ if (fs.existsSync(envPath)) {
+ print.normal(` Reading existing ${envPath}`);
+ let envContent = fs.readFileSync(envPath, "utf8");
+ const dbUrlRegex = new RegExp(`^${dbUrlKey}=.*`, "m");
+
+ if (dbUrlRegex.test(envContent)) {
+ envContent = envContent.replace(dbUrlRegex, dbUrlLine);
+ print.normal(` Updating existing ${dbUrlKey} in ${envPath}`);
+ } else {
+ envContent += `\n${dbUrlLine}\n`;
+ print.normal(` Adding ${dbUrlKey} to ${envPath}`);
+ }
+ fs.writeFileSync(envPath, envContent);
+ print.normal(` Successfully wrote changes to ${envPath}`);
+ } else {
+ print.normal(` ${envPath} not found. Checking for example file...`);
+ let envContent = "";
+ if (fs.existsSync(envExamplePath)) {
+ print.normal(` Reading example file ${envExamplePath}`);
+ envContent = fs.readFileSync(envExamplePath, "utf8");
+ const dbUrlRegex = new RegExp(`^${dbUrlKey}=.*`, "m");
+ if (dbUrlRegex.test(envContent)) {
+ envContent = envContent.replace(dbUrlRegex, dbUrlLine);
+ print.normal(` Used ${envExamplePath} as template, updated key.`);
+ } else {
+ envContent += `\n${dbUrlLine}\n`;
+ print.normal(` Used ${envExamplePath} as template, added key.`);
+ }
+ } else {
+ // Create minimal .env file
+ print.normal(` No example file found. Creating minimal ${envPath}...`);
+ envContent = `${dbUrlLine}\n`;
+ }
+ fs.writeFileSync(envPath, envContent);
+ print.normal(` Successfully created/wrote ${envPath}`);
+ }
+ } catch (error: unknown) {
+ print.error(` ❌ Error occurred within updateEnvFile function:`);
+ if (error instanceof Error) {
+ print.error(` Message: ${error.message}`);
+ print.error(` Stack: ${error.stack}`);
+ } else {
+ print.error(` Unknown error: ${String(error)}`);
+ }
+ throw error; // Re-throw the error to stop the script
+ }
+}
+
+// Function to wait for DB connection
+async function waitForDB(
+ connectionString: string,
+ retries = 15,
+ delay = 3000
+): Promise {
+ print.info(`Attempting to connect to database... (up to ${retries} retries)`);
+ for (let i = 0; i < retries; i++) {
+ try {
+ const pool = new pg.Pool({ connectionString });
+ await pool.query("SELECT 1");
+ await pool.end();
+ print.success("Database connection successful!");
+ return;
+ } catch (error: unknown) {
+ const errorMessage =
+ error instanceof Error ? error.message : String(error);
+ print.error(`DB connection attempt ${i + 1} failed: ${errorMessage}`);
+ print.normal(`Retrying in ${delay / 1000}s...`);
+ if (i === retries - 1) {
+ print.error("Database connection failed after multiple retries.");
+ if (error instanceof Error) {
+ print.error(`Stack Trace: ${error.stack}`);
+ }
+ throw error;
+ }
+ await new Promise((resolve) => setTimeout(resolve, delay));
+ }
+ }
+}
+
+// Main function
+async function main(): Promise {
+ print.info("🚀 NextWiki Docker Database Setup 🚀");
+ print.normal("This script will:");
+ print.normal("1. Check for Docker and required commands (pnpm).");
+ print.normal("2. Ensure a PostgreSQL container is running.");
+ print.normal("3. Update your .env file with the connection string.");
+ print.normal("4. Wait for the database to be ready.");
+ print.normal("5. Generate Drizzle migrations.");
+ print.normal("6. Apply Drizzle migrations.");
+ print.normal("7. Seed the database.");
+ print.normal("");
+
+ // --- Prerequisites Check ---
+ if (!commandExists("docker")) {
+ print.error("❌ Error: Docker is not installed or not in PATH.");
+ print.normal("Please install Docker: https://docs.docker.com/get-docker/");
+ process.exit(1);
+ }
+ if (!commandExists("pnpm")) {
+ print.error("❌ Error: pnpm is not installed or not in PATH.");
+ print.normal("Please install pnpm: https://pnpm.io/installation");
+ process.exit(1);
+ }
+
+ try {
+ runCommand("docker info", true); // Check if Docker daemon is running
+ } catch (error) {
+ void error;
+ print.error("❌ Error: Docker daemon is not running.");
+ print.normal("Please start Docker and try again.");
+ process.exit(1);
+ }
+ print.success("✅ Prerequisites met (Docker, pnpm).");
+
+ // --- Docker Container Setup ---
+ if (containerExists(config.containerName)) {
+ print.info(`➡️ Container "${config.containerName}" already exists.`);
+ if (!containerRunning(config.containerName)) {
+ print.normal("Starting existing container...");
+ runCommand(`docker start ${config.containerName}`);
+ print.success("Container started.");
+ } else {
+ print.normal("Container is already running.");
+ }
+ } else {
+ print.info(
+ `✨ Creating new PostgreSQL container "${config.containerName}"...`
+ );
+ const dockerCommand = `docker run --name ${config.containerName} \
+ -e POSTGRES_USER=${config.dbUser} \
+ -e POSTGRES_PASSWORD=${config.dbPassword} \
+ -e POSTGRES_DB=${config.dbName} \
+ -p ${config.hostPort}:${config.dbPort} \
+ --health-cmd="pg_isready -U ${config.dbUser} -d ${config.dbName}" \
+ --health-interval=5s \
+ --health-timeout=5s \
+ --health-retries=5 \
+ -d postgres:17`; // Using postgres:17, consider locking version or making configurable
+ runCommand(dockerCommand);
+
+ // Wait briefly for container to initialize before checking health/connection
+ print.normal("Waiting a few seconds for container to initialize...");
+ await new Promise((resolve) => setTimeout(resolve, 5000));
+
+ if (!containerRunning(config.containerName)) {
+ print.error("❌ Container failed to start properly.");
+ print.normal(`Check Docker logs: docker logs ${config.containerName}`);
+ process.exit(1);
+ }
+ print.success("✅ PostgreSQL container created and running.");
+ }
+
+ // --- Environment Setup ---
+ const connectionString = `postgresql://${config.dbUser}:${config.dbPassword}@localhost:${config.hostPort}/${config.dbName}`;
+ print.info("🔄 Updating .env file...");
+ updateEnvFile(connectionString);
+ print.success("✅ .env file update attempt finished.");
+ print.normal(
+ `Using connection string: ${connectionString.replace(
+ config.dbPassword,
+ "****"
+ )}`
+ ); // Hide password in log
+
+ // --- Wait for DB ---
+ print.info("⏳ Waiting for database connection...");
+ await waitForDB(connectionString);
+ print.success("✅ Database connection established.");
+
+ // --- Database Schema and Data ---
+ try {
+ print.info("🔄 Generating database migrations...");
+ runCommand("pnpm db:generate");
+ print.success("✅ Migrations generated successfully.");
+
+ print.info("🔄 Applying database migrations...");
+ runCommand("pnpm db:migrate");
+ print.success("✅ Migrations applied successfully.");
+
+ print.info("🌱 Seeding database...");
+ runCommand("pnpm db:seed");
+ print.success("✅ Database seeded successfully.");
+ } catch (error) {
+ void error;
+ print.error("❌ An error occurred during migration or seeding.");
+ // Error details are already printed by runCommand
+ process.exit(1);
+ }
+
+ // --- Completion ---
+ print.success("🎉 All setup steps completed successfully! 🎉");
+ print.normal("");
+ print.info("Next steps:");
+ print.normal(" Start the application: pnpm dev");
+ print.normal("");
+ print.info("Container management:");
+ print.normal(` Stop the database: docker stop ${config.containerName}`);
+ print.normal(` Start the database: docker start ${config.containerName}`);
+ print.normal(` View logs: docker logs ${config.containerName}`);
+ print.normal(` Remove the container: docker rm -f ${config.containerName}`);
+ print.normal(
+ ` Remove container and data: docker rm -f -v ${config.containerName}`
+ );
+}
+
+// Run the main function
+main().catch((error) => {
+ void error;
+ // Error details should have been printed already by internal functions
+ print.error("❌ Setup script failed.");
+ process.exit(1);
+});
diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts
new file mode 100644
index 0000000..7daa824
--- /dev/null
+++ b/packages/db/src/client.ts
@@ -0,0 +1,23 @@
+// Client-safe exports for the @repo/db package
+
+// Re-export all types from the schema index
+// These are stripped by the build process and safe for client import
+export type * from "./schema/index.js";
+
+// Re-export types from the registry
+export type {
+ Permission,
+ PermissionModule,
+ PermissionAction,
+ PermissionResource,
+ PermissionIdentifier,
+} from "./registry/types.js";
+
+// Re-export specific functions from the registry that are client-safe
+export {
+ validatePermissionId,
+ getAllPermissionIds,
+ getAllPermissions,
+} from "./registry/permissions.js";
+
+// DO NOT export the db instance, seed function, or server-only functions here.
diff --git a/src/lib/db/index.ts b/packages/db/src/index.ts
similarity index 77%
rename from src/lib/db/index.ts
rename to packages/db/src/index.ts
index 81df906..cc21ff9 100644
--- a/src/lib/db/index.ts
+++ b/packages/db/src/index.ts
@@ -1,10 +1,16 @@
+import "server-only";
+
+import * as dotenv from "dotenv";
import { neon, neonConfig } from "@neondatabase/serverless";
import { drizzle as drizzleNeon } from "drizzle-orm/neon-http";
import { NeonHttpDatabase } from "drizzle-orm/neon-http";
import { drizzle as drizzlePg } from "drizzle-orm/node-postgres";
import { NodePgDatabase } from "drizzle-orm/node-postgres";
-import { Pool } from "pg";
-import * as schema from "./schema";
+import pg from "pg";
+import * as schema from "./schema/index.js";
+
+// Load environment variables from .env file
+dotenv.config();
// Database connection string - should be in environment variables in a real app
const connectionString = process.env.DATABASE_URL;
@@ -16,13 +22,6 @@ if (!connectionString) {
throw new Error("Please set DATABASE_URL in your .env file");
}
-// Log connection string without credentials for debugging
-// const sanitizedConnectionString = connectionString.replace(
-// /postgresql:\/\/([^:]+):([^@]+)@/,
-// "postgresql://$1:****@"
-// );
-// console.log('Using connection string:', sanitizedConnectionString);
-
// Define a union type for both possible database types
export type DatabaseType =
| NeonHttpDatabase
@@ -45,7 +44,7 @@ if (
console.log("Using Neon database driver");
} else {
// Use regular PostgreSQL driver for local or other PostgreSQL databases
- const pool = new Pool({ connectionString });
+ const pool = new pg.Pool({ connectionString });
// Create Drizzle client with regular PostgreSQL driver
db = drizzlePg(pool, { schema });
// console.debug("Using standard PostgreSQL driver");
@@ -54,5 +53,14 @@ if (
// Export the configured db
export { db };
+// Re-export the seed function
+export { seed } from "./seeds/run.js";
+
// Re-export the schema
-export * from "./schema";
+export * from "./schema/index.js";
+
+// Re-export the registry
+export * from "./registry/index.js";
+
+// Re-export the utils
+export { runRawSqlMigration } from "./utils.js";
diff --git a/packages/db/src/migrate.ts b/packages/db/src/migrate.ts
new file mode 100644
index 0000000..53838a4
--- /dev/null
+++ b/packages/db/src/migrate.ts
@@ -0,0 +1,153 @@
+// Import dotenv first and configure it
+import dotenv from "dotenv";
+import path from "path";
+import fs from "fs";
+
+// Get project root directory
+const projectRoot = process.cwd();
+const envPath = path.join(projectRoot, ".env");
+
+// Check if .env file exists and log its path
+console.log("Looking for .env file at:", envPath);
+console.log(".env file exists:", fs.existsSync(envPath));
+
+// Explicitly load .env file with absolute path
+const result = dotenv.config({ path: envPath });
+
+// Log dotenv result
+if (result.error) {
+ console.error("Error loading .env file:", result.error);
+} else {
+ console.log(".env file loaded successfully");
+ console.log(
+ "DATABASE_URL from process.env:",
+ process.env.DATABASE_URL ? "Found" : "Not found"
+ );
+}
+
+// Then import other dependencies
+import { migrate as migrateNeon } from "drizzle-orm/neon-http/migrator";
+import { migrate as migratePg } from "drizzle-orm/node-postgres/migrator";
+import { NeonHttpDatabase } from "drizzle-orm/neon-http";
+import { NodePgDatabase } from "drizzle-orm/node-postgres";
+import * as schema from "./schema/index.js";
+import { neon } from "@neondatabase/serverless";
+import { drizzle as drizzleNeon } from "drizzle-orm/neon-http";
+import { drizzle as drizzlePg } from "drizzle-orm/node-postgres";
+import pg from "pg";
+
+// Create database connection locally instead of importing from index.ts
+const getDb = () => {
+ const connectionString = process.env.DATABASE_URL;
+ if (!connectionString) {
+ throw new Error(
+ "DATABASE_URL environment variable is still not set after loading .env!"
+ );
+ }
+
+ console.log("Connection string found in process.env");
+
+ // Create database client based on connection string
+ if (
+ connectionString.includes("pooler.internal.neon") ||
+ connectionString.includes(".neon.tech")
+ ) {
+ const sql = neon(connectionString);
+ return drizzleNeon(sql, { schema });
+ } else {
+ const pool = new pg.Pool({ connectionString });
+ return drizzlePg(pool, { schema });
+ }
+};
+
+// This is the migration function that will run all pending migrations
+async function runMigrations(force = false) {
+ try {
+ console.log("Running migrations...");
+
+ // Get database connection
+ const db = getDb();
+
+ // Get connection string for detection
+ const connectionString = process.env.DATABASE_URL || "";
+ console.log(
+ "Migration script using connection string (sanitized):",
+ connectionString.replace(
+ /postgresql:\/\/([^:]+):([^@]+)@/,
+ "postgresql://$1:****@"
+ )
+ );
+
+ let migrationsResult; // To store the result of migrate()
+
+ try {
+ if (
+ connectionString.includes("pooler.internal.neon") ||
+ connectionString.includes(".neon.tech")
+ ) {
+ console.log("Using Neon migrator...");
+ migrationsResult = await migrateNeon(
+ db as NeonHttpDatabase,
+ {
+ migrationsFolder: "drizzle",
+ }
+ );
+ console.log("Migrations completed successfully using Neon driver");
+ } else {
+ console.log("Using PostgreSQL migrator...");
+ migrationsResult = await migratePg(
+ db as NodePgDatabase,
+ {
+ migrationsFolder: "drizzle",
+ }
+ );
+ console.log(
+ "Migrations completed successfully using PostgreSQL driver"
+ );
+ }
+
+ console.log("Migration result:", migrationsResult);
+ } catch (error: unknown) {
+ const migrationError = error as { message?: string };
+
+ if (
+ migrationError.message &&
+ migrationError.message.includes("already exists")
+ ) {
+ console.warn(
+ "Some relations already exist. This likely means migrations were already applied."
+ );
+ console.warn("Original error:", migrationError.message);
+
+ if (force) {
+ console.log(
+ "Force flag is set, attempting to continue with other migrations..."
+ );
+ } else {
+ console.log(
+ "Use the --force flag to attempt running migrations anyway."
+ );
+ console.log(
+ "Or manually check your database schema to ensure it matches your migrations."
+ );
+ }
+ } else {
+ throw error;
+ }
+ }
+
+ process.exit(0);
+ } catch (error) {
+ console.error("Migration failed:", error);
+ console.error("Error details:", error);
+ process.exit(1);
+ }
+}
+
+// Run migrations if file is executed directly
+if (process.argv[1] && import.meta.url === `file://${process.argv[1]}`) {
+ const forceFlag = process.argv.includes("--force");
+ runMigrations(forceFlag);
+}
+
+export default runMigrations;
diff --git a/src/lib/db/migrations/setup-trigram-search.sql b/packages/db/src/migrations/setup-trigram-search.sql
similarity index 100%
rename from src/lib/db/migrations/setup-trigram-search.sql
rename to packages/db/src/migrations/setup-trigram-search.sql
diff --git a/packages/db/src/registry/index.ts b/packages/db/src/registry/index.ts
new file mode 100644
index 0000000..4230340
--- /dev/null
+++ b/packages/db/src/registry/index.ts
@@ -0,0 +1,2 @@
+export * from "./permissions.js";
+export * from "./types.js";
diff --git a/packages/db/src/registry/permissions.ts b/packages/db/src/registry/permissions.ts
new file mode 100644
index 0000000..ac87a4f
--- /dev/null
+++ b/packages/db/src/registry/permissions.ts
@@ -0,0 +1,221 @@
+import {
+ Permission,
+ PermissionIdentifier,
+ PermissionModule,
+ PermissionAction,
+ PermissionResource,
+} from "./types.js"; // Added .js extension
+
+/**
+ * Define all system permissions
+ */
+const PERMISSION_LIST: Permission[] = [
+ // Wiki module permissions
+ {
+ module: "wiki",
+ resource: "page",
+ action: "create",
+ description: "Create new wiki pages",
+ },
+ {
+ module: "wiki",
+ resource: "page",
+ action: "read",
+ description: "Read wiki pages",
+ },
+ {
+ module: "wiki",
+ resource: "page",
+ action: "update",
+ description: "Update wiki pages",
+ },
+ {
+ module: "wiki",
+ resource: "page",
+ action: "delete",
+ description: "Delete wiki pages",
+ },
+ {
+ module: "wiki",
+ resource: "page",
+ action: "move",
+ description: "Move or rename wiki pages",
+ },
+
+ // System module permissions
+ {
+ module: "system",
+ resource: "settings",
+ action: "read",
+ description: "View system settings",
+ },
+ {
+ module: "system",
+ resource: "settings",
+ action: "update",
+ description: "Update system settings",
+ },
+ {
+ module: "system",
+ resource: "permissions",
+ action: "read",
+ description: "View system permissions",
+ },
+ {
+ module: "system",
+ resource: "permissions",
+ action: "update",
+ description: "Update system permissions",
+ },
+ {
+ module: "system",
+ resource: "users",
+ action: "read",
+ description: "View user list",
+ },
+ {
+ module: "system",
+ resource: "users",
+ action: "create",
+ description: "Create new users",
+ },
+ {
+ module: "system",
+ resource: "users",
+ action: "update",
+ description: "Update existing users",
+ },
+ {
+ module: "system",
+ resource: "users",
+ action: "delete",
+ description: "Delete users",
+ },
+ {
+ module: "system",
+ resource: "groups",
+ action: "read",
+ description: "View group list",
+ },
+ {
+ module: "system",
+ resource: "groups",
+ action: "create",
+ description: "Create new groups",
+ },
+ {
+ module: "system",
+ resource: "groups",
+ action: "update",
+ description: "Update existing groups",
+ },
+ {
+ module: "system",
+ resource: "groups",
+ action: "delete",
+ description: "Delete groups",
+ },
+
+ // Assets module permissions
+ {
+ module: "assets",
+ resource: "asset",
+ action: "create",
+ description: "Upload assets (images, files, etc.)",
+ },
+ {
+ module: "assets",
+ resource: "asset",
+ action: "read",
+ description: "View assets",
+ },
+ {
+ module: "assets",
+ resource: "asset",
+ action: "update",
+ description: "Update assets",
+ },
+ {
+ module: "assets",
+ resource: "asset",
+ action: "delete",
+ description: "Delete assets",
+ },
+];
+
+/**
+ * Creates a permission identifier from a permission object
+ */
+export function createPermissionId(
+ permission: Permission
+): PermissionIdentifier {
+ return `${permission.module}:${permission.resource}:${permission.action}` as PermissionIdentifier;
+}
+
+/**
+ * Build the permissions record from the list
+ */
+export const PERMISSIONS = PERMISSION_LIST.reduce<
+ Record
+>(
+ (acc, permission) => {
+ const id = createPermissionId(permission);
+ acc[id] = permission;
+ return acc;
+ },
+ {} as Record
+);
+
+/**
+ * Validates if a string is a valid permission identifier
+ */
+export function validatePermissionId(id: string): id is PermissionIdentifier {
+ return id in PERMISSIONS;
+}
+
+/**
+ * Gets all permissions from the registry
+ */
+export function getAllPermissions(): Permission[] {
+ return PERMISSION_LIST;
+}
+
+/**
+ * Gets all permission identifiers
+ */
+export function getAllPermissionIds(): PermissionIdentifier[] {
+ return Object.keys(PERMISSIONS) as PermissionIdentifier[];
+}
+
+/**
+ * Gets all available permission modules
+ */
+export function getAvailableModules(): PermissionModule[] {
+ const modules = new Set();
+ getAllPermissions().forEach((permission) => {
+ modules.add(permission.module);
+ });
+ return Array.from(modules);
+}
+
+/**
+ * Gets all available permission actions
+ */
+export function getAvailableActions(): PermissionAction[] {
+ const actions = new Set();
+ getAllPermissions().forEach((permission) => {
+ actions.add(permission.action);
+ });
+ return Array.from(actions);
+}
+
+/**
+ * Gets all available permission resources
+ */
+export function getAvailableResources(): PermissionResource[] {
+ const resources = new Set();
+ getAllPermissions().forEach((permission) => {
+ resources.add(permission.resource);
+ });
+ return Array.from(resources);
+}
diff --git a/packages/db/src/registry/types.ts b/packages/db/src/registry/types.ts
new file mode 100644
index 0000000..8ee1ce8
--- /dev/null
+++ b/packages/db/src/registry/types.ts
@@ -0,0 +1,40 @@
+/**
+ * Permission system types
+ */
+
+/**
+ * Valid permission modules in the system
+ */
+export type PermissionModule = "wiki" | "system" | "assets";
+
+/**
+ * Valid permission actions in the system
+ */
+export type PermissionAction = "create" | "read" | "update" | "delete" | "move";
+
+/**
+ * Valid permission resources in the system
+ */
+export type PermissionResource =
+ | "page"
+ | "settings"
+ | "permissions"
+ | "users"
+ | "groups"
+ | "asset";
+
+/**
+ * Unified permission structure
+ */
+export interface Permission {
+ module: PermissionModule;
+ resource: PermissionResource;
+ action: PermissionAction;
+ description: string;
+}
+
+/**
+ * Type for the permission identifier string in format module:resource:action
+ */
+export type PermissionIdentifier =
+ `${PermissionModule}:${PermissionResource}:${PermissionAction}`;
diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts
new file mode 100644
index 0000000..ce789dd
--- /dev/null
+++ b/packages/db/src/schema/index.ts
@@ -0,0 +1,508 @@
+import {
+ pgTable,
+ serial,
+ text,
+ timestamp,
+ varchar,
+ boolean,
+ integer,
+ primaryKey,
+ customType,
+ index,
+ pgEnum,
+ uuid,
+} from "drizzle-orm/pg-core";
+import { relations, SQL, sql } from "drizzle-orm";
+
+// Define custom PostgreSQL extension for trigrams
+export const pgExtensions = sql`
+ CREATE EXTENSION IF NOT EXISTS pg_trgm;
+`;
+
+export const tsvector = customType<{
+ data: string;
+}>({
+ dataType() {
+ return `tsvector`;
+ },
+});
+
+// Users table
+export const users = pgTable(
+ "users",
+ {
+ id: serial("id").primaryKey(),
+ name: varchar("name", { length: 255 }),
+ email: varchar("email", { length: 255 }).notNull().unique(),
+ password: varchar("password", { length: 255 }),
+ emailVerified: timestamp("email_verified"),
+ image: text("image"),
+ createdAt: timestamp("created_at").defaultNow(),
+ updatedAt: timestamp("updated_at").defaultNow(),
+ },
+ (t) => [index("email_idx").on(t.email)]
+);
+
+// Permissions table - defines available permissions in the system
+export const permissions = pgTable("permissions", {
+ id: serial("id").primaryKey(),
+ module: varchar("module", { length: 50 }).notNull(), // e.g., 'wiki', 'system', 'assets'
+ resource: varchar("resource", { length: 50 }).notNull(), // e.g., 'page', 'asset', 'user'
+ action: varchar("action", { length: 50 }).notNull(), // e.g., 'create', 'read', 'update', 'delete'
+ name: varchar("name", { length: 100 })
+ .notNull()
+ .generatedAlwaysAs(
+ (): SQL => sql`"module" || ':' || "resource" || ':' || "action"`
+ )
+ .unique(),
+ description: text("description"),
+ createdAt: timestamp("created_at").defaultNow(),
+});
+
+// Groups table - custom user groups
+export const groups = pgTable("groups", {
+ id: serial("id").primaryKey(),
+ name: varchar("name", { length: 100 }).notNull().unique(),
+ description: text("description"),
+ createdAt: timestamp("created_at").defaultNow(),
+ updatedAt: timestamp("updated_at").defaultNow(),
+ isSystem: boolean("is_system").default(false), // Is this a system group that cannot be deleted
+ isEditable: boolean("is_editable").default(true), // Can the group's permissions be modified
+ allowUserAssignment: boolean("allow_user_assignment").default(true), // Can users be assigned to this group
+});
+
+// User to groups many-to-many relationship
+export const userGroups = pgTable(
+ "user_groups",
+ {
+ userId: integer("user_id")
+ .references(() => users.id)
+ .notNull(),
+ groupId: integer("group_id")
+ .references(() => groups.id)
+ .notNull(),
+ createdAt: timestamp("created_at").defaultNow(),
+ },
+ (t) => [
+ primaryKey({ columns: [t.userId, t.groupId] }),
+ index("user_group_idx").on(t.userId, t.groupId),
+ ]
+);
+
+// Group permissions - linking groups to permissions
+export const groupPermissions = pgTable(
+ "group_permissions",
+ {
+ groupId: integer("group_id")
+ .references(() => groups.id)
+ .notNull(),
+ permissionId: integer("permission_id")
+ .references(() => permissions.id)
+ .notNull(),
+ createdAt: timestamp("created_at").defaultNow(),
+ },
+ (t) => [
+ primaryKey({ columns: [t.groupId, t.permissionId] }),
+ index("group_permission_idx").on(t.groupId, t.permissionId),
+ ]
+);
+
+// Group module permissions - defines which modules a group can access
+export const groupModulePermissions = pgTable(
+ "group_module_permissions",
+ {
+ groupId: integer("group_id")
+ .references(() => groups.id)
+ .notNull(),
+ module: varchar("module", { length: 50 }).notNull(),
+ createdAt: timestamp("created_at").defaultNow(),
+ },
+ (t) => [
+ primaryKey({ columns: [t.groupId, t.module] }),
+ index("group_module_permissions_idx").on(t.groupId, t.module),
+ ]
+);
+
+// Group action permissions - defines which actions a group can perform
+export const groupActionPermissions = pgTable(
+ "group_action_permissions",
+ {
+ groupId: integer("group_id")
+ .references(() => groups.id)
+ .notNull(),
+ action: varchar("action", { length: 50 }).notNull(),
+ createdAt: timestamp("created_at").defaultNow(),
+ },
+ (t) => [
+ primaryKey({ columns: [t.groupId, t.action] }),
+ index("group_action_permissions_idx").on(t.groupId, t.action),
+ ]
+);
+
+// Page-specific permissions (overrides group permissions for specific pages)
+export const pagePermissions = pgTable(
+ "page_permissions",
+ {
+ id: serial("id").primaryKey(),
+ pageId: integer("page_id")
+ .references(() => wikiPages.id)
+ .notNull(),
+ groupId: integer("group_id").references(() => groups.id),
+ permissionId: integer("permission_id")
+ .references(() => permissions.id)
+ .notNull(),
+ permissionType: varchar("permission_type", { length: 10 })
+ .notNull()
+ .default("allow"), // 'allow' or 'deny'
+ createdAt: timestamp("created_at").defaultNow(),
+ },
+ (t) => [
+ // Create a unique constraint to prevent duplicates
+ index("page_group_perm_idx").on(t.pageId, t.permissionId, t.groupId),
+ ]
+);
+
+// User relations
+export const usersRelations = relations(users, ({ many }) => ({
+ accounts: many(accounts),
+ sessions: many(sessions),
+ createdWikiPages: many(wikiPages, {
+ relationName: "createdBy",
+ }),
+ updatedWikiPages: many(wikiPages, {
+ relationName: "updatedBy",
+ }),
+ userGroups: many(userGroups),
+}));
+
+// Group relations
+export const groupsRelations = relations(groups, ({ many }) => ({
+ userGroups: many(userGroups),
+ groupPermissions: many(groupPermissions),
+ pagePermissions: many(pagePermissions),
+ groupModulePermissions: many(groupModulePermissions),
+ groupActionPermissions: many(groupActionPermissions),
+}));
+
+// Permission relations
+export const permissionsRelations = relations(permissions, ({ many }) => ({
+ groupPermissions: many(groupPermissions),
+ pagePermissions: many(pagePermissions),
+}));
+
+// User to groups relations
+export const userGroupsRelations = relations(userGroups, ({ one }) => ({
+ user: one(users, {
+ fields: [userGroups.userId],
+ references: [users.id],
+ }),
+ group: one(groups, {
+ fields: [userGroups.groupId],
+ references: [groups.id],
+ }),
+}));
+
+// Group permissions relations
+export const groupPermissionsRelations = relations(
+ groupPermissions,
+ ({ one }) => ({
+ group: one(groups, {
+ fields: [groupPermissions.groupId],
+ references: [groups.id],
+ }),
+ permission: one(permissions, {
+ fields: [groupPermissions.permissionId],
+ references: [permissions.id],
+ }),
+ })
+);
+
+// Page permissions relations
+export const pagePermissionsRelations = relations(
+ pagePermissions,
+ ({ one }) => ({
+ page: one(wikiPages, {
+ fields: [pagePermissions.pageId],
+ references: [wikiPages.id],
+ }),
+ group: one(groups, {
+ fields: [pagePermissions.groupId],
+ references: [groups.id],
+ }),
+ permission: one(permissions, {
+ fields: [pagePermissions.permissionId],
+ references: [permissions.id],
+ }),
+ })
+);
+
+export const groupModulePermissionsRelations = relations(
+ groupModulePermissions,
+ ({ one }) => ({
+ group: one(groups, {
+ fields: [groupModulePermissions.groupId],
+ references: [groups.id],
+ }),
+ })
+);
+
+export const groupActionPermissionsRelations = relations(
+ groupActionPermissions,
+ ({ one }) => ({
+ group: one(groups, {
+ fields: [groupActionPermissions.groupId],
+ references: [groups.id],
+ }),
+ })
+);
+
+export const wikiPageEditorTypeEnum = pgEnum("editor_type", [
+ "markdown",
+ "html",
+]);
+
+// Pages table
+export const wikiPages = pgTable(
+ "wiki_pages",
+ {
+ id: serial("id").primaryKey(),
+ path: varchar("path", { length: 1000 }).notNull().unique(),
+ title: varchar("title", { length: 255 }).notNull(),
+ content: text("content"),
+ renderedHtml: text("rendered_html"),
+ editorType: wikiPageEditorTypeEnum("editor_type"),
+ isPublished: boolean("is_published").default(false),
+ createdById: integer("created_by_id")
+ .notNull()
+ .references(() => users.id),
+ createdAt: timestamp("created_at").defaultNow(),
+ updatedById: integer("updated_by_id")
+ .notNull()
+ .references(() => users.id),
+ updatedAt: timestamp("updated_at").defaultNow(),
+ renderedHtmlUpdatedAt: timestamp("rendered_html_updated_at"),
+ lockedById: integer("locked_by_id").references(() => users.id),
+ lockedAt: timestamp("locked_at"),
+ lockExpiresAt: timestamp("lock_expires_at"),
+ search: tsvector("search")
+ .notNull()
+ .generatedAlwaysAs(
+ (): SQL =>
+ sql`setweight(to_tsvector('english', ${wikiPages.title}), 'A')
+ ||
+ setweight(to_tsvector('english', ${wikiPages.content}), 'B')`
+ ),
+ },
+ (t) => [
+ // Vector search index for tsvector column
+ index("idx_search").using("gin", t.search),
+ // Title trigram index
+ index("trgm_idx_title").on(t.title),
+ ]
+);
+
+// Page relations
+export const wikiPagesRelations = relations(wikiPages, ({ one, many }) => ({
+ createdBy: one(users, {
+ fields: [wikiPages.createdById],
+ references: [users.id],
+ relationName: "createdBy",
+ }),
+ updatedBy: one(users, {
+ fields: [wikiPages.updatedById],
+ references: [users.id],
+ relationName: "updatedBy",
+ }),
+ lockedBy: one(users, {
+ fields: [wikiPages.lockedById],
+ references: [users.id],
+ relationName: "lockedBy",
+ }),
+ revisions: many(wikiPageRevisions),
+ tags: many(wikiPageToTag),
+ assets: many(assetsToPages),
+}));
+
+// Page revisions table
+export const wikiPageRevisions = pgTable("wiki_page_revisions", {
+ id: serial("id").primaryKey(),
+ pageId: integer("page_id")
+ .references(() => wikiPages.id)
+ .notNull(),
+ content: text("content").notNull(),
+ createdById: integer("created_by_id").references(() => users.id),
+ createdAt: timestamp("created_at").defaultNow(),
+});
+
+// Page revision relations
+export const wikiPageRevisionsRelations = relations(
+ wikiPageRevisions,
+ ({ one }) => ({
+ page: one(wikiPages, {
+ fields: [wikiPageRevisions.pageId],
+ references: [wikiPages.id],
+ }),
+ createdBy: one(users, {
+ fields: [wikiPageRevisions.createdById],
+ references: [users.id],
+ }),
+ })
+);
+
+// Tags table
+export const wikiTags = pgTable("wiki_tags", {
+ id: serial("id").primaryKey(),
+ name: varchar("name", { length: 100 }).notNull().unique(),
+ description: text("description"),
+ createdAt: timestamp("created_at").defaultNow(),
+});
+
+// Pages to tags (many-to-many)
+export const wikiPageToTag = pgTable(
+ "wiki_page_to_tag",
+ {
+ pageId: integer("page_id")
+ .references(() => wikiPages.id)
+ .notNull(),
+ tagId: integer("tag_id")
+ .references(() => wikiTags.id)
+ .notNull(),
+ },
+ (t) => ({
+ pk: primaryKey({ columns: [t.pageId, t.tagId] }),
+ })
+);
+
+// Tag relations
+export const wikiTagsRelations = relations(wikiTags, ({ many }) => ({
+ pages: many(wikiPageToTag),
+}));
+
+// Page to tag relations
+export const wikiPageToTagRelations = relations(wikiPageToTag, ({ one }) => ({
+ page: one(wikiPages, {
+ fields: [wikiPageToTag.pageId],
+ references: [wikiPages.id],
+ }),
+ tag: one(wikiTags, {
+ fields: [wikiPageToTag.tagId],
+ references: [wikiTags.id],
+ }),
+}));
+
+// NextAuth tables
+export const accounts = pgTable("accounts", {
+ id: serial("id").primaryKey(),
+ userId: integer("user_id")
+ .references(() => users.id)
+ .notNull(),
+ type: varchar("type", { length: 255 }).notNull(),
+ provider: varchar("provider", { length: 255 }).notNull(),
+ providerAccountId: varchar("provider_account_id", { length: 255 }).notNull(),
+ refresh_token: text("refresh_token"),
+ access_token: text("access_token"),
+ expires_at: integer("expires_at"),
+ token_type: varchar("token_type", { length: 255 }),
+ scope: varchar("scope", { length: 255 }),
+ id_token: text("id_token"),
+ session_state: varchar("session_state", { length: 255 }),
+ createdAt: timestamp("created_at").defaultNow(),
+ updatedAt: timestamp("updated_at").defaultNow(),
+});
+
+// Account relations
+export const accountsRelations = relations(accounts, ({ one }) => ({
+ user: one(users, {
+ fields: [accounts.userId],
+ references: [users.id],
+ }),
+}));
+
+export const sessions = pgTable("sessions", {
+ id: serial("id").primaryKey(),
+ sessionToken: varchar("session_token", { length: 255 }).notNull().unique(),
+ userId: integer("user_id")
+ .references(() => users.id)
+ .notNull(),
+ expires: timestamp("expires").notNull(),
+});
+
+// Session relations
+export const sessionsRelations = relations(sessions, ({ one }) => ({
+ user: one(users, {
+ fields: [sessions.userId],
+ references: [users.id],
+ }),
+}));
+
+export const verificationTokens = pgTable(
+ "verification_tokens",
+ {
+ identifier: varchar("identifier", { length: 255 }).notNull(),
+ token: varchar("token", { length: 255 }).notNull(),
+ expires: timestamp("expires").notNull(),
+ },
+ (t) => ({
+ pk: primaryKey({ columns: [t.identifier, t.token] }),
+ })
+);
+
+// Assets table for storing uploaded files
+export const assets = pgTable("assets", {
+ id: uuid("id").primaryKey().defaultRandom(),
+ name: varchar("name", { length: 255 }),
+ description: text("description"),
+ fileName: varchar("file_name", { length: 255 }).notNull(),
+ fileType: varchar("file_type", { length: 100 }).notNull(),
+ fileSize: integer("file_size").notNull(),
+ data: text("data").notNull(), // Base64 encoded file data
+ uploadedById: integer("uploaded_by_id")
+ .references(() => users.id)
+ .notNull(),
+ createdAt: timestamp("created_at").defaultNow(),
+});
+
+// Junction table for many-to-many relationship between assets and pages
+export const assetsToPages = pgTable(
+ "assets_to_pages",
+ {
+ assetId: uuid("asset_id")
+ .references(() => assets.id)
+ .notNull(),
+ pageId: integer("page_id")
+ .references(() => wikiPages.id)
+ .notNull(),
+ },
+ (t) => [
+ primaryKey({ columns: [t.assetId, t.pageId] }),
+ index("asset_page_idx").on(t.assetId, t.pageId),
+ ]
+);
+
+// Asset relations - defines how assets relate to other tables
+export const assetsRelations = relations(assets, ({ one, many }) => ({
+ uploadedBy: one(users, {
+ // 1:1 relation to user who uploaded the asset
+ fields: [assets.uploadedById],
+ references: [users.id],
+ }),
+ pages: many(assetsToPages), // Many-to-many relation to pages via junction table
+}));
+
+// AssetsToPages relations - defines the junction table relations
+// This is needed because:
+// 1. It allows querying from the junction table to get the connected asset/page
+// 2. It enables proper type inference when querying through the relations
+// 3. It maintains bidirectional navigation between assets and pages
+export const assetsToPagesRelations = relations(assetsToPages, ({ one }) => ({
+ asset: one(assets, {
+ // 1:1 relation back to the asset
+ fields: [assetsToPages.assetId],
+ references: [assets.id],
+ }),
+ page: one(wikiPages, {
+ // 1:1 relation to the connected page
+ fields: [assetsToPages.pageId],
+ references: [wikiPages.id],
+ }),
+}));
diff --git a/packages/db/src/seeds/.gitignore b/packages/db/src/seeds/.gitignore
new file mode 100644
index 0000000..725c70e
--- /dev/null
+++ b/packages/db/src/seeds/.gitignore
@@ -0,0 +1,2 @@
+custom-seeds.ts
+custom/
\ No newline at end of file
diff --git a/packages/db/src/seeds/custom-seeds.example.ts b/packages/db/src/seeds/custom-seeds.example.ts
new file mode 100644
index 0000000..9091b2b
--- /dev/null
+++ b/packages/db/src/seeds/custom-seeds.example.ts
@@ -0,0 +1,19 @@
+// This file is intended for developers to add their own custom seed data.
+// It is ignored by git by default (see .gitignore).
+// You can copy the contents of custom-seeds.example.ts here to get started.
+// import { logger } from "~/lib/utils/logger";
+
+/**
+ * Runs custom seed operations defined by the developer.
+ * Add your custom seeding logic within this function or call other
+ * imported seed functions.
+ */
+export async function runCustomSeeds() {
+ // logger.log(" -> Running custom seeds (if any defined)...");
+ // Example: Uncomment and import if you want to run the example page seed
+ // import { seedExamplePages } from './custom-seeds.example';
+ // await seedExamplePages();
+ // Add other custom seed calls here:
+ // await seedMyTestData();
+ // logger.log(" -> Custom seeds finished.");
+}
diff --git a/packages/db/src/seeds/permissions.ts b/packages/db/src/seeds/permissions.ts
new file mode 100644
index 0000000..3a8034d
--- /dev/null
+++ b/packages/db/src/seeds/permissions.ts
@@ -0,0 +1,225 @@
+import { db } from "../index.js";
+import * as schema from "../schema/index.js";
+import {
+ getAllPermissions,
+ createPermissionId,
+} from "../registry/permissions.js";
+import { Permission } from "../registry/types.js";
+import { sql } from "drizzle-orm";
+
+/**
+ * Seed all permissions from the central registry
+ */
+export async function seedPermissions() {
+ console.log(`Seeding permissions from registry...`);
+
+ const registryPermissions = getAllPermissions();
+
+ // Prepare permissions for insertion, generating the unique name
+ const permissionsToInsert = registryPermissions.map((p: Permission) => ({
+ module: p.module,
+ resource: p.resource,
+ action: p.action,
+ description: p.description,
+ name: createPermissionId(p), // Use the function to generate the name
+ }));
+
+ console.log(`Found ${permissionsToInsert.length} permissions to seed.`);
+
+ if (permissionsToInsert.length === 0) {
+ console.warn("No permissions found in the registry to seed.");
+ return;
+ }
+
+ try {
+ // Use insert with onConflictDoUpdate to handle existing permissions
+ await db
+ .insert(schema.permissions)
+ .values(permissionsToInsert)
+ .onConflictDoUpdate({
+ target: schema.permissions.name, // Target the unique name column
+ set: {
+ // Use sql`excluded.column_name` syntax
+ description: sql`excluded.description`,
+ },
+ });
+
+ console.log("Permissions seeding finished.");
+ } catch (error) {
+ console.error(`Error during permissions seeding:`, error);
+ // Consider re-throwing or handling the error more robustly
+ }
+}
+
+/**
+ * Create default groups with permissions
+ */
+export async function createDefaultGroups() {
+ console.log("Creating default groups...");
+
+ try {
+ // --- Create Groups (using onConflictDoUpdate for idempotency) ---
+ const groupsToCreate = [
+ {
+ name: "Administrators",
+ description: "Full access to all wiki features",
+ isEditable: false,
+ allowUserAssignment: true,
+ isSystem: true,
+ },
+ {
+ name: "Editors",
+ description: "Can edit, create, and manage wiki content",
+ isEditable: true,
+ allowUserAssignment: true,
+ isSystem: false,
+ },
+ {
+ name: "Viewers",
+ description: "Can only view wiki content",
+ isEditable: true,
+ allowUserAssignment: true,
+ isSystem: true,
+ },
+ {
+ name: "Guests",
+ description: "Default group for non-authenticated users",
+ isEditable: true,
+ allowUserAssignment: false,
+ isSystem: true,
+ },
+ ];
+
+ const createdGroups = await db
+ .insert(schema.groups)
+ .values(groupsToCreate)
+ .onConflictDoUpdate({
+ target: schema.groups.name,
+ set: {
+ // Use sql`excluded.column_name` syntax
+ description: sql`excluded.description`,
+ isEditable: sql`excluded.is_editable`,
+ allowUserAssignment: sql`excluded.allow_user_assignment`,
+ isSystem: sql`excluded.is_system`,
+ },
+ })
+ .returning(); // Get created/updated groups with IDs
+
+ const adminGroup = createdGroups.find((g) => g.name === "Administrators");
+ const editorGroup = createdGroups.find((g) => g.name === "Editors");
+ const viewerGroup = createdGroups.find((g) => g.name === "Viewers");
+ const guestGroup = createdGroups.find((g) => g.name === "Guests");
+
+ // --- Assign Permissions ---
+ const allDbPermissions = await db.query.permissions.findMany();
+ if (!allDbPermissions || allDbPermissions.length === 0) {
+ console.error(
+ "No permissions found in the database. Cannot assign permissions to groups. Ensure seedPermissions ran successfully."
+ );
+ return;
+ }
+
+ const findPermission = (id: string) =>
+ allDbPermissions.find((p) => p.name === id);
+
+ // Administrator: All permissions
+ if (adminGroup) {
+ const allPermissionIds = allDbPermissions.map((p) => p.id);
+ if (allPermissionIds.length > 0) {
+ await db
+ .insert(schema.groupPermissions)
+ .values(
+ allPermissionIds.map((permissionId) => ({
+ groupId: adminGroup.id,
+ permissionId,
+ }))
+ )
+ .onConflictDoNothing();
+ console.log(
+ `Assigned ${allPermissionIds.length} permissions to Administrators.`
+ );
+ } else {
+ console.warn("No permissions available to assign to Administrators.");
+ }
+ }
+
+ // Editors: Wiki create, read, update + Asset read
+ if (editorGroup) {
+ const editorPerms = [
+ findPermission("wiki:page:create"),
+ findPermission("wiki:page:read"),
+ findPermission("wiki:page:update"),
+ findPermission("assets:asset:read"), // Editors likely need to see assets too
+ ].filter((p) => p !== undefined) as typeof allDbPermissions;
+
+ if (editorPerms.length > 0) {
+ await db
+ .insert(schema.groupPermissions)
+ .values(
+ editorPerms.map((p) => ({
+ groupId: editorGroup.id,
+ permissionId: p.id,
+ }))
+ )
+ .onConflictDoNothing();
+ console.log(`Assigned ${editorPerms.length} permissions to Editors.`);
+ } else {
+ console.warn("Could not find necessary permissions for Editors.");
+ }
+ }
+
+ // Viewers & Guests: Wiki read, Asset read
+ const readPermIds = [
+ findPermission("wiki:page:read")?.id,
+ findPermission("assets:asset:read")?.id,
+ ].filter((id) => id !== undefined) as number[];
+
+ const viewerGuestGroups = [viewerGroup, guestGroup].filter(
+ (g) => g !== undefined
+ ) as typeof createdGroups;
+
+ if (readPermIds.length > 0) {
+ for (const group of viewerGuestGroups) {
+ await db
+ .insert(schema.groupPermissions)
+ .values(
+ readPermIds.map((permissionId) => ({
+ groupId: group.id,
+ permissionId,
+ }))
+ )
+ .onConflictDoNothing();
+ console.log(
+ `Assigned ${readPermIds.length} read permissions to ${group.name}.`
+ );
+ }
+ } else {
+ console.warn("Could not find read permissions for Viewers/Guests.");
+ }
+
+ // -- Assign Module/Action Permissions (Simplified Example) --
+ // You might need more granular control here based on your specific needs
+ // Example: Assign 'wiki' and 'assets' module access and 'read' action to Viewers/Guests
+ for (const group of viewerGuestGroups) {
+ await db
+ .insert(schema.groupModulePermissions)
+ .values([
+ { groupId: group.id, module: "wiki" },
+ { groupId: group.id, module: "assets" },
+ ])
+ .onConflictDoNothing();
+ await db
+ .insert(schema.groupActionPermissions)
+ .values({ groupId: group.id, action: "read" })
+ .onConflictDoNothing();
+ console.log(`Assigned basic module/action permissions to ${group.name}.`);
+ }
+
+ console.log("Default groups and permissions assigned successfully!");
+ } catch (error) {
+ console.error(
+ "Error creating default groups or assigning permissions:",
+ error
+ );
+ }
+}
diff --git a/packages/db/src/seeds/run.ts b/packages/db/src/seeds/run.ts
new file mode 100644
index 0000000..7726602
--- /dev/null
+++ b/packages/db/src/seeds/run.ts
@@ -0,0 +1,43 @@
+import { createDefaultGroups, seedPermissions } from "./permissions.js";
+import { runCustomSeeds } from "./custom-seeds.js";
+// import { db } from "../index.js"; // No longer needed for closing
+// import pg from "pg"; // No longer needed for closing
+
+/**
+ * Main function to run all seed operations.
+ */
+async function seed() {
+ console.log("Starting database seeding...");
+
+ try {
+ // 1. Seed Permissions
+ await seedPermissions();
+
+ // 2. Create Default Groups and assign base permissions
+ await createDefaultGroups();
+
+ // 3. Run Custom Seeds (admin user, example pages, etc.)
+ await runCustomSeeds();
+
+ console.log("\n✅ Database seeding completed successfully.");
+ } catch (error) {
+ console.error("\n❌ Database seeding failed:", error);
+ process.exitCode = 1; // Indicate failure
+ } finally {
+ console.log("Seeding script finished.");
+ }
+}
+
+/**
+ * Execute the seed function when this module is run directly.
+ * Uses Node.js module detection pattern to determine direct execution.
+ */
+// Use ES module way to check if the script is run directly
+if (process.argv[1] && import.meta.url === `file://${process.argv[1]}`) {
+ void seed().catch((error) => {
+ console.error("Failed to run seeds:", error);
+ process.exit(1);
+ });
+}
+
+export { seed };
diff --git a/packages/db/src/utils.ts b/packages/db/src/utils.ts
new file mode 100644
index 0000000..41227f6
--- /dev/null
+++ b/packages/db/src/utils.ts
@@ -0,0 +1,44 @@
+import fs from "node:fs/promises";
+import path from "node:path";
+import { db } from "./index.js";
+import { sql } from "drizzle-orm";
+
+/**
+ * Executes a raw SQL migration script from a given file path.
+ *
+ * @param migrationPath - The path to the SQL migration file, relative to the project root or absolute.
+ * @param relativeToDir - The directory to resolve relative paths from (defaults to the current working directory).
+ * @returns A promise that resolves when the migration is executed successfully.
+ * @throws Will throw an error if the file cannot be read or the SQL execution fails.
+ */
+export async function runRawSqlMigration(
+ migrationPath: string,
+ relativeToDir: string = process.cwd()
+): Promise {
+ const absolutePath = path.resolve(relativeToDir, migrationPath);
+ try {
+ console.log(`Reading migration file: ${absolutePath}`);
+ const migrationSql = await fs.readFile(absolutePath, "utf-8");
+
+ if (!migrationSql.trim()) {
+ console.log(`Migration file is empty: ${absolutePath}. Skipping.`);
+ return;
+ }
+
+ console.log(`Executing raw SQL migration from: ${absolutePath}`);
+ // Use db.execute for drivers like pg, mysql2, neon
+ // Use db.run for drivers like better-sqlite3
+ // Assuming a PostgreSQL-compatible driver here based on pg_trgm usage seen elsewhere.
+ await db.execute(sql.raw(migrationSql));
+
+ console.log(`Successfully executed migration: ${absolutePath}`);
+ } catch (error) {
+ console.error(
+ `Failed to execute raw SQL migration from ${absolutePath}:`,
+ error
+ );
+ throw new Error(
+ `Migration failed for ${absolutePath}: ${error instanceof Error ? error.message : String(error)}`
+ );
+ }
+}
diff --git a/packages/db/tsconfig.json b/packages/db/tsconfig.json
new file mode 100644
index 0000000..338a472
--- /dev/null
+++ b/packages/db/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "@repo/typescript-config/base.json",
+ "compilerOptions": {
+ "outDir": "dist",
+ "types": ["node"]
+ },
+ "include": ["src/**/*.ts", "src/**/*.tsx"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/packages/db/tsup.config.ts b/packages/db/tsup.config.ts
new file mode 100644
index 0000000..d17d116
--- /dev/null
+++ b/packages/db/tsup.config.ts
@@ -0,0 +1,40 @@
+import { defineConfig } from "tsup";
+import { exec } from "child_process"; // Import exec for onSuccess command
+
+export default defineConfig({
+ entry: ["src/index.ts", "src/client.ts"], // Add client.ts entry point
+ format: ["esm", "cjs"], // Output formats
+ dts: true, // Generate .d.ts files
+ splitting: false, // Keep everything in one file per format
+ sourcemap: true, // Generate source maps
+ clean: true, // Clean dist folder before build
+ outExtension(ctx) {
+ // Ensure .cjs extension for commonjs
+ return {
+ js: ctx.format === "cjs" ? `.cjs` : ".js",
+ };
+ },
+ async onSuccess() {
+ // Copy the pages directory after build succeeds
+ // Uses shell command, adjust if needed for cross-platform compatibility (e.g., use fs-extra)
+ console.log("Build successful, copying pages directory...");
+ await new Promise((resolve, reject) => {
+ exec(
+ "cp -R src/seeds/custom/pages dist/pages",
+ (error, stdout, stderr) => {
+ if (error) {
+ console.error(`Error copying pages: ${error}`);
+ return reject(error);
+ }
+ if (stderr) {
+ console.error(`Copy stderr: ${stderr}`);
+ // Optional: reject on stderr? Depends if warnings are expected
+ }
+ console.log(`Copy stdout: ${stdout}`);
+ console.log("Successfully copied pages directory.");
+ resolve(stdout);
+ }
+ );
+ });
+ },
+});
diff --git a/packages/eslint-config/README.md b/packages/eslint-config/README.md
new file mode 100644
index 0000000..8b42d90
--- /dev/null
+++ b/packages/eslint-config/README.md
@@ -0,0 +1,3 @@
+# `@turbo/eslint-config`
+
+Collection of internal eslint configurations.
diff --git a/packages/eslint-config/base.js b/packages/eslint-config/base.js
new file mode 100644
index 0000000..09d316e
--- /dev/null
+++ b/packages/eslint-config/base.js
@@ -0,0 +1,32 @@
+import js from "@eslint/js";
+import eslintConfigPrettier from "eslint-config-prettier";
+import turboPlugin from "eslint-plugin-turbo";
+import tseslint from "typescript-eslint";
+import onlyWarn from "eslint-plugin-only-warn";
+
+/**
+ * A shared ESLint configuration for the repository.
+ *
+ * @type {import("eslint").Linter.Config[]}
+ * */
+export const config = [
+ js.configs.recommended,
+ eslintConfigPrettier,
+ ...tseslint.configs.recommended,
+ {
+ plugins: {
+ turbo: turboPlugin,
+ },
+ rules: {
+ "turbo/no-undeclared-env-vars": "warn",
+ },
+ },
+ {
+ plugins: {
+ onlyWarn,
+ },
+ },
+ {
+ ignores: ["dist/**"],
+ },
+];
diff --git a/packages/eslint-config/next.js b/packages/eslint-config/next.js
new file mode 100644
index 0000000..6bf01a7
--- /dev/null
+++ b/packages/eslint-config/next.js
@@ -0,0 +1,49 @@
+import js from "@eslint/js";
+import eslintConfigPrettier from "eslint-config-prettier";
+import tseslint from "typescript-eslint";
+import pluginReactHooks from "eslint-plugin-react-hooks";
+import pluginReact from "eslint-plugin-react";
+import globals from "globals";
+import pluginNext from "@next/eslint-plugin-next";
+import { config as baseConfig } from "./base.js";
+
+/**
+ * A custom ESLint configuration for libraries that use Next.js.
+ *
+ * @type {import("eslint").Linter.Config[]}
+ * */
+export const nextJsConfig = [
+ ...baseConfig,
+ js.configs.recommended,
+ eslintConfigPrettier,
+ ...tseslint.configs.recommended,
+ {
+ ...pluginReact.configs.flat.recommended,
+ languageOptions: {
+ ...pluginReact.configs.flat.recommended.languageOptions,
+ globals: {
+ ...globals.serviceworker,
+ },
+ },
+ },
+ {
+ plugins: {
+ "@next/next": pluginNext,
+ },
+ rules: {
+ ...pluginNext.configs.recommended.rules,
+ ...pluginNext.configs["core-web-vitals"].rules,
+ },
+ },
+ {
+ plugins: {
+ "react-hooks": pluginReactHooks,
+ },
+ settings: { react: { version: "detect" } },
+ rules: {
+ ...pluginReactHooks.configs.recommended.rules,
+ // React scope no longer necessary with new JSX transform.
+ "react/react-in-jsx-scope": "off",
+ },
+ },
+];
diff --git a/packages/eslint-config/package.json b/packages/eslint-config/package.json
new file mode 100644
index 0000000..f256ece
--- /dev/null
+++ b/packages/eslint-config/package.json
@@ -0,0 +1,24 @@
+{
+ "name": "@repo/eslint-config",
+ "version": "0.0.0",
+ "type": "module",
+ "private": true,
+ "exports": {
+ "./base": "./base.js",
+ "./next-js": "./next.js",
+ "./react-internal": "./react-internal.js"
+ },
+ "devDependencies": {
+ "@eslint/js": "^9.25.1",
+ "@next/eslint-plugin-next": "^15.3.1",
+ "eslint": "^9.25.1",
+ "eslint-config-prettier": "^10.1.2",
+ "eslint-plugin-only-warn": "^1.1.0",
+ "eslint-plugin-react": "^7.37.5",
+ "eslint-plugin-react-hooks": "^5.2.0",
+ "eslint-plugin-turbo": "^2.5.0",
+ "globals": "^16.0.0",
+ "typescript": "^5.8.3",
+ "typescript-eslint": "^8.31.0"
+ }
+}
diff --git a/packages/eslint-config/react-internal.js b/packages/eslint-config/react-internal.js
new file mode 100644
index 0000000..0147f4b
--- /dev/null
+++ b/packages/eslint-config/react-internal.js
@@ -0,0 +1,41 @@
+import js from "@eslint/js";
+import eslintConfigPrettier from "eslint-config-prettier";
+import tseslint from "typescript-eslint";
+import pluginReactHooks from "eslint-plugin-react-hooks";
+import pluginReact from "eslint-plugin-react";
+import globals from "globals";
+import { config as baseConfig } from "./base.js";
+
+/**
+ * A custom ESLint configuration for libraries that use React.
+ *
+ * @type {import("eslint").Linter.Config[]} */
+export const config = [
+ ...baseConfig,
+ js.configs.recommended,
+ eslintConfigPrettier,
+ ...tseslint.configs.recommended,
+ pluginReact.configs.flat.recommended,
+ {
+ languageOptions: {
+ ...pluginReact.configs.flat.recommended.languageOptions,
+ globals: {
+ ...globals.serviceworker,
+ ...globals.browser,
+ },
+ },
+ },
+ {
+ plugins: {
+ "react-hooks": pluginReactHooks,
+ },
+ settings: { react: { version: "detect" } },
+ rules: {
+ ...pluginReactHooks.configs.recommended.rules,
+ // React scope no longer necessary with new JSX transform.
+ "react/react-in-jsx-scope": "off",
+ // Disable prop-types rule for TypeScript projects
+ "react/prop-types": "off",
+ },
+ },
+];
diff --git a/packages/logger/eslint.config.mjs b/packages/logger/eslint.config.mjs
new file mode 100644
index 0000000..9b56f93
--- /dev/null
+++ b/packages/logger/eslint.config.mjs
@@ -0,0 +1,4 @@
+import { config } from "@repo/eslint-config/base";
+
+/** @type {import("eslint").Linter.Config} */
+export default config;
diff --git a/packages/logger/package.json b/packages/logger/package.json
new file mode 100644
index 0000000..ce81488
--- /dev/null
+++ b/packages/logger/package.json
@@ -0,0 +1,24 @@
+{
+ "name": "@repo/logger",
+ "version": "0.0.0",
+ "type": "module",
+ "private": true,
+ "exports": {
+ ".": "./src/index.ts"
+ },
+ "types": "./src/index.ts",
+ "scripts": {
+ "lint": "eslint . --max-warnings 0",
+ "clean": "rm -rf .turbo node_modules dist"
+ },
+ "dependencies": {
+ "date-fns": "^4.1.0"
+ },
+ "devDependencies": {
+ "@repo/eslint-config": "workspace:*",
+ "@repo/typescript-config": "workspace:*",
+ "@types/node": "^22.14.1",
+ "eslint": "^9.6.0",
+ "typescript": "^5.5.3"
+ }
+}
diff --git a/packages/logger/src/index.ts b/packages/logger/src/index.ts
new file mode 100644
index 0000000..8bb8d46
--- /dev/null
+++ b/packages/logger/src/index.ts
@@ -0,0 +1,183 @@
+import { format } from "date-fns";
+
+// Define log levels and their order
+enum LogLevel {
+ DEBUG = "DEBUG",
+ INFO = "INFO",
+ WARN = "WARN",
+ ERROR = "ERROR",
+}
+
+const LogLevelOrder: Record = {
+ [LogLevel.DEBUG]: 1,
+ [LogLevel.INFO]: 2,
+ [LogLevel.WARN]: 3,
+ [LogLevel.ERROR]: 4,
+};
+
+// ANSI color codes
+const colors = {
+ reset: "\x1b[0m",
+ cyan: "\x1b[36m",
+ green: "\x1b[32m",
+ yellow: "\x1b[33m",
+ red: "\x1b[31m",
+ magenta: "\x1b[35m",
+ gray: "\x1b[90m",
+ blue: "\x1b[34m",
+ orange: "\x1b[38;5;208m",
+ purple: "\x1b[38;5;129m",
+};
+
+const levelColors: Record = {
+ [LogLevel.DEBUG]: colors.purple,
+ [LogLevel.INFO]: colors.green,
+ [LogLevel.WARN]: colors.yellow,
+ [LogLevel.ERROR]: colors.red,
+};
+
+// Assign specific colors to known origins
+const originColors: Record = {
+ WSS: colors.magenta,
+ PROD: colors.cyan,
+ NEXT: colors.blue, // From user's package.json change
+ CLIENT: colors.orange, // Specific color for client-side logs
+ APP: colors.green,
+};
+
+const defaultOriginColor = colors.gray; // Fallback color
+
+// Safe check for TTY, defaults to false in non-Node.js environments
+const useColors =
+ typeof window === "undefined" && // Check if NOT in browser first
+ typeof process !== "undefined" &&
+ process.stdout?.isTTY === true;
+
+// Get server-side origin using env (only checked when on server)
+const getServerProcessOrigin = (): string | undefined => {
+ // env.PROCESS_ORIGIN should only be accessed server-side
+ if (typeof window === "undefined") {
+ const origin = process.env.PROCESS_ORIGIN;
+ if (origin === "WSS" || origin === "PROD" || origin === "NEXT") {
+ return origin;
+ }
+ }
+ return undefined;
+};
+
+// Formats only the prefix part of the log message
+const formatLogPrefix = (level: LogLevel, origin: string): string => {
+ const timestamp = format(new Date(), "yyyy-MM-dd HH:mm:ss.SSS");
+ const levelColor = levelColors[level] || colors.reset;
+ const originColor = originColors[origin.toUpperCase()] || defaultOriginColor;
+
+ if (useColors) {
+ return `${colors.gray}[${timestamp}]${
+ colors.reset
+ } ${levelColor}${level.padEnd(5)}${
+ colors.reset
+ } ${originColor}[${origin.toUpperCase()}]${colors.reset}`;
+ } else {
+ return `[${timestamp}] ${level.padEnd(5)} [${origin.toUpperCase()}]`;
+ }
+};
+
+export interface Logger {
+ debug: (message: string, ...args: unknown[]) => void;
+ info: (message: string, ...args: unknown[]) => void;
+ warn: (message: string, ...args: unknown[]) => void;
+ error: (message: string, ...args: unknown[]) => void;
+ log: (message: string, ...args: unknown[]) => void; // Alias for info
+}
+
+interface CreateLoggerOptions {
+ maxLevel?: LogLevel;
+}
+
+/**
+ * Creates a new logger instance associated with a specific origin.
+ */
+export const createLogger = (
+ origin_param?: string,
+ options?: CreateLoggerOptions
+): Logger => {
+ const defaultMaxLevel =
+ process.env.NEXT_PUBLIC_NODE_ENV === "production"
+ ? LogLevel.INFO
+ : LogLevel.DEBUG;
+
+ const maxLevel = options?.maxLevel || defaultMaxLevel;
+ const maxLevelOrder = LogLevelOrder[maxLevel];
+
+ // Determine origin: param -> server detect -> client detect -> fallback
+ const origin = origin_param
+ ? origin_param
+ : typeof window === "undefined"
+ ? (getServerProcessOrigin() ?? "APP") // Server: try detect, else APP
+ : "CLIENT"; // Client: always CLIENT
+
+ const logFn =
+ (level: LogLevel) =>
+ (message: string, ...args: unknown[]) => {
+ const currentLevelOrder = LogLevelOrder[level];
+
+ if (currentLevelOrder < maxLevelOrder) {
+ return;
+ }
+
+ // Format only the prefix
+ const prefix = formatLogPrefix(level, origin);
+
+ // Pass prefix and original arguments to console methods
+ switch (level) {
+ case LogLevel.DEBUG:
+ case LogLevel.INFO:
+ console.log(prefix, message, ...args);
+ break;
+ case LogLevel.WARN:
+ console.warn(prefix, message, ...args);
+ break;
+ case LogLevel.ERROR:
+ console.error(prefix, message, ...args);
+ break;
+ }
+ };
+
+ return {
+ debug: logFn(LogLevel.DEBUG),
+ info: logFn(LogLevel.INFO),
+ warn: logFn(LogLevel.WARN),
+ error: logFn(LogLevel.ERROR),
+ log: logFn(LogLevel.INFO),
+ };
+};
+
+// --- Singleton Logger Instances ---
+
+/**
+ * Logger instance specifically for the WebSocket Development Server.
+ */
+export const wssLogger = createLogger("WSS");
+
+/**
+ * Logger instance specifically for the Production Server / Next.js SSR.
+ */
+export const prodLogger = createLogger("PROD");
+
+/**
+ * Logger instance specifically for the Next.js Development/Build process.
+ */
+export const nextLogger = createLogger("NEXT");
+
+/**
+ * Logger instance specifically for client-side browser code.
+ */
+export const clientLogger = createLogger("CLIENT");
+
+/**
+ * A default logger instance. Automatically detects if running on Server (WSS/PROD/NEXT)
+ * or Client (CLIENT) and uses the appropriate origin.
+ * Falls back to 'APP' if server origin cannot be detected.
+ * Useful for shared code where the context isn't immediately obvious.
+ */
+export const logger = createLogger(); // Will now auto-detect server/client origin
diff --git a/packages/logger/tsconfig.json b/packages/logger/tsconfig.json
new file mode 100644
index 0000000..338a472
--- /dev/null
+++ b/packages/logger/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "@repo/typescript-config/base.json",
+ "compilerOptions": {
+ "outDir": "dist",
+ "types": ["node"]
+ },
+ "include": ["src/**/*.ts", "src/**/*.tsx"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/packages/tailwind-config/eslint.config.mjs b/packages/tailwind-config/eslint.config.mjs
new file mode 100644
index 0000000..9b56f93
--- /dev/null
+++ b/packages/tailwind-config/eslint.config.mjs
@@ -0,0 +1,4 @@
+import { config } from "@repo/eslint-config/base";
+
+/** @type {import("eslint").Linter.Config} */
+export default config;
diff --git a/packages/tailwind-config/package.json b/packages/tailwind-config/package.json
new file mode 100644
index 0000000..8caa1c7
--- /dev/null
+++ b/packages/tailwind-config/package.json
@@ -0,0 +1,27 @@
+{
+ "name": "@repo/tailwind-config",
+ "version": "0.0.0",
+ "type": "module",
+ "private": true,
+ "exports": {
+ ".": "./tailwind.config.ts"
+ },
+ "types": "./tailwind.config.ts",
+ "scripts": {
+ "lint": "eslint . --max-warnings 0",
+ "clean": "rm -rf .turbo node_modules dist"
+ },
+ "dependencies": {},
+ "devDependencies": {
+ "@repo/eslint-config": "workspace:*",
+ "@repo/typescript-config": "workspace:*",
+ "eslint": "^9.25.1",
+ "typescript": "^5.8.3",
+ "tailwindcss": "^4.1.4",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "tailwind-merge": "^3.2.0",
+ "tailwindcss-animate": "^1.0.7",
+ "@tailwindcss/typography": "^0.5.16"
+ }
+}
diff --git a/tailwind.config.js b/packages/tailwind-config/tailwind.config.ts
similarity index 92%
rename from tailwind.config.js
rename to packages/tailwind-config/tailwind.config.ts
index e66b9e8..eb734a1 100644
--- a/tailwind.config.js
+++ b/packages/tailwind-config/tailwind.config.ts
@@ -1,10 +1,8 @@
/** @type {import('tailwindcss').Config} */
+import type { Config } from "tailwindcss";
+import typography from "@tailwindcss/typography";
-const config = {
- content: [
- "./src/app/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}",
- "./src/components/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}",
- ],
+const sharedConfig: Omit = {
darkMode: "class",
theme: {
extend: {
@@ -22,6 +20,7 @@ const config = {
text: {
primary: "var(--color-text-primary)",
secondary: "var(--color-text-secondary)",
+ tertiary: "var(--color-text-tertiary)",
accent: "var(--color-text-accent)",
},
border: {
@@ -158,23 +157,6 @@ const config = {
1100: "var(--color-success-1100)",
1200: "var(--color-success-1200)",
},
- warning: {
- DEFAULT: "var(--color-warning)",
- foreground: "var(--color-warning-foreground)",
- 50: "var(--color-warning-50)",
- 100: "var(--color-warning-100)",
- 200: "var(--color-warning-200)",
- 300: "var(--color-warning-300)",
- 400: "var(--color-warning-400)",
- 500: "var(--color-warning-500)",
- 600: "var(--color-warning-600)",
- 700: "var(--color-warning-700)",
- 800: "var(--color-warning-800)",
- 900: "var(--color-warning-900)",
- 1000: "var(--color-warning-1000)",
- 1100: "var(--color-warning-1100)",
- 1200: "var(--color-warning-1200)",
- },
info: {
DEFAULT: "var(--color-info)",
foreground: "var(--color-info-foreground)",
@@ -323,7 +305,7 @@ const config = {
},
},
},
- plugins: [import("@tailwindcss/typography")],
+ plugins: [typography],
};
-export default config;
+export default sharedConfig;
diff --git a/packages/tailwind-config/tsconfig.json b/packages/tailwind-config/tsconfig.json
new file mode 100644
index 0000000..a6bb08c
--- /dev/null
+++ b/packages/tailwind-config/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "@repo/typescript-config/base.json",
+ "compilerOptions": {
+ "outDir": "dist"
+ },
+ "include": ["*.ts", "*.tsx"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/packages/types/eslint.config.mjs b/packages/types/eslint.config.mjs
new file mode 100644
index 0000000..9b56f93
--- /dev/null
+++ b/packages/types/eslint.config.mjs
@@ -0,0 +1,4 @@
+import { config } from "@repo/eslint-config/base";
+
+/** @type {import("eslint").Linter.Config} */
+export default config;
diff --git a/packages/types/package.json b/packages/types/package.json
new file mode 100644
index 0000000..7d9a947
--- /dev/null
+++ b/packages/types/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "@repo/types",
+ "version": "0.0.0",
+ "type": "module",
+ "private": true,
+ "exports": {
+ ".": "./src/index.ts"
+ },
+ "types": "./src/index.ts",
+ "scripts": {
+ "lint": "eslint . --max-warnings 0",
+ "clean": "rm -rf .turbo node_modules dist"
+ },
+ "dependencies": {},
+ "devDependencies": {
+ "@repo/eslint-config": "workspace:*",
+ "@repo/typescript-config": "workspace:*",
+ "eslint": "^9.6.0",
+ "typescript": "^5.5.3"
+ }
+}
diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts
new file mode 100644
index 0000000..23d4a2c
--- /dev/null
+++ b/packages/types/src/index.ts
@@ -0,0 +1,2 @@
+// Export your package components here
+export const name = "types";
diff --git a/packages/types/tsconfig.json b/packages/types/tsconfig.json
new file mode 100644
index 0000000..452c81c
--- /dev/null
+++ b/packages/types/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "@repo/typescript-config/base.json",
+ "compilerOptions": {
+ "outDir": "dist"
+ },
+ "include": ["src/**/*.ts", "src/**/*.tsx"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/packages/typescript-config/base.json b/packages/typescript-config/base.json
new file mode 100644
index 0000000..5117f2a
--- /dev/null
+++ b/packages/typescript-config/base.json
@@ -0,0 +1,19 @@
+{
+ "$schema": "https://json.schemastore.org/tsconfig",
+ "compilerOptions": {
+ "declaration": true,
+ "declarationMap": true,
+ "esModuleInterop": true,
+ "incremental": false,
+ "isolatedModules": true,
+ "lib": ["es2022", "DOM", "DOM.Iterable"],
+ "module": "NodeNext",
+ "moduleDetection": "force",
+ "moduleResolution": "NodeNext",
+ "noUncheckedIndexedAccess": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "target": "ES2022"
+ }
+}
diff --git a/packages/typescript-config/nextjs.json b/packages/typescript-config/nextjs.json
new file mode 100644
index 0000000..5711546
--- /dev/null
+++ b/packages/typescript-config/nextjs.json
@@ -0,0 +1,12 @@
+{
+ "$schema": "https://json.schemastore.org/tsconfig",
+ "extends": "./base.json",
+ "compilerOptions": {
+ "plugins": [{ "name": "next" }],
+ "module": "Preserve",
+ "moduleResolution": "Bundler",
+ "allowJs": true,
+ "jsx": "preserve",
+ "noEmit": true
+ }
+}
diff --git a/packages/typescript-config/package.json b/packages/typescript-config/package.json
new file mode 100644
index 0000000..27c0e60
--- /dev/null
+++ b/packages/typescript-config/package.json
@@ -0,0 +1,9 @@
+{
+ "name": "@repo/typescript-config",
+ "version": "0.0.0",
+ "private": true,
+ "license": "MIT",
+ "publishConfig": {
+ "access": "public"
+ }
+}
diff --git a/packages/typescript-config/react-library.json b/packages/typescript-config/react-library.json
new file mode 100644
index 0000000..c3a1b26
--- /dev/null
+++ b/packages/typescript-config/react-library.json
@@ -0,0 +1,7 @@
+{
+ "$schema": "https://json.schemastore.org/tsconfig",
+ "extends": "./base.json",
+ "compilerOptions": {
+ "jsx": "react-jsx"
+ }
+}
diff --git a/packages/ui/eslint.config.mjs b/packages/ui/eslint.config.mjs
new file mode 100644
index 0000000..19170f8
--- /dev/null
+++ b/packages/ui/eslint.config.mjs
@@ -0,0 +1,4 @@
+import { config } from "@repo/eslint-config/react-internal";
+
+/** @type {import("eslint").Linter.Config} */
+export default config;
diff --git a/packages/ui/package.json b/packages/ui/package.json
new file mode 100644
index 0000000..6f67cb7
--- /dev/null
+++ b/packages/ui/package.json
@@ -0,0 +1,74 @@
+{
+ "name": "@repo/ui",
+ "version": "0.0.0",
+ "private": true,
+ "sideEffects": [
+ "**/*.css"
+ ],
+ "files": [
+ "dist"
+ ],
+ "exports": {
+ ".": "./src/index.ts",
+ "./utils": "./src/utils.ts",
+ "./styles.css": "./dist/index.css",
+ "./globals.css": "./src/styles/globals.css"
+ },
+ "main": "./src/index.ts",
+ "types": "./src/index.ts",
+ "scripts": {
+ "dev": "pnpx @tailwindcss/cli -i ./src/styles/globals.css -o ./dist/index.css --watch",
+ "build": "pnpx @tailwindcss/cli -i ./src/styles/globals.css -o ./dist/index.css",
+ "lint": "eslint . --max-warnings 0",
+ "generate:component": "turbo gen react-component",
+ "check-types": "tsc --noEmit"
+ },
+ "peerDependencies": {
+ "next": "^15.3.1",
+ "react": "^19.1.0",
+ "react-dom": "^19.1.0",
+ "tailwindcss": "^4.1.4"
+ },
+ "dependencies": {
+ "@radix-ui/react-alert-dialog": "^1.1.10",
+ "@radix-ui/react-avatar": "^1.1.6",
+ "@radix-ui/react-checkbox": "^1.2.2",
+ "@radix-ui/react-dialog": "^1.1.10",
+ "@radix-ui/react-icons": "^1.3.2",
+ "@radix-ui/react-label": "^2.1.4",
+ "@radix-ui/react-popover": "^1.1.10",
+ "@radix-ui/react-radio-group": "^1.3.3",
+ "@radix-ui/react-scroll-area": "^1.2.5",
+ "@radix-ui/react-select": "^2.2.2",
+ "@radix-ui/react-slot": "^1.2.0",
+ "@radix-ui/react-tabs": "^1.1.8",
+ "@radix-ui/react-tooltip": "^1.2.3",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "cmdk": "^1.1.1",
+ "lucide-react": "^0.503.0",
+ "react-syntax-highlighter": "^15.6.1",
+ "tailwind-merge": "^3.2.0",
+ "tailwindcss-animate": "^1.0.7"
+ },
+ "devDependencies": {
+ "@repo/eslint-config": "workspace:*",
+ "@repo/typescript-config": "workspace:*",
+ "@repo/tailwind-config": "workspace:*",
+ "@turbo/gen": "^2.5.0",
+ "@types/node": "^22.14.1",
+ "@types/react": "^19.1.2",
+ "@types/react-dom": "^19.1.2",
+ "@types/react-syntax-highlighter": "^15.5.13",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "eslint": "^9.25.1",
+ "lucide-react": "^0.503.0",
+ "react": "^19.1.0",
+ "react-dom": "^19.1.0",
+ "tailwindcss": "^4.1.4",
+ "@tailwindcss/cli": "^4.1.4",
+ "typescript": "5.8.3",
+ "@tailwindcss/typography": "^0.5.16"
+ }
+}
diff --git a/packages/ui/postcss.config.mjs b/packages/ui/postcss.config.mjs
new file mode 100644
index 0000000..2aa7205
--- /dev/null
+++ b/packages/ui/postcss.config.mjs
@@ -0,0 +1,6 @@
+export default {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+};
diff --git a/src/components/ui/alert.tsx b/packages/ui/src/components/alert.tsx
similarity index 88%
rename from src/components/ui/alert.tsx
rename to packages/ui/src/components/alert.tsx
index 5eae0a7..e0a5df2 100644
--- a/src/components/ui/alert.tsx
+++ b/packages/ui/src/components/alert.tsx
@@ -1,9 +1,9 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
-import { cn } from "~/lib/utils";
+import { cn } from "../utils";
import { X, AlertCircle, AlertTriangle, Info, CheckCircle } from "lucide-react";
-// FIXME: This alret is weird
+// FIXME: This alert is weird, it's not working as expected
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-text-primary",
@@ -71,14 +71,14 @@ const Alert = React.forwardRef(
switch (variant) {
case "success":
- return