First Commit. Duplicate bunext-sqlite preset
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
import H1 from "@moduletrace/twui/layout/H1";
|
||||
import Section from "@moduletrace/twui/layout/Section";
|
||||
import { useContext, type ReactNode } from "react";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import Breadcrumbs from "@moduletrace/twui/elements/Breadcrumbs";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
description?: string | ReactNode;
|
||||
buttons?: ReactNode;
|
||||
};
|
||||
|
||||
export default function AdminHero({ title, description, buttons }: Props) {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
|
||||
return (
|
||||
<Section className="pb-0! pt-10! xl:pt-14!">
|
||||
<Stack className="w-full items-stretch gap-0!">
|
||||
<Breadcrumbs
|
||||
currentLinkProps={{
|
||||
className: "text-slate-400! pointer-events-none",
|
||||
}}
|
||||
pageUrl={pageProps?.url?.pathname}
|
||||
backButton
|
||||
skipHome
|
||||
/>
|
||||
<Row className="w-full justify-between items-end gap-6! mt-6 pb-8 border-b border-slate-200 dark:border-white/10">
|
||||
<Stack className="gap-3! max-w-2xl">
|
||||
<H1>{title}</H1>
|
||||
{description ? (
|
||||
typeof description == "string" ? (
|
||||
<Span className="text-slate-500 dark:text-slate-400 text-[15px] leading-relaxed">
|
||||
{description}
|
||||
</Span>
|
||||
) : (
|
||||
description
|
||||
)
|
||||
) : null}
|
||||
</Stack>
|
||||
{buttons ? <Row className="gap-2!">{buttons}</Row> : null}
|
||||
</Row>
|
||||
</Stack>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Divider from "@moduletrace/twui/layout/Divider";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
|
||||
export default function AuthDivider() {
|
||||
return (
|
||||
<Row className="w-full flex-nowrap gap-4 -my-2">
|
||||
<Divider />
|
||||
<Span className="text-sm font-semibold opacity-50">OR</Span>
|
||||
<Divider />
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Link from "@moduletrace/twui/layout/Link";
|
||||
|
||||
type Props = {
|
||||
prompt: string;
|
||||
title: string;
|
||||
url: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function AuthFooterLink({
|
||||
prompt,
|
||||
title,
|
||||
url,
|
||||
className,
|
||||
}: Props) {
|
||||
return (
|
||||
<Row className={twMerge("justify-center gap-1.5", className)}>
|
||||
<Span variant="faded">{prompt}</Span>
|
||||
<Link href={url}>{title}</Link>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { ReactNode } from "react";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import H1 from "@moduletrace/twui/layout/H1";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
|
||||
type Props = {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
description?: ReactNode;
|
||||
};
|
||||
|
||||
export default function AuthHero({ eyebrow, title, description }: Props) {
|
||||
return (
|
||||
<Stack className="w-full items-center gap-4!">
|
||||
<Span className="htag">{eyebrow}</Span>
|
||||
<H1 className="text-center text-3xl! sm:text-4xl! tracking-[-0.035em]">
|
||||
{title}
|
||||
</H1>
|
||||
{description ? (
|
||||
<Span className="text-base text-slate-500 dark:text-slate-400 leading-snug text-center max-w-md">
|
||||
{description}
|
||||
</Span>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
import Img from "@moduletrace/twui/layout/Img";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
type Props = {
|
||||
src: string;
|
||||
opacity?: number;
|
||||
};
|
||||
|
||||
export default function BlurredImageBG({ src, opacity = 0.1 }: Props) {
|
||||
/**
|
||||
* Render component
|
||||
*/
|
||||
return (
|
||||
<Img
|
||||
alt="bg image"
|
||||
src={src}
|
||||
className={twMerge(
|
||||
`absolute top-0 left-0 w-[150vw] h-full blur-xs object-cover`,
|
||||
"z-0",
|
||||
)}
|
||||
style={{ opacity }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import Loading from "@moduletrace/twui/elements/Loading";
|
||||
import Select from "@moduletrace/twui/form/Select";
|
||||
import { useAdminCrudGet } from "@/src/hooks/use-admin-crud-get";
|
||||
import type { TableType } from "@/src/types";
|
||||
import Modal from "@moduletrace/twui/elements/Modal";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Card from "@moduletrace/twui/elements/Card";
|
||||
import H3 from "@moduletrace/twui/layout/H3";
|
||||
import type { BUN_SQLITE_BSQLITE_ALL_TYPEDEFS } from "@/db/types/db";
|
||||
import useStatus from "@moduletrace/twui/hooks/useStatus";
|
||||
import H4 from "@moduletrace/twui/layout/H4";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import dayjs from "dayjs";
|
||||
import twuiNumberfy from "@moduletrace/twui/utils/numberfy";
|
||||
import LucideIcon from "@moduletrace/twui/elements/lucide-icon";
|
||||
import EmptyContent from "@moduletrace/twui/elements/EmptyContent";
|
||||
|
||||
export type CopyableRecord = {
|
||||
id?: number | "";
|
||||
title?: string;
|
||||
subject?: string;
|
||||
};
|
||||
|
||||
type Props<T extends {} = BUN_SQLITE_BSQLITE_ALL_TYPEDEFS> = {
|
||||
table: TableType;
|
||||
title: string;
|
||||
onSelect?: (record: T) => void;
|
||||
onClear?: () => void;
|
||||
};
|
||||
|
||||
export default function CopyFromExistingSelect<
|
||||
T extends BUN_SQLITE_BSQLITE_ALL_TYPEDEFS =
|
||||
BUN_SQLITE_BSQLITE_ALL_TYPEDEFS,
|
||||
>({ table, title, onSelect, onClear }: Props<T>) {
|
||||
const { res: records, ready } = useAdminCrudGet<T>({
|
||||
table,
|
||||
});
|
||||
|
||||
const { open, setOpen } = useStatus();
|
||||
|
||||
return (
|
||||
<Row>
|
||||
<Button
|
||||
title="Clear Form"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onClear?.();
|
||||
}}
|
||||
variant="outlined"
|
||||
color="gray"
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
<Modal
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
target={
|
||||
<Button
|
||||
title="Duplicate Existing Record"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
variant="outlined"
|
||||
>
|
||||
Create from an Existing Record
|
||||
</Button>
|
||||
}
|
||||
className="xl:p-6"
|
||||
>
|
||||
<Stack className="gap-6 w-full">
|
||||
<Stack className="gap-1">
|
||||
<H3 className="text-2xl! font-semibold">
|
||||
{title || "Select Record"}
|
||||
</H3>
|
||||
<Span>Select a record to duplicate</Span>
|
||||
</Stack>
|
||||
|
||||
<Stack className="w-full items-stretch">
|
||||
{records?.[0] ? (
|
||||
records?.map((rec, index) => {
|
||||
const date = dayjs(
|
||||
twuiNumberfy(rec.created_at),
|
||||
);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={index}
|
||||
onClick={() => {
|
||||
onSelect?.(rec);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="w-full cursor-pointer"
|
||||
>
|
||||
<Row className="w-full justify-between">
|
||||
<Row>
|
||||
<LucideIcon
|
||||
name="Database"
|
||||
size={17}
|
||||
opacity={0.5}
|
||||
/>
|
||||
<H4 className="text-base! font-semibold">
|
||||
{rec.title}
|
||||
</H4>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<Span
|
||||
size="smaller"
|
||||
variant="faded"
|
||||
className="text-nowrap"
|
||||
>
|
||||
{date
|
||||
.toString()
|
||||
.substring(0, 16)}
|
||||
</Span>
|
||||
</Row>
|
||||
</Row>
|
||||
</Card>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<EmptyContent title="No Records Found!" />
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import H3 from "@moduletrace/twui/layout/H3";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import type { FeatureItem } from "@/src/types";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
|
||||
type Props = FeatureItem & {
|
||||
index: number;
|
||||
};
|
||||
|
||||
export default function FeatureCard({
|
||||
Icon,
|
||||
title,
|
||||
description,
|
||||
index,
|
||||
}: Props) {
|
||||
return (
|
||||
<Stack className="h-full w-full gap-14! justify-between bg-light dark:bg-background-dark p-8 xl:p-10 transition-colors hover:bg-white dark:hover:bg-white/5">
|
||||
<Row className="w-full justify-between">
|
||||
<Span className="text-xs text-slate-400">
|
||||
{String(index + 1).padStart(2, "0")}
|
||||
</Span>
|
||||
<Icon size={20} strokeWidth={1.5} className="text-slate-700 dark:text-slate-300" />
|
||||
</Row>
|
||||
<Stack className="gap-3!">
|
||||
<H3>{title}</H3>
|
||||
<Span className="text-slate-500 dark:text-slate-400 leading-relaxed">
|
||||
{description}
|
||||
</Span>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { FeatureItem } from "@/src/types";
|
||||
import FeatureCard from "./feature-card";
|
||||
|
||||
type Props = {
|
||||
items: FeatureItem[];
|
||||
};
|
||||
|
||||
export default function FeatureGrid({ items }: Props) {
|
||||
return (
|
||||
<div className="grid w-full grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-px bg-slate-200 dark:bg-white/10 border border-slate-200 dark:border-white/10 rounded-default-lg overflow-hidden">
|
||||
{items.map((item, index) => (
|
||||
<FeatureCard key={item.title} {...item} index={index} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Img from "@moduletrace/twui/layout/Img";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import type useFormInit from "@/src/hooks/use-form-init";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import useGoogleAuth, {
|
||||
type GoogleAuthMode,
|
||||
} from "@/src/hooks/use-google-auth";
|
||||
|
||||
type Props = Pick<
|
||||
ReturnType<typeof useFormInit>,
|
||||
"loading" | "setLoading" | "setStatus"
|
||||
> & {
|
||||
mode: GoogleAuthMode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Themed button with Google's own (transparent) button laid over it. Once
|
||||
* Google's button is ready it takes keyboard focus and clicks, and the
|
||||
* themed button below is hidden from assistive tech.
|
||||
*/
|
||||
export default function GoogleAuthButton({
|
||||
mode,
|
||||
loading,
|
||||
setLoading,
|
||||
setStatus,
|
||||
}: Props) {
|
||||
const { handleButtonClick, googleButtonRef, googleReady } = useGoogleAuth({
|
||||
mode,
|
||||
loading,
|
||||
setLoading,
|
||||
setStatus,
|
||||
});
|
||||
|
||||
const overlayActive = googleReady && !loading;
|
||||
const label =
|
||||
mode == "signup" ? "Sign up with Google" : "Log in with Google";
|
||||
|
||||
return (
|
||||
<Row className="w-full relative justify-center rounded-default focus-within:ring-2 focus-within:ring-primary/70">
|
||||
<Button
|
||||
title={label}
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="gray"
|
||||
className="w-full py-3 relative"
|
||||
disabled={loading}
|
||||
onClick={handleButtonClick}
|
||||
tabIndex={overlayActive ? -1 : undefined}
|
||||
aria-hidden={overlayActive || undefined}
|
||||
>
|
||||
<Img alt="" src={`/icons/google.png`} size={17} />
|
||||
<Span>{label}</Span>
|
||||
</Button>
|
||||
<div
|
||||
ref={googleButtonRef}
|
||||
className={twMerge(
|
||||
"absolute inset-0 z-10 opacity-0",
|
||||
overlayActive
|
||||
? "pointer-events-auto"
|
||||
: "pointer-events-none",
|
||||
)}
|
||||
/>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useContext, type ComponentProps } from "react";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import GoogleAuthButton from "./google-auth-button";
|
||||
import AuthDivider from "./auth-divider";
|
||||
|
||||
/**
|
||||
* Google button plus the "OR" divider. Renders nothing when Google
|
||||
* sign-in isn't configured.
|
||||
*/
|
||||
export default function GoogleAuthSection(
|
||||
props: ComponentProps<typeof GoogleAuthButton>,
|
||||
) {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
|
||||
if (!pageProps?.envs?.GOOGLE_CLIENT_ID) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<GoogleAuthButton {...props} />
|
||||
<AuthDivider />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
import Img from "@moduletrace/twui/layout/Img";
|
||||
import type { ComponentProps } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
type Props = {
|
||||
src: string;
|
||||
img_props?: Omit<ComponentProps<typeof Img>, "alt">;
|
||||
};
|
||||
|
||||
export default function ImageBG({ src, img_props }: Props) {
|
||||
return (
|
||||
<Img
|
||||
{...img_props}
|
||||
alt="bg image"
|
||||
src={src}
|
||||
className={twMerge(
|
||||
`absolute top-0 left-0 w-full h-full object-cover`,
|
||||
"z-0",
|
||||
img_props?.className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import Section from "@moduletrace/twui/layout/Section";
|
||||
import Container from "@moduletrace/twui/layout/Container";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import H1 from "@moduletrace/twui/layout/H1";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
sub_title?: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export default function InnerPageHeroSection({
|
||||
title,
|
||||
description,
|
||||
sub_title,
|
||||
}: Props) {
|
||||
return (
|
||||
<Section className="border-b border-slate-200/60 dark:border-white/10">
|
||||
<Container>
|
||||
<Stack className="w-full items-center max-w-3xl mx-auto gap-6!">
|
||||
{sub_title ? (
|
||||
<Span className="htag">{sub_title}</Span>
|
||||
) : null}
|
||||
<H1 className="text-center">{title}</H1>
|
||||
{description ? (
|
||||
<Span className="text-lg sm:text-xl leading-snug text-center text-slate-500 dark:text-slate-400">
|
||||
{description}
|
||||
</Span>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Container>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import Input from "@moduletrace/twui/form/Input";
|
||||
|
||||
type Props = {
|
||||
onCodeChange: (code: string) => void;
|
||||
};
|
||||
|
||||
export default function LoginCodeInput({ onCodeChange }: Props) {
|
||||
return (
|
||||
<Input
|
||||
id="code"
|
||||
name="code"
|
||||
type="text"
|
||||
label="Code"
|
||||
placeholder="123456"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
maxLength={6}
|
||||
autoFocus
|
||||
onChange={(e) => {
|
||||
onCodeChange(e.target.value);
|
||||
}}
|
||||
showLabel
|
||||
required
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import Tag from "@moduletrace/twui/elements/Tag";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import { TriangleAlert, CircleCheck } from "lucide-react";
|
||||
import type { PropsWithChildren } from "react";
|
||||
import type { TWUIStatusType } from "@moduletrace/twui/hooks/useStatus";
|
||||
import Paper from "@moduletrace/twui/elements/Paper";
|
||||
|
||||
type Props = PropsWithChildren & {
|
||||
title?: string;
|
||||
className?: string;
|
||||
status?: TWUIStatusType;
|
||||
};
|
||||
|
||||
export default function LoginShell({ children, className, status }: Props) {
|
||||
return (
|
||||
<>
|
||||
<Paper
|
||||
className={twMerge(
|
||||
"w-full flex-col items-center",
|
||||
"px-4 sm:px-6 py-8 shadow-md relative",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{status?.error && status.msg ? (
|
||||
<Tag
|
||||
variant="outlined"
|
||||
color="error"
|
||||
className="w-full py-1.5 outline-error/50 bg-error/5!"
|
||||
>
|
||||
<Row>
|
||||
<TriangleAlert size={15} />
|
||||
<span>{status.msg}</span>
|
||||
</Row>
|
||||
</Tag>
|
||||
) : null}
|
||||
{status?.success && status.msg ? (
|
||||
<Tag
|
||||
variant="outlined"
|
||||
color="success"
|
||||
className="w-full py-1.5 outline-success/50 bg-success/5!"
|
||||
>
|
||||
<Row>
|
||||
<CircleCheck size={15} />
|
||||
<span>{status.msg}</span>
|
||||
</Row>
|
||||
</Tag>
|
||||
) : null}
|
||||
{children}
|
||||
</Paper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { ComponentProps } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
type Props = {
|
||||
icon_size?: number;
|
||||
img_props?: Pick<ComponentProps<"svg">, "className">;
|
||||
};
|
||||
|
||||
export default function LogoIcon({ icon_size = 40, img_props }: Props) {
|
||||
return (
|
||||
<svg
|
||||
width={icon_size}
|
||||
height={icon_size}
|
||||
viewBox="0 0 512 512"
|
||||
role="img"
|
||||
aria-label="Logo Icon"
|
||||
className={twMerge("shrink-0", img_props?.className)}
|
||||
>
|
||||
<rect
|
||||
x="16"
|
||||
y="16"
|
||||
width="480"
|
||||
height="480"
|
||||
rx="120"
|
||||
className="fill-slate-900 dark:fill-slate-800"
|
||||
/>
|
||||
<path
|
||||
d="M290 100 L144 286 H244 L222 412 L368 226 H268 Z"
|
||||
strokeWidth="28"
|
||||
strokeLinejoin="round"
|
||||
className="fill-white stroke-white"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import type { ComponentProps, PropsWithChildren } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import LogoIcon from "./logo-icon";
|
||||
import { SiteData } from "@/src/data/site-data";
|
||||
|
||||
type Props = {
|
||||
icon_size?: number;
|
||||
text_wrapper_props?: ComponentProps<typeof Stack>;
|
||||
text_props?: ComponentProps<typeof Span>;
|
||||
};
|
||||
|
||||
export default function Logo({
|
||||
icon_size = 36,
|
||||
text_props,
|
||||
text_wrapper_props,
|
||||
}: Props) {
|
||||
return (
|
||||
<a href="/" className="logo-link">
|
||||
<Row className="gap-3! flex-nowrap items-center">
|
||||
<LogoIcon icon_size={icon_size} />
|
||||
<Stack
|
||||
{...text_wrapper_props}
|
||||
className={twMerge("gap-1!", text_wrapper_props?.className)}
|
||||
>
|
||||
<Stack className="gap-0!">
|
||||
<HeroText text_props={text_props}>
|
||||
{SiteData["SiteName"]}
|
||||
</HeroText>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Row>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function HeroText({
|
||||
text_props,
|
||||
children,
|
||||
}: PropsWithChildren & {
|
||||
text_props?: ComponentProps<typeof Span>;
|
||||
}) {
|
||||
return (
|
||||
<Span
|
||||
{...text_props}
|
||||
className={twMerge(
|
||||
"text-[17px] font-semibold tracking-tight text-dark dark:text-white heading leading-[24px] whitespace-nowrap",
|
||||
text_props?.className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Span>
|
||||
);
|
||||
}
|
||||
Executable
+38
@@ -0,0 +1,38 @@
|
||||
import React, { type ComponentProps, type ReactNode } from "react";
|
||||
import Container from "@moduletrace/twui/layout/Container";
|
||||
import H1 from "@moduletrace/twui/layout/H1";
|
||||
import P from "@moduletrace/twui/layout/P";
|
||||
import Section from "@moduletrace/twui/layout/Section";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import type Img from "@moduletrace/twui/layout/Img";
|
||||
import { SiteData } from "@/src/data/site-data";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
image_url?: string;
|
||||
sub_title?: string;
|
||||
cta_buttons?: ReactNode;
|
||||
img_props?: Omit<ComponentProps<typeof Img>, "alt">;
|
||||
img_component?: ReactNode;
|
||||
};
|
||||
|
||||
export default function MainHeroSection({
|
||||
title,
|
||||
cta_buttons,
|
||||
sub_title,
|
||||
}: Props) {
|
||||
return (
|
||||
<Section className="mt-hero-section">
|
||||
<Container className="mt-container flex-col! gap-0">
|
||||
<Span className="htag">{SiteData["SiteName"]}</Span>
|
||||
<H1 className="mt-hero-title">{title}</H1>
|
||||
{sub_title ? (
|
||||
<P className="mt-hero-desc">{sub_title}</P>
|
||||
) : null}
|
||||
{cta_buttons ? (
|
||||
<div className="mt-hero-ctas">{cta_buttons}</div>
|
||||
) : null}
|
||||
</Container>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import ColorSchemeSelector from "@moduletrace/twui/elements/ColorSchemeSelector";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Theme switch row pinned to the bottom of the mobile navs.
|
||||
*/
|
||||
export default function MobileNavThemeRow({ className }: Props) {
|
||||
return (
|
||||
<Row
|
||||
className={twMerge(
|
||||
"w-full justify-between items-center flex-nowrap",
|
||||
"border-t border-slate-200/70 dark:border-white/10",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Span className="text-sm text-slate-500 dark:text-slate-400">
|
||||
Theme
|
||||
</Span>
|
||||
<ColorSchemeSelector
|
||||
className="text-slate-500 hover:text-foreground-light dark:text-slate-400 dark:hover:text-foreground-dark transition-colors"
|
||||
size="small"
|
||||
/>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import Input from "@moduletrace/twui/form/Input";
|
||||
import type { AutocompleteOptions } from "@moduletrace/twui/types";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type Props = {
|
||||
id: string;
|
||||
label: string;
|
||||
autoComplete: (typeof AutocompleteOptions)[number];
|
||||
onValueChange: (value: string) => void;
|
||||
required?: boolean;
|
||||
info?: ReactNode;
|
||||
};
|
||||
|
||||
export default function PasswordInput({
|
||||
id,
|
||||
label,
|
||||
autoComplete,
|
||||
onValueChange,
|
||||
required,
|
||||
info,
|
||||
}: Props) {
|
||||
return (
|
||||
<Input
|
||||
id={id}
|
||||
name={id}
|
||||
type="password"
|
||||
label={label}
|
||||
autoComplete={autoComplete}
|
||||
info={info}
|
||||
onChange={(e) => {
|
||||
onValueChange(e.target.value);
|
||||
}}
|
||||
showLabel
|
||||
required={required}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import Loading from "@moduletrace/twui/elements/Loading";
|
||||
import Center from "@moduletrace/twui/layout/Center";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type Props = {
|
||||
url: string;
|
||||
body: any;
|
||||
download_name?: string;
|
||||
height?: number;
|
||||
};
|
||||
|
||||
export default function PdfPreview({
|
||||
url,
|
||||
body,
|
||||
download_name,
|
||||
height = 1200,
|
||||
}: Props) {
|
||||
const [blob_url, setBlobUrl] = useState<string>();
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
let object_url: string | undefined;
|
||||
let cancelled = false;
|
||||
|
||||
setBlobUrl(undefined);
|
||||
setError(undefined);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body || {}),
|
||||
});
|
||||
|
||||
const content_type = res.headers.get("Content-Type") || "";
|
||||
|
||||
if (content_type.includes("application/json")) {
|
||||
const json = await res.json();
|
||||
throw new Error(json.msg || "Couldn't generate PDF");
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Request failed with status ${res.status}`);
|
||||
}
|
||||
|
||||
const blob = await res.blob();
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
object_url = URL.createObjectURL(blob);
|
||||
setBlobUrl(object_url);
|
||||
} catch (error: any) {
|
||||
if (!cancelled) {
|
||||
setError(error.message || "Couldn't generate PDF preview");
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
|
||||
if (object_url) {
|
||||
URL.revokeObjectURL(object_url);
|
||||
}
|
||||
};
|
||||
}, [url]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Center className="py-10">
|
||||
<Span className="text-warning">{error}</Span>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (!blob_url) {
|
||||
return (
|
||||
<Center className="py-10">
|
||||
<Loading />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col gap-2 items-end">
|
||||
{download_name ? (
|
||||
<a
|
||||
href={blob_url}
|
||||
download={download_name}
|
||||
className="text-sm text-link-500 hover:opacity-70 underline"
|
||||
>
|
||||
Download PDF
|
||||
</a>
|
||||
) : null}
|
||||
<iframe
|
||||
src={blob_url}
|
||||
title="PDF Preview"
|
||||
className="w-full rounded-default border border-slate-300 dark:border-white/20 bg-white"
|
||||
style={{ height }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Executable
+42
@@ -0,0 +1,42 @@
|
||||
import Img from "@moduletrace/twui/layout/Img";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
|
||||
type Props = {
|
||||
src: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
alt?: string;
|
||||
};
|
||||
|
||||
export default function RectImage({
|
||||
src,
|
||||
height = 300,
|
||||
width = 200,
|
||||
alt,
|
||||
}: Props) {
|
||||
const img_component = (
|
||||
<Img
|
||||
alt={alt || "image"}
|
||||
src={src}
|
||||
className={twMerge(`w-full h-full object-cover`)}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack
|
||||
className={twMerge(`overflow-hidden hidden xl:flex`)}
|
||||
style={{
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
}}
|
||||
>
|
||||
{img_component}
|
||||
</Stack>
|
||||
<Stack className={twMerge(`overflow-hidden flex xl:hidden w-full`)}>
|
||||
{img_component}
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import type useFormInit from "@/src/hooks/use-form-init";
|
||||
import resendLoginCode from "@/src/functions/frontend/resend-login-code";
|
||||
|
||||
type Props = Pick<
|
||||
ReturnType<typeof useFormInit>,
|
||||
"loading" | "setLoading" | "setStatus"
|
||||
>;
|
||||
|
||||
export default function ResendCodeButton({
|
||||
loading,
|
||||
setLoading,
|
||||
setStatus,
|
||||
}: Props) {
|
||||
return (
|
||||
<Row className="w-full justify-center gap-1.5 text-sm">
|
||||
<Span variant="faded">Didn't get a code?</Span>
|
||||
<Button
|
||||
title="Resend code"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="small"
|
||||
loading={loading}
|
||||
onClick={() => {
|
||||
resendLoginCode({ setLoading, setStatus });
|
||||
}}
|
||||
>
|
||||
Resend code
|
||||
</Button>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import LucideIcon from "@moduletrace/twui/elements/lucide-icon";
|
||||
import Input from "@moduletrace/twui/form/Input";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Paper from "@moduletrace/twui/elements/Paper";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Border from "@moduletrace/twui/elements/Border";
|
||||
import { formatNaira } from "@/src/utils/money";
|
||||
import twuiNumberfy from "@moduletrace/twui/utils/numberfy";
|
||||
|
||||
export type SortableLineItemFormObject = {
|
||||
description?: string;
|
||||
quantity?: string;
|
||||
/** Naira amount as a plain number string (converted to kobo on submit). */
|
||||
unit_price?: string;
|
||||
/** Client-side row identity for stable React keys during reorder. */
|
||||
_key?: string;
|
||||
};
|
||||
|
||||
type Props<T extends SortableLineItemFormObject> = {
|
||||
item: T;
|
||||
index: number;
|
||||
placeholder: string;
|
||||
is_dragging: boolean;
|
||||
is_drop_target: boolean;
|
||||
/** Whether a drag is currently in progress anywhere in the list. */
|
||||
drag_active: boolean;
|
||||
can_move_up: boolean;
|
||||
can_move_down: boolean;
|
||||
onChange: (item: T) => void;
|
||||
onRemove: () => void;
|
||||
onMoveUp: () => void;
|
||||
onMoveDown: () => void;
|
||||
onDragStart: () => void;
|
||||
onDragOver: () => void;
|
||||
onDragLeave: () => void;
|
||||
/** `drop_after` is true when dropped on the lower half of this row. */
|
||||
onDrop: (drop_after: boolean) => void;
|
||||
onDragEnd: () => void;
|
||||
};
|
||||
|
||||
export default function SortableLineItemRow<
|
||||
T extends SortableLineItemFormObject,
|
||||
>({
|
||||
item,
|
||||
index,
|
||||
placeholder,
|
||||
is_dragging,
|
||||
is_drop_target,
|
||||
drag_active,
|
||||
can_move_up,
|
||||
can_move_down,
|
||||
onChange,
|
||||
onRemove,
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
onDragStart,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDrop,
|
||||
onDragEnd,
|
||||
}: Props<T>) {
|
||||
const line_total_kobo =
|
||||
(Number(item.quantity) || 0) * (Number(item.unit_price) || 0) * 100;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
className={[
|
||||
"w-full gap-3!",
|
||||
is_dragging && "opacity-60",
|
||||
is_drop_target && "ring-2 ring-primary/70",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
onDragOver={(e) => {
|
||||
if (!drag_active) return;
|
||||
e.preventDefault();
|
||||
onDragOver();
|
||||
}}
|
||||
onDragLeave={() => {
|
||||
if (drag_active) onDragLeave();
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
if (!drag_active) return;
|
||||
e.preventDefault();
|
||||
const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
|
||||
onDrop(e.clientY - rect.top > rect.height / 2);
|
||||
}}
|
||||
>
|
||||
<Row className="w-full justify-between items-center gap-2!">
|
||||
<Row className="gap-2! items-center">
|
||||
<Span
|
||||
title="Drag to reorder"
|
||||
className="cursor-grab select-none p-1 -m-1!"
|
||||
draggable
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
<LucideIcon name="GripVertical" size={16} />
|
||||
</Span>
|
||||
<Span className="text-xs font-semibold opacity-60">
|
||||
Item #{index + 1}
|
||||
</Span>
|
||||
</Row>
|
||||
<Row className="gap-1! items-center">
|
||||
<Button
|
||||
title="Move Up"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
size="smaller"
|
||||
disabled={!can_move_up}
|
||||
onClick={onMoveUp}
|
||||
>
|
||||
<LucideIcon name="ArrowUp" size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
title="Move Down"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
size="smaller"
|
||||
disabled={!can_move_down}
|
||||
onClick={onMoveDown}
|
||||
>
|
||||
<LucideIcon name="ArrowDown" size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
title="Remove Line Item"
|
||||
variant="ghost"
|
||||
color="error"
|
||||
size="smaller"
|
||||
onClick={onRemove}
|
||||
>
|
||||
<LucideIcon name="Trash2" size={14} />
|
||||
</Button>
|
||||
</Row>
|
||||
</Row>
|
||||
|
||||
<Input
|
||||
title="Description"
|
||||
placeholder={placeholder}
|
||||
defaultValue={item.description}
|
||||
onChange={(e) => {
|
||||
onChange({ ...item, description: e.target.value });
|
||||
}}
|
||||
showLabel
|
||||
/>
|
||||
|
||||
<Row className="w-full grid xl:grid-cols-3 items-stretch">
|
||||
<Input
|
||||
title="Quantity"
|
||||
type="number"
|
||||
min={1}
|
||||
defaultValue={item.quantity}
|
||||
onChange={(e) => {
|
||||
onChange({ ...item, quantity: e.target.value });
|
||||
}}
|
||||
showLabel
|
||||
/>
|
||||
|
||||
<Input
|
||||
title="Unit Price (₦)"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min={0}
|
||||
defaultValue={item.unit_price}
|
||||
onChange={(e) => {
|
||||
onChange({
|
||||
...item,
|
||||
unit_price: Math.round(
|
||||
twuiNumberfy(e.target.value),
|
||||
).toString(),
|
||||
});
|
||||
}}
|
||||
showLabel
|
||||
/>
|
||||
|
||||
<Border className="py-1 px-4">
|
||||
<Span className="text-sm font-semibold">
|
||||
{formatNaira(line_total_kobo)}
|
||||
</Span>
|
||||
</Border>
|
||||
</Row>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import H3 from "@moduletrace/twui/layout/H3";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { useEffect, useState } from "react";
|
||||
import { formatNaira, sumInvoiceItemsKobo } from "@/src/utils/money";
|
||||
import SortableLineItemRow, {
|
||||
type SortableLineItemFormObject,
|
||||
} from "./sortable-line-item-row";
|
||||
|
||||
let row_key_counter = 0;
|
||||
|
||||
export const makeRowKey = (): string =>
|
||||
typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID()
|
||||
: `row-${Date.now()}-${row_key_counter++}`;
|
||||
|
||||
type Props<T extends SortableLineItemFormObject> = {
|
||||
items: T[];
|
||||
empty_item: T;
|
||||
item_placeholder: string;
|
||||
onChange: (items: T[]) => void;
|
||||
};
|
||||
|
||||
export default function SortableLineItemsEditor<
|
||||
T extends SortableLineItemFormObject,
|
||||
>({ items, empty_item, item_placeholder, onChange }: Props<T>) {
|
||||
const [drag_source, set_drag_source] = useState<number | undefined>();
|
||||
const [drag_target, set_drag_target] = useState<number | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
if (items.some((item) => !item._key)) {
|
||||
onChange(
|
||||
items.map((item) => ({ ...item, _key: makeRowKey() })),
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const display_items = items.length ? items : [empty_item];
|
||||
const can_remove = items.length > 1;
|
||||
|
||||
const setItems = (next_items: T[]) => onChange(next_items);
|
||||
|
||||
const endDrag = () => {
|
||||
set_drag_source(undefined);
|
||||
set_drag_target(undefined);
|
||||
};
|
||||
|
||||
const moveTo = (from: number | undefined, target: number, drop_after: boolean) => {
|
||||
if (from === undefined || from === target) return;
|
||||
|
||||
let to = drop_after ? target + 1 : target;
|
||||
if (from < to) to -= 1;
|
||||
if (to === from) return;
|
||||
|
||||
const next_items = items.length ? [...items] : [{ ...empty_item }];
|
||||
const moved_item: T = next_items[from] as T;
|
||||
next_items.splice(from, 1);
|
||||
next_items.splice(to, 0, moved_item);
|
||||
setItems(next_items);
|
||||
};
|
||||
|
||||
const moveBy = (index: number, delta: -1 | 1) => {
|
||||
const target = index + delta;
|
||||
if (target < 0 || target >= (items.length ? items.length : 1)) return;
|
||||
moveTo(index, target, delta === 1);
|
||||
};
|
||||
|
||||
const total_kobo = sumInvoiceItemsKobo(
|
||||
items.map((item) => ({
|
||||
quantity: item.quantity,
|
||||
unit_price_kobo: (Number(item.unit_price) || 0) * 100,
|
||||
})),
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack className="w-full gap-4">
|
||||
<H3 className="!mb-0">Line Items</H3>
|
||||
|
||||
{display_items.map((item, index) => (
|
||||
<SortableLineItemRow
|
||||
key={item._key || index}
|
||||
item={item}
|
||||
index={index}
|
||||
placeholder={item_placeholder}
|
||||
is_dragging={drag_source === index}
|
||||
is_drop_target={
|
||||
drag_source !== undefined &&
|
||||
drag_source !== index &&
|
||||
drag_target === index
|
||||
}
|
||||
drag_active={drag_source !== undefined}
|
||||
can_move_up={index > 0}
|
||||
can_move_down={index < items.length - 1}
|
||||
onChange={(next_item) => {
|
||||
const next_items = items.length
|
||||
? [...items]
|
||||
: [{ ...empty_item }];
|
||||
next_items[index] = next_item;
|
||||
setItems(next_items);
|
||||
}}
|
||||
onRemove={() => {
|
||||
if (!can_remove) return;
|
||||
setItems(items.filter((_, i) => i !== index));
|
||||
}}
|
||||
onMoveUp={() => moveBy(index, -1)}
|
||||
onMoveDown={() => moveBy(index, 1)}
|
||||
onDragStart={() => {
|
||||
if (items.length < 2 || drag_source !== undefined) return;
|
||||
set_drag_source(index);
|
||||
}}
|
||||
onDragOver={() => {
|
||||
if (
|
||||
drag_source === undefined ||
|
||||
drag_source === index ||
|
||||
drag_target === index
|
||||
) return;
|
||||
set_drag_target(index);
|
||||
}}
|
||||
onDragLeave={() => {
|
||||
if (drag_target === index) {
|
||||
set_drag_target(undefined);
|
||||
}
|
||||
}}
|
||||
onDrop={(drop_after) => {
|
||||
moveTo(drag_source, index, drop_after);
|
||||
endDrag();
|
||||
}}
|
||||
onDragEnd={endDrag}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Row className="w-full justify-between items-center">
|
||||
<Button
|
||||
title="Add Line Item"
|
||||
color="gray"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setItems([
|
||||
...items,
|
||||
{ ...empty_item, _key: makeRowKey() },
|
||||
]);
|
||||
}}
|
||||
>
|
||||
Add Line Item
|
||||
</Button>
|
||||
|
||||
<Span className="text-base font-semibold">
|
||||
Total: {formatNaira(total_kobo)}
|
||||
</Span>
|
||||
</Row>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
Executable
+53
@@ -0,0 +1,53 @@
|
||||
import Section from "@moduletrace/twui/layout/Section";
|
||||
import Container from "@moduletrace/twui/layout/Container";
|
||||
import H2 from "@moduletrace/twui/layout/H2";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import type { TWUILucideIconName } from "@moduletrace/twui/elements/lucide-icon";
|
||||
import LucideIcon from "@moduletrace/twui/elements/lucide-icon";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type Props = {
|
||||
tagline: string;
|
||||
icon_name?: TWUILucideIconName;
|
||||
sub_title?: string;
|
||||
post_component?: ReactNode;
|
||||
};
|
||||
|
||||
export default function TaglineSection({
|
||||
tagline,
|
||||
icon_name,
|
||||
sub_title,
|
||||
post_component,
|
||||
}: Props) {
|
||||
/**
|
||||
* Render component
|
||||
*/
|
||||
return (
|
||||
<Section className="bg-secondary relative">
|
||||
<Container className="xl:py-10">
|
||||
<Stack className="w-full items-center gap-6">
|
||||
{icon_name && (
|
||||
<LucideIcon
|
||||
name={icon_name}
|
||||
size={50}
|
||||
className="text-accent"
|
||||
/>
|
||||
)}
|
||||
<H2 className="text-center max-w-4xl xl:text-6xl! text-white">
|
||||
{tagline}
|
||||
</H2>
|
||||
{sub_title ? (
|
||||
<Span
|
||||
className="text-white/80 max-w-4xl text-center"
|
||||
size="larger"
|
||||
>
|
||||
{sub_title}
|
||||
</Span>
|
||||
) : null}
|
||||
{post_component}
|
||||
</Stack>
|
||||
</Container>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Applies the saved color scheme to `<html>` before first paint, so pages
|
||||
* never flash light before twui's `ColorSchemeSelector` mounts. Mirrors the
|
||||
* selector's resolution order: `localStorage.theme`, then the OS preference.
|
||||
*/
|
||||
const ThemeInitScriptSource = `(function () {
|
||||
try {
|
||||
var theme = localStorage.getItem("theme");
|
||||
if (theme !== "dark" && theme !== "light") {
|
||||
theme = window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
}
|
||||
var root = document.documentElement;
|
||||
root.classList.remove("dark", "light");
|
||||
root.classList.add(theme);
|
||||
} catch (e) {}
|
||||
})();`;
|
||||
|
||||
export default function ThemeInitScript() {
|
||||
return (
|
||||
<script dangerouslySetInnerHTML={{ __html: ThemeInitScriptSource }} />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import bunext from "@moduletrace/bunext";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
|
||||
export default async function heartbeat() {
|
||||
const db_alive = await BunSQLite.sql({
|
||||
sql: `SELECT 1 AS alive`,
|
||||
});
|
||||
|
||||
bunext.bunextLog.info(
|
||||
`Cron: database heartbeat ${db_alive.success ? "ok" : "failed"}`,
|
||||
);
|
||||
|
||||
return db_alive;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import bunext from "@moduletrace/bunext";
|
||||
import cronHeartbeat from "./heartbeat";
|
||||
import registerCronJob from "./register-cron-job";
|
||||
|
||||
export default function cron() {
|
||||
if (process.env.NODE_ENV == "production" && !process.env.MAIN_SERVER) {
|
||||
return;
|
||||
}
|
||||
|
||||
bunext.bunextLog.watch(`Running Cron Services ...`);
|
||||
|
||||
registerCronJob({
|
||||
title: "heartbeat",
|
||||
schedule: "@hourly",
|
||||
task: cronHeartbeat,
|
||||
run_on_start: true,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import logEvent, { logError } from "@/src/functions/backend/logger";
|
||||
|
||||
type Params = {
|
||||
title: string;
|
||||
schedule: Bun.CronWithAutocomplete;
|
||||
task: () => Promise<unknown>;
|
||||
run_on_start?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Registers an in-process `Bun.cron` job. Each run is logged first, and
|
||||
* errors are caught and logged so a failing task never crashes the process
|
||||
* (Bun.cron rejections otherwise surface as `unhandledRejection`).
|
||||
* Schedules are interpreted in UTC.
|
||||
*/
|
||||
export default function registerCronJob({
|
||||
title,
|
||||
schedule,
|
||||
task,
|
||||
run_on_start,
|
||||
}: Params) {
|
||||
const run = async () => {
|
||||
try {
|
||||
await logEvent({
|
||||
title: `Cron: ${title}`,
|
||||
category: "cron",
|
||||
plain_text: `CRON: ${title} ...`,
|
||||
});
|
||||
await task();
|
||||
} catch (error) {
|
||||
await logError({
|
||||
title: `Cron: ${title} failed`,
|
||||
category: "cron",
|
||||
error,
|
||||
}).catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
if (run_on_start) {
|
||||
run();
|
||||
}
|
||||
|
||||
return Bun.cron(schedule, run);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { AppData } from "./app-data";
|
||||
import { SiteData } from "./site-data";
|
||||
|
||||
describe("AppData auth constants", () => {
|
||||
test("cookie names are derived from the site slug", () => {
|
||||
expect(AppData["AuthKeyCookieName"]).toBe(
|
||||
`${SiteData["SiteSlug"]}_auth_key`,
|
||||
);
|
||||
expect(AppData["AuthCSRFCookieName"]).toBe(
|
||||
`${SiteData["SiteSlug"]}_auth_csrf`,
|
||||
);
|
||||
expect(AppData["SSOAuthCookieName"]).toBe(
|
||||
`${SiteData["SiteSlug"]}_sso_auth`,
|
||||
);
|
||||
});
|
||||
|
||||
test("expiry and limits are positive", () => {
|
||||
expect(AppData["AuthExpiryDays"]).toBeGreaterThan(0);
|
||||
expect(AppData["SSOCodeExpiryMinutes"]).toBeGreaterThan(0);
|
||||
expect(AppData["DefaultQueryLimit"]).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("has a configured health message", () => {
|
||||
expect(AppData["ServerHealthMessage"]).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { SiteData } from "./site-data";
|
||||
|
||||
export const AppData = {
|
||||
HashCost: 12,
|
||||
SSOAuthCookieName: `${SiteData["SiteSlug"]}_sso_auth`,
|
||||
SSOCodeExpiryMinutes: 15,
|
||||
SSOPendingLoginMaxMinutes: 60,
|
||||
SSOPendingLoginStorageKey: `${SiteData["SiteSlug"]}_sso_pending_login`,
|
||||
ServerHealthMessage: "Server Running Well",
|
||||
AuthKeyCookieName: `${SiteData["SiteSlug"]}_auth_key`,
|
||||
AuthCSRFCookieName: `${SiteData["SiteSlug"]}_auth_csrf`,
|
||||
AuthExpiryDays: 60,
|
||||
APICrudBasePath: "/api/admin/crud",
|
||||
ForcePrivateHeaderKey: `x-${SiteData["SiteSlug"]}-force-private`,
|
||||
ForcePublicHeaderKey: `x-${SiteData["SiteSlug"]}-force-public`,
|
||||
DefaultQueryLimit: 40,
|
||||
MainImageSize: 500,
|
||||
MaxImageWidth: 1500,
|
||||
ImageThumbnailSize: 150,
|
||||
ImageQuality: 85,
|
||||
ImageThumbnailQuality: 70,
|
||||
PaystackEndpoint: "https://api.paystack.co",
|
||||
SSOMaxCodeAttempts: 5,
|
||||
SSOCodeRequestLimit: 5,
|
||||
ContactFormLimit: 5,
|
||||
RateLimitWindowMinutes: 15,
|
||||
UsernameRegex: /^[a-z0-9._-]{3,32}$/,
|
||||
PasswordMinLength: 8,
|
||||
PasswordMaxLength: 128,
|
||||
PasswordAttemptLimit: 10,
|
||||
} as const;
|
||||
@@ -0,0 +1,5 @@
|
||||
export const SiteData = {
|
||||
SiteName: `Bun SQLite Preset`,
|
||||
SiteURL: `http://localhost:7247`,
|
||||
SiteSlug: `bsqlite`,
|
||||
} as const;
|
||||
@@ -0,0 +1,35 @@
|
||||
import { SiteData } from "@/src/data/site-data";
|
||||
|
||||
/**
|
||||
* Global company letterhead defaults.
|
||||
* These prefill sender blocks on new letter/email templates
|
||||
* and appear in the branded footer of generated PDFs.
|
||||
* Per-template fields override these.
|
||||
*
|
||||
* TODO: Replace the placeholder contact details below with the
|
||||
* official company registered address, phone and RC number.
|
||||
*/
|
||||
export const CompanyLetterheadDefaults = {
|
||||
name: SiteData["SiteName"],
|
||||
address_lines: [
|
||||
"Your Company Limited",
|
||||
"No. 1 Example Street, Example City",
|
||||
],
|
||||
phone: "+000 000 000 0000",
|
||||
email: "[email protected]",
|
||||
website: SiteData["SiteURL"],
|
||||
rc_number: "",
|
||||
bank_name: "",
|
||||
ngn_acc_number: "",
|
||||
} as const;
|
||||
|
||||
export type CompanyLetterhead = {
|
||||
name?: string;
|
||||
address_lines?: readonly string[];
|
||||
phone?: string;
|
||||
email?: string;
|
||||
website?: string;
|
||||
rc_number?: string;
|
||||
bank_name?: string;
|
||||
ngn_acc_number?: string;
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
export const EmailStatuses = [
|
||||
{
|
||||
title: "Draft",
|
||||
value: "draft",
|
||||
},
|
||||
{
|
||||
title: "Sent",
|
||||
value: "sent",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export type EmailStatus = (typeof EmailStatuses)[number]["value"];
|
||||
|
||||
export const EmailAttachmentTypes = [
|
||||
{
|
||||
title: "Letter",
|
||||
value: "letter",
|
||||
},
|
||||
{
|
||||
title: "Invoice",
|
||||
value: "invoice",
|
||||
},
|
||||
{
|
||||
title: "Quote",
|
||||
value: "quote",
|
||||
},
|
||||
{
|
||||
title: "File",
|
||||
value: "file",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export type EmailAttachmentType = (typeof EmailAttachmentTypes)[number]["value"];
|
||||
|
||||
export const InvoiceStatuses = [
|
||||
{
|
||||
title: "Draft",
|
||||
value: "draft",
|
||||
},
|
||||
{
|
||||
title: "Issued",
|
||||
value: "issued",
|
||||
},
|
||||
{
|
||||
title: "Paid",
|
||||
value: "paid",
|
||||
},
|
||||
{
|
||||
title: "Cancelled",
|
||||
value: "cancelled",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export type InvoiceStatus = (typeof InvoiceStatuses)[number]["value"];
|
||||
|
||||
export const QuoteStatuses = [
|
||||
{
|
||||
title: "Draft",
|
||||
value: "draft",
|
||||
},
|
||||
{
|
||||
title: "Sent",
|
||||
value: "sent",
|
||||
},
|
||||
{
|
||||
title: "Accepted",
|
||||
value: "accepted",
|
||||
},
|
||||
{
|
||||
title: "Declined",
|
||||
value: "declined",
|
||||
},
|
||||
{
|
||||
title: "Expired",
|
||||
value: "expired",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export type QuoteStatus = (typeof QuoteStatuses)[number]["value"];
|
||||
@@ -0,0 +1,22 @@
|
||||
export const LogCategories = [
|
||||
{
|
||||
title: "Auth",
|
||||
value: "auth",
|
||||
},
|
||||
{
|
||||
title: "WebSocket",
|
||||
value: "websocket",
|
||||
},
|
||||
{
|
||||
title: "Cron",
|
||||
value: "cron",
|
||||
},
|
||||
{
|
||||
title: "System",
|
||||
value: "system",
|
||||
},
|
||||
{
|
||||
title: "Error",
|
||||
value: "error",
|
||||
},
|
||||
] as const;
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { TwuiHeaderLink } from "@moduletrace/twui/elements/HeaderNav";
|
||||
|
||||
export const MainLinks: TwuiHeaderLink[] = [
|
||||
{
|
||||
title: "Features",
|
||||
url: "/#features",
|
||||
},
|
||||
{
|
||||
title: "About",
|
||||
url: "/about",
|
||||
},
|
||||
{
|
||||
title: "Contact",
|
||||
url: "/contact",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,29 @@
|
||||
export const MediaTypes = [
|
||||
{
|
||||
title: "Image",
|
||||
value: "image",
|
||||
},
|
||||
{
|
||||
title: "Video",
|
||||
value: "video",
|
||||
},
|
||||
{
|
||||
title: "Document",
|
||||
value: "document",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const MediaParadigms = [
|
||||
{
|
||||
title: "User Profile Image",
|
||||
value: "user-profile-image",
|
||||
},
|
||||
{
|
||||
title: "Generic",
|
||||
value: "generic",
|
||||
},
|
||||
{
|
||||
title: "Event",
|
||||
value: "event",
|
||||
},
|
||||
] as const;
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { TableType } from "@/src/types";
|
||||
|
||||
/**
|
||||
* Tables holding secrets (password hashes, pending login codes). They are
|
||||
* never reachable through the admin CRUD API, not even by super admins.
|
||||
*/
|
||||
export const PrivateTables: TableType[] = ["user_passwords", "sso_login_codes"];
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
RolesAdminCantUpdate,
|
||||
SuperAdminOnlyEditableUserTypes,
|
||||
UneditableUserTypes,
|
||||
UserTypes,
|
||||
UserTypeAssignmentMatrix,
|
||||
UserTypeDeletionMatrix,
|
||||
} from "./user-types-dict";
|
||||
|
||||
describe("UserTypes dict", () => {
|
||||
test("has unique values", () => {
|
||||
const values = UserTypes.map((ut) => ut.value);
|
||||
expect(new Set(values).size).toBe(values.length);
|
||||
});
|
||||
|
||||
test("contains required core types", () => {
|
||||
const values = new Set(UserTypes.map((ut) => ut.value));
|
||||
const required = [
|
||||
"super_admin",
|
||||
"admin",
|
||||
"revoked",
|
||||
"read_only",
|
||||
] as const;
|
||||
for (const type of required) {
|
||||
expect(values.has(type)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("super_admin is uneditable and excluded from admin updates", () => {
|
||||
expect(UneditableUserTypes).toContain("super_admin");
|
||||
expect(RolesAdminCantUpdate).toContain("super_admin");
|
||||
expect(SuperAdminOnlyEditableUserTypes).toContain("revoked");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Assignment matrices", () => {
|
||||
test("cover every user type", () => {
|
||||
for (const ut of UserTypes) {
|
||||
expect(UserTypeAssignmentMatrix[ut.value]).toBeDefined();
|
||||
expect(UserTypeDeletionMatrix[ut.value]).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
test("super_admin is only assignable by pass-through actors", () => {
|
||||
expect(
|
||||
UserTypeAssignmentMatrix["super_admin"].allowed_actors,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
test("elevated types are pass-through only", () => {
|
||||
const elevated = [
|
||||
"admin",
|
||||
"president",
|
||||
"vice_president",
|
||||
"secretary",
|
||||
] as const;
|
||||
for (const type of elevated) {
|
||||
const entry = UserTypeAssignmentMatrix[type];
|
||||
expect(Array.isArray(entry.allowed_actors)).toBe(true);
|
||||
expect((entry.allowed_actors as string[]).length).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
test("admin can manage auditor but not another admin", () => {
|
||||
expect(
|
||||
UserTypeAssignmentMatrix["auditor"].allowed_actors,
|
||||
).toContain("admin");
|
||||
|
||||
expect(
|
||||
(UserTypeAssignmentMatrix["admin"].allowed_actors as string[])
|
||||
.length,
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { UserType, UserTypeAssignmentMatrixType } from "../types";
|
||||
|
||||
export const UserTypes = [
|
||||
{
|
||||
value: "super_admin",
|
||||
},
|
||||
{
|
||||
value: "admin",
|
||||
},
|
||||
{
|
||||
value: "auditor",
|
||||
},
|
||||
{
|
||||
value: "president",
|
||||
},
|
||||
{
|
||||
value: "vice_president",
|
||||
},
|
||||
{
|
||||
value: "secretary",
|
||||
},
|
||||
{
|
||||
value: "revoked",
|
||||
},
|
||||
{
|
||||
value: "read_only",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const SuperAdminOnlyEditableUserTypes: UserType[] = ["revoked"];
|
||||
|
||||
export const UneditableUserTypes: UserType[] = ["super_admin"];
|
||||
|
||||
export const RolesAdminCantUpdate: UserType[] = [
|
||||
"super_admin",
|
||||
"admin",
|
||||
"revoked",
|
||||
];
|
||||
|
||||
export const UserManagementUserTypes: UserType[] = ["admin"];
|
||||
|
||||
/**
|
||||
* Declarative ACL for granting user types.
|
||||
*
|
||||
* Matrix forms:
|
||||
* - `allowed_actors` as an array lists actor types allowed to perform the action.
|
||||
* - `allowed_actors` as a per-actor matrix keys actors to rules:
|
||||
* - `allowed_targets`: the target user must hold at least one of these types.
|
||||
* - `excluded_targets`: the target user must not hold any of these types.
|
||||
* - Entry-level `excluded_targets` blocks the action when the target user
|
||||
* holds any listed type.
|
||||
* - Empty/missing `allowed_actors` means only pass-through actors
|
||||
* (`super_admin`) may perform the action.
|
||||
* - `same_as` clones the rules of another actor type.
|
||||
*/
|
||||
export const UserTypeAssignmentMatrix: UserTypeAssignmentMatrixType = {
|
||||
super_admin: {},
|
||||
admin: {
|
||||
allowed_actors: [],
|
||||
},
|
||||
president: {
|
||||
allowed_actors: [],
|
||||
},
|
||||
vice_president: {
|
||||
allowed_actors: [],
|
||||
},
|
||||
secretary: {
|
||||
allowed_actors: [],
|
||||
},
|
||||
auditor: {
|
||||
allowed_actors: ["admin"],
|
||||
},
|
||||
read_only: {
|
||||
allowed_actors: {
|
||||
admin: {
|
||||
excluded_targets: ["super_admin"],
|
||||
},
|
||||
},
|
||||
},
|
||||
revoked: {
|
||||
allowed_actors: {
|
||||
admin: {
|
||||
allowed_targets: ["read_only"],
|
||||
excluded_targets: [
|
||||
"super_admin",
|
||||
"admin",
|
||||
"president",
|
||||
"vice_president",
|
||||
"secretary",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const UserTypeDeletionMatrix: UserTypeAssignmentMatrixType = {
|
||||
super_admin: {},
|
||||
admin: {
|
||||
allowed_actors: [],
|
||||
},
|
||||
president: {
|
||||
allowed_actors: [],
|
||||
},
|
||||
vice_president: {
|
||||
allowed_actors: [],
|
||||
},
|
||||
secretary: {
|
||||
allowed_actors: [],
|
||||
},
|
||||
auditor: {
|
||||
allowed_actors: ["admin"],
|
||||
},
|
||||
read_only: {
|
||||
allowed_actors: {
|
||||
admin: {
|
||||
excluded_targets: ["super_admin"],
|
||||
},
|
||||
},
|
||||
},
|
||||
revoked: {
|
||||
allowed_actors: {
|
||||
admin: {
|
||||
allowed_targets: ["read_only"],
|
||||
excluded_targets: [
|
||||
"super_admin",
|
||||
"admin",
|
||||
"president",
|
||||
"vice_president",
|
||||
"secretary",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
export const WebsocketRooms = [
|
||||
{
|
||||
title: "Public",
|
||||
value: "public",
|
||||
description: `Public Room for All`,
|
||||
},
|
||||
] as const;
|
||||
@@ -0,0 +1,219 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { CompanyLetterheadDefaults } from "@/src/dict/company-dict";
|
||||
import { SiteData } from "@/src/data/site-data";
|
||||
import { ThemeColors } from "@/src/functions/backend/theme/theme-colors";
|
||||
|
||||
type Props = PropsWithChildren & {};
|
||||
|
||||
const Brand = {
|
||||
ink: ThemeColors.ink,
|
||||
muted: ThemeColors.muted,
|
||||
page: ThemeColors.page,
|
||||
surface: ThemeColors.surface,
|
||||
hairline: ThemeColors.hairline,
|
||||
} as const;
|
||||
|
||||
const font_stack =
|
||||
"-apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji'";
|
||||
|
||||
type FooterLinkProps = {
|
||||
label: string;
|
||||
};
|
||||
|
||||
function FooterLink({ label }: FooterLinkProps) {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
lineHeight: "1.8",
|
||||
color: Brand.muted,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function EmailRoot({ children }: Props) {
|
||||
const footer_links = [
|
||||
CompanyLetterheadDefaults.phone,
|
||||
CompanyLetterheadDefaults.email,
|
||||
CompanyLetterheadDefaults.website,
|
||||
];
|
||||
|
||||
// Absolute URL - email clients cannot resolve site-relative paths.
|
||||
const logo_url = `${SiteData["SiteURL"]}/logos/logo.png`;
|
||||
const logo_size = 40;
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1"
|
||||
/>
|
||||
<meta name="color-scheme" content="light" />
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
<title>{CompanyLetterheadDefaults.name}</title>
|
||||
</head>
|
||||
<body
|
||||
style={{
|
||||
margin: "0",
|
||||
padding: "0",
|
||||
background: Brand.page,
|
||||
fontFamily: font_stack,
|
||||
fontSize: "15px",
|
||||
color: Brand.ink,
|
||||
WebkitTextSizeAdjust: "100%",
|
||||
}}
|
||||
>
|
||||
<table
|
||||
role="presentation"
|
||||
width="100%"
|
||||
cellPadding={0}
|
||||
cellSpacing={0}
|
||||
style={{ background: Brand.page }}
|
||||
bgcolor={Brand.page}
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" style={{ padding: "40px 16px" }}>
|
||||
<table
|
||||
role="presentation"
|
||||
width="100%"
|
||||
cellPadding={0}
|
||||
cellSpacing={0}
|
||||
style={{
|
||||
maxWidth: "600px",
|
||||
background: Brand.surface,
|
||||
border: `1px solid ${Brand.hairline}`,
|
||||
boxShadow:
|
||||
"0 2px 14px rgba(20, 22, 26, 0.08)",
|
||||
}}
|
||||
bgcolor={Brand.surface}
|
||||
>
|
||||
<tbody>
|
||||
<tr
|
||||
aria-hidden
|
||||
style={{
|
||||
background: Brand.ink,
|
||||
height: "6px",
|
||||
fontSize: "1px",
|
||||
}}
|
||||
>
|
||||
<td> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
style={{
|
||||
padding: "26px",
|
||||
}}
|
||||
>
|
||||
<a
|
||||
href={
|
||||
CompanyLetterheadDefaults.website
|
||||
}
|
||||
style={{
|
||||
textDecoration: "none",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "10px",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={logo_url}
|
||||
alt={
|
||||
CompanyLetterheadDefaults.name
|
||||
}
|
||||
width={logo_size}
|
||||
height={logo_size}
|
||||
style={{
|
||||
width: `${logo_size}px`,
|
||||
height: `${logo_size}px`,
|
||||
border: "0",
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontFamily:
|
||||
font_stack,
|
||||
fontSize: "18px",
|
||||
fontWeight: "800",
|
||||
letterSpacing:
|
||||
"-0.2px",
|
||||
color: Brand.ink,
|
||||
}}
|
||||
>
|
||||
{
|
||||
CompanyLetterheadDefaults.name
|
||||
}
|
||||
</span>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
style={{
|
||||
padding: "24px 32px 32px",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
style={{
|
||||
borderTop: `1px solid ${Brand.hairline}`,
|
||||
padding: "20px 32px 22px",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
display: "block",
|
||||
fontFamily: font_stack,
|
||||
fontSize: "13px",
|
||||
fontWeight: "700",
|
||||
color: Brand.ink,
|
||||
}}
|
||||
>
|
||||
{
|
||||
CompanyLetterheadDefaults.name
|
||||
}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
display: "block",
|
||||
fontSize: "12px",
|
||||
lineHeight: "1.7",
|
||||
color: Brand.muted,
|
||||
}}
|
||||
>
|
||||
{CompanyLetterheadDefaults.address_lines.join(
|
||||
" · ",
|
||||
)}
|
||||
</span>
|
||||
{footer_links.map(
|
||||
(label, index) => (
|
||||
<FooterLink
|
||||
key={index}
|
||||
label={
|
||||
index === 0
|
||||
? label
|
||||
: ` · ${label}`
|
||||
}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import checkUserAccess from "./check-user-access";
|
||||
import type { BUN_SQLITE_BSQLITE_USER_TYPES } from "@/db/types/db";
|
||||
|
||||
function makeUserTypes(...types: string[]): BUN_SQLITE_BSQLITE_USER_TYPES[] {
|
||||
return types.map((user_type) => ({
|
||||
user_id: 1,
|
||||
user_type,
|
||||
}));
|
||||
}
|
||||
|
||||
describe("checkUserAccess", () => {
|
||||
test("fails with null, undefined or empty user_types", () => {
|
||||
expect(
|
||||
checkUserAccess({ user_types: null }).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
checkUserAccess({ user_types: undefined }).success,
|
||||
).toBe(false);
|
||||
expect(checkUserAccess({ user_types: [] }).success).toBe(false);
|
||||
});
|
||||
|
||||
test("super_admin always passes", () => {
|
||||
expect(
|
||||
checkUserAccess({
|
||||
user_types: makeUserTypes("super_admin"),
|
||||
includes: ["admin"],
|
||||
}).success,
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
checkUserAccess({
|
||||
user_types: makeUserTypes("super_admin", "revoked"),
|
||||
excludes: ["revoked"],
|
||||
}).success,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("excludes take priority over includes", () => {
|
||||
const res = checkUserAccess({
|
||||
user_types: makeUserTypes("admin"),
|
||||
includes: ["admin"],
|
||||
excludes: ["admin"],
|
||||
});
|
||||
|
||||
expect(res.success).toBe(false);
|
||||
expect(res.msg).toBe(`User Excluded`);
|
||||
});
|
||||
|
||||
test("includes require a match", () => {
|
||||
const res = checkUserAccess({
|
||||
user_types: makeUserTypes("board_member"),
|
||||
includes: ["admin"],
|
||||
});
|
||||
|
||||
expect(res.success).toBe(false);
|
||||
expect(res.msg).toBe(`User not included`);
|
||||
});
|
||||
|
||||
test("includes match with OR semantics", () => {
|
||||
expect(
|
||||
checkUserAccess({
|
||||
user_types: makeUserTypes("president"),
|
||||
includes: ["admin", "president"],
|
||||
}).success,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("fails when no includes or excludes are provided", () => {
|
||||
const res = checkUserAccess({
|
||||
user_types: makeUserTypes("admin"),
|
||||
});
|
||||
|
||||
expect(res.success).toBe(false);
|
||||
expect(res.msg).toBe(`No includes or excludes provided`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { BUN_SQLITE_BSQLITE_USER_TYPES } from "@/db/types/db";
|
||||
import type { UserType } from "@/src/types";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
|
||||
type Params = {
|
||||
includes?: UserType[];
|
||||
excludes?: UserType[];
|
||||
user_types?: BUN_SQLITE_BSQLITE_USER_TYPES[] | null;
|
||||
};
|
||||
|
||||
export default function checkUserAccess({
|
||||
user_types,
|
||||
excludes,
|
||||
includes,
|
||||
}: Params): APIResponseObject {
|
||||
if (!user_types?.[0]) {
|
||||
return {
|
||||
success: false,
|
||||
msg: `No User Types Provided`,
|
||||
};
|
||||
}
|
||||
|
||||
if (user_types.find((t) => t.user_type === "super_admin")) {
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
const userTypeSet = new Set(user_types.map((t) => t.user_type));
|
||||
|
||||
if (excludes) {
|
||||
for (let i = 0; i < excludes.length; i++) {
|
||||
const exclude = excludes[i] as any;
|
||||
if (userTypeSet.has(exclude)) {
|
||||
return {
|
||||
success: false,
|
||||
msg: `User Excluded`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (includes) {
|
||||
const is_user_included = includes.find((i) =>
|
||||
userTypeSet.has(i as any),
|
||||
);
|
||||
|
||||
if (!is_user_included) {
|
||||
return {
|
||||
success: false,
|
||||
msg: `User not included`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!includes?.[0] && !excludes?.[0]) {
|
||||
return {
|
||||
success: false,
|
||||
msg: `No includes or excludes provided`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type {
|
||||
BUN_SQLITE_BSQLITE_USERS,
|
||||
BUN_SQLITE_BSQLITE_USER_TYPES,
|
||||
BunSQLiteTables,
|
||||
} from "@/db/types/db";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
|
||||
type Params = {
|
||||
first_name: string;
|
||||
last_name?: string;
|
||||
email: string;
|
||||
username: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a self-signup account with the default `read_only` user type
|
||||
* and returns its id.
|
||||
*/
|
||||
export default async function createSignupUser({
|
||||
first_name,
|
||||
last_name,
|
||||
email,
|
||||
username,
|
||||
}: Params) {
|
||||
const new_user_res = await BunSQLite.insert<
|
||||
BUN_SQLITE_BSQLITE_USERS,
|
||||
(typeof BunSQLiteTables)[number]
|
||||
>({
|
||||
table: "users",
|
||||
data: [
|
||||
{
|
||||
first_name,
|
||||
last_name: last_name || "",
|
||||
email,
|
||||
username,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
/**
|
||||
* bun-sqlite inserts with `RETURNING *` by default, so the new row
|
||||
* comes back as `singleRes` (with `insertId` as a fallback).
|
||||
*/
|
||||
const new_user_id =
|
||||
new_user_res.singleRes?.id || new_user_res.postInsertReturn?.insertId;
|
||||
|
||||
if (!new_user_id) {
|
||||
throw new AppError(`Couldn't create your account. Please try again.`);
|
||||
}
|
||||
|
||||
const type_res = await BunSQLite.insert<
|
||||
BUN_SQLITE_BSQLITE_USER_TYPES,
|
||||
(typeof BunSQLiteTables)[number]
|
||||
>({
|
||||
table: "user_types",
|
||||
data: [
|
||||
{
|
||||
user_id: new_user_id,
|
||||
user_type: "read_only",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (!type_res.success) {
|
||||
throw new AppError(`Couldn't create your account. Please try again.`);
|
||||
}
|
||||
|
||||
return new_user_id;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type {
|
||||
BUN_SQLITE_BSQLITE_SSO_LOGIN_CODES,
|
||||
BunSQLiteTables,
|
||||
} from "@/db/types/db";
|
||||
|
||||
type Params = {
|
||||
user_id: string | number;
|
||||
};
|
||||
|
||||
export default async function deleteUserSSOCodes({ user_id }: Params) {
|
||||
return await BunSQLite.delete<
|
||||
BUN_SQLITE_BSQLITE_SSO_LOGIN_CODES,
|
||||
(typeof BunSQLiteTables)[number]
|
||||
>({
|
||||
table: "sso_login_codes",
|
||||
query: {
|
||||
query: {
|
||||
user_id: {
|
||||
value: user_id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_BSQLITE_USERS, BunSQLiteTables } from "@/db/types/db";
|
||||
|
||||
type Params = {
|
||||
identifier: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Looks a user up by email or username.
|
||||
*/
|
||||
export default async function findUserByIdentifier({ identifier }: Params) {
|
||||
return (
|
||||
await BunSQLite.select<
|
||||
BUN_SQLITE_BSQLITE_USERS,
|
||||
(typeof BunSQLiteTables)[number]
|
||||
>({
|
||||
table: "users",
|
||||
query: {
|
||||
query: {
|
||||
email: {
|
||||
value: identifier,
|
||||
},
|
||||
username: {
|
||||
value: identifier,
|
||||
},
|
||||
},
|
||||
searchOperator: "OR",
|
||||
},
|
||||
})
|
||||
)?.singleRes;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import generateCSRF from "./gen-csrf";
|
||||
|
||||
describe("generateCSRF", () => {
|
||||
test("returns a non-empty string", () => {
|
||||
expect(generateCSRF().length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("is unique across calls", () => {
|
||||
const a = generateCSRF();
|
||||
const b = generateCSRF();
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
test("produces url-safe base64 characters only", () => {
|
||||
expect(generateCSRF()).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
export default function generateCSRF() {
|
||||
const bytes = new Uint8Array(24);
|
||||
crypto.getRandomValues(bytes);
|
||||
|
||||
let binary = "";
|
||||
for (const byte of bytes) {
|
||||
binary += String.fromCharCode(byte);
|
||||
}
|
||||
|
||||
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { randomInt } from "node:crypto";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_BSQLITE_USERS, BunSQLiteTables } from "@/db/types/db";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
|
||||
type Params = {
|
||||
email: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Derives a free username from an email's local part, adding a numeric
|
||||
* suffix when the plain version is taken.
|
||||
*/
|
||||
export default async function generateUniqueUsername({ email }: Params) {
|
||||
const base = (email.split("@")[0] || "user")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._-]/g, "")
|
||||
.slice(0, 24)
|
||||
.padEnd(3, "0");
|
||||
|
||||
for (let attempt = 0; attempt < 6; attempt++) {
|
||||
const candidate =
|
||||
attempt == 0 ? base : `${base}${randomInt(1000, 100000)}`;
|
||||
|
||||
const existing = (
|
||||
await BunSQLite.select<
|
||||
BUN_SQLITE_BSQLITE_USERS,
|
||||
(typeof BunSQLiteTables)[number]
|
||||
>({
|
||||
table: "users",
|
||||
query: {
|
||||
query: {
|
||||
username: {
|
||||
value: candidate,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
)?.singleRes;
|
||||
|
||||
if (!existing?.id) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
throw new AppError(`Couldn't pick a username. Please sign up with the form.`);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import parseLoginMethod from "@/src/utils/parse-login-method";
|
||||
|
||||
/**
|
||||
* Server-side source of truth for the site-wide login method.
|
||||
*/
|
||||
export default function grabLoginMethod() {
|
||||
return parseLoginMethod({ value: process.env.AUTH_LOGIN_METHOD });
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type {
|
||||
BUN_SQLITE_BSQLITE_USER_TYPES,
|
||||
BunSQLiteTables,
|
||||
} from "@/db/types/db";
|
||||
import type { UserAuthReturn } from "@/src/types";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
};
|
||||
|
||||
export default async function grabUserAuthTypes({
|
||||
user_id,
|
||||
}: Params): Promise<UserAuthReturn> {
|
||||
try {
|
||||
const user_types =
|
||||
(
|
||||
await BunSQLite.select<
|
||||
BUN_SQLITE_BSQLITE_USER_TYPES,
|
||||
(typeof BunSQLiteTables)[number]
|
||||
>({
|
||||
table: "user_types",
|
||||
query: {
|
||||
query: {
|
||||
user_id: {
|
||||
value: user_id,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
).payload || [];
|
||||
|
||||
if (user_types.find((ty) => ty.user_type == "revoked")) {
|
||||
return {
|
||||
success: false,
|
||||
msg: "User Access Revoked",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user_types,
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
msg: error.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type {
|
||||
BUN_SQLITE_BSQLITE_USER_PASSWORDS,
|
||||
BunSQLiteTables,
|
||||
} from "@/db/types/db";
|
||||
|
||||
type Params = {
|
||||
user_id: string | number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Server-only. Password hashes live in their own table so that no user
|
||||
* query can ever return them.
|
||||
*/
|
||||
export default async function grabUserPasswordHash({ user_id }: Params) {
|
||||
const record = (
|
||||
await BunSQLite.select<
|
||||
BUN_SQLITE_BSQLITE_USER_PASSWORDS,
|
||||
(typeof BunSQLiteTables)[number]
|
||||
>({
|
||||
table: "user_passwords",
|
||||
query: {
|
||||
query: {
|
||||
user_id: {
|
||||
value: user_id,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
).singleRes;
|
||||
|
||||
return record?.password_hash || undefined;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type {
|
||||
APIResponseObject,
|
||||
BunextAPIRouteHandler,
|
||||
} from "@moduletrace/bunext/types";
|
||||
import _ from "lodash";
|
||||
import userAuth from "./user-auth";
|
||||
import checkUserAccess from "./check-user-access";
|
||||
import grabUsers from "../db/users/grab-users";
|
||||
|
||||
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
|
||||
req,
|
||||
query,
|
||||
}) => {
|
||||
const { user, user_types } = await userAuth({ req });
|
||||
|
||||
if (!user?.logged_in_status) {
|
||||
return {
|
||||
success: false,
|
||||
msg: `Unauthorized`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const is_admin = checkUserAccess({
|
||||
user_types,
|
||||
includes: ["admin"],
|
||||
});
|
||||
|
||||
if (
|
||||
!is_admin.success &&
|
||||
(!query?.user_id || query.user_id !== user.id)
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
msg: `Unauthorized`,
|
||||
};
|
||||
}
|
||||
|
||||
return await grabUsers({
|
||||
query,
|
||||
});
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
msg: error.message,
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
|
||||
type Params = {
|
||||
password: string;
|
||||
};
|
||||
|
||||
export default async function hashPassword({ password }: Params) {
|
||||
return await Bun.password.hash(password, {
|
||||
algorithm: "bcrypt",
|
||||
cost: AppData["HashCost"],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { randomInt } from "node:crypto";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import { SiteData } from "@/src/data/site-data";
|
||||
import sendEmail from "../email/send-email";
|
||||
import resolveEmailRecipient from "../email/resolve-email-recipient";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { SSOAuth, SSOMethod } from "@/src/types";
|
||||
import { setCookies } from "@/src/utils/cookies";
|
||||
import { encrypt } from "@/src/utils/crypt";
|
||||
import EJSON from "@/src/utils/ejson";
|
||||
import type {
|
||||
APIResponseObject,
|
||||
BunextAPIRouteHandlerObjectReturn,
|
||||
} from "@moduletrace/bunext/types";
|
||||
import type {
|
||||
BUN_SQLITE_BSQLITE_SSO_LOGIN_CODES,
|
||||
BunSQLiteTables,
|
||||
} from "@/db/types/db";
|
||||
|
||||
type Params = {
|
||||
user_id: number;
|
||||
email: string;
|
||||
method: SSOMethod;
|
||||
/**
|
||||
* Carried over on resends so the pending login can't be extended
|
||||
* indefinitely. Defaults to now.
|
||||
*/
|
||||
first_step_at?: number;
|
||||
};
|
||||
|
||||
export default async function issueSSOLoginCode({
|
||||
user_id,
|
||||
email,
|
||||
method,
|
||||
first_step_at,
|
||||
}: Params): Promise<BunextAPIRouteHandlerObjectReturn<APIResponseObject>> {
|
||||
const sso_code = randomInt(0, 1000000).toString().padStart(6, "0");
|
||||
|
||||
const record_sso = await BunSQLite.insert<
|
||||
BUN_SQLITE_BSQLITE_SSO_LOGIN_CODES,
|
||||
(typeof BunSQLiteTables)[number]
|
||||
>({
|
||||
data: [
|
||||
{
|
||||
code: sso_code,
|
||||
user_id,
|
||||
},
|
||||
],
|
||||
table: "sso_login_codes",
|
||||
update_on_duplicate: true,
|
||||
});
|
||||
|
||||
if (!record_sso.success) {
|
||||
return {
|
||||
success: false,
|
||||
msg: `Couldn't issue a login code. Please try again.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV != "production") {
|
||||
console.log(
|
||||
`SSO login code for ${email} (user #${user_id}): ${sso_code}\n` +
|
||||
`Code expires in ${AppData["SSOCodeExpiryMinutes"]} minutes.`,
|
||||
);
|
||||
}
|
||||
|
||||
const is_reset = method == "reset";
|
||||
|
||||
const email_component = (
|
||||
<div>
|
||||
<div>
|
||||
{is_reset
|
||||
? "Use this code to reset your password"
|
||||
: "Use this Code to Complete your login"}
|
||||
</div>
|
||||
<h1>{sso_code}</h1>
|
||||
<div>
|
||||
Code expires in {AppData["SSOCodeExpiryMinutes"]} minutes.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const recipient = resolveEmailRecipient({ to: email });
|
||||
|
||||
if (recipient) {
|
||||
await sendEmail({
|
||||
content: email_component,
|
||||
to: recipient,
|
||||
subject: is_reset
|
||||
? `Use this code to reset your password.`
|
||||
: `Use one-time-code to complete your login.`,
|
||||
text: is_reset
|
||||
? `Reset your ${SiteData["SiteName"]} password`
|
||||
: `Complete your login to the ${SiteData["SiteName"]} portal`,
|
||||
title: `${SiteData["SiteName"]} SSO`,
|
||||
options: {
|
||||
priority: "high",
|
||||
},
|
||||
})
|
||||
.then((mail_res) => {
|
||||
if (!mail_res.success) {
|
||||
console.log(
|
||||
`SSO email to user #${user_id} failed:\n${mail_res.msg}`,
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(
|
||||
`SSO email to user #${user_id} errored:\n${error.message}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const sso_auth: SSOAuth = {
|
||||
user_id,
|
||||
sso_code,
|
||||
email,
|
||||
method,
|
||||
first_step_at: first_step_at || Date.now(),
|
||||
};
|
||||
|
||||
const sso_auth_string = EJSON.stringify(sso_auth);
|
||||
if (!sso_auth_string) {
|
||||
throw new Error(`Couldn't Stringify SSO Auth`);
|
||||
}
|
||||
|
||||
const encrypted_sso_auth_string = await encrypt(sso_auth_string);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
bunext_api_route_res_transform_fn(res: Response) {
|
||||
const new_res = res.clone();
|
||||
setCookies(new_res, [
|
||||
{
|
||||
name: AppData["SSOAuthCookieName"],
|
||||
value: encrypted_sso_auth_string,
|
||||
},
|
||||
]);
|
||||
return new_res;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import { setCookies } from "@/src/utils/cookies";
|
||||
import { encrypt } from "@/src/utils/crypt";
|
||||
import grabUsers from "../db/users/grab-users";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type {
|
||||
BUN_SQLITE_BSQLITE_MEDIA,
|
||||
BUN_SQLITE_BSQLITE_USER_TYPES,
|
||||
BunSQLiteTables,
|
||||
} from "@/db/types/db";
|
||||
import generateCSRF from "./gen-csrf";
|
||||
import type { User } from "@/src/types";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
import grabMediaURL from "../media/grab-media-url";
|
||||
import logEvent, { grabRequestIP } from "../logger";
|
||||
import deleteUserSSOCodes from "./delete-user-sso-codes";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
|
||||
type Params = {
|
||||
user_id: string | number;
|
||||
reauth?: boolean;
|
||||
req?: Request;
|
||||
};
|
||||
|
||||
export default async function loginUser({
|
||||
user_id,
|
||||
reauth,
|
||||
req,
|
||||
}: Params): Promise<Response> {
|
||||
try {
|
||||
const now = Date.now();
|
||||
|
||||
const target_user_res = await grabUsers({
|
||||
query: { user_id },
|
||||
});
|
||||
|
||||
const target_user = target_user_res.singleRes;
|
||||
|
||||
if (!target_user?.id) {
|
||||
throw new AppError(
|
||||
`Couldn't find your account. Please start again.`,
|
||||
);
|
||||
}
|
||||
|
||||
const target_user_types = (
|
||||
await BunSQLite.select<
|
||||
BUN_SQLITE_BSQLITE_USER_TYPES,
|
||||
(typeof BunSQLiteTables)[number]
|
||||
>({
|
||||
table: "user_types",
|
||||
query: { query: { user_id: { value: target_user.id } } },
|
||||
})
|
||||
).payload;
|
||||
|
||||
if (!target_user_types?.[0]) {
|
||||
throw new AppError(`No User types found for this user.`);
|
||||
}
|
||||
|
||||
if (target_user_types.find((ty) => ty.user_type == "revoked")) {
|
||||
throw new AppError(`User access Revoked`);
|
||||
}
|
||||
|
||||
let csrf_key = generateCSRF();
|
||||
|
||||
const profile_media = target_user.profile_media_id
|
||||
? (
|
||||
await BunSQLite.select<
|
||||
BUN_SQLITE_BSQLITE_MEDIA,
|
||||
(typeof BunSQLiteTables)[number]
|
||||
>({
|
||||
table: "media",
|
||||
targetId: target_user.profile_media_id,
|
||||
})
|
||||
).singleRes
|
||||
: undefined;
|
||||
|
||||
const { media_url, media_thumbnail_url } = profile_media?.id
|
||||
? grabMediaURL({ media: profile_media })
|
||||
: {};
|
||||
|
||||
const new_logged_in_user: User = {
|
||||
id: target_user.id,
|
||||
csrf: csrf_key,
|
||||
date: now,
|
||||
first_name: target_user.first_name || "",
|
||||
last_name: target_user.last_name || "",
|
||||
email: target_user.email || "",
|
||||
image: media_url,
|
||||
image_thumbnail: media_thumbnail_url,
|
||||
verification_status: 1,
|
||||
logged_in_status: true,
|
||||
roles: target_user.user_types || "none",
|
||||
};
|
||||
|
||||
const user_payload_json = JSON.stringify(new_logged_in_user);
|
||||
const encrypted_key = await encrypt(user_payload_json);
|
||||
|
||||
const res_obj: APIResponseObject = {
|
||||
success: true,
|
||||
singleRes: new_logged_in_user,
|
||||
};
|
||||
|
||||
const res = new Response(JSON.stringify(res_obj), {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const maxAge = AppData["AuthExpiryDays"] * 24 * 60 * 60;
|
||||
|
||||
setCookies(res, [
|
||||
{
|
||||
name: AppData["AuthKeyCookieName"],
|
||||
value: encrypted_key,
|
||||
maxAge,
|
||||
},
|
||||
{
|
||||
name: AppData["AuthCSRFCookieName"],
|
||||
value: csrf_key,
|
||||
maxAge,
|
||||
},
|
||||
]);
|
||||
|
||||
await deleteUserSSOCodes({ user_id: new_logged_in_user.id });
|
||||
|
||||
if (!reauth) {
|
||||
await logEvent({
|
||||
title: "User logged in",
|
||||
category: "auth",
|
||||
user_id: new_logged_in_user.id,
|
||||
plain_text: `User #${new_logged_in_user.id} [${new_logged_in_user.first_name} ${new_logged_in_user.last_name}] logged in successfully.`,
|
||||
metadata: {
|
||||
user_id: new_logged_in_user.id,
|
||||
reauth: false,
|
||||
},
|
||||
ip_address: grabRequestIP(req),
|
||||
});
|
||||
}
|
||||
|
||||
return res;
|
||||
} catch (error: any) {
|
||||
await logEvent({
|
||||
title: "Login failed",
|
||||
category: "auth",
|
||||
plain_text: `Login failed for user_id ${user_id}: ${error.message}`,
|
||||
metadata: {
|
||||
user_id,
|
||||
},
|
||||
ip_address: grabRequestIP(req),
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type {
|
||||
BUN_SQLITE_BSQLITE_USER_PASSWORDS,
|
||||
BunSQLiteTables,
|
||||
} from "@/db/types/db";
|
||||
import hashPassword from "./hash-password";
|
||||
import deleteUserSSOCodes from "./delete-user-sso-codes";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
|
||||
type Params = {
|
||||
user_id: number;
|
||||
password: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates or replaces the user's password. Outstanding login codes are
|
||||
* revoked, since a code issued before the change would skip the new
|
||||
* password.
|
||||
*/
|
||||
export default async function setUserPassword({ user_id, password }: Params) {
|
||||
const password_hash = await hashPassword({ password });
|
||||
|
||||
const res = await BunSQLite.insert<
|
||||
BUN_SQLITE_BSQLITE_USER_PASSWORDS,
|
||||
(typeof BunSQLiteTables)[number]
|
||||
>({
|
||||
table: "user_passwords",
|
||||
data: [
|
||||
{
|
||||
user_id,
|
||||
password_hash,
|
||||
},
|
||||
],
|
||||
update_on_duplicate: true,
|
||||
});
|
||||
|
||||
if (!res.success) {
|
||||
throw new AppError(`Couldn't save your password. Please try again.`);
|
||||
}
|
||||
|
||||
await deleteUserSSOCodes({ user_id });
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import { getCookie } from "@/src/utils/cookies";
|
||||
import { decrypt } from "@/src/utils/crypt";
|
||||
import { safeCompare } from "@/src/utils/safe-compare";
|
||||
import grabUserAuthTypes from "./grab-user-auth-types";
|
||||
import type { User, UserAuthReturn } from "@/src/types";
|
||||
|
||||
type Params = {
|
||||
req: Request;
|
||||
};
|
||||
|
||||
export default async function userAuth({
|
||||
req,
|
||||
}: Params): Promise<UserAuthReturn> {
|
||||
try {
|
||||
const auth_key_cookie = getCookie(req, AppData["AuthKeyCookieName"]);
|
||||
const auth_csrf_cookie = getCookie(req, AppData["AuthCSRFCookieName"]);
|
||||
const now = Date.now();
|
||||
|
||||
if (!auth_key_cookie) throw new Error(`Auth Key not found!`);
|
||||
|
||||
if (!auth_csrf_cookie) throw new Error(`Auth CSRF not found!`);
|
||||
|
||||
const decrypted_key = await decrypt(auth_key_cookie);
|
||||
const decrypted_user_payload = JSON.parse(decrypted_key) as User;
|
||||
|
||||
if (!decrypted_user_payload?.id) throw new Error(`Invalid Key`);
|
||||
if (!decrypted_user_payload.date) throw new Error(`Key has no date!`);
|
||||
|
||||
if (!decrypted_user_payload.csrf) throw new Error(`Key has no CSRF!`);
|
||||
|
||||
if (!safeCompare(decrypted_user_payload.csrf, auth_csrf_cookie)) {
|
||||
throw new Error(`CSRF Mismatch`);
|
||||
}
|
||||
|
||||
const time_elapsed = now - decrypted_user_payload.date;
|
||||
const expiry_time = AppData["AuthExpiryDays"] * 24 * 60 * 60 * 1000;
|
||||
|
||||
if (time_elapsed > expiry_time) {
|
||||
throw new Error(`Key Expired`);
|
||||
}
|
||||
|
||||
const user_auth_types = await grabUserAuthTypes({
|
||||
user_id: decrypted_user_payload.id,
|
||||
});
|
||||
|
||||
if (!user_auth_types.success) {
|
||||
return user_auth_types;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: decrypted_user_payload,
|
||||
user_types: user_auth_types.user_types,
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
msg: error.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import AppError from "@/src/utils/app-error";
|
||||
|
||||
type GoogleJWTHeader = {
|
||||
alg?: string;
|
||||
kid?: string;
|
||||
typ?: string;
|
||||
};
|
||||
|
||||
type GoogleJWKS = {
|
||||
keys?: GoogleJWK[];
|
||||
};
|
||||
|
||||
type GoogleJWK = JsonWebKey & {
|
||||
kid?: string;
|
||||
};
|
||||
|
||||
type GoogleIDTokenPayload = {
|
||||
aud?: string | string[];
|
||||
email?: string;
|
||||
email_verified?: boolean | string;
|
||||
exp?: number | string;
|
||||
family_name?: string;
|
||||
given_name?: string;
|
||||
iss?: string;
|
||||
name?: string;
|
||||
picture?: string;
|
||||
sub?: string;
|
||||
};
|
||||
|
||||
type Params = {
|
||||
clientId: string;
|
||||
idToken: string;
|
||||
};
|
||||
|
||||
type GoogleIDTokenUser = Required<Pick<GoogleIDTokenPayload, "email" | "sub">> &
|
||||
Pick<
|
||||
GoogleIDTokenPayload,
|
||||
"name" | "given_name" | "family_name" | "picture"
|
||||
>;
|
||||
|
||||
const GoogleJWKSUrl = "https://www.googleapis.com/oauth2/v3/certs";
|
||||
|
||||
let cachedGoogleKeys: {
|
||||
expiresAt: number;
|
||||
keys: GoogleJWK[];
|
||||
} | null = null;
|
||||
|
||||
function decodeBase64Url(value: string) {
|
||||
const normalizedValue = value.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const padding = normalizedValue.length % 4;
|
||||
const paddedValue =
|
||||
padding === 0
|
||||
? normalizedValue
|
||||
: normalizedValue.padEnd(
|
||||
normalizedValue.length + (4 - padding),
|
||||
"=",
|
||||
);
|
||||
|
||||
return Buffer.from(paddedValue, "base64");
|
||||
}
|
||||
|
||||
function parseGoogleTokenPart<T>(value: string): T {
|
||||
return JSON.parse(decodeBase64Url(value).toString("utf8")) as T;
|
||||
}
|
||||
|
||||
async function getGoogleSigningKeys() {
|
||||
if (cachedGoogleKeys && cachedGoogleKeys.expiresAt > Date.now()) {
|
||||
return cachedGoogleKeys.keys;
|
||||
}
|
||||
|
||||
const response = await fetch(GoogleJWKSUrl);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Couldn't fetch Google's signing keys`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as GoogleJWKS;
|
||||
|
||||
if (!data.keys?.length) {
|
||||
throw new Error(`Google didn't return any signing keys`);
|
||||
}
|
||||
|
||||
const maxAge =
|
||||
Number(
|
||||
response.headers.get("cache-control")?.match(/max-age=(\d+)/)?.[1],
|
||||
) || 300;
|
||||
|
||||
cachedGoogleKeys = {
|
||||
expiresAt: Date.now() + maxAge * 1000,
|
||||
keys: data.keys,
|
||||
};
|
||||
|
||||
return data.keys;
|
||||
}
|
||||
|
||||
function hasMatchingAudience(
|
||||
aud: GoogleIDTokenPayload["aud"],
|
||||
clientId: string,
|
||||
) {
|
||||
if (Array.isArray(aud)) {
|
||||
return aud.includes(clientId);
|
||||
}
|
||||
|
||||
return aud === clientId;
|
||||
}
|
||||
|
||||
export default async function verifyGoogleIdToken({
|
||||
clientId,
|
||||
idToken,
|
||||
}: Params): Promise<GoogleIDTokenUser> {
|
||||
const tokenParts = idToken.split(".");
|
||||
|
||||
if (tokenParts.length !== 3) {
|
||||
throw new AppError(`Invalid Google token`);
|
||||
}
|
||||
|
||||
const [encodedHeader, encodedPayload, encodedSignature] = tokenParts;
|
||||
|
||||
if (!encodedHeader) {
|
||||
throw new Error(`No encodedHeader`);
|
||||
}
|
||||
|
||||
if (!encodedPayload) {
|
||||
throw new Error(`No encodedPayload`);
|
||||
}
|
||||
|
||||
if (!encodedSignature) {
|
||||
throw new Error(`No encodedSignature`);
|
||||
}
|
||||
|
||||
const header = parseGoogleTokenPart<GoogleJWTHeader>(encodedHeader);
|
||||
const payload = parseGoogleTokenPart<GoogleIDTokenPayload>(encodedPayload);
|
||||
|
||||
if (header.alg !== "RS256" || !header.kid) {
|
||||
throw new Error(`Unsupported Google token signature`);
|
||||
}
|
||||
|
||||
const signingKeys = await getGoogleSigningKeys();
|
||||
const signingKey = signingKeys.find((key) => key.kid === header.kid);
|
||||
|
||||
if (!signingKey) {
|
||||
throw new Error(`Unknown Google signing key`);
|
||||
}
|
||||
|
||||
const importedKey = await crypto.subtle.importKey(
|
||||
"jwk",
|
||||
signingKey,
|
||||
{
|
||||
name: "RSASSA-PKCS1-v1_5",
|
||||
hash: "SHA-256",
|
||||
},
|
||||
false,
|
||||
["verify"],
|
||||
);
|
||||
|
||||
const isValidSignature = await crypto.subtle.verify(
|
||||
"RSASSA-PKCS1-v1_5",
|
||||
importedKey,
|
||||
decodeBase64Url(encodedSignature),
|
||||
new TextEncoder().encode(`${encodedHeader}.${encodedPayload}`),
|
||||
);
|
||||
|
||||
if (!isValidSignature) {
|
||||
throw new Error(`Invalid Google token signature`);
|
||||
}
|
||||
|
||||
if (!hasMatchingAudience(payload.aud, clientId)) {
|
||||
throw new AppError(`Google token was issued for a different app`);
|
||||
}
|
||||
|
||||
if (
|
||||
payload.iss !== "https://accounts.google.com" &&
|
||||
payload.iss !== "accounts.google.com"
|
||||
) {
|
||||
throw new AppError(`Invalid Google token issuer`);
|
||||
}
|
||||
|
||||
const expiresAt = Number(payload.exp || 0) * 1000;
|
||||
|
||||
if (!expiresAt || expiresAt <= Date.now()) {
|
||||
throw new AppError(`Google token has expired`);
|
||||
}
|
||||
|
||||
const emailVerified =
|
||||
payload.email_verified === true || payload.email_verified === "true";
|
||||
|
||||
if (!payload.email || !emailVerified || !payload.sub) {
|
||||
throw new AppError(`Google account email is not verified`);
|
||||
}
|
||||
|
||||
return {
|
||||
email: payload.email,
|
||||
sub: payload.sub,
|
||||
name: payload.name,
|
||||
given_name: payload.given_name,
|
||||
family_name: payload.family_name,
|
||||
picture: payload.picture,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import type {
|
||||
BUN_SQLITE_BSQLITE_SSO_LOGIN_CODES,
|
||||
BunSQLiteTables,
|
||||
} from "@/db/types/db";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import type { SSOAuth, SSOMethod } from "@/src/types";
|
||||
import { getCookie } from "@/src/utils/cookies";
|
||||
import { decrypt } from "@/src/utils/crypt";
|
||||
import EJSON from "@/src/utils/ejson";
|
||||
import { safeCompare } from "@/src/utils/safe-compare";
|
||||
import rateLimit, { resetRateLimit } from "@/src/utils/rate-limit";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import deleteUserSSOCodes from "./delete-user-sso-codes";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
|
||||
type Params = {
|
||||
req: Request;
|
||||
sso_code?: string | number;
|
||||
/**
|
||||
* The code's issuing method must match, e.g. a reset code can't
|
||||
* complete a login.
|
||||
*/
|
||||
method: SSOMethod;
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks an emailed code against the pending login in the SSO cookie.
|
||||
* Returns the user id on success, throws a user-facing error otherwise.
|
||||
*/
|
||||
export default async function verifySSOCode({
|
||||
req,
|
||||
sso_code,
|
||||
method,
|
||||
}: Params) {
|
||||
const sso_cookie = getCookie(req, AppData["SSOAuthCookieName"]);
|
||||
|
||||
if (!sso_cookie) {
|
||||
throw new AppError(`No login in progress. Please start again.`);
|
||||
}
|
||||
|
||||
if (!sso_code) {
|
||||
throw new AppError(`Please enter your code.`);
|
||||
}
|
||||
|
||||
const sso_auth = EJSON.parse(await decrypt(sso_cookie)) as SSOAuth;
|
||||
|
||||
if (!sso_auth?.user_id) {
|
||||
throw new AppError(`Invalid login session. Please start again.`);
|
||||
}
|
||||
|
||||
const user_id = sso_auth.user_id;
|
||||
|
||||
if (sso_auth.method != method) {
|
||||
await deleteUserSSOCodes({ user_id });
|
||||
throw new AppError(`This code can't be used here. Please start again.`);
|
||||
}
|
||||
|
||||
const attempts_key = `sso-login:${user_id}`;
|
||||
|
||||
const attempts = rateLimit({
|
||||
key: attempts_key,
|
||||
limit: AppData["SSOMaxCodeAttempts"],
|
||||
window_ms: AppData["SSOCodeExpiryMinutes"] * 60 * 1000,
|
||||
});
|
||||
|
||||
if (!attempts.allowed) {
|
||||
await deleteUserSSOCodes({ user_id });
|
||||
throw new AppError(`Too many attempts. Please request a new code.`);
|
||||
}
|
||||
|
||||
const target_sso = (
|
||||
await BunSQLite.select<
|
||||
BUN_SQLITE_BSQLITE_SSO_LOGIN_CODES,
|
||||
(typeof BunSQLiteTables)[number]
|
||||
>({
|
||||
table: "sso_login_codes",
|
||||
query: {
|
||||
query: {
|
||||
user_id: {
|
||||
value: user_id,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
).singleRes;
|
||||
|
||||
if (
|
||||
!target_sso?.code ||
|
||||
!safeCompare(String(target_sso.code), String(sso_code))
|
||||
) {
|
||||
throw new AppError(`Invalid Code!`);
|
||||
}
|
||||
|
||||
const time_elapsed = Date.now() - Number(target_sso.updated_at);
|
||||
const expiry_time = AppData["SSOCodeExpiryMinutes"] * 60 * 1000;
|
||||
|
||||
if (time_elapsed > expiry_time) {
|
||||
await deleteUserSSOCodes({ user_id });
|
||||
throw new AppError(`Code Expired. Please start again.`);
|
||||
}
|
||||
|
||||
resetRateLimit({ key: attempts_key });
|
||||
|
||||
return user_id;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import grabUserPasswordHash from "./grab-user-password-hash";
|
||||
import hashPassword from "./hash-password";
|
||||
|
||||
type Params = {
|
||||
user_id?: string | number;
|
||||
password: string;
|
||||
};
|
||||
|
||||
let dummy_hash: Promise<string> | undefined;
|
||||
|
||||
/**
|
||||
* Checks a password against the user's stored hash. Unknown users and
|
||||
* users without a password still pay for a full hash comparison, so
|
||||
* response timing doesn't reveal which accounts exist.
|
||||
*/
|
||||
export default async function verifyUserPassword({
|
||||
user_id,
|
||||
password,
|
||||
}: Params) {
|
||||
const password_hash = user_id
|
||||
? await grabUserPasswordHash({ user_id })
|
||||
: undefined;
|
||||
|
||||
if (!password_hash) {
|
||||
dummy_hash ||= hashPassword({ password: crypto.randomUUID() });
|
||||
await Bun.password.verify(password, await dummy_hash);
|
||||
return false;
|
||||
}
|
||||
|
||||
return await Bun.password.verify(password, password_hash);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import runBunSQLiteCommand from "./run-bun-sqlite-command";
|
||||
|
||||
/**
|
||||
* Writes a new database snapshot via `bun-sqlite backup`. bun-sqlite prunes
|
||||
* old snapshots down to `max_backups`.
|
||||
*/
|
||||
export default async function createDbBackup() {
|
||||
return await runBunSQLiteCommand({ args: ["backup"] });
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import path from "path";
|
||||
import BunSQLiteConfig from "@/bun-sqlite.config";
|
||||
|
||||
/**
|
||||
* Absolute path of the bun-sqlite backups directory. Mirrors bun-sqlite's
|
||||
* own resolution: `{db_dir}/{db_backup_dir || ".backups"}`.
|
||||
*/
|
||||
export default function grabDbBackupDir() {
|
||||
return path.resolve(
|
||||
process.cwd(),
|
||||
BunSQLiteConfig.db_dir || "",
|
||||
BunSQLiteConfig.db_backup_dir || ".backups",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import BunSQLiteConfig from "@/bun-sqlite.config";
|
||||
import type { DbBackup } from "@/src/types";
|
||||
import grabDbBackupDir from "./grab-db-backup-dir";
|
||||
|
||||
/**
|
||||
* Lists the `{db_name}-{timestamp}` snapshots written by `bun-sqlite
|
||||
* backup`. Newest first.
|
||||
*/
|
||||
export default function grabDbBackups(): DbBackup[] {
|
||||
const backup_dir = grabDbBackupDir();
|
||||
const backup_name_regex = new RegExp(
|
||||
`^${BunSQLiteConfig.db_name}-\\d+$`,
|
||||
);
|
||||
|
||||
if (!fs.existsSync(backup_dir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return fs
|
||||
.readdirSync(backup_dir)
|
||||
.filter((name) => backup_name_regex.test(name))
|
||||
.map((name) => {
|
||||
const stat = fs.statSync(path.join(backup_dir, name));
|
||||
const timestamp = Number(name.split("-").pop());
|
||||
|
||||
return {
|
||||
name,
|
||||
is_file: stat.isFile(),
|
||||
created_at: Number.isFinite(timestamp)
|
||||
? timestamp
|
||||
: stat.mtimeMs,
|
||||
size: stat.size,
|
||||
};
|
||||
})
|
||||
.filter((backup) => backup.is_file)
|
||||
.map(({ is_file, ...backup }) => backup)
|
||||
.sort((a, b) => b.created_at - a.created_at);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import checkUserAccess from "@/src/functions/backend/auth/check-user-access";
|
||||
|
||||
type Params = {
|
||||
req: Request;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the session user only when they are a super admin.
|
||||
*/
|
||||
export default async function grabSuperAdminUser({ req }: Params) {
|
||||
const { user, user_types } = await userAuth({ req });
|
||||
|
||||
if (!user?.logged_in_status) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* No includes/excludes: only the `super_admin` bypass passes.
|
||||
*/
|
||||
if (!checkUserAccess({ user_types }).success) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import path from "path";
|
||||
import grabDbBackupDir from "./grab-db-backup-dir";
|
||||
import grabDbBackups from "./grab-db-backups";
|
||||
|
||||
type Params = {
|
||||
backup_name?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves a backup file name from the request to its absolute path. Only
|
||||
* names of existing backups are accepted, so arbitrary paths can't be read.
|
||||
*/
|
||||
export default function resolveDbBackupPath({ backup_name }: Params) {
|
||||
const backup = grabDbBackups().find((b) => b.name === backup_name);
|
||||
|
||||
if (!backup) {
|
||||
throw new Error(`Backup not found`);
|
||||
}
|
||||
|
||||
return path.join(grabDbBackupDir(), backup.name);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import resolveDbBackupPath from "./resolve-db-backup-path";
|
||||
import runBunSQLiteCommand from "./run-bun-sqlite-command";
|
||||
|
||||
type Params = {
|
||||
backup_name?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Restores a database snapshot from the backups directory. `bun-sqlite
|
||||
* restore` is an interactive picker, so this uses its non-interactive
|
||||
* equivalent, `bun-sqlite import <file>`.
|
||||
*/
|
||||
export default async function restoreDbBackup({ backup_name }: Params) {
|
||||
const backup_path = resolveDbBackupPath({ backup_name });
|
||||
|
||||
return await runBunSQLiteCommand({ args: ["import", backup_path] });
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
type Params = {
|
||||
args: string[];
|
||||
};
|
||||
|
||||
let is_running = false;
|
||||
|
||||
/**
|
||||
* Runs a `bun-sqlite` CLI command (same as `bunx bun-sqlite ...`). Only one
|
||||
* command runs at a time so a backup and a restore never overlap.
|
||||
*/
|
||||
export default async function runBunSQLiteCommand({ args }: Params) {
|
||||
if (is_running) {
|
||||
throw new Error(`Another backup or restore is already running`);
|
||||
}
|
||||
|
||||
is_running = true;
|
||||
|
||||
try {
|
||||
const proc = Bun.spawn([process.execPath, "x", "bun-sqlite", ...args], {
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
stdin: "ignore",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
const [stdout, stderr, exit_code] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
proc.exited,
|
||||
]);
|
||||
|
||||
if (exit_code !== 0) {
|
||||
throw new Error(
|
||||
stderr.trim() ||
|
||||
stdout.trim() ||
|
||||
`bun-sqlite ${args[0]} failed (exit ${exit_code})`,
|
||||
);
|
||||
}
|
||||
|
||||
return { stdout };
|
||||
} finally {
|
||||
is_running = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import BunSQLiteConfig from "@/bun-sqlite.config";
|
||||
import grabDbBackupDir from "./grab-db-backup-dir";
|
||||
|
||||
type Params = {
|
||||
file: File;
|
||||
};
|
||||
|
||||
const SQLITE_HEADER = "SQLite format 3\0";
|
||||
|
||||
/**
|
||||
* Saves an uploaded SQLite database file into the backups directory under
|
||||
* the same `{db_name}-{timestamp}` name `bun-sqlite backup` uses, so it is
|
||||
* listed and pruned like any other backup. Returns the saved file name.
|
||||
*/
|
||||
export default async function saveUploadedDbBackup({ file }: Params) {
|
||||
if (!file.size) {
|
||||
throw new Error(`The uploaded backup file is empty`);
|
||||
}
|
||||
|
||||
const header = await file.slice(0, SQLITE_HEADER.length).text();
|
||||
|
||||
if (header !== SQLITE_HEADER) {
|
||||
throw new Error(`Only SQLite database files can be restored`);
|
||||
}
|
||||
|
||||
const backup_dir = grabDbBackupDir();
|
||||
|
||||
if (!fs.existsSync(backup_dir)) {
|
||||
fs.mkdirSync(backup_dir, { recursive: true });
|
||||
}
|
||||
|
||||
const backup_name = `${BunSQLiteConfig.db_name}-${Date.now()}`;
|
||||
|
||||
await Bun.write(path.join(backup_dir, backup_name), file);
|
||||
|
||||
return backup_name;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { BUN_SQLITE_BSQLITE_ALL_TYPEDEFS } from "@/db/types/db";
|
||||
import type { TableType } from "@/src/types";
|
||||
import uniqueStringArr from "@/src/utils/unique-string-arr";
|
||||
import type { ServerQueryParam } from "@moduletrace/bun-sqlite";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
import _ from "lodash";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* Allowed joins
|
||||
*/
|
||||
joins: TableType[];
|
||||
query: ServerQueryParam<any>;
|
||||
/**
|
||||
* Allowed fields from joins
|
||||
*/
|
||||
include_fields?: (keyof BUN_SQLITE_BSQLITE_ALL_TYPEDEFS)[];
|
||||
/**
|
||||
* Fields to exclude from joins
|
||||
*/
|
||||
exclude_fields?: (keyof BUN_SQLITE_BSQLITE_ALL_TYPEDEFS)[];
|
||||
};
|
||||
|
||||
export default function checkAllowedJoins({
|
||||
joins,
|
||||
query,
|
||||
include_fields,
|
||||
exclude_fields,
|
||||
}: Params): APIResponseObject {
|
||||
const all_joins = BunSQLite.utils.grab_join_fields_from_query_object({
|
||||
query,
|
||||
});
|
||||
|
||||
const all_joins_str_arr = uniqueStringArr(
|
||||
all_joins
|
||||
.filter((j) => Boolean(j.table))
|
||||
.map((j) => j.table) as string[],
|
||||
);
|
||||
|
||||
const are_joins_allowed =
|
||||
_.intersection(joins, all_joins_str_arr).length >=
|
||||
all_joins_str_arr.length;
|
||||
|
||||
if (!are_joins_allowed) {
|
||||
return {
|
||||
success: false,
|
||||
msg: `Can't Join all tables: \`${all_joins_str_arr.join(", ")}\`. Tables allowed are \`${joins.join(", ")}\``,
|
||||
};
|
||||
}
|
||||
|
||||
if (include_fields?.[0]) {
|
||||
for (let i = 0; i < all_joins.length; i++) {
|
||||
const join = all_joins[i];
|
||||
if (!join) continue;
|
||||
const join_field = join.field as keyof BUN_SQLITE_BSQLITE_ALL_TYPEDEFS;
|
||||
if (!include_fields.includes(join_field)) {
|
||||
return {
|
||||
success: false,
|
||||
msg: `Can't Join field: \`${join_field}\` in \`${join.table}\` table`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { BUN_SQLITE_BSQLITE_EMAIL_ATTACHMENTS } from "@/db/types/db";
|
||||
import type { APIResponseObject } from "@moduletrace/bun-sqlite";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
|
||||
export type EmailAttachmentRecord = BUN_SQLITE_BSQLITE_EMAIL_ATTACHMENTS & {
|
||||
letter_title?: string;
|
||||
invoice_number?: string;
|
||||
invoice_title?: string;
|
||||
quote_number?: string;
|
||||
quote_title?: string;
|
||||
media_write_path?: string;
|
||||
media_mime_type?: string;
|
||||
};
|
||||
|
||||
export default async function grabEmailAttachments({
|
||||
email_id,
|
||||
}: {
|
||||
email_id: number | string;
|
||||
}): Promise<APIResponseObject<EmailAttachmentRecord>> {
|
||||
return await BunSQLite.sql<EmailAttachmentRecord>({
|
||||
sql: `
|
||||
SELECT
|
||||
ea.*,
|
||||
l.title AS letter_title,
|
||||
i.invoice_number,
|
||||
i.title AS invoice_title,
|
||||
q.quote_number,
|
||||
q.title AS quote_title,
|
||||
m.media_write_path,
|
||||
m.mime_type AS media_mime_type
|
||||
FROM email_attachments ea
|
||||
LEFT JOIN letters l ON l.id = ea.letter_id
|
||||
LEFT JOIN invoices i ON i.id = ea.invoice_id
|
||||
LEFT JOIN quotes q ON q.id = ea.quote_id
|
||||
LEFT JOIN media m ON m.id = ea.media_id
|
||||
WHERE ea.email_id = ?
|
||||
ORDER BY ea.id ASC
|
||||
`,
|
||||
values: [email_id],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { BUN_SQLITE_BSQLITE_EMAILS } from "@/db/types/db";
|
||||
import type { APIResponseObject } from "@moduletrace/bun-sqlite";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
|
||||
export default async function grabEmail({
|
||||
id,
|
||||
}: {
|
||||
id: number | string;
|
||||
}): Promise<APIResponseObject<BUN_SQLITE_BSQLITE_EMAILS>> {
|
||||
return await BunSQLite.select<BUN_SQLITE_BSQLITE_EMAILS>({
|
||||
table: "emails",
|
||||
targetId: id,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { BUN_SQLITE_BSQLITE_LETTERS } from "@/db/types/db";
|
||||
import type { APIResponseObject } from "@moduletrace/bun-sqlite";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
|
||||
export default async function grabLetter({
|
||||
id,
|
||||
}: {
|
||||
id: number | string;
|
||||
}): Promise<APIResponseObject<BUN_SQLITE_BSQLITE_LETTERS>> {
|
||||
return await BunSQLite.select<BUN_SQLITE_BSQLITE_LETTERS>({
|
||||
table: "letters",
|
||||
targetId: id,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import { beforeEach, describe, expect, test } from "bun:test";
|
||||
import grabNextDocumentSequence, {
|
||||
type DocumentNumberSqlRunner,
|
||||
} from "./grab-next-document-sequence";
|
||||
|
||||
let db: Database;
|
||||
let run_sql: DocumentNumberSqlRunner;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(":memory:");
|
||||
db.run("CREATE TABLE invoices (invoice_number TEXT UNIQUE)");
|
||||
|
||||
run_sql = async ({ sql, values }) => {
|
||||
const row = db.query(sql).get(...values) as
|
||||
| { max_seq: number | null }
|
||||
| null;
|
||||
|
||||
return { singleRes: row };
|
||||
};
|
||||
});
|
||||
|
||||
function insertNumbers(numbers: string[]) {
|
||||
const insert = db.prepare("INSERT INTO invoices (invoice_number) VALUES (?)");
|
||||
|
||||
for (const number of numbers) {
|
||||
insert.run(number);
|
||||
}
|
||||
}
|
||||
|
||||
describe("grabNextDocumentSequence", () => {
|
||||
test("starts at 1 for an empty table", async () => {
|
||||
const next = await grabNextDocumentSequence({
|
||||
table: "invoices",
|
||||
column: "invoice_number",
|
||||
prefix: "BSQLITE-202610",
|
||||
run_sql,
|
||||
});
|
||||
|
||||
expect(next).toBe(1);
|
||||
});
|
||||
|
||||
test("returns max + 1 for existing numbers", async () => {
|
||||
insertNumbers([
|
||||
"BSQLITE-202610-001",
|
||||
"BSQLITE-202610-002",
|
||||
"BSQLITE-202610-003",
|
||||
]);
|
||||
|
||||
const next = await grabNextDocumentSequence({
|
||||
table: "invoices",
|
||||
column: "invoice_number",
|
||||
prefix: "BSQLITE-202610",
|
||||
run_sql,
|
||||
});
|
||||
|
||||
expect(next).toBe(4);
|
||||
});
|
||||
|
||||
test("does not reuse a number after a deletion", async () => {
|
||||
insertNumbers(["BSQLITE-202610-001", "BSQLITE-202610-003"]);
|
||||
|
||||
const next = await grabNextDocumentSequence({
|
||||
table: "invoices",
|
||||
column: "invoice_number",
|
||||
prefix: "BSQLITE-202610",
|
||||
run_sql,
|
||||
});
|
||||
|
||||
expect(next).toBe(4);
|
||||
});
|
||||
|
||||
test("ignores numbers from other prefixes", async () => {
|
||||
insertNumbers([
|
||||
"BSQLITE-202609-099",
|
||||
"BSQLITEQ-202610-050",
|
||||
"BSQLITE-202610-002",
|
||||
]);
|
||||
|
||||
const next = await grabNextDocumentSequence({
|
||||
table: "invoices",
|
||||
column: "invoice_number",
|
||||
prefix: "BSQLITE-202610",
|
||||
run_sql,
|
||||
});
|
||||
|
||||
expect(next).toBe(3);
|
||||
});
|
||||
|
||||
test("handles sequences beyond three digits", async () => {
|
||||
insertNumbers(["BSQLITE-202610-009", "BSQLITE-202610-010"]);
|
||||
|
||||
const next = await grabNextDocumentSequence({
|
||||
table: "invoices",
|
||||
column: "invoice_number",
|
||||
prefix: "BSQLITE-202610",
|
||||
run_sql,
|
||||
});
|
||||
|
||||
expect(next).toBe(11);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
|
||||
type DocumentNumberTarget = {
|
||||
table: "invoices" | "quotes";
|
||||
column: "invoice_number" | "quote_number";
|
||||
};
|
||||
|
||||
export type DocumentNumberSqlRunner = (args: {
|
||||
sql: string;
|
||||
values: (string | number)[];
|
||||
}) => Promise<{ singleRes?: { max_seq?: number | string | null } | null }>;
|
||||
|
||||
type Params = DocumentNumberTarget & {
|
||||
prefix: string;
|
||||
/**
|
||||
* Injectable query runner, used by tests to target an in-memory DB.
|
||||
*/
|
||||
run_sql?: DocumentNumberSqlRunner;
|
||||
};
|
||||
|
||||
/**
|
||||
* # Grab the next document sequence.
|
||||
* Reads the highest existing numeric suffix for the prefix instead of
|
||||
* counting rows, so deleting a document can never hand out a number that
|
||||
* already exists.
|
||||
*/
|
||||
export default async function grabNextDocumentSequence({
|
||||
table,
|
||||
column,
|
||||
prefix,
|
||||
run_sql,
|
||||
}: Params): Promise<number> {
|
||||
const sql = `SELECT MAX(CAST(SUBSTR(${column}, ?) AS INTEGER)) AS max_seq FROM ${table} WHERE ${column} LIKE ?`;
|
||||
const values = [prefix.length + 2, `${prefix}-%`];
|
||||
|
||||
const res = run_sql
|
||||
? await run_sql({ sql, values })
|
||||
: await BunSQLite.sql({ sql, values });
|
||||
|
||||
return Number(res.singleRes?.max_seq || 0) + 1;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type {
|
||||
BUN_SQLITE_BSQLITE_INVOICE_ITEMS,
|
||||
BUN_SQLITE_BSQLITE_INVOICE_QUOTES,
|
||||
BUN_SQLITE_BSQLITE_INVOICES,
|
||||
} from "@/db/types/db";
|
||||
import type { APIResponseObject } from "@moduletrace/bun-sqlite";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
|
||||
export default async function grabInvoice({
|
||||
id,
|
||||
}: {
|
||||
id: number | string;
|
||||
}): Promise<APIResponseObject<BUN_SQLITE_BSQLITE_INVOICES>> {
|
||||
return await BunSQLite.select<BUN_SQLITE_BSQLITE_INVOICES>({
|
||||
table: "invoices",
|
||||
targetId: id,
|
||||
});
|
||||
}
|
||||
|
||||
export async function grabInvoiceItems({
|
||||
invoice_id,
|
||||
}: {
|
||||
invoice_id: number | string;
|
||||
}): Promise<APIResponseObject<BUN_SQLITE_BSQLITE_INVOICE_ITEMS>> {
|
||||
return await BunSQLite.select<BUN_SQLITE_BSQLITE_INVOICE_ITEMS>({
|
||||
table: "invoice_items",
|
||||
query: {
|
||||
query: {
|
||||
invoice_id: {
|
||||
value: Number(invoice_id),
|
||||
},
|
||||
},
|
||||
order: [
|
||||
{ field: "sort_order", strategy: "ASC" },
|
||||
{ field: "id", strategy: "ASC" },
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export type InvoiceQuoteLinkRecord = BUN_SQLITE_BSQLITE_INVOICE_QUOTES & {
|
||||
quote_number?: string;
|
||||
quote_title?: string;
|
||||
};
|
||||
|
||||
export async function grabInvoiceQuoteLinks({
|
||||
invoice_id,
|
||||
}: {
|
||||
invoice_id: number | string;
|
||||
}): Promise<APIResponseObject<InvoiceQuoteLinkRecord>> {
|
||||
return await BunSQLite.sql<InvoiceQuoteLinkRecord>({
|
||||
sql: `
|
||||
SELECT
|
||||
iq.*,
|
||||
q.quote_number,
|
||||
q.title AS quote_title
|
||||
FROM invoice_quotes iq
|
||||
LEFT JOIN quotes q ON q.id = iq.quote_id
|
||||
WHERE iq.invoice_id = ?
|
||||
ORDER BY iq.id ASC
|
||||
`,
|
||||
values: [invoice_id],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { BUN_SQLITE_BSQLITE_MEDIA_JOIN } from "@/src/types/sql-joins";
|
||||
import type { APIResponseObject } from "@moduletrace/bun-sqlite";
|
||||
import _ from "lodash";
|
||||
import grabUsers from "../users/grab-users";
|
||||
|
||||
type Params = {
|
||||
media_res: APIResponseObject<BUN_SQLITE_BSQLITE_MEDIA_JOIN>;
|
||||
};
|
||||
|
||||
export default async function appendUsersToMedia({
|
||||
media_res,
|
||||
}: Params): Promise<APIResponseObject<BUN_SQLITE_BSQLITE_MEDIA_JOIN>> {
|
||||
try {
|
||||
let new_media_res = _.cloneDeep(media_res);
|
||||
|
||||
const media = new_media_res.payload;
|
||||
|
||||
const users_ids = media
|
||||
?.map((m) => m.user_id)
|
||||
.filter((n) => !_.isUndefined(n) && _.isNumber(n));
|
||||
|
||||
const unique_users_ids = _.uniq(users_ids);
|
||||
|
||||
if (users_ids?.[0] && media?.[0]) {
|
||||
const users_res = await grabUsers({
|
||||
query: { ids: unique_users_ids },
|
||||
});
|
||||
|
||||
const users = users_res.payload || [];
|
||||
|
||||
for (let i = 0; i < media.length; i++) {
|
||||
const single_media = media[i];
|
||||
const target_user = users.find(
|
||||
(u) => u.id == single_media?.user_id,
|
||||
);
|
||||
|
||||
if (!target_user || !single_media?.user_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
new_media_res.payload![i]!.user = target_user;
|
||||
|
||||
if (i == 0) {
|
||||
new_media_res.singleRes = target_user;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new_media_res;
|
||||
} catch (error: any) {
|
||||
console.log("Append users to media ERROR =>", error.message);
|
||||
|
||||
return media_res;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import _ from "lodash";
|
||||
import type { BUN_SQLITE_BSQLITE_MEDIA } from "@/db/types/db";
|
||||
import grabMediaFromParadigm from "@/src/functions/backend/db/media/grab-media-from-paraidigm";
|
||||
import type { MediaParadigmType, TableType, User } from "@/src/types";
|
||||
import grabDirNames from "@/src/utils/grab-dir-names";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import { rmSync } from "fs";
|
||||
import path from "path";
|
||||
|
||||
type Params = {
|
||||
id?: string | number;
|
||||
ids?: (string | number)[];
|
||||
media_paradigm?: MediaParadigmType;
|
||||
/**
|
||||
* ID of the user to be assigned to this media.
|
||||
* Different from logged in user id
|
||||
*/
|
||||
user_id?: string | number | null;
|
||||
can_delete_all_media?: boolean;
|
||||
};
|
||||
|
||||
export default async function deleteMedia(params: Params) {
|
||||
const { id, ids, media_paradigm, user_id, can_delete_all_media } = params;
|
||||
|
||||
const { DATA_DIR } = grabDirNames();
|
||||
|
||||
let media_to_delete = new Set<number>();
|
||||
|
||||
if (media_paradigm && user_id) {
|
||||
const media_res = (
|
||||
await grabMediaFromParadigm({ media_paradigm, user_id })
|
||||
).payload;
|
||||
|
||||
if (media_res) {
|
||||
for (let i = 0; i < media_res.length; i++) {
|
||||
const media = media_res[i];
|
||||
if (!media?.id) continue;
|
||||
media_to_delete.add(media.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let media_ids_to_del: (number | string)[] = [];
|
||||
|
||||
if (id) {
|
||||
media_ids_to_del.push(id);
|
||||
}
|
||||
|
||||
if (ids) {
|
||||
media_ids_to_del.push(...ids);
|
||||
}
|
||||
|
||||
if (media_ids_to_del[0]) {
|
||||
for (let i = 0; i < media_ids_to_del.length; i++) {
|
||||
const media_id_to_delete = media_ids_to_del[i];
|
||||
|
||||
const target_media = (
|
||||
await BunSQLite.select<BUN_SQLITE_BSQLITE_MEDIA>({
|
||||
table: "media",
|
||||
targetId: media_id_to_delete,
|
||||
})
|
||||
).singleRes;
|
||||
|
||||
if (target_media?.id) {
|
||||
if (target_media.user_id !== user_id && !can_delete_all_media) {
|
||||
throw new Error(`Can't delete this media!`);
|
||||
}
|
||||
|
||||
media_to_delete.add(target_media.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const media_to_delete_array = Array.from(media_to_delete);
|
||||
|
||||
for (let i = 0; i < media_to_delete_array.length; i++) {
|
||||
const media_id = media_to_delete_array[i];
|
||||
|
||||
const target_media = (
|
||||
await BunSQLite.select<BUN_SQLITE_BSQLITE_MEDIA>({
|
||||
table: "media",
|
||||
targetId: media_id,
|
||||
})
|
||||
).singleRes;
|
||||
|
||||
if (!target_media?.id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (target_media.media_write_path) {
|
||||
rmSync(
|
||||
path.join(DATA_DIR, target_media.media_write_path),
|
||||
{ force: true },
|
||||
);
|
||||
}
|
||||
|
||||
if (target_media.media_thumbnail_write_path) {
|
||||
rmSync(
|
||||
path.join(DATA_DIR, target_media.media_thumbnail_write_path),
|
||||
{ force: true },
|
||||
);
|
||||
}
|
||||
|
||||
await BunSQLite.delete<BUN_SQLITE_BSQLITE_MEDIA, TableType>({
|
||||
table: "media",
|
||||
targetId: target_media.id,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { BUN_SQLITE_BSQLITE_MEDIA_PARADIGMS } from "@/db/types/db";
|
||||
import type { MediaParadigmType, TableType } from "@/src/types";
|
||||
import type { BUN_SQLITE_BSQLITE_MEDIA_PARADIGMS_JOIN } from "@/src/types/sql-joins";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
import _ from "lodash";
|
||||
|
||||
type Params = {
|
||||
media_paradigm: MediaParadigmType;
|
||||
user_id?: string | number;
|
||||
};
|
||||
|
||||
export default async function grabMediaFromParadigm({
|
||||
media_paradigm,
|
||||
user_id,
|
||||
}: Params): Promise<APIResponseObject<BUN_SQLITE_BSQLITE_MEDIA_PARADIGMS_JOIN>> {
|
||||
try {
|
||||
const media_paradigms_res = await BunSQLite.select<
|
||||
BUN_SQLITE_BSQLITE_MEDIA_PARADIGMS,
|
||||
TableType
|
||||
>({
|
||||
table: "media_paradigms",
|
||||
query: {
|
||||
query: {
|
||||
media_paradigm: {
|
||||
value: media_paradigm,
|
||||
},
|
||||
},
|
||||
join: [
|
||||
{
|
||||
joinType: "INNER JOIN",
|
||||
tableName: "media",
|
||||
match: [
|
||||
{
|
||||
source: "media_id",
|
||||
target: "id",
|
||||
},
|
||||
],
|
||||
selectFields: ["user_id"],
|
||||
},
|
||||
{
|
||||
joinType: "INNER JOIN",
|
||||
tableName: "users",
|
||||
match: [
|
||||
{
|
||||
source: {
|
||||
fieldName: "user_id",
|
||||
tableName: "media",
|
||||
},
|
||||
target: "id",
|
||||
},
|
||||
...(user_id
|
||||
? [
|
||||
{
|
||||
source: {
|
||||
fieldName: "id",
|
||||
tableName: "users",
|
||||
},
|
||||
targetLiteral: user_id,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
selectFields: ["first_name", "last_name"],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...media_paradigms_res,
|
||||
singleRes: media_paradigms_res.singleRes,
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
msg: error.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { BUN_SQLITE_BSQLITE_MEDIA } from "@/db/types/db";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import type { ApiReqParams, User } from "@/src/types";
|
||||
import type { BUN_SQLITE_BSQLITE_MEDIA_JOIN } from "@/src/types/sql-joins";
|
||||
import BunSQLite, { type APIResponseObject } from "@moduletrace/bun-sqlite";
|
||||
import type { SQLInsertGenValueType } from "@moduletrace/bun-sqlite";
|
||||
import _ from "lodash";
|
||||
import appendUsersToMedia from "./append-users-to-media";
|
||||
|
||||
type Params = {
|
||||
query?: ApiReqParams;
|
||||
user?: User;
|
||||
};
|
||||
|
||||
export default async function grabMedia(
|
||||
params?: Params,
|
||||
): Promise<APIResponseObject<BUN_SQLITE_BSQLITE_MEDIA_JOIN>> {
|
||||
const { query, user } = params || {};
|
||||
|
||||
try {
|
||||
const LIMIT = AppData["DefaultQueryLimit"];
|
||||
const values: SQLInsertGenValueType[] = [];
|
||||
const hasParadigms = Boolean(query?.media_paradigms?.length);
|
||||
|
||||
let sql = `SELECT\n`;
|
||||
sql += ` m.*,\n`;
|
||||
sql += ` GROUP_CONCAT(DISTINCT mp.media_paradigm) AS media_paradigms\n`;
|
||||
sql += `FROM media m\n`;
|
||||
sql += `LEFT JOIN media_paradigms mp ON m.id = mp.media_id\n`;
|
||||
|
||||
sql += `WHERE COALESCE(m.archived, 0) <> 1\n`;
|
||||
|
||||
if (hasParadigms && query?.media_paradigms) {
|
||||
const placeholders = query.media_paradigms.map(() => "?").join(",");
|
||||
sql += ` AND m.id IN (\n`;
|
||||
sql += ` SELECT sub_mp.media_id\n`;
|
||||
sql += ` FROM media_paradigms sub_mp\n`;
|
||||
sql += ` WHERE sub_mp.media_paradigm IN (${placeholders})\n`;
|
||||
sql += ` GROUP BY sub_mp.media_id\n`;
|
||||
sql += ` HAVING COUNT(DISTINCT sub_mp.media_paradigm) = ?\n`;
|
||||
sql += ` )\n`;
|
||||
|
||||
values.push(...query.media_paradigms, query.media_paradigms.length);
|
||||
}
|
||||
|
||||
if (query?.is_media_private) {
|
||||
sql += ` AND m.is_private = 1\n`;
|
||||
if (!user?.id) {
|
||||
throw new Error(`Private media requires authenticated user`);
|
||||
} else {
|
||||
sql += ` AND m.user_id = ?\n`;
|
||||
values.push(user.id);
|
||||
}
|
||||
} else {
|
||||
sql += ` AND COALESCE(m.is_private, 0) <> 1\n`;
|
||||
}
|
||||
|
||||
if (query?.id) {
|
||||
sql += ` AND m.id = ?\n`;
|
||||
values.push(query.id);
|
||||
}
|
||||
|
||||
if (query?.is_media_primary) {
|
||||
sql += ` AND m.is_primary = ?\n`;
|
||||
values.push(1);
|
||||
}
|
||||
|
||||
if (query?.user_id) {
|
||||
sql += ` AND m.user_id = ?\n`;
|
||||
values.push(query.user_id);
|
||||
}
|
||||
|
||||
// 4. Parameterized Search Terms
|
||||
if (query?.search_terms?.length) {
|
||||
const fields_to_search: (keyof BUN_SQLITE_BSQLITE_MEDIA)[] = [
|
||||
"title",
|
||||
"description",
|
||||
"summary",
|
||||
"text_content",
|
||||
];
|
||||
|
||||
const searchConditions: string[] = [];
|
||||
|
||||
for (const search_term of query.search_terms) {
|
||||
const termConditions: string[] = [];
|
||||
for (const field of fields_to_search) {
|
||||
termConditions.push(`m.${field} LIKE ?`);
|
||||
values.push(`%${search_term}%`);
|
||||
}
|
||||
searchConditions.push(`(${termConditions.join(" OR ")})`);
|
||||
}
|
||||
|
||||
sql += ` AND (${searchConditions.join(" OR ")})\n`;
|
||||
}
|
||||
|
||||
sql += `GROUP BY m.id\n`;
|
||||
|
||||
sql += `ORDER BY m.id DESC\n`;
|
||||
|
||||
let sql_limit = `LIMIT ?`;
|
||||
const limitValues: SQLInsertGenValueType[] = [LIMIT];
|
||||
|
||||
if (query?.page && query.page > 1) {
|
||||
const offset = LIMIT * (query.page - 1);
|
||||
sql_limit += ` OFFSET ?`;
|
||||
limitValues.push(offset);
|
||||
}
|
||||
|
||||
const final_sql = `${sql}\n${sql_limit}`;
|
||||
const final_values = [...values, ...limitValues];
|
||||
|
||||
const res = await BunSQLite.sql<BUN_SQLITE_BSQLITE_MEDIA_JOIN>({
|
||||
sql: final_sql,
|
||||
values: final_values,
|
||||
});
|
||||
|
||||
// Count Query
|
||||
if (query?.count) {
|
||||
const count_sql = `SELECT COUNT(*) AS count FROM (${sql}) AS subquery_count`;
|
||||
const count_res = await BunSQLite.sql<{ count: number }>({
|
||||
sql: count_sql,
|
||||
values,
|
||||
});
|
||||
res.count = count_res.singleRes?.count ?? 0;
|
||||
}
|
||||
|
||||
return await appendUsersToMedia({ media_res: res });
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
msg: error.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type {
|
||||
BUN_SQLITE_BSQLITE_QUOTE_ITEMS,
|
||||
BUN_SQLITE_BSQLITE_QUOTES,
|
||||
} from "@/db/types/db";
|
||||
import type { APIResponseObject } from "@moduletrace/bun-sqlite";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
|
||||
export default async function grabQuote({
|
||||
id,
|
||||
}: {
|
||||
id: number | string;
|
||||
}): Promise<APIResponseObject<BUN_SQLITE_BSQLITE_QUOTES>> {
|
||||
return await BunSQLite.select<BUN_SQLITE_BSQLITE_QUOTES>({
|
||||
table: "quotes",
|
||||
targetId: id,
|
||||
});
|
||||
}
|
||||
|
||||
export async function grabQuoteItems({
|
||||
quote_id,
|
||||
}: {
|
||||
quote_id: number | string;
|
||||
}): Promise<APIResponseObject<BUN_SQLITE_BSQLITE_QUOTE_ITEMS>> {
|
||||
return await BunSQLite.select<BUN_SQLITE_BSQLITE_QUOTE_ITEMS>({
|
||||
table: "quote_items",
|
||||
query: {
|
||||
query: {
|
||||
quote_id: {
|
||||
value: Number(quote_id),
|
||||
},
|
||||
},
|
||||
order: [
|
||||
{ field: "sort_order", strategy: "ASC" },
|
||||
{ field: "id", strategy: "ASC" },
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type {
|
||||
BUN_SQLITE_BSQLITE_USER_TYPES,
|
||||
BunSQLiteTables,
|
||||
} from "@/db/types/db";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* All `user_types` rows for a user (including `revoked`).
|
||||
*/
|
||||
export default async function grabUserTypes({
|
||||
user_id,
|
||||
}: Params): Promise<BUN_SQLITE_BSQLITE_USER_TYPES[]> {
|
||||
const res = await BunSQLite.select<
|
||||
BUN_SQLITE_BSQLITE_USER_TYPES,
|
||||
(typeof BunSQLiteTables)[number]
|
||||
>({
|
||||
table: "user_types",
|
||||
query: {
|
||||
query: {
|
||||
user_id: {
|
||||
value: user_id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return res.payload || [];
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import type { ApiReqParams, SQLInsertGenValueType } from "@/src/types";
|
||||
import type { BUN_SQLITE_BSQLITE_USERS_JOIN } from "@/src/types/sql-joins";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
import _ from "lodash";
|
||||
|
||||
type Params = {
|
||||
query?: ApiReqParams;
|
||||
/**
|
||||
* For search terms (ie `search_term_*`) parameters.
|
||||
* This forces all searches to `=` instead of `LIKE`
|
||||
*/
|
||||
exact_match?: boolean;
|
||||
user_id?: string | number;
|
||||
};
|
||||
|
||||
export default async function grabUsers(
|
||||
params?: Params,
|
||||
): Promise<APIResponseObject<BUN_SQLITE_BSQLITE_USERS_JOIN>> {
|
||||
const { query } = params || {};
|
||||
|
||||
try {
|
||||
const LIMIT = AppData["DefaultQueryLimit"];
|
||||
|
||||
let sql = ``;
|
||||
let values: SQLInsertGenValueType[] = [];
|
||||
|
||||
sql += `SELECT\n`;
|
||||
sql += ` u.*,\n`;
|
||||
sql += ` ut_agg.user_types,\n`;
|
||||
sql += ` m.id AS profile_media_id\n`;
|
||||
sql += `FROM users u\n`;
|
||||
sql += `LEFT JOIN (\n`;
|
||||
sql += ` SELECT user_id, GROUP_CONCAT(DISTINCT user_type) AS user_types\n`;
|
||||
sql += ` FROM user_types\n`;
|
||||
sql += ` GROUP BY user_id\n`;
|
||||
sql += `) ut_agg ON u.id = ut_agg.user_id\n`;
|
||||
sql += `LEFT JOIN media m ON (\n`;
|
||||
sql += ` u.id = m.user_id\n`;
|
||||
sql += ` AND m.is_primary = 1\n`;
|
||||
sql += `)\n`;
|
||||
sql += `LEFT JOIN media_paradigms mp ON (\n`;
|
||||
sql += ` m.id = mp.media_id\n`;
|
||||
sql += ` AND mp.media_paradigm = 'user-profile-image'\n`;
|
||||
sql += `)\n`;
|
||||
sql += `WHERE COALESCE(u.archived, 0) <> 1\n`;
|
||||
|
||||
const target_user_id = params?.user_id || query?.user_id;
|
||||
|
||||
if (target_user_id) {
|
||||
sql += ` AND u.id=?\n`;
|
||||
values.push(target_user_id);
|
||||
}
|
||||
|
||||
if (query?.search_term_user_type) {
|
||||
sql += ` AND EXISTS (\n`;
|
||||
sql += ` SELECT 1 FROM user_types WHERE user_id = u.id AND user_type LIKE`;
|
||||
|
||||
if (Array.isArray(query.search_term_user_type)) {
|
||||
sql += `(`;
|
||||
for (let i = 0; i < query.search_term_user_type.length; i++) {
|
||||
const ut = query.search_term_user_type[i];
|
||||
sql += ` ?`;
|
||||
values.push(`%${ut}%`);
|
||||
}
|
||||
sql += `)`;
|
||||
} else {
|
||||
sql += ` ?`;
|
||||
values.push(`%${query.search_term_user_type}%`);
|
||||
}
|
||||
|
||||
sql += ` )\n`;
|
||||
} else if (query?.target_user_type) {
|
||||
sql += ` AND EXISTS (
|
||||
SELECT 1 FROM user_types WHERE user_id = u.id AND user_type = ?
|
||||
)\n`;
|
||||
values.push(query.target_user_type);
|
||||
}
|
||||
|
||||
let search_terms = [
|
||||
{ field: "first_name", value: query?.search_term_first_name },
|
||||
{ field: "last_name", value: query?.search_term_last_name },
|
||||
{ field: "email", value: query?.search_term_email },
|
||||
{ field: "id", value: query?.search_term_id },
|
||||
] as const;
|
||||
|
||||
for (const term of search_terms) {
|
||||
if (term.value) {
|
||||
const isId = term.field === "id";
|
||||
const match_value = params?.exact_match ? "=" : "LIKE";
|
||||
|
||||
if (Array.isArray(term.value)) {
|
||||
sql += ` AND (`;
|
||||
for (let i = 0; i < term.value.length; i++) {
|
||||
const term_value = term.value[i];
|
||||
if (!term_value) continue;
|
||||
const match_value_param = params?.exact_match
|
||||
? term_value
|
||||
: `%${term_value.replace(/ /g, "%")}%`;
|
||||
|
||||
sql += ` u.${term.field} ${isId ? "=" : match_value} ?`;
|
||||
if (i < term.value.length - 1) {
|
||||
sql += ` OR`;
|
||||
}
|
||||
values.push(isId ? term_value : match_value_param);
|
||||
}
|
||||
sql += ` )\n`;
|
||||
} else {
|
||||
const match_value_param = params?.exact_match
|
||||
? term.value
|
||||
: `%${term.value.replace(/ /g, "%")}%`;
|
||||
sql += ` AND u.${term.field} ${isId ? "=" : match_value} ?\n`;
|
||||
values.push(isId ? term.value : match_value_param);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let sql_limit = ``;
|
||||
sql_limit += `LIMIT ${LIMIT}\n`;
|
||||
if (query?.page) {
|
||||
const offset = LIMIT * (query.page - 1);
|
||||
sql_limit += `OFFSET ${offset}\n`;
|
||||
}
|
||||
|
||||
const res = await BunSQLite.sql<BUN_SQLITE_BSQLITE_USERS_JOIN>({
|
||||
sql: `${sql}\n${sql_limit}`,
|
||||
values,
|
||||
});
|
||||
|
||||
if (query?.count) {
|
||||
const count_sql = `SELECT COUNT(*) AS count FROM (${sql}) x`;
|
||||
// const count_sql = `SELECT COUNT(*) AS count FROM users u WHERE u.archived IS NOT 1 ${
|
||||
// query?.user_id ? "AND u.id = ?" : ""
|
||||
// }`;
|
||||
const count_res = await BunSQLite.sql({ sql: count_sql, values });
|
||||
res.count = count_res.singleRes?.count;
|
||||
}
|
||||
|
||||
return res;
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
msg: error.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import TurndownService from "turndown";
|
||||
import { extractText, getDocumentProxy } from "unpdf";
|
||||
import mammoth from "mammoth";
|
||||
// import * as XLSX from "xlsx";
|
||||
import bufferFromUrl from "@/src/utils/buffer-from-url";
|
||||
|
||||
const turndownService = new TurndownService({
|
||||
headingStyle: "atx",
|
||||
codeBlockStyle: "fenced",
|
||||
});
|
||||
|
||||
type Params = {
|
||||
file_stream?: Buffer<ArrayBuffer>;
|
||||
file_path?: string;
|
||||
/**
|
||||
* File extension, e.g. `pdf`, `docx`, `xlsx`
|
||||
*/
|
||||
file_type?: string;
|
||||
url?: string;
|
||||
};
|
||||
|
||||
export default async function fileToText({
|
||||
file_type,
|
||||
file_stream,
|
||||
file_path,
|
||||
url,
|
||||
}: Params): Promise<string | undefined> {
|
||||
let buffer: Buffer | undefined;
|
||||
let ext: string;
|
||||
|
||||
if (url) {
|
||||
const result = await bufferFromUrl(url);
|
||||
buffer = result.buffer;
|
||||
ext = result.ext;
|
||||
} else {
|
||||
buffer = file_path
|
||||
? Buffer.from(await Bun.file(file_path).arrayBuffer())
|
||||
: file_stream;
|
||||
ext = (file_type || file_path?.split(".").pop() || "txt")
|
||||
.toString()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
if (!buffer) return undefined;
|
||||
|
||||
switch (ext) {
|
||||
case "docx": {
|
||||
const { value: html } = await mammoth.convertToHtml({ buffer });
|
||||
return turndownService.turndown(html);
|
||||
}
|
||||
|
||||
case "pdf": {
|
||||
try {
|
||||
const pdfUintBuffer = new Uint8Array(buffer);
|
||||
const pdfDocProxy = await getDocumentProxy(pdfUintBuffer);
|
||||
const { text } = await extractText(pdfDocProxy, {
|
||||
mergePages: true,
|
||||
});
|
||||
return text;
|
||||
} catch (err: any) {
|
||||
console.error(
|
||||
`PDF text extraction failed: ${err?.message ?? err}`,
|
||||
);
|
||||
throw new Error(
|
||||
`Failed to extract text from PDF: ${err?.message ?? "unknown error"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// case "xlsx":
|
||||
// case "csv": {
|
||||
// const workbook = XLSX.read(buffer, { type: "buffer" });
|
||||
// return workbook.SheetNames.map((name) => {
|
||||
// const sheet = workbook.Sheets[name];
|
||||
// return `### Sheet: ${name}\n\n${XLSX.utils.sheet_to_csv(sheet!)}`;
|
||||
// }).join("\n\n");
|
||||
// }
|
||||
|
||||
case "md":
|
||||
case "txt":
|
||||
default:
|
||||
return buffer.toString("utf-8");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { MediaDataType } from "@/src/types";
|
||||
|
||||
export default async function MediaDataToArrayBuffer(
|
||||
body: MediaDataType,
|
||||
): Promise<ArrayBuffer> {
|
||||
if (body == null) {
|
||||
return new ArrayBuffer(0);
|
||||
}
|
||||
if (body instanceof ArrayBuffer) {
|
||||
return body;
|
||||
}
|
||||
if (body instanceof SharedArrayBuffer) {
|
||||
const copy = new ArrayBuffer(body.byteLength);
|
||||
new Uint8Array(copy).set(new Uint8Array(body));
|
||||
return copy;
|
||||
}
|
||||
if (typeof body === "string") {
|
||||
return new TextEncoder().encode(body).buffer as ArrayBuffer;
|
||||
}
|
||||
if (body instanceof ReadableStream) {
|
||||
return Bun.readableStreamToArrayBuffer(body);
|
||||
}
|
||||
// Blob, BunFile, Request, Response — wrap in Response and use Bun's reader
|
||||
return new Response(body as BodyInit)
|
||||
.bytes()
|
||||
.then((b) => b.buffer as ArrayBuffer);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { MediaDataType } from "@/src/types";
|
||||
|
||||
export default async function mediaDataToSharpInput(
|
||||
body: MediaDataType,
|
||||
): Promise<Buffer> {
|
||||
if (body instanceof Buffer) return body;
|
||||
if (body instanceof SharedArrayBuffer)
|
||||
return Buffer.from(new Uint8Array(body));
|
||||
if (body instanceof ArrayBuffer) return Buffer.from(body);
|
||||
if (body instanceof Uint8Array) return Buffer.from(body);
|
||||
if (body instanceof Blob || body instanceof File)
|
||||
return Buffer.from(await body.arrayBuffer());
|
||||
if (body instanceof Response || body instanceof Request)
|
||||
return Buffer.from(await body.arrayBuffer());
|
||||
if (typeof body === "string") return Buffer.from(body, "base64");
|
||||
if (ArrayBuffer.isView(body))
|
||||
return Buffer.from(body.buffer, body.byteOffset, body.byteLength);
|
||||
|
||||
if ("arrayBuffer" in body && typeof body.arrayBuffer === "function") {
|
||||
return Buffer.from(await (body as any).arrayBuffer());
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported body type: ${typeof body}`);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { MediaDataType } from "@/src/types";
|
||||
import MediaDataToArrayBuffer from "./media-data-to-array-buffer";
|
||||
|
||||
export default async function sniffMediaExtension(
|
||||
data: MediaDataType,
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
const bytes = new Uint8Array(await MediaDataToArrayBuffer(data));
|
||||
|
||||
if (bytes.length < 12) return undefined;
|
||||
|
||||
const has = (...signature: number[]) =>
|
||||
signature.every((byte, i) => bytes[i] === byte);
|
||||
|
||||
if (has(0xff, 0xd8, 0xff)) return "jpg";
|
||||
if (has(0x89, 0x50, 0x4e, 0x47)) return "png";
|
||||
if (has(0x47, 0x49, 0x46, 0x38)) return "gif";
|
||||
if (has(0x25, 0x50, 0x44, 0x46)) return "pdf";
|
||||
if (has(0x50, 0x4b, 0x03, 0x04)) return "zip";
|
||||
if (has(0x49, 0x44, 0x33)) return "mp3";
|
||||
if (has(0x4f, 0x67, 0x67, 0x53)) return "ogg";
|
||||
|
||||
if (has(0x52, 0x49, 0x46, 0x46)) {
|
||||
const riff_type = String.fromCharCode(
|
||||
bytes[8]!,
|
||||
bytes[9]!,
|
||||
bytes[10]!,
|
||||
bytes[11]!,
|
||||
);
|
||||
|
||||
if (riff_type === "WEBP") return "webp";
|
||||
if (riff_type === "WAVE") return "wav";
|
||||
}
|
||||
|
||||
if (
|
||||
String.fromCharCode(bytes[4]!, bytes[5]!, bytes[6]!, bytes[7]!) ===
|
||||
"ftyp"
|
||||
) {
|
||||
return "mp4";
|
||||
}
|
||||
|
||||
const head = String.fromCharCode(...bytes.subarray(0, 8)).trimStart();
|
||||
|
||||
if (head.startsWith("<svg") || head.startsWith("<?xml")) return "svg";
|
||||
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import type {
|
||||
BUN_SQLITE_BSQLITE_MEDIA,
|
||||
BUN_SQLITE_BSQLITE_MEDIA_PARADIGMS,
|
||||
BUN_SQLITE_BSQLITE_USERS,
|
||||
BunSQLiteTables,
|
||||
} from "@/db/types/db";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import type {
|
||||
MediaDataType,
|
||||
MediaParadigm,
|
||||
MediaType,
|
||||
TableType,
|
||||
User,
|
||||
} from "@/src/types";
|
||||
import grabDirNames from "@/src/utils/grab-dir-names";
|
||||
import BunSQLite, { type APIResponseObject } from "@moduletrace/bun-sqlite";
|
||||
import { existsSync, mkdirSync } from "fs";
|
||||
import path from "path";
|
||||
import sharp from "sharp";
|
||||
import fileToText from "./file-to-text";
|
||||
import MediaDataToArrayBuffer from "./media-data-to-array-buffer";
|
||||
import sniffMediaExtension from "./sniff-media-ext";
|
||||
import mediaDataToSharpInput from "./meida-data-to-sharp-input";
|
||||
import deleteMedia from "../../media/delete-media";
|
||||
|
||||
type Params = {
|
||||
media_paradigms?: MediaParadigm[];
|
||||
media_type?: MediaType;
|
||||
user: BUN_SQLITE_BSQLITE_USERS | User;
|
||||
data?: MediaDataType;
|
||||
is_primary?: boolean;
|
||||
update_on_duplicate?: boolean;
|
||||
extract_media_text_content?: boolean;
|
||||
media?: BUN_SQLITE_BSQLITE_MEDIA;
|
||||
media_name?: string;
|
||||
media_mime_type?: string;
|
||||
is_private?: boolean;
|
||||
image_width?: number;
|
||||
image_height?: number;
|
||||
thumbnail_width?: number;
|
||||
existing_media_id?: string | number;
|
||||
media_text_content?: string;
|
||||
};
|
||||
|
||||
export default async function uploadAndRecordMedia({
|
||||
media_paradigms,
|
||||
user,
|
||||
data,
|
||||
media_type,
|
||||
is_primary,
|
||||
update_on_duplicate,
|
||||
extract_media_text_content,
|
||||
media,
|
||||
media_mime_type,
|
||||
media_name,
|
||||
is_private,
|
||||
image_width,
|
||||
image_height,
|
||||
thumbnail_width,
|
||||
existing_media_id,
|
||||
media_text_content: passed_media_text_content,
|
||||
}: Params): Promise<APIResponseObject<BUN_SQLITE_BSQLITE_MEDIA>> {
|
||||
const now = Date.now();
|
||||
let target_file_name = `${media_name || now}`;
|
||||
let target_file_thumbnail_name = `${media_name || now}-thumb`;
|
||||
|
||||
const {
|
||||
USER_MEDIA_PRIVATE_RELATIVE_DIR,
|
||||
USER_MEDIA_PUBLIC_RELATIVE_DIR,
|
||||
DATA_DIR,
|
||||
} = grabDirNames({
|
||||
user,
|
||||
});
|
||||
|
||||
const MEDIA_RELATIVE_DIR = is_private
|
||||
? USER_MEDIA_PRIVATE_RELATIVE_DIR
|
||||
: USER_MEDIA_PUBLIC_RELATIVE_DIR;
|
||||
|
||||
if (!MEDIA_RELATIVE_DIR) {
|
||||
throw new Error(`No target media directory found!`);
|
||||
}
|
||||
|
||||
let media_written_to_disk = false;
|
||||
let media_text_content = passed_media_text_content;
|
||||
|
||||
let media_ext = media_mime_type
|
||||
? media_mime_type.replace(/\./g, "")
|
||||
: undefined;
|
||||
|
||||
if (!media_ext && data) {
|
||||
if (typeof data === "string" && data.startsWith("data:")) {
|
||||
const base64_mime = data.match(/^data:([^;]+);base64,/)?.[1];
|
||||
|
||||
if (base64_mime) {
|
||||
media_ext = base64_mime.split("/").pop() ?? undefined;
|
||||
}
|
||||
} else if (data instanceof File && data.name.includes(".")) {
|
||||
media_ext = data.name.split(".").pop();
|
||||
} else if (
|
||||
data instanceof Blob &&
|
||||
data.type &&
|
||||
!data.type.includes("octet-stream")
|
||||
) {
|
||||
media_ext = data.type.split("/").pop();
|
||||
} else if (media_type === "image") {
|
||||
media_ext = "jpeg";
|
||||
} else if (typeof data !== "string") {
|
||||
media_ext = await sniffMediaExtension(data);
|
||||
}
|
||||
}
|
||||
|
||||
const media_write_path = path.join(
|
||||
MEDIA_RELATIVE_DIR,
|
||||
`${target_file_name}.${media_ext}`,
|
||||
);
|
||||
const media_thumbnail_write_path = path.join(
|
||||
MEDIA_RELATIVE_DIR,
|
||||
`${target_file_thumbnail_name}.${media_ext}`,
|
||||
);
|
||||
|
||||
const media_write_path_full = path.join(DATA_DIR, media_write_path);
|
||||
const media_thumbnail_write_path_full = path.join(
|
||||
DATA_DIR,
|
||||
media_thumbnail_write_path,
|
||||
);
|
||||
|
||||
mkdirSync(path.dirname(media_write_path_full), { recursive: true });
|
||||
|
||||
if (data && media_type == "image" && media_ext) {
|
||||
const buffer = await mediaDataToSharpInput(data);
|
||||
const is_profile_image =
|
||||
media_paradigms?.includes("user-profile-image");
|
||||
const thumb_width = thumbnail_width ?? AppData["ImageThumbnailSize"];
|
||||
|
||||
let full_width: number | undefined;
|
||||
let full_height: number | undefined;
|
||||
let full_options: {
|
||||
fit?: "cover" | "inside";
|
||||
withoutEnlargement?: boolean;
|
||||
} = {};
|
||||
|
||||
if (is_profile_image) {
|
||||
full_width = AppData["MainImageSize"];
|
||||
full_height = AppData["MainImageSize"];
|
||||
full_options = { fit: "cover" };
|
||||
} else {
|
||||
const meta = await sharp(buffer).metadata();
|
||||
const source_width = image_width ?? meta.width;
|
||||
const source_height = image_height ?? meta.height;
|
||||
const max = AppData["MaxImageWidth"];
|
||||
|
||||
full_width =
|
||||
source_width != null
|
||||
? source_width > max
|
||||
? max
|
||||
: source_width
|
||||
: max;
|
||||
full_height =
|
||||
image_height != null
|
||||
? source_height != null && source_height > max
|
||||
? max
|
||||
: source_height
|
||||
: undefined;
|
||||
full_options = { fit: "inside", withoutEnlargement: true };
|
||||
}
|
||||
|
||||
const [full, thumb] = await Promise.all([
|
||||
sharp(buffer)
|
||||
.resize(full_width, full_height, full_options)
|
||||
.jpeg({ quality: AppData["ImageQuality"] })
|
||||
.toBuffer(),
|
||||
sharp(buffer)
|
||||
.resize(
|
||||
thumb_width,
|
||||
is_profile_image ? thumb_width : undefined,
|
||||
is_profile_image
|
||||
? { fit: "cover" }
|
||||
: { fit: "inside", withoutEnlargement: true },
|
||||
)
|
||||
.jpeg({ quality: AppData["ImageThumbnailQuality"] })
|
||||
.toBuffer(),
|
||||
]);
|
||||
|
||||
await Bun.write(media_write_path_full, full);
|
||||
await Bun.write(media_thumbnail_write_path_full, thumb);
|
||||
} else if (data) {
|
||||
const media_array_buffer = await MediaDataToArrayBuffer(data);
|
||||
await Bun.write(media_write_path_full, media_array_buffer, {
|
||||
mode: 1,
|
||||
});
|
||||
|
||||
if (extract_media_text_content) {
|
||||
const media_text = await fileToText({
|
||||
file_type: media_ext,
|
||||
file_stream: Buffer.from(media_array_buffer),
|
||||
});
|
||||
|
||||
if (media_text) {
|
||||
media_text_content = media_text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (existsSync(media_write_path)) {
|
||||
media_written_to_disk = true;
|
||||
}
|
||||
|
||||
if (is_primary) {
|
||||
const existing_user_primary_media = await BunSQLite.select<
|
||||
BUN_SQLITE_BSQLITE_MEDIA,
|
||||
TableType
|
||||
>({
|
||||
table: "media",
|
||||
query: {
|
||||
query: {
|
||||
user_id: { value: user.id },
|
||||
is_primary: { value: 1 },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (existing_user_primary_media.payload) {
|
||||
await deleteMedia({
|
||||
can_delete_all_media: true,
|
||||
ids: existing_user_primary_media.payload.map((m) => m.id!),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const media_data: BUN_SQLITE_BSQLITE_MEDIA = {
|
||||
...media,
|
||||
user_id: Number(user.id),
|
||||
is_primary: is_primary ? 1 : 0,
|
||||
media_write_path: media_write_path,
|
||||
media_thumbnail_write_path:
|
||||
media_type === "image" ? media_thumbnail_write_path : undefined,
|
||||
media_type: media_type as any,
|
||||
mime_type: media_type == "image" ? "jpeg" : media_ext,
|
||||
text_content: media_text_content,
|
||||
};
|
||||
|
||||
let media_record: BUN_SQLITE_BSQLITE_MEDIA | undefined = undefined;
|
||||
|
||||
if (existing_media_id) {
|
||||
const update_media_record = await BunSQLite.update<
|
||||
BUN_SQLITE_BSQLITE_MEDIA,
|
||||
TableType
|
||||
>({
|
||||
data: media_data,
|
||||
table: "media",
|
||||
targetId: existing_media_id,
|
||||
});
|
||||
|
||||
media_record = update_media_record.singleRes || undefined;
|
||||
} else {
|
||||
const new_media_record = await BunSQLite.insert<
|
||||
BUN_SQLITE_BSQLITE_MEDIA,
|
||||
TableType
|
||||
>({
|
||||
data: [media_data],
|
||||
table: "media",
|
||||
update_on_duplicate,
|
||||
});
|
||||
|
||||
media_record = new_media_record.singleRes || undefined;
|
||||
}
|
||||
|
||||
if (!media_record) {
|
||||
throw new Error(`Couldn't record media!`);
|
||||
}
|
||||
|
||||
if (media_paradigms?.[0] && media_record?.id) {
|
||||
const delete_existing_paradigms = await BunSQLite.delete<
|
||||
BUN_SQLITE_BSQLITE_MEDIA_PARADIGMS,
|
||||
(typeof BunSQLiteTables)[number]
|
||||
>({
|
||||
table: "media_paradigms",
|
||||
query: {
|
||||
query: { media_id: { value: media_record.id } },
|
||||
},
|
||||
});
|
||||
|
||||
await BunSQLite.insert<
|
||||
BUN_SQLITE_BSQLITE_MEDIA_PARADIGMS,
|
||||
(typeof BunSQLiteTables)[number]
|
||||
>({
|
||||
data: media_paradigms.map((mp) => ({
|
||||
media_id: media_record.id,
|
||||
media_paradigm: mp,
|
||||
})),
|
||||
table: "media_paradigms",
|
||||
update_on_duplicate,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
singleRes: media_record,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import sanitizeRichTextHtml from "@/src/utils/sanitize-rich-text-html";
|
||||
|
||||
type UserWithBio = {
|
||||
bio?: string | null;
|
||||
};
|
||||
|
||||
type Params<T extends UserWithBio> = {
|
||||
user: T;
|
||||
};
|
||||
|
||||
/**
|
||||
* # Sanitize a user's `bio` HTML.
|
||||
* The `bio` column is editable by every logged-in user (including
|
||||
* `read_only`) and rendered with `dangerouslySetInnerHTML`, so it must be
|
||||
* sanitized on write and before render.
|
||||
*/
|
||||
export default function sanitizeUserBio<T extends UserWithBio>({
|
||||
user,
|
||||
}: Params<T>): T {
|
||||
if (typeof user.bio !== "string" || !user.bio) {
|
||||
return user;
|
||||
}
|
||||
|
||||
return {
|
||||
...user,
|
||||
bio: sanitizeRichTextHtml({ html: user.bio }),
|
||||
} as T;
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
import { formatInvoiceDate } from "@/src/functions/backend/pdf/letter-date";
|
||||
import { CompanyLetterheadDefaults } from "@/src/dict/company-dict";
|
||||
import { formatNaira } from "@/src/utils/money";
|
||||
import { ThemeColors } from "@/src/functions/backend/theme/theme-colors";
|
||||
|
||||
const letter_divider = (
|
||||
<hr
|
||||
style={{
|
||||
border: "none",
|
||||
borderTop: `1px solid ${ThemeColors.hairline}`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
type InvoiceEmailContentProps = {
|
||||
invoice: {
|
||||
invoice_number?: string;
|
||||
client_name?: string;
|
||||
issue_date?: number | "";
|
||||
due_date?: number | "";
|
||||
status?: string;
|
||||
notes?: string;
|
||||
};
|
||||
items: {
|
||||
description?: string;
|
||||
quantity?: number | string | "";
|
||||
unit_price_kobo?: number | string | "";
|
||||
}[];
|
||||
};
|
||||
|
||||
type QuoteEmailContentProps = {
|
||||
quote: {
|
||||
quote_number?: string;
|
||||
client_name?: string;
|
||||
issue_date?: number | "";
|
||||
valid_until?: number | "";
|
||||
status?: string;
|
||||
notes?: string;
|
||||
};
|
||||
items: {
|
||||
description?: string;
|
||||
quantity?: number | string | "";
|
||||
unit_price_kobo?: number | string | "";
|
||||
}[];
|
||||
};
|
||||
|
||||
export function InvoiceEmailContent({
|
||||
invoice,
|
||||
items,
|
||||
}: InvoiceEmailContentProps) {
|
||||
const total_kobo = items.reduce((acc, item) => {
|
||||
return (
|
||||
acc +
|
||||
(Number(item.quantity) || 0) * (Number(item.unit_price_kobo) || 0)
|
||||
);
|
||||
}, 0);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "13px",
|
||||
fontWeight: "600",
|
||||
color: ThemeColors.ink,
|
||||
}}
|
||||
>
|
||||
{CompanyLetterheadDefaults.name}
|
||||
</div>
|
||||
{letter_divider}
|
||||
|
||||
<h1
|
||||
style={{
|
||||
fontSize: "18px",
|
||||
margin: "0 0 6px",
|
||||
}}
|
||||
>
|
||||
Invoice {invoice.invoice_number || ""}
|
||||
</h1>
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontSize: "13px",
|
||||
lineHeight: "1.7",
|
||||
color: ThemeColors.body,
|
||||
}}
|
||||
>
|
||||
Hello {invoice.client_name || "there"},
|
||||
<br />
|
||||
Please find attached invoice {invoice.invoice_number || ""} for
|
||||
your records.
|
||||
</div>
|
||||
|
||||
{letter_divider}
|
||||
|
||||
<div style={{ fontSize: "14px", lineHeight: "1.6" }}>
|
||||
<table
|
||||
width="100%"
|
||||
cellPadding="0"
|
||||
cellSpacing="0"
|
||||
style={{ fontSize: "13px", borderCollapse: "collapse" }}
|
||||
>
|
||||
<tr
|
||||
style={{
|
||||
background: ThemeColors.ink,
|
||||
color: ThemeColors.surface,
|
||||
fontWeight: "600",
|
||||
}}
|
||||
>
|
||||
<td style={{ padding: "8px 10px" }}>Description</td>
|
||||
<td align="right" style={{ padding: "8px 10px" }}>
|
||||
Qty
|
||||
</td>
|
||||
<td align="right" style={{ padding: "8px 10px" }}>
|
||||
Amount
|
||||
</td>
|
||||
</tr>
|
||||
{items.map((item, index) => (
|
||||
<tr
|
||||
key={index}
|
||||
style={
|
||||
index % 2 == 0
|
||||
? { background: ThemeColors.faint }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<td style={{ padding: "7px 10px" }}>
|
||||
{item.description}
|
||||
</td>
|
||||
<td align="right" style={{ padding: "7px 10px" }}>
|
||||
{Number(item.quantity) || 0}
|
||||
</td>
|
||||
<td align="right" style={{ padding: "7px 10px" }}>
|
||||
{formatNaira(
|
||||
(Number(item.quantity) || 0) *
|
||||
(Number(item.unit_price_kobo) || 0),
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr>
|
||||
<td
|
||||
colSpan={2}
|
||||
align="right"
|
||||
style={{
|
||||
padding: "9px 10px",
|
||||
fontWeight: "600",
|
||||
}}
|
||||
>
|
||||
Total (NGN)
|
||||
</td>
|
||||
<td
|
||||
align="right"
|
||||
style={{
|
||||
padding: "9px 10px",
|
||||
fontWeight: "600",
|
||||
}}
|
||||
>
|
||||
{formatNaira(total_kobo)}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{letter_divider}
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontSize: "13px",
|
||||
lineHeight: "1.7",
|
||||
color: ThemeColors.body,
|
||||
}}
|
||||
>
|
||||
Invoice {invoice.invoice_number || ""} was issued on{" "}
|
||||
{formatInvoiceDate(invoice.issue_date || Date.now())}
|
||||
{invoice.due_date
|
||||
? ` and is due by ${formatInvoiceDate(invoice.due_date)}`
|
||||
: ""}
|
||||
.
|
||||
<br />
|
||||
<br />
|
||||
Thank you for your business.
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: ThemeColors.muted,
|
||||
borderTop: `1px solid ${ThemeColors.hairline}`,
|
||||
paddingTop: "12px",
|
||||
}}
|
||||
>
|
||||
{CompanyLetterheadDefaults.name} -{" "}
|
||||
{CompanyLetterheadDefaults.address_lines.join(", ")} |{" "}
|
||||
{CompanyLetterheadDefaults.phone} |{" "}
|
||||
{CompanyLetterheadDefaults.email} |{" "}
|
||||
{CompanyLetterheadDefaults.website}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export function QuoteEmailContent({ quote, items }: QuoteEmailContentProps) {
|
||||
const total_kobo = items.reduce((acc, item) => {
|
||||
return (
|
||||
acc +
|
||||
(Number(item.quantity) || 0) * (Number(item.unit_price_kobo) || 0)
|
||||
);
|
||||
}, 0);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "13px",
|
||||
fontWeight: "600",
|
||||
color: ThemeColors.ink,
|
||||
}}
|
||||
>
|
||||
{CompanyLetterheadDefaults.name}
|
||||
</div>
|
||||
{letter_divider}
|
||||
|
||||
<h1
|
||||
style={{
|
||||
fontSize: "18px",
|
||||
margin: "0 0 6px",
|
||||
}}
|
||||
>
|
||||
Quotation {quote.quote_number || ""}
|
||||
</h1>
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontSize: "13px",
|
||||
lineHeight: "1.7",
|
||||
color: ThemeColors.body,
|
||||
}}
|
||||
>
|
||||
Hello {quote.client_name || "there"},
|
||||
<br />
|
||||
Please find attached quotation {quote.quote_number || ""} for
|
||||
our proposed works.
|
||||
</div>
|
||||
|
||||
{letter_divider}
|
||||
|
||||
<div style={{ fontSize: "14px", lineHeight: "1.6" }}>
|
||||
<table
|
||||
width="100%"
|
||||
cellPadding="0"
|
||||
cellSpacing="0"
|
||||
style={{ fontSize: "13px", borderCollapse: "collapse" }}
|
||||
>
|
||||
<tr
|
||||
style={{
|
||||
background: ThemeColors.ink,
|
||||
color: ThemeColors.surface,
|
||||
fontWeight: "600",
|
||||
}}
|
||||
>
|
||||
<td style={{ padding: "8px 10px" }}>Description</td>
|
||||
<td align="right" style={{ padding: "8px 10px" }}>
|
||||
Qty
|
||||
</td>
|
||||
<td align="right" style={{ padding: "8px 10px" }}>
|
||||
Amount
|
||||
</td>
|
||||
</tr>
|
||||
{items.map((item, index) => (
|
||||
<tr
|
||||
key={index}
|
||||
style={
|
||||
index % 2 == 0
|
||||
? { background: ThemeColors.faint }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<td style={{ padding: "7px 10px" }}>
|
||||
{item.description}
|
||||
</td>
|
||||
<td align="right" style={{ padding: "7px 10px" }}>
|
||||
{Number(item.quantity) || 0}
|
||||
</td>
|
||||
<td align="right" style={{ padding: "7px 10px" }}>
|
||||
{formatNaira(
|
||||
(Number(item.quantity) || 0) *
|
||||
(Number(item.unit_price_kobo) || 0),
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr>
|
||||
<td
|
||||
colSpan={2}
|
||||
align="right"
|
||||
style={{
|
||||
padding: "9px 10px",
|
||||
fontWeight: "600",
|
||||
}}
|
||||
>
|
||||
Total (NGN)
|
||||
</td>
|
||||
<td
|
||||
align="right"
|
||||
style={{
|
||||
padding: "9px 10px",
|
||||
fontWeight: "600",
|
||||
}}
|
||||
>
|
||||
{formatNaira(total_kobo)}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{letter_divider}
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontSize: "13px",
|
||||
lineHeight: "1.7",
|
||||
color: ThemeColors.body,
|
||||
}}
|
||||
>
|
||||
Quotation {quote.quote_number || ""} was issued on{" "}
|
||||
{formatInvoiceDate(quote.issue_date || Date.now())}
|
||||
{quote.valid_until
|
||||
? ` and is valid until ${formatInvoiceDate(quote.valid_until)}`
|
||||
: ""}
|
||||
.
|
||||
<br />
|
||||
<br />
|
||||
We look forward to working with you.
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: ThemeColors.muted,
|
||||
borderTop: `1px solid ${ThemeColors.hairline}`,
|
||||
paddingTop: "12px",
|
||||
}}
|
||||
>
|
||||
{CompanyLetterheadDefaults.name} -{" "}
|
||||
{CompanyLetterheadDefaults.address_lines.join(", ")} |{" "}
|
||||
{CompanyLetterheadDefaults.phone} |{" "}
|
||||
{CompanyLetterheadDefaults.email} |{" "}
|
||||
{CompanyLetterheadDefaults.website}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import nodemailer from "nodemailer";
|
||||
|
||||
const SMTPTimeoutMilliseconds = 15 * 1000;
|
||||
|
||||
const EmailClient = nodemailer.createTransport({
|
||||
host: process.env.EMAIL_DOMAIN,
|
||||
port: 465,
|
||||
secure: true,
|
||||
connectionTimeout: SMTPTimeoutMilliseconds,
|
||||
greetingTimeout: SMTPTimeoutMilliseconds,
|
||||
auth: {
|
||||
user: process.env.EMAIL_USER,
|
||||
pass: process.env.EMAIL_PASS,
|
||||
},
|
||||
});
|
||||
|
||||
export default EmailClient;
|
||||
@@ -0,0 +1,22 @@
|
||||
type Params = {
|
||||
/**
|
||||
* The real recipient, used in production
|
||||
*/
|
||||
to: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Outside production, all outgoing mail is redirected to
|
||||
* `DEV_EMAIL_RECIPIENT` so real users never get test emails. Returns
|
||||
* `undefined` when no dev recipient is configured - callers should skip
|
||||
* sending in that case.
|
||||
*/
|
||||
export default function resolveEmailRecipient({
|
||||
to,
|
||||
}: Params): string | undefined {
|
||||
if (process.env.NODE_ENV == "production") {
|
||||
return to;
|
||||
}
|
||||
|
||||
return process.env.DEV_EMAIL_RECIPIENT || undefined;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { renderToString } from "react-dom/server";
|
||||
import path from "path";
|
||||
import type Mail from "nodemailer/lib/mailer";
|
||||
import type SMTPTransport from "nodemailer/lib/smtp-transport";
|
||||
import type { FC, ReactNode } from "react";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
import EmailClient from "./email-client";
|
||||
import { SiteData } from "@/src/data/site-data";
|
||||
|
||||
type Params = {
|
||||
options?: Mail.Options & Partial<SMTPTransport.Options>;
|
||||
to: string;
|
||||
content: ReactNode;
|
||||
subject: string;
|
||||
text: string;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
export default async function sendEmail({
|
||||
options,
|
||||
to,
|
||||
content,
|
||||
subject,
|
||||
text,
|
||||
title,
|
||||
}: Params): Promise<APIResponseObject> {
|
||||
try {
|
||||
const now = Date.now();
|
||||
const EmailRoot = (
|
||||
await import(path.join(process.cwd(), `src/email/root?t=${now}`))
|
||||
).default as FC<any>;
|
||||
const final_component = <EmailRoot>{content}</EmailRoot>;
|
||||
|
||||
const html = renderToString(final_component);
|
||||
|
||||
const mail = await EmailClient.sendMail({
|
||||
from: `"${title || `${SiteData["SiteName"]} Mailer`}" <${process.env.EMAIL_USER}>`,
|
||||
to,
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
sender: `${process.env.EMAIL_USER}`,
|
||||
...options,
|
||||
});
|
||||
|
||||
return {
|
||||
success: Boolean(mail.accepted?.[0]),
|
||||
singleRes: mail,
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
msg: error.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import { LogCategories } from "@/src/dict/log-categories";
|
||||
|
||||
export type LogCategory = (typeof LogCategories)[number]["value"];
|
||||
|
||||
export function grabRequestIP(req?: Request): string | undefined {
|
||||
if (!req) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const forwarded = req.headers.get("x-forwarded-for");
|
||||
|
||||
if (forwarded) {
|
||||
return forwarded.split(",").shift()?.trim();
|
||||
}
|
||||
|
||||
return req.headers.get("x-real-ip") || undefined;
|
||||
}
|
||||
|
||||
export type LogDetail = {
|
||||
label: string;
|
||||
value: unknown;
|
||||
};
|
||||
|
||||
export type LogEventParams = {
|
||||
title: string;
|
||||
category: LogCategory;
|
||||
user_id?: number;
|
||||
plain_text: string;
|
||||
markdown?: string;
|
||||
html?: string;
|
||||
details?: LogDetail[];
|
||||
metadata?: Record<string, unknown>;
|
||||
ip_address?: string;
|
||||
};
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
export function buildLogFormats({
|
||||
plain_text,
|
||||
details = [],
|
||||
}: {
|
||||
plain_text: string;
|
||||
details?: LogDetail[];
|
||||
}): { markdown: string; html: string } {
|
||||
let markdown = plain_text;
|
||||
|
||||
if (details.length) {
|
||||
markdown +=
|
||||
`\n\n| Field | Value |\n| --- | --- |\n` +
|
||||
details
|
||||
.map(
|
||||
(detail) =>
|
||||
`| ${detail.label} | \`${String(detail.value)}\` |`,
|
||||
)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
const html_details = details.length
|
||||
? `<table><tbody>` +
|
||||
details
|
||||
.map(
|
||||
(detail) =>
|
||||
`<tr><td><strong>${escapeHtml(detail.label)}</strong></td><td><code>${escapeHtml(
|
||||
String(detail.value),
|
||||
)}</code></td></tr>`,
|
||||
)
|
||||
.join("") +
|
||||
`</tbody></table>`
|
||||
: "";
|
||||
|
||||
const html =
|
||||
`<p>${escapeHtml(plain_text).replaceAll("\n", "<br/>")}</p>` +
|
||||
html_details;
|
||||
|
||||
return { markdown, html };
|
||||
}
|
||||
|
||||
export default async function logEvent({
|
||||
title,
|
||||
category,
|
||||
user_id,
|
||||
plain_text,
|
||||
markdown,
|
||||
html,
|
||||
details,
|
||||
metadata,
|
||||
ip_address,
|
||||
}: LogEventParams) {
|
||||
try {
|
||||
const formats = buildLogFormats({ plain_text, details });
|
||||
|
||||
return await BunSQLite.insert({
|
||||
table: "logs",
|
||||
data: [
|
||||
{
|
||||
title: title.slice(0, 255),
|
||||
category,
|
||||
user_id,
|
||||
plain_text,
|
||||
markdown: markdown || formats.markdown,
|
||||
html: html || formats.html,
|
||||
metadata: metadata ? JSON.stringify(metadata) : undefined,
|
||||
ip_address,
|
||||
},
|
||||
],
|
||||
});
|
||||
} catch (error) {
|
||||
return { success: false };
|
||||
}
|
||||
}
|
||||
|
||||
export async function logError({
|
||||
title,
|
||||
category = "error",
|
||||
error,
|
||||
user_id,
|
||||
details,
|
||||
metadata,
|
||||
ip_address,
|
||||
}: {
|
||||
title: string;
|
||||
category?: LogCategory;
|
||||
error: unknown;
|
||||
user_id?: number;
|
||||
details?: LogDetail[];
|
||||
metadata?: Record<string, unknown>;
|
||||
ip_address?: string;
|
||||
}) {
|
||||
const error_message =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
const error_stack =
|
||||
error instanceof Error && error.stack ? error.stack : undefined;
|
||||
|
||||
return await logEvent({
|
||||
title,
|
||||
category,
|
||||
user_id,
|
||||
plain_text: `${title}: ${error_message}`,
|
||||
details: [
|
||||
...(details || []),
|
||||
{ label: "Error", value: error_message },
|
||||
],
|
||||
metadata: {
|
||||
...metadata,
|
||||
error_stack,
|
||||
},
|
||||
ip_address,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { BUN_SQLITE_BSQLITE_MEDIA } from "@/db/types/db";
|
||||
import type { BUN_SQLITE_BSQLITE_USERS_JOIN } from "@/src/types/sql-joins";
|
||||
|
||||
type Params = {
|
||||
media?: BUN_SQLITE_BSQLITE_MEDIA;
|
||||
user?: BUN_SQLITE_BSQLITE_USERS_JOIN;
|
||||
};
|
||||
|
||||
export default function grabMediaURL({ media: passed_media, user }: Params) {
|
||||
const media = passed_media || {};
|
||||
|
||||
if (user?.profile_media_id) {
|
||||
media.id = user.profile_media_id;
|
||||
}
|
||||
|
||||
let media_url = `/media`;
|
||||
|
||||
if (media.is_private) {
|
||||
media_url += `/private`;
|
||||
} else {
|
||||
media_url += `/public`;
|
||||
}
|
||||
|
||||
if (!media.id) {
|
||||
return {};
|
||||
}
|
||||
|
||||
media_url += `/${media.id}`;
|
||||
|
||||
const media_thumbnail_url =
|
||||
media.media_type == "image" || user?.id
|
||||
? `${media_url}/thumbnail`
|
||||
: undefined;
|
||||
|
||||
return { media_url, media_thumbnail_url };
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import htmlToTextBlocks from "./html-blocks";
|
||||
|
||||
test("parses paragraphs and inline styles", () => {
|
||||
const blocks = htmlToTextBlocks(
|
||||
`<p>Hello <strong>world</strong> and <em>friends</em>.</p><p>Second <u>underlined</u> paragraph.</p>`,
|
||||
);
|
||||
|
||||
expect(blocks).toHaveLength(2);
|
||||
expect(blocks[0]!.type).toBe("paragraph");
|
||||
expect((blocks[0] as any).segments).toEqual([
|
||||
{ text: "Hello " },
|
||||
{ text: "world", bold: true },
|
||||
{ text: " and " },
|
||||
{ text: "friends", italic: true },
|
||||
{ text: "." },
|
||||
]);
|
||||
expect((blocks[1] as any).segments).toEqual([
|
||||
{ text: "Second " },
|
||||
{ text: "underlined", underline: true },
|
||||
{ text: " paragraph." },
|
||||
]);
|
||||
});
|
||||
|
||||
test("parses lists with ordering", () => {
|
||||
const blocks = htmlToTextBlocks(
|
||||
`<ul><li>One</li><li>Two</li></ul><ol><li>First</li></ol>`,
|
||||
);
|
||||
|
||||
expect(blocks).toHaveLength(3);
|
||||
expect(blocks[0]!).toEqual({
|
||||
type: "list-item",
|
||||
ordered: false,
|
||||
index: 0,
|
||||
segments: [{ text: "One" }],
|
||||
});
|
||||
expect(blocks[1]!).toEqual({
|
||||
type: "list-item",
|
||||
ordered: false,
|
||||
index: 1,
|
||||
segments: [{ text: "Two" }],
|
||||
});
|
||||
expect(blocks[2]!).toEqual({
|
||||
type: "list-item",
|
||||
ordered: true,
|
||||
index: 0,
|
||||
segments: [{ text: "First" }],
|
||||
});
|
||||
});
|
||||
|
||||
test("parses headings, blockquote, hr and br", () => {
|
||||
const blocks = htmlToTextBlocks(
|
||||
`<h2>Heading</h2><blockquote>Quoted</blockquote><hr><p>Line one<br>Line two</p>`,
|
||||
);
|
||||
|
||||
expect(blocks[0]!.type).toBe("heading");
|
||||
expect((blocks[0] as any).level).toBe(2);
|
||||
expect(blocks[1]!.type).toBe("blockquote");
|
||||
expect(blocks[2]!).toEqual({ type: "divider" });
|
||||
// <br> splits into separate paragraphs
|
||||
expect(blocks).toHaveLength(5);
|
||||
expect((blocks[3] as any).segments.map((s: any) => s.text).join("")).toBe(
|
||||
"Line one",
|
||||
);
|
||||
expect((blocks[4] as any).segments.map((s: any) => s.text).join("")).toBe(
|
||||
"Line two",
|
||||
);
|
||||
});
|
||||
|
||||
test("decodes HTML entities", () => {
|
||||
const blocks = htmlToTextBlocks(
|
||||
`<p>Fee & charges — £500 > 400</p>`,
|
||||
);
|
||||
|
||||
expect((blocks[0] as any).segments[0].text).toBe(
|
||||
"Fee & charges \u2014 \u00a3500 > 400",
|
||||
);
|
||||
});
|
||||
|
||||
test("uses img alt text and ignores unknown tags", () => {
|
||||
const blocks = htmlToTextBlocks(
|
||||
`<p>See <img src="x.png" alt="the chart" /> below.</p><svg><circle></circle></svg>`,
|
||||
);
|
||||
|
||||
expect((blocks[0] as any).segments.map((s: any) => s.text).join("")).toBe(
|
||||
"See the chart below.",
|
||||
);
|
||||
});
|
||||
|
||||
test("returns empty for empty input", () => {
|
||||
expect(htmlToTextBlocks(undefined)).toEqual([]);
|
||||
expect(htmlToTextBlocks("")).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,262 @@
|
||||
export type TextSegment = {
|
||||
text: string;
|
||||
bold?: boolean;
|
||||
italic?: boolean;
|
||||
underline?: boolean;
|
||||
};
|
||||
|
||||
export type TextBlock =
|
||||
| { type: "paragraph"; segments: TextSegment[] }
|
||||
| { type: "heading"; level: number; segments: TextSegment[] }
|
||||
| { type: "list-item"; ordered: boolean; index: number; segments: TextSegment[] }
|
||||
| { type: "blockquote"; segments: TextSegment[] }
|
||||
| { type: "divider" };
|
||||
|
||||
const named_entities: Record<string, string> = {
|
||||
amp: "&",
|
||||
lt: "<",
|
||||
gt: ">",
|
||||
quot: '"',
|
||||
apos: "'",
|
||||
nbsp: " ",
|
||||
ensp: " ",
|
||||
emsp: " ",
|
||||
ndash: "\u2013",
|
||||
mdash: "\u2014",
|
||||
hellip: "\u2026",
|
||||
lsquo: "\u2018",
|
||||
rsquo: "\u2019",
|
||||
ldquo: "\u201c",
|
||||
rdquo: "\u201d",
|
||||
bull: "\u2022",
|
||||
middot: "\u00b7",
|
||||
times: "\u00d7",
|
||||
copy: "\u00a9",
|
||||
reg: "\u00ae",
|
||||
trade: "\u2122",
|
||||
deg: "\u00b0",
|
||||
sect: "\u00a7",
|
||||
para: "\u00b6",
|
||||
euro: "\u20ac",
|
||||
pound: "\u00a3",
|
||||
minus: "\u2212",
|
||||
radic: "\u221a",
|
||||
frac12: "\u00bd",
|
||||
frac14: "\u00bc",
|
||||
frac34: "\u00be",
|
||||
};
|
||||
|
||||
function decodeEntities(text: string): string {
|
||||
return text
|
||||
.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) =>
|
||||
String.fromCodePoint(parseInt(hex, 16)),
|
||||
)
|
||||
.replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(parseInt(dec, 10)))
|
||||
.replace(
|
||||
/&([a-zA-Z]+);/g,
|
||||
(match, name: string) => named_entities[name.toLowerCase()] || match,
|
||||
);
|
||||
}
|
||||
|
||||
const INLINE_STYLE_OPEN = ["b", "strong", "i", "em", "u", "ins"];
|
||||
const INLINE_STYLE_CLOSE = ["b", "strong", "i", "em", "u", "ins"];
|
||||
const HEADING_LEVELS: Record<string, number> = {
|
||||
h1: 1,
|
||||
h2: 2,
|
||||
h3: 3,
|
||||
h4: 4,
|
||||
h5: 5,
|
||||
h6: 6,
|
||||
};
|
||||
|
||||
/**
|
||||
* # Convert an HTML fragment (TinyMCE output) into simple text blocks
|
||||
* suitable for rendering in a PDF. Supports: p, div, br, h1-h6, ul/ol/li,
|
||||
* blockquote, hr, b/strong, i/em, u/ins, a, span, img (alt text) and
|
||||
* basic table cells (degraded to inline text separated by spaces).
|
||||
*/
|
||||
export default function htmlToTextBlocks(html?: string): TextBlock[] {
|
||||
const source = html || "";
|
||||
const blocks: TextBlock[] = [];
|
||||
|
||||
let pending_text = "";
|
||||
const style = { bold: false, italic: false, underline: false };
|
||||
let current: TextBlock | undefined;
|
||||
const list_stack: { ordered: boolean; index: number }[] = [];
|
||||
|
||||
const appendText = (raw: string) => {
|
||||
if (!raw) return;
|
||||
const decoded = decodeEntities(raw);
|
||||
if (!decoded) return;
|
||||
pending_text += decoded;
|
||||
};
|
||||
|
||||
const flushText = () => {
|
||||
if (!pending_text) return;
|
||||
if (!current) {
|
||||
current = { type: "paragraph", segments: [] };
|
||||
} else if (current.type == "divider") {
|
||||
current = { type: "paragraph", segments: [] };
|
||||
}
|
||||
current.segments.push({
|
||||
text: pending_text,
|
||||
...(style.bold ? { bold: true } : {}),
|
||||
...(style.italic ? { italic: true } : {}),
|
||||
...(style.underline ? { underline: true } : {}),
|
||||
});
|
||||
pending_text = "";
|
||||
};
|
||||
|
||||
const flushBlock = () => {
|
||||
flushText();
|
||||
if (current) {
|
||||
blocks.push(current);
|
||||
current = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const startBlock = (block: TextBlock) => {
|
||||
flushBlock();
|
||||
current = block;
|
||||
};
|
||||
|
||||
const pattern = /<(\/?)([a-zA-Z0-9]+)([^>]*)>/g;
|
||||
let last = 0;
|
||||
let match;
|
||||
|
||||
while ((match = pattern.exec(source)) !== null) {
|
||||
appendText(source.slice(last, match.index));
|
||||
last = match.index + (match[0] || "").length;
|
||||
|
||||
const closing = Boolean(match[1]);
|
||||
const tag = (match[2] || "").toLowerCase();
|
||||
const attrs = match[3] || "";
|
||||
|
||||
if (INLINE_STYLE_OPEN.includes(tag) && !closing) {
|
||||
if (["b", "strong"].includes(tag)) {
|
||||
flushText();
|
||||
style.bold = true;
|
||||
}
|
||||
if (["i", "em"].includes(tag)) {
|
||||
flushText();
|
||||
style.italic = true;
|
||||
}
|
||||
if (["u", "ins"].includes(tag)) {
|
||||
flushText();
|
||||
style.underline = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (INLINE_STYLE_CLOSE.includes(tag) && closing) {
|
||||
if (["b", "strong"].includes(tag)) {
|
||||
flushText();
|
||||
style.bold = false;
|
||||
}
|
||||
if (["i", "em"].includes(tag)) {
|
||||
flushText();
|
||||
style.italic = false;
|
||||
}
|
||||
if (["u", "ins"].includes(tag)) {
|
||||
flushText();
|
||||
style.underline = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (["p", "div"].includes(tag)) {
|
||||
if (closing) {
|
||||
flushBlock();
|
||||
} else {
|
||||
startBlock({ type: "paragraph", segments: [] });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (HEADING_LEVELS[tag]) {
|
||||
if (closing) {
|
||||
flushBlock();
|
||||
} else {
|
||||
startBlock({
|
||||
type: "heading",
|
||||
level: HEADING_LEVELS[tag],
|
||||
segments: [],
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tag == "br") {
|
||||
flushBlock();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tag == "hr") {
|
||||
flushBlock();
|
||||
blocks.push({ type: "divider" });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (["ul", "ol"].includes(tag)) {
|
||||
if (closing) {
|
||||
flushBlock();
|
||||
list_stack.pop();
|
||||
} else {
|
||||
flushBlock();
|
||||
list_stack.push({ ordered: tag == "ol", index: 0 });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tag == "li") {
|
||||
if (closing) {
|
||||
flushBlock();
|
||||
} else {
|
||||
const list = list_stack[list_stack.length - 1] || {
|
||||
ordered: false,
|
||||
index: 0,
|
||||
};
|
||||
startBlock({
|
||||
type: "list-item",
|
||||
ordered: list.ordered,
|
||||
index: list.index,
|
||||
segments: [],
|
||||
});
|
||||
list.index += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tag == "blockquote") {
|
||||
if (closing) {
|
||||
flushBlock();
|
||||
} else {
|
||||
startBlock({ type: "blockquote", segments: [] });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tag == "img" && !closing) {
|
||||
const alt = attrs.match(/alt\s*=\s*["']([^"']*)["']/i);
|
||||
if (alt?.[1]) appendText(` ${alt[1]} `);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (["td", "th", "p", "div", "li"].includes(tag) && !closing) {
|
||||
appendText(" ");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (["table", "thead", "tbody", "tr"].includes(tag)) {
|
||||
if (!closing && ["tr"].includes(tag)) {
|
||||
// no-op, cells add their own spacing
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
appendText(source.slice(last));
|
||||
flushBlock();
|
||||
|
||||
return blocks;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,38 @@
|
||||
import dayjs from "dayjs";
|
||||
|
||||
function ordinal(n: number): string {
|
||||
const mod100 = n % 100;
|
||||
if (mod100 >= 11 && mod100 <= 13) {
|
||||
return "th";
|
||||
}
|
||||
switch (n % 10) {
|
||||
case 1:
|
||||
return "st";
|
||||
case 2:
|
||||
return "nd";
|
||||
case 3:
|
||||
return "rd";
|
||||
default:
|
||||
return "th";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* # Format a timestamp as a letter date, e.g. "22nd September, 2026".
|
||||
*/
|
||||
export function formatLetterDate(timestamp: number): string {
|
||||
const date = dayjs(timestamp);
|
||||
return `${date.date()}${ordinal(date.date())} ${date.format("MMMM, YYYY")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* # Format a timestamp as a short invoice date, e.g. "22 Sep 2026".
|
||||
*/
|
||||
export function formatInvoiceDate(
|
||||
timestamp?: number | string | "",
|
||||
): string {
|
||||
if (!timestamp) {
|
||||
return "\u2014";
|
||||
}
|
||||
return dayjs(Number(timestamp)).format("DD MMM YYYY");
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user