diff options
| -rw-r--r-- | apps/web/app/settings/feeds/page.tsx | 5 | ||||
| -rw-r--r-- | apps/web/components/settings/FeedSettings.tsx | 398 | ||||
| -rw-r--r-- | apps/web/components/settings/sidebar/items.tsx | 14 | ||||
| -rw-r--r-- | apps/workers/feedWorker.ts | 149 | ||||
| -rw-r--r-- | apps/workers/index.ts | 8 | ||||
| -rw-r--r-- | apps/workers/package.json | 2 | ||||
| -rw-r--r-- | apps/workers/trpc.ts | 33 | ||||
| -rw-r--r-- | packages/db/drizzle/0032_futuristic_shiva.sql | 25 | ||||
| -rw-r--r-- | packages/db/drizzle/meta/0032_snapshot.json | 1404 | ||||
| -rw-r--r-- | packages/db/drizzle/meta/_journal.json | 7 | ||||
| -rw-r--r-- | packages/db/schema.ts | 46 | ||||
| -rw-r--r-- | packages/shared/queues.ts | 17 | ||||
| -rw-r--r-- | packages/shared/types/feeds.ts | 27 | ||||
| -rw-r--r-- | packages/trpc/routers/_app.ts | 2 | ||||
| -rw-r--r-- | packages/trpc/routers/feeds.ts | 121 | ||||
| -rw-r--r-- | pnpm-lock.yaml | 25 |
16 files changed, 2280 insertions, 3 deletions
diff --git a/apps/web/app/settings/feeds/page.tsx b/apps/web/app/settings/feeds/page.tsx new file mode 100644 index 00000000..d2d1f182 --- /dev/null +++ b/apps/web/app/settings/feeds/page.tsx @@ -0,0 +1,5 @@ +import FeedSettings from "@/components/settings/FeedSettings"; + +export default function FeedSettingsPage() { + return <FeedSettings />; +} diff --git a/apps/web/components/settings/FeedSettings.tsx b/apps/web/components/settings/FeedSettings.tsx new file mode 100644 index 00000000..9f2dbda9 --- /dev/null +++ b/apps/web/components/settings/FeedSettings.tsx @@ -0,0 +1,398 @@ +"use client"; + +import React from "react"; +import { ActionButton } from "@/components/ui/action-button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { FullPageSpinner } from "@/components/ui/full-page-spinner"; +import { Input } from "@/components/ui/input"; +import { toast } from "@/components/ui/use-toast"; +import { api } from "@/lib/trpc"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + ArrowDownToLine, + CheckCircle, + CircleDashed, + Edit, + Plus, + Save, + Trash2, + XCircle, +} from "lucide-react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { + ZFeed, + zNewFeedSchema, + zUpdateFeedSchema, +} from "@hoarder/shared/types/feeds"; + +import ActionConfirmingDialog from "../ui/action-confirming-dialog"; +import { Button } from "../ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "../ui/dialog"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "../ui/table"; + +export function FeedsEditorDialog() { + const [open, setOpen] = React.useState(false); + const apiUtils = api.useUtils(); + + const form = useForm<z.infer<typeof zNewFeedSchema>>({ + resolver: zodResolver(zNewFeedSchema), + defaultValues: { + name: "", + url: "", + }, + }); + + React.useEffect(() => { + if (open) { + form.reset(); + } + }, [open]); + + const { mutateAsync: createFeed, isPending: isCreating } = + api.feeds.create.useMutation({ + onSuccess: () => { + toast({ + description: "Feed has been created!", + }); + apiUtils.feeds.list.invalidate(); + setOpen(false); + }, + }); + + return ( + <Dialog open={open} onOpenChange={setOpen}> + <DialogTrigger asChild> + <Button> + <Plus className="mr-2 size-4" /> + Add a Subscription + </Button> + </DialogTrigger> + <DialogContent> + <DialogHeader> + <DialogTitle>Subscribe to a new Feed</DialogTitle> + </DialogHeader> + <Form {...form}> + <form + className="flex flex-col gap-3" + onSubmit={form.handleSubmit(async (value) => { + await createFeed(value); + form.resetField("name"); + form.resetField("url"); + })} + > + <FormField + control={form.control} + name="name" + render={({ field }) => { + return ( + <FormItem className="flex-1"> + <FormLabel>Name</FormLabel> + <FormControl> + <Input placeholder="Feed Name" type="text" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + ); + }} + /> + <FormField + control={form.control} + name="url" + render={({ field }) => { + return ( + <FormItem className="flex-1"> + <FormLabel>URL</FormLabel> + <FormControl> + <Input placeholder="Feed URL" type="text" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + ); + }} + /> + </form> + </Form> + <DialogFooter> + <DialogClose asChild> + <Button type="button" variant="secondary"> + Close + </Button> + </DialogClose> + <ActionButton + onClick={form.handleSubmit(async (value) => { + await createFeed(value); + })} + loading={isCreating} + variant="default" + className="items-center" + > + <Plus className="mr-2 size-4" /> + Add + </ActionButton> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} + +export function EditFeedDialog({ feed }: { feed: ZFeed }) { + const apiUtils = api.useUtils(); + const [open, setOpen] = React.useState(false); + React.useEffect(() => { + if (open) { + form.reset({ + feedId: feed.id, + name: feed.name, + url: feed.url, + }); + } + }, [open]); + const { mutateAsync: updateFeed, isPending: isUpdating } = + api.feeds.update.useMutation({ + onSuccess: () => { + toast({ + description: "Feed has been updated!", + }); + setOpen(false); + apiUtils.feeds.list.invalidate(); + }, + }); + const form = useForm<z.infer<typeof zUpdateFeedSchema>>({ + resolver: zodResolver(zUpdateFeedSchema), + defaultValues: { + feedId: feed.id, + name: feed.name, + url: feed.url, + }, + }); + return ( + <Dialog open={open} onOpenChange={setOpen}> + <DialogTrigger asChild> + <Button variant="secondary"> + <Edit className="mr-2 size-4" /> + Edit + </Button> + </DialogTrigger> + <DialogContent> + <DialogHeader> + <DialogTitle>Edit Feed</DialogTitle> + </DialogHeader> + + <Form {...form}> + <form + className="flex flex-col gap-3" + onSubmit={form.handleSubmit(async (value) => { + await updateFeed(value); + })} + > + <FormField + control={form.control} + name="feedId" + render={({ field }) => { + return ( + <FormItem className="hidden"> + <FormControl> + <Input type="hidden" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + ); + }} + /> + <FormField + control={form.control} + name="name" + render={({ field }) => { + return ( + <FormItem className="flex-1"> + <FormLabel>Name</FormLabel> + <FormControl> + <Input placeholder="Feed name" type="text" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + ); + }} + /> + <FormField + control={form.control} + name="url" + render={({ field }) => { + return ( + <FormItem className="flex-1"> + <FormLabel>URL</FormLabel> + <FormControl> + <Input placeholder="Feed url" type="text" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + ); + }} + /> + </form> + </Form> + <DialogFooter> + <DialogClose asChild> + <Button type="button" variant="secondary"> + Close + </Button> + </DialogClose> + <ActionButton + loading={isUpdating} + onClick={form.handleSubmit(async (value) => { + await updateFeed(value); + })} + type="submit" + className="items-center" + > + <Save className="mr-2 size-4" /> + Save + </ActionButton> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} + +export function FeedRow({ feed }: { feed: ZFeed }) { + const apiUtils = api.useUtils(); + const { mutate: deleteFeed, isPending: isDeleting } = + api.feeds.delete.useMutation({ + onSuccess: () => { + toast({ + description: "Feed has been deleted!", + }); + apiUtils.feeds.list.invalidate(); + }, + }); + + const { mutate: fetchNow, isPending: isFetching } = + api.feeds.fetchNow.useMutation({ + onSuccess: () => { + toast({ + description: "Feed fetch has been enqueued!", + }); + apiUtils.feeds.list.invalidate(); + }, + }); + + return ( + <TableRow> + <TableCell>{feed.name}</TableCell> + <TableCell>{feed.url}</TableCell> + <TableCell>{feed.lastFetchedAt?.toLocaleString()}</TableCell> + <TableCell> + {feed.lastFetchedStatus === "success" ? ( + <span title="Successful"> + <CheckCircle /> + </span> + ) : feed.lastFetchedStatus === "failure" ? ( + <span title="Failed"> + <XCircle /> + </span> + ) : ( + <span title="Pending"> + <CircleDashed name="Pending" /> + </span> + )} + </TableCell> + <TableCell className="flex items-center gap-2"> + <EditFeedDialog feed={feed} /> + <ActionButton + loading={isFetching} + variant="secondary" + className="items-center" + onClick={() => fetchNow({ feedId: feed.id })} + > + <ArrowDownToLine className="mr-2 size-4" /> + Fetch Now + </ActionButton> + <ActionConfirmingDialog + title={`Delete Feed "${feed.name}"?`} + description={`Are you sure you want to delete the feed "${feed.name}"?`} + actionButton={() => ( + <ActionButton + loading={isDeleting} + variant="destructive" + onClick={() => deleteFeed({ feedId: feed.id })} + className="items-center" + type="button" + > + <Trash2 className="mr-2 size-4" /> + Delete + </ActionButton> + )} + > + <Button variant="destructive" disabled={isDeleting}> + <Trash2 className="mr-2 size-4" /> + Delete + </Button> + </ActionConfirmingDialog> + </TableCell> + </TableRow> + ); +} + +export default function FeedSettings() { + const { data: feeds, isLoading } = api.feeds.list.useQuery(); + return ( + <> + <div className="rounded-md border bg-background p-4"> + <div className="flex flex-col gap-2"> + <div className="flex items-center justify-between"> + <div className="mb-2 text-lg font-medium">RSS Subscriptions</div> + <FeedsEditorDialog /> + </div> + {isLoading && <FullPageSpinner />} + {feeds && feeds.feeds.length == 0 && ( + <p className="rounded-md bg-muted p-2 text-sm text-muted-foreground"> + You don't have any RSS subscriptions yet. + </p> + )} + {feeds && feeds.feeds.length > 0 && ( + <Table> + <TableHeader> + <TableRow> + <TableHead>Name</TableHead> + <TableHead>URL</TableHead> + <TableHead>Last Fetch</TableHead> + <TableHead>Last Status</TableHead> + <TableHead>Actions</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {feeds.feeds.map((feed) => ( + <FeedRow key={feed.id} feed={feed} /> + ))} + </TableBody> + </Table> + )} + </div> + </div> + </> + ); +} diff --git a/apps/web/components/settings/sidebar/items.tsx b/apps/web/components/settings/sidebar/items.tsx index 999825db..047ee233 100644 --- a/apps/web/components/settings/sidebar/items.tsx +++ b/apps/web/components/settings/sidebar/items.tsx @@ -1,5 +1,12 @@ import React from "react"; -import { ArrowLeft, Download, KeyRound, Sparkles, User } from "lucide-react"; +import { + ArrowLeft, + Download, + KeyRound, + Rss, + Sparkles, + User, +} from "lucide-react"; export const settingsSidebarItems: { name: string; @@ -22,6 +29,11 @@ export const settingsSidebarItems: { path: "/settings/ai", }, { + name: "RSS Subscriptions", + icon: <Rss size={18} />, + path: "/settings/feeds", + }, + { name: "Import / Export", icon: <Download size={18} />, path: "/settings/import", diff --git a/apps/workers/feedWorker.ts b/apps/workers/feedWorker.ts new file mode 100644 index 00000000..1bd24641 --- /dev/null +++ b/apps/workers/feedWorker.ts @@ -0,0 +1,149 @@ +import { and, eq, inArray } from "drizzle-orm"; +import { DequeuedJob, Runner } from "liteque"; +import Parser from "rss-parser"; +import { buildImpersonatingTRPCClient } from "trpc"; + +import type { ZFeedRequestSchema } from "@hoarder/shared/queues"; +import { db } from "@hoarder/db"; +import { rssFeedImportsTable, rssFeedsTable } from "@hoarder/db/schema"; +import logger from "@hoarder/shared/logger"; +import { FeedQueue } from "@hoarder/shared/queues"; +import { BookmarkTypes } from "@hoarder/shared/types/bookmarks"; + +export class FeedWorker { + static build() { + logger.info("Starting feed worker ..."); + const worker = new Runner<ZFeedRequestSchema>( + FeedQueue, + { + run: run, + onComplete: async (job) => { + const jobId = job.id; + logger.info(`[feed][${jobId}] Completed successfully`); + await db + .update(rssFeedsTable) + .set({ lastFetchedStatus: "success", lastFetchedAt: new Date() }) + .where(eq(rssFeedsTable.id, job.data?.feedId)); + }, + onError: async (job) => { + const jobId = job.id; + logger.error( + `[feed][${jobId}] Feed fetch job failed: ${job.error}\n${job.error.stack}`, + ); + if (job.data) { + await db + .update(rssFeedsTable) + .set({ lastFetchedStatus: "failure", lastFetchedAt: new Date() }) + .where(eq(rssFeedsTable.id, job.data?.feedId)); + } + }, + }, + { + concurrency: 1, + pollIntervalMs: 1000, + timeoutSecs: 30, + }, + ); + + return worker; + } +} + +async function run(req: DequeuedJob<ZFeedRequestSchema>) { + const jobId = req.id; + const feed = await db.query.rssFeedsTable.findFirst({ + where: eq(rssFeedsTable.id, req.data.feedId), + }); + if (!feed) { + throw new Error( + `[feed][${jobId}] Feed with id ${req.data.feedId} not found`, + ); + } + + const response = await fetch(feed.url, { + signal: AbortSignal.timeout(5000), + }); + const contentType = response.headers.get("content-type"); + if (!contentType || !contentType.includes("application/xml")) { + throw new Error( + `[feed][${jobId}] Feed with id ${req.data.feedId} is not a valid RSS feed`, + ); + } + const xmlData = await response.text(); + + logger.info( + `[feed][${jobId}] Successfully fetched feed "${feed.name}" (${feed.id}) ...`, + ); + + const parser = new Parser(); + const feedData = await parser.parseString(xmlData); + + logger.info( + `[feed][${jobId}] Found ${feedData.items.length} entries in feed "${feed.name}" (${feed.id}) ...`, + ); + + if (feedData.items.length === 0) { + logger.info(`[feed][${jobId}] No entries found.`); + return; + } + + const exitingEntries = await db.query.rssFeedImportsTable.findMany({ + where: and( + eq(rssFeedImportsTable.rssFeedId, feed.id), + inArray( + rssFeedImportsTable.entryId, + feedData.items + .map((item) => item.guid) + .filter((id): id is string => !!id), + ), + ), + }); + + const newEntries = feedData.items.filter( + (item) => + !exitingEntries.some((entry) => entry.entryId === item.guid) && item.link, + ); + + if (newEntries.length === 0) { + logger.info( + `[feed][${jobId}] No new entries found in feed "${feed.name}" (${feed.id}).`, + ); + return; + } + + logger.info( + `[feed][${jobId}] Found ${newEntries.length} new entries in feed "${feed.name}" (${feed.id}) ...`, + ); + + const trpcClient = await buildImpersonatingTRPCClient(feed.userId); + + const createdBookmarks = await Promise.allSettled( + newEntries.map((item) => + trpcClient.bookmarks.createBookmark({ + type: BookmarkTypes.LINK, + url: item.link!, + }), + ), + ); + + // It's ok if this is not transactional as the bookmarks will get linked in the next iteration. + await db + .insert(rssFeedImportsTable) + .values( + newEntries.map((item, idx) => { + const b = createdBookmarks[idx]; + return { + entryId: item.guid!, + bookmarkId: b.status === "fulfilled" ? b.value.id : null, + rssFeedId: feed.id, + }; + }), + ) + .onConflictDoNothing(); + + logger.info( + `[feed][${jobId}] Successfully imported ${newEntries.length} new enteries from feed "${feed.name}" (${feed.id}).`, + ); + + return Promise.resolve(); +} diff --git a/apps/workers/index.ts b/apps/workers/index.ts index 3b5896e4..c8978adc 100644 --- a/apps/workers/index.ts +++ b/apps/workers/index.ts @@ -1,5 +1,6 @@ import "dotenv/config"; +import { FeedWorker } from "feedWorker"; import { TidyAssetsWorker } from "tidyAssetsWorker"; import serverConfig from "@hoarder/shared/config"; @@ -16,12 +17,13 @@ async function main() { logger.info(`Workers version: ${serverConfig.serverVersion ?? "not set"}`); runQueueDBMigrations(); - const [crawler, openai, search, tidyAssets, video] = [ + const [crawler, openai, search, tidyAssets, video, feed] = [ await CrawlerWorker.build(), OpenAiWorker.build(), SearchIndexingWorker.build(), TidyAssetsWorker.build(), VideoWorker.build(), + FeedWorker.build(), ]; await Promise.any([ @@ -31,11 +33,12 @@ async function main() { search.run(), tidyAssets.run(), video.run(), + feed.run(), ]), shutdownPromise, ]); logger.info( - "Shutting down crawler, openai, tidyAssets, video and search workers ...", + "Shutting down crawler, openai, tidyAssets, video, feed and search workers ...", ); crawler.stop(); @@ -43,6 +46,7 @@ async function main() { search.stop(); tidyAssets.stop(); video.stop(); + feed.stop(); } main(); diff --git a/apps/workers/package.json b/apps/workers/package.json index 7f64e715..a7579319 100644 --- a/apps/workers/package.json +++ b/apps/workers/package.json @@ -6,6 +6,7 @@ "dependencies": { "@hoarder/db": "workspace:^0.1.0", "@hoarder/shared": "workspace:^0.1.0", + "@hoarder/trpc": "workspace:^0.1.0", "@hoarder/tsconfig": "workspace:^0.1.0", "@mozilla/readability": "^0.5.0", "@tsconfig/node21": "^21.0.1", @@ -32,6 +33,7 @@ "puppeteer-extra": "^3.3.6", "puppeteer-extra-plugin-adblocker": "^2.13.6", "puppeteer-extra-plugin-stealth": "^2.11.2", + "rss-parser": "^3.13.0", "tesseract.js": "^5.1.1", "tsx": "^4.7.1", "typescript": "^5.3.3", diff --git a/apps/workers/trpc.ts b/apps/workers/trpc.ts new file mode 100644 index 00000000..cd2e4c99 --- /dev/null +++ b/apps/workers/trpc.ts @@ -0,0 +1,33 @@ +import { eq } from "drizzle-orm"; + +import { db } from "@hoarder/db"; +import { users } from "@hoarder/db/schema"; +import { createCallerFactory } from "@hoarder/trpc"; +import { appRouter } from "@hoarder/trpc/routers/_app"; + +/** + * This is only safe to use in the context of a worker. + */ +export async function buildImpersonatingTRPCClient(userId: string) { + const createCaller = createCallerFactory(appRouter); + + const user = await db.query.users.findFirst({ + where: eq(users.id, userId), + }); + if (!user) { + throw new Error("User not found"); + } + + return createCaller({ + user: { + id: user.id, + name: user.name, + email: user.email, + role: user.role, + }, + db, + req: { + ip: null, + }, + }); +} diff --git a/packages/db/drizzle/0032_futuristic_shiva.sql b/packages/db/drizzle/0032_futuristic_shiva.sql new file mode 100644 index 00000000..4bb79426 --- /dev/null +++ b/packages/db/drizzle/0032_futuristic_shiva.sql @@ -0,0 +1,25 @@ +CREATE TABLE `rssFeedImports` ( + `id` text PRIMARY KEY NOT NULL, + `createdAt` integer NOT NULL, + `entryId` text NOT NULL, + `rssFeedId` text NOT NULL, + `bookmarkId` text, + FOREIGN KEY (`rssFeedId`) REFERENCES `rssFeeds`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`bookmarkId`) REFERENCES `bookmarks`(`id`) ON UPDATE no action ON DELETE set null +); +--> statement-breakpoint +CREATE TABLE `rssFeeds` ( + `id` text PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `url` text NOT NULL, + `createdAt` integer NOT NULL, + `lastFetchedAt` integer, + `lastFetchedStatus` text DEFAULT 'pending', + `userId` text NOT NULL, + FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `rssFeedImports_feedIdIdx_idx` ON `rssFeedImports` (`rssFeedId`);--> statement-breakpoint +CREATE INDEX `rssFeedImports_entryIdIdx_idx` ON `rssFeedImports` (`entryId`);--> statement-breakpoint +CREATE UNIQUE INDEX `rssFeedImports_rssFeedId_entryId_unique` ON `rssFeedImports` (`rssFeedId`,`entryId`);--> statement-breakpoint +CREATE INDEX `rssFeeds_userId_idx` ON `rssFeeds` (`userId`);
\ No newline at end of file diff --git a/packages/db/drizzle/meta/0032_snapshot.json b/packages/db/drizzle/meta/0032_snapshot.json new file mode 100644 index 00000000..9d3c85bf --- /dev/null +++ b/packages/db/drizzle/meta/0032_snapshot.json @@ -0,0 +1,1404 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "f91f9a0f-8d99-4dc8-86db-58be9ede8982", + "prevId": "b79989eb-b62a-4ea7-aa4b-f3de839cb15e", + "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": {} + }, + "apiKey": { + "name": "apiKey", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyId": { + "name": "keyId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyHash": { + "name": "keyHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "apiKey_keyId_unique": { + "name": "apiKey_keyId_unique", + "columns": [ + "keyId" + ], + "isUnique": true + }, + "apiKey_name_userId_unique": { + "name": "apiKey_name_userId_unique", + "columns": [ + "name", + "userId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "apiKey_userId_user_id_fk": { + "name": "apiKey_userId_user_id_fk", + "tableFrom": "apiKey", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "assets": { + "name": "assets", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "assetType": { + "name": "assetType", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "contentType": { + "name": "contentType", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fileName": { + "name": "fileName", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bookmarkId": { + "name": "bookmarkId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "assets_bookmarkId_idx": { + "name": "assets_bookmarkId_idx", + "columns": [ + "bookmarkId" + ], + "isUnique": false + }, + "assets_assetType_idx": { + "name": "assets_assetType_idx", + "columns": [ + "assetType" + ], + "isUnique": false + }, + "assets_userId_idx": { + "name": "assets_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "assets_bookmarkId_bookmarks_id_fk": { + "name": "assets_bookmarkId_bookmarks_id_fk", + "tableFrom": "assets", + "tableTo": "bookmarks", + "columnsFrom": [ + "bookmarkId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "assets_userId_user_id_fk": { + "name": "assets_userId_user_id_fk", + "tableFrom": "assets", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "bookmarkAssets": { + "name": "bookmarkAssets", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "assetType": { + "name": "assetType", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "assetId": { + "name": "assetId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fileName": { + "name": "fileName", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sourceUrl": { + "name": "sourceUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "bookmarkAssets_id_bookmarks_id_fk": { + "name": "bookmarkAssets_id_bookmarks_id_fk", + "tableFrom": "bookmarkAssets", + "tableTo": "bookmarks", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "bookmarkLinks": { + "name": "bookmarkLinks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "imageUrl": { + "name": "imageUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "favicon": { + "name": "favicon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "htmlContent": { + "name": "htmlContent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "crawledAt": { + "name": "crawledAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "crawlStatus": { + "name": "crawlStatus", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'pending'" + } + }, + "indexes": { + "bookmarkLinks_url_idx": { + "name": "bookmarkLinks_url_idx", + "columns": [ + "url" + ], + "isUnique": false + } + }, + "foreignKeys": { + "bookmarkLinks_id_bookmarks_id_fk": { + "name": "bookmarkLinks_id_bookmarks_id_fk", + "tableFrom": "bookmarkLinks", + "tableTo": "bookmarks", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "bookmarkLists": { + "name": "bookmarkLists", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parentId": { + "name": "parentId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "bookmarkLists_userId_idx": { + "name": "bookmarkLists_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "bookmarkLists_userId_user_id_fk": { + "name": "bookmarkLists_userId_user_id_fk", + "tableFrom": "bookmarkLists", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bookmarkLists_parentId_bookmarkLists_id_fk": { + "name": "bookmarkLists_parentId_bookmarkLists_id_fk", + "tableFrom": "bookmarkLists", + "tableTo": "bookmarkLists", + "columnsFrom": [ + "parentId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "bookmarkTags": { + "name": "bookmarkTags", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "bookmarkTags_name_idx": { + "name": "bookmarkTags_name_idx", + "columns": [ + "name" + ], + "isUnique": false + }, + "bookmarkTags_userId_idx": { + "name": "bookmarkTags_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + }, + "bookmarkTags_userId_name_unique": { + "name": "bookmarkTags_userId_name_unique", + "columns": [ + "userId", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": { + "bookmarkTags_userId_user_id_fk": { + "name": "bookmarkTags_userId_user_id_fk", + "tableFrom": "bookmarkTags", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "bookmarkTexts": { + "name": "bookmarkTexts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sourceUrl": { + "name": "sourceUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "bookmarkTexts_id_bookmarks_id_fk": { + "name": "bookmarkTexts_id_bookmarks_id_fk", + "tableFrom": "bookmarkTexts", + "tableTo": "bookmarks", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "bookmarks": { + "name": "bookmarks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived": { + "name": "archived", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "favourited": { + "name": "favourited", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "taggingStatus": { + "name": "taggingStatus", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'pending'" + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "bookmarks_userId_idx": { + "name": "bookmarks_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + }, + "bookmarks_archived_idx": { + "name": "bookmarks_archived_idx", + "columns": [ + "archived" + ], + "isUnique": false + }, + "bookmarks_favourited_idx": { + "name": "bookmarks_favourited_idx", + "columns": [ + "favourited" + ], + "isUnique": false + }, + "bookmarks_createdAt_idx": { + "name": "bookmarks_createdAt_idx", + "columns": [ + "createdAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "bookmarks_userId_user_id_fk": { + "name": "bookmarks_userId_user_id_fk", + "tableFrom": "bookmarks", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "bookmarksInLists": { + "name": "bookmarksInLists", + "columns": { + "bookmarkId": { + "name": "bookmarkId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "listId": { + "name": "listId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "addedAt": { + "name": "addedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "bookmarksInLists_bookmarkId_idx": { + "name": "bookmarksInLists_bookmarkId_idx", + "columns": [ + "bookmarkId" + ], + "isUnique": false + }, + "bookmarksInLists_listId_idx": { + "name": "bookmarksInLists_listId_idx", + "columns": [ + "listId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "bookmarksInLists_bookmarkId_bookmarks_id_fk": { + "name": "bookmarksInLists_bookmarkId_bookmarks_id_fk", + "tableFrom": "bookmarksInLists", + "tableTo": "bookmarks", + "columnsFrom": [ + "bookmarkId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bookmarksInLists_listId_bookmarkLists_id_fk": { + "name": "bookmarksInLists_listId_bookmarkLists_id_fk", + "tableFrom": "bookmarksInLists", + "tableTo": "bookmarkLists", + "columnsFrom": [ + "listId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "bookmarksInLists_bookmarkId_listId_pk": { + "columns": [ + "bookmarkId", + "listId" + ], + "name": "bookmarksInLists_bookmarkId_listId_pk" + } + }, + "uniqueConstraints": {} + }, + "config": { + "name": "config", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "customPrompts": { + "name": "customPrompts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attachedBy": { + "name": "attachedBy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "customPrompts_userId_idx": { + "name": "customPrompts_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "customPrompts_userId_user_id_fk": { + "name": "customPrompts_userId_user_id_fk", + "tableFrom": "customPrompts", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "rssFeedImports": { + "name": "rssFeedImports", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entryId": { + "name": "entryId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rssFeedId": { + "name": "rssFeedId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bookmarkId": { + "name": "bookmarkId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "rssFeedImports_feedIdIdx_idx": { + "name": "rssFeedImports_feedIdIdx_idx", + "columns": [ + "rssFeedId" + ], + "isUnique": false + }, + "rssFeedImports_entryIdIdx_idx": { + "name": "rssFeedImports_entryIdIdx_idx", + "columns": [ + "entryId" + ], + "isUnique": false + }, + "rssFeedImports_rssFeedId_entryId_unique": { + "name": "rssFeedImports_rssFeedId_entryId_unique", + "columns": [ + "rssFeedId", + "entryId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "rssFeedImports_rssFeedId_rssFeeds_id_fk": { + "name": "rssFeedImports_rssFeedId_rssFeeds_id_fk", + "tableFrom": "rssFeedImports", + "tableTo": "rssFeeds", + "columnsFrom": [ + "rssFeedId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "rssFeedImports_bookmarkId_bookmarks_id_fk": { + "name": "rssFeedImports_bookmarkId_bookmarks_id_fk", + "tableFrom": "rssFeedImports", + "tableTo": "bookmarks", + "columnsFrom": [ + "bookmarkId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "rssFeeds": { + "name": "rssFeeds", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lastFetchedAt": { + "name": "lastFetchedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastFetchedStatus": { + "name": "lastFetchedStatus", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'pending'" + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "rssFeeds_userId_idx": { + "name": "rssFeeds_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "rssFeeds_userId_user_id_fk": { + "name": "rssFeeds_userId_user_id_fk", + "tableFrom": "rssFeeds", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "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": {} + }, + "tagsOnBookmarks": { + "name": "tagsOnBookmarks", + "columns": { + "bookmarkId": { + "name": "bookmarkId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tagId": { + "name": "tagId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attachedAt": { + "name": "attachedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "attachedBy": { + "name": "attachedBy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tagsOnBookmarks_tagId_idx": { + "name": "tagsOnBookmarks_tagId_idx", + "columns": [ + "bookmarkId" + ], + "isUnique": false + }, + "tagsOnBookmarks_bookmarkId_idx": { + "name": "tagsOnBookmarks_bookmarkId_idx", + "columns": [ + "bookmarkId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "tagsOnBookmarks_bookmarkId_bookmarks_id_fk": { + "name": "tagsOnBookmarks_bookmarkId_bookmarks_id_fk", + "tableFrom": "tagsOnBookmarks", + "tableTo": "bookmarks", + "columnsFrom": [ + "bookmarkId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tagsOnBookmarks_tagId_bookmarkTags_id_fk": { + "name": "tagsOnBookmarks_tagId_bookmarkTags_id_fk", + "tableFrom": "tagsOnBookmarks", + "tableTo": "bookmarkTags", + "columnsFrom": [ + "tagId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tagsOnBookmarks_bookmarkId_tagId_pk": { + "columns": [ + "bookmarkId", + "tagId" + ], + "name": "tagsOnBookmarks_bookmarkId_tagId_pk" + } + }, + "uniqueConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "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 + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'user'" + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "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": {} + }, + "internal": { + "indexes": {} + } +}
\ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 5edfb39a..457c1cfa 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -225,6 +225,13 @@ "when": 1729980727614, "tag": "0031_yummy_famine", "breakpoints": true + }, + { + "idx": 32, + "version": "6", + "when": 1730653452808, + "tag": "0032_futuristic_shiva", + "breakpoints": true } ] }
\ No newline at end of file diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 10c69d9d..12255cfb 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -334,6 +334,52 @@ export const customPrompts = sqliteTable( }), ); +export const rssFeedsTable = sqliteTable( + "rssFeeds", + { + id: text("id") + .notNull() + .primaryKey() + .$defaultFn(() => createId()), + name: text("name").notNull(), + url: text("url").notNull(), + createdAt: createdAtField(), + lastFetchedAt: integer("lastFetchedAt", { mode: "timestamp" }), + lastFetchedStatus: text("lastFetchedStatus", { + enum: ["pending", "failure", "success"], + }).default("pending"), + userId: text("userId") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + }, + (bl) => ({ + userIdIdx: index("rssFeeds_userId_idx").on(bl.userId), + }), +); + +export const rssFeedImportsTable = sqliteTable( + "rssFeedImports", + { + id: text("id") + .notNull() + .primaryKey() + .$defaultFn(() => createId()), + createdAt: createdAtField(), + entryId: text("entryId").notNull(), + rssFeedId: text("rssFeedId") + .notNull() + .references(() => rssFeedsTable.id, { onDelete: "cascade" }), + bookmarkId: text("bookmarkId").references(() => bookmarks.id, { + onDelete: "set null", + }), + }, + (bl) => ({ + feedIdIdx: index("rssFeedImports_feedIdIdx_idx").on(bl.rssFeedId), + entryIdIdx: index("rssFeedImports_entryIdIdx_idx").on(bl.entryId), + feedIdEntryIdUnique: unique().on(bl.rssFeedId, bl.entryId), + }), +); + export const config = sqliteTable("config", { key: text("key").notNull().primaryKey(), value: text("value").notNull(), diff --git a/packages/shared/queues.ts b/packages/shared/queues.ts index 6189a633..7baae535 100644 --- a/packages/shared/queues.ts +++ b/packages/shared/queues.ts @@ -116,3 +116,20 @@ export async function triggerVideoWorker(bookmarkId: string, url: string) { url, }); } + +// Feed Worker +export const zFeedRequestSchema = z.object({ + feedId: z.string(), +}); +export type ZFeedRequestSchema = z.infer<typeof zFeedRequestSchema>; + +export const FeedQueue = new SqliteQueue<ZFeedRequestSchema>( + "feed_queue", + queueDB, + { + defaultJobArgs: { + // One retry is enough for the feed queue given that it's periodic + numRetries: 1, + }, + }, +); diff --git a/packages/shared/types/feeds.ts b/packages/shared/types/feeds.ts new file mode 100644 index 00000000..3771624b --- /dev/null +++ b/packages/shared/types/feeds.ts @@ -0,0 +1,27 @@ +import { z } from "zod"; + +const MAX_FEED_URL_LENGTH = 500; +const MAX_FEED_NAME_LENGTH = 100; + +export const zAppliesToEnumSchema = z.enum(["all", "text", "images"]); + +export const zFeedSchema = z.object({ + id: z.string(), + name: z.string().min(1).max(MAX_FEED_NAME_LENGTH), + url: z.string().url(), + lastFetchedStatus: z.enum(["success", "failure", "pending"]).nullable(), + lastFetchedAt: z.date().nullable(), +}); + +export type ZFeed = z.infer<typeof zFeedSchema>; + +export const zNewFeedSchema = z.object({ + name: z.string().min(1).max(MAX_FEED_NAME_LENGTH), + url: z.string().max(MAX_FEED_URL_LENGTH).url(), +}); + +export const zUpdateFeedSchema = z.object({ + feedId: z.string(), + name: z.string().min(1).max(MAX_FEED_NAME_LENGTH), + url: z.string().max(MAX_FEED_URL_LENGTH).url(), +}); diff --git a/packages/trpc/routers/_app.ts b/packages/trpc/routers/_app.ts index 01c92e6a..ea1e0ca8 100644 --- a/packages/trpc/routers/_app.ts +++ b/packages/trpc/routers/_app.ts @@ -2,6 +2,7 @@ import { router } from "../index"; import { adminAppRouter } from "./admin"; import { apiKeysAppRouter } from "./apiKeys"; import { bookmarksAppRouter } from "./bookmarks"; +import { feedsAppRouter } from "./feeds"; import { listsAppRouter } from "./lists"; import { promptsAppRouter } from "./prompts"; import { tagsAppRouter } from "./tags"; @@ -15,6 +16,7 @@ export const appRouter = router({ tags: tagsAppRouter, prompts: promptsAppRouter, admin: adminAppRouter, + feeds: feedsAppRouter, }); // export type definition of API export type AppRouter = typeof appRouter; diff --git a/packages/trpc/routers/feeds.ts b/packages/trpc/routers/feeds.ts new file mode 100644 index 00000000..a8025dfb --- /dev/null +++ b/packages/trpc/routers/feeds.ts @@ -0,0 +1,121 @@ +import { experimental_trpcMiddleware, TRPCError } from "@trpc/server"; +import { and, eq } from "drizzle-orm"; +import { z } from "zod"; + +import { rssFeedsTable } from "@hoarder/db/schema"; +import { FeedQueue } from "@hoarder/shared/queues"; +import { + zFeedSchema, + zNewFeedSchema, + zUpdateFeedSchema, +} from "@hoarder/shared/types/feeds"; + +import { authedProcedure, Context, router } from "../index"; + +export const ensureFeedOwnership = experimental_trpcMiddleware<{ + ctx: Context; + input: { feedId: string }; +}>().create(async (opts) => { + const feed = await opts.ctx.db.query.rssFeedsTable.findFirst({ + where: eq(rssFeedsTable.id, opts.input.feedId), + columns: { + userId: true, + }, + }); + if (!opts.ctx.user) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "User is not authorized", + }); + } + if (!feed) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Feed not found", + }); + } + if (feed.userId != opts.ctx.user.id) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "User is not allowed to access resource", + }); + } + + return opts.next(); +}); + +export const feedsAppRouter = router({ + create: authedProcedure + .input(zNewFeedSchema) + .output(zFeedSchema) + .mutation(async ({ input, ctx }) => { + const [feed] = await ctx.db + .insert(rssFeedsTable) + .values({ + name: input.name, + url: input.url, + userId: ctx.user.id, + }) + .returning(); + return feed; + }), + update: authedProcedure + .input(zUpdateFeedSchema) + .output(zFeedSchema) + .use(ensureFeedOwnership) + .mutation(async ({ input, ctx }) => { + const feed = await ctx.db + .update(rssFeedsTable) + .set({ + name: input.name, + url: input.url, + }) + .where( + and( + eq(rssFeedsTable.userId, ctx.user.id), + eq(rssFeedsTable.id, input.feedId), + ), + ) + .returning(); + if (feed.length == 0) { + throw new TRPCError({ code: "NOT_FOUND" }); + } + return feed[0]; + }), + list: authedProcedure + .output(z.object({ feeds: z.array(zFeedSchema) })) + .query(async ({ ctx }) => { + const feeds = await ctx.db.query.rssFeedsTable.findMany({ + where: eq(rssFeedsTable.userId, ctx.user.id), + }); + return { feeds }; + }), + delete: authedProcedure + .input( + z.object({ + feedId: z.string(), + }), + ) + .use(ensureFeedOwnership) + .mutation(async ({ input, ctx }) => { + const res = await ctx.db + .delete(rssFeedsTable) + .where( + and( + eq(rssFeedsTable.userId, ctx.user.id), + eq(rssFeedsTable.id, input.feedId), + ), + ); + if (res.changes == 0) { + throw new TRPCError({ code: "NOT_FOUND" }); + } + }), + fetchNow: authedProcedure + .input(z.object({ feedId: z.string() })) + .use(ensureFeedOwnership) + .mutation(async ({ input }) => { + await FeedQueue.enqueue({ + feedId: input.feedId, + }); + }), +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1ea23f8e..83cff91d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -689,6 +689,9 @@ importers: '@hoarder/shared': specifier: workspace:^0.1.0 version: link:../../packages/shared + '@hoarder/trpc': + specifier: workspace:^0.1.0 + version: link:../../packages/trpc '@hoarder/tsconfig': specifier: workspace:^0.1.0 version: link:../../tooling/typescript @@ -767,6 +770,9 @@ importers: puppeteer-extra-plugin-stealth: specifier: ^2.11.2 version: 2.11.2(puppeteer-extra@3.3.6(puppeteer@22.3.0(typescript@5.3.3))) + rss-parser: + specifier: ^3.13.0 + version: 3.13.0 tesseract.js: specifier: ^5.1.1 version: 5.1.1 @@ -11217,6 +11223,9 @@ packages: rrweb-cssom@0.7.1: resolution: {integrity: sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==} + rss-parser@3.13.0: + resolution: {integrity: sha512-7jWUBV5yGN3rqMMj7CZufl/291QAhvrrGpDNE4k/02ZchL0npisiYYqULF71jCEKoIiHvK/Q2e6IkDwPziT7+w==} + rtl-detect@1.1.2: resolution: {integrity: sha512-PGMBq03+TTG/p/cRB7HCLKJ1MgDIi07+QU1faSjiYRfmY5UsAttV9Hs08jDAHVwcOwmVLcSJkpwyfXszVjWfIQ==} @@ -12886,6 +12895,10 @@ packages: resolution: {integrity: sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==} engines: {node: '>=4.0.0'} + xml2js@0.5.0: + resolution: {integrity: sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==} + engines: {node: '>=4.0.0'} + xml2js@0.6.0: resolution: {integrity: sha512-eLTh0kA8uHceqesPqSE+VvO1CDDJWMwlQfB6LuN6T8w6MaDJ8Txm8P7s5cHD0miF0V+GGTZrDQfxPZQVsur33w==} engines: {node: '>=4.0.0'} @@ -28422,6 +28435,12 @@ snapshots: rrweb-cssom@0.7.1: dev: false + rss-parser@3.13.0: + dependencies: + entities: 2.0.3 + xml2js: 0.5.0 + dev: false + rtl-detect@1.1.2: dev: false @@ -30648,6 +30667,12 @@ snapshots: xmlbuilder: 11.0.1 dev: false + xml2js@0.5.0: + dependencies: + sax: 1.3.0 + xmlbuilder: 11.0.1 + dev: false + xml2js@0.6.0: dependencies: sax: 1.3.0 |
