diff --git a/.cursorindexingignore b/.cursorindexingignore deleted file mode 100644 index 953908e7..00000000 --- a/.cursorindexingignore +++ /dev/null @@ -1,3 +0,0 @@ - -# Don't index SpecStory auto-save files, but allow explicit context inclusion via @ references -.specstory/** diff --git a/apps/ssr/next-env.d.ts b/apps/ssr/next-env.d.ts index 830fb594..a3e4680c 100644 --- a/apps/ssr/next-env.d.ts +++ b/apps/ssr/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -/// +import './.next/dev/types/routes.d.ts' // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/ssr/package.json b/apps/ssr/package.json index 22c18fc6..e2b858ef 100644 --- a/apps/ssr/package.json +++ b/apps/ssr/package.json @@ -23,8 +23,8 @@ "@afilmory/data": "workspace:*", "@t3-oss/env-nextjs": "0.13.8", "clsx": "2.1.1", - "drizzle-orm": "0.44.6", - "es-toolkit": "1.40.0", + "drizzle-orm": "0.44.7", + "es-toolkit": "1.41.0", "linkedom": "0.18.12", "pg": "8.16.3", "postgres": "3.4.7", @@ -43,7 +43,7 @@ "@iconify-json/mingcute": "catalog:", "@tailwindcss/postcss": "catalog:", "@tailwindcss/typography": "catalog:", - "@types/node": "24.8.1", + "@types/node": "24.9.1", "@types/pg": "8.15.5", "@types/react": "19.2.2", "@types/react-dom": "19.2.2", @@ -51,7 +51,7 @@ "cross-env": "10.1.0", "dotenv-expand": "catalog:", "drizzle-kit": "0.31.5", - "next": "15.5.6", + "next": "16.0.0", "postcss": "8.5.6", "tailwind-scrollbar": "catalog:", "tailwindcss": "catalog:", diff --git a/apps/ssr/src/app/(pages)/share/iframe/PhotoItem.tsx b/apps/ssr/src/app/(pages)/share/iframe/PhotoItem.tsx index 46a7af87..75416a74 100644 --- a/apps/ssr/src/app/(pages)/share/iframe/PhotoItem.tsx +++ b/apps/ssr/src/app/(pages)/share/iframe/PhotoItem.tsx @@ -9,7 +9,7 @@ import { } from '@afilmory/components/icons/index.tsx' import { thumbHashToDataURL } from 'thumbhash' -import { cn } from '~/lib/cn' +import { cn } from '@afilmory/utils' import { url } from '../../../../../../../config.json' diff --git a/apps/ssr/tsconfig.json b/apps/ssr/tsconfig.json index 636913c7..aec335ec 100644 --- a/apps/ssr/tsconfig.json +++ b/apps/ssr/tsconfig.json @@ -17,7 +17,7 @@ "resolveJsonModule": true, "isolatedModules": true, "allowImportingTsExtensions": true, - "jsx": "preserve", + "jsx": "react-jsx", "plugins": [ { "name": "next" diff --git a/apps/web/package.json b/apps/web/package.json index 2e1b3a2f..382073a4 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -19,23 +19,17 @@ }, "dependencies": { "@afilmory/data": "workspace:*", + "@afilmory/hooks": "workspace:*", "@afilmory/ssr-sdk": "workspace:*", + "@afilmory/ui": "workspace:*", + "@afilmory/utils": "workspace:*", "@afilmory/webgl-viewer": "workspace:*", "@essentials/request-timeout": "1.3.0", "@headlessui/react": "2.2.9", "@lobehub/fluent-emoji": "2.0.0", "@maplibre/maplibre-gl-geocoder": "^1.9.1", "@radix-ui/react-avatar": "1.1.10", - "@radix-ui/react-checkbox": "1.3.3", - "@radix-ui/react-context-menu": "2.2.16", - "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-dropdown-menu": "2.1.16", - "@radix-ui/react-hover-card": "1.1.15", - "@radix-ui/react-popover": "1.1.15", - "@radix-ui/react-scroll-area": "1.2.10", - "@radix-ui/react-slot": "1.2.3", - "@radix-ui/react-switch": "1.2.6", - "@radix-ui/react-tooltip": "1.2.8", "@react-hook/window-size": "3.1.1", "@remixicon/react": "4.7.0", "@t3-oss/env-core": "catalog:", @@ -46,7 +40,7 @@ "clsx": "2.1.1", "consola": "3.4.2", "dotenv": "17.2.3", - "es-toolkit": "1.40.0", + "es-toolkit": "1.41.0", "file-type": "^21.0.0", "foxact": "0.2.49", "heic-to": "1.3.0", @@ -63,7 +57,7 @@ "react-dom": "19.2.0", "react-error-boundary": "6.0.0", "react-freeze": "1.0.4", - "react-i18next": "16.1.0", + "react-i18next": "16.2.0", "react-image-gallery": "1.4.0", "react-intersection-observer": "9.16.0", "react-map-gl": "^8.1.0", @@ -73,7 +67,7 @@ "react-use-measure": "2.1.7", "react-zoom-pan-pinch": "3.7.0", "sonner": "2.0.7", - "swiper": "12.0.2", + "swiper": "12.0.3", "swr": "2.3.6", "tailwind-merge": "3.3.1", "tailwind-variants": "catalog:", @@ -90,15 +84,15 @@ "@iconify-json/ri": "^1.2.6", "@tailwindcss/postcss": "catalog:", "@tailwindcss/typography": "catalog:", - "@tailwindcss/vite": "4.1.14", - "@types/node": "24.8.1", + "@tailwindcss/vite": "4.1.16", + "@types/node": "24.9.1", "@types/react": "19.2.2", "@types/react-dom": "19.2.2", - "@vitejs/plugin-react": "^5.0.4", + "@vitejs/plugin-react": "^5.1.0", "ast-kit": "2.1.3", "babel-plugin-react-compiler": "19.1.0-rc.3", "code-inspector-plugin": "1.2.10", - "daisyui": "5.3.7", + "daisyui": "5.3.9", "execa": "9.6.0", "kolorist": "1.8.0", "postcss": "8.5.6", diff --git a/apps/web/src/components/common/ErrorElement.tsx b/apps/web/src/components/common/ErrorElement.tsx index b6e23730..c76dc47b 100644 --- a/apps/web/src/components/common/ErrorElement.tsx +++ b/apps/web/src/components/common/ErrorElement.tsx @@ -1,11 +1,10 @@ +import { Button } from '@afilmory/ui' import { repository } from '@pkg' import { useEffect, useRef } from 'react' import { isRouteErrorResponse, useRouteError } from 'react-router' import { attachOpenInEditor } from '~/lib/dev' -import { Button } from '../ui/button' - export function ErrorElement() { const error = useRouteError() const message = isRouteErrorResponse(error) diff --git a/apps/web/src/components/common/LoadRemixAsyncComponent.tsx b/apps/web/src/components/common/LoadRemixAsyncComponent.tsx deleted file mode 100644 index fdbe471b..00000000 --- a/apps/web/src/components/common/LoadRemixAsyncComponent.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import type { FC, ReactNode } from 'react' -import { createElement, useEffect, useState } from 'react' - -import { LoadingCircle } from '../ui/loading' - -export const LoadRemixAsyncComponent: FC<{ - loader: () => Promise - Header: FC<{ loader: () => any; [key: string]: any }> -}> = ({ loader, Header }) => { - const [loading, setLoading] = useState(true) - - const [Component, setComponent] = useState<{ c: () => ReactNode }>({ - c: () => null, - }) - - useEffect(() => { - let isUnmounted = false - setLoading(true) - loader() - .then((module) => { - if (!module.Component) { - return - } - if (isUnmounted) return - - const { loader } = module - setComponent({ - c: () => ( - <> -
- - - ), - }) - }) - .finally(() => { - setLoading(false) - }) - return () => { - isUnmounted = true - } - }, [Header, loader]) - - if (loading) { - return ( -
- -
- ) - } - - return createElement(Component.c) -} diff --git a/apps/web/src/components/common/NotFound.tsx b/apps/web/src/components/common/NotFound.tsx index a6ed8163..c8e57e11 100644 --- a/apps/web/src/components/common/NotFound.tsx +++ b/apps/web/src/components/common/NotFound.tsx @@ -1,7 +1,6 @@ +import { Button } from '@afilmory/ui' import { useLocation, useNavigate } from 'react-router' -import { Button } from '../ui/button' - export const NotFound = () => { const location = useLocation() diff --git a/apps/web/src/components/gallery/CommandPalette.tsx b/apps/web/src/components/gallery/CommandPalette.tsx index 90497d23..afeecaf6 100644 --- a/apps/web/src/components/gallery/CommandPalette.tsx +++ b/apps/web/src/components/gallery/CommandPalette.tsx @@ -1,4 +1,5 @@ import { photoLoader } from '@afilmory/data' +import { clsxm } from '@afilmory/utils' import { useAtom } from 'jotai' import * as React from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -8,7 +9,6 @@ import { useNavigate } from 'react-router' import { gallerySettingAtom } from '~/atoms/app' import { usePhotoViewer } from '~/hooks/usePhotoViewer' import { MageLens } from '~/icons' -import { clsxm } from '~/lib/cn' // Command types type CommandType = 'search' | 'filter' | 'action' | 'photo' diff --git a/apps/web/src/components/ui/date-range-indicator/DateRangeIndicator.tsx b/apps/web/src/components/ui/date-range-indicator/DateRangeIndicator.tsx index 5db2f9f9..651f4c05 100644 --- a/apps/web/src/components/ui/date-range-indicator/DateRangeIndicator.tsx +++ b/apps/web/src/components/ui/date-range-indicator/DateRangeIndicator.tsx @@ -1,10 +1,9 @@ +import { clsxm, Spring } from '@afilmory/utils' import { AnimatePresence, m } from 'motion/react' import { memo } from 'react' import { useTranslation } from 'react-i18next' import { useMobile } from '~/hooks/useMobile' -import { clsxm } from '~/lib/cn' -import { Spring } from '~/lib/spring' interface DateRangeIndicatorProps { dateRange: string diff --git a/apps/web/src/components/ui/lazy-image/index.tsx b/apps/web/src/components/ui/lazy-image/index.tsx index bd9f8b83..b41ca15c 100644 --- a/apps/web/src/components/ui/lazy-image/index.tsx +++ b/apps/web/src/components/ui/lazy-image/index.tsx @@ -1,8 +1,8 @@ import { useCallback, useState } from 'react' import { useInView } from 'react-intersection-observer' -import { Thumbhash } from '~/components/ui/thumbhash' -import { clsxm } from '~/lib/cn' +import { Thumbhash } from '@afilmory/ui' +import { clsxm } from '@afilmory/utils' export interface LazyImageProps { src: string diff --git a/apps/web/src/components/ui/loading.tsx b/apps/web/src/components/ui/loading.tsx deleted file mode 100644 index a278d2d3..00000000 --- a/apps/web/src/components/ui/loading.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { clsxm } from '~/lib/cn' - -interface LoadingCircleProps { - size: 'small' | 'medium' | 'large' -} - -const sizeMap = { - small: 'text-md', - medium: 'text-xl', - large: 'text-3xl', -} -export const LoadingCircle: Component = ({ - className, - size, -}) => ( -
- -
-) diff --git a/apps/web/src/components/ui/map/ClusterPhotoGrid.tsx b/apps/web/src/components/ui/map/ClusterPhotoGrid.tsx index 266a8550..a24e6acc 100644 --- a/apps/web/src/components/ui/map/ClusterPhotoGrid.tsx +++ b/apps/web/src/components/ui/map/ClusterPhotoGrid.tsx @@ -2,8 +2,8 @@ import { m } from 'motion/react' import { useTranslation } from 'react-i18next' import { Link } from 'react-router' -import { LazyImage } from '~/components/ui/lazy-image' -import { Spring } from '~/lib/spring' +import { LazyImage } from '@afilmory/ui' +import { Spring } from '@afilmory/utils' import type { PhotoMarker } from '~/types/map' interface ClusterPhotoGridProps { diff --git a/apps/web/src/components/ui/map/MapBackButton.tsx b/apps/web/src/components/ui/map/MapBackButton.tsx index 5eecbabd..8d2cc917 100644 --- a/apps/web/src/components/ui/map/MapBackButton.tsx +++ b/apps/web/src/components/ui/map/MapBackButton.tsx @@ -1,9 +1,8 @@ +import { GlassButton } from '@afilmory/ui' import { startTransition } from 'react' import { useTranslation } from 'react-i18next' import { useNavigate } from 'react-router' -import { GlassButton } from '../button/GlassButton' - export const MapBackButton = () => { const { t } = useTranslation() const navigate = useNavigate() diff --git a/apps/web/src/components/ui/map/shared/ClusterMarker.tsx b/apps/web/src/components/ui/map/shared/ClusterMarker.tsx index 879f17bc..8cb06730 100644 --- a/apps/web/src/components/ui/map/shared/ClusterMarker.tsx +++ b/apps/web/src/components/ui/map/shared/ClusterMarker.tsx @@ -1,12 +1,8 @@ import { m } from 'motion/react' import { Marker } from 'react-map-gl/maplibre' -import { - HoverCard, - HoverCardContent, - HoverCardTrigger, -} from '~/components/ui/hover-card' -import { LazyImage } from '~/components/ui/lazy-image' +import { HoverCard, HoverCardContent, HoverCardTrigger } from '@afilmory/ui' +import { LazyImage } from '@afilmory/ui' import { ClusterPhotoGrid } from '../ClusterPhotoGrid' import type { ClusterMarkerProps } from './types' diff --git a/apps/web/src/components/ui/map/shared/PhotoMarkerPin.tsx b/apps/web/src/components/ui/map/shared/PhotoMarkerPin.tsx index 1ccdd91f..29730337 100644 --- a/apps/web/src/components/ui/map/shared/PhotoMarkerPin.tsx +++ b/apps/web/src/components/ui/map/shared/PhotoMarkerPin.tsx @@ -1,14 +1,13 @@ -import { m } from 'motion/react' -import { Marker } from 'react-map-gl/maplibre' -import { Link } from 'react-router' - -import { GlassButton } from '~/components/ui/button/GlassButton' import { + GlassButton, HoverCard, HoverCardContent, HoverCardTrigger, -} from '~/components/ui/hover-card' -import { LazyImage } from '~/components/ui/lazy-image' + LazyImage, +} from '@afilmory/ui' +import { m } from 'motion/react' +import { Marker } from 'react-map-gl/maplibre' +import { Link } from 'react-router' import type { PhotoMarkerPinProps } from './types' diff --git a/apps/web/src/components/ui/number/SlidingNumber.tsx b/apps/web/src/components/ui/number/SlidingNumber.tsx index 04f491be..9b3195fd 100644 --- a/apps/web/src/components/ui/number/SlidingNumber.tsx +++ b/apps/web/src/components/ui/number/SlidingNumber.tsx @@ -1,13 +1,11 @@ 'use client' +import { clsxm, Spring } from '@afilmory/utils' import type { MotionValue, SpringOptions, UseInViewOptions } from 'motion/react' import { m as motion, useInView, useSpring, useTransform } from 'motion/react' import * as React from 'react' import useMeasure from 'react-use-measure' -import { clsxm } from '~/lib/cn' -import { Spring } from '~/lib/spring' - type SlidingNumberRollerProps = { prevValue: number value: number diff --git a/apps/web/src/components/ui/photo-viewer/DOMImageViewer.tsx b/apps/web/src/components/ui/photo-viewer/DOMImageViewer.tsx index 57ad0064..d2264862 100644 --- a/apps/web/src/components/ui/photo-viewer/DOMImageViewer.tsx +++ b/apps/web/src/components/ui/photo-viewer/DOMImageViewer.tsx @@ -6,7 +6,7 @@ import type { } from 'react-zoom-pan-pinch' import { TransformComponent, TransformWrapper } from 'react-zoom-pan-pinch' -import { clsxm } from '~/lib/cn' +import { clsxm } from '@afilmory/utils' import type { DOMImageViewerProps } from './types' diff --git a/apps/web/src/components/ui/photo-viewer/ExifPanel.tsx b/apps/web/src/components/ui/photo-viewer/ExifPanel.tsx index f5934f64..e8cba634 100644 --- a/apps/web/src/components/ui/photo-viewer/ExifPanel.tsx +++ b/apps/web/src/components/ui/photo-viewer/ExifPanel.tsx @@ -1,6 +1,8 @@ import './PhotoViewer.css' import type { PhotoManifestItem, PickedExif } from '@afilmory/builder' +import { MotionButtonBase, ScrollArea } from '@afilmory/ui' +import { Spring } from '@afilmory/utils' import { isNil } from 'es-toolkit/compat' import { useAtomValue } from 'jotai' import { m } from 'motion/react' @@ -9,7 +11,6 @@ import { Fragment, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { isExiftoolLoadedAtom } from '~/atoms/app' -import { ScrollArea } from '~/components/ui/scroll-areas/ScrollArea' import { useMobile } from '~/hooks/useMobile' import { CarbonIsoOutline, @@ -20,9 +21,7 @@ import { } from '~/icons' import { getImageFormat } from '~/lib/image-utils' import { convertExifGPSToDecimal } from '~/lib/map-utils' -import { Spring } from '~/lib/spring' -import { MotionButtonBase } from '../button' import { formatExifData, Row } from './formatExifData' import { HistogramChart } from './HistogramChart' import { MiniMap } from './MiniMap' diff --git a/apps/web/src/components/ui/photo-viewer/GalleryThumbnail.tsx b/apps/web/src/components/ui/photo-viewer/GalleryThumbnail.tsx index 19fe2974..b9bc89b4 100644 --- a/apps/web/src/components/ui/photo-viewer/GalleryThumbnail.tsx +++ b/apps/web/src/components/ui/photo-viewer/GalleryThumbnail.tsx @@ -1,15 +1,13 @@ +import { Thumbhash } from '@afilmory/ui' +import { clsxm, Spring } from '@afilmory/utils' import { m } from 'motion/react' import type { FC } from 'react' import { useEffect, useRef, useState } from 'react' import { useMobile } from '~/hooks/useMobile' -import { clsxm } from '~/lib/cn' import { nextFrame } from '~/lib/dom' -import { Spring } from '~/lib/spring' import type { PhotoManifest } from '~/types/photo' -import { Thumbhash } from '../thumbhash' - const thumbnailSize = { mobile: 48, desktop: 64, diff --git a/apps/web/src/components/ui/photo-viewer/HDRBadge.tsx b/apps/web/src/components/ui/photo-viewer/HDRBadge.tsx index c1079605..5069a9ea 100644 --- a/apps/web/src/components/ui/photo-viewer/HDRBadge.tsx +++ b/apps/web/src/components/ui/photo-viewer/HDRBadge.tsx @@ -1,6 +1,6 @@ import type { FC } from 'react' -import { clsxm } from '~/lib/cn' +import { clsxm } from '@afilmory/utils' export const HDRBadge: FC = () => { return ( diff --git a/apps/web/src/components/ui/photo-viewer/HistogramChart.tsx b/apps/web/src/components/ui/photo-viewer/HistogramChart.tsx index 482f3a97..0b4a6f1c 100644 --- a/apps/web/src/components/ui/photo-viewer/HistogramChart.tsx +++ b/apps/web/src/components/ui/photo-viewer/HistogramChart.tsx @@ -2,7 +2,7 @@ import type { FC } from 'react' import { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { cx } from '~/lib/cn' +import { cx } from '@afilmory/utils' interface CompressedHistogramData { red: number[] diff --git a/apps/web/src/components/ui/photo-viewer/LivePhotoBadge.tsx b/apps/web/src/components/ui/photo-viewer/LivePhotoBadge.tsx index 9e30d472..6c504fb3 100644 --- a/apps/web/src/components/ui/photo-viewer/LivePhotoBadge.tsx +++ b/apps/web/src/components/ui/photo-viewer/LivePhotoBadge.tsx @@ -3,7 +3,7 @@ import type { FC } from 'react' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' -import { clsxm } from '~/lib/cn' +import { clsxm } from '@afilmory/utils' import { isMobileDevice } from '~/lib/device-viewport' import type { LivePhotoBadgeProps } from './types' diff --git a/apps/web/src/components/ui/photo-viewer/LivePhotoVideo.tsx b/apps/web/src/components/ui/photo-viewer/LivePhotoVideo.tsx index 4b15b163..b5ee9845 100644 --- a/apps/web/src/components/ui/photo-viewer/LivePhotoVideo.tsx +++ b/apps/web/src/components/ui/photo-viewer/LivePhotoVideo.tsx @@ -7,7 +7,7 @@ import { useState, } from 'react' -import { clsxm } from '~/lib/cn' +import { clsxm } from '@afilmory/utils' import type { ImageLoaderManager } from '~/lib/image-loader-manager' import type { LoadingIndicatorRef } from './LoadingIndicator' diff --git a/apps/web/src/components/ui/photo-viewer/PhotoViewer.tsx b/apps/web/src/components/ui/photo-viewer/PhotoViewer.tsx index 155568a5..2113355c 100644 --- a/apps/web/src/components/ui/photo-viewer/PhotoViewer.tsx +++ b/apps/web/src/components/ui/photo-viewer/PhotoViewer.tsx @@ -3,6 +3,8 @@ import './PhotoViewer.css' import 'swiper/css' import 'swiper/css/navigation' +import { Thumbhash } from '@afilmory/ui' +import { Spring } from '@afilmory/utils' import { AnimatePresence, m } from 'motion/react' import { Fragment, @@ -19,10 +21,8 @@ import { Swiper, SwiperSlide } from 'swiper/react' import { injectConfig } from '~/config' import { useMobile } from '~/hooks/useMobile' -import { Spring } from '~/lib/spring' import type { PhotoManifest } from '~/types/photo' -import { Thumbhash } from '../thumbhash' import { PhotoViewerTransitionPreview } from './animations/PhotoViewerTransitionPreview' import { usePhotoViewerTransitions } from './animations/usePhotoViewerTransitions' import { ExifPanel } from './ExifPanel' diff --git a/apps/web/src/components/ui/photo-viewer/ProgressiveImage.tsx b/apps/web/src/components/ui/photo-viewer/ProgressiveImage.tsx index 99dd9bae..69fef98e 100644 --- a/apps/web/src/components/ui/photo-viewer/ProgressiveImage.tsx +++ b/apps/web/src/components/ui/photo-viewer/ProgressiveImage.tsx @@ -6,7 +6,7 @@ import type { ReactZoomPanPinchRef } from 'react-zoom-pan-pinch' import { useMediaQuery } from 'usehooks-ts' import { useShowContextMenu } from '~/atoms/context-menu' -import { clsxm } from '~/lib/cn' +import { clsxm } from '@afilmory/utils' import { canUseWebGL } from '~/lib/feature' import { SlidingNumber } from '../number/SlidingNumber' diff --git a/apps/web/src/components/ui/photo-viewer/RawExifViewer.tsx b/apps/web/src/components/ui/photo-viewer/RawExifViewer.tsx index 8e67300e..3e0cb527 100644 --- a/apps/web/src/components/ui/photo-viewer/RawExifViewer.tsx +++ b/apps/web/src/components/ui/photo-viewer/RawExifViewer.tsx @@ -9,8 +9,8 @@ import { DialogHeader, DialogTitle, DialogTrigger, -} from '~/components/ui/dialog' -import { ScrollArea } from '~/components/ui/scroll-areas/ScrollArea' +} from '@afilmory/ui' +import { ScrollArea } from '@afilmory/ui' import { ExifToolManager } from '~/lib/exiftool' import type { PhotoManifest } from '~/types/photo' diff --git a/apps/web/src/components/ui/photo-viewer/Reaction.tsx b/apps/web/src/components/ui/photo-viewer/Reaction.tsx index 0f0ec85e..e9641e1b 100644 --- a/apps/web/src/components/ui/photo-viewer/Reaction.tsx +++ b/apps/web/src/components/ui/photo-viewer/Reaction.tsx @@ -1,3 +1,4 @@ +import { clsxm, Spring } from '@afilmory/utils' import { FluentEmoji, getEmoji } from '@lobehub/fluent-emoji' import * as DropdownMenu from '@radix-ui/react-dropdown-menu' import { produce } from 'immer' @@ -10,8 +11,6 @@ import { tv } from 'tailwind-variants' import { useOnClickOutside } from 'usehooks-ts' import { client } from '~/lib/client' -import { clsxm } from '~/lib/cn' -import { Spring } from '~/lib/spring' import { useAnalysis } from './hooks/useAnalysis' diff --git a/apps/web/src/components/ui/photo-viewer/SharePanel.tsx b/apps/web/src/components/ui/photo-viewer/SharePanel.tsx index 6185bd52..cfe15bb2 100644 --- a/apps/web/src/components/ui/photo-viewer/SharePanel.tsx +++ b/apps/web/src/components/ui/photo-viewer/SharePanel.tsx @@ -1,3 +1,5 @@ +import { RootPortal } from '@afilmory/ui' +import { clsxm, Spring } from '@afilmory/utils' import { siteConfig } from '@config' import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu' import { AnimatePresence, m } from 'motion/react' @@ -6,12 +8,8 @@ import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { injectConfig } from '~/config' -import { clsxm } from '~/lib/cn' -import { Spring } from '~/lib/spring' import type { PhotoManifest } from '~/types/photo' -import { RootPortal } from '../portal' - interface SharePanelProps { photo: PhotoManifest trigger: React.ReactNode diff --git a/apps/web/src/components/ui/photo-viewer/animations/PhotoViewerTransitionPreview.tsx b/apps/web/src/components/ui/photo-viewer/animations/PhotoViewerTransitionPreview.tsx index 523f510d..d7774ce7 100644 --- a/apps/web/src/components/ui/photo-viewer/animations/PhotoViewerTransitionPreview.tsx +++ b/apps/web/src/components/ui/photo-viewer/animations/PhotoViewerTransitionPreview.tsx @@ -1,7 +1,7 @@ import { m } from 'motion/react' -import { Thumbhash } from '~/components/ui/thumbhash' -import { Spring } from '~/lib/spring' +import { Thumbhash } from '@afilmory/ui' +import { Spring } from '@afilmory/utils' import type { PhotoViewerTransition } from './types' diff --git a/apps/web/src/components/ui/photo-viewer/formatExifData.tsx b/apps/web/src/components/ui/photo-viewer/formatExifData.tsx index 43b718af..f30f753b 100644 --- a/apps/web/src/components/ui/photo-viewer/formatExifData.tsx +++ b/apps/web/src/components/ui/photo-viewer/formatExifData.tsx @@ -1,11 +1,10 @@ import type { FujiRecipe, PickedExif } from '@afilmory/builder' +import { EllipsisHorizontalTextWithTooltip } from '@afilmory/ui' import type { FC } from 'react' import { i18nAtom } from '~/i18n' import { jotaiStore } from '~/lib/jotai' -import { EllipsisHorizontalTextWithTooltip } from '../typography/EllipsisWithTooltip' - // Helper function to clean up EXIF values by removing unnecessary characters const cleanExifValue = (value: string | null | undefined): string | null => { if (!value) return null diff --git a/apps/web/src/components/ui/slider.tsx b/apps/web/src/components/ui/slider.tsx index 50e66b98..2c0490b7 100644 --- a/apps/web/src/components/ui/slider.tsx +++ b/apps/web/src/components/ui/slider.tsx @@ -1,7 +1,7 @@ import { useCallback, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { clsxm } from '~/lib/cn' +import { clsxm } from '@afilmory/utils' interface SliderProps { value: number | 'auto' diff --git a/apps/web/src/hooks/common/index.ts b/apps/web/src/hooks/common/index.ts deleted file mode 100644 index 32978cd7..00000000 --- a/apps/web/src/hooks/common/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './useDark' -export * from './usePrevious' -export * from './useRefValue' -export * from './useTitle' diff --git a/apps/web/src/hooks/common/useDark.ts b/apps/web/src/hooks/common/useDark.ts deleted file mode 100644 index dd21d7c8..00000000 --- a/apps/web/src/hooks/common/useDark.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { useAtomValue } from 'jotai' -import { atomWithStorage } from 'jotai/utils' -import { useCallback, useLayoutEffect } from 'react' -import { useMediaQuery } from 'usehooks-ts' - -import { nextFrame } from '~/lib/dom' -import { jotaiStore } from '~/lib/jotai' - -const useDarkQuery = () => useMediaQuery('(prefers-color-scheme: dark)') -type ColorMode = 'light' | 'dark' | 'system' -const themeAtom = atomWithStorage( - 'color-mode', - 'system' as ColorMode, - undefined, - { - getOnInit: true, - }, -) - -function useDarkWebApp() { - const systemIsDark = useDarkQuery() - const mode = useAtomValue(themeAtom) - return mode === 'dark' || (mode === 'system' && systemIsDark) -} -export const useIsDark = useDarkWebApp - -export const useThemeAtomValue = () => useAtomValue(themeAtom) - -const useSyncThemeWebApp = () => { - const colorMode = useAtomValue(themeAtom) - const systemIsDark = useDarkQuery() - useLayoutEffect(() => { - const realColorMode: Exclude = - colorMode === 'system' ? (systemIsDark ? 'dark' : 'light') : colorMode - document.documentElement.dataset.theme = realColorMode - disableTransition(['[role=switch]>*'])() - }, [colorMode, systemIsDark]) -} - -export const useSyncThemeark = useSyncThemeWebApp - -export const useSetTheme = () => - useCallback((colorMode: ColorMode) => { - jotaiStore.set(themeAtom, colorMode) - }, []) - -function disableTransition(disableTransitionExclude: string[] = []) { - const css = document.createElement('style') - css.append( - document.createTextNode( - ` -*${disableTransitionExclude.map((s) => `:not(${s})`).join('')} { - -webkit-transition: none !important; - -moz-transition: none !important; - -o-transition: none !important; - -ms-transition: none !important; - transition: none !important; -} - `, - ), - ) - document.head.append(css) - - return () => { - // Force restyle - ;(() => window.getComputedStyle(document.body))() - - // Wait for next tick before removing - nextFrame(() => { - css.remove() - }) - } -} diff --git a/apps/web/src/hooks/common/useTitle.ts b/apps/web/src/hooks/useTitle.ts similarity index 100% rename from apps/web/src/hooks/common/useTitle.ts rename to apps/web/src/hooks/useTitle.ts diff --git a/apps/web/src/lib/color.ts b/apps/web/src/lib/color.ts index a1fc6e73..8faae31c 100644 --- a/apps/web/src/lib/color.ts +++ b/apps/web/src/lib/color.ts @@ -1,4 +1,4 @@ -import { decompressUint8Array } from '@afilmory/builder' +import { decompressUint8Array } from '@afilmory/utils' import { thumbHashToDataURL } from 'thumbhash' const BG_HEX = '#1c1c1e' @@ -8,7 +8,11 @@ export type RGB = { r: number; g: number; b: number } export const hexToRgb = (hex: string): RGB | null => { const m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex) if (!m) return null - return { r: Number.parseInt(m[1], 16), g: Number.parseInt(m[2], 16), b: Number.parseInt(m[3], 16) } + return { + r: Number.parseInt(m[1], 16), + g: Number.parseInt(m[2], 16), + b: Number.parseInt(m[3], 16), + } } export const rgbToHex = ({ r, g, b }: RGB): string => { diff --git a/apps/web/src/modules/gallery/ActionGroup.tsx b/apps/web/src/modules/gallery/ActionGroup.tsx index dd5df147..32b97f9c 100644 --- a/apps/web/src/modules/gallery/ActionGroup.tsx +++ b/apps/web/src/modules/gallery/ActionGroup.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next' import { useNavigate } from 'react-router' import { gallerySettingAtom, isCommandPaletteOpenAtom } from '~/atoms/app' -import { Button } from '~/components/ui/button' +import { Button } from '@afilmory/ui' import { ResponsiveActionButton } from './components/ActionButton' import { ViewPanel } from './panels/ViewPanel' diff --git a/apps/web/src/modules/gallery/FloatingActionButton.tsx b/apps/web/src/modules/gallery/FloatingActionButton.tsx index f703c2f8..496f2aea 100644 --- a/apps/web/src/modules/gallery/FloatingActionButton.tsx +++ b/apps/web/src/modules/gallery/FloatingActionButton.tsx @@ -3,9 +3,9 @@ import { AnimatePresence, m, useAnimation } from 'motion/react' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Button } from '~/components/ui/button' -import { clsxm } from '~/lib/cn' -import { Spring } from '~/lib/spring' +import { Button } from '@afilmory/ui' +import { clsxm } from '@afilmory/utils' +import { Spring } from '@afilmory/utils' type TranslationKeys = keyof typeof en diff --git a/apps/web/src/modules/gallery/Masonic.tsx b/apps/web/src/modules/gallery/Masonic.tsx index aa0bb9bf..60710e29 100644 --- a/apps/web/src/modules/gallery/Masonic.tsx +++ b/apps/web/src/modules/gallery/Masonic.tsx @@ -20,7 +20,7 @@ import { import { useForceUpdate } from 'motion/react' import * as React from 'react' -import { useScrollViewElement } from '~/components/ui/scroll-areas/hooks' +import { useScrollViewElement } from '@afilmory/ui' export interface MasonryRef { reposition: () => void diff --git a/apps/web/src/modules/gallery/MasonryHeaderMasonryItem.tsx b/apps/web/src/modules/gallery/MasonryHeaderMasonryItem.tsx index 3a0178a9..c9ec88fc 100644 --- a/apps/web/src/modules/gallery/MasonryHeaderMasonryItem.tsx +++ b/apps/web/src/modules/gallery/MasonryHeaderMasonryItem.tsx @@ -4,7 +4,7 @@ import * as AvatarPrimitive from '@radix-ui/react-avatar' import { useTranslation } from 'react-i18next' import { usePhotos } from '~/hooks/usePhotoViewer' -import { clsxm } from '~/lib/cn' +import { clsxm } from '@afilmory/utils' import { ActionGroup } from './ActionGroup' diff --git a/apps/web/src/modules/gallery/MasonryPhotoItem.tsx b/apps/web/src/modules/gallery/MasonryPhotoItem.tsx index ca90c1a6..885858f5 100644 --- a/apps/web/src/modules/gallery/MasonryPhotoItem.tsx +++ b/apps/web/src/modules/gallery/MasonryPhotoItem.tsx @@ -3,7 +3,7 @@ import { m } from 'motion/react' import { Fragment, useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Thumbhash } from '~/components/ui/thumbhash' +import { Thumbhash } from '@afilmory/ui' import { useContextPhotos, usePhotoViewer } from '~/hooks/usePhotoViewer' import { CarbonIsoOutline, diff --git a/apps/web/src/modules/gallery/MasonryRoot.tsx b/apps/web/src/modules/gallery/MasonryRoot.tsx index ded8f6bf..a47fff73 100644 --- a/apps/web/src/modules/gallery/MasonryRoot.tsx +++ b/apps/web/src/modules/gallery/MasonryRoot.tsx @@ -1,16 +1,14 @@ +import { useScrollViewElement } from '@afilmory/ui' +import { clsxm, Spring } from '@afilmory/utils' import { useAtomValue } from 'jotai' import { AnimatePresence, m } from 'motion/react' import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { gallerySettingAtom } from '~/atoms/app' import { DateRangeIndicator } from '~/components/ui/date-range-indicator' -import { useScrollViewElement } from '~/components/ui/scroll-areas/hooks' import { useMobile } from '~/hooks/useMobile' import { useContextPhotos } from '~/hooks/usePhotoViewer' -import { useTypeScriptHappyCallback } from '~/hooks/useTypeScriptCallback' import { useVisiblePhotosDateRange } from '~/hooks/useVisiblePhotosDateRange' -import { clsxm } from '~/lib/cn' -import { Spring } from '~/lib/spring' import type { PhotoManifest } from '~/types/photo' import { ActionGroup } from './ActionGroup' @@ -175,7 +173,7 @@ export const MasonryRoot = () => { columnGutter={4} rowGutter={4} itemHeightEstimate={400} - itemKey={useTypeScriptHappyCallback((data, _index) => { + itemKey={useCallback((data, _index) => { if (data instanceof MasonryHeaderItem) { return 'header' } diff --git a/apps/web/src/modules/gallery/components/ActionButton.tsx b/apps/web/src/modules/gallery/components/ActionButton.tsx index 4bf7fa41..a8f6ee3f 100644 --- a/apps/web/src/modules/gallery/components/ActionButton.tsx +++ b/apps/web/src/modules/gallery/components/ActionButton.tsx @@ -3,14 +3,14 @@ import { useState } from 'react' import { Drawer } from 'vaul' import { gallerySettingAtom } from '~/atoms/app' -import { Button } from '~/components/ui/button' +import { Button } from '@afilmory/ui' import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger, -} from '~/components/ui/dropdown-menu' +} from '@afilmory/ui' import { useMobile } from '~/hooks/useMobile' -import { clsxm } from '~/lib/cn' +import { clsxm } from '@afilmory/utils' // 通用的操作按钮组件 export const ActionButton = ({ diff --git a/apps/web/src/pages/(data)/manifest.tsx b/apps/web/src/pages/(data)/manifest.tsx index b83a222b..a37dceb0 100644 --- a/apps/web/src/pages/(data)/manifest.tsx +++ b/apps/web/src/pages/(data)/manifest.tsx @@ -1,8 +1,8 @@ import { photoLoader } from '@afilmory/data' import { useMemo, useState } from 'react' -import { Button } from '~/components/ui/button' -import { ScrollArea } from '~/components/ui/scroll-areas/ScrollArea' +import { Button } from '@afilmory/ui' +import { ScrollArea } from '@afilmory/ui' // JSON 语法高亮组件 const JsonHighlight = ({ data }: { data: any }) => { diff --git a/apps/web/src/pages/(debug)/blurhash.tsx b/apps/web/src/pages/(debug)/blurhash.tsx index 5f083299..e1afccde 100644 --- a/apps/web/src/pages/(debug)/blurhash.tsx +++ b/apps/web/src/pages/(debug)/blurhash.tsx @@ -1,7 +1,7 @@ import { photoLoader } from '@afilmory/data' -import { ScrollArea } from '~/components/ui/scroll-areas/ScrollArea' -import { Thumbhash } from '~/components/ui/thumbhash' +import { ScrollArea } from '@afilmory/ui' +import { Thumbhash } from '@afilmory/ui' export const Component = () => { const photos = photoLoader.getPhotos() diff --git a/apps/web/src/pages/(debug)/iframe.tsx b/apps/web/src/pages/(debug)/iframe.tsx index 9c09ed7b..407544ce 100644 --- a/apps/web/src/pages/(debug)/iframe.tsx +++ b/apps/web/src/pages/(debug)/iframe.tsx @@ -1,4 +1,4 @@ -import { ScrollArea } from '~/components/ui/scroll-areas/ScrollArea' +import { ScrollArea } from '@afilmory/ui' export const Component = () => { return ( diff --git a/apps/web/src/pages/(debug)/webgl-preview.tsx b/apps/web/src/pages/(debug)/webgl-preview.tsx index a16c2eab..08a3db2f 100644 --- a/apps/web/src/pages/(debug)/webgl-preview.tsx +++ b/apps/web/src/pages/(debug)/webgl-preview.tsx @@ -1,7 +1,7 @@ import { WebGLImageViewer } from '@afilmory/webgl-viewer' import { useState } from 'react' -import { Button } from '~/components/ui/button' +import { Button } from '@afilmory/ui' import { useBlobUrl } from '~/lib/blob-url-manager' export const Component = () => { diff --git a/apps/web/src/pages/(main)/[photoId]/index.tsx b/apps/web/src/pages/(main)/[photoId]/index.tsx index 0fe9fc6e..10b2c42f 100644 --- a/apps/web/src/pages/(main)/[photoId]/index.tsx +++ b/apps/web/src/pages/(main)/[photoId]/index.tsx @@ -1,13 +1,12 @@ +import { RootPortal, RootPortalProvider } from '@afilmory/ui' import clsx from 'clsx' import { useEffect, useMemo, useState } from 'react' import { RemoveScroll } from 'react-remove-scroll' import { NotFound } from '~/components/common/NotFound' import { PhotoViewer } from '~/components/ui/photo-viewer' -import { RootPortal } from '~/components/ui/portal' -import { RootPortalProvider } from '~/components/ui/portal/provider' -import { useTitle } from '~/hooks/common' import { useContextPhotos, usePhotoViewer } from '~/hooks/usePhotoViewer' +import { useTitle } from '~/hooks/useTitle' import { deriveAccentFromSources } from '~/lib/color' export const Component = () => { diff --git a/apps/web/src/pages/(main)/layout.tsx b/apps/web/src/pages/(main)/layout.tsx index 3b209625..bfc4f9b7 100644 --- a/apps/web/src/pages/(main)/layout.tsx +++ b/apps/web/src/pages/(main)/layout.tsx @@ -1,4 +1,5 @@ import { photoLoader } from '@afilmory/data' +import { ScrollArea, ScrollElementContext } from '@afilmory/ui' import siteConfig from '@config' import { useAtomValue, useSetAtom } from 'jotai' import { useEffect, useRef } from 'react' @@ -11,8 +12,6 @@ import { } from 'react-router' import { gallerySettingAtom } from '~/atoms/app' -import { ScrollElementContext } from '~/components/ui/scroll-areas/ctx' -import { ScrollArea } from '~/components/ui/scroll-areas/ScrollArea' import { useMobile } from '~/hooks/useMobile' import { getFilteredPhotos, diff --git a/apps/web/src/providers/context-menu-provider.tsx b/apps/web/src/providers/context-menu-provider.tsx index 17e306cf..89de6e27 100644 --- a/apps/web/src/providers/context-menu-provider.tsx +++ b/apps/web/src/providers/context-menu-provider.tsx @@ -17,8 +17,8 @@ import { ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuTrigger, -} from '~/components/ui/context-menu' -import { clsxm as cn } from '~/lib/cn' +} from '@afilmory/ui' +import { clsxm as cn } from '@afilmory/utils' import { nextFrame, preventDefault } from '~/lib/dom' export const ContextMenuProvider: Component = ({ children }) => ( diff --git a/apps/web/src/providers/root-providers.tsx b/apps/web/src/providers/root-providers.tsx index 8f078c40..7f176364 100644 --- a/apps/web/src/providers/root-providers.tsx +++ b/apps/web/src/providers/root-providers.tsx @@ -1,10 +1,10 @@ +import { Toaster } from '@afilmory/ui' +import { Spring } from '@afilmory/utils' import { Provider } from 'jotai' import { domMax, LazyMotion, MotionConfig } from 'motion/react' import type { FC, PropsWithChildren } from 'react' -import { Toaster } from '~/components/ui/sonner' import { jotaiStore } from '~/lib/jotai' -import { Spring } from '~/lib/spring' import { ContextMenuProvider } from './context-menu-provider' import { EventProvider } from './event-provider' diff --git a/be/.env.example b/be/.env.example new file mode 100644 index 00000000..00eb92a0 --- /dev/null +++ b/be/.env.example @@ -0,0 +1,8 @@ + +PORT=1841 +DATABASE_URL=postgres://localhost:5432/afilmory +PG_CONN_TIMEOUT= +PG_POOL_MAX= +PG_IDLE_TIMEOUT= +REDIS_URL=localhost:6379 +CONFIG_ENCRYPTION_KEY=e77d740f107f7bf618cb3918a49b69ed5abfce2751a0e87925a3e72e2f986dc8 \ No newline at end of file diff --git a/be/.gitattributes b/be/.gitattributes new file mode 100644 index 00000000..72f43a41 --- /dev/null +++ b/be/.gitattributes @@ -0,0 +1 @@ +api.paw filter=lfs diff=lfs merge=lfs -text diff --git a/be/.gitignore b/be/.gitignore new file mode 100644 index 00000000..014ca335 --- /dev/null +++ b/be/.gitignore @@ -0,0 +1,143 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.* +!.env.example + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist +.output + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# Sveltekit cache directory +.svelte-kit/ + +# vitepress build output +**/.vitepress/dist + +# vitepress cache directory +**/.vitepress/cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# Firebase cache directory +.firebase/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v3 +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions + +# Vite files +vite.config.js.timestamp-* +vite.config.ts.timestamp-* +.vite/ + +coverage \ No newline at end of file diff --git a/be/.prettierrc b/be/.prettierrc new file mode 100644 index 00000000..91319657 --- /dev/null +++ b/be/.prettierrc @@ -0,0 +1,12 @@ +{ + "semi": false, + "singleQuote": true, + "trailingComma": "all", + "arrowParens": "always", + "printWidth": 120, + "tabWidth": 2, + "useTabs": false, + "bracketSpacing": true, + "bracketSameLine": false, + "jsxBracketSameLine": false +} diff --git a/be/AGENTS.md b/be/AGENTS.md new file mode 100644 index 00000000..39ea016a --- /dev/null +++ b/be/AGENTS.md @@ -0,0 +1,1829 @@ +# Hono Framework Developer Guide for AI Agents + +This document provides a comprehensive guide to the Hono-based enterprise framework for AI coding assistants. This framework is NestJS-inspired with Hono performance, featuring decorators, dependency injection, and a modular architecture. + +## 📋 Table of Contents + +- [Framework Overview](#framework-overview) +- [Core Concepts](#core-concepts) +- [Architecture Patterns](#architecture-patterns) +- [Decorators Reference](#decorators-reference) +- [Request Pipeline](#request-pipeline) +- [Dependency Injection](#dependency-injection) +- [Common Implementation Patterns](#common-implementation-patterns) +- [Testing Strategy](#testing-strategy) + +## Framework Overview + +### What is This Framework? + +This is a custom web framework built on top of Hono that provides: + +- **Decorator-based routing** (similar to NestJS) +- **Dependency injection** via `tsyringe` +- **Request-scoped context** using `AsyncLocalStorage` +- **Extensible enhancers** (Guards, Pipes, Interceptors, Filters) +- **Type-safe validation** with Zod +- **Lifecycle hooks** for startup/shutdown management + +### Key Framework Packages + +| Package | Purpose | +| ---------------------- | -------------------------------------------------------------- | +| `@afilmory/framework` | Core framework with decorators, DI, HTTP context, logger, etc. | +| `@afilmory/db` | Drizzle ORM schema and migrations | +| `@afilmory/env` | Runtime environment validation | +| `@afilmory/redis` | Redis client factory with strong typing | +| `@afilmory/task-queue` | Task queue implementation with in-memory and Redis drivers | +| `@afilmory/websocket` | WebSocket gateway with Redis pub/sub | + +## Core Concepts + +### 1. Modules + +Modules are the fundamental building blocks that organize your application into cohesive feature sets. + +**Module Structure:** + +```typescript +import { Module } from '@afilmory/framework' + +@Module({ + imports: [OtherModule], // Import other modules + controllers: [UserController], // HTTP endpoints + providers: [UserService], // Injectable services +}) +export class UserModule {} +``` + +**Key Points:** + +- Modules are **singletons** - only instantiated once +- `imports` - Include other modules to access their exported providers +- `controllers` - Define HTTP route handlers +- `providers` - Services, repositories, utilities available for DI +- Use `forwardRef(() => Module)` for circular dependencies + +### 2. Controllers + +Controllers handle HTTP requests and define routes using decorators. + +**Basic Controller:** + +```typescript +import { Controller, Get, Post, Body, Param, Query } from '@afilmory/framework' + +@Controller('users') // Base path: /users +export class UserController { + constructor(private readonly userService: UserService) {} + + @Get('/') + async findAll(@Query('limit') limit?: string) { + return this.userService.findAll(Number(limit) || 10) + } + + @Get('/:id') + async findOne(@Param('id') id: string) { + return this.userService.findById(id) + } + + @Post('/') + async create(@Body() createUserDto: CreateUserDto) { + return this.userService.create(createUserDto) + } +} +``` + +**Key Points:** + +- Controllers **must** have `@Controller(prefix)` decorator +- The `prefix` is the base path for all routes in the controller +- Route methods use HTTP decorators: `@Get()`, `@Post()`, `@Put()`, `@Patch()`, `@Delete()` +- Constructor injection automatically resolves dependencies + +### 3. Providers (Services) + +Providers are injectable classes that contain business logic. + +```typescript +import { injectable } from 'tsyringe' + +@injectable() +export class UserService { + constructor( + private readonly dbAccessor: DbAccessor, + private readonly redis: RedisAccessor, + ) {} + + async findById(id: string) { + const db = this.dbAccessor.get() + return db.query.users.findFirst({ + where: eq(schema.users.id, id), + }) + } + + async create(data: CreateUserInput) { + const db = this.dbAccessor.get() + const [user] = await db.insert(schema.users).values(data).returning() + return user + } +} +``` + +**Key Points:** + +- Providers **must** have `@injectable()` decorator from `tsyringe` +- Registered in module's `providers` array +- Use constructor injection for dependencies +- Should contain reusable business logic + +### 4. Request Context (HttpContext) + +The framework provides a request-scoped context using Node's `AsyncLocalStorage`. + +**Accessing Context:** + +```typescript +import { HttpContext } from '@afilmory/framework' + +// In any service, guard, interceptor, or pipe +@injectable() +export class AuditService { + logRequest() { + const honoContext = HttpContext.getValue('hono') + const path = honoContext.req.path + const method = honoContext.req.method + console.log(`Request: ${method} ${path}`) + } +} + +// Or get the entire context +const context = HttpContext.get() +const honoContext = context.hono +``` + +**Setting Custom Values:** + +```typescript +// Extend the context type +declare module '@afilmory/framework' { + interface HttpContextValues { + userId?: string + requestId?: string + } +} + +// In a guard or interceptor +HttpContext.setValue('userId', '123') +HttpContext.assign({ userId: '123', requestId: 'abc' }) +``` + +**Key Points:** + +- Context is **automatically** managed per request +- Available in guards, pipes, interceptors, filters, and services +- Use `HttpContext.getValue('hono')` to access Hono's `Context` +- Can be extended with custom properties via module augmentation + +## Architecture Patterns + +### Application Bootstrap + +**Standard Bootstrap Pattern:** + +```typescript +import 'reflect-metadata' +import { serve } from '@hono/node-server' +import { createApplication } from '@afilmory/framework' + +async function bootstrap() { + // Create the application + const app = await createApplication(AppModule, { + globalPrefix: '/api', // Optional: all routes prefixed with /api + }) + + // Register global enhancers + app.useGlobalPipes(ValidationPipe) + app.useGlobalGuards(AuthGuard) + app.useGlobalInterceptors(LoggingInterceptor) + app.useGlobalFilters(AllExceptionsFilter) + + // Get the underlying Hono instance + const hono = app.getInstance() + + // Start the server + serve({ + fetch: hono.fetch, + port: 3000, + hostname: '0.0.0.0', + }) +} + +bootstrap() +``` + +**Key Points:** + +- `reflect-metadata` **must** be imported at the top +- `createApplication` is async and returns `HonoHttpApplication` +- Global enhancers apply to **all** routes +- Access Hono instance via `app.getInstance()` for middleware + +### Module Organization + +**Root Module Pattern:** + +```typescript +import { Module } from '@afilmory/framework' +import { DatabaseModule } from './database/database.module' +import { RedisModule } from './redis/redis.module' +import { UserModule } from './modules/user/user.module' +import { AuthModule } from './modules/auth/auth.module' + +@Module({ + imports: [ + DatabaseModule, // Infrastructure modules first + RedisModule, + UserModule, // Feature modules + AuthModule, + ], +}) +export class AppModule {} +``` + +**Key Points:** + +- Root module typically has no controllers/providers +- Import infrastructure modules (DB, Redis) first +- Feature modules come after infrastructure +- Each feature should be self-contained + +### Infrastructure Modules (Database & Redis) + +**Database Module Pattern:** + +```typescript +import { Module } from '@afilmory/framework' +import { DbAccessor } from './database.provider' + +@Module({ + providers: [DbAccessor], +}) +export class DatabaseModule {} + +// Provider +@injectable() +export class DbAccessor { + private db: ReturnType | null = null + + constructor() { + // Initialize connection pool + const pool = new Pool({ connectionString: env.DATABASE_URL }) + this.db = drizzle(pool, { schema }) + } + + get() { + if (!this.db) { + throw new Error('Database not initialized') + } + return this.db + } +} +``` + +**Redis Module Pattern:** + +```typescript +import { Module } from '@afilmory/framework' +import { RedisAccessor } from './redis.provider' + +@Module({ + providers: [RedisAccessor], +}) +export class RedisModule {} + +// Provider +@injectable() +export class RedisAccessor { + private client: Redis + + constructor() { + this.client = new Redis(env.REDIS_URL) + } + + get(): Redis { + return this.client + } +} +``` + +**Key Points:** + +- Infrastructure providers use **accessor pattern** with `.get()` method +- There aren't `exports` in module, this is different from NestJS +- Initialize connections in constructor +- Implement lifecycle hooks (`OnModuleDestroy`) for cleanup + +## Decorators Reference + +### Module Decorators + +```typescript +// Define a module +@Module({ + imports: [FeatureModule], // Other modules to import + controllers: [MyController], // HTTP endpoints + providers: [MyService], // Injectable services +}) +export class MyModule {} + +// Forward reference for circular dependencies +@Module({ + imports: [forwardRef(() => CircularModule)], +}) +export class MyModule {} +``` + +### Controller & Route Decorators + +```typescript +// Controller base path +@Controller('api/v1/users') +export class UserController {} + +// HTTP method decorators +@Get('/path') // GET request +@Post('/path') // POST request +@Put('/path') // PUT request +@Patch('/path') // PATCH request +@Delete('/path') // DELETE request +@Options('/path') // OPTIONS request +@Head('/path') // HEAD request +``` + +### Parameter Decorators + +```typescript +class MyController { + @Get('/:id') + async handler( + @Param('id') id: string, // Route parameter + @Query('search') search?: string, // Query string parameter + @Body() body: CreateDto, // Request body (auto-parsed JSON) + @Headers('authorization') auth?: string, // Specific header + @Headers() allHeaders: Headers, // All headers + @Req() request: HonoRequest, // Hono request object + @ContextParam() context: Context, // Hono context + context: Context, // Inferred context (if no decorator) + ) { + // Handler logic + } +} +``` + +**Parameter with Pipes:** + +```typescript +// Apply pipe to specific parameter +@Get('/:id') +async findOne(@Param('id', ParseIntPipe) id: number) { + // id is now a number (transformed by pipe) +} + +// Multiple pipes +@Post('/') +async create(@Body(ValidationPipe, TransformPipe) data: CreateDto) { + // data is validated then transformed +} +``` + +### Enhancer Decorators + +```typescript +// Guards - Authorization/Authentication +@UseGuards(AuthGuard, RolesGuard) +@Get('/protected') +async protectedRoute() {} + +// Pipes - Validation/Transformation +@UsePipes(ValidationPipe, TransformPipe) +@Post('/data') +async create() {} + +// Interceptors - Modify request/response +@UseInterceptors(LoggingInterceptor, CacheInterceptor) +@Get('/data') +async getData() {} + +// Exception Filters - Error handling +@UseFilters(HttpExceptionFilter, ValidationExceptionFilter) +@Post('/risky') +async riskyOperation() {} +``` + +**Scope:** + +- **Method level**: Apply to specific route handler +- **Controller level**: Apply to all routes in controller +- **Global level**: Apply to all routes in application (via `app.useGlobal*()`) + +### Validation Decorators (Zod) + +```typescript +import { z } from 'zod' +import { createZodSchemaDto } from '@afilmory/framework' + +// Define schema +const CreateUserSchema = z.object({ + email: z.string().email(), + name: z.string().min(1), + age: z.number().int().positive().optional(), +}) + +// Create DTO class +class CreateUserDto extends createZodSchemaDto(CreateUserSchema) {} + +// Use in controller +@Controller('users') +export class UserController { + @Post('/') + async create(@Body() data: CreateUserDto) { + // data is validated and typed + } +} +``` + +## Request Pipeline + +### Execution Order + +When a request hits an endpoint, the framework processes it through these phases: + +``` +Request + ↓ +1. HttpContext.run() - Establish request scope + ↓ +2. Guards - Check permissions (global → controller → method) + ↓ +3. Interceptors (before) - Pre-processing (global → controller → method) + ↓ +4. Pipes - Parameter validation/transformation + ↓ +5. Controller Handler - Your business logic + ↓ +6. Interceptors (after) - Post-processing (reverse order) + ↓ +7. Exception Filters - Error handling (if error thrown) + ↓ +Response +``` + +### 1. Guards + +Guards determine whether a request should be handled by the route. + +**Guard Implementation:** + +```typescript +import { injectable } from 'tsyringe' +import { CanActivate, ExecutionContext, UnauthorizedException, HttpContext } from '@afilmory/framework' + +@injectable() +export class AuthGuard implements CanActivate { + async canActivate(context: ExecutionContext): Promise { + const httpContext = context.switchToHttp().getContext() + const honoContext = httpContext.hono + + const token = honoContext.req.header('authorization') + + if (!token) { + throw new UnauthorizedException('Missing authorization token') + } + + // Validate token + const user = await this.validateToken(token) + + if (!user) { + return false // Returns 403 Forbidden + } + + // Store user in context for later use + HttpContext.assign({ user }) + + return true + } + + private async validateToken(token: string) { + // Token validation logic + } +} +``` + +**Usage:** + +```typescript +@Controller('admin') +@UseGuards(AuthGuard, AdminGuard) // All routes protected +export class AdminController { + @Get('/dashboard') + async getDashboard() { + // Only reached if guards pass + } + + @Get('/public') + async getPublic() { + // Still protected by controller-level guards + } +} +``` + +**Key Points:** + +- Return `false` → 403 Forbidden (automatic) +- Throw exception → Custom error response +- Guards run in order: global → controller → method +- Use for authentication, authorization, rate limiting + +### 2. Pipes + +Pipes transform and validate input data. + +**Pipe Implementation:** + +```typescript +import { injectable } from 'tsyringe' +import { PipeTransform, ArgumentMetadata, BadRequestException } from '@afilmory/framework' + +@injectable() +export class ParseIntPipe implements PipeTransform { + transform(value: string, metadata: ArgumentMetadata): number { + const parsed = Number.parseInt(value, 10) + + if (Number.isNaN(parsed)) { + throw new BadRequestException(`Validation failed: "${value}" is not an integer`) + } + + return parsed + } +} +``` + +**Built-in Validation Pipe:** + +```typescript +import { createZodValidationPipe } from '@afilmory/framework' + +// Create configured validation pipe +const ValidationPipe = createZodValidationPipe({ + transform: true, // Transform to DTO class instances + whitelist: true, // Strip unknown properties + errorHttpStatusCode: 422, // Status code for validation errors + forbidUnknownValues: true, // Reject non-objects for body + stopAtFirstError: false, // Return all validation errors +}) + +// already registered globally +app.useGlobalPipes(ValidationPipe) +``` + +**Key Points:** + +- Pipes run **after** guards, **before** handler +- Order: global → method → parameter +- Use for validation, transformation, sanitization +- Parameter pipes run **last** (most specific) + +### 3. Interceptors + +Interceptors wrap the request/response flow and can modify both. + +**Interceptor Implementation:** + +```typescript +import { injectable } from 'tsyringe' +import { Interceptor, ExecutionContext, CallHandler, FrameworkResponse } from '@afilmory/framework' + +@injectable() +export class LoggingInterceptor implements Interceptor { + async intercept(context: ExecutionContext, next: CallHandler): Promise { + const httpContext = context.switchToHttp().getContext() + const { req } = httpContext.hono + + const start = Date.now() + console.log(`→ ${req.method} ${req.path}`) + + // Call the handler and subsequent interceptors + const response = await next.handle() + + const duration = Date.now() - start + console.log(`← ${req.method} ${req.path} ${duration}ms`) + + return response + } +} +``` + +**Response Transform Interceptor:** + +```typescript +@injectable() +export class TransformInterceptor implements Interceptor { + async intercept(context: ExecutionContext, next: CallHandler): Promise { + const response = await next.handle() + + // Transform response body + const data = await response.clone().json() + + return new Response( + JSON.stringify({ + success: true, + data, + timestamp: new Date().toISOString(), + }), + { + status: response.status, + headers: response.headers, + }, + ) + } +} +``` + +**Key Points:** + +- Wrap handler execution with `next.handle()` +- Can modify request before handler +- Can modify response after handler +- Run in order: global → controller → method (then reverse) +- Use for logging, caching, response transformation + +### 4. Exception Filters + +Filters catch and handle exceptions thrown during request processing. + +**Filter Implementation:** + +```typescript +import { injectable } from 'tsyringe' +import { ExceptionFilter, ArgumentsHost, HttpException } from '@afilmory/framework' + +@injectable() +export class AllExceptionsFilter implements ExceptionFilter { + async catch(exception: Error, host: ArgumentsHost) { + const httpContext = host.switchToHttp().getContext() + const { hono } = httpContext + + let status = 500 + let message = 'Internal server error' + let details: any = {} + + if (exception instanceof HttpException) { + status = exception.getStatus() + const response = exception.getResponse() + + if (typeof response === 'object') { + details = response + } else { + message = String(response) + } + } else { + message = exception.message + details.stack = exception.stack + } + + return new Response( + JSON.stringify({ + statusCode: status, + message, + ...details, + path: hono.req.path, + timestamp: new Date().toISOString(), + }), + { + status, + headers: { 'content-type': 'application/json' }, + }, + ) + } +} +``` + +**Key Points:** + +- Filters run when exception is thrown +- Can return custom Response or undefined +- If filter returns undefined, next filter runs +- Use for error logging, error formatting, monitoring + +## Dependency Injection + +### Basic DI Usage + +**Service Registration:** + +```typescript +// In module +@Module({ + providers: [ + UserService, // Singleton by default + EmailService, + ], +}) +export class UserModule {} +``` + +**Constructor Injection:** + +```typescript +@injectable() +export class UserService { + constructor( + private readonly db: DbAccessor, + private readonly cache: RedisAccessor, + private readonly logger: Logger, + ) {} +} +``` + +### Accessing the Container + +```typescript +// In application bootstrap +const app = await createApplication(AppModule) +const container = app.getContainer() + +// Manually resolve a provider +const userService = container.resolve(UserService) +``` + +### Important DI Patterns + +**❌ Wrong - Import Type:** + +```typescript +// This will cause DI errors! +import type { UserService } from './user.service' + +@injectable() +export class OrderService { + constructor(private readonly userService: UserService) {} + // ^^^ Type-only import won't work +} +``` + +**✅ Correct - Import Value:** + +```typescript +// Import the actual class +import { UserService } from './user.service' + +@injectable() +export class OrderService { + constructor(private readonly userService: UserService) {} +} +``` + +## Common Implementation Patterns + +### 1. CRUD Controller Pattern + +```typescript +import { z } from 'zod' +import { createZodSchemaDto } from '@afilmory/framework' + +// DTOs for request validation +const PaginationQuerySchema = z.object({ + page: z.string().regex(/^\d+$/).transform(Number).default('1'), + limit: z.string().regex(/^\d+$/).transform(Number).default('10'), +}) + +const UserIdParamSchema = z.object({ + id: z.string().uuid(), +}) + +const CreateUserSchema = z.object({ + email: z.string().email(), + name: z.string().min(1).max(100), + age: z.number().int().positive().optional(), +}) + +const UpdateUserSchema = z.object({ + email: z.string().email().optional(), + name: z.string().min(1).max(100).optional(), + age: z.number().int().positive().optional(), +}) + +class PaginationQueryDto extends createZodSchemaDto(PaginationQuerySchema) {} +class UserIdParamDto extends createZodSchemaDto(UserIdParamSchema) {} +class CreateUserDto extends createZodSchemaDto(CreateUserSchema) {} +class UpdateUserDto extends createZodSchemaDto(UpdateUserSchema) {} + +@Controller('users') +export class UserController { + constructor(private readonly userService: UserService) {} + + @Get('/') + async findAll(@Query() query: PaginationQueryDto) { + return this.userService.findAll({ + page: query.page, + limit: query.limit, + }) + } + + @Get('/:id') + async findOne(@Param() params: UserIdParamDto) { + const user = await this.userService.findById(params.id) + if (!user) { + throw new NotFoundException(`User ${params.id} not found`) + } + return user + } + + @Post('/') + async create(@Body() createUserDto: CreateUserDto) { + return this.userService.create(createUserDto) + } + + @Patch('/:id') + async update(@Param() params: UserIdParamDto, @Body() updateUserDto: UpdateUserDto) { + return this.userService.update(params.id, updateUserDto) + } + + @Delete('/:id') + async remove(@Param() params: UserIdParamDto) { + await this.userService.remove(params.id) + return { deleted: true } + } +} +``` + +**Key Points:** + +- **Query Parameters**: Use `@Query()` without parameter name to get all query params, then validate with DTO +- **Route Parameters**: Use `@Param()` without parameter name to get all params, then validate with DTO +- **Schema Transformation**: Use `.transform()` to convert string query params to numbers +- **Default Values**: Use `.default()` for optional query parameters +- **Validation**: All parameters are validated through Zod schemas before reaching the handler + +### 2. Service with Database Pattern + +```typescript +@injectable() +export class UserService { + constructor(private readonly db: DbAccessor) {} + + async findAll(options: { page: number; limit: number }) { + const db = this.db.get() + const offset = (options.page - 1) * options.limit + + const users = await db.query.users.findMany({ + limit: options.limit, + offset, + }) + + return { + data: users, + page: options.page, + limit: options.limit, + } + } + + async findById(id: string) { + const db = this.db.get() + return db.query.users.findFirst({ + where: eq(schema.users.id, id), + }) + } + + async create(data: CreateUserInput) { + const db = this.db.get() + const [user] = await db.insert(schema.users).values(data).returning() + return user + } + + async update(id: string, data: UpdateUserInput) { + const db = this.db.get() + const [updated] = await db.update(schema.users).set(data).where(eq(schema.users.id, id)).returning() + return updated + } + + async remove(id: string) { + const db = this.db.get() + await db.delete(schema.users).where(eq(schema.users.id, id)) + } +} +``` + +### 3. Lifecycle Hooks Pattern + +```typescript +@injectable() +export class DatabaseService implements OnModuleInit, OnModuleDestroy { + private pool: Pool | null = null + + async onModuleInit() { + console.log('Initializing database connection...') + this.pool = new Pool({ connectionString: env.DATABASE_URL }) + await this.pool.query('SELECT 1') // Test connection + console.log('Database connected') + } + + async onModuleDestroy() { + console.log('Closing database connection...') + await this.pool?.end() + console.log('Database disconnected') + } + + getPool(): Pool { + if (!this.pool) { + throw new Error('Database not initialized') + } + return this.pool + } +} +``` + +**Available Lifecycle Hooks:** + +```typescript +interface OnModuleInit { + onModuleInit(): Promise | void + // Called after module and its imports are registered +} + +interface OnApplicationBootstrap { + onApplicationBootstrap(): Promise | void + // Called after all modules are initialized +} + +interface BeforeApplicationShutdown { + beforeApplicationShutdown(signal?: string): Promise | void + // Called before shutdown begins +} + +interface OnModuleDestroy { + onModuleDestroy(): Promise | void + // Called during teardown +} + +interface OnApplicationShutdown { + onApplicationShutdown(signal?: string): Promise | void + // Called as final shutdown step +} +``` + +**Graceful Shutdown:** + +```typescript +const app = await createApplication(AppModule) +const hono = app.getInstance() + +const server = serve({ fetch: hono.fetch, port: 3000 }) + +// Handle shutdown signals +process.on('SIGTERM', async () => { + console.log('SIGTERM received, shutting down...') + await app.close('SIGTERM') + server.close() + process.exit(0) +}) + +process.on('SIGINT', async () => { + console.log('SIGINT received, shutting down...') + await app.close('SIGINT') + server.close() + process.exit(0) +}) +``` + +### 4. Caching Pattern with Redis + +```typescript +@injectable() +export class CacheService { + constructor(private readonly redis: RedisAccessor) {} + + async get(key: string): Promise { + const value = await this.redis.get().get(key) + return value ? JSON.parse(value) : null + } + + async set(key: string, value: any, ttlSeconds: number): Promise { + await this.redis.get().set(key, JSON.stringify(value), 'EX', ttlSeconds) + } + + async del(key: string): Promise { + await this.redis.get().del(key) + } +} +``` + +### 5. Error Handling Pattern + +```typescript +// Business exception +export class BizException extends HttpException { + constructor( + public readonly code: number, + message: string, + public readonly data?: any, + ) { + super( + { statusCode: 400, code, message, data }, + 400, + message, + ) + } +} + +// Specific business errors +export const ErrorCodes = { + USER_NOT_FOUND: 1001, + INVALID_CREDENTIALS: 1002, + EMAIL_ALREADY_EXISTS: 1003, +} as const + +// Usage in service +@injectable() +export class UserService { + async findById(id: string) { + const user = await this.db.query.users.findFirst(...) + if (!user) { + throw new BizException( + ErrorCodes.USER_NOT_FOUND, + `User ${id} not found`, + ) + } + return user + } +} + +// Exception filter +@injectable() +export class BizExceptionFilter implements ExceptionFilter { + async catch(exception: BizException, host: ArgumentsHost) { + const httpContext = host.switchToHttp().getContext() + + return new Response( + JSON.stringify({ + success: false, + code: exception.code, + message: exception.message, + data: exception.data, + timestamp: new Date().toISOString(), + }), + { + status: exception.getStatus(), + headers: { 'content-type': 'application/json' }, + } + ) + } +} +``` + +### 6. WebSocket Pattern + +The `@afilmory/websocket` package provides a Redis-backed WebSocket gateway with channel subscriptions, pub/sub fan-out, and automatic heartbeat management. + +**WebSocket Module Setup:** + +```typescript +import { Module } from '@afilmory/framework' +import { RedisModule } from '../redis/redis.module' +import { WebSocketGatewayProvider } from './websocket.provider' +import { WebSocketService } from './websocket.service' + +@Module({ + imports: [RedisModule], + providers: [WebSocketGatewayProvider, WebSocketService], +}) +export class WebSocketModule {} +``` + +**WebSocket Gateway Provider:** + +```typescript +import { injectable } from 'tsyringe' +import { OnModuleInit, OnModuleDestroy, createLogger } from '@afilmory/framework' +import { RedisPubSubBroker, RedisWebSocketGateway } from '@afilmory/websocket' +import { RedisAccessor } from '../redis/redis.provider' + +@injectable() +export class WebSocketGatewayProvider implements OnModuleInit, OnModuleDestroy { + private gateway?: RedisWebSocketGateway + private broker?: RedisPubSubBroker + private subscriber?: Redis + + constructor(private readonly redis: RedisAccessor) {} + + async onModuleInit(): Promise { + const publisher = this.redis.get() + const subscriber = publisher.duplicate() + + this.subscriber = subscriber + this.broker = new RedisPubSubBroker({ publisher, subscriber }) + } + + async attachToHttpServer(server: Server): Promise { + if (!this.broker) { + throw new Error('Broker not initialized') + } + + this.gateway = new RedisWebSocketGateway({ + broker: this.broker, + server, + path: '/ws', + heartbeatIntervalMs: 30000, + allowClientPublish: false, // Disable client-initiated publish + handshakeValidator: async (request) => { + // Validate auth token from query params or headers + const token = new URL(request.url!, 'http://localhost').searchParams.get('token') + if (!token) { + throw new Error('Missing authentication token') + } + // Validate token here + }, + identifyClient: async (request) => { + // Return unique client identifier + return extractUserIdFromRequest(request) + }, + }) + + await this.gateway.start() + } + + async onModuleDestroy(): Promise { + await this.gateway?.stop() + await this.subscriber?.quit() + } + + getGateway(): RedisWebSocketGateway { + if (!this.gateway) { + throw new Error('Gateway not initialized') + } + return this.gateway + } +} +``` + +**WebSocket Service:** + +```typescript +@injectable() +export class WebSocketService { + constructor(private readonly gatewayProvider: WebSocketGatewayProvider) {} + + async publishToChannel(channel: string, payload: T): Promise { + const gateway = this.gatewayProvider.getGateway() + await gateway.publish({ channel, payload }) + } + + async notifyUser(userId: string, notification: Notification): Promise { + await this.publishToChannel(`user:${userId}`, { + type: 'notification', + data: notification, + }) + } + + async broadcastToAll(message: string): Promise { + await this.publishToChannel('broadcast', { + type: 'announcement', + message, + }) + } +} +``` + +**Bootstrap with WebSocket:** + +```typescript +import { serve } from '@hono/node-server' +import { createApplication } from '@afilmory/framework' + +async function bootstrap() { + const app = await createApplication(AppModule) + const hono = app.getInstance() + + const server = serve({ fetch: hono.fetch, port: 3000 }) + + // Attach WebSocket gateway to HTTP server + const container = app.getContainer() + const wsProvider = container.resolve(WebSocketGatewayProvider) + await wsProvider.attachToHttpServer(server) +} + +bootstrap() +``` + +**Client-Side Usage:** + +```typescript +// Connect to WebSocket +const ws = new WebSocket('ws://localhost:3000/ws?token=YOUR_TOKEN') + +ws.onopen = () => { + // Subscribe to channels + ws.send( + JSON.stringify({ + type: 'subscribe', + channels: ['user:123', 'broadcast'], + }), + ) +} + +ws.onmessage = (event) => { + const message = JSON.parse(event.data) + + switch (message.type) { + case 'ack': + console.log('Subscribed to:', message.channels) + break + case 'message': + console.log('Received:', message.channel, message.payload) + break + case 'error': + console.error('Error:', message.code, message.message) + break + } +} + +// Unsubscribe from channels +ws.send( + JSON.stringify({ + type: 'unsubscribe', + channels: ['broadcast'], + }), +) + +// Ping-pong for keepalive +ws.send(JSON.stringify({ type: 'ping' })) +``` + +**Key Points:** + +- **Redis Pub/Sub**: Uses Redis for message distribution across multiple server instances +- **Channel Subscriptions**: Clients subscribe to channels and receive real-time updates +- **Automatic Heartbeat**: Built-in ping/pong mechanism for connection health +- **Handshake Validation**: Validate authentication before accepting connections +- **Client Identification**: Custom logic to identify connected clients +- **Server-Side Publish**: Services can publish messages through the gateway + +### 7. Task Queue Pattern + +The `@afilmory/task-queue` package provides a robust task queue system with support for retries, priority, delayed execution, and middleware. + +**Task Queue Module Setup:** + +```typescript +import { Module } from '@afilmory/framework' +import { TaskQueueModule } from '@afilmory/task-queue' +import { TaskQueueManager } from './task-queue.manager' +import { TaskQueueService } from './task-queue.service' +import { TaskQueueController } from './task-queue.controller' + +@Module({ + imports: [TaskQueueModule], + controllers: [TaskQueueController], + providers: [TaskQueueManager, TaskQueueService], +}) +export class QueueModule {} +``` + +**Task Queue Worker with Decorators:** + +```typescript +import { injectable } from 'tsyringe' +import { OnModuleDestroy, OnModuleInit, createLogger } from '@afilmory/framework' +import { RedisQueueDriver, TaskContext, TaskProcessor, TaskQueue, TaskQueueManager } from '@afilmory/task-queue' +import { RedisAccessor } from '../redis/redis.provider' + +@injectable() +export class TaskQueueWorker implements OnModuleInit, OnModuleDestroy { + private readonly logger = createLogger('Tasks:worker') + public queue!: TaskQueue + + constructor( + private readonly manager: TaskQueueManager, + private readonly redis: RedisAccessor, + ) {} + + async onModuleInit(): Promise { + const driver = new RedisQueueDriver({ + redis: this.redis.get(), + queueName: 'core:jobs', + visibilityTimeoutMs: 45_000, + }) + + this.queue = this.manager.createQueue('core-jobs', { + driver, + start: false, + logger: this.logger, + middlewares: [ + async (context, next) => { + this.logger.debug('Task started', { taskId: context.taskId, name: context.name }) + const start = Date.now() + try { + await next() + } finally { + this.logger.debug('Task finished', { + taskId: context.taskId, + name: context.name, + duration: Date.now() - start, + }) + } + }, + ], + }) + + await this.queue.start({ pollIntervalMs: 200 }) + } + + @TaskProcessor('send-email', { + options: { + maxAttempts: 3, + backoffStrategy: (attempt) => Math.min(60_000, 2 ** attempt * 1_000), + retryableFilter: (error) => error instanceof NetworkError, + }, + }) + async sendEmail(payload: EmailPayload, context: TaskContext): Promise { + context.logger.info('Sending email', { to: payload.to }) + await sendEmail(payload) + } + + @TaskProcessor('process-image', { + options: (instance) => ({ + maxAttempts: 5, + backoffStrategy: (attempt) => attempt * 5_000, + }), + }) + async processImage(payload: ImagePayload, context: TaskContext): Promise { + context.logger.info('Processing image', { imageId: payload.imageId }) + return await processImage(payload) + } + + @TaskProcessor({ name: 'deliver-webhook', queueProperty: 'queue' }) + async deliverWebhook(payload: WebhookPayload, context: TaskContext): Promise { + try { + await fetch(payload.url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload.data), + }) + } catch (error: any) { + if (error?.status === 429) { + context.setRetry({ retry: true, delayMs: 60_000 }) + } + throw error + } + } + + async onModuleDestroy(): Promise { + await this.queue?.shutdown() + } +} +``` + +Handlers are bound automatically after `onModuleInit` finishes, so as long as the queue property is set before the method resolves, the decorator wires everything up. Options may be an object or a factory that receives the instance (helpful for per-environment tuning). + +**Task Queue Service:** + +```typescript +@injectable() +export class TaskQueueService { + constructor(private readonly worker: TaskQueueWorker) {} + + async enqueueEmail(email: EmailPayload): Promise { + const task = await this.worker.queue.enqueue({ + name: 'send-email', + payload: email, + priority: 5, + }) + + return task.id + } + + async enqueueDelayedTask(payload: unknown, delayMs: number): Promise { + const task = await this.worker.queue.enqueue({ + name: 'deliver-webhook', + payload, + runAt: Date.now() + delayMs, + priority: 0, + }) + + return task.id + } + + async enqueueBatch(emails: EmailPayload[]): Promise { + return await Promise.all(emails.map((email) => this.enqueueEmail(email))) + } + + async getQueueStats() { + return await this.worker.queue.getStats() + } +} +``` + +**Task Queue Controller:** + +```typescript +@Controller('queue') +export class TaskQueueController { + constructor(private readonly queueService: TaskQueueService) {} + + @Post('/tasks/email') + async enqueueEmail(@Body() dto: EmailDto) { + const taskId = await this.queueService.enqueueEmail({ + to: dto.to, + subject: dto.subject, + body: dto.body, + }) + + return { + taskId, + status: 'queued', + message: 'Email task enqueued successfully', + } + } + + @Post('/tasks/delayed') + async enqueueDelayed(@Body() dto: { payload: unknown; delaySeconds: number }) { + const taskId = await this.queueService.enqueueDelayedTask(dto.payload, dto.delaySeconds * 1000) + + return { + taskId, + status: 'scheduled', + scheduledFor: new Date(Date.now() + dto.delaySeconds * 1000), + } + } + + @Get('/stats') + async getStats() { + return await this.queueService.getQueueStats() + } +} +``` + +**Advanced Task Handler with Custom Retry:** + +```typescript +@TaskProcessor('complex-task', { + options: { + maxAttempts: 5, + backoffStrategy: (attempt) => { + const base = 2 ** attempt * 1_000 + const jitter = Math.random() * 1_000 + return Math.min(300_000, base + jitter) + }, + }, +}) +async complexTaskHandler(payload: ComplexPayload, context: TaskContext): Promise { + try { + await performComplexOperation(payload) + } catch (error) { + if (error instanceof TemporaryError) { + context.setRetry({ retry: true, delayMs: 30_000 }) + throw error + } + + if (error instanceof PermanentError) { + throw new TaskDropError('Permanent failure, cannot retry') + } + + throw error + } +} +``` + +**Task Middleware:** + +```typescript +// Logging middleware +const loggingMiddleware: TaskMiddleware = async (context, next) => { + console.log(`[${context.name}] Starting task ${context.taskId}`) + const start = Date.now() + + try { + await next() + console.log(`[${context.name}] Completed in ${Date.now() - start}ms`) + } catch (error) { + console.error(`[${context.name}] Failed:`, error) + throw error + } +} + +// Metrics middleware +const metricsMiddleware: TaskMiddleware = async (context, next) => { + const labels = { taskName: context.name } + tasksTotal.inc(labels) + + const timer = tasksLatency.startTimer(labels) + try { + await next() + tasksSuccess.inc(labels) + } catch (error) { + tasksFailure.inc(labels) + throw error + } finally { + timer() + } +} + +// Apply middlewares +const queue = new TaskQueue({ + name: 'main', + middlewares: [loggingMiddleware, metricsMiddleware], +}) +``` + +**Key Points:** + +- **Task Processors**: Annotate methods with `@TaskProcessor()`; registration runs automatically after `onModuleInit` +- **Priority Queue**: Tasks with higher priority are processed first +- **Delayed Execution**: Schedule tasks to run at a specific time +- **Retry Strategies**: Exponential backoff, linear backoff, custom logic via handler options or `context.setRetry` +- **Middleware**: Add cross-cutting concerns like logging, metrics, tracing +- **Driver Support**: In-memory driver for development, Redis driver for production +- **Visibility Timeout**: Prevents tasks from being processed by multiple workers +- **Graceful Shutdown**: Stop processing and wait for in-flight tasks to complete + +### 8. OpenAPI & Scalar Docs + +Generate synchronized documentation from the existing decorator metadata and surface it through a hosted Scalar UI. + +- **Two-dimensional tagging**: Operations carry module-path and controller tags (e.g. `Root / User`, `User`) so clients can cluster endpoints by feature. +- **Schema reuse**: DTO Zod definitions become reusable components referenced by parameters and request bodies. +- **Interactive docs**: The Scalar embed mirrors their recommended CDN usage, so no bundler work is required. +- **Customization**: Use `@ApiTags('Admin')` on controllers or handlers to add business-facing groupings, and `@ApiDoc({ summary, deprecated, tags })` to tweak individual operations inline. + +## Testing Strategy + +### Framework Testing + +The framework itself has 100% test coverage. When implementing features: + +**1. Unit Tests for Services:** + +```typescript +import { describe, it, expect, beforeEach } from 'vitest' +import { container } from 'tsyringe' + +describe('UserService', () => { + let service: UserService + let mockDb: DbAccessor + + beforeEach(() => { + // Setup mocks + mockDb = { + get: () => mockDbInstance, + } as any + + container.register(DbAccessor, { useValue: mockDb }) + service = container.resolve(UserService) + }) + + it('should find user by id', async () => { + const user = await service.findById('123') + expect(user).toBeDefined() + }) +}) +``` + +**2. Integration Tests for Controllers:** + +```typescript +import { describe, it, expect } from 'vitest' +import { createApplication } from '@afilmory/framework' + +describe('UserController', () => { + let app: HonoHttpApplication + + beforeEach(async () => { + app = await createApplication(UserModule) + }) + + it('should return user list', async () => { + const hono = app.getInstance() + const res = await hono.request('/users') + + expect(res.status).toBe(200) + const data = await res.json() + expect(Array.isArray(data)).toBe(true) + }) + + afterEach(async () => { + await app.close() + }) +}) +``` + +**3. E2E Tests:** + +```typescript +describe('Authentication Flow', () => { + it('should login and access protected route', async () => { + // Login + const loginRes = await fetch('http://localhost:3000/api/auth/login', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email: 'test@example.com', password: 'pass' }), + }) + const { access_token } = await loginRes.json() + + // Access protected route + const profileRes = await fetch('http://localhost:3000/api/auth/profile', { + headers: { authorization: `Bearer ${access_token}` }, + }) + expect(profileRes.status).toBe(200) + }) +}) +``` + +## Best Practices for AI Agents + +### When Creating New Features + +1. **Start with the Module:** + - Create module file with `@Module()` decorator + - Define imports, controllers, providers + +2. **Create DTOs with Zod:** + - Define schemas with `z.object()` + - Create DTO classes with `extend createZodSchemaDto()` + +3. **Implement Service:** + - Add `@injectable()` decorator + - Use constructor injection for dependencies + - Implement business logic methods + +4. **Implement Controller:** + - Add `@Controller(prefix)` decorator + - Use HTTP method decorators + - Use parameter decorators for input + - Inject service via constructor + +5. **Add Enhancers if Needed:** + - Guards for authorization + - Pipes for custom validation + - Interceptors for cross-cutting concerns + - Filters for error handling + +6. **Register in Root Module:** + - Add to `imports` array in root module + +### Common Pitfalls to Avoid + +❌ **Don't:** + +- Import types instead of classes for DI +- Forget `@injectable()` decorator on services +- Forget `@Controller()` decorator on controllers +- Use relative imports for cross-module dependencies +- Mutate request/response objects directly + +✅ **Do:** + +- Import actual classes for DI +- Use decorators consistently +- Use `HttpContext` for request-scoped data +- Follow module boundaries +- Return plain objects (framework handles Response creation) +- Use lifecycle hooks for initialization/cleanup + +### Code Organization + +``` +src/ +├── modules/ +│ ├── user/ +│ │ ├── user.module.ts +│ │ ├── user.controller.ts +│ │ ├── user.service.ts +│ │ ├── dto/ +│ │ │ ├── create-user.dto.ts +│ │ │ └── update-user.dto.ts +│ │ └── entities/ +│ │ └── user.entity.ts +│ └── auth/ +│ ├── auth.module.ts +│ ├── auth.controller.ts +│ ├── auth.service.ts +│ └── guards/ +│ └── auth.guard.ts +├── guards/ # Shared guards +├── interceptors/ # Shared interceptors +├── pipes/ # Shared pipes +├── filters/ # Shared filters +├── database/ # Database module +│ ├── database.module.ts +│ └── database.provider.ts +├── redis/ # Redis module +│ ├── redis.module.ts +│ └── redis.provider.ts +├── app.module.ts # Root module +└── index.ts # Bootstrap +``` + +--- + +## Quick Reference + +### Essential Imports + +```typescript +// Framework core +import { + Module, + Controller, + Get, + Post, + Put, + Patch, + Delete, + Body, + Query, + Param, + Headers, + Req, + ContextParam, + UseGuards, + UsePipes, + UseInterceptors, + UseFilters, + HttpContext, + HttpException, + BadRequestException, + UnauthorizedException, + ForbiddenException, + NotFoundException, + createApplication, + createZodValidationPipe, + createZodSchemaDto, +} from '@afilmory/framework' + +// DI +import { injectable } from 'tsyringe' + +// Validation +import { z } from 'zod' + +// Hono types +import type { Context } from 'hono' +``` + +### Minimal Working Example + +```typescript +// app.module.ts +import { Module } from '@afilmory/framework' +import { AppController } from './app.controller' +import { AppService } from './app.service' + +@Module({ + controllers: [AppController], + providers: [AppService], +}) +export class AppModule {} + +// app.service.ts +import { injectable } from 'tsyringe' + +@injectable() +export class AppService { + getMessage() { + return { message: 'Hello World!' } + } +} + +// app.controller.ts +import { Controller, Get } from '@afilmory/framework' +import { AppService } from './app.service' + +@Controller('app') +export class AppController { + constructor(private readonly service: AppService) {} + + @Get('/') + async getMessage() { + return this.service.getMessage() + } +} + +// index.ts +import 'reflect-metadata' +import { serve } from '@hono/node-server' +import { createApplication } from '@afilmory/framework' +import { AppModule } from './app.module' + +async function bootstrap() { + const app = await createApplication(AppModule) + const hono = app.getInstance() + serve({ fetch: hono.fetch, port: 3000 }) +} + +bootstrap() +``` + +--- + +This framework provides a robust foundation for building enterprise-grade HTTP services with TypeScript. Follow the patterns outlined here, and you'll create maintainable, testable, and scalable applications. diff --git a/be/README.md b/be/README.md new file mode 100644 index 00000000..aabae72e --- /dev/null +++ b/be/README.md @@ -0,0 +1,414 @@ +# Hono Enterprise Template + +A NestJS‑inspired, Hono‑powered enterprise template for building modular, type‑safe HTTP services. The core framework package ships with dependency injection, decorators, guards, pipes, interceptors, exception filters, request‑scoped context, and an extensible pretty logger. The framework tests achieve 100% coverage and the sample app demonstrates all enhancement paths end‑to‑end. + +## ✨ Features + +- **Hono application layer**: Hono performance with opinionated structure and decorators. +- **Modular architecture + DI**: `tsyringe`-based container, constructor injection, module imports/exports. +- **Request context**: `HttpContext` built on `AsyncLocalStorage` to safely access the current `Context` anywhere. +- **Composables (enhancers)**: Guards, Pipes, Interceptors, and Exception Filters with a declarative API. +- **Zod validation pipe**: metadata-driven DTO validation via `createZodValidationPipe({ ... })` and `@ZodSchema` decorators. +- **Pretty logger**: Namespaced, colorized output with CI-safe text labels and hierarchical `extend()`. +- **Task queue decorators**: Register background job handlers with `@TaskProcessor()` and let the queue wire itself up. +- **OpenAPI explorer**: Generate OpenAPI 3.1 docs from decorators and serve them through Scalar. +- **First-class testing**: Framework Vitest suite with 100% coverage; demo app covers all enhancer paths. +- **Infrastructure providers via DI**: Postgres (Drizzle) and Redis (ioredis) wired as modules. + +## 📁 Monorepo Layout + +| Path | Description | +| -------------------------- | ---------------------------------------------------------------------------------------- | +| `apps/core` | Demo application showcasing modules, controllers, and all enhancers; usable as a starter | +| `packages/framework` | Core framework: `HonoHttpApplication`, decorators, HTTP context, logger, Zod pipe, etc. | +| `packages/framework/tests` | Vitest suite for the framework with coverage and lifecycle tests | +| `packages/db` | Drizzle schema & types plus migrations configuration | +| `packages/env` | Runtime env validation powered by `@t3-oss/env-core` | +| `packages/redis` | Redis client factory (`ioredis`) and strong types | +| `packages/websocket` | Redis-backed WebSocket gateway with pub/sub broker, heartbeat management, and logging | + +## ✅ Requirements + +- Node.js 18+ (uses `AsyncLocalStorage` and modern ESM tooling) +- pnpm 10+ +- TypeScript 5.9 + +## 🚀 Quickstart + +```bash +# install dependencies +pnpm install + +# run framework tests (with coverage) +pnpm -C packages/framework test + +# run demo app tests +pnpm -C apps/core test + +# start the demo app (vite-node) +pnpm -C apps/core dev + +# or run the in-process demo runner +pnpm -C apps/web dev +``` + +Coverage reports are generated at `packages/framework/coverage`. + +## 🔧 Environment + +Create a `.env` file at the repo root with at least: + +```bash +DATABASE_URL=postgres://user:pass@localhost:5432/db +REDIS_URL=redis://localhost:6379 + +# Optional WebSocket gateway configuration +# WEBSOCKET_ENABLED=true +# WEBSOCKET_PORT=8081 +# WEBSOCKET_PATH=/ws +# WEBSOCKET_HEARTBEAT_INTERVAL_MS=30000 + +# Optional Postgres pool tuning +PG_POOL_MAX=10 +PG_IDLE_TIMEOUT=30000 +PG_CONN_TIMEOUT=5000 +``` + +## 🧱 Architecture & Runtime Model + +### 1) Modules and Controllers + +```ts +import { Controller, Get, Query, UseGuards, Module } from '@afilmory/framework' + +@Controller('demo') +export class DemoController { + constructor(private readonly service: DemoService) {} + + @Get('/hello') + @UseGuards(ApiKeyGuard) + async greet(@Query('name') name: string) { + return this.service.greet(name) + } +} + +@Module({ + controllers: [DemoController], + providers: [DemoService, ApiKeyGuard], +}) +export class DemoModule {} +``` + +Bootstrapping with `createApplication(RootModule, options)` performs: + +1. Recursive module registration via `imports`. +2. DI registration of `providers` and `controllers` using `tsyringe`. +3. Route discovery from class/method decorators and mapping to Hono. +4. Per-request pipeline: Guards → Pipes (global/method/parameter) → Interceptors → Controller → Filters. + +### 1.1) Provider Lifecycle + +Providers and controllers may implement lifecycle interfaces inspired by NestJS: + +- `OnModuleInit` → `onModuleInit()` after a module and its imports finish registering. +- `OnApplicationBootstrap` → `onApplicationBootstrap()` after the app finishes initialization. +- `BeforeApplicationShutdown` → `beforeApplicationShutdown(signal?)` prior to shutdown. +- `OnModuleDestroy` → `onModuleDestroy()` during teardown. +- `OnApplicationShutdown` → `onApplicationShutdown(signal?)` as the final shutdown step. + +Call `await app.close('SIGTERM')` on the `HonoHttpApplication` instance to trigger shutdown hooks. + +### 2) Enhancers (Guards, Pipes, Interceptors, Filters) + +- `@UseGuards(...guards)`: `CanActivate.canActivate(ctx)` returning `boolean | Promise`. `false` throws `ForbiddenException`. +- `@UsePipes(...pipes)` and parameter-level pipes (e.g., `@Param('id', ParseIntPipe)`): merged globally and per-method. +- `@UseInterceptors(...interceptors)`: `interceptor.intercept(context, next)` chaining. +- `@UseFilters(...filters)`: handle and customize error responses; unhandled errors return a 500 JSON payload. + +Zod validation is provided by registering DTO classes with `createZodSchemaDto(...)` (or the lower-level `@ZodSchema(...)`) and enabling a global `createZodValidationPipe({ ... })`. See `packages/framework/tests/application.spec.ts` for full examples. + +### 2.5) Infrastructure Modules: Database (Postgres) and Redis + +Both the database and Redis are registered as DI-driven modules in the demo app. + +- Database lives under `apps/core/src/database` and exposes a `DbAccessor` that returns a request-aware Drizzle instance. +- Redis lives under `apps/core/src/redis` and exposes a `RedisAccessor` that returns a singleton `ioredis` client. + +Ensure `DatabaseModule` and `RedisModule` are imported by your root module (already wired in the demo): + +```ts +import { Module } from '@afilmory/framework' +import { DatabaseModule } from '../database/module' +import { RedisModule } from '../redis/module' +import { AppModule } from './app/app.module' + +@Module({ + imports: [DatabaseModule, RedisModule, AppModule], +}) +export class AppModules {} +``` + +Using Redis from a service via DI: + +```ts +import { injectable } from 'tsyringe' +import { RedisAccessor } from '../redis/providers' + +@injectable() +export class CacheService { + constructor(private readonly redis: RedisAccessor) {} + + async setGreeting(key: string, name: string): Promise { + await this.redis.get().set(key, name, 'EX', 60) + } + + async getGreeting(key: string): Promise { + return await this.redis.get().get(key) + } +} +``` + +### 2.6) WebSocket Gateway + +The `@afilmory/websocket` package provides a Redis-backed WebSocket gateway with channel subscriptions, Redis pub/sub fan-out, and automatic heartbeat/ping management. The demo app exposes it through `WebSocketDemoModule` (disabled by default). The `/api/websocket/info` route reports status, and `/api/websocket/channels/:channel/publish` publishes payloads to connected clients. + +### 2.7) Task Queue with Decorators + +The `@afilmory/task-queue` package ships with a decorator-driven registration model so workers only need to annotate their handler methods: + +```ts +import { injectable } from 'tsyringe' +import { OnModuleDestroy, OnModuleInit } from '@afilmory/framework' +import { RedisQueueDriver, TaskContext, TaskProcessor, TaskQueue, TaskQueueManager } from '@afilmory/task-queue' + +@injectable() +export class NotificationQueue implements OnModuleInit, OnModuleDestroy { + public queue!: TaskQueue + + constructor( + private readonly manager: TaskQueueManager, + private readonly redis: RedisAccessor, + ) {} + + async onModuleInit(): Promise { + const driver = new RedisQueueDriver({ + redis: this.redis.get(), + queueName: 'core:notifications', + visibilityTimeoutMs: 45_000, + }) + + this.queue = this.manager.createQueue('notifications', { + driver, + start: false, + middlewares: [ + async (ctx, next) => { + ctx.logger.debug('start', { taskId: ctx.taskId }) + await next() + }, + ], + }) + + await this.queue.start({ pollIntervalMs: 250 }) + } + + @TaskProcessor('send-notification', { + options: { + maxAttempts: 5, + retryableFilter: () => true, + backoffStrategy: (attempt) => Math.min(30_000, 2 ** attempt * 250), + }, + }) + async sendNotification(payload: NotificationPayload, context: TaskContext): Promise { + // business logic here + context.logger.info('Delivered notification', { taskId: context.taskId }) + } + + async onModuleDestroy(): Promise { + await this.queue?.shutdown() + } +} +``` + +`@TaskProcessor()` delays registration until `onModuleInit` finishes so that the queue instance is ready, supports alternate queue property names, and accepts per-handler options (or an options factory). Any service can inject the queue to enqueue work: + +```ts +@injectable() +export class NotificationService { + constructor(private readonly worker: NotificationQueue) {} + + async enqueue(payload: NotificationPayload) { + return await this.worker.queue.enqueue({ name: 'send-notification', payload }) + } +} +``` + +### 2.8) OpenAPI & Interactive Docs + +The framework can build an OpenAPI 3.1 document directly from module and controller decorators and expose it alongside a Scalar-powered UI. + +```ts +import type { Hono } from 'hono' +import { ApiDoc, ApiTags, createOpenApiDocument } from '@afilmory/framework' + +import { AppModules } from './modules/index.module' + +function registerDocs(app: Hono, prefix = '/api') { + const document = createOpenApiDocument(AppModules, { + title: 'Core Service API', + version: '1.0.0', + description: 'Decorator-generated OpenAPI spec', + globalPrefix: prefix, + servers: [{ url: prefix }], + }) + + const specPath = `${prefix}/openapi.json` + const docsPath = `${prefix}/docs` + + app.get(specPath, (ctx) => ctx.json(document)) + app.get(docsPath, (ctx) => ctx.html(renderScalarHtml(specPath))) +} +``` + +`createOpenApiDocument()` groups operations by module and controller, providing consistent tags for consumers, while the Scalar embed above mirrors the recommended CDN integration. + +Decorate controllers or individual handlers with `@ApiTags()` to introduce domain-specific groupings, and use `@ApiDoc({ summary, tags, deprecated, ... })` to fine-tune operation metadata without leaving your code. + +### 3) Result Handling + +Handlers may return `Response`, `string`, `ArrayBuffer`, `ArrayBufferView`, `ReadableStream`, or plain objects. Non-`Response` values are normalized to a proper HTTP response. `undefined` or returning `context.res` preserves the current response. + +### 4) Logger + +```ts +import { createLogger } from '@afilmory/framework' + +const logger = createLogger('App') +logger.info('Service started') +logger.warn('Auth failed', { userId }) + +const scoped = logger.extend('Module') +scoped.debug('Loaded') +``` + +Logger options include custom writer, color strategy, clock, per-level colors, and CI-safe text labels. The framework uses namespaces `Framework`, `Framework:DI`, and `Framework:Router` internally. + +### 5) Request Context + +`HttpContext.run(context, fn)` establishes a request scope backed by `AsyncLocalStorage`. The store is a typed object that always includes the active Hono `Context` as `store.hono` and can be extended via module augmentation. Use `HttpContext.get()`/`HttpContext.getValue('hono')` inside guards, interceptors, or services, and `HttpContext.assign()`/`setValue()` to attach custom request metadata. + +## 🧪 Testing & Quality + +- Framework tests: `pnpm -C packages/framework test` (coverage threshold 100%). +- Demo app tests: `pnpm -C apps/core test`. +- Type checking: use TypeScript 5.9; optionally run `pnpm tsc --noEmit` at the repo root. + +## 🧩 Developer Guide + +### Bootstrapping an App + +```ts +import 'reflect-metadata' +import { serve } from '@hono/node-server' +import { createApplication, createZodValidationPipe } from '@afilmory/framework' +import { AppModule } from './app.module' + +const ValidationPipe = createZodValidationPipe({ + transform: true, + whitelist: true, + errorHttpStatusCode: 422, + forbidUnknownValues: true, +}) + +const app = await createApplication(AppModule, { globalPrefix: '/api' }) +app.useGlobalPipes(ValidationPipe) +app.useGlobalFilters(AllExceptionsFilter) +app.useGlobalInterceptors(LoggingInterceptor) + +const hono = app.getInstance() + +serve({ fetch: hono.fetch, port: 3000 }) +``` + +### Dependency Injection & Types + +Use `tsyringe` decorators for providers and constructor injection. When running through transpilers that strip design metadata (e.g. esbuild), add a `Reflect.metadata` shim so runtime DI still sees parameter types: + +```ts +import 'reflect-metadata' +import { injectable } from 'tsyringe' +import { Controller, Get } from '@afilmory/framework' + +@injectable() +class AppService { + getHello(echo?: string | null) { + return { + message: 'Hello', + timestamp: new Date().toISOString(), + echo: echo ?? undefined, + } + } +} + +@Controller('app') +@injectable() +@Reflect.metadata('design:paramtypes', [AppService]) +class AppController { + constructor(private readonly service: AppService) {} + + @Get('/') + getRoot() { + return this.service.getHello() + } +} +``` + +### Parameter Decorators + +`@Body`, `@Query`, `@Param`, `@Headers`, `@Req`, `@ContextParam` extract values and optionally run per-parameter pipes. + +### Exceptions + +Throw `HttpException` or built-ins like `BadRequestException`, `ForbiddenException`, `NotFoundException`. Custom filters may translate errors into consistent API responses. + +### Validation with Zod + +```ts +import { z } from 'zod' +import { Body, Controller, Post, createZodSchemaDto } from '@afilmory/framework' + +const CreateMessageSchema = z.object({ + message: z.string().min(1), + tags: z.array(z.string()).default([]), +}) + +class CreateMessageDto extends createZodSchemaDto(CreateMessageSchema) {} + +@Controller('messages') +class MessagesController { + @Post('/:id') + create(@Body() body: CreateMessageDto) { + return { status: 'queued', ...body } + } +} +``` + +Alternative: call `createZodDto(CreateMessageSchema)` to obtain a ready-to-use class without extending. + +## 📜 Scripts + +In `apps/core/package.json`: + +- `dev`: start the demo server with vite-node. +- `demo`: run an in-process demo exercising routes and enhancers. +- `test`: run tests for the demo app. + +## 🔗 References & Inspiration + +- [NestJS](https://nestjs.com/) — decorator-driven, layered application architecture. +- [Hono](https://hono.dev/) — small, fast web framework. +- [tsyringe](https://github.com/microsoft/tsyringe) — lightweight dependency injection container. +- [Zod](https://zod.dev/) — type-safe schema validation. + +--- + +Customize the framework under `packages/framework/src` and use `apps/core` as a reference implementation for modules, controllers, and enhancers. Consider extending with enterprise capabilities (configuration, CQRS, event bus, etc.) as your project evolves. diff --git a/be/apps/core/nodemon.json b/be/apps/core/nodemon.json new file mode 100644 index 00000000..5d8dcf8c --- /dev/null +++ b/be/apps/core/nodemon.json @@ -0,0 +1,6 @@ +{ + "ignore": ["dist"], + "watch": ["src", "../packages/*/src"], + "ext": "ts,js,json", + "exec": "vite-node src/index.ts" +} diff --git a/be/apps/core/package.json b/be/apps/core/package.json new file mode 100644 index 00000000..29fa53fd --- /dev/null +++ b/be/apps/core/package.json @@ -0,0 +1,47 @@ +{ + "name": "core", + "type": "module", + "version": "1.0.0", + "packageManager": "pnpm@10.18.0", + "author": "Innei", + "license": "MIT", + "main": "index.ts", + "scripts": { + "build": "vite build", + "db:generate": "pnpm -C ../../packages/db db:generate", + "db:migrate": "pnpm -C ../../packages/db db:migrate", + "db:studio": "pnpm -C ../../packages/db db:studio", + "dev": "nodemon", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@afilmory/be-utils": "workspace:*", + "@afilmory/builder": "workspace:*", + "@afilmory/db": "workspace:*", + "@afilmory/env": "workspace:*", + "@afilmory/framework": "workspace:*", + "@afilmory/redis": "workspace:*", + "@afilmory/task-queue": "workspace:*", + "@aws-sdk/client-s3": "3.916.0", + "@hono/node-server": "^1.19.5", + "better-auth": "1.3.29", + "drizzle-orm": "^0.44.7", + "hono": "4.10.2", + "pg": "^8.16.3", + "picocolors": "1.1.1", + "reflect-metadata": "0.2.2", + "tsyringe": "4.10.0", + "zod": "^4.1.11" + }, + "devDependencies": { + "@types/node": "^24.9.1", + "@types/pg": "8.15.5", + "nodemon": "3.1.10", + "unplugin-swc": "1.5.8", + "vite": "7.1.12", + "vite-node": "3.2.4", + "vite-tsconfig-paths": "5.1.4", + "vitest": "4.0.3" + } +} diff --git a/be/apps/core/src/__tests__/application.spec.ts b/be/apps/core/src/__tests__/application.spec.ts new file mode 100644 index 00000000..720303fe --- /dev/null +++ b/be/apps/core/src/__tests__/application.spec.ts @@ -0,0 +1,238 @@ +import type { HonoHttpApplication } from '@afilmory/framework' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' + +import { createConfiguredApp as createAppFactory } from '../app.factory' + +const BASE_URL = 'http://localhost' + +function buildRequest(path: string, init?: RequestInit) { + return new Request(`${BASE_URL}${path}`, init) +} + +function authorizedHeaders() { + return { + 'x-api-key': process.env.API_KEY ?? 'secret-key', + } +} + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +describe('HonoHttpApplication integration', () => { + let app: HonoHttpApplication + let fetcher: (request: Request) => Promise + + beforeAll(async () => { + app = await createAppFactory() + fetcher = (request: Request) => Promise.resolve(app.getInstance().fetch(request)) + }) + + afterAll(async () => { + await app.close('tests') + }) + + const json = async (response: Response) => ({ + status: response.status, + data: await response.json(), + }) + + it('responds to root route without guard', async () => { + const response = await fetcher( + buildRequest('/api/app?echo=test-suite', { + method: 'GET', + }), + ) + + const body = await json(response) + expect(body.status).toBe(200) + expect(body.data).toMatchObject({ + message: 'Hello from HonoHttpApplication', + echo: 'test-suite', + }) + }) + + it('enforces guards when API key missing', async () => { + const response = await fetcher(buildRequest('/api/app/profiles/5')) + + const body = await json(response) + expect(body.status).toBe(401) + expect(body.data).toMatchObject({ message: 'Invalid API key' }) + }) + + it('resolves params, query, and pipes when authorized', async () => { + const response = await fetcher( + buildRequest('/api/app/profiles/7?verbose=true', { + headers: authorizedHeaders(), + }), + ) + + const body = await json(response) + expect(body.status).toBe(200) + expect(body.data).toMatchObject({ + id: 7, + username: 'user-7', + role: 'member', + }) + expect(body.data.verbose).toBeDefined() + }) + + it('returns validation error on malformed JSON payload', async () => { + const response = await fetcher( + buildRequest('/api/app/messages/1', { + method: 'POST', + headers: { + ...authorizedHeaders(), + 'content-type': 'application/json', + }, + body: '{ invalid json', + }), + ) + + const body = await json(response) + expect(body.status).toBe(400) + expect(body.data).toMatchObject({ message: 'Invalid JSON payload' }) + }) + + it('processes body payload with validation and pipes', async () => { + const response = await fetcher( + buildRequest('/api/app/messages/9', { + method: 'POST', + headers: { + ...authorizedHeaders(), + 'content-type': 'application/json', + 'x-request-id': 'vitest-request', + }, + body: JSON.stringify({ + message: 'unit test', + tags: ['vitest'], + }), + }), + ) + + const body = await json(response) + expect(body.status).toBe(200) + expect(body.data).toMatchObject({ + requestId: 'vitest-request', + data: { + id: 9, + message: 'unit test', + }, + }) + }) + + it('enqueues and processes redis-backed queue jobs', async () => { + const enqueue = await fetcher( + buildRequest('/api/queue/jobs', { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ + recipient: 'queue-test@example.com', + message: 'integration job', + attemptsBeforeSuccess: 2, + metadata: { + source: 'vitest', + }, + }), + }), + ) + + const enqueueBody = await json(enqueue) + expect(enqueueBody.status).toBe(202) + const jobId = enqueueBody.data.jobId as string + expect(typeof jobId).toBe('string') + + let jobState: Awaited> | undefined + for (let i = 0; i < 30; i += 1) { + await sleep(100) + const statusResponse = await fetcher(buildRequest(`/api/queue/jobs/${jobId}`, { method: 'GET' })) + if (statusResponse.status !== 200) { + continue + } + + jobState = await json(statusResponse) + if (['completed', 'failed'].includes(jobState.data.status)) { + break + } + } + + expect(jobState).toBeDefined() + expect(jobState?.status).toBe(200) + expect(jobState?.data.status).toBe('completed') + expect(jobState?.data.attempts).toBeGreaterThan(1) + expect(jobState?.data.result?.deliveredAt).toBeTypeOf('string') + + const listResponse = await fetcher(buildRequest('/api/queue/jobs', { method: 'GET' })) + const listBody = await json(listResponse) + expect(listBody.status).toBe(200) + expect(Array.isArray(listBody.data)).toBe(true) + expect(listBody.data.some((item: { id: string }) => item.id === jobId)).toBe(true) + + const statsResponse = await fetcher(buildRequest('/api/queue/stats', { method: 'GET' })) + const statsBody = await json(statsResponse) + expect(statsBody.status).toBe(200) + expect(statsBody.data.trackedJobs).toBeGreaterThanOrEqual(1) + + const missingResponse = await fetcher(buildRequest('/api/queue/jobs/non-existent', { method: 'GET' })) + expect(missingResponse.status).toBe(404) + }) + + it('validates body with zod pipe and reports schema errors', async () => { + const response = await fetcher( + buildRequest('/api/app/messages/10', { + method: 'POST', + headers: { + ...authorizedHeaders(), + 'content-type': 'application/json', + }, + body: JSON.stringify({ tags: [] }), + }), + ) + + const body = await json(response) + expect(body.status).toBe(422) + expect(body.data).toMatchObject({ + statusCode: 422, + message: 'Validation failed', + errors: { + message: ['Message is required'], + }, + meta: { + target: 'CreateMessageDto', + paramType: 'body', + }, + }) + }) + + it('exposes HttpContext through async_hooks store', async () => { + const response = await fetcher( + buildRequest('/api/app/context-check', { + method: 'GET', + headers: authorizedHeaders(), + }), + ) + + const body = await json(response) + expect(body.status).toBe(200) + expect(body.data).toMatchObject({ + same: true, + path: '/api/app/context-check', + }) + }) + + it('delegates unhandled errors to the exception filter', async () => { + const response = await fetcher( + buildRequest('/api/app/error', { + method: 'GET', + headers: authorizedHeaders(), + }), + ) + + const body = await json(response) + expect(body.status).toBe(500) + expect(body.data).toMatchObject({ + statusCode: 500, + message: 'Internal server error', + }) + }) +}) diff --git a/be/apps/core/src/app.factory.ts b/be/apps/core/src/app.factory.ts new file mode 100644 index 00000000..a3cfca2e --- /dev/null +++ b/be/apps/core/src/app.factory.ts @@ -0,0 +1,53 @@ +import 'reflect-metadata' + +import { env } from '@afilmory/env' +import type { HonoHttpApplication } from '@afilmory/framework' +import { createApplication, createZodValidationPipe } from '@afilmory/framework' + +import { PgPoolProvider } from './database/database.provider' +import { AllExceptionsFilter } from './filters/all-exceptions.filter' +import { LoggingInterceptor } from './interceptors/logging.interceptor' +import { ResponseTransformInterceptor } from './interceptors/response-transform.interceptor' +import { AppModules } from './modules/index.module' +import { registerOpenApiRoutes } from './openapi' +import { RedisProvider } from './redis/redis.provider' + +export interface BootstrapOptions { + globalPrefix?: string +} + +const isDevelopment = env.NODE_ENV !== 'production' + +const GlobalValidationPipe = createZodValidationPipe({ + transform: true, + whitelist: true, + errorHttpStatusCode: 422, + forbidUnknownValues: true, + enableDebugMessages: isDevelopment, + stopAtFirstError: true, +}) + +export async function createConfiguredApp(options: BootstrapOptions = {}): Promise { + const app = await createApplication(AppModules, { + globalPrefix: options.globalPrefix ?? '/api', + }) + + app.useGlobalFilters(new AllExceptionsFilter()) + app.useGlobalInterceptors(new LoggingInterceptor()) + app.useGlobalInterceptors(new ResponseTransformInterceptor()) + + app.useGlobalPipes(new GlobalValidationPipe()) + + // Warm up DB connection during bootstrap + const container = app.getContainer() + const poolProvider = container.resolve(PgPoolProvider) + await poolProvider.warmup() + + // Warm up Redis connection during bootstrap + const redisProvider = container.resolve(RedisProvider) + await redisProvider.warmup() + + registerOpenApiRoutes(app.getInstance(), { globalPrefix: options.globalPrefix ?? '/api' }) + + return app +} diff --git a/be/apps/core/src/database/database.config.ts b/be/apps/core/src/database/database.config.ts new file mode 100644 index 00000000..b37c40f1 --- /dev/null +++ b/be/apps/core/src/database/database.config.ts @@ -0,0 +1,33 @@ +import { env } from '@afilmory/env' +import { injectable } from 'tsyringe' + +export interface DatabaseOptions { + url: string + /** Maximum number of clients in the pool */ + max?: number + /** Number of milliseconds a client must sit idle in the pool and not be checked out before it is disconnected */ + idleTimeoutMillis?: number + /** Number of milliseconds to wait before timing out when connecting a new client */ + connectionTimeoutMillis?: number +} + +@injectable() +export class DatabaseConfig { + getOptions(): DatabaseOptions { + const url = env.DATABASE_URL + if (!url || url.trim().length === 0) { + throw new Error('DATABASE_URL is required for database connection') + } + + const max = env.PG_POOL_MAX + const idleTimeoutMillis = env.PG_IDLE_TIMEOUT + const connectionTimeoutMillis = env.PG_CONN_TIMEOUT + + return { + url, + max, + idleTimeoutMillis, + connectionTimeoutMillis, + } + } +} diff --git a/be/apps/core/src/database/database.module.ts b/be/apps/core/src/database/database.module.ts new file mode 100644 index 00000000..d671b01c --- /dev/null +++ b/be/apps/core/src/database/database.module.ts @@ -0,0 +1,33 @@ +import { Module } from '@afilmory/framework' +import { injectable } from 'tsyringe' + +import { DatabaseConfig } from './database.config' +import { DbAccessor, DrizzleProvider, PgPoolProvider } from './database.provider' + +@injectable() +class PgPoolTokenProvider { + constructor(private readonly poolProvider: PgPoolProvider) {} + get() { + return this.poolProvider.getPool() + } +} + +@injectable() +class DrizzleTokenProvider { + constructor(private readonly drizzleProvider: DrizzleProvider) {} + get() { + return this.drizzleProvider.getDb() + } +} + +@Module({ + providers: [ + DatabaseConfig, + PgPoolProvider, + DrizzleProvider, + DbAccessor, + PgPoolTokenProvider, + DrizzleTokenProvider, + ], +}) +export class DatabaseModule {} diff --git a/be/apps/core/src/database/database.provider.ts b/be/apps/core/src/database/database.provider.ts new file mode 100644 index 00000000..6b8c8d55 --- /dev/null +++ b/be/apps/core/src/database/database.provider.ts @@ -0,0 +1,139 @@ +import { AsyncLocalStorage } from 'node:async_hooks' + +import { dbSchema } from '@afilmory/db' +import { createLogger } from '@afilmory/framework' +import { drizzle } from 'drizzle-orm/node-postgres' +import { Pool } from 'pg' +import { injectable } from 'tsyringe' + +import { BizException, ErrorCode } from 'core/errors' +import { getTenantContext } from 'core/modules/tenant/tenant.context' +import { DatabaseConfig } from './database.config' +import type { DatabaseContextStore, DrizzleDb } from './tokens' + +const dbContext = new AsyncLocalStorage() +const logger = createLogger('DB') + +export function runWithDbContext(fn: () => Promise | T) { + return new Promise((resolve, reject) => { + dbContext.run({}, () => { + Promise.resolve(fn()).then(resolve).catch(reject) + }) + }) +} + +export function getOptionalDbContext(): DatabaseContextStore | undefined { + return dbContext.getStore() +} + +export async function applyTenantIsolationContext(options?: { + tenantId?: string | null + isSuperAdmin?: boolean +}): Promise { + const store = getOptionalDbContext() + if (!store?.transaction) { + return + } + + const tenantContext = options?.tenantId + ? { id: options.tenantId } + : (() => { + const context = getTenantContext() + return context ? { id: context.tenant.id } : null + })() + + const isSuperAdmin = options?.isSuperAdmin ?? false + + if (!tenantContext && !isSuperAdmin) { + throw new BizException(ErrorCode.TENANT_NOT_FOUND) + } + + const current = store.tenantIsolation + const tenantId = tenantContext?.id ?? null + + if (current && current.tenantId === tenantId && current.isSuperAdmin === isSuperAdmin) { + return + } + + const client = store.transaction.client + + await client.query('SET LOCAL afilmory.is_superadmin = $1', [isSuperAdmin ? 'true' : 'false']) + + if (isSuperAdmin) { + await client.query('RESET afilmory.tenant_id') + } else if (tenantId) { + await client.query('SET LOCAL afilmory.tenant_id = $1', [tenantId]) + } + + store.tenantIsolation = { + tenantId, + isSuperAdmin, + } +} + +@injectable() +export class PgPoolProvider { + private pool?: Pool + + constructor(private readonly config: DatabaseConfig) {} + + getPool(): Pool { + if (!this.pool) { + const options = this.config.getOptions() + this.pool = new Pool({ + connectionString: options.url, + max: options.max, + idleTimeoutMillis: options.idleTimeoutMillis, + connectionTimeoutMillis: options.connectionTimeoutMillis, + }) + this.pool.on('error', (error) => { + logger.error(`Unexpected error on idle PostgreSQL client: ${String(error)}`) + }) + } + return this.pool + } + + async warmup(): Promise { + const pool = this.getPool() + const client = await pool.connect() + try { + await client.query('SELECT 1') + logger.info('Database connection established successfully') + } finally { + client.release() + } + } +} + +@injectable() +export class DrizzleProvider { + private db?: DrizzleDb + + constructor(private readonly poolProvider: PgPoolProvider) {} + + getDb(): DrizzleDb { + if (!this.db) { + this.db = drizzle(this.poolProvider.getPool(), { schema: dbSchema }) + } + return this.db + } +} + +@injectable() +export class DbAccessor { + constructor( + private readonly provider: DrizzleProvider, + private readonly poolProvider: PgPoolProvider, + ) {} + + get(): DrizzleDb { + const store = getOptionalDbContext() + if (store?.transaction) { + if (!store.db) { + store.db = drizzle(store.transaction.client, { schema: dbSchema }) + } + return store.db + } + return this.provider.getDb() + } +} diff --git a/be/apps/core/src/database/tokens.ts b/be/apps/core/src/database/tokens.ts new file mode 100644 index 00000000..1cbdc901 --- /dev/null +++ b/be/apps/core/src/database/tokens.ts @@ -0,0 +1,24 @@ +import type { DBSchema } from '@afilmory/db' +import type { NodePgDatabase } from 'drizzle-orm/node-postgres' +import type { PoolClient } from 'pg' + +export type DrizzleDb = NodePgDatabase + +export const DRIZZLE_DB = Symbol.for('core.database.drizzle') +export const PG_POOL = Symbol.for('core.database.pg-pool') + +export type DrizzleDbToken = typeof DRIZZLE_DB +export type PgPoolToken = typeof PG_POOL + +export interface TransactionContext { + client: PoolClient +} + +export interface DatabaseContextStore { + transaction?: TransactionContext + db?: DrizzleDb + tenantIsolation?: { + tenantId?: string | null + isSuperAdmin?: boolean + } +} diff --git a/be/apps/core/src/database/transaction.interceptor.ts b/be/apps/core/src/database/transaction.interceptor.ts new file mode 100644 index 00000000..9eff26d7 --- /dev/null +++ b/be/apps/core/src/database/transaction.interceptor.ts @@ -0,0 +1,45 @@ +import type { CallHandler, ExecutionContext, FrameworkResponse, Interceptor } from '@afilmory/framework' +import { createLogger } from '@afilmory/framework' +import type { PoolClient } from 'pg' +import { injectable } from 'tsyringe' + +import { applyTenantIsolationContext, getOptionalDbContext, PgPoolProvider, runWithDbContext } from './database.provider' +import { getTenantContext } from 'core/modules/tenant/tenant.context' + +const logger = createLogger('DB') + +@injectable() +export class TransactionInterceptor implements Interceptor { + constructor(private readonly poolProvider: PgPoolProvider) {} + + async intercept(context: ExecutionContext, next: CallHandler): Promise { + // Ensure db context exists per request lifecycle + return await runWithDbContext(async () => { + const client: PoolClient = await this.poolProvider.getPool().connect() + const store = getOptionalDbContext()! + store.transaction = { client } + try { + await client.query('BEGIN') + + const tenant = getTenantContext() + if (tenant) { + await applyTenantIsolationContext({ tenantId: tenant.tenant.id, isSuperAdmin: false }) + } + + const result = await next.handle() + await client.query('COMMIT') + return result + } catch (error) { + try { + await client.query('ROLLBACK') + } catch (rollbackError) { + logger.error(`Transaction rollback failed: ${String(rollbackError)}`) + } + throw error + } finally { + store.transaction = undefined + client.release() + } + }) + } +} diff --git a/be/apps/core/src/errors/biz-exception.ts b/be/apps/core/src/errors/biz-exception.ts new file mode 100644 index 00000000..3d318d57 --- /dev/null +++ b/be/apps/core/src/errors/biz-exception.ts @@ -0,0 +1,46 @@ +import type { ErrorCode, ErrorDescriptor } from './error-codes' +import { ERROR_CODE_DESCRIPTORS } from './error-codes' + +export interface BizExceptionOptions { + message?: string + details?: TDetails + cause?: unknown +} + +export interface BizErrorResponse { + code: ErrorCode + message: string + details?: TDetails +} + +export class BizException extends Error { + readonly code: ErrorCode + readonly details?: TDetails + private readonly httpStatus: number + + constructor(code: ErrorCode, options?: BizExceptionOptions) { + const descriptor: ErrorDescriptor = ERROR_CODE_DESCRIPTORS[code] + super(options?.message ?? descriptor.message, options?.cause ? { cause: options.cause } : undefined) + this.name = 'BizException' + this.code = code + this.details = options?.details + this.httpStatus = descriptor.httpStatus + } + + getHttpStatus(): number { + return this.httpStatus + } + + toResponse(): BizErrorResponse { + const response: BizErrorResponse = { + code: this.code, + message: this.message, + } + + if (this.details !== undefined) { + response.details = this.details + } + + return response + } +} diff --git a/be/apps/core/src/errors/error-codes.ts b/be/apps/core/src/errors/error-codes.ts new file mode 100644 index 00000000..b9d7cf40 --- /dev/null +++ b/be/apps/core/src/errors/error-codes.ts @@ -0,0 +1,65 @@ +export enum ErrorCode { + // Common + COMMON_VALIDATION = 1, + COMMON_BAD_REQUEST = 2, + COMMON_NOT_FOUND = 3, + COMMON_CONFLICT = 4, + COMMON_RATE_LIMITED = 5, + + // Auth + AUTH_UNAUTHORIZED = 10, + AUTH_FORBIDDEN = 11, + + // Tenant + TENANT_NOT_FOUND = 20, + TENANT_SUSPENDED = 21, + TENANT_INACTIVE = 22, +} + +export interface ErrorDescriptor { + httpStatus: number + message: string +} + +export const ERROR_CODE_DESCRIPTORS: Record = { + [ErrorCode.COMMON_VALIDATION]: { + httpStatus: 422, + message: 'Validation failed', + }, + [ErrorCode.COMMON_BAD_REQUEST]: { + httpStatus: 400, + message: 'Bad request', + }, + [ErrorCode.COMMON_NOT_FOUND]: { + httpStatus: 404, + message: 'Resource not found', + }, + [ErrorCode.COMMON_CONFLICT]: { + httpStatus: 409, + message: 'Resource conflict', + }, + [ErrorCode.COMMON_RATE_LIMITED]: { + httpStatus: 429, + message: 'Too many requests', + }, + [ErrorCode.AUTH_UNAUTHORIZED]: { + httpStatus: 401, + message: 'Unauthorized', + }, + [ErrorCode.AUTH_FORBIDDEN]: { + httpStatus: 403, + message: 'Forbidden', + }, + [ErrorCode.TENANT_NOT_FOUND]: { + httpStatus: 404, + message: 'Tenant not found', + }, + [ErrorCode.TENANT_SUSPENDED]: { + httpStatus: 403, + message: 'Tenant is suspended', + }, + [ErrorCode.TENANT_INACTIVE]: { + httpStatus: 403, + message: 'Tenant is not active', + }, +} diff --git a/be/apps/core/src/errors/index.ts b/be/apps/core/src/errors/index.ts new file mode 100644 index 00000000..079ab419 --- /dev/null +++ b/be/apps/core/src/errors/index.ts @@ -0,0 +1,2 @@ +export * from './biz-exception' +export * from './error-codes' diff --git a/be/apps/core/src/filters/all-exceptions.filter.ts b/be/apps/core/src/filters/all-exceptions.filter.ts new file mode 100644 index 00000000..bb2e1fe9 --- /dev/null +++ b/be/apps/core/src/filters/all-exceptions.filter.ts @@ -0,0 +1,59 @@ +import type { ArgumentsHost, ExceptionFilter } from '@afilmory/framework' +import { createLogger, HttpException } from '@afilmory/framework' +import { BizException } from 'core/errors' +import { toUri } from 'core/helpers/url.helper' +import { injectable } from 'tsyringe' + +@injectable() +export class AllExceptionsFilter implements ExceptionFilter { + private readonly logger = createLogger('AllExceptionsFilter') + catch(exception: unknown, host: ArgumentsHost) { + if (exception instanceof BizException) { + const response = exception.toResponse() + return new Response(JSON.stringify(response), { + status: exception.getHttpStatus(), + headers: { + 'content-type': 'application/json', + }, + }) + } + + if (exception instanceof HttpException) { + return new Response(JSON.stringify(exception.getResponse()), { + status: exception.getStatus(), + headers: { + 'content-type': 'application/json', + }, + }) + } + + if (typeof exception === 'object' && exception !== null && 'statusCode' in exception) { + return new Response(JSON.stringify(exception), { + status: exception.statusCode as number, + headers: { + 'content-type': 'application/json', + }, + }) + } + + const store = host.getContext() + const ctx = store.hono + + const error = exception instanceof Error ? exception : new Error(String(exception)) + + this.logger.error(`--- ${ctx.req.method} ${toUri(ctx.req.url)} --->\n`, error) + + return new Response( + JSON.stringify({ + statusCode: 500, + message: 'Internal server error', + }), + { + status: 500, + headers: { + 'content-type': 'application/json', + }, + }, + ) + } +} diff --git a/be/apps/core/src/guards/auth.guard.ts b/be/apps/core/src/guards/auth.guard.ts new file mode 100644 index 00000000..29721dd4 --- /dev/null +++ b/be/apps/core/src/guards/auth.guard.ts @@ -0,0 +1,103 @@ +import { authUsers } from '@afilmory/db' +import type { CanActivate, ExecutionContext } from '@afilmory/framework' +import { HttpContext } from '@afilmory/framework' +import type { Session } from 'better-auth' +import { applyTenantIsolationContext, DbAccessor } from 'core/database/database.provider' +import { BizException, ErrorCode } from 'core/errors' +import { eq } from 'drizzle-orm' +import { injectable } from 'tsyringe' + +import type { AuthSession } from '../modules/auth/auth.provider' +import { AuthProvider } from '../modules/auth/auth.provider' +import { getAllowedRoleMask, roleNameToBit } from './roles.decorator' + +declare module '@afilmory/framework' { + interface HttpContextValues { + auth?: { + user?: AuthSession['user'] + session?: Session + } + } +} + +@injectable() +export class AuthGuard implements CanActivate { + constructor( + private readonly authProvider: AuthProvider, + private readonly dbAccessor: DbAccessor, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const store = context.getContext() + const { hono } = store + + const auth = await this.authProvider.getAuth() + + const session = await auth.api.getSession({ headers: hono.req.raw.headers }) + + const tenantContext = HttpContext.getValue('tenant') + + if (session) { + HttpContext.assign({ + auth: { + user: session.user, + session: session.session, + }, + }) + + const roleName = session.user.role as 'user' | 'admin' | 'superadmin' | undefined + const isSuperAdmin = roleName === 'superadmin' + let sessionTenantId = session.user?.tenantId + + if (!isSuperAdmin) { + if (!tenantContext) { + throw new BizException(ErrorCode.TENANT_NOT_FOUND) + } + + if (!sessionTenantId) { + const db = this.dbAccessor.get() + const [record] = await db + .select({ tenantId: authUsers.tenantId }) + .from(authUsers) + .where(eq(authUsers.id, session.user.id)) + .limit(1) + + sessionTenantId = record?.tenantId ?? '' + } + + if (!sessionTenantId) { + throw new BizException(ErrorCode.AUTH_FORBIDDEN) + } + + if (sessionTenantId !== tenantContext.tenant.id) { + throw new BizException(ErrorCode.AUTH_FORBIDDEN) + } + } + + await applyTenantIsolationContext({ + tenantId: tenantContext?.tenant.id ?? sessionTenantId ?? null, + isSuperAdmin, + }) + + if (isSuperAdmin) { + return true + } + } + // Role verification if decorator is present + const handler = context.getHandler() + const requiredMask = getAllowedRoleMask(handler) + if (requiredMask > 0) { + if (!session) { + throw new BizException(ErrorCode.AUTH_UNAUTHORIZED) + } + + const userRoleName = session.user.role as 'user' | 'admin' | 'superadmin' | undefined + const userMask = userRoleName ? roleNameToBit(userRoleName) : 0 + const hasRole = (requiredMask & userMask) !== 0 + if (!hasRole) { + throw new BizException(ErrorCode.AUTH_FORBIDDEN) + } + } + return true + } +} diff --git a/be/apps/core/src/guards/roles.decorator.ts b/be/apps/core/src/guards/roles.decorator.ts new file mode 100644 index 00000000..b2342194 --- /dev/null +++ b/be/apps/core/src/guards/roles.decorator.ts @@ -0,0 +1,45 @@ +import { applyDecorators } from '@afilmory/framework' + +export const ROLES_METADATA = Symbol.for('core.auth.allowed_roles') + +export enum RoleBit { + GUEST = 0, + USER = 1 << 0, + ADMIN = 1 << 1, + SUPERADMIN = 1 << 2, +} + +export type RoleName = 'user' | 'admin' | 'superadmin' | (string & {}) + +export function roleNameToBit(name?: RoleName): RoleBit { + switch (name) { + case 'superadmin': { + return RoleBit.SUPERADMIN | RoleBit.ADMIN | RoleBit.USER | RoleBit.GUEST + } + + case 'admin': { + return RoleBit.ADMIN | RoleBit.USER | RoleBit.GUEST + } + + case 'user': { + return RoleBit.USER | RoleBit.GUEST + } + + default: { + return RoleBit.GUEST + } + } +} + +export function Roles(...roles: Array): MethodDecorator & ClassDecorator { + const mask = roles.map((r) => (typeof r === 'string' ? roleNameToBit(r) : r)).reduce((m, r) => m | r, 0) + + return applyDecorators((target: object, propertyKey: string | symbol, descriptor: PropertyDescriptor) => { + const targetForMetadata = descriptor?.value && typeof descriptor.value === 'function' ? descriptor.value : target + Reflect.defineMetadata(ROLES_METADATA, mask, targetForMetadata) + }) +} + +export function getAllowedRoleMask(target: object): number { + return (Reflect.getMetadata(ROLES_METADATA, target) || 0) as number +} diff --git a/be/apps/core/src/helpers/logger.helper.ts b/be/apps/core/src/helpers/logger.helper.ts new file mode 100644 index 00000000..86289297 --- /dev/null +++ b/be/apps/core/src/helpers/logger.helper.ts @@ -0,0 +1,3 @@ +import { createLogger } from '@afilmory/framework' + +export const logger = createLogger('Global') diff --git a/be/apps/core/src/helpers/url.helper.ts b/be/apps/core/src/helpers/url.helper.ts new file mode 100644 index 00000000..2e8a686e --- /dev/null +++ b/be/apps/core/src/helpers/url.helper.ts @@ -0,0 +1,8 @@ +export function toUri(url: string): string { + try { + const u = new URL(url) + return `${u.pathname}${u.search}` + } catch { + return url + } +} diff --git a/be/apps/core/src/index.ts b/be/apps/core/src/index.ts new file mode 100644 index 00000000..35713dce --- /dev/null +++ b/be/apps/core/src/index.ts @@ -0,0 +1,38 @@ +import 'reflect-metadata' + +import { env } from '@afilmory/env' +import { serve } from '@hono/node-server' +import { green } from 'picocolors' + +import { createConfiguredApp } from './app.factory' +import { logger } from './helpers/logger.helper' + +process.title = 'Hono HTTP Server' + +async function bootstrap() { + const app = await createConfiguredApp({ + globalPrefix: '/api', + }) + + const hono = app.getInstance() + const port = env.PORT + + const hostname = env.HOSTNAME + const server = serve({ + fetch: hono.fetch, + port, + hostname, + }) + + void server + + logger.info( + `Hono HTTP application started on http://${hostname}:${port}. ${green(`+${performance.now().toFixed(2)}ms`)}`, + ) +} + +bootstrap().catch((error) => { + console.error('Application bootstrap failed', error) + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1) +}) diff --git a/be/apps/core/src/interceptors/logging.interceptor.ts b/be/apps/core/src/interceptors/logging.interceptor.ts new file mode 100644 index 00000000..d8298011 --- /dev/null +++ b/be/apps/core/src/interceptors/logging.interceptor.ts @@ -0,0 +1,24 @@ +import type { CallHandler, ExecutionContext, FrameworkResponse, Interceptor } from '@afilmory/framework' +import { createLogger } from '@afilmory/framework' +import { toUri } from 'core/helpers/url.helper' +import { green } from 'picocolors' +import { injectable } from 'tsyringe' + +const httpLogger = createLogger('HTTP') + +@injectable() +export class LoggingInterceptor implements Interceptor { + async intercept(context: ExecutionContext, next: CallHandler): Promise { + const start = performance.now() + const { hono } = context.getContext() + const { method, url } = hono.req + + const uri = toUri(url) + httpLogger.info(['<---', `${method} -> ${uri}`].join(' ')) + const result = await next.handle() + const durationMs = Number((performance.now() - start).toFixed(2)) + httpLogger.info(['--->', `${method} -> ${uri}`, green(`+${durationMs}ms`)].join(' ')) + + return result + } +} diff --git a/be/apps/core/src/interceptors/response-transform.decorator.ts b/be/apps/core/src/interceptors/response-transform.decorator.ts new file mode 100644 index 00000000..d825f1bd --- /dev/null +++ b/be/apps/core/src/interceptors/response-transform.decorator.ts @@ -0,0 +1,8 @@ +export const RESPONSE_TRANSFORM_BYPASS = Symbol.for('core.response_transform.bypass') + +export function BypassResponseTransform(): MethodDecorator & ClassDecorator { + return (target: object, propertyKey?: string | symbol, descriptor?: PropertyDescriptor) => { + const targetForMetadata = descriptor?.value && typeof descriptor.value === 'function' ? descriptor.value : target + Reflect.defineMetadata(RESPONSE_TRANSFORM_BYPASS, true, targetForMetadata) + } +} diff --git a/be/apps/core/src/interceptors/response-transform.interceptor.ts b/be/apps/core/src/interceptors/response-transform.interceptor.ts new file mode 100644 index 00000000..240b96eb --- /dev/null +++ b/be/apps/core/src/interceptors/response-transform.interceptor.ts @@ -0,0 +1,151 @@ +import { env } from '@afilmory/env' +import type { CallHandler, ExecutionContext, FrameworkResponse, Interceptor } from '@afilmory/framework' +import { injectable } from 'tsyringe' + +import { RESPONSE_TRANSFORM_BYPASS } from './response-transform.decorator' + +function isPrimitive(value: unknown): value is string | number | boolean | null { + const type = typeof value + return value == null || type === 'string' || type === 'number' || type === 'boolean' +} + +function isPlainObject(value: unknown): value is Record { + if (value === null || typeof value !== 'object') return false + const proto = Object.getPrototypeOf(value) + return proto === Object.prototype || proto === null +} + +function snakeCase(input: string): string { + if (input.length === 0) return input + const replaced = input + .replaceAll(/[\s.-]+/g, '_') + .replaceAll(/([a-z0-9])([A-Z])/g, '$1_$2') + .replaceAll(/([A-Z]+)([A-Z][a-z0-9]+)/g, '$1_$2') + .replaceAll(/_{2,}/g, '_') + return replaced.toLowerCase() +} + +function isJsonLikeContentType(contentType: string | null): boolean { + if (!contentType) return false + const lower = contentType.toLowerCase() + return lower.includes('json') +} + +function shouldBypassTransform(target: object | Function | undefined): boolean { + if (!target) return false + try { + return Boolean(Reflect.getMetadata(RESPONSE_TRANSFORM_BYPASS, target)) + } catch { + return false + } +} + +function transformKeysToSnakeCase(value: unknown, seen = new WeakMap()): unknown { + if (isPrimitive(value)) return value + + if (Array.isArray(value)) { + return value.map((item) => transformKeysToSnakeCase(item, seen)) + } + + if (value instanceof Date || value instanceof RegExp || value instanceof URL || value instanceof Error) { + return value + } + + if (typeof value === 'object' && value !== null) { + const obj = value as Record + + if (!isPlainObject(obj)) { + return obj + } + + const existing = seen.get(obj) + if (existing) { + return existing + } + + const output: Record = Object.create(null) + seen.set(obj, output) + + for (const key of Object.keys(obj)) { + // Skip dangerous keys and functions + if (key === '__proto__' || key === 'constructor' || key === 'prototype') { + continue + } + + const val = obj[key] + if (typeof val === 'function') { + continue + } + + const newKey = snakeCase(key) + output[newKey] = transformKeysToSnakeCase(val, seen) + } + + return output + } + + return value +} + +@injectable() +export class ResponseTransformInterceptor implements Interceptor { + async intercept(context: ExecutionContext, next: CallHandler): Promise { + const handler = context.getHandler() + const clazz = context.getClass() + + if (shouldBypassTransform(handler) || shouldBypassTransform(clazz) || env.TEST) { + return await next.handle() + } + + const response = await next.handle() + + // Only process JSON responses with bodies + const contentType = response.headers.get('content-type') + if (!isJsonLikeContentType(contentType)) { + return response + } + + // Avoid transforming empty bodies or no-content statuses + if (response.status === 204 || response.status === 304) { + return response + } + + // Read body safely from a clone + let rawText = '' + try { + const clone = response.clone() + rawText = await clone.text() + } catch { + return response + } + + if (!rawText) { + return response + } + + let payload: unknown + try { + payload = JSON.parse(rawText) + } catch { + return response + } + + // Transform only objects/arrays + if (!isPlainObject(payload) && !Array.isArray(payload)) { + return response + } + + const transformed = transformKeysToSnakeCase(payload) + const body = JSON.stringify(transformed === undefined ? null : transformed) + + const headers = new Headers(response.headers) + headers.set('content-type', contentType || 'application/json; charset=utf-8') + headers.delete('content-length') + + return new Response(body, { + status: response.status, + statusText: response.statusText, + headers, + }) as FrameworkResponse + } +} diff --git a/be/apps/core/src/middlewares/cors.middleware.ts b/be/apps/core/src/middlewares/cors.middleware.ts new file mode 100644 index 00000000..05f8615f --- /dev/null +++ b/be/apps/core/src/middlewares/cors.middleware.ts @@ -0,0 +1,174 @@ +import type { HttpMiddleware, OnModuleDestroy, OnModuleInit } from '@afilmory/framework' +import { EventEmitterService, Middleware } from '@afilmory/framework' +import type { Context, Next } from 'hono' +import { cors } from 'hono/cors' +import { injectable } from 'tsyringe' + +import { logger } from '../helpers/logger.helper' +import { SettingService } from '../modules/setting/setting.service' +import { getTenantContext } from '../modules/tenant/tenant.context' +import { TenantService } from '../modules/tenant/tenant.service' + +type AllowedOrigins = '*' | string[] + +function normalizeOriginValue(value: string): string { + const trimmed = value.trim() + if (trimmed === '' || trimmed === '*') { + return trimmed + } + + try { + const url = new URL(trimmed) + return `${url.protocol}//${url.host}` + } catch { + return trimmed.replace(/\/+$/, '') + } +} + +function parseAllowedOrigins(raw: string | null): AllowedOrigins { + if (!raw) { + return '*' + } + + const entries = raw + .split(/[\n,]/) + .map((value) => normalizeOriginValue(value)) + .filter((value) => value.length > 0) + + if (entries.length === 0 || entries.includes('*')) { + return '*' + } + + return Array.from(new Set(entries)) +} + +@Middleware({ path: '/*', priority: -100 }) +@injectable() +export class CorsMiddleware implements HttpMiddleware, OnModuleInit, OnModuleDestroy { + private readonly allowedOrigins = new Map() + private defaultTenantId?: string + private readonly logger = logger.extend('CorsMiddleware') + private readonly corsMiddleware = cors({ + origin: (origin) => this.resolveOrigin(origin), + credentials: true, + }) + + private readonly handleSettingUpdated = ({ + tenantId, + key, + value, + }: { + tenantId: string + key: string + value: string + }) => { + if (key !== 'http.cors.allowedOrigins') { + return + } + void this.reloadAllowedOrigins(tenantId) + } + + private readonly handleSettingDeleted = ({ tenantId, key }: { tenantId: string; key: string }) => { + if (key !== 'http.cors.allowedOrigins') { + return + } + this.allowedOrigins.delete(tenantId) + } + + constructor( + private readonly eventEmitter: EventEmitterService, + private readonly settingService: SettingService, + private readonly tenantService: TenantService, + ) {} + + async onModuleInit(): Promise { + try { + const defaultTenant = await this.tenantService.getDefaultTenant() + this.defaultTenantId = defaultTenant.tenant.id + await this.reloadAllowedOrigins(defaultTenant.tenant.id) + } catch (error) { + this.logger.warn('Failed to preload default tenant CORS configuration', error) + } + this.eventEmitter.on('setting.updated', this.handleSettingUpdated) + this.eventEmitter.on('setting.deleted', this.handleSettingDeleted) + } + + async onModuleDestroy(): Promise { + this.eventEmitter.off('setting.updated', this.handleSettingUpdated) + this.eventEmitter.off('setting.deleted', this.handleSettingDeleted) + } + + async use(context: Context, next: Next): Promise { + const tenantContext = getTenantContext() + const tenantId = tenantContext?.tenant.id ?? this.defaultTenantId + + if (tenantId) { + await this.ensureTenantOriginsLoaded(tenantId) + } else { + this.logger.warn('Tenant context missing for request %s %s', context.req.method, context.req.path) + } + + return await this.corsMiddleware(context, next) + } + + private async ensureTenantOriginsLoaded(tenantId: string): Promise { + if (this.allowedOrigins.has(tenantId)) { + return + } + + await this.reloadAllowedOrigins(tenantId) + } + + private async reloadAllowedOrigins(tenantId: string): Promise { + let raw: string | null = null + + try { + raw = await this.settingService.get('http.cors.allowedOrigins', { tenantId }) + } catch (error) { + this.logger.warn('Failed to load CORS configuration from settings for tenant %s', tenantId, error) + } + + this.updateAllowedOrigins(tenantId, raw) + } + + private updateAllowedOrigins(tenantId: string, next: string | null): void { + const parsed = parseAllowedOrigins(next) + this.allowedOrigins.set(tenantId, parsed) + this.logger.info( + 'Updated CORS allowed origins for tenant %s %s', + tenantId, + parsed === '*' ? '*' : JSON.stringify(parsed), + ) + } + + private resolveOrigin(origin: string | undefined): string | null { + if (!origin) { + return null + } + + const normalized = normalizeOriginValue(origin) + + if (!normalized) { + return null + } + + const tenantContext = getTenantContext() + const tenantId = tenantContext?.tenant.id ?? this.defaultTenantId + + if (!tenantId) { + return null + } + + const allowed = this.allowedOrigins.get(tenantId) + + if (!allowed) { + return null + } + + if (allowed === '*') { + return normalized + } + + return allowed.includes(normalized) ? normalized : null + } +} diff --git a/be/apps/core/src/middlewares/database-context.middleware.ts b/be/apps/core/src/middlewares/database-context.middleware.ts new file mode 100644 index 00000000..209ed233 --- /dev/null +++ b/be/apps/core/src/middlewares/database-context.middleware.ts @@ -0,0 +1,46 @@ +import type { HttpMiddleware } from '@afilmory/framework' +import { Middleware } from '@afilmory/framework' +import type { Context, Next } from 'hono' +import { injectable } from 'tsyringe' + +import { applyTenantIsolationContext, getOptionalDbContext, PgPoolProvider, runWithDbContext } from 'core/database/database.provider' +import { getTenantContext } from 'core/modules/tenant/tenant.context' +import { logger } from '../helpers/logger.helper' + +@Middleware({ path: '/*', priority: -180 }) +@injectable() +export class DatabaseContextMiddleware implements HttpMiddleware { + private readonly log = logger.extend('DatabaseContext') + + constructor(private readonly poolProvider: PgPoolProvider) {} + + async use(_context: Context, next: Next): Promise { + return await runWithDbContext(async () => { + const client = await this.poolProvider.getPool().connect() + const store = getOptionalDbContext()! + store.transaction = { client } + try { + await client.query('BEGIN') + + const tenant = getTenantContext() + if (tenant) { + await applyTenantIsolationContext({ tenantId: tenant.tenant.id, isSuperAdmin: false }) + } + + const response = await next() + await client.query('COMMIT') + return response + } catch (error) { + try { + await client.query('ROLLBACK') + } catch (rollbackError) { + this.log.error(`Transaction rollback failed: ${String(rollbackError)}`) + } + throw error + } finally { + store.transaction = undefined + client.release() + } + }) + } +} diff --git a/be/apps/core/src/middlewares/tenant-resolver.middleware.ts b/be/apps/core/src/middlewares/tenant-resolver.middleware.ts new file mode 100644 index 00000000..07413191 --- /dev/null +++ b/be/apps/core/src/middlewares/tenant-resolver.middleware.ts @@ -0,0 +1,52 @@ +import type { HttpMiddleware } from '@afilmory/framework' +import { HttpContext, Middleware } from '@afilmory/framework' +import type { Context, Next } from 'hono' +import { injectable } from 'tsyringe' + +import { logger } from '../helpers/logger.helper' +import { TenantService } from '../modules/tenant/tenant.service' + +const HEADER_TENANT_ID = 'x-tenant-id' +const HEADER_TENANT_SLUG = 'x-tenant-slug' + +@Middleware({ path: '/*', priority: -200 }) +@injectable() +export class TenantResolverMiddleware implements HttpMiddleware { + private readonly log = logger.extend('TenantResolver') + + constructor(private readonly tenantService: TenantService) {} + + async use(context: Context, next: Next): Promise { + const tenantContext = await this.resolveTenantContext(context) + HttpContext.assign({ tenant: tenantContext }) + + const response = await next() + + context.header(HEADER_TENANT_ID, tenantContext.tenant.id) + context.header(HEADER_TENANT_SLUG, tenantContext.tenant.slug) + + return response + } + + private async resolveTenantContext(context: Context) { + const host = context.req.header('host') + const tenantId = context.req.header(HEADER_TENANT_ID) + const tenantSlug = context.req.header(HEADER_TENANT_SLUG) + + this.log.debug( + 'Resolve tenant for request %s %s (host=%s, id=%s, slug=%s)', + context.req.method, + context.req.path, + host ?? 'n/a', + tenantId ?? 'n/a', + tenantSlug ?? 'n/a', + ) + + return await this.tenantService.resolve({ + tenantId, + slug: tenantSlug, + domain: host, + fallbackToDefault: true, + }) + } +} diff --git a/be/apps/core/src/modules/auth/auth.config.ts b/be/apps/core/src/modules/auth/auth.config.ts new file mode 100644 index 00000000..77b27563 --- /dev/null +++ b/be/apps/core/src/modules/auth/auth.config.ts @@ -0,0 +1,42 @@ +import { env } from '@afilmory/env' +import { injectable } from 'tsyringe' + +export interface SocialProvidersConfig { + google?: { clientId: string; clientSecret: string; redirectUri?: string } + github?: { clientId: string; clientSecret: string; redirectUri?: string } + zoom?: { clientId: string; clientSecret: string; redirectUri?: string } +} + +export interface AuthModuleOptions { + prefix: string + useDrizzle: boolean + socialProviders: SocialProvidersConfig +} + +@injectable() +export class AuthConfig { + getOptions(): AuthModuleOptions { + const prefix = '/auth' + const socialProviders: SocialProvidersConfig = {} + + if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) { + socialProviders.google = { + clientId: env.GOOGLE_CLIENT_ID, + clientSecret: env.GOOGLE_CLIENT_SECRET, + } + } + + if (env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET) { + socialProviders.github = { + clientId: env.GITHUB_CLIENT_ID, + clientSecret: env.GITHUB_CLIENT_SECRET, + } + } + + return { + prefix, + useDrizzle: true, + socialProviders, + } + } +} diff --git a/be/apps/core/src/modules/auth/auth.controller.ts b/be/apps/core/src/modules/auth/auth.controller.ts new file mode 100644 index 00000000..bb23c695 --- /dev/null +++ b/be/apps/core/src/modules/auth/auth.controller.ts @@ -0,0 +1,63 @@ +import { Body, ContextParam, Controller, Get, Post, UnauthorizedException } from '@afilmory/framework' +import type { Context } from 'hono' + +import { RoleBit, Roles } from '../../guards/roles.decorator' +import { AuthProvider } from './auth.provider' + +@Controller('auth') +export class AuthController { + constructor(private readonly auth: AuthProvider) {} + + @Get('/session') + async getSession(@ContextParam() context: Context) { + const auth = this.auth.getAuth() + // forward tenant headers so Better Auth can persist tenantId via databaseHooks + const headers = new Headers(context.req.raw.headers) + const tenant = (context as any).var?.tenant + if (tenant?.tenant?.id) { + headers.set('x-tenant-id', tenant.tenant.id) + if (tenant.tenant.slug) headers.set('x-tenant-slug', tenant.tenant.slug) + } + const session = await auth.api.getSession({ headers }) + if (!session) { + throw new UnauthorizedException() + } + return { user: session.user, session: session.session } + } + + @Post('/sign-in/email') + async signInEmail(@ContextParam() context: Context, @Body() body: { email: string; password: string }) { + const auth = this.auth.getAuth() + const headers = new Headers(context.req.raw.headers) + const tenant = (context as any).var?.tenant + if (tenant?.tenant?.id) { + headers.set('x-tenant-id', tenant.tenant.id) + if (tenant.tenant.slug) headers.set('x-tenant-slug', tenant.tenant.slug) + } + const response = await auth.api.signInEmail({ + body: { + email: body.email, + password: body.password, + }, + asResponse: true, + headers, + }) + return response + } + + @Get('/admin-only') + @Roles(RoleBit.ADMIN) + async adminOnly(@ContextParam() _context: Context) { + return { ok: true } + } + + @Get('/*') + async passthroughGet(@ContextParam() context: Context) { + return await this.auth.handler(context) + } + + @Post('/*') + async passthroughPost(@ContextParam() context: Context) { + return await this.auth.handler(context) + } +} diff --git a/be/apps/core/src/modules/auth/auth.module.ts b/be/apps/core/src/modules/auth/auth.module.ts new file mode 100644 index 00000000..0dd04f2c --- /dev/null +++ b/be/apps/core/src/modules/auth/auth.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@afilmory/framework' +import { DatabaseModule } from 'core/database/database.module' + +import { AuthConfig } from './auth.config' +import { AuthController } from './auth.controller' +import { AuthProvider } from './auth.provider' + +@Module({ + imports: [DatabaseModule], + controllers: [AuthController], + providers: [AuthProvider, AuthConfig], +}) +export class AuthModule {} diff --git a/be/apps/core/src/modules/auth/auth.provider.ts b/be/apps/core/src/modules/auth/auth.provider.ts new file mode 100644 index 00000000..ccfc8294 --- /dev/null +++ b/be/apps/core/src/modules/auth/auth.provider.ts @@ -0,0 +1,97 @@ +import { generateId } from '@afilmory/be-utils' +import { authAccounts, authSessions, authUsers } from '@afilmory/db' +import type { OnModuleInit } from '@afilmory/framework' +import { HttpContext, createLogger } from '@afilmory/framework' +import { betterAuth } from 'better-auth' +import { drizzleAdapter } from 'better-auth/adapters/drizzle' +import { admin } from 'better-auth/plugins' +import type { Context } from 'hono' +import { injectable } from 'tsyringe' + +import { DrizzleProvider } from '../../database/database.provider' +import { AuthConfig } from './auth.config' + +export type BetterAuthInstance = ReturnType + +const logger = createLogger('Auth') + +@injectable() +export class AuthProvider implements OnModuleInit { + private instance?: ReturnType + + constructor( + private readonly config: AuthConfig, + private readonly drizzleProvider: DrizzleProvider, + ) {} + + onModuleInit(): void { + this.instance = this.getAuth() + } + + private createAuth() { + const options = this.config.getOptions() + const db = this.drizzleProvider.getDb() + return betterAuth({ + database: drizzleAdapter(db, { + provider: 'pg', + schema: { + user: authUsers, + session: authSessions, + account: authAccounts, + }, + }), + socialProviders: options.socialProviders, + emailAndPassword: { enabled: true }, + user: { + // Ensure tenantId and role are part of the typed/session payload + additionalFields: { + tenantId: { type: 'string', input: false }, + role: { type: 'string', input: false }, + }, + }, + databaseHooks: { + session: { + create: { + before: async (session) => { + // Attach tenantId from our request-scoped context to the auth session record + const tenant = HttpContext.getValue('tenant') as { tenant: { id: string } } | undefined + return { + data: { + ...session, + tenantId: tenant?.tenant.id ?? null, + }, + } + }, + }, + }, + }, + advanced: { + database: { + generateId: () => generateId(), + }, + }, + plugins: [ + admin({ + adminRoles: ['admin'], + defaultRole: 'user', + defaultBanReason: 'Spamming', + }), + ], + }) + } + getAuth() { + if (!this.instance) { + this.instance = this.createAuth() + logger.info('Better Auth initialized') + } + return this.instance + } + + handler(context: Context): Promise { + const auth = this.getAuth() + return auth.handler(context.req.raw) + } +} + +export type AuthInstance = ReturnType +export type AuthSession = BetterAuthInstance['$Infer']['Session'] diff --git a/be/apps/core/src/modules/index.module.ts b/be/apps/core/src/modules/index.module.ts new file mode 100644 index 00000000..ba87521d --- /dev/null +++ b/be/apps/core/src/modules/index.module.ts @@ -0,0 +1,53 @@ +import { APP_GUARD, APP_MIDDLEWARE, EventModule, Module } from '@afilmory/framework' +import { AuthGuard } from 'core/guards/auth.guard' +import { CorsMiddleware } from 'core/middlewares/cors.middleware' +import { TenantResolverMiddleware } from 'core/middlewares/tenant-resolver.middleware' +import { DatabaseContextMiddleware } from 'core/middlewares/database-context.middleware' +import { RedisAccessor } from 'core/redis/redis.provider' + +import { DatabaseModule } from '../database/database.module' +import { RedisModule } from '../redis/redis.module' +import { AuthModule } from './auth/auth.module' +import { OnboardingModule } from './onboarding/onboarding.module' +import { PhotoModule } from './photo/photo.module' +import { SettingModule } from './setting/setting.module' +import { TenantModule } from './tenant/tenant.module' + +@Module({ + imports: [ + DatabaseModule, + RedisModule, + AuthModule, + SettingModule, + OnboardingModule, + PhotoModule, + TenantModule, + EventModule.forRootAsync({ + useFactory: async (redis: RedisAccessor) => { + return { + redisClient: redis.get(), + } + }, + inject: [RedisAccessor], + }), + ], + providers: [ + { + provide: APP_MIDDLEWARE, + useClass: TenantResolverMiddleware, + }, + { + provide: APP_MIDDLEWARE, + useClass: DatabaseContextMiddleware, + }, + { + provide: APP_MIDDLEWARE, + useClass: CorsMiddleware, + }, + { + provide: APP_GUARD, + useClass: AuthGuard, + }, + ], +}) +export class AppModules {} diff --git a/be/apps/core/src/modules/onboarding/onboarding.controller.ts b/be/apps/core/src/modules/onboarding/onboarding.controller.ts new file mode 100644 index 00000000..8e94b391 --- /dev/null +++ b/be/apps/core/src/modules/onboarding/onboarding.controller.ts @@ -0,0 +1,31 @@ +import { Body, Controller, Get, Post } from '@afilmory/framework' +import { BizException, ErrorCode } from 'core/errors' + +import { OnboardingInitDto } from './onboarding.dto' +import { OnboardingService } from './onboarding.service' + +@Controller('onboarding') +export class OnboardingController { + constructor(private readonly service: OnboardingService) {} + + @Get('/status') + async getStatus() { + const initialized = await this.service.isInitialized() + return { initialized } + } + + @Post('/init') + async initialize(@Body() dto: OnboardingInitDto) { + const initialized = await this.service.isInitialized() + if (initialized) { + throw new BizException(ErrorCode.COMMON_CONFLICT, { message: 'Already initialized' }) + } + const result = await this.service.initialize(dto) + return { + ok: true, + adminUserId: result.adminUserId, + tenantId: result.tenantId, + superAdminUserId: result.superAdminUserId, + } + } +} diff --git a/be/apps/core/src/modules/onboarding/onboarding.dto.ts b/be/apps/core/src/modules/onboarding/onboarding.dto.ts new file mode 100644 index 00000000..b603c248 --- /dev/null +++ b/be/apps/core/src/modules/onboarding/onboarding.dto.ts @@ -0,0 +1,54 @@ +import { createZodDto } from '@afilmory/framework' +import { z } from 'zod' + +import { SETTING_SCHEMAS, SettingKeys } from '../setting/setting.constant' + +const adminSchema = z.object({ + email: z.email(), + password: z.string().min(8), + name: z + .string() + .min(1) + .regex(/^(?!root$)/i, { message: 'Name "root" is reserved' }), +}) + +const keySchema = z.enum(SettingKeys) + +const settingEntrySchema = z.object({ + key: keySchema, + value: z.unknown(), +}) + +const normalizeEntries = z + .union([settingEntrySchema, z.object({ entries: z.array(settingEntrySchema).min(1) })]) + .transform((payload) => { + const entries = 'entries' in payload ? payload.entries : [payload] + return entries.map((entry) => ({ + key: entry.key, + value: SETTING_SCHEMAS[entry.key].parse(entry.value), + })) + }) + +export class OnboardingInitDto extends createZodDto( + z.object({ + admin: adminSchema, + tenant: z.object({ + name: z.string().min(1), + slug: z + .string() + .min(1) + .regex(/^[a-z0-9-]+$/, { message: 'Slug should be lowercase alphanumeric with hyphen' }), + domain: z + .string() + .min(1) + .regex(/^[a-z0-9.-]+$/, { message: 'Domain should be lowercase letters, numbers, dot or hyphen' }) + .optional(), + }), + settings: normalizeEntries.optional().transform((entries) => entries ?? []), + }), +) {} + +export type NormalizedSettingEntry = { + key: z.infer + value: unknown +} diff --git a/be/apps/core/src/modules/onboarding/onboarding.module.ts b/be/apps/core/src/modules/onboarding/onboarding.module.ts new file mode 100644 index 00000000..b90d707b --- /dev/null +++ b/be/apps/core/src/modules/onboarding/onboarding.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@afilmory/framework' + +import { DatabaseModule } from '../../database/database.module' +import { AuthModule } from '../auth/auth.module' +import { TenantModule } from '../tenant/tenant.module' +import { SettingModule } from '../setting/setting.module' +import { OnboardingController } from './onboarding.controller' +import { OnboardingService } from './onboarding.service' + +@Module({ + imports: [DatabaseModule, AuthModule, SettingModule, TenantModule], + providers: [OnboardingService], + controllers: [OnboardingController], +}) +export class OnboardingModule {} diff --git a/be/apps/core/src/modules/onboarding/onboarding.service.ts b/be/apps/core/src/modules/onboarding/onboarding.service.ts new file mode 100644 index 00000000..ec96dba8 --- /dev/null +++ b/be/apps/core/src/modules/onboarding/onboarding.service.ts @@ -0,0 +1,121 @@ +import { randomBytes } from 'node:crypto' + +import { authUsers } from '@afilmory/db' +import { env } from '@afilmory/env' +import { createLogger } from '@afilmory/framework' +import { BizException, ErrorCode } from 'core/errors' +import { eq } from 'drizzle-orm' +import { injectable } from 'tsyringe' + +import { DbAccessor } from '../../database/database.provider' +import { AuthProvider } from '../auth/auth.provider' +import { SettingService } from '../setting/setting.service' +import { TenantService } from '../tenant/tenant.service' +import type { NormalizedSettingEntry, OnboardingInitDto } from './onboarding.dto' + +const log = createLogger('Onboarding') + +@injectable() +export class OnboardingService { + constructor( + private readonly db: DbAccessor, + private readonly auth: AuthProvider, + private readonly settings: SettingService, + private readonly tenantService: TenantService, + ) {} + + async isInitialized(): Promise { + const db = this.db.get() + const [user] = await db.select().from(authUsers).limit(1) + return Boolean(user) + } + + async initialize( + payload: OnboardingInitDto, + ): Promise<{ adminUserId: string; superAdminUserId: string; tenantId: string }> { + const already = await this.isInitialized() + if (already) { + throw new BizException(ErrorCode.COMMON_CONFLICT, { message: 'Application already initialized' }) + } + const db = this.db.get() + + // Create first tenant + const tenantAggregate = await this.tenantService.createTenant({ + name: payload.tenant.name, + slug: payload.tenant.slug, + domain: payload.tenant.domain, + }) + + log.info('Created tenant %s (%s)', tenantAggregate.tenant.slug, tenantAggregate.tenant.id) + + // Apply initial settings to tenant + const entries = (payload.settings as unknown as NormalizedSettingEntry[]) ?? [] + if (entries.length > 0) { + const entriesWithTenant = entries.map((entry) => ({ + key: entry.key, + value: entry.value, + options: { tenantId: tenantAggregate.tenant.id }, + })) as Parameters[0] + await this.settings.setMany(entriesWithTenant) + } + + const auth = this.auth.getAuth() + + // Create initial admin for this tenant + const adminResult = await auth.api.signUpEmail({ + body: { + email: payload.admin.email, + password: payload.admin.password, + name: payload.admin.name, + // @ts-expect-error - tenantId is not part of the signUpEmail body + tenantId: tenantAggregate.tenant.id, + }, + }) + + const adminUserId = adminResult.user.id + + await db + .update(authUsers) + .set({ role: 'admin', tenantId: tenantAggregate.tenant.id }) + .where(eq(authUsers.id, adminUserId)) + + log.info('Provisioned tenant admin %s for tenant %s', adminUserId, tenantAggregate.tenant.slug) + + // Create global superadmin account + const superPassword = this.generatePassword() + const superEmail = env.DEFAULT_SUPERADMIN_EMAIL + const superUsername = env.DEFAULT_SUPERADMIN_USERNAME + + const superResult = await auth.api.signUpEmail({ + body: { + email: superEmail, + password: superPassword, + name: superUsername, + }, + }) + + const superAdminId = superResult.user.id + + await db + .update(authUsers) + .set({ + role: 'superadmin', + tenantId: null, + name: superUsername, + username: superUsername, + displayUsername: superUsername, + }) + .where(eq(authUsers.id, superAdminId)) + + log.info('Superadmin account created: %s (%s)', superUsername, superAdminId) + process.stdout.write( + `Superadmin credentials -> email: ${superEmail} username: ${superUsername} password: ${superPassword}\n`, + ) + + return { adminUserId, superAdminUserId: superAdminId, tenantId: tenantAggregate.tenant.id } + } + + private generatePassword(): string { + return randomBytes(16).toString('base64url') + } +} diff --git a/be/apps/core/src/modules/photo/photo.module.ts b/be/apps/core/src/modules/photo/photo.module.ts new file mode 100644 index 00000000..af26a15c --- /dev/null +++ b/be/apps/core/src/modules/photo/photo.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@afilmory/framework' + +import { PhotoBuilderService } from './photo.service' + +@Module({ + providers: [PhotoBuilderService], +}) +export class PhotoModule {} diff --git a/be/apps/core/src/modules/photo/photo.service.ts b/be/apps/core/src/modules/photo/photo.service.ts new file mode 100644 index 00000000..fbf7c91b --- /dev/null +++ b/be/apps/core/src/modules/photo/photo.service.ts @@ -0,0 +1,100 @@ +import type { + BuilderConfig, + PhotoManifestItem, + PhotoProcessingContext, + PhotoProcessorOptions, + StorageConfig, + StorageObject, + StorageProvider, +} from '@afilmory/builder' +import { AfilmoryBuilder, processPhotoWithPipeline, StorageFactory, StorageManager } from '@afilmory/builder' +import type { _Object } from '@aws-sdk/client-s3' +import { injectable } from 'tsyringe' + +const DEFAULT_PROCESSOR_OPTIONS: PhotoProcessorOptions = { + isForceMode: false, + isForceManifest: false, + isForceThumbnails: false, +} + +export type ProcessPhotoOptions = { + existingItem?: PhotoManifestItem + livePhotoMap?: Map + processorOptions?: Partial + builder?: AfilmoryBuilder +} + +@injectable() +export class PhotoBuilderService { + private readonly defaultBuilder: AfilmoryBuilder + + constructor() { + this.defaultBuilder = new AfilmoryBuilder() + } + + getDefaultBuilder(): AfilmoryBuilder { + return this.defaultBuilder + } + + createBuilder(config?: Partial): AfilmoryBuilder { + return new AfilmoryBuilder(config) + } + + createStorageManager(config: StorageConfig): StorageManager { + return new StorageManager(config) + } + + resolveStorageProvider(config: StorageConfig): StorageProvider { + return StorageFactory.createProvider(config) + } + + applyStorageConfig(builder: AfilmoryBuilder, config: StorageConfig): void { + builder.getStorageManager().switchProvider(config) + } + + async processPhotoFromStorageObject( + object: StorageObject, + options?: ProcessPhotoOptions, + ): Promise>> { + const { existingItem, livePhotoMap, processorOptions, builder } = options ?? {} + const activeBuilder = builder ?? this.defaultBuilder + + const mergedOptions: PhotoProcessorOptions = { + ...DEFAULT_PROCESSOR_OPTIONS, + ...processorOptions, + } + + const context: PhotoProcessingContext = { + photoKey: object.key, + obj: this.toLegacyObject(object), + existingItem, + livePhotoMap: this.toLegacyLivePhotoMap(livePhotoMap), + options: mergedOptions, + } + + return await processPhotoWithPipeline(context, activeBuilder) + } + + private toLegacyObject(object: StorageObject): _Object { + return { + Key: object.key, + Size: object.size, + LastModified: object.lastModified, + ETag: object.etag, + } + } + + private toLegacyLivePhotoMap(livePhotoMap?: Map): Map { + if (!livePhotoMap) { + return new Map() + } + + const result = new Map() + + for (const [key, value] of livePhotoMap) { + result.set(key, this.toLegacyObject(value)) + } + + return result + } +} diff --git a/be/apps/core/src/modules/setting/setting.constant.ts b/be/apps/core/src/modules/setting/setting.constant.ts new file mode 100644 index 00000000..f2b9c378 --- /dev/null +++ b/be/apps/core/src/modules/setting/setting.constant.ts @@ -0,0 +1,70 @@ +import { z } from 'zod' + +import type { SettingDefinition, SettingMetadata } from './setting.type' + +export const DEFAULT_SETTING_DEFINITIONS = { + 'ai.openai.apiKey': { + isSensitive: true, + schema: z.string().min(1, 'OpenAI API key cannot be empty'), + }, + 'ai.openai.baseUrl': { + isSensitive: false, + schema: z.url('OpenAI Base URL cannot be empty'), + }, + 'ai.embedding.model': { + isSensitive: false, + schema: z.string().min(1, 'AI Model name cannot be empty'), + }, + 'auth.google.clientId': { + isSensitive: false, + schema: z.string().min(1, 'Google Client ID cannot be empty'), + }, + 'auth.google.clientSecret': { + isSensitive: true, + schema: z.string().min(1, 'Google Client secret cannot be empty'), + }, + 'auth.github.clientId': { + isSensitive: false, + schema: z.string().min(1, 'GitHub Client ID cannot be empty'), + }, + 'auth.github.clientSecret': { + isSensitive: true, + schema: z.string().min(1, 'GitHub Client secret cannot be empty'), + }, + 'http.cors.allowedOrigins': { + isSensitive: false, + schema: z + .string() + .min(1, 'CORS allowed origins cannot be empty') + .transform((value) => value.trim()), + }, + 'services.amap.apiKey': { + isSensitive: true, + schema: z.string().min(1, 'Gaode Map API key cannot be empty'), + }, +} as const satisfies Record + +export const DEFAULT_SETTING_METADATA = Object.fromEntries( + Object.entries(DEFAULT_SETTING_DEFINITIONS).map(([key, definition]) => [ + key, + { isSensitive: definition.isSensitive } satisfies SettingMetadata, + ]), +) as Record + +const settingKeys = Object.keys(DEFAULT_SETTING_DEFINITIONS) as Array + +export const SettingKeys = settingKeys as [ + keyof typeof DEFAULT_SETTING_DEFINITIONS, + ...Array, +] + +export const SETTING_SCHEMAS = Object.fromEntries( + Object.entries(DEFAULT_SETTING_DEFINITIONS).map(([key, definition]) => [key, definition.schema]), +) as Record< + keyof typeof DEFAULT_SETTING_DEFINITIONS, + (typeof DEFAULT_SETTING_DEFINITIONS)[keyof typeof DEFAULT_SETTING_DEFINITIONS]['schema'] +> + +export const AES_ALGORITHM = 'aes-256-gcm' +export const IV_LENGTH = 12 +export const AUTH_TAG_LENGTH = 16 diff --git a/be/apps/core/src/modules/setting/setting.controller.ts b/be/apps/core/src/modules/setting/setting.controller.ts new file mode 100644 index 00000000..a39a68cb --- /dev/null +++ b/be/apps/core/src/modules/setting/setting.controller.ts @@ -0,0 +1,51 @@ +import { Body, Controller, Delete, Get, Param, Post, Query } from '@afilmory/framework' +import { Roles } from 'core/guards/roles.decorator' +import { BypassResponseTransform } from 'core/interceptors/response-transform.decorator' + +import { SettingKeys } from './setting.constant' +import { DeleteSettingDto, GetSettingDto, GetSettingsQueryDto, SetSettingDto } from './setting.dto' +import { SettingService } from './setting.service' + +@Controller('settings') +@Roles('admin') +export class SettingController { + constructor(private readonly settingService: SettingService) {} + + @Get('/ui-schema') + @BypassResponseTransform() + async getUiSchema() { + return await this.settingService.getUiSchema() + } + + @Get('/:key') + async get(@Param() { key }: GetSettingDto) { + const value = await this.settingService.get(key, {}) + return { key, value } + } + + @Get('/') + async getMany(@Query() query: GetSettingsQueryDto) { + const keys = query?.keys ?? [] + const targetKeys = keys.length > 0 ? keys : Array.from(SettingKeys) + const values = await this.settingService.getMany(targetKeys, {}) + return { keys: targetKeys, values } + } + + @Post('/') + async set(@Body() { entries }: SetSettingDto) { + await this.settingService.setMany(entries) + return { updated: entries } + } + + @Delete('/:key') + async delete(@Param() { key }: GetSettingDto) { + await this.settingService.delete(key) + return { key, deleted: true } + } + + @Delete('/') + async deleteMany(@Body() { keys }: DeleteSettingDto) { + await this.settingService.deleteMany(keys) + return { keys, deleted: true } + } +} diff --git a/be/apps/core/src/modules/setting/setting.dto.ts b/be/apps/core/src/modules/setting/setting.dto.ts new file mode 100644 index 00000000..dd9df663 --- /dev/null +++ b/be/apps/core/src/modules/setting/setting.dto.ts @@ -0,0 +1,47 @@ +import { createZodDto } from '@afilmory/framework' +import { z } from 'zod' + +import { SETTING_SCHEMAS, SettingKeys } from './setting.constant' + +const keySchema = z.enum(SettingKeys) + +const settingEntrySchema = z.object({ + key: keySchema, + value: z.unknown(), +}) + +const normalizeEntries = z + .union([settingEntrySchema, z.object({ entries: z.array(settingEntrySchema).min(1) })]) + .transform((payload) => { + const entries = 'entries' in payload ? payload.entries : [payload] + return entries.map((entry) => ({ + key: entry.key, + value: SETTING_SCHEMAS[entry.key].parse(entry.value), + })) + }) + +const keysInputSchema = z + .union([keySchema, z.array(keySchema)]) + .transform((value) => (Array.isArray(value) ? value : [value])) + +export class GetSettingDto extends createZodDto( + z.object({ + key: keySchema, + }), +) {} + +export class GetSettingsQueryDto extends createZodDto( + z + .object({ + keys: keysInputSchema.optional(), + }) + .transform((payload) => ({ keys: payload.keys ?? [] })), +) {} + +export class SetSettingDto extends createZodDto(normalizeEntries.transform((entries) => ({ entries }))) {} + +export class DeleteSettingDto extends createZodDto( + z + .union([z.object({ key: keySchema }), z.object({ keys: z.array(keySchema).min(1) })]) + .transform((payload) => ({ keys: 'keys' in payload ? payload.keys : [payload.key] })), +) {} diff --git a/be/apps/core/src/modules/setting/setting.module.ts b/be/apps/core/src/modules/setting/setting.module.ts new file mode 100644 index 00000000..9bd820c8 --- /dev/null +++ b/be/apps/core/src/modules/setting/setting.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@afilmory/framework' + +import { DatabaseModule } from '../../database/database.module' +import { SettingController } from './setting.controller' +import { SettingService } from './setting.service' + +@Module({ + imports: [DatabaseModule], + providers: [SettingService], + controllers: [SettingController], +}) +export class SettingModule {} diff --git a/be/apps/core/src/modules/setting/setting.service.ts b/be/apps/core/src/modules/setting/setting.service.ts new file mode 100644 index 00000000..07b52ee6 --- /dev/null +++ b/be/apps/core/src/modules/setting/setting.service.ts @@ -0,0 +1,221 @@ +import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto' + +import { settings } from '@afilmory/db' +import { env } from '@afilmory/env' +import { EventEmitterService } from '@afilmory/framework' +import { BizException, ErrorCode } from 'core/errors' +import { and, eq, inArray } from 'drizzle-orm' +import { injectable } from 'tsyringe' + +import { DbAccessor } from '../../database/database.provider' +import { getTenantContext } from '../tenant/tenant.context' +import { AES_ALGORITHM, AUTH_TAG_LENGTH, DEFAULT_SETTING_METADATA, IV_LENGTH } from './setting.constant' +import type { SettingKeyType, SettingRecord, SettingUiSchemaResponse, SettingValueMap } from './setting.type' +import { SETTING_UI_SCHEMA, SETTING_UI_SCHEMA_KEYS } from './setting.ui-schema' + +export type SettingOption = { + tenantId?: string +} + +export type SetSettingOptions = { + isSensitive?: boolean + description?: string | null +} & SettingOption + +declare module '@afilmory/framework' { + interface Events { + 'setting.updated': { tenantId: string; key: string; value: string } + 'setting.deleted': { tenantId: string; key: string } + } +} +type SettingEntryInput = { + [K in SettingKeyType]: { key: K; value: SettingValueMap[K]; options?: SetSettingOptions } +}[SettingKeyType] + +function isSettingKey(key: string): key is SettingKeyType { + return key in DEFAULT_SETTING_METADATA +} + +@injectable() +export class SettingService { + private readonly encryptionKey: Buffer + + constructor( + private readonly dbAccessor: DbAccessor, + private readonly eventEmitter: EventEmitterService, + ) { + this.encryptionKey = createHash('sha256').update(env.CONFIG_ENCRYPTION_KEY).digest() + } + + async get(key: K, options: SettingOption): Promise + async get(key: string, options?: SettingOption): Promise { + const tenantId = this.resolveTenantId(options) + const record = await this.findSettingRecord(key, tenantId) + if (!record) { + return null + } + const value = record.isSensitive ? this.decrypt(record.value) : record.value + return value + } + + async getMany( + keys: K, + options?: SettingOption, + ): Promise<{ [P in K[number]]: SettingValueMap[P] | null }> + async getMany(keys: readonly string[], options?: SettingOption): Promise> { + if (keys.length === 0) { + return {} + } + + const uniqueKeys = Array.from(new Set(keys)) + const tenantId = this.resolveTenantId(options) + + const db = this.dbAccessor.get() + const records = await db + .select() + .from(settings) + .where(and(eq(settings.tenantId, tenantId), inArray(settings.key, uniqueKeys))) + + const recordMap = new Map(records.map((record) => [record.key, record])) + + return uniqueKeys.reduce>((acc, key) => { + const record = recordMap.get(key) + if (!record) { + acc[key] = null + return acc + } + acc[key] = record.isSensitive ? this.decrypt(record.value) : record.value + return acc + }, {}) + } + + async set(key: K, value: SettingValueMap[K], options: SetSettingOptions): Promise + async set(key: string, value: string, options: SetSettingOptions): Promise + async set(key: string, value: string, options: SetSettingOptions): Promise { + const tenantId = this.resolveTenantId(options) + const existing = await this.findSettingRecord(key, tenantId) + const defaultMetadata = isSettingKey(key) ? DEFAULT_SETTING_METADATA[key] : undefined + const isSensitive = options.isSensitive ?? defaultMetadata?.isSensitive ?? existing?.isSensitive ?? false + const payload = isSensitive ? this.encrypt(value) : value + const db = this.dbAccessor.get() + + const insertPayload: typeof settings.$inferInsert = { + tenantId, + key, + value: payload, + isSensitive, + } + + const updatePayload: Partial = { + value: payload, + isSensitive, + updatedAt: new Date().toISOString(), + } + + await db + .insert(settings) + .values(insertPayload) + .onConflictDoUpdate({ + target: [settings.tenantId, settings.key], + set: updatePayload, + }) + + await this.eventEmitter.emit('setting.updated', { tenantId, key, value }) + } + + async setMany(entries: readonly SettingEntryInput[]): Promise { + for (const entry of entries) { + await this.set(entry.key, entry.value, entry.options ?? {}) + } + } + + async delete(key: string, options?: SettingOption): Promise { + const tenantId = this.resolveTenantId(options) + const db = this.dbAccessor.get() + await db.delete(settings).where(and(eq(settings.tenantId, tenantId), eq(settings.key, key))) + + await this.eventEmitter.emit('setting.deleted', { tenantId, key }) + } + + async deleteMany(keys: readonly string[], options?: SettingOption): Promise { + if (keys.length === 0) { + return + } + + const tenantId = this.resolveTenantId(options) + const db = this.dbAccessor.get() + const uniqueKeys = Array.from(new Set(keys)) + await db + .delete(settings) + .where(and(eq(settings.tenantId, tenantId), inArray(settings.key, uniqueKeys))) + + for (const key of uniqueKeys) { + await this.eventEmitter.emit('setting.deleted', { tenantId, key }) + } + } + + async getUiSchema(): Promise { + const rawValues = await this.getMany(SETTING_UI_SCHEMA_KEYS, {}) + const typedValues: SettingUiSchemaResponse['values'] = {} + + for (const key of SETTING_UI_SCHEMA_KEYS) { + const metadata = DEFAULT_SETTING_METADATA[key] + const rawValue = rawValues[key] ?? null + + if (metadata?.isSensitive) { + typedValues[key] = null + continue + } + + typedValues[key] = rawValue as SettingValueMap[typeof key] | null + } + + return { + schema: SETTING_UI_SCHEMA, + values: typedValues, + } + } + + private async findSettingRecord(key: string, tenantId: string): Promise { + const db = this.dbAccessor.get() + const [record] = await db + .select() + .from(settings) + .where(and(eq(settings.tenantId, tenantId), eq(settings.key, key))) + .limit(1) + + return record ?? null + } + + private resolveTenantId(options?: SettingOption): string { + if (options?.tenantId) { + return options.tenantId + } + + const tenant = getTenantContext() + if (!tenant) { + throw new BizException(ErrorCode.TENANT_NOT_FOUND) + } + + return tenant.tenant.id + } + + private encrypt(value: string): string { + const iv = randomBytes(IV_LENGTH) + const cipher = createCipheriv(AES_ALGORITHM, this.encryptionKey, iv) + const encrypted = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]) + const authTag = cipher.getAuthTag() + return Buffer.concat([iv, authTag, encrypted]).toString('base64') + } + + private decrypt(payload: string): string { + const buffer = Buffer.from(payload, 'base64') + const iv = buffer.subarray(0, IV_LENGTH) + const authTag = buffer.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH) + const encryptedText = buffer.subarray(IV_LENGTH + AUTH_TAG_LENGTH) + const decipher = createDecipheriv(AES_ALGORITHM, this.encryptionKey, iv) + decipher.setAuthTag(authTag) + const decrypted = Buffer.concat([decipher.update(encryptedText), decipher.final()]) + return decrypted.toString('utf8') + } +} diff --git a/be/apps/core/src/modules/setting/setting.type.ts b/be/apps/core/src/modules/setting/setting.type.ts new file mode 100644 index 00000000..29730e12 --- /dev/null +++ b/be/apps/core/src/modules/setting/setting.type.ts @@ -0,0 +1,50 @@ +import type { settings } from '@memora/db' +import type { z } from 'zod' + +import type { + UiFieldComponentDefinition, + UiFieldComponentType, + UiFieldNode, + UiGroupNode, + UiSchema, + UiSectionNode, +} from '../ui-schema/ui-schema.type' +import type { DEFAULT_SETTING_DEFINITIONS } from './setting.constant' + +export type SettingDefinition = { + readonly isSensitive: boolean + readonly schema: Schema +} + +export type SettingMetadata = Pick + +export type SettingRecord = typeof settings.$inferSelect + +export type SettingKeyType = keyof typeof DEFAULT_SETTING_DEFINITIONS + +export type SettingValueMap = { + [K in SettingKeyType]: z.infer<(typeof DEFAULT_SETTING_DEFINITIONS)[K]['schema']> +} + +export type SettingValueType = SettingValueMap[SettingKeyType] + +export type SettingComponentType = UiFieldComponentType + +export type SettingFieldComponentDefinition = UiFieldComponentDefinition + +export interface SettingFieldNode extends UiFieldNode { + readonly isSensitive: boolean +} + +export interface SettingGroupNode extends UiGroupNode {} + +export interface SettingSectionNode extends UiSectionNode {} + +export type SettingNode = SettingSectionNode | SettingGroupNode | SettingFieldNode + +export interface SettingUiSchema extends UiSchema {} + +export interface SettingUiSchemaResponse { + readonly schema: SettingUiSchema + readonly values: Partial> +} diff --git a/be/apps/core/src/modules/setting/setting.ui-schema.ts b/be/apps/core/src/modules/setting/setting.ui-schema.ts new file mode 100644 index 00000000..98a9bcd1 --- /dev/null +++ b/be/apps/core/src/modules/setting/setting.ui-schema.ts @@ -0,0 +1,254 @@ +import type { UiNode } from '../ui-schema/ui-schema.type' +import { DEFAULT_SETTING_METADATA } from './setting.constant' +import type { SettingKeyType, SettingUiSchema } from './setting.type' + +function getIsSensitive(key: SettingKeyType): boolean { + return DEFAULT_SETTING_METADATA[key]?.isSensitive ?? false +} + +export const SETTING_UI_SCHEMA_VERSION = '1.2.0' + +export const SETTING_UI_SCHEMA: SettingUiSchema = { + version: SETTING_UI_SCHEMA_VERSION, + title: '系统设置', + description: '管理 Memora 平台的全局行为与第三方服务接入。', + sections: [ + { + type: 'section', + id: 'ai', + title: 'AI 与智能功能', + description: '配置 OpenAI 以及嵌入式模型以启用智能特性。', + icon: 'i-lucide-brain-circuit', + children: [ + { + type: 'group', + id: 'ai-openai', + title: 'OpenAI 接入', + description: '为 API 请求配置服务端所需的 OpenAI 凭据。', + icon: 'i-lucide-bot', + children: [ + { + type: 'field', + id: 'ai.openai.apiKey', + title: 'API Key', + description: '用于调用 OpenAI 接口的密钥,通常以 “sk-” 开头。', + helperText: '出于安全考虑仅在受信环境中填写,提交后会进行加密存储。', + key: 'ai.openai.apiKey', + isSensitive: getIsSensitive('ai.openai.apiKey'), + component: { + type: 'secret', + placeholder: 'sk-********************************', + autoComplete: 'off', + revealable: true, + }, + }, + { + type: 'field', + id: 'ai.openai.baseUrl', + title: '自定义 Base URL', + description: '可选,若你使用自建代理,填写代理的完整 URL。', + key: 'ai.openai.baseUrl', + helperText: '例如 https://api.openai.com/v1,末尾无需斜杠。', + isSensitive: getIsSensitive('ai.openai.baseUrl'), + component: { + type: 'text', + inputType: 'url', + placeholder: 'https://api.openai.com/v1', + autoComplete: 'off', + }, + }, + ], + }, + { + type: 'group', + id: 'ai-embedding', + title: '向量嵌入模型', + description: '用于语义搜索或文本向量化的模型。', + icon: 'i-lucide-fingerprint', + children: [ + { + type: 'field', + id: 'ai.embedding.model', + title: 'Embedding 模型标识', + description: '例如 text-embedding-3-large、text-embedding-3-small 等。', + key: 'ai.embedding.model', + helperText: '填写完整的模型名称,留空将导致相关功能不可用。', + isSensitive: getIsSensitive('ai.embedding.model'), + component: { + type: 'text', + placeholder: 'text-embedding-3-large', + autoComplete: 'off', + }, + }, + ], + }, + ], + }, + { + type: 'section', + id: 'auth', + title: '登录与认证', + description: '配置第三方 OAuth 登录用于后台访问控制。', + icon: 'i-lucide-shield-check', + children: [ + { + type: 'group', + id: 'auth-google', + title: 'Google OAuth', + description: '在 Google Cloud Console 中创建 OAuth 应用后填写以下信息。', + icon: 'i-lucide-badge-check', + children: [ + { + type: 'field', + id: 'auth.google.clientId', + title: 'Client ID', + description: 'Google OAuth 的客户端 ID。', + key: 'auth.google.clientId', + helperText: '通常以 .apps.googleusercontent.com 结尾。', + isSensitive: getIsSensitive('auth.google.clientId'), + component: { + type: 'text', + placeholder: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.apps.googleusercontent.com', + autoComplete: 'off', + }, + }, + { + type: 'field', + id: 'auth.google.clientSecret', + title: 'Client Secret', + description: 'Google OAuth 的客户端密钥。', + key: 'auth.google.clientSecret', + isSensitive: getIsSensitive('auth.google.clientSecret'), + component: { + type: 'secret', + placeholder: '************', + autoComplete: 'off', + revealable: true, + }, + }, + ], + }, + { + type: 'group', + id: 'auth-github', + title: 'GitHub OAuth', + description: '在 GitHub OAuth Apps 中创建应用后填写。', + icon: 'i-lucide-github', + children: [ + { + type: 'field', + id: 'auth.github.clientId', + title: 'Client ID', + description: 'GitHub OAuth 的客户端 ID。', + key: 'auth.github.clientId', + helperText: '在 GitHub Developer settings 中可以找到。', + isSensitive: getIsSensitive('auth.github.clientId'), + component: { + type: 'text', + placeholder: 'Iv1.xxxxxxxxxxxxxxxx', + autoComplete: 'off', + }, + }, + { + type: 'field', + id: 'auth.github.clientSecret', + title: 'Client Secret', + description: 'GitHub OAuth 的客户端密钥。', + key: 'auth.github.clientSecret', + isSensitive: getIsSensitive('auth.github.clientSecret'), + component: { + type: 'secret', + placeholder: '****************', + autoComplete: 'off', + revealable: true, + }, + }, + ], + }, + ], + }, + { + type: 'section', + id: 'http', + title: 'HTTP 与安全', + description: '控制跨域访问等 Web 层配置。', + icon: 'i-lucide-globe-2', + children: [ + { + type: 'group', + id: 'http-cors', + title: '跨域策略 (CORS)', + description: '配置允许访问后台接口的来源列表。', + icon: 'i-lucide-shield-alert', + children: [ + { + type: 'field', + id: 'http.cors.allowedOrigins', + title: '允许的域名列表', + description: '以逗号分隔的域名或通配符,必须至少填写一个。', + helperText: '例如 https://example.com, https://admin.example.com', + key: 'http.cors.allowedOrigins', + isSensitive: getIsSensitive('http.cors.allowedOrigins'), + component: { + type: 'textarea', + placeholder: 'https://example.com, https://admin.example.com', + minRows: 3, + maxRows: 6, + }, + }, + ], + }, + ], + }, + { + type: 'section', + id: 'services', + title: '地图与定位', + description: '配置地图底图与地理编码等服务。', + icon: 'i-lucide-map', + children: [ + { + type: 'group', + id: 'services-amap', + title: '高德地图接入', + description: '填写高德地图 Web 服务 Key 以启用后台地图选点与地理搜索能力。', + icon: 'i-lucide-map-pinned', + children: [ + { + type: 'field', + id: 'services.amap.apiKey', + title: '高德地图 Key', + description: '前往高德开发者控制台创建 Web 服务 Key,并授权所需的 IP/域名后填入。', + helperText: '提交后将加密存储,仅后台调用地图与地理编码接口。', + key: 'services.amap.apiKey', + isSensitive: getIsSensitive('services.amap.apiKey'), + component: { + type: 'secret', + placeholder: '****************', + autoComplete: 'off', + revealable: true, + }, + }, + ], + }, + ], + }, + ], +} satisfies SettingUiSchema + +function collectKeys(nodes: ReadonlyArray>): SettingKeyType[] { + const keys: SettingKeyType[] = [] + + for (const node of nodes) { + if (node.type === 'field') { + keys.push(node.key) + continue + } + + keys.push(...collectKeys(node.children)) + } + + return keys +} + +export const SETTING_UI_SCHEMA_KEYS = Array.from(new Set(collectKeys(SETTING_UI_SCHEMA.sections))) as SettingKeyType[] diff --git a/be/apps/core/src/modules/tenant/tenant.context.ts b/be/apps/core/src/modules/tenant/tenant.context.ts new file mode 100644 index 00000000..1df0097e --- /dev/null +++ b/be/apps/core/src/modules/tenant/tenant.context.ts @@ -0,0 +1,25 @@ +import { BizException, ErrorCode } from 'core/errors' +import { HttpContext } from '@afilmory/framework' +import type { HttpContextValues } from '@afilmory/framework' + +import type { TenantContext } from './tenant.types' + +declare module '@afilmory/framework' { + interface HttpContextValues { + tenant?: TenantContext + } +} + +export function getTenantContext(options?: { + required?: TRequired +}): TRequired extends true ? TenantContext : TenantContext | undefined { + const context = HttpContext.getValue('tenant') + if (options?.required && !context) { + throw new BizException(ErrorCode.TENANT_NOT_FOUND) + } + return context as TRequired extends true ? TenantContext : TenantContext | undefined +} + +export function requireTenantContext(): TenantContext { + return getTenantContext({ required: true }) +} diff --git a/be/apps/core/src/modules/tenant/tenant.module.ts b/be/apps/core/src/modules/tenant/tenant.module.ts new file mode 100644 index 00000000..191173d2 --- /dev/null +++ b/be/apps/core/src/modules/tenant/tenant.module.ts @@ -0,0 +1,13 @@ +import './tenant.context' + +import { Module } from '@afilmory/framework' +import { DatabaseModule } from 'core/database/database.module' + +import { TenantRepository } from './tenant.repository' +import { TenantService } from './tenant.service' + +@Module({ + imports: [DatabaseModule], + providers: [TenantRepository, TenantService], +}) +export class TenantModule {} diff --git a/be/apps/core/src/modules/tenant/tenant.repository.ts b/be/apps/core/src/modules/tenant/tenant.repository.ts new file mode 100644 index 00000000..157fac64 --- /dev/null +++ b/be/apps/core/src/modules/tenant/tenant.repository.ts @@ -0,0 +1,87 @@ +import { generateId } from '@afilmory/be-utils' +import { tenantDomains, tenants } from '@afilmory/db' +import { eq } from 'drizzle-orm' +import { injectable } from 'tsyringe' + +import { DbAccessor } from '../../database/database.provider' +import type { TenantAggregate, TenantDomainMatch } from './tenant.types' + +@injectable() +export class TenantRepository { + constructor(private readonly dbAccessor: DbAccessor) {} + + async findById(id: string): Promise { + const db = this.dbAccessor.get() + const [tenant] = await db.select().from(tenants).where(eq(tenants.id, id)).limit(1) + if (!tenant) { + return null + } + const domains = await db.select().from(tenantDomains).where(eq(tenantDomains.tenantId, tenant.id)) + return { tenant, domains } + } + + async findBySlug(slug: string): Promise { + const db = this.dbAccessor.get() + const [tenant] = await db.select().from(tenants).where(eq(tenants.slug, slug)).limit(1) + if (!tenant) { + return null + } + const domains = await db.select().from(tenantDomains).where(eq(tenantDomains.tenantId, tenant.id)) + return { tenant, domains } + } + + async findByDomain(domain: string): Promise { + const normalized = domain.trim().toLowerCase() + if (!normalized) { + return null + } + + const db = this.dbAccessor.get() + const [matchedDomain] = await db.select().from(tenantDomains).where(eq(tenantDomains.domain, normalized)).limit(1) + + if (!matchedDomain) { + return null + } + + const aggregate = await this.findById(matchedDomain.tenantId) + if (!aggregate) { + return null + } + + return { + ...aggregate, + matchedDomain, + } + } + + async createTenant(payload: { name: string; slug: string; domain?: string | null }): Promise { + const db = this.dbAccessor.get() + const tenantId = generateId() + const tenantRecord: typeof tenants.$inferInsert = { + id: tenantId, + name: payload.name, + slug: payload.slug, + status: 'active', + primaryDomain: payload.domain ?? null, + } + + await db.insert(tenants).values(tenantRecord) + + if (payload.domain) { + const domainRecord: typeof tenantDomains.$inferInsert = { + id: generateId(), + tenantId, + domain: payload.domain, + isPrimary: true, + } + await db.insert(tenantDomains).values(domainRecord) + } + + return await this.findById(tenantId).then((aggregate) => { + if (!aggregate) { + throw new Error('Failed to create tenant') + } + return aggregate + }) + } +} diff --git a/be/apps/core/src/modules/tenant/tenant.service.ts b/be/apps/core/src/modules/tenant/tenant.service.ts new file mode 100644 index 00000000..2be57849 --- /dev/null +++ b/be/apps/core/src/modules/tenant/tenant.service.ts @@ -0,0 +1,124 @@ +import { env } from '@afilmory/env' +import { BizException, ErrorCode } from 'core/errors' +import { injectable } from 'tsyringe' + +import type { TenantAggregate, TenantContext, TenantDomainMatch, TenantResolutionInput } from './tenant.types' +import { TenantRepository } from './tenant.repository' + +@injectable() +export class TenantService { + private readonly defaultTenantSlug = env.DEFAULT_TENANT_SLUG + + constructor(private readonly repository: TenantRepository) {} + + async createTenant(payload: { name: string; slug: string; domain?: string | null }): Promise { + return await this.repository.createTenant(payload) + } + + async resolve(input: TenantResolutionInput): Promise { + const fallbackToDefault = input.fallbackToDefault ?? true + const tenantId = this.normalizeString(input.tenantId) + const slug = this.normalizeSlug(input.slug) + const domain = this.normalizeDomain(input.domain) + + let aggregate: TenantAggregate | TenantDomainMatch | null = null + + if (tenantId) { + aggregate = await this.repository.findById(tenantId) + } + + if (!aggregate && slug) { + aggregate = await this.repository.findBySlug(slug) + } + + if (!aggregate && domain) { + aggregate = await this.repository.findByDomain(domain) + } + + if (!aggregate && fallbackToDefault) { + aggregate = await this.repository.findBySlug(this.defaultTenantSlug) + } + + if (!aggregate) { + throw new BizException(ErrorCode.TENANT_NOT_FOUND) + } + + this.ensureTenantIsActive(aggregate.tenant) + + const matchedDomain = this.extractMatchedDomain(aggregate) + + return { + tenant: aggregate.tenant, + domains: aggregate.domains, + matchedDomain, + } + } + + async getById(id: string): Promise { + const aggregate = await this.repository.findById(id) + if (!aggregate) { + throw new BizException(ErrorCode.TENANT_NOT_FOUND) + } + this.ensureTenantIsActive(aggregate.tenant) + return aggregate + } + + async getBySlug(slug: string): Promise { + const normalized = this.normalizeSlug(slug) + if (!normalized) { + throw new BizException(ErrorCode.TENANT_NOT_FOUND) + } + + const aggregate = await this.repository.findBySlug(normalized) + if (!aggregate) { + throw new BizException(ErrorCode.TENANT_NOT_FOUND) + } + this.ensureTenantIsActive(aggregate.tenant) + return aggregate + } + + async getDefaultTenant(): Promise { + return await this.getBySlug(this.defaultTenantSlug) + } + + private ensureTenantIsActive(tenant: TenantAggregate['tenant']): void { + if (tenant.status === 'suspended') { + throw new BizException(ErrorCode.TENANT_SUSPENDED) + } + + if (tenant.status !== 'active') { + throw new BizException(ErrorCode.TENANT_INACTIVE) + } + } + + private normalizeString(value?: string | null): string | null { + if (!value) { + return null + } + const trimmed = value.trim() + return trimmed.length > 0 ? trimmed : null + } + + private normalizeSlug(value?: string | null): string | null { + const normalized = this.normalizeString(value) + return normalized ? normalized.toLowerCase() : null + } + + private normalizeDomain(value?: string | null): string | null { + const normalized = this.normalizeString(value) + if (!normalized) { + return null + } + + return normalized.replace(/:\d+$/, '').toLowerCase() + } + + private extractMatchedDomain( + aggregate: TenantAggregate | TenantDomainMatch, + ): TenantDomainMatch['matchedDomain'] | null { + if ('matchedDomain' in aggregate && aggregate.matchedDomain) { + return aggregate.matchedDomain + } + return null + } +} diff --git a/be/apps/core/src/modules/tenant/tenant.types.ts b/be/apps/core/src/modules/tenant/tenant.types.ts new file mode 100644 index 00000000..e64c81d7 --- /dev/null +++ b/be/apps/core/src/modules/tenant/tenant.types.ts @@ -0,0 +1,31 @@ +import type { tenantDomains, tenantStatusEnum, tenants } from '@afilmory/db' + +export type TenantRecord = typeof tenants.$inferSelect +export type TenantDomainRecord = typeof tenantDomains.$inferSelect +export type TenantStatus = (typeof tenantStatusEnum.enumValues)[number] + +export interface TenantAggregate { + tenant: TenantRecord + domains: TenantDomainRecord[] +} + +export interface TenantDomainMatch extends TenantAggregate { + matchedDomain: TenantDomainRecord +} + +export interface TenantContext extends TenantAggregate { + matchedDomain?: TenantDomainRecord | null +} + +export interface TenantResolutionInput { + tenantId?: string | null + slug?: string | null + domain?: string | null + fallbackToDefault?: boolean +} + +export interface TenantCacheEntry { + aggregate: TenantAggregate + matchedDomain?: TenantDomainRecord | null + cachedAt: number +} diff --git a/be/apps/core/src/openapi.ts b/be/apps/core/src/openapi.ts new file mode 100644 index 00000000..6bf5f4b1 --- /dev/null +++ b/be/apps/core/src/openapi.ts @@ -0,0 +1,73 @@ +import { env } from '@afilmory/env' +import { createOpenApiDocument } from '@afilmory/framework' +import type { Hono } from 'hono' + +import { logger } from './helpers/logger.helper' +import { AppModules } from './modules/index.module' + +interface RegisterOpenApiOptions { + globalPrefix: string +} + +function normalizePrefix(prefix: string): string { + if (!prefix || prefix === '/') { + return '' + } + + const trimmed = prefix.trim() + const withLeading = trimmed.startsWith('/') ? trimmed : `/${trimmed}` + return withLeading.replace(/\/+$/, '') +} + +export function registerOpenApiRoutes(app: Hono, options: RegisterOpenApiOptions): void { + const prefix = normalizePrefix(options.globalPrefix) + const specPath = `${prefix}/openapi.json` + const docsPath = `${prefix}/docs` + + const document = createOpenApiDocument(AppModules, { + title: 'Core Service API', + version: '1.0.0', + description: 'OpenAPI specification generated from decorators', + + servers: prefix ? [{ url: prefix }] : undefined, + }) + + app.get(specPath || '/openapi.json', (context) => context.json(document)) + + app.get(docsPath || '/docs', (context) => { + context.header('content-type', 'text/html; charset=utf-8') + return context.html(renderScalarHtml(specPath || '/openapi.json')) + }) + + logger.info(`OpenAPI routes registered: http://localhost:${env.PORT}${docsPath}`) +} + +function renderScalarHtml(specUrl: string): string { + return ` + + + Scalar API Reference + + + + + +
+ + + + + + + +` +} diff --git a/be/apps/core/src/pipes/parse-int.pipe.ts b/be/apps/core/src/pipes/parse-int.pipe.ts new file mode 100644 index 00000000..f8ee3737 --- /dev/null +++ b/be/apps/core/src/pipes/parse-int.pipe.ts @@ -0,0 +1,21 @@ +import type { PipeTransform } from '@afilmory/framework' +import { BadRequestException } from '@afilmory/framework' +import { injectable } from 'tsyringe' + +@injectable() +export class ParseIntPipe implements PipeTransform { + transform(value: unknown): number { + if (typeof value === 'number' && Number.isFinite(value)) { + return value + } + + if (typeof value === 'string' && value.trim().length > 0) { + const parsed = Number.parseInt(value, 10) + if (!Number.isNaN(parsed)) { + return parsed + } + } + + throw new BadRequestException('Validation failed (numeric string expected)') + } +} diff --git a/be/apps/core/src/redis/redis.config.ts b/be/apps/core/src/redis/redis.config.ts new file mode 100644 index 00000000..e9000eac --- /dev/null +++ b/be/apps/core/src/redis/redis.config.ts @@ -0,0 +1,17 @@ +import { env } from '@afilmory/env' +import { injectable } from 'tsyringe' + +export interface RedisOptions { + url: string +} + +@injectable() +export class RedisConfig { + getOptions(): RedisOptions { + const url = env.REDIS_URL + if (!url || url.trim().length === 0) { + throw new Error('REDIS_URL is required for redis connection') + } + return { url } + } +} diff --git a/be/apps/core/src/redis/redis.module.ts b/be/apps/core/src/redis/redis.module.ts new file mode 100644 index 00000000..67c4ce30 --- /dev/null +++ b/be/apps/core/src/redis/redis.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@afilmory/framework' +import { injectable } from 'tsyringe' + +import { RedisConfig } from './redis.config' +import { RedisAccessor, RedisProvider } from './redis.provider' + +@injectable() +class RedisTokenProvider { + constructor(private readonly provider: RedisProvider) {} + get() { + return this.provider.getClient() + } +} + +@Module({ + providers: [RedisConfig, RedisProvider, RedisAccessor, RedisTokenProvider], +}) +export class RedisModule {} diff --git a/be/apps/core/src/redis/redis.provider.ts b/be/apps/core/src/redis/redis.provider.ts new file mode 100644 index 00000000..ac63031e --- /dev/null +++ b/be/apps/core/src/redis/redis.provider.ts @@ -0,0 +1,50 @@ +import { createLogger } from '@afilmory/framework' +import type { RedisClient } from '@afilmory/redis' +import { createRedisClient } from '@afilmory/redis' +import { injectable } from 'tsyringe' + +import { RedisConfig } from './redis.config' + +const logger = createLogger('Redis') + +@injectable() +export class RedisProvider { + private client?: RedisClient + + constructor(private readonly config: RedisConfig) {} + + getClient(): RedisClient { + if (!this.client) { + const options = this.config.getOptions() + const client = createRedisClient(options.url) + client.on('error', (error) => { + logger.error(`Redis error: ${String(error)}`) + }) + client.on('connect', () => { + logger.info('Redis connecting...') + }) + client.on('ready', () => { + logger.info('Redis connection established successfully') + }) + client.on('end', () => { + logger.warn('Redis connection closed') + }) + this.client = client + } + return this.client + } + + async warmup(): Promise { + const client = this.getClient() + await client.ping() + } +} + +@injectable() +export class RedisAccessor { + constructor(private readonly provider: RedisProvider) {} + + get(): RedisClient { + return this.provider.getClient() + } +} diff --git a/be/apps/core/src/redis/tokens.ts b/be/apps/core/src/redis/tokens.ts new file mode 100644 index 00000000..9c4ce23e --- /dev/null +++ b/be/apps/core/src/redis/tokens.ts @@ -0,0 +1,5 @@ +export const REDIS_CLIENT = Symbol.for('core.redis.client') + +export type RedisClientToken = typeof REDIS_CLIENT + +export { type RedisClient } from '@afilmory/redis' diff --git a/be/apps/core/tsconfig.json b/be/apps/core/tsconfig.json new file mode 100644 index 00000000..97e362c1 --- /dev/null +++ b/be/apps/core/tsconfig.json @@ -0,0 +1,36 @@ +{ + "compilerOptions": { + "incremental": true, + "target": "es2022", + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "baseUrl": ".", + "module": "ESNext", + "moduleResolution": "Bundler", + "verbatimModuleSyntax": true, + "types": ["node"], + "paths": { + "core": ["./src"], + "core/*": ["./src/*"], + "@afilmory/db": ["../../packages/db/src"], + "@afilmory/db/*": ["../../packages/db/src/*"], + "@afilmory/be-utils": ["../../packages/utils/src"], + "@afilmory/be-utils/*": ["../../packages/utils/src/*"], + "@afilmory/websocket": ["../../packages/websocket/src"], + "@afilmory/websocket/*": ["../../packages/websocket/src/*"] + }, + "resolveJsonModule": true, + "allowJs": true, + "strict": true, + "noImplicitAny": false, + "declaration": false, + "outDir": "./dist", + "removeComments": true, + "sourceMap": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src/**/*", "*.d.ts"], + "exclude": [] +} diff --git a/be/apps/core/vite.config.ts b/be/apps/core/vite.config.ts new file mode 100644 index 00000000..ef281904 --- /dev/null +++ b/be/apps/core/vite.config.ts @@ -0,0 +1,37 @@ +import { builtinModules } from 'node:module' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +import swc from 'unplugin-swc' +import { defineConfig } from 'vite' +import tsconfigPaths from 'vite-tsconfig-paths' + +const NODE_BUILT_IN_MODULES = builtinModules.filter((m) => !m.startsWith('_')) +NODE_BUILT_IN_MODULES.push(...NODE_BUILT_IN_MODULES.map((m) => `node:${m}`)) + +const __dirname = dirname(fileURLToPath(import.meta.url)) + +export default defineConfig({ + plugins: [tsconfigPaths(), swc.vite()], + esbuild: false, + resolve: { + alias: { + '@afilmory/be-utils': resolve(__dirname, '../../packages/utils/src'), + '@afilmory/be-utils/': `${resolve(__dirname, '../../packages/utils/src')}/`, + }, + }, + ssr: { + noExternal: true, + }, + build: { + ssr: true, + ssrEmitAssets: true, + rollupOptions: { + external: NODE_BUILT_IN_MODULES, + + input: { + main: resolve(__dirname, 'src/index.ts'), + }, + }, + }, +}) diff --git a/be/apps/core/vitest.config.ts b/be/apps/core/vitest.config.ts new file mode 100644 index 00000000..584e4b49 --- /dev/null +++ b/be/apps/core/vitest.config.ts @@ -0,0 +1,16 @@ +import swc from 'unplugin-swc' +import tsconfigPaths from 'vite-tsconfig-paths' +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + environment: 'node', + coverage: { + reporter: ['text'], + }, + setupFiles: ['./vitest.setup.ts'], + maxConcurrency: 1, + }, + esbuild: false, + plugins: [tsconfigPaths(), swc.vite()], +}) diff --git a/be/apps/core/vitest.setup.ts b/be/apps/core/vitest.setup.ts new file mode 100644 index 00000000..d29db696 --- /dev/null +++ b/be/apps/core/vitest.setup.ts @@ -0,0 +1 @@ +process.env.API_KEY = process.env.API_KEY ?? 'secret-key' diff --git a/be/apps/dashboard/.gitignore b/be/apps/dashboard/.gitignore new file mode 100644 index 00000000..9f9d1034 --- /dev/null +++ b/be/apps/dashboard/.gitignore @@ -0,0 +1,7 @@ +node_modules +.DS_Store +dist +dist-ssr +*.local + +generated-routes.ts \ No newline at end of file diff --git a/be/apps/dashboard/.prettierrc.mjs b/be/apps/dashboard/.prettierrc.mjs new file mode 100644 index 00000000..0f454b1f --- /dev/null +++ b/be/apps/dashboard/.prettierrc.mjs @@ -0,0 +1,5 @@ +import { factory } from '@innei/prettier' + +export default factory({ + importSort: false, +}) diff --git a/be/apps/dashboard/agents.md b/be/apps/dashboard/agents.md new file mode 100644 index 00000000..c0c67236 --- /dev/null +++ b/be/apps/dashboard/agents.md @@ -0,0 +1,268 @@ +# agents.md + +Guidance for AI code agents working in this repository. This document codifies project-specific rules, patterns, and safe operations. When in doubt or on conflict, prefer the actual codebase. + +Scope: + +- Stack: Vite + React 19 + TypeScript, TailwindCSS v4, Radix UI, Jotai, TanStack Query, Framer Motion (LazyMotion), React Router with vite-plugin-route-builder. +- Package manager: pnpm (required). See package.json for script names and versions. + +Core commands: + +- Development: pnpm dev +- Build: pnpm build +- Preview: pnpm serve +- Lint: pnpm lint +- Format: pnpm format + +Repository rules (must follow): + +- Never edit auto-generated files (e.g., src/generated-routes.ts). Add/rename files under src/pages/ to affect routing. +- Use the path alias ~/ for all src imports (configured in tsconfig). +- Use Framer Motion’s LazyMotion with m._ components only. Do not use motion._ directly. +- Prefer Spring presets from ~/lib/spring for animations. +- Use the Pastel color system classes instead of raw Tailwind colors. +- Follow component organization: + - Base UI primitives -> src/components/ui/ + - App-shared (non-domain) -> src/components/common/ + - Feature/domain modules -> src/modules// +- State management via Jotai with helpers from ~/lib/jotai. Atoms live in src/atoms/. +- Do not rely on the global location object. Use the stable router utilities (~/atoms/route) or React Router hooks through the StableRouterProvider. +- Keep JSX self-closing where applicable; adhere to eslint-config-hyoban and Prettier settings. + +Routing and layouts: + +- File-based routing via vite-plugin-route-builder. + - Sync routes: \*.sync.tsx (no code-splitting) + - Async routes: \*.tsx (lazy loaded) + - Layout files: layout.tsx within a segment; render children via +- Example segment structure (do not edit src/generated-routes.ts directly): + - src/pages/(main)/index.sync.tsx -> root route + - src/pages/(main)/about.sync.tsx -> /about + - src/pages/(main)/settings/layout.tsx -> wraps /settings subtree + +Providers: + +- Root providers are composed in src/providers/root-providers.tsx and include: + - LazyMotion + MotionConfig + - TanStack QueryClientProvider + - Jotai Provider with a global store + - Event, Context menu, and settings sync providers + - StableRouterProvider to stabilize routing data and navigation + - ModalContainer and Toaster +- Add new cross-cutting providers here, keeping order and side effects in mind. + +Animation rules: + +- Always use m.\* components imported from motion/react. +- Prefer transitions from Spring presets for consistency and bundle efficiency. + +Example (animation): + +``` +import { m } from 'motion/react' +import { Spring } from '@afilmory/utils' + +export function AnimatedCard(props: { children?: React.ReactNode }) { + return ( + + {props.children} + + ) +} +``` + +Jotai patterns: + +- Use createAtomHooks and createAtomAccessor from ~/lib/jotai for consistent access, hooks, and selectors. +- Keep atoms in src/atoms/; co-locate selectors next to atoms when domain-specific. + +Example (atom + hooks): + +``` +import { atom } from 'jotai' +import { createAtomHooks, createAtomAccessor } from '~/lib/jotai' + +const baseCounterAtom = atom(0) + +// Typed hooks: [atomRef, useAtomHook, useValue, useSetter, get, set] +export const [ + counterAtom, + useCounter, + useCounterValue, + useSetCounter, + getCounter, + setCounter, +] = createAtomHooks(baseCounterAtom) + +// Optional: selectors +export const useIsEven = () => { + const value = useCounterValue() // read-only value hook + return value % 2 === 0 +} +``` + +Stable routing patterns: + +- Read-only route data and stable navigate are provided via ~/atoms/route and set by src/providers/stable-router-provider.tsx. +- Prefer useReadonlyRouteSelector for reading route state without causing re-renders. +- Prefer getStableRouterNavigate for imperative navigation outside React components. + +Example (route utilities): + +``` +import { useReadonlyRouteSelector, getStableRouterNavigate } from '~/atoms/route' + +export function RouteAwareComponent() { + const pathname = useReadonlyRouteSelector((r) => r.location.pathname) + const params = useReadonlyRouteSelector((r) => r.params) + const navigate = getStableRouterNavigate() + + const goHome = () => navigate('/', { replace: true }) + + return ( +
+
Pathname: {pathname}
+
Params JSON: {JSON.stringify(params)}
+ +
+ ) +} +``` + +UI components: + +- Prefer primitives in src/components/ui/ for buttons, inputs, select, switch, slider, dialogs, context menus, etc. +- Compose primitives for feature-level components under src/modules//. +- Use the Pastel color tokens (e.g., text-text, bg-background, border-border, bg-fill, bg-accent). + +Example (simple page using primitives): + +``` +import { Button } from '@afilmory/ui' +import { Divider } from '@afilmory/ui' +import { Tooltip, TooltipContent, TooltipTrigger } from '@afilmory/ui' + +export const Component = () => { + return ( +
+

About

+

This is a template page.

+ + + + + + + + + Tooltip content + + +
+ ) +} +``` + +Color system: + +- Use the Pastel-based semantic tokens: + - Semantic: text-text, bg-background, border-border + - Application: bg-accent, bg-primary, text-accent + - Fill: bg-fill, bg-fill-secondary + - Material: bg-material-medium, bg-material-opaque +- Respect dark mode and contrast variants; prefer data-contrast attributes when applicable. + +File-based routing quickstart: + +- Add a synchronous page: + - Create src/pages/(main)/new-page.sync.tsx -> route /new-page +- Add a lazy page: + - Create src/pages/(main)/lazy.tsx -> route /lazy (code-split) +- Add a layout: + - Create src/pages/(main)/settings/layout.tsx including +- The route graph is generated into src/generated-routes.ts; do not edit it manually. + +TanStack Query: + +- Use a shared QueryClient (~/lib/query-client) via RootProviders. +- Keep query keys structured and co-locate query hooks with modules. + +Modal and toast: + +- Use Modal from ~/components/ui/modal and sonner Toaster already wired in RootProviders. +- Prefer declarative patterns; use the provided Modal.present helper when needed. + +Common agent playbook: + +1. Create a new feature module: + +- Place domain-specific components under src/modules//. +- If it needs a page, create it under src/pages//. +- Add routes via file creation; do not modify src/generated-routes.ts. + +2. Add state for a feature: + +- Create an atom in src/atoms/.ts. +- Expose hooks via createAtomHooks. Avoid exporting raw atoms unless necessary. + +3. Add animated UI: + +- Use m._ components with Spring presets. Do not import motion._. + +4. Add a provider: + +- Edit src/providers/root-providers.tsx to insert it near related providers. +- Ensure it’s side-effect free on import and respects React 19 rules. + +5. Navigation and route state: + +- Read-only selections via useReadonlyRouteSelector for stable, selective reads. +- Imperative navigation outside components via getStableRouterNavigate. + +6. Styling: + +- Use Pastel tokens. Avoid raw Tailwind colors unless necessary. + +Linting, formatting, and quality: + +- Run pnpm lint and pnpm format to conform to eslint-config-hyoban and Prettier. +- Ensure TS passes in builds (pnpm build runs type checks via Vite + TS). + +Do not: + +- Do not edit auto-generated route files. +- Do not use motion.\* directly. +- Do not bypass providers by re-creating QueryClient or Jotai store; use the shared instances. +- Do not use window.location directly; use routing utilities. +- Do not introduce ad-hoc color tokens that bypass the Pastel system. + +Troubleshooting: + +- Route not recognized: + - Check filename suffix (.sync.tsx vs .tsx), directory placement under src/pages/, and that the dev server/plugin picked up changes. +- Animation not working: + - Verify import { m } from 'motion/react' and applied Spring preset. +- State not updating: + - Ensure atoms are created via createAtomHooks and read/written through the provided hooks or accessors. + +References: + +- vite-plugin-route-builder: https://github.com/Innei/vite-plugin-route-builder +- Pastel color system: https://github.com/Innei/Pastel + +Change checklist (agents): + +- Imports use ~/ alias +- New components placed in correct directory (ui/common/modules) +- Routes added through src/pages/ files only +- m.\* + Spring presets for motion +- Pastel color tokens used +- Atoms created via createAtomHooks; selectors stable +- No edits to auto-generated files +- Code passes pnpm lint, pnpm format, and pnpm build diff --git a/be/apps/dashboard/components.json b/be/apps/dashboard/components.json new file mode 100644 index 00000000..5d46f2bd --- /dev/null +++ b/be/apps/dashboard/components.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/styles/tailwind.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "~/components", + "utils": "~/lib/utils", + "ui": "~/components/ui", + "lib": "~/lib", + "hooks": "~/hooks" + }, + "registries": { + "@animate-ui": "https://animate-ui.com/r/{name}.json" + } +} diff --git a/be/apps/dashboard/eslint.config.mjs b/be/apps/dashboard/eslint.config.mjs new file mode 100644 index 00000000..65e53e2e --- /dev/null +++ b/be/apps/dashboard/eslint.config.mjs @@ -0,0 +1,42 @@ +// @ts-check +import { defineConfig } from 'eslint-config-hyoban' + +export default defineConfig( + { + formatting: false, + lessOpinionated: true, + preferESM: false, + react: true, + tailwindCSS: false, + }, + { + settings: { + tailwindcss: { + whitelist: ['center'], + }, + }, + rules: { + 'unicorn/prefer-math-trunc': 'off', + '@eslint-react/no-clone-element': 0, + '@eslint-react/hooks-extra/no-direct-set-state-in-use-effect': 0, + // NOTE: Disable this temporarily + 'react-compiler/react-compiler': 0, + 'no-restricted-syntax': 0, + 'no-restricted-globals': [ + 'error', + { + name: 'location', + message: + "Since you don't use the same router instance in electron and browser, you can't use the global location to get the route info. \n\n" + + 'You can use `useLocaltion` or `getReadonlyRoute` to get the route info.', + }, + ], + }, + }, + { + files: ['**/*.tsx'], + rules: { + '@stylistic/jsx-self-closing-comp': 'error', + }, + }, +) diff --git a/be/apps/dashboard/index.html b/be/apps/dashboard/index.html new file mode 100644 index 00000000..3dfc4ae2 --- /dev/null +++ b/be/apps/dashboard/index.html @@ -0,0 +1,44 @@ + + + + + + + Vite App + + + +
+ + + diff --git a/be/apps/dashboard/package.json b/be/apps/dashboard/package.json new file mode 100644 index 00000000..0d1aea04 --- /dev/null +++ b/be/apps/dashboard/package.json @@ -0,0 +1,99 @@ +{ + "name": "@afilmory/dashboard", + "type": "module", + "version": "0.0.0", + "packageManager": "pnpm@10.18.0", + "repository": { + "type": "git", + "url": "https://github.com/afilmory/afilmory" + }, + "scripts": { + "build": "vite build", + "dev": "vite", + "format": "prettier --write \"src/**/*.ts\" ", + "lint": "eslint --fix", + "prepare": "simple-git-hooks", + "serve": "vite preview" + }, + "dependencies": { + "@afilmory/hooks": "workspace:*", + "@afilmory/ui": "workspace:*", + "@afilmory/utils": "workspace:*", + "@headlessui/react": "2.2.9", + "@pastel-palette/tailwindcss": "1.0.0-canary.3", + "@radix-ui/react-accordion": "1.2.12", + "@radix-ui/react-checkbox": "1.3.3", + "@radix-ui/react-context-menu": "2.2.16", + "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-label": "2.1.7", + "@radix-ui/react-scroll-area": "1.2.10", + "@radix-ui/react-select": "2.2.6", + "@radix-ui/react-slider": "1.3.6", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-switch": "1.2.6", + "@radix-ui/react-tooltip": "1.2.8", + "@remixicon/react": "4.7.0", + "@tanstack/react-query": "5.90.5", + "class-variance-authority": "0.7.1", + "clsx": "2.1.1", + "es-toolkit": "1.41.0", + "foxact": "0.2.49", + "immer": "10.1.3", + "jotai": "2.15.0", + "lucide-react": "0.547.0", + "motion": "12.23.24", + "ofetch": "1.4.1", + "radix-ui": "1.4.3", + "react": "19.2.0", + "react-dom": "19.2.0", + "react-router": "7.9.4", + "react-scan": "0.4.3", + "sonner": "2.0.7", + "tailwind-merge": "3.3.1", + "usehooks-ts": "3.1.1" + }, + "devDependencies": { + "@egoist/tailwindcss-icons": "1.9.0", + "@iconify-json/mingcute": "1.2.5", + "@innei/prettier": "^1.0.0", + "@tailwindcss/container-queries": "0.1.1", + "@tailwindcss/postcss": "4.1.16", + "@tailwindcss/typography": "0.5.19", + "@tailwindcss/vite": "4.1.16", + "@types/node": "24.9.1", + "@types/react": "19.2.2", + "@types/react-dom": "19.2.2", + "@vitejs/plugin-react": "^5.1.0", + "autoprefixer": "10.4.21", + "code-inspector-plugin": "1.2.10", + "eslint": "9.38.0", + "eslint-config-hyoban": "4.0.10", + "lint-staged": "16.2.6", + "postcss": "8.5.6", + "postcss-import": "16.1.1", + "prettier": "3.6.2", + "simple-git-hooks": "2.13.1", + "tailwind-scrollbar": "4.0.2", + "tailwind-variants": "3.1.1", + "tailwindcss": "4.1.16", + "tailwindcss-animate": "1.0.7", + "tailwindcss-safe-area": "1.1.0", + "tw-animate-css": "1.4.0", + "typescript": "5.9.3", + "vite": "7.1.12", + "vite-plugin-checker": "0.11.0", + "vite-plugin-route-builder": "0.4.1", + "vite-tsconfig-paths": "5.1.4" + }, + "simple-git-hooks": { + "pre-commit": "pnpm exec lint-staged" + }, + "lint-staged": { + "*.{js,jsx,ts,tsx}": [ + "prettier --ignore-path ./.gitignore --write " + ], + "*.{js,ts,cjs,mjs,jsx,tsx,json}": [ + "eslint --fix" + ] + } +} \ No newline at end of file diff --git a/be/apps/dashboard/plugins/eslint-recursive-sort.js b/be/apps/dashboard/plugins/eslint-recursive-sort.js new file mode 100644 index 00000000..d451554b --- /dev/null +++ b/be/apps/dashboard/plugins/eslint-recursive-sort.js @@ -0,0 +1,60 @@ +const sortObjectKeys = (obj) => { + if (typeof obj !== 'object' || obj === null) { + return obj + } + + if (Array.isArray(obj)) { + return obj.map((element) => sortObjectKeys(element)) + } + + return Object.keys(obj) + .sort() + .reduce((acc, key) => { + acc[key] = sortObjectKeys(obj[key]) + return acc + }, {}) +} +/** + * @type {import("eslint").ESLint.Plugin} + */ +export default { + rules: { + 'recursive-sort': { + meta: { + type: 'layout', + fixable: 'code', + }, + create(context) { + return { + Program(node) { + if (context.getFilename().endsWith('.json')) { + const sourceCode = context.getSourceCode() + const text = sourceCode.getText() + + try { + const json = JSON.parse(text) + const sortedJson = sortObjectKeys(json) + const sortedText = JSON.stringify(sortedJson, null, 2) + + if (text.trim() !== sortedText.trim()) { + context.report({ + node, + message: 'JSON keys are not sorted recursively', + fix(fixer) { + return fixer.replaceText(node, sortedText) + }, + }) + } + } catch (error) { + context.report({ + node, + message: `Invalid JSON: ${error.message}`, + }) + } + } + }, + } + }, + }, + }, +} diff --git a/be/apps/dashboard/src/App.tsx b/be/apps/dashboard/src/App.tsx new file mode 100644 index 00000000..b00d9c6a --- /dev/null +++ b/be/apps/dashboard/src/App.tsx @@ -0,0 +1,24 @@ +import type { FC } from 'react' +import { Outlet } from 'react-router' + +import { Footer } from './components/common/Footer' +import { RootProviders } from './providers/root-providers' + +export const App: FC = () => { + return ( + + +