mirror of
https://github.com/eliasstepanik/core.git
synced 2026-01-10 08:48:29 +00:00
Fix: echo v2
This commit is contained in:
parent
72edab887e
commit
060668e8c0
4
apps/webapp/.prettierrc
Normal file
4
apps/webapp/.prettierrc
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"tailwindConfig": "./tailwind.config.ts",
|
||||
"plugins": [ "prettier-plugin-tailwindcss" ]
|
||||
}
|
||||
@ -1,40 +1,43 @@
|
||||
# Welcome to Remix!
|
||||
# Memory Plane
|
||||
|
||||
- 📖 [Remix docs](https://remix.run/docs)
|
||||
Simple memory management system for AI agents with per-space ingestion and search capabilities.
|
||||
|
||||
## Development
|
||||
## Core APIs
|
||||
|
||||
Run the dev server:
|
||||
### 1. Ingest API
|
||||
|
||||
```shellscript
|
||||
npm run dev
|
||||
```
|
||||
- Endpoint per space for data ingestion
|
||||
- Queue-based processing per user
|
||||
- Creates and links graph nodes automatically
|
||||
- Optional review queue for controlled ingestion
|
||||
|
||||
## Deployment
|
||||
### 2. Search API
|
||||
|
||||
First, build your app for production:
|
||||
- Simple text-based search
|
||||
- Input: query string
|
||||
- Output: relevant text matches
|
||||
- Scoped to specific memory space
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
## Features (v1)
|
||||
|
||||
Then run the app in production mode:
|
||||
[ ] Auto-mode default with optional queue review
|
||||
[ ] Multiple Spaces support (unique URL per space)
|
||||
[ ] Basic rules engine for ingestion filters
|
||||
[ ] Clear, user-friendly guidelines
|
||||
[ ] Simple text search
|
||||
|
||||
```sh
|
||||
npm start
|
||||
```
|
||||
## Usage Guidelines
|
||||
|
||||
Now you'll need to pick a host to deploy it to.
|
||||
Store:
|
||||
|
||||
### DIY
|
||||
- Conversation history
|
||||
- User preferences
|
||||
- Task context
|
||||
- Reference materials
|
||||
|
||||
If you're familiar with deploying Node applications, the built-in Remix app server is production-ready.
|
||||
Don't Store:
|
||||
|
||||
Make sure to deploy the output of `npm run build`
|
||||
|
||||
- `build/server`
|
||||
- `build/client`
|
||||
|
||||
## Styling
|
||||
|
||||
This template comes with [Tailwind CSS](https://tailwindcss.com/) already configured for a simple default starting experience. You can use whatever css framework you prefer. See the [Vite docs on css](https://vitejs.dev/guide/features.html#css) for more information.
|
||||
- Sensitive data (PII)
|
||||
- Credentials
|
||||
- System logs
|
||||
- Temporary data
|
||||
|
||||
61
apps/webapp/app/components/ErrorDisplay.tsx
Normal file
61
apps/webapp/app/components/ErrorDisplay.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
import { isRouteErrorResponse, useRouteError } from "@remix-run/react";
|
||||
|
||||
import { friendlyErrorDisplay } from "~/utils/httpErrors";
|
||||
|
||||
import { type ReactNode } from "react";
|
||||
import { Button } from "./ui";
|
||||
import { Header1 } from "./ui/Headers";
|
||||
import { Paragraph } from "./ui/Paragraph";
|
||||
|
||||
type ErrorDisplayOptions = {
|
||||
button?: {
|
||||
title: string;
|
||||
to: string;
|
||||
};
|
||||
};
|
||||
|
||||
export function RouteErrorDisplay(options?: ErrorDisplayOptions) {
|
||||
const error = useRouteError();
|
||||
|
||||
return (
|
||||
<>
|
||||
{isRouteErrorResponse(error) ? (
|
||||
<ErrorDisplay
|
||||
title={friendlyErrorDisplay(error.status, error.statusText).title}
|
||||
message={
|
||||
error.data.message ??
|
||||
friendlyErrorDisplay(error.status, error.statusText).message
|
||||
}
|
||||
{...options}
|
||||
/>
|
||||
) : error instanceof Error ? (
|
||||
<ErrorDisplay title={error.name} message={error.message} {...options} />
|
||||
) : (
|
||||
<ErrorDisplay
|
||||
title="Oops"
|
||||
message={JSON.stringify(error)}
|
||||
{...options}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type DisplayOptionsProps = {
|
||||
title: string;
|
||||
message?: ReactNode;
|
||||
} & ErrorDisplayOptions;
|
||||
|
||||
export function ErrorDisplay({ title, message, button }: DisplayOptionsProps) {
|
||||
return (
|
||||
<div className="bg-background relative flex min-h-screen flex-col items-center justify-center">
|
||||
<div className="z-10 mt-[30vh] flex flex-col items-center gap-8">
|
||||
<Header1>{title}</Header1>
|
||||
{message && <Paragraph>{message}</Paragraph>}
|
||||
<Button variant="link">
|
||||
{button ? button.title : "Go to homepage"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
75
apps/webapp/app/components/layout/AppLayout.tsx
Normal file
75
apps/webapp/app/components/layout/AppLayout.tsx
Normal file
@ -0,0 +1,75 @@
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
/** This container is used to surround the entire app, it correctly places the nav bar */
|
||||
export function AppContainer({ children }: { children: React.ReactNode }) {
|
||||
return <div className={cn("grid h-full w-full grid-rows-1 overflow-hidden")}>{children}</div>;
|
||||
}
|
||||
|
||||
export function MainBody({ children }: { children: React.ReactNode }) {
|
||||
return <div className={cn("grid grid-rows-1 overflow-hidden")}>{children}</div>;
|
||||
}
|
||||
|
||||
/** This container should be placed around the content on a page */
|
||||
export function PageContainer({ children }: { children: React.ReactNode }) {
|
||||
return <div className="grid grid-rows-[auto_1fr] overflow-hidden">{children}</div>;
|
||||
}
|
||||
|
||||
export function PageBody({
|
||||
children,
|
||||
scrollable = true,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
scrollable?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
scrollable
|
||||
? "scrollbar-thumb-charcoal-600 overflow-y-auto p-3 scrollbar-thin scrollbar-track-transparent"
|
||||
: "overflow-hidden",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MainCenteredContainer({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="scrollbar-thumb-charcoal-600 h-full w-full overflow-y-auto scrollbar-thin scrollbar-track-transparent">
|
||||
<div className={cn("mx-auto mt-6 max-w-xs overflow-y-auto p-1 md:mt-[22vh]", className)}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MainHorizontallyCenteredContainer({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="scrollbar-thumb-charcoal-600 w-full overflow-y-auto scrollbar-thin scrollbar-track-transparent">
|
||||
<div
|
||||
className={cn(
|
||||
"scrollbar-thumb-charcoal-600 mx-auto mt-6 max-w-lg overflow-y-auto p-1 scrollbar-thin scrollbar-track-transparent md:mt-14",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
apps/webapp/app/components/layout/LoginPageLayout.tsx
Normal file
51
apps/webapp/app/components/layout/LoginPageLayout.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Paragraph } from "../ui/Paragraph";
|
||||
import { Header3 } from "../ui/Headers";
|
||||
import { Button } from "../ui";
|
||||
import Logo from "../logo/logo";
|
||||
import { Theme, useTheme } from "remix-themes";
|
||||
|
||||
interface QuoteType {
|
||||
quote: string;
|
||||
}
|
||||
|
||||
const quotes: QuoteType[] = [
|
||||
{
|
||||
quote:
|
||||
"Echo remembers that I prefer emails in dark mode and hate promotional content. It automatically filters and formats my communications just the way I like.",
|
||||
},
|
||||
{
|
||||
quote:
|
||||
"When I mention liking Nike's latest running shoes, Echo remembers this preference and helps surface relevant product launches and deals across my browsing.",
|
||||
},
|
||||
{
|
||||
quote:
|
||||
"Echo knows I'm a vegetarian and helps filter restaurant recommendations and recipes accordingly, without me having to specify it every time.",
|
||||
},
|
||||
{
|
||||
quote:
|
||||
"By remembering that I prefer technical documentation with code examples, Echo helps prioritize learning resources that match my learning style.",
|
||||
},
|
||||
];
|
||||
|
||||
export function LoginPageLayout({ children }: { children: React.ReactNode }) {
|
||||
const [randomQuote, setRandomQuote] = useState<QuoteType | null>(null);
|
||||
useEffect(() => {
|
||||
const randomIndex = Math.floor(Math.random() * quotes.length);
|
||||
setRandomQuote(quotes[randomIndex]);
|
||||
}, []);
|
||||
const [, setTheme] = useTheme();
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen flex-col items-center justify-center">
|
||||
<div className="pt-8">
|
||||
<Logo width={20} height={20} />
|
||||
<Button onClick={() => setTheme(Theme.DARK)}>theme</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex h-full flex-grow items-center justify-center">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
apps/webapp/app/components/logo/index.ts
Normal file
1
apps/webapp/app/components/logo/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./logo";
|
||||
67
apps/webapp/app/components/logo/logo.tsx
Normal file
67
apps/webapp/app/components/logo/logo.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
import React from "react";
|
||||
import { Theme, useTheme } from "remix-themes";
|
||||
|
||||
export interface LogoProps {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export default function StaticLogo({ width, height }: LogoProps) {
|
||||
const [theme] = useTheme();
|
||||
|
||||
if (theme === Theme.LIGHT) {
|
||||
return (
|
||||
<svg
|
||||
width="153"
|
||||
height="153"
|
||||
viewBox="0 0 153 153"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g clip-path="url(#clip0_47_2)">
|
||||
<path
|
||||
d="M43 40H110V62.2H72.7778V77H110V114H43V91.8H80.2222V77H43V40Z"
|
||||
fill="black"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_47_2">
|
||||
<path
|
||||
d="M0 12C0 5.37258 5.37258 0 12 0H141C147.627 0 153 5.37258 153 12V141C153 147.627 147.627 153 141 153H12C5.37258 153 0 147.627 0 141V12Z"
|
||||
fill="white"
|
||||
/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<svg
|
||||
width="153"
|
||||
height="153"
|
||||
viewBox="0 0 153 153"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g clip-path="url(#clip0_55_2)">
|
||||
<path
|
||||
d="M0 12C0 5.37258 5.37258 0 12 0H141C147.627 0 153 5.37258 153 12V141C153 147.627 147.627 153 141 153H12C5.37258 153 0 147.627 0 141V12Z"
|
||||
fill="#1E1E1E"
|
||||
/>
|
||||
<path
|
||||
d="M43 40H110V62.2H72.7778V77H110V114H43V91.8H80.2222V77H43V40Z"
|
||||
fill="white"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_55_2">
|
||||
<path
|
||||
d="M0 12C0 5.37258 5.37258 0 12 0H141C147.627 0 153 5.37258 153 12V141C153 147.627 147.627 153 141 153H12C5.37258 153 0 147.627 0 141V12Z"
|
||||
fill="white"
|
||||
/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
90
apps/webapp/app/components/ui/Headers.tsx
Normal file
90
apps/webapp/app/components/ui/Headers.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
const headerVariants = {
|
||||
header1: {
|
||||
text: "font-sans text-2xl leading-5 md:leading-6 lg:leading-7 font-medium",
|
||||
spacing: "mb-2",
|
||||
},
|
||||
header2: {
|
||||
text: "font-sans text-base leading-6 font-medium",
|
||||
spacing: "mb-2",
|
||||
},
|
||||
header3: {
|
||||
text: "font-sans text-sm leading-5 font-medium",
|
||||
spacing: "mb-2",
|
||||
},
|
||||
};
|
||||
|
||||
const textColorVariants = {
|
||||
bright: "text-text-bright",
|
||||
dimmed: "text-text-dimmed",
|
||||
};
|
||||
|
||||
export type HeaderVariant = keyof typeof headerVariants;
|
||||
|
||||
type HeaderProps = {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
spacing?: boolean;
|
||||
textColor?: "bright" | "dimmed";
|
||||
} & React.HTMLAttributes<HTMLHeadingElement>;
|
||||
|
||||
export function Header1({
|
||||
className,
|
||||
children,
|
||||
spacing = false,
|
||||
textColor = "bright",
|
||||
}: HeaderProps) {
|
||||
return (
|
||||
<h1
|
||||
className={cn(
|
||||
headerVariants.header1.text,
|
||||
spacing === true && headerVariants.header1.spacing,
|
||||
textColor === "bright" ? textColorVariants.bright : textColorVariants.dimmed,
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</h1>
|
||||
);
|
||||
}
|
||||
|
||||
export function Header2({
|
||||
className,
|
||||
children,
|
||||
spacing = false,
|
||||
textColor = "bright",
|
||||
}: HeaderProps) {
|
||||
return (
|
||||
<h2
|
||||
className={cn(
|
||||
headerVariants.header2.text,
|
||||
spacing === true && headerVariants.header2.spacing,
|
||||
textColor === "bright" ? textColorVariants.bright : textColorVariants.dimmed,
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</h2>
|
||||
);
|
||||
}
|
||||
|
||||
export function Header3({
|
||||
className,
|
||||
children,
|
||||
spacing = false,
|
||||
textColor = "bright",
|
||||
}: HeaderProps) {
|
||||
return (
|
||||
<h3
|
||||
className={cn(
|
||||
headerVariants.header3.text,
|
||||
spacing === true && headerVariants.header3.spacing,
|
||||
textColor === "bright" ? textColorVariants.bright : textColorVariants.dimmed,
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
}
|
||||
95
apps/webapp/app/components/ui/Paragraph.tsx
Normal file
95
apps/webapp/app/components/ui/Paragraph.tsx
Normal file
@ -0,0 +1,95 @@
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
const paragraphVariants = {
|
||||
base: {
|
||||
text: "font-sans text-base font-normal text-text-dimmed",
|
||||
spacing: "mb-3",
|
||||
},
|
||||
"base/bright": {
|
||||
text: "font-sans text-base font-normal text-text-bright",
|
||||
spacing: "mb-3",
|
||||
},
|
||||
small: {
|
||||
text: "font-sans text-sm font-normal text-text-dimmed",
|
||||
spacing: "mb-2",
|
||||
},
|
||||
"small/bright": {
|
||||
text: "font-sans text-sm font-normal text-text-bright",
|
||||
spacing: "mb-2",
|
||||
},
|
||||
"extra-small": {
|
||||
text: "font-sans text-xs font-normal text-text-dimmed",
|
||||
spacing: "mb-1.5",
|
||||
},
|
||||
"extra-small/bright": {
|
||||
text: "font-sans text-xs font-normal text-text-bright",
|
||||
spacing: "mb-1.5",
|
||||
},
|
||||
"extra-small/mono": {
|
||||
text: "font-mono text-xs font-normal text-text-dimmed",
|
||||
spacing: "mb-1.5",
|
||||
},
|
||||
"extra-small/bright/mono": {
|
||||
text: "font-mono text-xs text-text-bright",
|
||||
spacing: "mb-1.5",
|
||||
},
|
||||
"extra-small/caps": {
|
||||
text: "font-sans text-xs uppercase tracking-wider font-normal text-text-dimmed",
|
||||
spacing: "mb-1.5",
|
||||
},
|
||||
"extra-small/bright/caps": {
|
||||
text: "font-sans text-xs uppercase tracking-wider font-normal text-text-bright",
|
||||
spacing: "mb-1.5",
|
||||
},
|
||||
"extra-extra-small": {
|
||||
text: "font-sans text-xxs font-normal text-text-dimmed",
|
||||
spacing: "mb-1",
|
||||
},
|
||||
"extra-extra-small/bright": {
|
||||
text: "font-sans text-xxs font-normal text-text-bright",
|
||||
spacing: "mb-1",
|
||||
},
|
||||
|
||||
"extra-extra-small/caps": {
|
||||
text: "font-sans text-xxs uppercase tracking-wider font-normal text-text-dimmed",
|
||||
spacing: "mb-1",
|
||||
},
|
||||
"extra-extra-small/bright/caps": {
|
||||
text: "font-sans text-xxs uppercase tracking-wider font-normal text-text-bright",
|
||||
spacing: "mb-1",
|
||||
},
|
||||
"extra-extra-small/dimmed/caps": {
|
||||
text: "font-sans text-xxs uppercase tracking-wider font-normal text-text-dimmed",
|
||||
spacing: "mb-1",
|
||||
},
|
||||
};
|
||||
|
||||
export type ParagraphVariant = keyof typeof paragraphVariants;
|
||||
|
||||
type ParagraphProps = {
|
||||
variant?: ParagraphVariant;
|
||||
className?: string;
|
||||
spacing?: boolean;
|
||||
children: React.ReactNode;
|
||||
} & React.HTMLAttributes<HTMLParagraphElement>;
|
||||
|
||||
export function Paragraph({
|
||||
variant = "base",
|
||||
className,
|
||||
spacing = false,
|
||||
children,
|
||||
...props
|
||||
}: ParagraphProps) {
|
||||
return (
|
||||
<p
|
||||
className={cn(
|
||||
paragraphVariants[variant].text,
|
||||
spacing === true && paragraphVariants[variant].spacing,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
88
apps/webapp/app/components/ui/button.tsx
Normal file
88
apps/webapp/app/components/ui/button.tsx
Normal file
@ -0,0 +1,88 @@
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import React from "react";
|
||||
|
||||
import { cn } from "../../lib/utils";
|
||||
import { Loader } from "lucide-react";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded transition-colors focus-visible:outline-none focus-visible:shadow-none disabled:pointer-events-none disabled:opacity-50 dark:focus-visible:ring-slate-300",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-white shadow hover:bg-primary/90 dark:hover:bg-primary/90",
|
||||
destructive: "text-red-500 bg-grayAlpha-100 border-none",
|
||||
outline: "border border-border shadow-sm hover:bg-gray-100 shadow-none",
|
||||
secondary: "bg-grayAlpha-100 border-none",
|
||||
ghost: "dark:focus-visible:ring-0 hover:bg-grayAlpha-100",
|
||||
link: "dark:focus-visible:ring-0",
|
||||
},
|
||||
size: {
|
||||
default: "h-7 rounded px-2 py-1",
|
||||
sm: "h-6 rounded-sm px-2 py-2",
|
||||
xs: "h-5 rounded-sm px-1 py-1",
|
||||
lg: "h-8 px-4 py-2",
|
||||
xl: "h-9 rounded px-8",
|
||||
"2xl": "h-12 rounded px-8",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
full: {
|
||||
false: "w-auto",
|
||||
true: "w-full",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
full: false,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
isLoading?: boolean;
|
||||
asChild?: boolean;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
full,
|
||||
asChild = false,
|
||||
children,
|
||||
isLoading,
|
||||
isActive,
|
||||
disabled,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
className={cn(
|
||||
buttonVariants({ variant, size, full, className }),
|
||||
isActive && "bg-accent text-accent-foreground"
|
||||
)}
|
||||
ref={ref}
|
||||
type="button"
|
||||
{...props}
|
||||
disabled={isLoading ?? disabled}
|
||||
>
|
||||
{isLoading ? <Loader className="mr-2 animate-spin" /> : <></>}
|
||||
{children}
|
||||
</Comp>
|
||||
);
|
||||
}
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
1
apps/webapp/app/components/ui/index.ts
Normal file
1
apps/webapp/app/components/ui/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./button";
|
||||
232
apps/webapp/app/db.server.ts
Normal file
232
apps/webapp/app/db.server.ts
Normal file
@ -0,0 +1,232 @@
|
||||
import {
|
||||
Prisma,
|
||||
PrismaClient,
|
||||
type PrismaClientOrTransaction,
|
||||
type PrismaReplicaClient,
|
||||
type PrismaTransactionClient,
|
||||
type PrismaTransactionOptions,
|
||||
$transaction as transac,
|
||||
} from "@echo/database";
|
||||
import invariant from "tiny-invariant";
|
||||
import { z } from "zod";
|
||||
import { env } from "./env.server";
|
||||
import { logger } from "./services/logger.service";
|
||||
import { isValidDatabaseUrl } from "./utils/db";
|
||||
import { singleton } from "./utils/singleton";
|
||||
|
||||
import { type Span } from "@opentelemetry/api";
|
||||
|
||||
export type {
|
||||
PrismaTransactionClient,
|
||||
PrismaClientOrTransaction,
|
||||
PrismaTransactionOptions,
|
||||
PrismaReplicaClient,
|
||||
};
|
||||
|
||||
export async function $transaction<R>(
|
||||
prisma: PrismaClientOrTransaction,
|
||||
name: string,
|
||||
fn: (prisma: PrismaTransactionClient, span?: Span) => Promise<R>,
|
||||
options?: PrismaTransactionOptions
|
||||
): Promise<R | undefined>;
|
||||
export async function $transaction<R>(
|
||||
prisma: PrismaClientOrTransaction,
|
||||
fn: (prisma: PrismaTransactionClient) => Promise<R>,
|
||||
options?: PrismaTransactionOptions
|
||||
): Promise<R | undefined>;
|
||||
export async function $transaction<R>(
|
||||
prisma: PrismaClientOrTransaction,
|
||||
fnOrName: ((prisma: PrismaTransactionClient) => Promise<R>) | string,
|
||||
fnOrOptions?: ((prisma: PrismaTransactionClient) => Promise<R>) | PrismaTransactionOptions,
|
||||
options?: PrismaTransactionOptions
|
||||
): Promise<R | undefined> {
|
||||
if (typeof fnOrName === "string") {
|
||||
const fn = fnOrOptions as (prisma: PrismaTransactionClient) => Promise<R>;
|
||||
|
||||
return await transac(
|
||||
prisma,
|
||||
(client) => fn(client),
|
||||
(error) => {
|
||||
logger.error("prisma.$transaction error", {
|
||||
code: error.code,
|
||||
meta: error.meta,
|
||||
stack: error.stack,
|
||||
message: error.message,
|
||||
name: error.name,
|
||||
});
|
||||
},
|
||||
options
|
||||
);
|
||||
} else {
|
||||
return transac(
|
||||
prisma,
|
||||
fnOrName,
|
||||
(error) => {
|
||||
logger.error("prisma.$transaction error", {
|
||||
code: error.code,
|
||||
meta: error.meta,
|
||||
stack: error.stack,
|
||||
message: error.message,
|
||||
name: error.name,
|
||||
});
|
||||
},
|
||||
typeof fnOrOptions === "function" ? undefined : fnOrOptions
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export { Prisma };
|
||||
|
||||
export const prisma = singleton("prisma", getClient);
|
||||
|
||||
export const $replica: PrismaReplicaClient = singleton(
|
||||
"replica",
|
||||
() => getReplicaClient() ?? prisma
|
||||
);
|
||||
|
||||
function getClient() {
|
||||
const { DATABASE_URL } = process.env;
|
||||
invariant(typeof DATABASE_URL === "string", "DATABASE_URL env var not set");
|
||||
|
||||
const databaseUrl = extendQueryParams(DATABASE_URL, {
|
||||
connection_limit: env.DATABASE_CONNECTION_LIMIT.toString(),
|
||||
pool_timeout: env.DATABASE_POOL_TIMEOUT.toString(),
|
||||
connection_timeout: env.DATABASE_CONNECTION_TIMEOUT.toString(),
|
||||
});
|
||||
|
||||
console.log(`🔌 setting up prisma client to ${redactUrlSecrets(databaseUrl)}`);
|
||||
|
||||
const client = new PrismaClient({
|
||||
datasources: {
|
||||
db: {
|
||||
url: databaseUrl.href,
|
||||
},
|
||||
},
|
||||
// @ts-expect-error
|
||||
log: [
|
||||
{
|
||||
emit: "stdout",
|
||||
level: "error",
|
||||
},
|
||||
{
|
||||
emit: "stdout",
|
||||
level: "info",
|
||||
},
|
||||
{
|
||||
emit: "stdout",
|
||||
level: "warn",
|
||||
},
|
||||
].concat(
|
||||
process.env.VERBOSE_PRISMA_LOGS === "1"
|
||||
? [
|
||||
{ emit: "event", level: "query" },
|
||||
{ emit: "stdout", level: "query" },
|
||||
]
|
||||
: []
|
||||
),
|
||||
});
|
||||
|
||||
// connect eagerly
|
||||
client.$connect();
|
||||
|
||||
console.log(`🔌 prisma client connected`);
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
function getReplicaClient() {
|
||||
if (!env.DATABASE_READ_REPLICA_URL) {
|
||||
console.log(`🔌 No database replica, using the regular client`);
|
||||
return;
|
||||
}
|
||||
|
||||
const replicaUrl = extendQueryParams(env.DATABASE_READ_REPLICA_URL, {
|
||||
connection_limit: env.DATABASE_CONNECTION_LIMIT.toString(),
|
||||
pool_timeout: env.DATABASE_POOL_TIMEOUT.toString(),
|
||||
connection_timeout: env.DATABASE_CONNECTION_TIMEOUT.toString(),
|
||||
});
|
||||
|
||||
console.log(`🔌 setting up read replica connection to ${redactUrlSecrets(replicaUrl)}`);
|
||||
|
||||
const replicaClient = new PrismaClient({
|
||||
datasources: {
|
||||
db: {
|
||||
url: replicaUrl.href,
|
||||
},
|
||||
},
|
||||
// @ts-expect-error
|
||||
log: [
|
||||
{
|
||||
emit: "stdout",
|
||||
level: "error",
|
||||
},
|
||||
{
|
||||
emit: "stdout",
|
||||
level: "info",
|
||||
},
|
||||
{
|
||||
emit: "stdout",
|
||||
level: "warn",
|
||||
},
|
||||
].concat(
|
||||
process.env.VERBOSE_PRISMA_LOGS === "1"
|
||||
? [
|
||||
{ emit: "event", level: "query" },
|
||||
{ emit: "stdout", level: "query" },
|
||||
]
|
||||
: []
|
||||
),
|
||||
});
|
||||
|
||||
// connect eagerly
|
||||
replicaClient.$connect();
|
||||
|
||||
console.log(`🔌 read replica connected`);
|
||||
|
||||
return replicaClient;
|
||||
}
|
||||
|
||||
function extendQueryParams(hrefOrUrl: string | URL, queryParams: Record<string, string>) {
|
||||
const url = new URL(hrefOrUrl);
|
||||
const query = url.searchParams;
|
||||
|
||||
for (const [key, val] of Object.entries(queryParams)) {
|
||||
query.set(key, val);
|
||||
}
|
||||
|
||||
url.search = query.toString();
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
function redactUrlSecrets(hrefOrUrl: string | URL) {
|
||||
const url = new URL(hrefOrUrl);
|
||||
url.password = "";
|
||||
return url.href;
|
||||
}
|
||||
|
||||
export type { PrismaClient } from "@echo/database";
|
||||
|
||||
export const PrismaErrorSchema = z.object({
|
||||
code: z.string(),
|
||||
});
|
||||
|
||||
function getDatabaseSchema() {
|
||||
if (!isValidDatabaseUrl(env.DATABASE_URL)) {
|
||||
throw new Error("Invalid Database URL");
|
||||
}
|
||||
|
||||
const databaseUrl = new URL(env.DATABASE_URL);
|
||||
const schemaFromSearchParam = databaseUrl.searchParams.get("schema");
|
||||
|
||||
if (!schemaFromSearchParam) {
|
||||
console.debug("❗ database schema unspecified, will default to `public` schema");
|
||||
return "public";
|
||||
}
|
||||
|
||||
return schemaFromSearchParam;
|
||||
}
|
||||
|
||||
export const DATABASE_SCHEMA = singleton("DATABASE_SCHEMA", getDatabaseSchema);
|
||||
|
||||
export const sqlDatabaseSchema = Prisma.sql([`${DATABASE_SCHEMA}`]);
|
||||
@ -22,8 +22,13 @@ const EnvironmentSchema = z.object({
|
||||
SESSION_SECRET: z.string(),
|
||||
|
||||
APP_ENV: z.string().default(process.env.NODE_ENV),
|
||||
LOGIN_ORIGIN: z.string().default("http://localhost:5173"),
|
||||
APP_ORIGIN: z.string().default("http://localhost:5173"),
|
||||
POSTHOG_PROJECT_KEY: z.string().default(""),
|
||||
|
||||
// google auth
|
||||
AUTH_GOOGLE_CLIENT_ID: z.string().optional(),
|
||||
AUTH_GOOGLE_CLIENT_SECRET: z.string().optional(),
|
||||
});
|
||||
|
||||
export type Environment = z.infer<typeof EnvironmentSchema>;
|
||||
|
||||
26
apps/webapp/app/hooks/useChanged.ts
Normal file
26
apps/webapp/app/hooks/useChanged.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
/** Call a function when the id of the item changes */
|
||||
export function useChanged<T extends { id: string }>(
|
||||
getItem: () => T | undefined,
|
||||
action: (item: T | undefined) => void,
|
||||
sendInitialUndefined = true
|
||||
) {
|
||||
const previousItemId = useRef<string | undefined>();
|
||||
const item = getItem();
|
||||
|
||||
//when the value changes, call the action
|
||||
useEffect(() => {
|
||||
if (previousItemId.current !== item?.id) {
|
||||
action(item);
|
||||
}
|
||||
|
||||
previousItemId.current = item?.id;
|
||||
}, [item]);
|
||||
|
||||
//if sendInitialUndefined is true, call the action when the component first renders
|
||||
useEffect(() => {
|
||||
if (item !== undefined || sendInitialUndefined === false) return;
|
||||
action(item);
|
||||
}, []);
|
||||
}
|
||||
49
apps/webapp/app/hooks/usePostHog.ts
Normal file
49
apps/webapp/app/hooks/usePostHog.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import { useLocation } from "@remix-run/react";
|
||||
import posthog from "posthog-js";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { useOptionalUser, useUserChanged } from "./useUser";
|
||||
|
||||
export const usePostHog = (apiKey?: string, logging = false, debug = false): void => {
|
||||
const postHogInitialized = useRef(false);
|
||||
const location = useLocation();
|
||||
const user = useOptionalUser();
|
||||
|
||||
//start PostHog once
|
||||
useEffect(() => {
|
||||
if (apiKey === undefined || apiKey === "") return;
|
||||
if (postHogInitialized.current === true) return;
|
||||
if (logging) console.log("Initializing PostHog");
|
||||
posthog.init(apiKey, {
|
||||
api_host: "https://eu.posthog.com",
|
||||
opt_in_site_apps: true,
|
||||
debug,
|
||||
loaded: function (posthog) {
|
||||
if (logging) console.log("PostHog loaded");
|
||||
if (user !== undefined) {
|
||||
if (logging) console.log("Loaded: Identifying user", user);
|
||||
posthog.identify(user.id, { email: user.email });
|
||||
}
|
||||
},
|
||||
});
|
||||
postHogInitialized.current = true;
|
||||
}, [apiKey, logging, user]);
|
||||
|
||||
useUserChanged((user) => {
|
||||
if (postHogInitialized.current === false) return;
|
||||
if (logging) console.log("User changed");
|
||||
if (user) {
|
||||
if (logging) console.log("Identifying user", user);
|
||||
posthog.identify(user.id, { email: user.email });
|
||||
} else {
|
||||
if (logging) console.log("Resetting user");
|
||||
posthog.reset();
|
||||
}
|
||||
});
|
||||
|
||||
//page view
|
||||
useEffect(() => {
|
||||
if (postHogInitialized.current === false) return;
|
||||
posthog.capture("$pageview");
|
||||
}, [location, logging]);
|
||||
};
|
||||
44
apps/webapp/app/hooks/useTypedMatchData.ts
Normal file
44
apps/webapp/app/hooks/useTypedMatchData.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { type UIMatch, useMatches } from "@remix-run/react";
|
||||
import {
|
||||
type RemixSerializedType,
|
||||
type UseDataFunctionReturn,
|
||||
deserializeRemix,
|
||||
} from "remix-typedjson";
|
||||
|
||||
type AppData = any;
|
||||
|
||||
function useTypedDataFromMatches<T = AppData>({
|
||||
id,
|
||||
matches,
|
||||
}: {
|
||||
id: string;
|
||||
matches: UIMatch[];
|
||||
}): UseDataFunctionReturn<T> | undefined {
|
||||
const match = matches.find((m) => m.id === id);
|
||||
return useTypedMatchData<T>(match);
|
||||
}
|
||||
|
||||
export function useTypedMatchesData<T = AppData>({
|
||||
id,
|
||||
matches,
|
||||
}: {
|
||||
id: string;
|
||||
matches?: UIMatch[];
|
||||
}): UseDataFunctionReturn<T> | undefined {
|
||||
if (!matches) {
|
||||
matches = useMatches();
|
||||
}
|
||||
|
||||
return useTypedDataFromMatches<T>({ id, matches });
|
||||
}
|
||||
|
||||
export function useTypedMatchData<T = AppData>(
|
||||
match: UIMatch | undefined
|
||||
): UseDataFunctionReturn<T> | undefined {
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
return deserializeRemix<T>(match.data as RemixSerializedType<T>) as
|
||||
| UseDataFunctionReturn<T>
|
||||
| undefined;
|
||||
}
|
||||
43
apps/webapp/app/hooks/useUser.ts
Normal file
43
apps/webapp/app/hooks/useUser.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { type UIMatch } from "@remix-run/react";
|
||||
import type { User } from "~/models/user.server";
|
||||
import { type loader } from "~/root";
|
||||
import { useChanged } from "./useChanged";
|
||||
import { useTypedMatchesData } from "./useTypedMatchData";
|
||||
|
||||
export function useIsImpersonating(matches?: UIMatch[]) {
|
||||
const data = useTypedMatchesData<typeof orgLoader>({
|
||||
id: "routes/_app.workspace.$workspaceSlug",
|
||||
matches,
|
||||
});
|
||||
return data?.isImpersonating === true;
|
||||
}
|
||||
|
||||
export function useOptionalUser(matches?: UIMatch[]): User | undefined {
|
||||
const routeMatch = useTypedMatchesData<typeof loader>({
|
||||
id: "root",
|
||||
matches,
|
||||
});
|
||||
|
||||
return routeMatch?.user ?? undefined;
|
||||
}
|
||||
|
||||
export function useUser(matches?: UIMatch[]): User {
|
||||
const maybeUser = useOptionalUser(matches);
|
||||
if (!maybeUser) {
|
||||
throw new Error(
|
||||
"No user found in root loader, but user is required by useUser. If user is optional, try useOptionalUser instead."
|
||||
);
|
||||
}
|
||||
return maybeUser;
|
||||
}
|
||||
|
||||
export function useUserChanged(callback: (user: User | undefined) => void) {
|
||||
useChanged(useOptionalUser, callback);
|
||||
}
|
||||
|
||||
export function useHasAdminAccess(matches?: UIMatch[]): boolean {
|
||||
const user = useOptionalUser(matches);
|
||||
const isImpersonating = useIsImpersonating(matches);
|
||||
|
||||
return Boolean(user?.admin) || isImpersonating;
|
||||
}
|
||||
@ -1,6 +1,11 @@
|
||||
import { json, type Session , createCookieSessionStorage } from "@remix-run/node";
|
||||
import {
|
||||
json,
|
||||
type Session,
|
||||
createCookieSessionStorage,
|
||||
} from "@remix-run/node";
|
||||
import { redirect } from "remix-typedjson";
|
||||
import { env } from "~/env.server";
|
||||
import { createThemeSessionResolver } from "remix-themes";
|
||||
|
||||
export type ToastMessage = {
|
||||
message: string;
|
||||
@ -29,7 +34,7 @@ export const { commitSession, getSession } = createCookieSessionStorage({
|
||||
export function setSuccessMessage(
|
||||
session: Session,
|
||||
message: string,
|
||||
options?: ToastMessageOptions
|
||||
options?: ToastMessageOptions,
|
||||
) {
|
||||
session.flash("toastMessage", {
|
||||
message,
|
||||
@ -40,7 +45,11 @@ export function setSuccessMessage(
|
||||
} as ToastMessage);
|
||||
}
|
||||
|
||||
export function setErrorMessage(session: Session, message: string, options?: ToastMessageOptions) {
|
||||
export function setErrorMessage(
|
||||
session: Session,
|
||||
message: string,
|
||||
options?: ToastMessageOptions,
|
||||
) {
|
||||
session.flash("toastMessage", {
|
||||
message,
|
||||
type: "error",
|
||||
@ -53,7 +62,7 @@ export function setErrorMessage(session: Session, message: string, options?: Toa
|
||||
export async function setRequestErrorMessage(
|
||||
request: Request,
|
||||
message: string,
|
||||
options?: ToastMessageOptions
|
||||
options?: ToastMessageOptions,
|
||||
) {
|
||||
const session = await getSession(request.headers.get("cookie"));
|
||||
|
||||
@ -65,7 +74,7 @@ export async function setRequestErrorMessage(
|
||||
export async function setRequestSuccessMessage(
|
||||
request: Request,
|
||||
message: string,
|
||||
options?: ToastMessageOptions
|
||||
options?: ToastMessageOptions,
|
||||
) {
|
||||
const session = await getSession(request.headers.get("cookie"));
|
||||
|
||||
@ -86,7 +95,7 @@ export async function jsonWithSuccessMessage(
|
||||
data: any,
|
||||
request: Request,
|
||||
message: string,
|
||||
options?: ToastMessageOptions
|
||||
options?: ToastMessageOptions,
|
||||
) {
|
||||
const session = await getSession(request.headers.get("cookie"));
|
||||
|
||||
@ -105,7 +114,7 @@ export async function jsonWithErrorMessage(
|
||||
data: any,
|
||||
request: Request,
|
||||
message: string,
|
||||
options?: ToastMessageOptions
|
||||
options?: ToastMessageOptions,
|
||||
) {
|
||||
const session = await getSession(request.headers.get("cookie"));
|
||||
|
||||
@ -124,7 +133,7 @@ export async function redirectWithSuccessMessage(
|
||||
path: string,
|
||||
request: Request,
|
||||
message: string,
|
||||
options?: ToastMessageOptions
|
||||
options?: ToastMessageOptions,
|
||||
) {
|
||||
const session = await getSession(request.headers.get("cookie"));
|
||||
|
||||
@ -143,7 +152,7 @@ export async function redirectWithErrorMessage(
|
||||
path: string,
|
||||
request: Request,
|
||||
message: string,
|
||||
options?: ToastMessageOptions
|
||||
options?: ToastMessageOptions,
|
||||
) {
|
||||
const session = await getSession(request.headers.get("cookie"));
|
||||
|
||||
@ -161,7 +170,7 @@ export async function redirectWithErrorMessage(
|
||||
export async function redirectBackWithErrorMessage(
|
||||
request: Request,
|
||||
message: string,
|
||||
options?: ToastMessageOptions
|
||||
options?: ToastMessageOptions,
|
||||
) {
|
||||
const url = new URL(request.url);
|
||||
return redirectWithErrorMessage(url.pathname, request, message, options);
|
||||
@ -170,7 +179,7 @@ export async function redirectBackWithErrorMessage(
|
||||
export async function redirectBackWithSuccessMessage(
|
||||
request: Request,
|
||||
message: string,
|
||||
options?: ToastMessageOptions
|
||||
options?: ToastMessageOptions,
|
||||
) {
|
||||
const url = new URL(request.url);
|
||||
return redirectWithSuccessMessage(url.pathname, request, message, options);
|
||||
|
||||
155
apps/webapp/app/models/user.server.ts
Normal file
155
apps/webapp/app/models/user.server.ts
Normal file
@ -0,0 +1,155 @@
|
||||
import type { Prisma, User } from "@echo/database";
|
||||
import type { GoogleProfile } from "remix-auth-google";
|
||||
import { prisma } from "~/db.server";
|
||||
export type { User } from "@echo/database";
|
||||
|
||||
type FindOrCreateGoogle = {
|
||||
authenticationMethod: "GOOGLE";
|
||||
email: User["email"];
|
||||
authenticationProfile: GoogleProfile;
|
||||
authenticationExtraParams: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type FindOrCreateUser = FindOrCreateGoogle;
|
||||
|
||||
type LoggedInUser = {
|
||||
user: User;
|
||||
isNewUser: boolean;
|
||||
};
|
||||
|
||||
export async function findOrCreateUser(input: FindOrCreateUser): Promise<LoggedInUser> {
|
||||
return findOrCreateGoogleUser(input);
|
||||
}
|
||||
|
||||
export async function findOrCreateGoogleUser({
|
||||
email,
|
||||
authenticationProfile,
|
||||
authenticationExtraParams,
|
||||
}: FindOrCreateGoogle): Promise<LoggedInUser> {
|
||||
const name = authenticationProfile._json.name;
|
||||
let avatarUrl: string | undefined = undefined;
|
||||
if (authenticationProfile.photos[0]) {
|
||||
avatarUrl = authenticationProfile.photos[0].value;
|
||||
}
|
||||
const displayName = authenticationProfile.displayName;
|
||||
const authProfile = authenticationProfile
|
||||
? (authenticationProfile as unknown as Prisma.JsonObject)
|
||||
: undefined;
|
||||
const authExtraParams = authenticationExtraParams
|
||||
? (authenticationExtraParams as unknown as Prisma.JsonObject)
|
||||
: undefined;
|
||||
|
||||
const authIdentifier = `github:${authenticationProfile.id}`;
|
||||
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: {
|
||||
authIdentifier,
|
||||
},
|
||||
});
|
||||
|
||||
const existingEmailUser = await prisma.user.findUnique({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingEmailUser && !existingUser) {
|
||||
const user = await prisma.user.update({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
data: {
|
||||
authenticationProfile: authProfile,
|
||||
authenticationExtraParams: authExtraParams,
|
||||
avatarUrl,
|
||||
authIdentifier,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
user,
|
||||
isNewUser: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (existingEmailUser && existingUser) {
|
||||
const user = await prisma.user.update({
|
||||
where: {
|
||||
id: existingUser.id,
|
||||
},
|
||||
data: {},
|
||||
});
|
||||
|
||||
return {
|
||||
user,
|
||||
isNewUser: false,
|
||||
};
|
||||
}
|
||||
|
||||
const user = await prisma.user.upsert({
|
||||
where: {
|
||||
authIdentifier,
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
authenticationProfile: authProfile,
|
||||
authenticationExtraParams: authExtraParams,
|
||||
name,
|
||||
avatarUrl,
|
||||
displayName,
|
||||
authIdentifier,
|
||||
email,
|
||||
authenticationMethod: "GOOGLE",
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
user,
|
||||
isNewUser: !existingUser,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getUserById(id: User["id"]) {
|
||||
const user = await prisma.user.findUnique({ where: { id } });
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...user,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getUserByEmail(email: User["email"]) {
|
||||
return prisma.user.findUnique({ where: { email } });
|
||||
}
|
||||
|
||||
export function updateUser({
|
||||
id,
|
||||
name,
|
||||
email,
|
||||
marketingEmails,
|
||||
referralSource,
|
||||
}: Pick<User, "id" | "name" | "email"> & {
|
||||
marketingEmails?: boolean;
|
||||
referralSource?: string;
|
||||
}) {
|
||||
return prisma.user.update({
|
||||
where: { id },
|
||||
data: { name, email, marketingEmails, referralSource, confirmedBasicDetails: true },
|
||||
});
|
||||
}
|
||||
|
||||
export async function grantUserCloudAccess({ id, inviteCode }: { id: string; inviteCode: string }) {
|
||||
return prisma.user.update({
|
||||
where: { id },
|
||||
data: {
|
||||
InvitationCode: {
|
||||
connect: {
|
||||
code: inviteCode,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -1,47 +1,73 @@
|
||||
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "@remix-run/react";
|
||||
import type { LinksFunction, LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
|
||||
import { type UseDataFunctionReturn, typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import {
|
||||
Links,
|
||||
LiveReload,
|
||||
Meta,
|
||||
Outlet,
|
||||
Scripts,
|
||||
ScrollRestoration,
|
||||
useLoaderData,
|
||||
} from "@remix-run/react";
|
||||
import type {
|
||||
LinksFunction,
|
||||
LoaderFunctionArgs,
|
||||
MetaFunction,
|
||||
} from "@remix-run/node";
|
||||
import {
|
||||
type UseDataFunctionReturn,
|
||||
typedjson,
|
||||
useTypedLoaderData,
|
||||
} from "remix-typedjson";
|
||||
|
||||
import styles from "./tailwind.css?url";
|
||||
|
||||
import tailwindStylesheetUrl from "~/tailwind.css";
|
||||
import { appEnvTitleTag } from "./utils";
|
||||
import { commitSession, getSession, type ToastMessage } from "./models/message.server";
|
||||
import {
|
||||
commitSession,
|
||||
getSession,
|
||||
type ToastMessage,
|
||||
} from "./models/message.server";
|
||||
import { env } from "./env.server";
|
||||
import { getUser } from "./services/session.server";
|
||||
import { usePostHog } from "./hooks/usePostHog";
|
||||
import {
|
||||
AppContainer,
|
||||
MainCenteredContainer,
|
||||
} from "./components/layout/AppLayout";
|
||||
import { RouteErrorDisplay } from "./components/ErrorDisplay";
|
||||
import { themeSessionResolver } from "./services/sessionStorage.server";
|
||||
import {
|
||||
PreventFlashOnWrongTheme,
|
||||
ThemeProvider,
|
||||
useTheme,
|
||||
} from "remix-themes";
|
||||
import clsx from "clsx";
|
||||
|
||||
export const links: LinksFunction = () => [
|
||||
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
|
||||
{
|
||||
rel: "preconnect",
|
||||
href: "https://fonts.gstatic.com",
|
||||
crossOrigin: "anonymous",
|
||||
},
|
||||
{
|
||||
rel: "stylesheet",
|
||||
href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap",
|
||||
},
|
||||
{ rel: "stylesheet", href: tailwindStylesheetUrl },
|
||||
];
|
||||
export const links: LinksFunction = () => [{ rel: "stylesheet", href: styles }];
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const session = await getSession(request.headers.get("cookie"));
|
||||
const toastMessage = session.get("toastMessage") as ToastMessage;
|
||||
const { getTheme } = await themeSessionResolver(request);
|
||||
|
||||
const posthogProjectKey = env.POSTHOG_PROJECT_KEY;
|
||||
|
||||
return typedjson(
|
||||
{
|
||||
user: await getUser(request),
|
||||
toastMessage,
|
||||
theme: getTheme(),
|
||||
posthogProjectKey,
|
||||
appEnv: env.APP_ENV,
|
||||
appOrigin: env.APP_ORIGIN,
|
||||
},
|
||||
{ headers: { "Set-Cookie": await commitSession(session) } }
|
||||
{ headers: { "Set-Cookie": await commitSession(session) } },
|
||||
);
|
||||
};
|
||||
|
||||
export const meta: MetaFunction = ({ data }) => {
|
||||
const typedData = data as UseDataFunctionReturn<typeof loader>;
|
||||
|
||||
return [
|
||||
{ title: `Echo${appEnvTitleTag(typedData.appEnv)}` },
|
||||
{ title: `Echo${typedData && appEnvTitleTag(typedData.appEnv)}` },
|
||||
{
|
||||
name: "viewport",
|
||||
content: "width=1024, initial-scale=1",
|
||||
@ -49,31 +75,70 @@ export const meta: MetaFunction = ({ data }) => {
|
||||
{
|
||||
name: "robots",
|
||||
content:
|
||||
typeof window === "undefined" || window.location.hostname !== "echo.mysigma.ai"
|
||||
typeof window === "undefined" ||
|
||||
window.location.hostname !== "echo.mysigma.ai"
|
||||
? "noindex, nofollow"
|
||||
: "index, follow",
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export function Layout({ children }: { children: React.ReactNode }) {
|
||||
export function ErrorBoundary() {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<Meta />
|
||||
<Links />
|
||||
</head>
|
||||
<body>
|
||||
{children}
|
||||
<ScrollRestoration />
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
<>
|
||||
<html lang="en" className="h-full">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
|
||||
<Meta />
|
||||
<Links />
|
||||
</head>
|
||||
<body className="bg-background h-full overflow-hidden">
|
||||
<AppContainer>
|
||||
<MainCenteredContainer>
|
||||
<RouteErrorDisplay />
|
||||
</MainCenteredContainer>
|
||||
</AppContainer>
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return <Outlet />;
|
||||
function App() {
|
||||
const { posthogProjectKey } = useTypedLoaderData<typeof loader>();
|
||||
usePostHog(posthogProjectKey);
|
||||
const [theme] = useTheme();
|
||||
|
||||
return (
|
||||
<>
|
||||
<html lang="en" className={clsx(theme, "h-full")}>
|
||||
<head>
|
||||
<Meta />
|
||||
<Links />
|
||||
<PreventFlashOnWrongTheme ssrTheme={Boolean(theme)} />
|
||||
</head>
|
||||
<body className="bg-background h-full overflow-hidden">
|
||||
<Outlet />
|
||||
<ScrollRestoration />
|
||||
|
||||
<Scripts />
|
||||
<LiveReload />
|
||||
</body>
|
||||
</html>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Wrap your app with ThemeProvider.
|
||||
// `specifiedTheme` is the stored theme in the session storage.
|
||||
// `themeAction` is the action name that's used to change the theme in the session storage.
|
||||
export default function AppWithProviders() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
return (
|
||||
<ThemeProvider specifiedTheme={data.theme} themeAction="/action/set-theme">
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
4
apps/webapp/app/routes/action.set-theme.ts
Normal file
4
apps/webapp/app/routes/action.set-theme.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { createThemeAction } from "remix-themes";
|
||||
import { themeSessionResolver } from "~/services/sessionStorage.server";
|
||||
|
||||
export const action = createThemeAction(themeSessionResolver);
|
||||
31
apps/webapp/app/routes/login.tsx
Normal file
31
apps/webapp/app/routes/login.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import { type LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { useNavigation } from "@remix-run/react";
|
||||
import { typedjson } from "remix-typedjson";
|
||||
import { LoginPageLayout } from "~/components/layout/LoginPageLayout";
|
||||
import { authenticator } from "~/services/auth.server";
|
||||
import {
|
||||
commitSession,
|
||||
getUserSession,
|
||||
} from "~/services/sessionStorage.server";
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
await authenticator.isAuthenticated(request, {
|
||||
successRedirect: "/",
|
||||
});
|
||||
|
||||
const session = await getUserSession(request);
|
||||
|
||||
return typedjson({
|
||||
headers: { "Set-Cookie": await commitSession(session) },
|
||||
});
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigation();
|
||||
|
||||
return (
|
||||
<LoginPageLayout>
|
||||
<h2>Lohin</h2>
|
||||
</LoginPageLayout>
|
||||
);
|
||||
}
|
||||
64
apps/webapp/app/services/apiRateLimit.server.ts
Normal file
64
apps/webapp/app/services/apiRateLimit.server.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import { env } from "~/env.server";
|
||||
import { authenticateAuthorizationHeader } from "./apiAuth.server";
|
||||
import { authorizationRateLimitMiddleware } from "./authorizationRateLimitMiddleware.server";
|
||||
import { Duration } from "./rateLimiter.server";
|
||||
|
||||
export const apiRateLimiter = authorizationRateLimitMiddleware({
|
||||
redis: {
|
||||
port: env.RATE_LIMIT_REDIS_PORT,
|
||||
host: env.RATE_LIMIT_REDIS_HOST,
|
||||
username: env.RATE_LIMIT_REDIS_USERNAME,
|
||||
password: env.RATE_LIMIT_REDIS_PASSWORD,
|
||||
tlsDisabled: env.RATE_LIMIT_REDIS_TLS_DISABLED === "true",
|
||||
clusterMode: env.RATE_LIMIT_REDIS_CLUSTER_MODE_ENABLED === "1",
|
||||
},
|
||||
keyPrefix: "api",
|
||||
defaultLimiter: {
|
||||
type: "tokenBucket",
|
||||
refillRate: env.API_RATE_LIMIT_REFILL_RATE,
|
||||
interval: env.API_RATE_LIMIT_REFILL_INTERVAL as Duration,
|
||||
maxTokens: env.API_RATE_LIMIT_MAX,
|
||||
},
|
||||
limiterCache: {
|
||||
fresh: 60_000 * 10, // Data is fresh for 10 minutes
|
||||
stale: 60_000 * 20, // Date is stale after 20 minutes
|
||||
},
|
||||
limiterConfigOverride: async (authorizationValue) => {
|
||||
const authenticatedEnv = await authenticateAuthorizationHeader(authorizationValue, {
|
||||
allowPublicKey: true,
|
||||
allowJWT: true,
|
||||
});
|
||||
|
||||
if (!authenticatedEnv || !authenticatedEnv.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (authenticatedEnv.type === "PUBLIC_JWT") {
|
||||
return {
|
||||
type: "fixedWindow",
|
||||
window: env.API_RATE_LIMIT_JWT_WINDOW,
|
||||
tokens: env.API_RATE_LIMIT_JWT_TOKENS,
|
||||
};
|
||||
} else {
|
||||
return authenticatedEnv.environment.organization.apiRateLimiterConfig;
|
||||
}
|
||||
},
|
||||
pathMatchers: [/^\/api/],
|
||||
// Allow /api/v1/tasks/:id/callback/:secret
|
||||
pathWhiteList: [
|
||||
"/api/internal/stripe_webhooks",
|
||||
"/api/v1/authorization-code",
|
||||
"/api/v1/token",
|
||||
"/api/v1/usage/ingest",
|
||||
"/api/v1/timezones",
|
||||
"/api/v1/usage/ingest",
|
||||
"/api/v1/auth/jwt/claims",
|
||||
],
|
||||
log: {
|
||||
rejections: env.API_RATE_LIMIT_REJECTION_LOGS_ENABLED === "1",
|
||||
requests: env.API_RATE_LIMIT_REQUEST_LOGS_ENABLED === "1",
|
||||
limiter: env.API_RATE_LIMIT_LIMITER_LOGS_ENABLED === "1",
|
||||
},
|
||||
});
|
||||
|
||||
export type RateLimitMiddleware = ReturnType<typeof authorizationRateLimitMiddleware>;
|
||||
20
apps/webapp/app/services/auth.server.ts
Normal file
20
apps/webapp/app/services/auth.server.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { Authenticator } from "remix-auth";
|
||||
import type { AuthUser } from "./authUser";
|
||||
|
||||
import { addGoogleStrategy } from "./googleAuth.server";
|
||||
import { sessionStorage } from "./sessionStorage.server";
|
||||
import { env } from "~/env.server";
|
||||
|
||||
// Create an instance of the authenticator, pass a generic with what
|
||||
// strategies will return and will store in the session
|
||||
const authenticator = new Authenticator<AuthUser>(sessionStorage);
|
||||
|
||||
const isGoogleAuthSupported =
|
||||
typeof env.AUTH_GOOGLE_CLIENT_ID === "string" &&
|
||||
typeof env.AUTH_GOOGLE_CLIENT_SECRET === "string";
|
||||
|
||||
if (env.AUTH_GOOGLE_CLIENT_ID && env.AUTH_GOOGLE_CLIENT_SECRET) {
|
||||
addGoogleStrategy(authenticator, env.AUTH_GOOGLE_CLIENT_ID, env.AUTH_GOOGLE_CLIENT_SECRET);
|
||||
}
|
||||
|
||||
export { authenticator, isGoogleAuthSupported };
|
||||
3
apps/webapp/app/services/authUser.ts
Normal file
3
apps/webapp/app/services/authUser.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export type AuthUser = {
|
||||
userId: string;
|
||||
};
|
||||
54
apps/webapp/app/services/googleAuth.server.ts
Normal file
54
apps/webapp/app/services/googleAuth.server.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import type { Authenticator } from "remix-auth";
|
||||
import { GoogleStrategy } from "remix-auth-google";
|
||||
import { env } from "~/env.server";
|
||||
import { findOrCreateUser } from "~/models/user.server";
|
||||
import type { AuthUser } from "./authUser";
|
||||
import { postAuthentication } from "./postAuth.server";
|
||||
import { logger } from "./logger.service";
|
||||
|
||||
export function addGoogleStrategy(
|
||||
authenticator: Authenticator<AuthUser>,
|
||||
clientID: string,
|
||||
clientSecret: string
|
||||
) {
|
||||
const googleStrategy = new GoogleStrategy(
|
||||
{
|
||||
clientID,
|
||||
clientSecret,
|
||||
callbackURL: `${env.LOGIN_ORIGIN}/auth/google/callback`,
|
||||
},
|
||||
async ({ extraParams, profile }) => {
|
||||
const emails = profile.emails;
|
||||
|
||||
if (!emails) {
|
||||
throw new Error("Google login requires an email address");
|
||||
}
|
||||
|
||||
try {
|
||||
logger.debug("Google login", {
|
||||
emails,
|
||||
profile,
|
||||
extraParams,
|
||||
});
|
||||
|
||||
const { user, isNewUser } = await findOrCreateUser({
|
||||
email: emails[0].value,
|
||||
authenticationMethod: "GOOGLE",
|
||||
authenticationProfile: profile,
|
||||
authenticationExtraParams: extraParams,
|
||||
});
|
||||
|
||||
await postAuthentication({ user, isNewUser, loginMethod: "GOOGLE" });
|
||||
|
||||
return {
|
||||
userId: user.id,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
authenticator.use(googleStrategy);
|
||||
}
|
||||
20
apps/webapp/app/services/httpAsyncStorage.server.ts
Normal file
20
apps/webapp/app/services/httpAsyncStorage.server.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
|
||||
export type HttpLocalStorage = {
|
||||
requestId: string;
|
||||
path: string;
|
||||
host: string;
|
||||
method: string;
|
||||
};
|
||||
|
||||
const httpLocalStorage = new AsyncLocalStorage<HttpLocalStorage>();
|
||||
|
||||
export type RunWithHttpContextFunction = <T>(context: HttpLocalStorage, fn: () => T) => T;
|
||||
|
||||
export function runWithHttpContext<T>(context: HttpLocalStorage, fn: () => T): T {
|
||||
return httpLocalStorage.run(context, fn);
|
||||
}
|
||||
|
||||
export function getHttpContext(): HttpLocalStorage | undefined {
|
||||
return httpLocalStorage.getStore();
|
||||
}
|
||||
44
apps/webapp/app/services/impersonation.server.ts
Normal file
44
apps/webapp/app/services/impersonation.server.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { createCookieSessionStorage, type Session } from "@remix-run/node";
|
||||
import { env } from "~/env.server";
|
||||
|
||||
export const impersonationSessionStorage = createCookieSessionStorage({
|
||||
cookie: {
|
||||
name: "__impersonate", // use any name you want here
|
||||
sameSite: "lax", // this helps with CSRF
|
||||
path: "/", // remember to add this so the cookie will work in all routes
|
||||
httpOnly: true, // for security reasons, make this cookie http only
|
||||
secrets: [env.SESSION_SECRET],
|
||||
secure: env.NODE_ENV === "production", // enable this in prod only
|
||||
maxAge: 60 * 60 * 24, // 1 day
|
||||
},
|
||||
});
|
||||
|
||||
export function getImpersonationSession(request: Request) {
|
||||
return impersonationSessionStorage.getSession(request.headers.get("Cookie"));
|
||||
}
|
||||
|
||||
export function commitImpersonationSession(session: Session) {
|
||||
return impersonationSessionStorage.commitSession(session);
|
||||
}
|
||||
|
||||
export async function getImpersonationId(request: Request) {
|
||||
const session = await getImpersonationSession(request);
|
||||
|
||||
return session.get("impersonatedUserId") as string | undefined;
|
||||
}
|
||||
|
||||
export async function setImpersonationId(userId: string, request: Request) {
|
||||
const session = await getImpersonationSession(request);
|
||||
|
||||
session.set("impersonatedUserId", userId);
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function clearImpersonationId(request: Request) {
|
||||
const session = await getImpersonationSession(request);
|
||||
|
||||
session.unset("impersonatedUserId");
|
||||
|
||||
return session;
|
||||
}
|
||||
296
apps/webapp/app/services/logger.service.ts
Normal file
296
apps/webapp/app/services/logger.service.ts
Normal file
@ -0,0 +1,296 @@
|
||||
import { sensitiveDataReplacer } from "./sensitiveDataReplacer";
|
||||
import { AsyncLocalStorage } from "async_hooks";
|
||||
import { getHttpContext } from "./httpAsyncStorage.server";
|
||||
|
||||
import { env } from "node:process";
|
||||
import { Buffer } from "node:buffer";
|
||||
import { trace, context } from "@opentelemetry/api";
|
||||
|
||||
export type LogLevel = "log" | "error" | "warn" | "info" | "debug";
|
||||
|
||||
const logLevels: Array<LogLevel> = ["log", "error", "warn", "info", "debug"];
|
||||
|
||||
export class Logger {
|
||||
#name: string;
|
||||
readonly #level: number;
|
||||
#filteredKeys: string[] = [];
|
||||
#jsonReplacer?: (key: string, value: unknown) => unknown;
|
||||
#additionalFields: () => Record<string, unknown>;
|
||||
|
||||
constructor(
|
||||
name: string,
|
||||
level: LogLevel = "info",
|
||||
filteredKeys: string[] = [],
|
||||
jsonReplacer?: (key: string, value: unknown) => unknown,
|
||||
additionalFields?: () => Record<string, unknown>
|
||||
) {
|
||||
this.#name = name;
|
||||
this.#level = logLevels.indexOf((env.APP_LOG_LEVEL ?? level) as LogLevel);
|
||||
this.#filteredKeys = filteredKeys;
|
||||
this.#jsonReplacer = createReplacer(jsonReplacer);
|
||||
this.#additionalFields = additionalFields ?? (() => ({}));
|
||||
}
|
||||
|
||||
child(fields: Record<string, unknown>) {
|
||||
return new Logger(
|
||||
this.#name,
|
||||
logLevels[this.#level],
|
||||
this.#filteredKeys,
|
||||
this.#jsonReplacer,
|
||||
() => ({ ...this.#additionalFields(), ...fields })
|
||||
);
|
||||
}
|
||||
|
||||
// Return a new Logger instance with the same name and a new log level
|
||||
// but filter out the keys from the log messages (at any level)
|
||||
filter(...keys: string[]) {
|
||||
return new Logger(this.#name, logLevels[this.#level], keys, this.#jsonReplacer);
|
||||
}
|
||||
|
||||
static satisfiesLogLevel(logLevel: LogLevel, setLevel: LogLevel) {
|
||||
return logLevels.indexOf(logLevel) <= logLevels.indexOf(setLevel);
|
||||
}
|
||||
|
||||
log(message: string, ...args: Array<Record<string, unknown> | undefined>) {
|
||||
if (this.#level < 0) return;
|
||||
|
||||
this.#structuredLog(console.log, message, "log", ...args);
|
||||
}
|
||||
|
||||
error(message: string, ...args: Array<Record<string, unknown> | undefined>) {
|
||||
if (this.#level < 1) return;
|
||||
|
||||
this.#structuredLog(console.error, message, "error", ...args);
|
||||
}
|
||||
|
||||
warn(message: string, ...args: Array<Record<string, unknown> | undefined>) {
|
||||
if (this.#level < 2) return;
|
||||
|
||||
this.#structuredLog(console.warn, message, "warn", ...args);
|
||||
}
|
||||
|
||||
info(message: string, ...args: Array<Record<string, unknown> | undefined>) {
|
||||
if (this.#level < 3) return;
|
||||
|
||||
this.#structuredLog(console.info, message, "info", ...args);
|
||||
}
|
||||
|
||||
debug(message: string, ...args: Array<Record<string, unknown> | undefined>) {
|
||||
if (this.#level < 4) return;
|
||||
|
||||
this.#structuredLog(console.debug, message, "debug", ...args);
|
||||
}
|
||||
|
||||
#structuredLog(
|
||||
loggerFunction: (message: string, ...args: any[]) => void,
|
||||
message: string,
|
||||
level: string,
|
||||
...args: Array<Record<string, unknown> | undefined>
|
||||
) {
|
||||
// Get the current context from trace if it exists
|
||||
const currentSpan = trace.getSpan(context.active());
|
||||
|
||||
const structuredError = extractStructuredErrorFromArgs(...args);
|
||||
const structuredMessage = extractStructuredMessageFromArgs(...args);
|
||||
|
||||
const structuredLog = {
|
||||
...structureArgs(safeJsonClone(args) as Record<string, unknown>[], this.#filteredKeys),
|
||||
...this.#additionalFields(),
|
||||
...(structuredError ? { error: structuredError } : {}),
|
||||
timestamp: new Date(),
|
||||
name: this.#name,
|
||||
message,
|
||||
...(structuredMessage ? { $message: structuredMessage } : {}),
|
||||
level,
|
||||
traceId:
|
||||
currentSpan && currentSpan.isRecording() ? currentSpan?.spanContext().traceId : undefined,
|
||||
parentSpanId:
|
||||
currentSpan && currentSpan.isRecording() ? currentSpan?.spanContext().spanId : undefined,
|
||||
};
|
||||
|
||||
// If the span is not recording, and it's a debug log, mark it so we can filter it out when we forward it
|
||||
if (currentSpan && !currentSpan.isRecording() && level === "debug") {
|
||||
structuredLog.skipForwarding = true;
|
||||
}
|
||||
|
||||
loggerFunction(JSON.stringify(structuredLog, this.#jsonReplacer));
|
||||
}
|
||||
}
|
||||
|
||||
// Detect if args is an error object
|
||||
// Or if args contains an error object at the "error" key
|
||||
// In both cases, return the error object as a structured error
|
||||
function extractStructuredErrorFromArgs(...args: Array<Record<string, unknown> | undefined>) {
|
||||
const error = args.find((arg) => arg instanceof Error) as Error | undefined;
|
||||
|
||||
if (error) {
|
||||
return {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
name: error.name,
|
||||
};
|
||||
}
|
||||
|
||||
const structuredError = args.find((arg) => arg?.error);
|
||||
|
||||
if (structuredError && structuredError.error instanceof Error) {
|
||||
return {
|
||||
message: structuredError.error.message,
|
||||
stack: structuredError.error.stack,
|
||||
name: structuredError.error.name,
|
||||
};
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
function extractStructuredMessageFromArgs(...args: Array<Record<string, unknown> | undefined>) {
|
||||
// Check to see if there is a `message` key in the args, and if so, return it
|
||||
const structuredMessage = args.find((arg) => arg?.message);
|
||||
|
||||
if (structuredMessage) {
|
||||
return structuredMessage.message;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
function createReplacer(replacer?: (key: string, value: unknown) => unknown) {
|
||||
return (key: string, value: unknown) => {
|
||||
if (typeof value === "bigint") {
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
if (replacer) {
|
||||
return replacer(key, value);
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
}
|
||||
|
||||
// Replacer function for JSON.stringify that converts BigInts to strings
|
||||
function bigIntReplacer(_key: string, value: unknown) {
|
||||
if (typeof value === "bigint") {
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function safeJsonClone(obj: unknown) {
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(obj, bigIntReplacer));
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// If args is has a single item that is an object, return that object
|
||||
function structureArgs(args: Array<Record<string, unknown>>, filteredKeys: string[] = []) {
|
||||
if (!args) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.length === 1 && typeof args[0] === "object") {
|
||||
return filterKeys(JSON.parse(JSON.stringify(args[0], bigIntReplacer)), filteredKeys);
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
// Recursively filter out keys from an object, including nested objects, and arrays
|
||||
function filterKeys(obj: unknown, keys: string[]): any {
|
||||
if (typeof obj !== "object" || obj === null) {
|
||||
return obj;
|
||||
}
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map((item) => filterKeys(item, keys));
|
||||
}
|
||||
|
||||
const filteredObj: any = {};
|
||||
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (keys.includes(key)) {
|
||||
if (value) {
|
||||
filteredObj[key] = `[filtered ${prettyPrintBytes(value)}]`;
|
||||
} else {
|
||||
filteredObj[key] = value;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
filteredObj[key] = filterKeys(value, keys);
|
||||
}
|
||||
|
||||
return filteredObj;
|
||||
}
|
||||
|
||||
function prettyPrintBytes(value: unknown): string {
|
||||
if (env.NODE_ENV === "production") {
|
||||
return "skipped size";
|
||||
}
|
||||
|
||||
const sizeInBytes = getSizeInBytes(value);
|
||||
|
||||
if (sizeInBytes < 1024) {
|
||||
return `${sizeInBytes} bytes`;
|
||||
}
|
||||
|
||||
if (sizeInBytes < 1024 * 1024) {
|
||||
return `${(sizeInBytes / 1024).toFixed(2)} KB`;
|
||||
}
|
||||
|
||||
if (sizeInBytes < 1024 * 1024 * 1024) {
|
||||
return `${(sizeInBytes / (1024 * 1024)).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
return `${(sizeInBytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
function getSizeInBytes(value: unknown) {
|
||||
const jsonString = JSON.stringify(value);
|
||||
|
||||
return Buffer.byteLength(jsonString, "utf8");
|
||||
}
|
||||
|
||||
const currentFieldsStore = new AsyncLocalStorage<Record<string, unknown>>();
|
||||
|
||||
export const logger = new Logger(
|
||||
"webapp",
|
||||
(process.env.APP_LOG_LEVEL ?? "debug") as LogLevel,
|
||||
["examples", "output", "connectionString", "payload"],
|
||||
sensitiveDataReplacer,
|
||||
() => {
|
||||
const fields = currentFieldsStore.getStore();
|
||||
const httpContext = getHttpContext();
|
||||
return { ...fields, http: httpContext };
|
||||
}
|
||||
);
|
||||
|
||||
export const workerLogger = new Logger(
|
||||
"worker",
|
||||
(process.env.APP_LOG_LEVEL ?? "debug") as LogLevel,
|
||||
["examples", "output", "connectionString"],
|
||||
sensitiveDataReplacer,
|
||||
() => {
|
||||
const fields = currentFieldsStore.getStore();
|
||||
return fields ? { ...fields } : {};
|
||||
}
|
||||
);
|
||||
|
||||
export const socketLogger = new Logger(
|
||||
"socket",
|
||||
(process.env.APP_LOG_LEVEL ?? "debug") as LogLevel,
|
||||
[],
|
||||
sensitiveDataReplacer,
|
||||
() => {
|
||||
const fields = currentFieldsStore.getStore();
|
||||
return fields ? { ...fields } : {};
|
||||
}
|
||||
);
|
||||
14
apps/webapp/app/services/postAuth.server.ts
Normal file
14
apps/webapp/app/services/postAuth.server.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import type { User } from "~/models/user.server";
|
||||
import { singleton } from "~/utils/singleton";
|
||||
|
||||
export async function postAuthentication({
|
||||
user,
|
||||
loginMethod,
|
||||
isNewUser,
|
||||
}: {
|
||||
user: User;
|
||||
loginMethod: User["authenticationMethod"];
|
||||
isNewUser: boolean;
|
||||
}) {
|
||||
console.log(user);
|
||||
}
|
||||
33
apps/webapp/app/services/sensitiveDataReplacer.ts
Normal file
33
apps/webapp/app/services/sensitiveDataReplacer.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const RedactStringSchema = z.object({
|
||||
__redactedString: z.literal(true),
|
||||
strings: z.array(z.string()),
|
||||
interpolations: z.array(z.string()),
|
||||
});
|
||||
|
||||
export type RedactString = z.infer<typeof RedactStringSchema>;
|
||||
|
||||
// Replaces redacted strings with "******".
|
||||
// For example, this object: {"Authorization":{"__redactedString":true,"strings":["Bearer ",""],"interpolations":["sk-1234"]}}
|
||||
// Would get stringified like so: {"Authorization": "Bearer ******"}
|
||||
export function sensitiveDataReplacer(key: string, value: any): any {
|
||||
if (typeof value === "object" && value !== null && value.__redactedString === true) {
|
||||
return redactString(value);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function redactString(value: RedactString) {
|
||||
let result = "";
|
||||
|
||||
for (let i = 0; i < value.strings.length; i++) {
|
||||
result += value.strings[i];
|
||||
if (i < value.interpolations.length) {
|
||||
result += "********";
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
import { redirect } from "@remix-run/node";
|
||||
import { getUserById } from "~/models/user.server";
|
||||
import { authenticator } from "./auth.server";
|
||||
import { getImpersonationId } from "./impersonation.server";
|
||||
|
||||
export async function getUserId(request: Request): Promise<string | undefined> {
|
||||
const impersonatedUserId = await getImpersonationId(request);
|
||||
|
||||
if (impersonatedUserId) return impersonatedUserId;
|
||||
|
||||
let authUser = await authenticator.isAuthenticated(request);
|
||||
return authUser?.userId;
|
||||
}
|
||||
|
||||
export async function getUser(request: Request) {
|
||||
const userId = await getUserId(request);
|
||||
if (userId === undefined) return null;
|
||||
|
||||
const user = await getUserById(userId);
|
||||
if (user) return user;
|
||||
|
||||
throw await logout(request);
|
||||
}
|
||||
|
||||
export async function logout(request: Request) {
|
||||
return redirect("/logout");
|
||||
}
|
||||
35
apps/webapp/app/services/sessionStorage.server.ts
Normal file
35
apps/webapp/app/services/sessionStorage.server.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { createCookieSessionStorage } from "@remix-run/node";
|
||||
import { createThemeSessionResolver } from "remix-themes";
|
||||
import { env } from "~/env.server";
|
||||
|
||||
export const sessionStorage = createCookieSessionStorage({
|
||||
cookie: {
|
||||
name: "__session", // use any name you want here
|
||||
sameSite: "lax", // this helps with CSRF
|
||||
path: "/", // remember to add this so the cookie will work in all routes
|
||||
httpOnly: true, // for security reasons, make this cookie http only
|
||||
secrets: [env.SESSION_SECRET],
|
||||
secure: env.NODE_ENV === "production", // enable this in prod only
|
||||
maxAge: 60 * 60 * 24 * 365, // 7 days
|
||||
},
|
||||
});
|
||||
|
||||
export const themeStorage = createCookieSessionStorage({
|
||||
cookie: {
|
||||
name: "__theme",
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
secrets: [env.SESSION_SECRET],
|
||||
secure: env.NODE_ENV === "production",
|
||||
maxAge: 60 * 60 * 24 * 365, // 1 year
|
||||
},
|
||||
});
|
||||
|
||||
export const themeSessionResolver = createThemeSessionResolver(sessionStorage);
|
||||
|
||||
export function getUserSession(request: Request) {
|
||||
return sessionStorage.getSession(request.headers.get("Cookie"));
|
||||
}
|
||||
|
||||
export const { getSession, commitSession, destroySession } = sessionStorage;
|
||||
@ -1,85 +1,66 @@
|
||||
@import url("non.geist");
|
||||
@import url("non.geist/mono");
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@import "tailwindcss";
|
||||
|
||||
html,
|
||||
body {
|
||||
@apply bg-background;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
color-scheme: dark;
|
||||
}
|
||||
:root {
|
||||
--background: oklch(91.28% 0 0);
|
||||
--background-2: oklch(95.21% 0 0);
|
||||
--background-3: oklch(100% 0 0);
|
||||
--foreground: oklch(0% 0 0);
|
||||
--popover: oklch(93.05% 0 0);
|
||||
--popover-foreground: oklch(0% 0 0);
|
||||
--primary: oklch(54% 0.1789 271);
|
||||
--primary-foreground: oklch(100% 0 0);
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: oklch(0% 0 0);
|
||||
--muted: oklch(0% 0 0 / 19.22%);
|
||||
--muted-foreground: oklch(49.26% 0 0);
|
||||
--accent: oklch(100% 0 0);
|
||||
--accent-foreground: oklch(0% 0 0);
|
||||
--destructive: oklch(61.34% 0.162 23.58);
|
||||
--destructive-foreground: oklch(100% 0 0);
|
||||
--warning: oklch(70.43% 0.14390424619548714 87.9634104985311);
|
||||
--warning-foreground: oklch(100% 0 0);
|
||||
--success: oklch(64.93% 0.107 154.24);
|
||||
--success-foreground: oklch(100% 0 0);
|
||||
--border: oklch(0% 0 0 / 19.22%);
|
||||
--border-dark: oklch(0% 0 0 / 39.22%);
|
||||
--input: oklch(0% 0 0 / 6.27%);
|
||||
--ring: 221.2 83.2% 53.3%;
|
||||
--radius: 8px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 91.28% 0 0;
|
||||
--background-2: 95.21% 0 0;
|
||||
--background-3: 100% 0 0;
|
||||
--foreground: 0% 0 0;
|
||||
--popover: 93.05% 0 0;
|
||||
--popover-foreground: 0% 0 0;
|
||||
--primary: 54% 0.1789 271;
|
||||
--primary-foreground: 100% 0 0;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 0% 0 0;
|
||||
--muted: 0% 0 0 / 19.22%;
|
||||
--muted-foreground: 49.26% 0 0;
|
||||
--accent: 100% 0 0;
|
||||
--accent-foreground: 0% 0 0;
|
||||
--destructive: 61.34% 0.162 23.58;
|
||||
--destructive-foreground: 100% 0 0;
|
||||
--warning: 70.43% 0.14390424619548714 87.9634104985311;
|
||||
--warning-foreground: 100% 0 0;
|
||||
--success: 64.93% 0.107 154.24;
|
||||
--success-foreground: 100% 0 0;
|
||||
--border: 0% 0 0 / 19.22%;
|
||||
--border-dark: 0% 0 0 / 39.22%;
|
||||
--input: 0% 0 0 / 6.27%;
|
||||
--ring: 221.2 83.2% 53.3%;
|
||||
--radius: 8px;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 21.34% 0 0;
|
||||
--background-2: 25.2% 0 0;
|
||||
--background-3: 28.5% 0 0;
|
||||
--foreground: 92.8% 0 0;
|
||||
--popover: 28.5% 0 0;
|
||||
--popover-foreground: 92.8% 0 0;
|
||||
--primary: 54% 0.1789 271;
|
||||
--primary-foreground: 92.8% 0 0;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 92.8% 0 0;
|
||||
--muted: 100% 0 0 / 13.33%;
|
||||
--muted-foreground: 76.99% 0 0;
|
||||
--accent: 95.81% 0 0;
|
||||
--accent-foreground: 0% 0 0;
|
||||
--warning: 70.43% 0.14390424619548714 87.9634104985311;
|
||||
--warning-foreground: 100% 0 0;
|
||||
--destructive: 61.34% 0.162 23.58;
|
||||
--destructive-foreground: 100% 0 0;
|
||||
--success: 64.93% 0.107 154.24;
|
||||
--success-foreground: 100% 0 0;
|
||||
--border: 100% 0 0 / 13.33%;
|
||||
--border-dark: 100% 0 0 / 39.33%;
|
||||
--input: 100% 0 0 / 10.59%;
|
||||
--ring: 221.2 83.2% 53.3%;
|
||||
}
|
||||
.dark,
|
||||
:root[class~="dark"] {
|
||||
--background: oklch(21.34% 0 0);
|
||||
--background-2: oklch(25.2% 0 0);
|
||||
--background-3: oklch(28.5% 0 0);
|
||||
--foreground: oklch(92.8% 0 0);
|
||||
--popover: oklch(28.5% 0 0);
|
||||
--popover-foreground: oklch(92.8% 0 0);
|
||||
--primary: oklch(54% 0.1789 271);
|
||||
--primary-foreground: oklch(92.8% 0 0);
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: oklch(92.8% 0 0);
|
||||
--muted: oklch(100% 0 0 / 13.33%);
|
||||
--muted-foreground: oklch(76.99% 0 0);
|
||||
--accent: oklch(95.81% 0 0);
|
||||
--accent-foreground: oklch(0% 0 0);
|
||||
--warning: oklch(70.43% 0.14390424619548714 87.9634104985311);
|
||||
--warning-foreground: oklch(100% 0 0);
|
||||
--destructive: oklch(61.34% 0.162 23.58);
|
||||
--destructive-foreground: oklch(100% 0 0);
|
||||
--success: oklch(64.93% 0.107 154.24);
|
||||
--success-foreground: oklch(100% 0 0);
|
||||
--border: oklch(100% 0 0 / 13.33%);
|
||||
--border-dark: oklch(100% 0 0 / 39.33%);
|
||||
--input: oklch(100% 0 0 / 10.59%);
|
||||
--ring: 221.2 83.2% 53.3%;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
}
|
||||
@ -1,5 +1,4 @@
|
||||
import type { UIMatch } from "@remix-run/react";
|
||||
import { useMatches } from "@remix-run/react";
|
||||
import { useMatches, type UIMatch } from "@remix-run/react";
|
||||
|
||||
const DEFAULT_REDIRECT = "/";
|
||||
|
||||
@ -12,7 +11,7 @@ const DEFAULT_REDIRECT = "/";
|
||||
*/
|
||||
export function safeRedirect(
|
||||
to: FormDataEntryValue | string | null | undefined,
|
||||
defaultRedirect: string = DEFAULT_REDIRECT
|
||||
defaultRedirect: string = DEFAULT_REDIRECT,
|
||||
) {
|
||||
if (!to || typeof to !== "string") {
|
||||
return defaultRedirect;
|
||||
@ -31,7 +30,10 @@ export function safeRedirect(
|
||||
* @param {string} id The route id
|
||||
* @returns {JSON|undefined} The router data or undefined if not found
|
||||
*/
|
||||
export function useMatchesData(id: string | string[], debug: boolean = false): UIMatch | undefined {
|
||||
export function useMatchesData(
|
||||
id: string | string[],
|
||||
debug: boolean = false,
|
||||
): UIMatch | undefined {
|
||||
const matchingRoutes = useMatches();
|
||||
|
||||
if (debug) {
|
||||
@ -41,10 +43,13 @@ export function useMatchesData(id: string | string[], debug: boolean = false): U
|
||||
const paths = Array.isArray(id) ? id : [id];
|
||||
|
||||
// Get the first matching route
|
||||
const route = paths.reduce((acc, path) => {
|
||||
if (acc) return acc;
|
||||
return matchingRoutes.find((route) => route.id === path);
|
||||
}, undefined as UIMatch | undefined);
|
||||
const route = paths.reduce(
|
||||
(acc, path) => {
|
||||
if (acc) return acc;
|
||||
return matchingRoutes.find((route) => route.id === path);
|
||||
},
|
||||
undefined as UIMatch | undefined,
|
||||
);
|
||||
|
||||
return route;
|
||||
}
|
||||
|
||||
24
apps/webapp/app/utils/httpErrors.ts
Normal file
24
apps/webapp/app/utils/httpErrors.ts
Normal file
@ -0,0 +1,24 @@
|
||||
export function friendlyErrorDisplay(statusCode: number, statusText?: string) {
|
||||
switch (statusCode) {
|
||||
case 400:
|
||||
return {
|
||||
title: "400: Bad request",
|
||||
message: statusText ?? "The request was invalid.",
|
||||
};
|
||||
case 404:
|
||||
return {
|
||||
title: "404: Page not found",
|
||||
message: statusText ?? "The page you're looking for doesn't exist.",
|
||||
};
|
||||
case 500:
|
||||
return {
|
||||
title: "500: Server error",
|
||||
message: statusText ?? "Something went wrong on our end. Please try again later.",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
title: `${statusCode}: Error`,
|
||||
message: statusText ?? "An error occurred.",
|
||||
};
|
||||
}
|
||||
}
|
||||
6
apps/webapp/app/utils/singleton.ts
Normal file
6
apps/webapp/app/utils/singleton.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export function singleton<T>(name: string, getValue: () => T): T {
|
||||
const thusly = globalThis as any;
|
||||
thusly.__echo_singletons ??= {};
|
||||
thusly.__echo_singletons[name] ??= getValue();
|
||||
return thusly.__echo_singletons[name];
|
||||
}
|
||||
@ -5,55 +5,85 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "remix vite:build",
|
||||
"dev": "remix vite:dev",
|
||||
"dev": "node ./server.js",
|
||||
"lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .",
|
||||
"start": "remix-serve ./build/server/index.js",
|
||||
"typecheck": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@remix-run/node": "^2.16.7",
|
||||
"@remix-run/react": "^2.16.7",
|
||||
"@remix-run/serve": "^2.16.7",
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
"@echo/database": "workspace:*",
|
||||
"@opentelemetry/api": "1.9.0",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@remix-run/express": "2.16.7",
|
||||
"@remix-run/node": "2.1.0",
|
||||
"@remix-run/react": "2.16.7",
|
||||
"@remix-run/router": "^1.15.3",
|
||||
"@remix-run/serve": "2.16.7",
|
||||
"@remix-run/server-runtime": "2.16.7",
|
||||
"@remix-run/v1-meta": "^0.1.3",
|
||||
"@tailwindcss/postcss": "^4.1.7",
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
"compression": "^1.7.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"remix-typedjson": "0.3.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cross-env": "^7.0.3",
|
||||
"express": "^4.18.1",
|
||||
"isbot": "^4.1.0",
|
||||
"morgan": "^1.10.0",
|
||||
"nanoid": "3.3.8",
|
||||
"lucide-react": "^0.511.0",
|
||||
"non.geist": "^1.0.2",
|
||||
"posthog-js": "^1.116.6",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"tailwind-merge": "^1.12.0",
|
||||
"tailwind-scrollbar-hide": "^1.1.7",
|
||||
"tailwindcss-animate": "^1.0.5",
|
||||
"remix-auth": "^3.6.0",
|
||||
"remix-auth-google": "^2.0.0",
|
||||
"remix-typedjson": "0.3.1",
|
||||
"remix-utils": "^7.7.0",
|
||||
"remix-themes": "^1.3.1",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwind-scrollbar-hide": "^2.0.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tailwindcss-textshadow": "^2.1.3",
|
||||
"non.geist": "^1.0.2",
|
||||
"tiny-invariant": "^1.3.1",
|
||||
"zod": "3.23.8",
|
||||
"zod-error": "1.5.0",
|
||||
"zod-validation-error": "^1.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@remix-run/dev": "^2.16.7",
|
||||
"@remix-run/dev": "2.16.7",
|
||||
"@remix-run/eslint-config": "2.16.7",
|
||||
"@remix-run/testing": "^2.16.7",
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tailwindcss/vite": "^4.1.7",
|
||||
|
||||
"@types/compression": "^1.7.2",
|
||||
"@types/morgan": "^1.9.3",
|
||||
"@types/express": "^4.17.13",
|
||||
"@types/react": "^18.2.20",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
"@typescript-eslint/eslint-plugin": "^6.7.4",
|
||||
"@typescript-eslint/parser": "^6.7.4",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"esbuild": "0.25.5",
|
||||
"eslint": "^8.38.0",
|
||||
"css-loader": "^6.10.0",
|
||||
"esbuild": "^0.25.5",
|
||||
"eslint-import-resolver-typescript": "^3.6.1",
|
||||
"eslint-plugin-import": "^2.28.1",
|
||||
"eslint-plugin-jsx-a11y": "^6.7.1",
|
||||
"eslint-plugin-react": "^7.33.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint": "^8.24.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-plugin-import": "^2.29.1",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"eslint-plugin-turbo": "^2.0.4",
|
||||
"postcss": "^8.4.38",
|
||||
"prettier": "^2.8.8",
|
||||
"prettier-plugin-tailwindcss": "^0.3.0",
|
||||
"@tailwindcss/forms": "^0.5.3",
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"tailwind-scrollbar": "^3.0.1",
|
||||
"tailwindcss": "3.4.1",
|
||||
"typescript": "^5.1.6",
|
||||
"postcss-import": "^16.0.1",
|
||||
"postcss-loader": "^8.1.1",
|
||||
"prettier": "^3.5.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||
"tailwind-scrollbar": "^4.0.2",
|
||||
"tailwindcss": "4.1.7",
|
||||
"typescript": "5.8.3",
|
||||
"vite": "^6.0.0",
|
||||
"vite-tsconfig-paths": "^4.2.1"
|
||||
},
|
||||
|
||||
@ -1,6 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@ -1,4 +0,0 @@
|
||||
module.exports = {
|
||||
...require("../../prettier.config.js"),
|
||||
plugins: [require("prettier-plugin-tailwindcss")],
|
||||
};
|
||||
@ -1,30 +0,0 @@
|
||||
/** @type {import('@remix-run/dev').AppConfig} */
|
||||
module.exports = {
|
||||
dev: {
|
||||
port: 8002,
|
||||
},
|
||||
tailwind: true,
|
||||
postcss: true,
|
||||
cacheDirectory: "./node_modules/.cache/remix",
|
||||
ignoredRouteFiles: ["**/.*"],
|
||||
serverModuleFormat: "cjs",
|
||||
serverDependenciesToBundle: [
|
||||
/^remix-utils.*/,
|
||||
/^@internal\//, // Bundle all internal packages
|
||||
"marked",
|
||||
"axios",
|
||||
"p-limit",
|
||||
"yocto-queue",
|
||||
"@unkey/cache",
|
||||
"@unkey/cache/stores",
|
||||
"emails",
|
||||
"highlight.run",
|
||||
"random-words",
|
||||
"superjson",
|
||||
"prismjs/components/prism-json",
|
||||
"prismjs/components/prism-typescript",
|
||||
"redlock",
|
||||
"parse-duration",
|
||||
],
|
||||
browserNodeBuiltinsPolyfill: { modules: { path: true, os: true, crypto: true } },
|
||||
};
|
||||
51
apps/webapp/server.js
Normal file
51
apps/webapp/server.js
Normal file
@ -0,0 +1,51 @@
|
||||
import { createRequestHandler } from "@remix-run/express";
|
||||
import compression from "compression";
|
||||
import express from "express";
|
||||
import morgan from "morgan";
|
||||
|
||||
const viteDevServer =
|
||||
process.env.NODE_ENV === "production"
|
||||
? undefined
|
||||
: await import("vite").then((vite) =>
|
||||
vite.createServer({
|
||||
server: { middlewareMode: true },
|
||||
}),
|
||||
);
|
||||
|
||||
const remixHandler = createRequestHandler({
|
||||
build: viteDevServer
|
||||
? () => viteDevServer.ssrLoadModule("virtual:remix/server-build")
|
||||
: await import("./build/server/index.js"),
|
||||
});
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use(compression());
|
||||
|
||||
// http://expressjs.com/en/advanced/best-practice-security.html#at-a-minimum-disable-x-powered-by-header
|
||||
app.disable("x-powered-by");
|
||||
|
||||
// handle asset requests
|
||||
if (viteDevServer) {
|
||||
app.use(viteDevServer.middlewares);
|
||||
} else {
|
||||
// Vite fingerprints its assets so we can cache forever.
|
||||
app.use(
|
||||
"/assets",
|
||||
express.static("build/client/assets", { immutable: true, maxAge: "1y" }),
|
||||
);
|
||||
}
|
||||
|
||||
// Everything else (like favicon.ico) is cached for an hour. You may want to be
|
||||
// more aggressive with this caching.
|
||||
app.use(express.static("build/client", { maxAge: "1h" }));
|
||||
|
||||
app.use(morgan("tiny"));
|
||||
|
||||
// handle SSR requests
|
||||
app.all("*", remixHandler);
|
||||
|
||||
const port = process.env.REMIX_APP_PORT || 3000;
|
||||
app.listen(port, () =>
|
||||
console.log(`Express server listening at http://localhost:${port}`),
|
||||
);
|
||||
@ -1,167 +0,0 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
const colors = require("tailwindcss/colors");
|
||||
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
darkMode: ["class"],
|
||||
content: ["./app/**/{**,.client,.server}/**/*.{js,jsx,ts,tsx}"],
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: "2rem",
|
||||
screens: {
|
||||
"2xl": "1400px",
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ["Geist Variable", "Helvetica Neue", "Helvetica", "Arial", "sans-serif"],
|
||||
mono: ["Geist Mono Variable", "monaco", "Consolas", "Lucida Console", "monospace"],
|
||||
},
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)'
|
||||
},
|
||||
colors: {
|
||||
border: {
|
||||
DEFAULT: 'oklch(var(--border))',
|
||||
dark: 'oklch(var(--border-dark))',
|
||||
},
|
||||
input: 'oklch(var(--input))',
|
||||
ring: 'oklch(var(--ring))',
|
||||
background: {
|
||||
'2': 'oklch(var(--background-2) / <alpha-value>)',
|
||||
'3': 'oklch(var(--background-3) / <alpha-value>)',
|
||||
DEFAULT: 'oklch(var(--background) / <alpha-value>)',
|
||||
},
|
||||
foreground: 'oklch(var(--foreground) / <alpha-value>)',
|
||||
primary: {
|
||||
DEFAULT: 'oklch(var(--primary) / <alpha-value>)',
|
||||
foreground: 'oklch(var(--primary-foreground) / <alpha-value>)',
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: 'hsl(var(--secondary))',
|
||||
foreground: 'hsl(var(--secondary-foreground))',
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: 'oklch(var(--destructive) / <alpha-value>)',
|
||||
foreground: 'oklch(var(--destructive-foreground) / <alpha-value>)',
|
||||
},
|
||||
warning: {
|
||||
DEFAULT: 'oklch(var(--warning) / <alpha-value>)',
|
||||
foreground: 'oklch(var(--warning-foreground) / <alpha-value>)',
|
||||
},
|
||||
success: {
|
||||
DEFAULT: 'oklch(var(--success) / <alpha-value>)',
|
||||
foreground: 'oklch(var(--success-foreground) / <alpha-value>)',
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: 'oklch(var(--muted))',
|
||||
foreground: 'oklch(var(--muted-foreground) / <alpha-value>)',
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: 'oklch(var(--accent) / <alpha-value>)',
|
||||
foreground: 'oklch(var(--accent-foreground) / <alpha-value>)',
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: 'oklch(var(--popover) / <alpha-value>)',
|
||||
foreground: 'oklch(var(--popover-foreground) / <alpha-value>)',
|
||||
},
|
||||
gray: {
|
||||
'50': 'var(--gray-50)',
|
||||
'100': 'var(--gray-100)',
|
||||
'200': 'var(--gray-200)',
|
||||
'300': 'var(--gray-300)',
|
||||
'400': 'var(--gray-400)',
|
||||
'500': 'var(--gray-500)',
|
||||
'600': 'var(--gray-600)',
|
||||
'700': 'var(--gray-700)',
|
||||
'800': 'var(--gray-800)',
|
||||
'900': 'var(--gray-900)',
|
||||
'950': 'var(--gray-950)',
|
||||
},
|
||||
grayAlpha: {
|
||||
'50': 'oklch(var(--grayAlpha-50))',
|
||||
'100': 'oklch(var(--grayAlpha-100))',
|
||||
'200': 'oklch(var(--grayAlpha-200))',
|
||||
'300': 'oklch(var(--grayAlpha-300))',
|
||||
'400': 'oklch(var(--grayAlpha-400))',
|
||||
'500': 'oklch(var(--grayAlpha-500))',
|
||||
'600': 'oklch(var(--grayAlpha-600))',
|
||||
'700': 'oklch(var(--grayAlpha-700))',
|
||||
'800': 'oklch(var(--grayAlpha-800))',
|
||||
'900': 'oklch(var(--grayAlpha-900))',
|
||||
'950': 'oklch(var(--grayAlpha-950))',
|
||||
},
|
||||
red: {
|
||||
'50': '#fdf3f3',
|
||||
'100': '#fbe9e8',
|
||||
'200': '#f7d4d4',
|
||||
'300': '#f0b1b1',
|
||||
'400': '#e78587',
|
||||
'500': '#d75056',
|
||||
'600': '#c43a46',
|
||||
'700': '#a52b3a',
|
||||
'800': '#8a2735',
|
||||
'900': '#772433',
|
||||
'950': '#420f18',
|
||||
},
|
||||
orange: {
|
||||
'50': '#fdf6ef',
|
||||
'100': '#fbead9',
|
||||
'200': '#f7d2b1',
|
||||
'300': '#f1b480',
|
||||
'400': '#ea8c4d',
|
||||
'500': '#e67333',
|
||||
'600': '#d65520',
|
||||
'700': '#b2401c',
|
||||
'800': '#8e341e',
|
||||
'900': '#732d1b',
|
||||
'950': '#3e140c',
|
||||
},
|
||||
yellow: {
|
||||
'50': '#fdfbe9',
|
||||
'100': '#faf7c7',
|
||||
'200': '#f7ec91',
|
||||
'300': '#f1db53',
|
||||
'400': '#ebc724',
|
||||
'500': '#dcb016',
|
||||
'600': '#c28c11',
|
||||
'700': '#976211',
|
||||
'800': '#7d4f16',
|
||||
'900': '#6b4118',
|
||||
'950': '#3e220a',
|
||||
},
|
||||
sidebar: {
|
||||
DEFAULT: 'oklch(var(--background-2) / <alpha-value>)',
|
||||
foreground: 'oklch(var(--foreground) / <alpha-value>)',
|
||||
primary: 'oklch(var(--primary) / <alpha-value>)',
|
||||
'primary-foreground':
|
||||
'oklch(var(--primary-foreground) / <alpha-value>)',
|
||||
accent: 'oklch(var(--accent) / <alpha-value>)',
|
||||
'accent-foreground':
|
||||
'oklch(var(--accent-foreground) / <alpha-value>)',
|
||||
border: 'oklch(var(--border))',
|
||||
ring: 'oklch(var(--ring))',
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
plugins: [ require("@tailwindcss/container-queries"),
|
||||
require("@tailwindcss/forms"),
|
||||
require("@tailwindcss/typography"),
|
||||
require("tailwindcss-animate"),
|
||||
require("tailwind-scrollbar"),
|
||||
require("tailwind-scrollbar-hide"),
|
||||
require("tailwindcss-textshadow"),
|
||||
function ({ addUtilities, theme }) {
|
||||
const focusStyles = theme("focusStyles", {});
|
||||
addUtilities({
|
||||
".focus-custom": {
|
||||
"&:focus-visible": focusStyles,
|
||||
},
|
||||
});
|
||||
},],
|
||||
}
|
||||
169
apps/webapp/tailwind.config.ts
Normal file
169
apps/webapp/tailwind.config.ts
Normal file
@ -0,0 +1,169 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
export default {
|
||||
content: ["./app/**/{**,.client,.server}/**/*.{js,jsx,ts,tsx}"],
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: "2rem",
|
||||
screens: {
|
||||
"2xl": "1400px",
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: [
|
||||
"Geist Variable",
|
||||
"Helvetica Neue",
|
||||
"Helvetica",
|
||||
"Arial",
|
||||
"sans-serif",
|
||||
],
|
||||
mono: [
|
||||
"Geist Mono Variable",
|
||||
"monaco",
|
||||
"Consolas",
|
||||
"Lucida Console",
|
||||
"monospace",
|
||||
],
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
colors: {
|
||||
border: {
|
||||
DEFAULT: "oklch(var(--border))",
|
||||
dark: "oklch(var(--border-dark))",
|
||||
},
|
||||
input: "oklch(var(--input))",
|
||||
ring: "oklch(var(--ring))",
|
||||
background: {
|
||||
2: "oklch(var(--background-2) / <alpha-value>)",
|
||||
3: "oklch(var(--background-3) / <alpha-value>)",
|
||||
DEFAULT: "oklch(var(--background) / <alpha-value>)",
|
||||
},
|
||||
foreground: "oklch(var(--foreground) / <alpha-value>)",
|
||||
primary: {
|
||||
DEFAULT: "oklch(var(--primary) / <alpha-value>)",
|
||||
foreground: "oklch(var(--primary-foreground) / <alpha-value>)",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "oklch(var(--destructive) / <alpha-value>)",
|
||||
foreground: "oklch(var(--destructive-foreground) / <alpha-value>)",
|
||||
},
|
||||
warning: {
|
||||
DEFAULT: "oklch(var(--warning) / <alpha-value>)",
|
||||
foreground: "oklch(var(--warning-foreground) / <alpha-value>)",
|
||||
},
|
||||
success: {
|
||||
DEFAULT: "oklch(var(--success) / <alpha-value>)",
|
||||
foreground: "oklch(var(--success-foreground) / <alpha-value>)",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "oklch(var(--muted))",
|
||||
foreground: "oklch(var(--muted-foreground) / <alpha-value>)",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "oklch(var(--accent) / <alpha-value>)",
|
||||
foreground: "oklch(var(--accent-foreground) / <alpha-value>)",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "oklch(var(--popover) / <alpha-value>)",
|
||||
foreground: "oklch(var(--popover-foreground) / <alpha-value>)",
|
||||
},
|
||||
gray: {
|
||||
50: "var(--gray-50)",
|
||||
100: "var(--gray-100)",
|
||||
200: "var(--gray-200)",
|
||||
300: "var(--gray-300)",
|
||||
400: "var(--gray-400)",
|
||||
500: "var(--gray-500)",
|
||||
600: "var(--gray-600)",
|
||||
700: "var(--gray-700)",
|
||||
800: "var(--gray-800)",
|
||||
900: "var(--gray-900)",
|
||||
950: "var(--gray-950)",
|
||||
},
|
||||
grayAlpha: {
|
||||
50: "oklch(var(--grayAlpha-50))",
|
||||
100: "oklch(var(--grayAlpha-100))",
|
||||
200: "oklch(var(--grayAlpha-200))",
|
||||
300: "oklch(var(--grayAlpha-300))",
|
||||
400: "oklch(var(--grayAlpha-400))",
|
||||
500: "oklch(var(--grayAlpha-500))",
|
||||
600: "oklch(var(--grayAlpha-600))",
|
||||
700: "oklch(var(--grayAlpha-700))",
|
||||
800: "oklch(var(--grayAlpha-800))",
|
||||
900: "oklch(var(--grayAlpha-900))",
|
||||
950: "oklch(var(--grayAlpha-950))",
|
||||
},
|
||||
red: {
|
||||
50: "#fdf3f3",
|
||||
100: "#fbe9e8",
|
||||
200: "#f7d4d4",
|
||||
300: "#f0b1b1",
|
||||
400: "#e78587",
|
||||
500: "#d75056",
|
||||
600: "#c43a46",
|
||||
700: "#a52b3a",
|
||||
800: "#8a2735",
|
||||
900: "#772433",
|
||||
950: "#420f18",
|
||||
},
|
||||
orange: {
|
||||
50: "#fdf6ef",
|
||||
100: "#fbead9",
|
||||
200: "#f7d2b1",
|
||||
300: "#f1b480",
|
||||
400: "#ea8c4d",
|
||||
500: "#e67333",
|
||||
600: "#d65520",
|
||||
700: "#b2401c",
|
||||
800: "#8e341e",
|
||||
900: "#732d1b",
|
||||
950: "#3e140c",
|
||||
},
|
||||
yellow: {
|
||||
50: "#fdfbe9",
|
||||
100: "#faf7c7",
|
||||
200: "#f7ec91",
|
||||
300: "#f1db53",
|
||||
400: "#ebc724",
|
||||
500: "#dcb016",
|
||||
600: "#c28c11",
|
||||
700: "#976211",
|
||||
800: "#7d4f16",
|
||||
900: "#6b4118",
|
||||
950: "#3e220a",
|
||||
},
|
||||
sidebar: {
|
||||
DEFAULT: "oklch(var(--background-2) / <alpha-value>)",
|
||||
foreground: "oklch(var(--foreground) / <alpha-value>)",
|
||||
primary: "oklch(var(--primary) / <alpha-value>)",
|
||||
"primary-foreground":
|
||||
"oklch(var(--primary-foreground) / <alpha-value>)",
|
||||
accent: "oklch(var(--accent) / <alpha-value>)",
|
||||
"accent-foreground":
|
||||
"oklch(var(--accent-foreground) / <alpha-value>)",
|
||||
border: "oklch(var(--border))",
|
||||
ring: "oklch(var(--ring))",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
require("@tailwindcss/container-queries"),
|
||||
require("@tailwindcss/forms"),
|
||||
require("@tailwindcss/typography"),
|
||||
require("tailwindcss-animate"),
|
||||
require("tailwind-scrollbar"),
|
||||
require("tailwind-scrollbar-hide"),
|
||||
require("tailwindcss-textshadow"),
|
||||
],
|
||||
} satisfies Config;
|
||||
@ -1,8 +1,15 @@
|
||||
{
|
||||
"exclude": [],
|
||||
"include": ["remix.env.d.ts", "global.d.ts", "**/*.ts", "**/*.tsx"],
|
||||
"include": [
|
||||
"remix.env.d.ts",
|
||||
"global.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
"tailwind.config.js",
|
||||
"tailwind.config.js"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"types": [],
|
||||
"types": ["@remix-run/node", "vite/client"],
|
||||
"lib": ["DOM", "DOM.Iterable", "DOM.AsyncIterable", "ES2020"],
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { vitePlugin as remix } from "@remix-run/dev";
|
||||
import { defineConfig } from "vite";
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
declare module "@remix-run/node" {
|
||||
interface Future {
|
||||
@ -10,6 +11,7 @@ declare module "@remix-run/node" {
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
tailwindcss(),
|
||||
remix({
|
||||
future: {
|
||||
v3_fetcherPersist: true,
|
||||
|
||||
26
package.json
26
package.json
@ -2,24 +2,26 @@
|
||||
"name": "echo",
|
||||
"private": true,
|
||||
"workspaces":
|
||||
[ "apps/*" ]
|
||||
[ "apps/*", "packages/*" ]
|
||||
,
|
||||
"scripts": {
|
||||
"build": "turbo run build",
|
||||
"dev": "turbo run dev",
|
||||
"lint": "turbo run lint",
|
||||
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
|
||||
"check-types": "turbo run check-types",
|
||||
"db:migrate": "turbo run db:migrate:deploy generate",
|
||||
"db:seed": "turbo run db:seed",
|
||||
"db:studio": "turbo run db:studio",
|
||||
"db:populate": "turbo run db:populate",
|
||||
"generate": "turbo run generate"
|
||||
"build": "dotenv -- turbo run build",
|
||||
"dev": "dotenv -- turbo run dev",
|
||||
"lint": "dotenv -- turbo run lint",
|
||||
"format": "dotenv -- prettier --write \"**/*.{ts,tsx,md}\"",
|
||||
"check-types": "dotenv -- turbo run check-types",
|
||||
"db:migrate": "dotenv -- turbo run db:migrate:deploy generate",
|
||||
"db:migrate:create": "dotenv -- turbo run db:migrate:create generate",
|
||||
"db:seed": "dotenv -- turbo run db:seed",
|
||||
"db:studio": "dotenv -- turbo run db:studio",
|
||||
"db:populate": "dotenv -- turbo run db:populate",
|
||||
"generate": "dotenv -- turbo run generate"
|
||||
},
|
||||
"devDependencies": {
|
||||
"dotenv-cli": "^7.4.4",
|
||||
"prettier": "^3.5.3",
|
||||
"turbo": "^2.5.3",
|
||||
"typescript": "5.8.2"
|
||||
"typescript": "5.5.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@changesets/cli": "2.26.2",
|
||||
|
||||
@ -9,12 +9,13 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"prisma": "5.4.1",
|
||||
"rimraf": "6.0.1"
|
||||
"rimraf": "6.0.1",
|
||||
"esbuild": "^0.15.10"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rimraf dist",
|
||||
"generate": "prisma generate",
|
||||
"db:migrate:dev:create": "prisma migrate dev --create-only",
|
||||
"db:migrate:create": "prisma migrate dev --preview-feature",
|
||||
"db:migrate:deploy": "prisma migrate deploy",
|
||||
"db:push": "prisma db push",
|
||||
"db:studio": "prisma studio",
|
||||
|
||||
@ -0,0 +1,84 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AuthenticationMethod" AS ENUM ('GOOGLE');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"authenticationMethod" "AuthenticationMethod" NOT NULL,
|
||||
"authenticationProfile" JSONB,
|
||||
"authenticationExtraParams" JSONB,
|
||||
"authIdentifier" TEXT,
|
||||
"displayName" TEXT,
|
||||
"name" TEXT,
|
||||
"avatarUrl" TEXT,
|
||||
"admin" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"marketingEmails" BOOLEAN NOT NULL DEFAULT true,
|
||||
"confirmedBasicDetails" BOOLEAN NOT NULL DEFAULT false,
|
||||
"referralSource" TEXT,
|
||||
"invitationCodeId" TEXT,
|
||||
|
||||
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AuthorizationCode" (
|
||||
"id" TEXT NOT NULL,
|
||||
"code" TEXT NOT NULL,
|
||||
"personalAccessTokenId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "AuthorizationCode_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "PersonalAccessToken" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"encryptedToken" JSONB NOT NULL,
|
||||
"obfuscatedToken" TEXT NOT NULL,
|
||||
"hashedToken" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"revokedAt" TIMESTAMP(3),
|
||||
"lastAccessedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "PersonalAccessToken_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "InvitationCode" (
|
||||
"id" TEXT NOT NULL,
|
||||
"code" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "InvitationCode_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_authIdentifier_key" ON "User"("authIdentifier");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "AuthorizationCode_code_key" ON "AuthorizationCode"("code");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "PersonalAccessToken_hashedToken_key" ON "PersonalAccessToken"("hashedToken");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "InvitationCode_code_key" ON "InvitationCode"("code");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "User" ADD CONSTRAINT "User_invitationCodeId_fkey" FOREIGN KEY ("invitationCodeId") REFERENCES "InvitationCode"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AuthorizationCode" ADD CONSTRAINT "AuthorizationCode_personalAccessTokenId_fkey" FOREIGN KEY ("personalAccessTokenId") REFERENCES "PersonalAccessToken"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PersonalAccessToken" ADD CONSTRAINT "PersonalAccessToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@ -0,0 +1,75 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "IngestionStatus" AS ENUM ('PENDING', 'PROCESSING', 'COMPLETED', 'FAILED', 'CANCELLED');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "memoryFilter" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Space" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"autoMode" BOOLEAN NOT NULL DEFAULT false,
|
||||
"userId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Space_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Entity" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"metadata" JSONB NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Entity_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SpaceEntity" (
|
||||
"id" TEXT NOT NULL,
|
||||
"spaceId" TEXT NOT NULL,
|
||||
"entityId" TEXT NOT NULL,
|
||||
"settings" JSONB,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "SpaceEntity_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "IngestionQueue" (
|
||||
"id" TEXT NOT NULL,
|
||||
"spaceId" TEXT NOT NULL,
|
||||
"data" JSONB NOT NULL,
|
||||
"status" "IngestionStatus" NOT NULL,
|
||||
"priority" INTEGER NOT NULL DEFAULT 0,
|
||||
"error" TEXT,
|
||||
"retryCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"processedAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "IngestionQueue_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Entity_name_key" ON "Entity"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "SpaceEntity_spaceId_entityId_key" ON "SpaceEntity"("spaceId", "entityId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Space" ADD CONSTRAINT "Space_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SpaceEntity" ADD CONSTRAINT "SpaceEntity_spaceId_fkey" FOREIGN KEY ("spaceId") REFERENCES "Space"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SpaceEntity" ADD CONSTRAINT "SpaceEntity_entityId_fkey" FOREIGN KEY ("entityId") REFERENCES "Entity"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "IngestionQueue" ADD CONSTRAINT "IngestionQueue_spaceId_fkey" FOREIGN KEY ("spaceId") REFERENCES "Space"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
3
packages/database/prisma/migrations/migration_lock.toml
Normal file
3
packages/database/prisma/migrations/migration_lock.toml
Normal file
@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "postgresql"
|
||||
@ -23,27 +23,22 @@ model User {
|
||||
name String?
|
||||
avatarUrl String?
|
||||
|
||||
admin Boolean @default(false)
|
||||
memoryFilter String? // Adding memory filter instructions
|
||||
|
||||
/// Preferences for the dashboard
|
||||
dashboardPreferences Json?
|
||||
admin Boolean @default(false)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
/// @deprecated
|
||||
isOnCloudWaitlist Boolean @default(false)
|
||||
/// @deprecated
|
||||
featureCloud Boolean @default(false)
|
||||
/// @deprecated
|
||||
isOnHostedRepoWaitlist Boolean @default(false)
|
||||
|
||||
marketingEmails Boolean @default(true)
|
||||
confirmedBasicDetails Boolean @default(false)
|
||||
|
||||
referralSource String?
|
||||
|
||||
personalAccessTokens PersonalAccessToken[]
|
||||
InvitationCode InvitationCode? @relation(fields: [invitationCodeId], references: [id])
|
||||
invitationCodeId String?
|
||||
Space Space[]
|
||||
}
|
||||
|
||||
enum AuthenticationMethod {
|
||||
@ -90,3 +85,95 @@ model PersonalAccessToken {
|
||||
|
||||
authorizationCodes AuthorizationCode[]
|
||||
}
|
||||
|
||||
model InvitationCode {
|
||||
id String @id @default(cuid())
|
||||
code String @unique
|
||||
|
||||
users User[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
// Space model for user workspaces
|
||||
model Space {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
description String?
|
||||
autoMode Boolean @default(false)
|
||||
|
||||
// Relations
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId String
|
||||
|
||||
// Space's enabled entities
|
||||
enabledEntities SpaceEntity[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
IngestionQueue IngestionQueue[]
|
||||
}
|
||||
|
||||
// Entity types that can be stored in the memory plane
|
||||
model Entity {
|
||||
id String @id @default(cuid())
|
||||
name String @unique // e.g., "User", "Issue", "Task", "Automation"
|
||||
metadata Json // Store field definitions and their types
|
||||
|
||||
// Relations
|
||||
spaceEntities SpaceEntity[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
// Junction table for Space-Entity relationship (what entities are enabled in each space)
|
||||
model SpaceEntity {
|
||||
id String @id @default(cuid())
|
||||
|
||||
// Relations
|
||||
space Space @relation(fields: [spaceId], references: [id])
|
||||
spaceId String
|
||||
|
||||
entity Entity @relation(fields: [entityId], references: [id])
|
||||
entityId String
|
||||
|
||||
// Custom settings for this entity in this space
|
||||
settings Json?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([spaceId, entityId])
|
||||
}
|
||||
|
||||
// Queue for processing ingestion tasks
|
||||
model IngestionQueue {
|
||||
id String @id @default(cuid())
|
||||
|
||||
// Relations
|
||||
space Space @relation(fields: [spaceId], references: [id])
|
||||
spaceId String
|
||||
|
||||
// Queue metadata
|
||||
data Json // The actual data to be processed
|
||||
status IngestionStatus
|
||||
priority Int @default(0)
|
||||
|
||||
// Error handling
|
||||
error String?
|
||||
retryCount Int @default(0)
|
||||
|
||||
// Timestamps
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
processedAt DateTime?
|
||||
}
|
||||
|
||||
enum IngestionStatus {
|
||||
PENDING
|
||||
PROCESSING
|
||||
COMPLETED
|
||||
FAILED
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
2716
pnpm-lock.yaml
generated
2716
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
35
turbo.json
35
turbo.json
@ -5,7 +5,13 @@
|
||||
"build": {
|
||||
"dependsOn": [ "^build" ],
|
||||
"inputs": [ "$TURBO_DEFAULT$", ".env*" ],
|
||||
"outputs": [ ".next/**", "!.next/cache/**" ]
|
||||
"outputs": [
|
||||
"dist/**",
|
||||
"public/build/**",
|
||||
"build/**",
|
||||
"app/styles/tailwind.css",
|
||||
".cache"
|
||||
]
|
||||
},
|
||||
"lint": {
|
||||
"dependsOn": [ "^lint" ]
|
||||
@ -14,8 +20,7 @@
|
||||
"dependsOn": [ "^check-types" ]
|
||||
},
|
||||
"dev": {
|
||||
"cache": false,
|
||||
"persistent": true
|
||||
"cache": false
|
||||
},
|
||||
"db:generate": {
|
||||
"cache": false
|
||||
@ -23,6 +28,10 @@
|
||||
"db:migrate:deploy": {
|
||||
"cache": false
|
||||
},
|
||||
"db:migrate:create": {
|
||||
"cache": false,
|
||||
"interactive": true
|
||||
},
|
||||
"db:studio": {
|
||||
"cache": false
|
||||
},
|
||||
@ -31,5 +40,23 @@
|
||||
"^generate"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"globalDependencies": [
|
||||
".env"
|
||||
],
|
||||
"globalEnv": [
|
||||
"NODE_ENV",
|
||||
"REMIX_APP_PORT",
|
||||
"CI",
|
||||
"DATABASE_URL",
|
||||
"DIRECT_URL",
|
||||
"SESSION_SECRET",
|
||||
"APP_ORIGIN",
|
||||
"LOGIN_ORIGIN",
|
||||
"POSTHOG_PROJECT_KEY",
|
||||
"AUTH_GOOGLE_CLIENT_ID",
|
||||
"AUTH_GOOGLE_CLIENT_SECRET",
|
||||
"APP_ENV",
|
||||
"APP_LOG_LEVEL"
|
||||
]
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user