First Commit. Duplicate bunext-sqlite preset

This commit is contained in:
2026-10-18 16:47:11 +01:00
commit 81e4a8aecc
718 changed files with 41695 additions and 0 deletions
+48
View File
@@ -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>
);
}
+13
View File
@@ -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>
);
}
+26
View File
@@ -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>
);
}
+24
View File
@@ -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>
);
}
+33
View File
@@ -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>
);
}
+16
View File
@@ -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 />
</>
);
}
+23
View File
@@ -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
/>
);
}
+53
View File
@@ -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>
</>
);
}
+35
View File
@@ -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>
);
}
+56
View File
@@ -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>
);
}
+38
View File
@@ -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>
);
}
+37
View File
@@ -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}
/>
);
}
+107
View File
@@ -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>
);
}
+42
View File
@@ -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>
);
}
+53
View File
@@ -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 }} />
);
}
+14
View File
@@ -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;
}
+18
View File
@@ -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,
});
}
+44
View File
@@ -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);
}
+27
View File
@@ -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();
});
});
+31
View File
@@ -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;
+5
View File
@@ -0,0 +1,5 @@
export const SiteData = {
SiteName: `Bun SQLite Preset`,
SiteURL: `http://localhost:7247`,
SiteSlug: `bsqlite`,
} as const;
+35
View File
@@ -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;
};
+79
View File
@@ -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"];
+22
View File
@@ -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;
+16
View File
@@ -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",
},
];
+29
View File
@@ -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;
+7
View File
@@ -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"];
+75
View File
@@ -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);
});
});
+134
View File
@@ -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",
],
},
},
},
};
+7
View File
@@ -0,0 +1,7 @@
export const WebsocketRooms = [
{
title: "Public",
value: "public",
description: `Public Room for All`,
},
] as const;
+219
View File
@@ -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>&nbsp;</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_-]+$/);
});
});
+11
View File
@@ -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;
}
+48
View File
@@ -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;
},
};
}
+154
View File
@@ -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 });
}
+62
View File
@@ -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,
};
}
}
+157
View File
@@ -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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
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 &amp; charges &mdash; &#163;500 &gt; 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([]);
});
+262
View File
@@ -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
+38
View File
@@ -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