feat(db): mesh data model — meshes, members, invites, audit log
- pgSchema "mesh" with 4 tables isolating the peer mesh domain - Enums: visibility, transport, tier, role - audit_log is metadata-only (E2E encryption enforced at broker/client) - Cascade on mesh delete, soft-delete via archivedAt/revokedAt Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
207
apps/web/src/modules/pdf/upload/confirm.tsx
Normal file
207
apps/web/src/modules/pdf/upload/confirm.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod";
|
||||
|
||||
import { formatFileSize } from "@turbostarter/ai/pdf/utils";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@turbostarter/ui-web/card";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@turbostarter/ui-web/form";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import { Input } from "@turbostarter/ui-web/input";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { authClient } from "~/lib/auth/client";
|
||||
import { useAIError } from "~/modules/common/hooks/use-ai-error";
|
||||
|
||||
import { pdf } from "../lib/api";
|
||||
|
||||
import { useUpload } from "./hooks/use-upload";
|
||||
import { getFileName } from "./utils";
|
||||
|
||||
import type { FileInput } from "./utils";
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(1).max(255),
|
||||
});
|
||||
|
||||
interface PdfUploadConfirmProps {
|
||||
readonly file: FileInput;
|
||||
readonly onCancel: () => void;
|
||||
}
|
||||
|
||||
export const PdfUploadConfirm = ({ file, onCancel }: PdfUploadConfirmProps) => {
|
||||
const { onError } = useAIError();
|
||||
const { t } = useTranslation(["common", "ai"]);
|
||||
const { data: session, isPending: isSessionLoading } = authClient.useSession();
|
||||
const form = useForm({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: getFileName(file) ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const upload = useUpload();
|
||||
const createChat = useMutation({
|
||||
...pdf.mutations.chats.create,
|
||||
onSuccess: (data) => {
|
||||
toast.success(t("pdf.upload.success"));
|
||||
if (data?.id) {
|
||||
return router.push(pathsConfig.apps.pdf.chat(data.id));
|
||||
}
|
||||
},
|
||||
onError,
|
||||
});
|
||||
|
||||
// Show sign-in prompt for non-authenticated users
|
||||
if (!isSessionLoading && !session?.user) {
|
||||
return (
|
||||
<Card className="relative z-10 flex w-full max-w-xl flex-col justify-center gap-3 overflow-hidden">
|
||||
<CardHeader className="flex flex-col items-center gap-1 py-8">
|
||||
<Icons.Lock className="mb-2 size-10 text-muted-foreground" />
|
||||
<CardTitle>{t("pdf.upload.signIn.title")}</CardTitle>
|
||||
<CardDescription className="text-center">
|
||||
{t("pdf.upload.signIn.description")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardFooter className="flex justify-center gap-2 pb-8">
|
||||
<Button variant="outline" onClick={onCancel}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link href={pathsConfig.auth.login}>
|
||||
{t("pdf.upload.signIn.cta")}
|
||||
</Link>
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const data = [
|
||||
{
|
||||
title: t("file"),
|
||||
value: "url" in file ? file.url : `./${file.name}`,
|
||||
},
|
||||
{
|
||||
title: t("size"),
|
||||
value: formatFileSize(file.size),
|
||||
},
|
||||
{
|
||||
title: t("type"),
|
||||
value: "PDF",
|
||||
},
|
||||
];
|
||||
|
||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
const { path } = await upload.mutateAsync({
|
||||
file,
|
||||
...values,
|
||||
});
|
||||
|
||||
await createChat.mutateAsync({
|
||||
json: {
|
||||
...values,
|
||||
path,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (form.formState.isSubmitting || form.formState.isSubmitSuccessful) {
|
||||
return (
|
||||
<Card className="relative z-10 flex w-full max-w-xl flex-col justify-center gap-3 overflow-hidden">
|
||||
<CardHeader className="flex flex-col items-center gap-1 py-16">
|
||||
<Icons.Loader className="mb-2 size-8 animate-spin" />
|
||||
<CardTitle>{t("pdf.upload.loading.title")}</CardTitle>
|
||||
<CardDescription>
|
||||
{t("pdf.upload.loading.description")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="relative z-10 flex w-full max-w-xl flex-col justify-center gap-3 overflow-hidden">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle>{t("pdf.upload.confirm.title")}</CardTitle>
|
||||
<CardDescription>{t("pdf.upload.confirm.description")}</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center justify-between gap-8 @md:gap-12">
|
||||
<FormLabel className="font-medium">
|
||||
{t("name")}:
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
autoFocus
|
||||
className="h-8 py-0 text-right"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
<FormMessage className="text-right" />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{data.map(({ title, value }) => (
|
||||
<div
|
||||
key={title}
|
||||
className="mb-0.5 flex items-center justify-between gap-8 text-sm @md:gap-12"
|
||||
>
|
||||
<span className="font-medium">{title}:</span>
|
||||
<span className="truncate">{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
className="grow @lg:grow-0"
|
||||
>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button type="submit" className="grow @lg:grow-0">
|
||||
{t("confirm")}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
44
apps/web/src/modules/pdf/upload/hooks/use-upload.tsx
Normal file
44
apps/web/src/modules/pdf/upload/hooks/use-upload.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
|
||||
import { handle } from "@turbostarter/api/utils";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { generateId } from "@turbostarter/shared/utils";
|
||||
|
||||
import { api } from "~/lib/api/client";
|
||||
import { authClient } from "~/lib/auth/client";
|
||||
import { readFile } from "~/modules/pdf/upload/utils";
|
||||
|
||||
import type { FileInput } from "~/modules/pdf/upload/utils";
|
||||
|
||||
export const useUpload = () => {
|
||||
const { t } = useTranslation("ai");
|
||||
const { data: session } = authClient.useSession();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (data: { file: FileInput }) => {
|
||||
if (!session?.user.id) {
|
||||
throw new Error(t("pdf.upload.error.unauthorized"));
|
||||
}
|
||||
|
||||
const path = `documents/${session.user.id}/${generateId()}.pdf`;
|
||||
|
||||
const { url: uploadUrl } = await handle(api.storage.upload.$get)({
|
||||
query: { path },
|
||||
});
|
||||
|
||||
const response = await fetch(uploadUrl, {
|
||||
method: "PUT",
|
||||
body: await readFile(data.file),
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(t("pdf.upload.error.api"));
|
||||
}
|
||||
|
||||
return { path };
|
||||
},
|
||||
});
|
||||
};
|
||||
155
apps/web/src/modules/pdf/upload/index.tsx
Normal file
155
apps/web/src/modules/pdf/upload/index.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ErrorCode, useDropzone as useReactDropzone } from "react-dropzone";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
EXAMPLE_PDF,
|
||||
MAX_FILE_SIZE,
|
||||
MAX_FILE_SIZE_IN_MB,
|
||||
} from "@turbostarter/ai/pdf/constants";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { GridPattern } from "@turbostarter/ui-web/grid-pattern";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
import { PdfUploadConfirm } from "~/modules/pdf/upload/confirm";
|
||||
|
||||
import { PdfUrlForm } from "./url-form";
|
||||
|
||||
import type { FileInput } from "./utils";
|
||||
|
||||
const useDropzone = ({ onDrop }: { onDrop: (files: File[]) => void }) => {
|
||||
const { t } = useTranslation("validation");
|
||||
|
||||
const errorMessages = useMemo(
|
||||
() => ({
|
||||
[ErrorCode.FileInvalidType]: t("error.file.type", {
|
||||
type: "PDF",
|
||||
}),
|
||||
[ErrorCode.FileTooLarge]: t("error.tooBig.file.notInclusive", {
|
||||
maximum: MAX_FILE_SIZE_IN_MB,
|
||||
}),
|
||||
[ErrorCode.FileTooSmall]: t("error.tooSmall.file.notInclusive", {
|
||||
minimum: 0,
|
||||
}),
|
||||
[ErrorCode.TooManyFiles]: t("error.file.maxCount", {
|
||||
count: 1,
|
||||
}),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
const dropzone = useReactDropzone({
|
||||
accept: {
|
||||
"application/pdf": [".pdf"],
|
||||
},
|
||||
maxFiles: 1,
|
||||
maxSize: MAX_FILE_SIZE * 1024 * 1024,
|
||||
onError: (error) => toast.error(error.message),
|
||||
noClick: true,
|
||||
noKeyboard: true,
|
||||
onDrop,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const code = dropzone.fileRejections[0]?.errors[0]?.code;
|
||||
if (code) {
|
||||
toast.error(errorMessages[code as ErrorCode]);
|
||||
}
|
||||
}, [dropzone.fileRejections, errorMessages]);
|
||||
|
||||
return dropzone;
|
||||
};
|
||||
|
||||
export const PdfUpload = () => {
|
||||
const [file, setFile] = useState<FileInput | null>(null);
|
||||
const { t } = useTranslation(["ai", "common"]);
|
||||
const { open, getRootProps, getInputProps, isDragAccept, isDragReject } =
|
||||
useDropzone({ onDrop: (files) => setFile(files[0] ?? null) });
|
||||
|
||||
if (file) {
|
||||
return (
|
||||
<Layout>
|
||||
<PdfUploadConfirm file={file} onCancel={() => setFile(null)} />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout
|
||||
{...getRootProps()}
|
||||
className={cn(
|
||||
{ "border-destructive": isDragReject },
|
||||
{ "border-muted-foreground": isDragAccept },
|
||||
)}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<div className="relative z-10 flex w-full max-w-md flex-col items-center justify-center gap-3">
|
||||
<Icons.FileText className="size-16" />
|
||||
<h1 className="text-4xl font-medium tracking-tight">
|
||||
{t("pdf.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground mb-6 max-w-sm text-center text-sm">
|
||||
{t("pdf.upload.description")}
|
||||
</p>
|
||||
|
||||
<PdfUrlForm onSuccess={setFile} />
|
||||
<Button
|
||||
className="bg-background w-full rounded-md"
|
||||
variant="outline"
|
||||
onClick={open}
|
||||
>
|
||||
{t("pdf.upload.fromDevice")}
|
||||
</Button>
|
||||
|
||||
<span className="text-muted-foreground text-sm">{t("or")}</span>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="bg-background h-auto w-full gap-3 overflow-hidden rounded-md px-2 pr-4"
|
||||
onClick={() => setFile(EXAMPLE_PDF)}
|
||||
>
|
||||
<div className="bg-muted rounded-md border p-1.5">
|
||||
<Icons.Paperclip className="size-4 shrink-0" />
|
||||
</div>
|
||||
<div className="mr-auto flex min-w-0 flex-col items-start">
|
||||
<span>{t("pdf.upload.example.cta")}</span>
|
||||
<span className="text-muted-foreground w-full truncate pr-4 text-xs">
|
||||
{EXAMPLE_PDF.url}
|
||||
</span>
|
||||
</div>
|
||||
<Icons.ArrowRight className="size-4 shrink-0" />
|
||||
</Button>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
const Layout = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex h-full w-full items-center justify-center rounded-lg border-2 border-dashed p-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<GridPattern
|
||||
width={50}
|
||||
height={50}
|
||||
x={-1}
|
||||
y={-1}
|
||||
strokeDasharray={"4 2"}
|
||||
className="mask-[radial-gradient(white,transparent)]"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
88
apps/web/src/modules/pdf/upload/url-form.tsx
Normal file
88
apps/web/src/modules/pdf/upload/url-form.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { MAX_FILE_SIZE_IN_MB } from "@turbostarter/ai/pdf/constants";
|
||||
import {
|
||||
pdfUrlFormSchema,
|
||||
validateRemotePdfUrl,
|
||||
} from "@turbostarter/ai/pdf/schema";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from "@turbostarter/ui-web/form";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import { Input } from "@turbostarter/ui-web/input";
|
||||
|
||||
import type { PdfUrlFormPayload } from "@turbostarter/ai/pdf/schema";
|
||||
import type { RemoteFile } from "@turbostarter/ai/pdf/types";
|
||||
|
||||
interface PdfUrlFormProps {
|
||||
readonly onSuccess: (file: RemoteFile) => void;
|
||||
}
|
||||
|
||||
export const PdfUrlForm = ({ onSuccess }: PdfUrlFormProps) => {
|
||||
const { t } = useTranslation(["common", "ai", "validation"]);
|
||||
const form = useForm({
|
||||
resolver: zodResolver(pdfUrlFormSchema),
|
||||
defaultValues: {
|
||||
url: "",
|
||||
},
|
||||
});
|
||||
|
||||
async function onSubmit(values: PdfUrlFormPayload) {
|
||||
const result = await validateRemotePdfUrl(values.url);
|
||||
|
||||
if (typeof result === "string") {
|
||||
return form.setError("url", {
|
||||
message: t(result, { maximum: MAX_FILE_SIZE_IN_MB, type: "PDF" }),
|
||||
});
|
||||
}
|
||||
|
||||
onSuccess(result);
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="flex w-full gap-2"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="url"
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormControl>
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder={t("pdf.upload.fromUrl.placeholder")}
|
||||
className="bg-background"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
className="rounded-md"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
{form.formState.isSubmitting ? (
|
||||
<Icons.Loader2 className="size-5 animate-spin" />
|
||||
) : (
|
||||
t("pdf.upload.fromUrl.cta")
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
37
apps/web/src/modules/pdf/upload/utils.ts
Normal file
37
apps/web/src/modules/pdf/upload/utils.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { RemoteFile } from "@turbostarter/ai/pdf/types";
|
||||
|
||||
export type FileInput = File | RemoteFile;
|
||||
|
||||
export const getFileName = (file: FileInput) => {
|
||||
if ("name" in file) {
|
||||
return file.name.replace(/\.[^.]+$/, "");
|
||||
}
|
||||
|
||||
const fileName = file.url.split("/").pop() ?? null;
|
||||
if (!fileName) return null;
|
||||
return fileName.replace(/\.[^.]+$/, "");
|
||||
};
|
||||
|
||||
export const readFile = async (file: FileInput) => {
|
||||
if ("url" in file) {
|
||||
// Use server proxy to fetch external URLs (avoids CORS issues)
|
||||
const proxyUrl = `/api/storage/proxy?url=${encodeURIComponent(file.url)}`;
|
||||
const response = await fetch(proxyUrl);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({})) as { error?: string };
|
||||
throw new Error(error.error ?? "Failed to fetch PDF from URL");
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
return blob;
|
||||
} else {
|
||||
const reader = new FileReader();
|
||||
return new Promise<Blob>((resolve, reject) => {
|
||||
reader.onloadend = () =>
|
||||
resolve(new Blob([reader.result as ArrayBuffer]));
|
||||
reader.onerror = reject;
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user