aboutsummaryrefslogtreecommitdiff
path: root/apps/web
diff options
context:
space:
mode:
authorDhravya <[email protected]>2024-06-30 14:07:16 -0500
committerDhravya <[email protected]>2024-06-30 14:07:16 -0500
commit7676ed57065eb61d54d500e68c7a7faae16e558b (patch)
tree0d59a7dd9ee1772667abbc03158e268732ff5214 /apps/web
parenttailwind shadow dom (diff)
downloadsupermemory-7676ed57065eb61d54d500e68c7a7faae16e558b.tar.xz
supermemory-7676ed57065eb61d54d500e68c7a7faae16e558b.zip
added things
Diffstat (limited to 'apps/web')
-rw-r--r--apps/web/app/(dash)/home/homeVariants.ts2
-rw-r--r--apps/web/app/(dash)/home/page.tsx7
-rw-r--r--apps/web/app/(dash)/memories/page.tsx90
-rw-r--r--apps/web/app/(dash)/memories/render-tweet.tsx33
-rw-r--r--apps/web/app/(dash)/menu.tsx21
-rw-r--r--apps/web/app/actions/doers.ts88
-rw-r--r--apps/web/app/actions/fetchers.ts14
-rw-r--r--apps/web/app/api/store/route.ts202
-rw-r--r--apps/web/migrations/0001_index_telegram_id.sql4
-rw-r--r--apps/web/migrations/000_setup.sql (renamed from apps/web/migrations/0000_classy_speed_demon.sql)0
-rw-r--r--apps/web/migrations/meta/0001_snapshot.json698
-rw-r--r--apps/web/migrations/meta/_journal.json7
-rw-r--r--apps/web/server/db/schema.ts2
-rw-r--r--apps/web/tsconfig.json3
14 files changed, 1100 insertions, 71 deletions
diff --git a/apps/web/app/(dash)/home/homeVariants.ts b/apps/web/app/(dash)/home/homeVariants.ts
index ec24e22b..cc533fc4 100644
--- a/apps/web/app/(dash)/home/homeVariants.ts
+++ b/apps/web/app/(dash)/home/homeVariants.ts
@@ -44,7 +44,7 @@ export const variants = [
},
{
type: "highlighted",
- content: " digital treasures.",
+ content: " digital treasure.",
},
],
];
diff --git a/apps/web/app/(dash)/home/page.tsx b/apps/web/app/(dash)/home/page.tsx
index 4f7e4af6..f648923c 100644
--- a/apps/web/app/(dash)/home/page.tsx
+++ b/apps/web/app/(dash)/home/page.tsx
@@ -3,7 +3,7 @@
import React, { useEffect, useState } from "react";
import QueryInput from "./queryinput";
import { homeSearchParamsCache } from "@/lib/searchParams";
-import { getSpaces } from "@/app/actions/fetchers";
+import { getSessionAuthToken, getSpaces } from "@/app/actions/fetchers";
import { useRouter } from "next/navigation";
import { createChatThread, linkTelegramToUser } from "@/app/actions/doers";
import { toast } from "sonner";
@@ -67,6 +67,11 @@ function Page({
});
setShowVariant(Math.floor(Math.random() * variants.length));
+
+ getSessionAuthToken().then((token) => {
+ if (typeof window === "undefined") return;
+ window.postMessage({ token: token.data }, "*");
+ });
}, []);
return (
diff --git a/apps/web/app/(dash)/memories/page.tsx b/apps/web/app/(dash)/memories/page.tsx
index cb3825e7..380a653e 100644
--- a/apps/web/app/(dash)/memories/page.tsx
+++ b/apps/web/app/(dash)/memories/page.tsx
@@ -1,18 +1,19 @@
"use client";
import { getAllUserMemoriesAndSpaces } from "@/app/actions/fetchers";
-import { Space } from "@/app/actions/types";
import { Content, StoredSpace } from "@/server/db/schema";
-import { NextIcon, SearchIcon, UrlIcon } from "@repo/ui/icons";
+import { MemoriesIcon, NextIcon, SearchIcon, UrlIcon } from "@repo/ui/icons";
+import { NotebookIcon, PaperclipIcon } from "lucide-react";
import Image from "next/image";
+import Link from "next/link";
import React, { useEffect, useMemo, useState } from "react";
import Masonry from "react-layout-masonry";
+import { getRawTweet } from "@repo/shared-types/utils";
+import { MyTweet } from "./render-tweet";
function Page() {
const [filter, setFilter] = useState("All");
- const [search, setSearch] = useState("");
-
const [memoriesAndSpaces, setMemoriesAndSpaces] = useState<{
memories: Content[];
spaces: StoredSpace[];
@@ -55,9 +56,13 @@ function Page() {
return (
item.item === "memory" && (item.data as Content).type === "note"
);
+ if (filter === "Tweet")
+ return (
+ item.item === "memory" && (item.data as Content).type === "tweet"
+ );
return false;
})
- .sort((a, b) => a.date - b.date);
+ .sort((a, b) => b.date - a.date);
}, [memoriesAndSpaces.memories, memoriesAndSpaces.spaces, filter]);
useEffect(() => {
@@ -69,7 +74,7 @@ function Page() {
}, []);
return (
- <div className="max-w-3xl min-w-3xl py-36 h-full flex mx-auto w-full flex-col gap-6">
+ <div className="px-2 md:px-32 py-36 h-full flex mx-auto w-full flex-col gap-6">
<h2 className="text-white w-full font-medium text-2xl text-left">
My Memories
</h2>
@@ -79,7 +84,7 @@ function Page() {
<Masonry
className="mt-6"
columns={{ 640: 1, 768: 2, 1024: 3, 1280: 4 }}
- gap={4}
+ gap={12}
>
{sortedItems.map((item) => {
if (item.item === "memory") {
@@ -88,12 +93,15 @@ function Page() {
type={(item.data as Content).type ?? "note"}
content={(item.data as Content).content}
title={(item.data as Content).title ?? "Untitled"}
- url={(item.data as Content).url}
+ url={
+ (item.data as Content).baseUrl ?? (item.data as Content).url
+ }
image={
(item.data as Content).ogImage ??
(item.data as Content).image ??
"/placeholder-image.svg" // TODO: add this placeholder
}
+ description={(item.data as Content).description ?? ""}
/>
);
}
@@ -123,47 +131,73 @@ function TabComponent({
}) {
// TODO: Display the space name and desription which is the number of elemenet in the space
return (
- <div className="flex items-center my-6">
- <div>
- <div className="h-12 w-12 bg-[#1F2428] flex justify-center items-center rounded-md">
- {title.slice(0, 2).toUpperCase()}
- </div>
- </div>
- <div className="grow px-4">
- <div className="text-lg text-[#fff]">{title}</div>
- <div>{description}</div>
+ <div className="flex flex-col gap-4 bg-[#161f2a]/30 backdrop-blur-md border-2 border-border w-full rounded-xl p-4 -z-10">
+ <div className="flex items-center gap-2 text-xs">
+ <Image alt="Spaces icon" src={MemoriesIcon} className="size-3" /> Space
</div>
- <div>
- <Image src={NextIcon} alt="Search icon" />
+ <div className="flex items-center">
+ <div>
+ <div className="h-12 w-12 flex justify-center items-center rounded-md">
+ {title.slice(0, 2).toUpperCase()}
+ </div>
+ </div>
+ <div className="grow px-4">
+ <div className="text-lg text-[#fff] line-clamp-2">{title}</div>
+ <div>{description}</div>
+ </div>
+ <div>
+ <Image src={NextIcon} alt="Search icon" />
+ </div>
</div>
</div>
);
}
-function LinkComponent({
+export function LinkComponent({
type,
content,
title,
url,
image,
+ description,
}: {
type: string;
content: string;
title: string;
url: string;
image?: string;
+ description: string;
}) {
// TODO: DISPLAY THE ITEM BASED ON `type` being note or page
return (
- <div className="w-full">
- <div className="text-lg text-[#fff]">{title}</div>
- <div>{content}</div>
- <div>{url}</div>
- </div>
+ <Link
+ href={url}
+ className={`bg-secondary border-2 border-border w-full rounded-xl ${type === "tweet" ? "" : "p-4"} hover:scale-105 transition duration-200`}
+ >
+ {type === "page" ? (
+ <>
+ <div className="flex items-center gap-2 text-xs">
+ <PaperclipIcon className="w-3 h-3" /> Page
+ </div>
+ <div className="text-lg text-[#fff] mt-4 line-clamp-2">{title}</div>
+ <div>{url}</div>
+ </>
+ ) : type === "note" ? (
+ <>
+ <div className="flex items-center gap-2 text-xs">
+ <NotebookIcon className="w-3 h-3" /> Note
+ </div>
+ <div className="text-lg text-[#fff] mt-4 line-clamp-2">{title}</div>
+ <div className="line-clamp-3">{content.replace(title, "")}</div>
+ </>
+ ) : type === "tweet" ? (
+ <MyTweet tweet={JSON.parse(getRawTweet(content) ?? "{}")} />
+ ) : null}
+ </Link>
);
}
-const FilterMethods = ["All", "Spaces", "Pages", "Notes"];
+const FilterMethods = ["All", "Spaces", "Pages", "Notes", "Tweet"];
function Filters({
setFilter,
filter,
@@ -175,12 +209,12 @@ function Filters({
<div className="flex gap-4">
{FilterMethods.map((i) => {
return (
- <div
+ <button
onClick={() => setFilter(i)}
className={`transition px-6 py-2 rounded-xl bg-border ${i === filter ? " text-[#369DFD]" : "text-[#B3BCC5] bg-secondary hover:bg-secondary hover:text-[#76a3cc]"}`}
>
{i}
- </div>
+ </button>
);
})}
</div>
diff --git a/apps/web/app/(dash)/memories/render-tweet.tsx b/apps/web/app/(dash)/memories/render-tweet.tsx
new file mode 100644
index 00000000..3e1e3746
--- /dev/null
+++ b/apps/web/app/(dash)/memories/render-tweet.tsx
@@ -0,0 +1,33 @@
+import type { Tweet } from "react-tweet/api";
+import {
+ type TwitterComponents,
+ TweetContainer,
+ TweetHeader,
+ TweetInReplyTo,
+ TweetBody,
+ TweetMedia,
+ TweetInfo,
+ QuotedTweet,
+ enrichTweet,
+} from "react-tweet";
+
+type Props = {
+ tweet: Tweet;
+ components?: TwitterComponents;
+};
+
+export const MyTweet = ({ tweet: t, components }: Props) => {
+ const tweet = enrichTweet(t);
+ return (
+ <TweetContainer className="bg-transparent !m-0 !p-0 !z-0">
+ <TweetHeader tweet={tweet} components={components} />
+ {tweet.in_reply_to_status_id_str && <TweetInReplyTo tweet={tweet} />}
+ <TweetBody tweet={tweet} />
+ {tweet.mediaDetails?.length ? (
+ <TweetMedia tweet={tweet} components={components} />
+ ) : null}
+ {tweet.quoted_tweet && <QuotedTweet tweet={tweet.quoted_tweet} />}
+ <TweetInfo tweet={tweet} />
+ </TweetContainer>
+ );
+};
diff --git a/apps/web/app/(dash)/menu.tsx b/apps/web/app/(dash)/menu.tsx
index 340c7e16..a8ba4172 100644
--- a/apps/web/app/(dash)/menu.tsx
+++ b/apps/web/app/(dash)/menu.tsx
@@ -65,12 +65,6 @@ function Menu() {
disabled: false,
},
{
- icon: ExploreIcon,
- text: "Explore",
- url: "/explore",
- disabled: true,
- },
- {
icon: CanvasIcon,
text: "Canvas",
url: "/canvas",
@@ -86,7 +80,9 @@ function Menu() {
return "none";
}
- if (content.match(/https?:\/\/[\w\.]+\/[\w]+\/[\w]+\/[\d]+/)) {
+ if (
+ content.match(/https?:\/\/(x\.com|twitter\.com)\/[\w]+\/[\w]+\/[\d]+/)
+ ) {
return "tweet";
} else if (content.match(/https?:\/\/[\w\.]+/)) {
return "page";
@@ -136,8 +132,8 @@ function Menu() {
<>
{/* Desktop Menu */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
- <div className="hidden lg:flex fixed h-screen pb-20 w-full p-4 items-center justify-start top-0 left-0 pointer-events-none">
- <div className="pointer-events-auto group flex w-14 text-foreground-menu text-[15px] font-medium flex-col items-start gap-6 overflow-hidden rounded-[28px] border-2 border-border bg-secondary px-3 py-4 duration-200 hover:w-40">
+ <div className="hidden lg:flex fixed h-screen pb-20 w-full p-4 items-center justify-start top-0 left-0 pointer-events-none z-[39]">
+ <div className="pointer-events-auto group flex w-14 text-foreground-menu text-[15px] font-medium flex-col items-start gap-6 overflow-hidden rounded-[28px] border-2 border-border bg-secondary px-3 py-4 duration-200 hover:w-40 z-[99999]">
<div className="border-b border-border pb-4 w-full">
<DialogTrigger
className={`flex w-full text-white brightness-75 hover:brightness-125 focus:brightness-125 cursor-pointer items-center gap-3 px-1 duration-200 justify-start`}
@@ -180,7 +176,7 @@ function Menu() {
</div>
</div>
- <DialogContent className="sm:max-w-[425px] rounded-2xl bg-[#161f2a]/30 backdrop-blur-md">
+ <DialogContent className="sm:max-w-[425px] rounded-2xl bg-[#161f2a]/40 backdrop-blur-md">
<form
action={async (e: FormData) => {
const content = e.get("content")?.toString();
@@ -202,7 +198,7 @@ function Menu() {
Resource (URL or content)
</Label>
<Textarea
- className="bg-[#161f2a] focus-visible:ring-0 border-none focus-visible:ring-offset-0 mt-2"
+ className="bg-[#2F353C] focus-visible:ring-0 border-none focus-visible:ring-offset-0 mt-2"
id="content"
name="content"
placeholder="Start typing a note or paste a URL here. I'll remember it."
@@ -287,7 +283,7 @@ function Menu() {
}
}}
placeholder="Save or create space by typing."
- className="bg-[#161f2a] h-min rounded-md mt-2 mb-4"
+ className="bg-[#2F353C] h-min rounded-md mt-2 mb-4"
/>
<div>
@@ -296,6 +292,7 @@ function Menu() {
{selectedSpaces.map((x, idx) => (
<button
key={x}
+ type="button"
onClick={() =>
setSelectedSpaces((prev) =>
prev.filter((y) => y !== x),
diff --git a/apps/web/app/actions/doers.ts b/apps/web/app/actions/doers.ts
index 91a40f2c..69e57687 100644
--- a/apps/web/app/actions/doers.ts
+++ b/apps/web/app/actions/doers.ts
@@ -6,6 +6,7 @@ import {
chatHistory,
chatThreads,
contentToSpace,
+ sessions,
space,
storedContent,
users,
@@ -17,9 +18,11 @@ import { getMetaData } from "@/lib/get-metadata";
import { and, eq, inArray, sql } from "drizzle-orm";
import { LIMITS } from "@/lib/constants";
import { z } from "zod";
-import { ChatHistory } from "@repo/shared-types";
+import { AddFromAPIType, ChatHistory } from "@repo/shared-types";
import { decipher } from "@/server/encrypt";
import { redirect } from "next/navigation";
+import { ensureAuth } from "../api/ensureAuth";
+import { tweetToMd } from "@repo/shared-types/utils";
export const createSpace = async (
input: string | FormData,
@@ -61,7 +64,7 @@ export const createSpace = async (
const typeDecider = (content: string) => {
// if the content is a URL, then it's a page. if its a URL with https://x.com/user/status/123, then it's a tweet. else, it's a note.
// do strict checking with regex
- if (content.match(/https?:\/\/[\w\.]+\/[\w]+\/[\w]+\/[\d]+/)) {
+ if (content.match(/https?:\/\/(x\.com|twitter\.com)\/[\w]+\/[\w]+\/[\d]+/)) {
return "tweet";
} else if (content.match(/https?:\/\/[\w\.]+/)) {
return "page";
@@ -72,19 +75,23 @@ const typeDecider = (content: string) => {
}
};
-export const limit = async (userId: string, type = "page") => {
- const count = await db
+export const limit = async (
+ userId: string,
+ type = "page",
+ items: number = 1,
+) => {
+ const countResult = await db
.select({
count: sql<number>`count(*)`.mapWith(Number),
})
.from(storedContent)
.where(and(eq(storedContent.userId, userId), eq(storedContent.type, type)));
- if (count[0]!.count > LIMITS[type as keyof typeof LIMITS]) {
- return false;
- }
+ const currentCount = countResult[0]?.count || 0;
+ const totalLimit = LIMITS[type as keyof typeof LIMITS];
+ const remainingLimit = totalLimit - currentCount;
- return true;
+ return items <= remainingLimit;
};
const getTweetData = async (tweetID: string) => {
@@ -133,6 +140,8 @@ export const createMemory = async (input: {
};
}
+ let noteId = 0;
+
if (type === "page") {
const response = await fetch("https://md.dhr.wtf/?url=" + input.content, {
headers: {
@@ -151,7 +160,7 @@ export const createMemory = async (input: {
}
} else if (type === "tweet") {
const tweet = await getTweetData(input.content.split("/").pop() as string);
- pageContent = JSON.stringify(tweet);
+ pageContent = tweetToMd(tweet);
metadata = {
baseUrl: input.content,
description: tweet.text,
@@ -160,7 +169,7 @@ export const createMemory = async (input: {
};
} else if (type === "note") {
pageContent = input.content;
- const noteId = new Date().getTime();
+ noteId = new Date().getTime();
metadata = {
baseUrl: `https://supermemory.ai/note/${noteId}`,
description: `Note created at ${new Date().toLocaleString()}`,
@@ -212,23 +221,49 @@ export const createMemory = async (input: {
};
}
+ let contentId: number | undefined;
+
// Insert into database
- const insertResponse = await db
- .insert(storedContent)
- .values({
- content: pageContent,
- title: metadata.title,
- description: metadata.description,
- url: input.content,
- baseUrl: metadata.baseUrl,
- image: metadata.image,
- savedAt: new Date(),
- userId: data.user.id,
- type,
- })
- .returning({ id: storedContent.id });
+ try {
+ const insertResponse = await db
+ .insert(storedContent)
+ .values({
+ content: pageContent,
+ title: metadata.title,
+ description: metadata.description,
+ url: input.content,
+ baseUrl: metadata.baseUrl,
+ image: metadata.image,
+ savedAt: new Date(),
+ userId: data.user.id,
+ type,
+ })
+ .returning({ id: storedContent.id });
+
+ contentId = insertResponse[0]?.id;
+ } catch (e) {
+ const error = e as Error;
+ console.log("Error: ", error.message);
+
+ if (
+ error.message.includes(
+ "D1_ERROR: UNIQUE constraint failed: storedContent.baseUrl",
+ )
+ ) {
+ return {
+ success: false,
+ data: 0,
+ error: "Content already exists",
+ };
+ }
+
+ return {
+ success: false,
+ data: 0,
+ error: "Failed to save to database with error: " + error.message,
+ };
+ }
- const contentId = insertResponse[0]?.id;
if (!contentId) {
return {
success: false,
@@ -277,6 +312,9 @@ export const createMemory = async (input: {
};
}
+ revalidatePath("/home");
+ revalidatePath("/memories");
+
return {
success: true,
data: 1,
diff --git a/apps/web/app/actions/fetchers.ts b/apps/web/app/actions/fetchers.ts
index 558ef3de..0035c217 100644
--- a/apps/web/app/actions/fetchers.ts
+++ b/apps/web/app/actions/fetchers.ts
@@ -18,6 +18,7 @@ import { ChatHistory, SourceZod } from "@repo/shared-types";
import { ChatHistory as ChatHistoryType } from "../../server/db/schema";
import { z } from "zod";
import { redirect } from "next/navigation";
+import { cookies, headers } from "next/headers";
export const getSpaces = async (): ServerActionReturnType<StoredSpace[]> => {
const data = await auth();
@@ -186,3 +187,16 @@ export const getChatHistory = async (): ServerActionReturnType<
};
}
};
+
+export const getSessionAuthToken = async (): ServerActionReturnType<string> => {
+ const token =
+ cookies().get("next-auth.session-token")?.value ??
+ cookies().get("__Secure-authjs.session-token")?.value ??
+ cookies().get("authjs.session-token")?.value ??
+ headers().get("Authorization")?.replace("Bearer ", "");
+
+ return {
+ success: true,
+ data: token,
+ };
+};
diff --git a/apps/web/app/api/store/route.ts b/apps/web/app/api/store/route.ts
new file mode 100644
index 00000000..8a126a56
--- /dev/null
+++ b/apps/web/app/api/store/route.ts
@@ -0,0 +1,202 @@
+import { type NextRequest } from "next/server";
+import { addFromAPIType, AddFromAPIType } from "@repo/shared-types";
+import { ensureAuth } from "../ensureAuth";
+import { z } from "zod";
+import { db } from "@/server/db";
+import { contentToSpace, space, storedContent } from "@/server/db/schema";
+import { and, eq, inArray } from "drizzle-orm";
+import { LIMITS } from "@/lib/constants";
+import { limit } from "@/app/actions/doers";
+
+export const runtime = "edge";
+
+const createMemoryFromAPI = async (input: {
+ data: AddFromAPIType;
+ userId: string;
+}) => {
+ if (!(await limit(input.userId, input.data.type))) {
+ return {
+ success: false,
+ data: 0,
+ error: `You have exceeded the limit of ${LIMITS[input.data.type as keyof typeof LIMITS]} ${input.data.type}s.`,
+ };
+ }
+
+ const vectorSaveResponse = await fetch(
+ `${process.env.BACKEND_BASE_URL}/api/add`,
+ {
+ method: "POST",
+ body: JSON.stringify({
+ pageContent: input.data.pageContent,
+ title: input.data.title,
+ description: input.data.description,
+ url: input.data.url,
+ spaces: input.data.spaces,
+ user: input.userId,
+ type: input.data.type,
+ }),
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: "Bearer " + process.env.BACKEND_SECURITY_KEY,
+ },
+ },
+ );
+
+ if (!vectorSaveResponse.ok) {
+ const errorData = await vectorSaveResponse.text();
+ console.error(errorData);
+ return {
+ success: false,
+ data: 0,
+ error: `Failed to save to vector store. Backend returned error: ${errorData}`,
+ };
+ }
+
+ let contentId: number | undefined;
+
+ // Insert into database
+ try {
+ const insertResponse = await db
+ .insert(storedContent)
+ .values({
+ content: input.data.pageContent,
+ title: input.data.title,
+ description: input.data.description,
+ url: input.data.url,
+ baseUrl: input.data.url,
+ image: input.data.image,
+ savedAt: new Date(),
+ userId: input.userId,
+ type: input.data.type,
+ })
+ .returning({ id: storedContent.id });
+
+ contentId = insertResponse[0]?.id;
+ } catch (e) {
+ const error = e as Error;
+ console.log("Error: ", error.message);
+
+ if (
+ error.message.includes(
+ "D1_ERROR: UNIQUE constraint failed: storedContent.baseUrl",
+ )
+ ) {
+ return {
+ success: false,
+ data: 0,
+ error: "Content already exists",
+ };
+ }
+
+ return {
+ success: false,
+ data: 0,
+ error: "Failed to save to database with error: " + error.message,
+ };
+ }
+
+ if (!contentId) {
+ return {
+ success: false,
+ data: 0,
+ error: "Failed to save to database",
+ };
+ }
+
+ if (input.data.spaces.length > 0) {
+ // Adding the many-to-many relationship between content and spaces
+ const spaceData = await db
+ .select()
+ .from(space)
+ .where(
+ and(
+ inArray(
+ space.id,
+ input.data.spaces.map((s) => parseInt(s)),
+ ),
+ eq(space.user, input.userId),
+ ),
+ )
+ .all();
+
+ await Promise.all(
+ spaceData.map(async (space) => {
+ await db
+ .insert(contentToSpace)
+ .values({ contentId: contentId, spaceId: space.id });
+ }),
+ );
+ }
+
+ try {
+ const response = await vectorSaveResponse.json();
+
+ const expectedResponse = z.object({ status: z.literal("ok") });
+
+ const parsedResponse = expectedResponse.safeParse(response);
+
+ if (!parsedResponse.success) {
+ return {
+ success: false,
+ data: 0,
+ error: `Failed to save to vector store. Backend returned error: ${parsedResponse.error.message}`,
+ };
+ }
+
+ return {
+ success: true,
+ data: 1,
+ };
+ } catch (e) {
+ return {
+ success: false,
+ data: 0,
+ error: `Failed to save to vector store. Backend returned error: ${e}`,
+ };
+ }
+};
+
+export async function POST(req: NextRequest) {
+ const session = await ensureAuth(req);
+
+ if (!session) {
+ return new Response("Unauthorized", { status: 401 });
+ }
+
+ if (!process.env.BACKEND_SECURITY_KEY) {
+ return new Response("Missing BACKEND_SECURITY_KEY", { status: 500 });
+ }
+
+ const body = await req.json();
+
+ const validated = addFromAPIType.safeParse(body);
+
+ if (!validated.success) {
+ return new Response(
+ JSON.stringify({
+ message: "Invalid request",
+ error: validated.error,
+ }),
+ { status: 400 },
+ );
+ }
+
+ const data = validated.data;
+
+ const result = await createMemoryFromAPI({
+ data,
+ userId: session.user.id,
+ });
+
+ if (!result.success) {
+ return new Response(
+ JSON.stringify({
+ message: "Failed to save document",
+ error: result.error,
+ }),
+ { status: 500 },
+ );
+ }
+
+ return new Response("ok", { status: 200 });
+}
diff --git a/apps/web/migrations/0001_index_telegram_id.sql b/apps/web/migrations/0001_index_telegram_id.sql
index 4b3aa44a..bff9d007 100644
--- a/apps/web/migrations/0001_index_telegram_id.sql
+++ b/apps/web/migrations/0001_index_telegram_id.sql
@@ -3,7 +3,7 @@ ALTER TABLE `space` ADD COLUMN `numItems` integer NOT NULL DEFAULT 0;
--> statement-breakpoint
ALTER TABLE `user` ADD COLUMN `telegramId` text;
-CREATE INDEX `storedContent_user_idx` ON `storedContent` (`user`);--> statement-breakpoint
CREATE INDEX `users_email_idx` ON `user` (`email`);--> statement-breakpoint
CREATE INDEX `users_telegram_idx` ON `user` (`telegramId`);--> statement-breakpoint
-CREATE INDEX `users_id_idx` ON `user` (`id`); \ No newline at end of file
+CREATE INDEX `users_id_idx` ON `user` (`id`);
+CREATE UNIQUE INDEX `storedContent_baseUrl_unique` ON `storedContent` (`baseUrl`); \ No newline at end of file
diff --git a/apps/web/migrations/0000_classy_speed_demon.sql b/apps/web/migrations/000_setup.sql
index a4855ec9..a4855ec9 100644
--- a/apps/web/migrations/0000_classy_speed_demon.sql
+++ b/apps/web/migrations/000_setup.sql
diff --git a/apps/web/migrations/meta/0001_snapshot.json b/apps/web/migrations/meta/0001_snapshot.json
new file mode 100644
index 00000000..6d8e6ebc
--- /dev/null
+++ b/apps/web/migrations/meta/0001_snapshot.json
@@ -0,0 +1,698 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "3f58a63e-3416-40c0-b8ef-82aa87eeed77",
+ "prevId": "6988522d-8117-484d-b52a-94c0fbd75140",
+ "tables": {
+ "account": {
+ "name": "account",
+ "columns": {
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "providerAccountId": {
+ "name": "providerAccountId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "token_type": {
+ "name": "token_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "session_state": {
+ "name": "session_state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "account_userId_user_id_fk": {
+ "name": "account_userId_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": ["userId"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "account_provider_providerAccountId_pk": {
+ "columns": ["provider", "providerAccountId"],
+ "name": "account_provider_providerAccountId_pk"
+ }
+ },
+ "uniqueConstraints": {}
+ },
+ "authenticator": {
+ "name": "authenticator",
+ "columns": {
+ "credentialID": {
+ "name": "credentialID",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "providerAccountId": {
+ "name": "providerAccountId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "credentialPublicKey": {
+ "name": "credentialPublicKey",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "counter": {
+ "name": "counter",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "credentialDeviceType": {
+ "name": "credentialDeviceType",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "credentialBackedUp": {
+ "name": "credentialBackedUp",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "transports": {
+ "name": "transports",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "authenticator_credentialID_unique": {
+ "name": "authenticator_credentialID_unique",
+ "columns": ["credentialID"],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "authenticator_userId_user_id_fk": {
+ "name": "authenticator_userId_user_id_fk",
+ "tableFrom": "authenticator",
+ "tableTo": "user",
+ "columnsFrom": ["userId"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "authenticator_userId_credentialID_pk": {
+ "columns": ["credentialID", "userId"],
+ "name": "authenticator_userId_credentialID_pk"
+ }
+ },
+ "uniqueConstraints": {}
+ },
+ "chatHistory": {
+ "name": "chatHistory",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "threadId": {
+ "name": "threadId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "question": {
+ "name": "question",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "answerParts": {
+ "name": "answerParts",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "answerSources": {
+ "name": "answerSources",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "answerJustification": {
+ "name": "answerJustification",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "chatHistory_thread_idx": {
+ "name": "chatHistory_thread_idx",
+ "columns": ["threadId"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "chatHistory_threadId_chatThread_id_fk": {
+ "name": "chatHistory_threadId_chatThread_id_fk",
+ "tableFrom": "chatHistory",
+ "tableTo": "chatThread",
+ "columnsFrom": ["threadId"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {}
+ },
+ "chatThread": {
+ "name": "chatThread",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "firstMessage": {
+ "name": "firstMessage",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "chatThread_user_idx": {
+ "name": "chatThread_user_idx",
+ "columns": ["userId"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "chatThread_userId_user_id_fk": {
+ "name": "chatThread_userId_user_id_fk",
+ "tableFrom": "chatThread",
+ "tableTo": "user",
+ "columnsFrom": ["userId"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {}
+ },
+ "contentToSpace": {
+ "name": "contentToSpace",
+ "columns": {
+ "contentId": {
+ "name": "contentId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "spaceId": {
+ "name": "spaceId",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "contentToSpace_contentId_storedContent_id_fk": {
+ "name": "contentToSpace_contentId_storedContent_id_fk",
+ "tableFrom": "contentToSpace",
+ "tableTo": "storedContent",
+ "columnsFrom": ["contentId"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "contentToSpace_spaceId_space_id_fk": {
+ "name": "contentToSpace_spaceId_space_id_fk",
+ "tableFrom": "contentToSpace",
+ "tableTo": "space",
+ "columnsFrom": ["spaceId"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "contentToSpace_contentId_spaceId_pk": {
+ "columns": ["contentId", "spaceId"],
+ "name": "contentToSpace_contentId_spaceId_pk"
+ }
+ },
+ "uniqueConstraints": {}
+ },
+ "session": {
+ "name": "session",
+ "columns": {
+ "sessionToken": {
+ "name": "sessionToken",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires": {
+ "name": "expires",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "session_userId_user_id_fk": {
+ "name": "session_userId_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "columnsFrom": ["userId"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {}
+ },
+ "space": {
+ "name": "space",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'none'"
+ },
+ "user": {
+ "name": "user",
+ "type": "text(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "numItems": {
+ "name": "numItems",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ }
+ },
+ "indexes": {
+ "space_name_unique": {
+ "name": "space_name_unique",
+ "columns": ["name"],
+ "isUnique": true
+ },
+ "spaces_name_idx": {
+ "name": "spaces_name_idx",
+ "columns": ["name"],
+ "isUnique": false
+ },
+ "spaces_user_idx": {
+ "name": "spaces_user_idx",
+ "columns": ["user"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "space_user_user_id_fk": {
+ "name": "space_user_user_id_fk",
+ "tableFrom": "space",
+ "tableTo": "user",
+ "columnsFrom": ["user"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {}
+ },
+ "storedContent": {
+ "name": "storedContent",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "savedAt": {
+ "name": "savedAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "baseUrl": {
+ "name": "baseUrl",
+ "type": "text(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "ogImage": {
+ "name": "ogImage",
+ "type": "text(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'page'"
+ },
+ "image": {
+ "name": "image",
+ "type": "text(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "user": {
+ "name": "user",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "storedContent_baseUrl_unique": {
+ "name": "storedContent_baseUrl_unique",
+ "columns": ["baseUrl"],
+ "isUnique": true
+ },
+ "storedContent_url_idx": {
+ "name": "storedContent_url_idx",
+ "columns": ["url"],
+ "isUnique": false
+ },
+ "storedContent_savedAt_idx": {
+ "name": "storedContent_savedAt_idx",
+ "columns": ["savedAt"],
+ "isUnique": false
+ },
+ "storedContent_title_idx": {
+ "name": "storedContent_title_idx",
+ "columns": ["title"],
+ "isUnique": false
+ },
+ "storedContent_user_idx": {
+ "name": "storedContent_user_idx",
+ "columns": ["user"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "storedContent_user_user_id_fk": {
+ "name": "storedContent_user_user_id_fk",
+ "tableFrom": "storedContent",
+ "tableTo": "user",
+ "columnsFrom": ["user"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {}
+ },
+ "user": {
+ "name": "user",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "emailVerified": {
+ "name": "emailVerified",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "telegramId": {
+ "name": "telegramId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "users_email_idx": {
+ "name": "users_email_idx",
+ "columns": ["email"],
+ "isUnique": false
+ },
+ "users_telegram_idx": {
+ "name": "users_telegram_idx",
+ "columns": ["telegramId"],
+ "isUnique": false
+ },
+ "users_id_idx": {
+ "name": "users_id_idx",
+ "columns": ["id"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {}
+ },
+ "verificationToken": {
+ "name": "verificationToken",
+ "columns": {
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires": {
+ "name": "expires",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "verificationToken_identifier_token_pk": {
+ "columns": ["identifier", "token"],
+ "name": "verificationToken_identifier_token_pk"
+ }
+ },
+ "uniqueConstraints": {}
+ }
+ },
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ }
+}
diff --git a/apps/web/migrations/meta/_journal.json b/apps/web/migrations/meta/_journal.json
index 552ae12f..3c6b0ed8 100644
--- a/apps/web/migrations/meta/_journal.json
+++ b/apps/web/migrations/meta/_journal.json
@@ -8,6 +8,13 @@
"when": 1719671614406,
"tag": "0000_classy_speed_demon",
"breakpoints": true
+ },
+ {
+ "idx": 1,
+ "version": "6",
+ "when": 1719772835765,
+ "tag": "0001_chubby_vulture",
+ "breakpoints": true
}
]
}
diff --git a/apps/web/server/db/schema.ts b/apps/web/server/db/schema.ts
index e1b94cf3..26a4f22d 100644
--- a/apps/web/server/db/schema.ts
+++ b/apps/web/server/db/schema.ts
@@ -110,7 +110,7 @@ export const storedContent = createTable(
description: text("description", { length: 255 }),
url: text("url").notNull(),
savedAt: int("savedAt", { mode: "timestamp" }).notNull(),
- baseUrl: text("baseUrl", { length: 255 }),
+ baseUrl: text("baseUrl", { length: 255 }).unique(),
ogImage: text("ogImage", { length: 255 }),
type: text("type").default("page"),
image: text("image", { length: 255 }),
diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json
index 34e0a9b0..63996482 100644
--- a/apps/web/tsconfig.json
+++ b/apps/web/tsconfig.json
@@ -17,7 +17,8 @@
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
- "../../packages/ui/"
+ "../../packages/ui/",
+ "../../packages/shared-types/utils.ts"
],
"exclude": ["node_modules/"]
}