From ce87e56053857630469d2cd0362374166e012dce Mon Sep 17 00:00:00 2001 From: EFLKumo <96906415+EFLKumo@users.noreply.github.com> Date: Sun, 4 Oct 2026 12:30:08 +0800 Subject: [PATCH] auth,feeds,ai,ui,deploy: build Stellar Use Effect services for requests, AI tasks, and the RSS worker. Add admin accounts, feed sharing, and stored reading profiles. Add Biome, Effect tools, and Podman deployment with Caddy. --- .containerignore | 21 + .env.example | 26 + .gitignore | 6 + .prettierignore | 10 - .prettierrc | 11 - .vscode/extensions.json | 7 + .vscode/settings.json | 12 + Caddyfile | 9 + Containerfile | 29 + README.md | 36 +- apps/web/drizzle.config.ts | 6 + apps/web/drizzle/0000_stellar.sql | 164 + ...1_profile-learning-and-recommendations.sql | 3 + apps/web/drizzle/meta/0000_snapshot.json | 1127 ++++ apps/web/drizzle/meta/0001_snapshot.json | 1134 ++++ apps/web/drizzle/meta/_journal.json | 20 + apps/web/eslint.config.js | 21 - apps/web/package.json | 40 +- apps/web/public/stellar.svg | 1 + apps/web/src/components/chat.tsx | 566 ++ apps/web/src/components/feeds.tsx | 879 ++++ apps/web/src/components/layout/app-shell.tsx | 338 ++ apps/web/src/components/markdown.tsx | 22 + apps/web/src/components/model-picker.tsx | 35 + apps/web/src/components/settings.tsx | 466 ++ apps/web/src/lib/auth-client.ts | 4 + apps/web/src/lib/client.ts | 164 + apps/web/src/routeTree.gen.ts | 134 +- apps/web/src/router.tsx | 1 + apps/web/src/routes/__root.tsx | 56 +- apps/web/src/routes/api/app.ts | 5 + apps/web/src/routes/api/auth/$.ts | 10 + apps/web/src/routes/api/chat.ts | 5 + apps/web/src/routes/api/health.ts | 5 + apps/web/src/routes/index.tsx | 135 +- apps/web/src/routes/login.tsx | 123 + apps/web/src/server/ai.test.ts | 491 ++ apps/web/src/server/ai.ts | 679 +++ apps/web/src/server/auth.ts | 90 + apps/web/src/server/commands.ts | 256 + apps/web/src/server/config.ts | 81 + apps/web/src/server/content.test.ts | 152 + apps/web/src/server/content.ts | 273 + apps/web/src/server/database.ts | 41 + apps/web/src/server/domain.ts | 540 ++ apps/web/src/server/errors.ts | 7 + apps/web/src/server/http.ts | 175 + apps/web/src/server/nitro.ts | 5 + apps/web/src/server/rss.test.ts | 176 + apps/web/src/server/rss.ts | 296 ++ apps/web/src/server/runtime.ts | 13 + apps/web/src/server/schema.ts | 224 + apps/web/src/server/setup.ts | 72 + apps/web/src/server/test-support.ts | 149 + apps/web/src/server/worker.ts | 18 + apps/web/src/shared/contracts.ts | 108 + apps/web/tests/browser.spec.ts | 233 + apps/web/tests/integration.test.ts | 392 ++ apps/web/tsconfig.json | 15 +- apps/web/vite.config.ts | 17 +- biome.json | 62 + compose.yaml | 91 + docs/architecture.md | 82 + docs/configuration.md | 81 + docs/deployment.md | 178 + docs/development.md | 118 + package.json | 20 +- packages/ui/eslint.config.js | 21 - packages/ui/eslint.config.ts | 3 - packages/ui/package.json | 9 +- packages/ui/src/components/accordion.tsx | 24 +- packages/ui/src/components/alert-dialog.tsx | 19 +- packages/ui/src/components/alert.tsx | 10 +- packages/ui/src/components/attachment.tsx | 29 +- packages/ui/src/components/avatar.tsx | 18 +- packages/ui/src/components/badge.tsx | 4 +- packages/ui/src/components/breadcrumb.tsx | 25 +- packages/ui/src/components/bubble.tsx | 12 +- packages/ui/src/components/button-group.tsx | 11 +- packages/ui/src/components/button.tsx | 2 +- packages/ui/src/components/calendar.tsx | 78 +- packages/ui/src/components/card.tsx | 20 +- packages/ui/src/components/carousel.tsx | 37 +- packages/ui/src/components/chart.tsx | 28 +- packages/ui/src/components/checkbox.tsx | 6 +- packages/ui/src/components/collapsible.tsx | 2 +- packages/ui/src/components/combobox.tsx | 71 +- packages/ui/src/components/command.tsx | 43 +- packages/ui/src/components/context-menu.tsx | 43 +- packages/ui/src/components/dialog.tsx | 18 +- packages/ui/src/components/drawer.tsx | 30 +- packages/ui/src/components/dropdown-menu.tsx | 44 +- packages/ui/src/components/empty.tsx | 16 +- packages/ui/src/components/field.tsx | 33 +- packages/ui/src/components/hover-card.tsx | 4 +- packages/ui/src/components/input-group.tsx | 23 +- packages/ui/src/components/input-otp.tsx | 14 +- packages/ui/src/components/input.tsx | 11 +- packages/ui/src/components/item.tsx | 33 +- packages/ui/src/components/kbd.tsx | 2 +- packages/ui/src/components/label.tsx | 4 +- packages/ui/src/components/marker.tsx | 12 +- packages/ui/src/components/menubar.tsx | 50 +- .../ui/src/components/message-scroller.tsx | 24 +- packages/ui/src/components/message.tsx | 14 +- packages/ui/src/components/native-select.tsx | 16 +- .../ui/src/components/navigation-menu.tsx | 27 +- packages/ui/src/components/pagination.tsx | 29 +- packages/ui/src/components/popover.tsx | 4 +- packages/ui/src/components/progress.tsx | 6 +- packages/ui/src/components/questionnaire.tsx | 41 +- packages/ui/src/components/radio-group.tsx | 2 +- packages/ui/src/components/resizable.tsx | 4 +- packages/ui/src/components/scroll-area.tsx | 3 +- packages/ui/src/components/select.tsx | 36 +- packages/ui/src/components/separator.tsx | 2 +- packages/ui/src/components/sheet.tsx | 22 +- packages/ui/src/components/sidebar.tsx | 63 +- packages/ui/src/components/spinner.tsx | 20 +- packages/ui/src/components/switch.tsx | 2 +- packages/ui/src/components/table.tsx | 16 +- packages/ui/src/components/tabs.tsx | 8 +- packages/ui/src/components/textarea.tsx | 4 +- packages/ui/src/components/toast.tsx | 53 +- packages/ui/src/components/toggle-group.tsx | 11 +- packages/ui/src/components/toggle.tsx | 2 +- packages/ui/src/components/tooltip.tsx | 4 +- packages/ui/src/hooks/use-mobile.ts | 19 + packages/ui/src/styles/globals.css | 212 +- packages/ui/tsconfig.json | 3 +- packages/ui/tsconfig.lint.json | 14 - playwright.config.ts | 17 + pnpm-lock.yaml | 4634 +++++++++++------ scripts/deploy.sh | 180 + scripts/entrypoint.sh | 5 + tsconfig.json | 8 +- turbo.json | 29 +- 137 files changed, 14652 insertions(+), 2457 deletions(-) create mode 100644 .containerignore create mode 100644 .env.example delete mode 100644 .prettierignore delete mode 100644 .prettierrc create mode 100644 .vscode/extensions.json create mode 100644 .vscode/settings.json create mode 100644 Caddyfile create mode 100644 Containerfile create mode 100644 apps/web/drizzle.config.ts create mode 100644 apps/web/drizzle/0000_stellar.sql create mode 100644 apps/web/drizzle/0001_profile-learning-and-recommendations.sql create mode 100644 apps/web/drizzle/meta/0000_snapshot.json create mode 100644 apps/web/drizzle/meta/0001_snapshot.json create mode 100644 apps/web/drizzle/meta/_journal.json delete mode 100644 apps/web/eslint.config.js create mode 100644 apps/web/public/stellar.svg create mode 100644 apps/web/src/components/chat.tsx create mode 100644 apps/web/src/components/feeds.tsx create mode 100644 apps/web/src/components/layout/app-shell.tsx create mode 100644 apps/web/src/components/markdown.tsx create mode 100644 apps/web/src/components/model-picker.tsx create mode 100644 apps/web/src/components/settings.tsx create mode 100644 apps/web/src/lib/auth-client.ts create mode 100644 apps/web/src/lib/client.ts create mode 100644 apps/web/src/routes/api/app.ts create mode 100644 apps/web/src/routes/api/auth/$.ts create mode 100644 apps/web/src/routes/api/chat.ts create mode 100644 apps/web/src/routes/api/health.ts create mode 100644 apps/web/src/routes/login.tsx create mode 100644 apps/web/src/server/ai.test.ts create mode 100644 apps/web/src/server/ai.ts create mode 100644 apps/web/src/server/auth.ts create mode 100644 apps/web/src/server/commands.ts create mode 100644 apps/web/src/server/config.ts create mode 100644 apps/web/src/server/content.test.ts create mode 100644 apps/web/src/server/content.ts create mode 100644 apps/web/src/server/database.ts create mode 100644 apps/web/src/server/domain.ts create mode 100644 apps/web/src/server/errors.ts create mode 100644 apps/web/src/server/http.ts create mode 100644 apps/web/src/server/nitro.ts create mode 100644 apps/web/src/server/rss.test.ts create mode 100644 apps/web/src/server/rss.ts create mode 100644 apps/web/src/server/runtime.ts create mode 100644 apps/web/src/server/schema.ts create mode 100644 apps/web/src/server/setup.ts create mode 100644 apps/web/src/server/test-support.ts create mode 100644 apps/web/src/server/worker.ts create mode 100644 apps/web/src/shared/contracts.ts create mode 100644 apps/web/tests/browser.spec.ts create mode 100644 apps/web/tests/integration.test.ts create mode 100644 biome.json create mode 100644 compose.yaml create mode 100644 docs/architecture.md create mode 100644 docs/configuration.md create mode 100644 docs/deployment.md create mode 100644 docs/development.md delete mode 100644 packages/ui/eslint.config.js delete mode 100644 packages/ui/eslint.config.ts create mode 100644 packages/ui/src/hooks/use-mobile.ts delete mode 100644 packages/ui/tsconfig.lint.json create mode 100644 playwright.config.ts create mode 100755 scripts/deploy.sh create mode 100755 scripts/entrypoint.sh diff --git a/.containerignore b/.containerignore new file mode 100644 index 0000000..f0ba2e8 --- /dev/null +++ b/.containerignore @@ -0,0 +1,21 @@ +.git +.jj +.agents +.codex +.aws +.env +.env.* +!.env.example +**/node_modules +**/.output +**/.nitro +**/.tanstack +**/.turbo +**/dist +**/dist-ssr +**/*.tsbuildinfo +coverage +test-results +playwright-report +backups +*.log diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a488180 --- /dev/null +++ b/.env.example @@ -0,0 +1,26 @@ +# Set the first administrator account. +STELLAR_ADMIN_EMAIL=admin@example.com +STELLAR_ADMIN_USERNAME=admin +# The deploy script generates a password when this field is empty. +STELLAR_ADMIN_PASSWORD= + +# Set the site address. +DOMAIN=localhost +APP_ORIGIN=http://localhost:8080 +HTTP_PORT=8080 +HTTPS_PORT=8443 + +# The deploy script generates these secrets when the fields are empty. +# Use hexadecimal characters for POSTGRES_PASSWORD. +POSTGRES_PASSWORD= +BETTER_AUTH_SECRET= + +# Set a key for each provider in AI_MODELS. +GOOGLE_GENERATIVE_AI_API_KEY= +OPENAI_API_KEY= +ANTHROPIC_API_KEY= +AI_MODELS='[{"id":"gemini-flash","name":"Gemini Flash","provider":"google","model":"gemini-flash-latest"}]' + +# Set the RSS worker limits. +RSS_POLL_SECONDS=900 +RSS_CONCURRENCY=2 diff --git a/.gitignore b/.gitignore index b734661..24bae8d 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,9 @@ todos.json # typescript *.tsbuildinfo + +!.env.example + +# Browser checks +test-results/ +playwright-report/ diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index 6b00aa1..0000000 --- a/.prettierignore +++ /dev/null @@ -1,10 +0,0 @@ -dist/ -node_modules/ -.turbo/ -.output/ -.nitro/ -.tanstack/ -.vinxi/ -coverage/ -pnpm-lock.yaml -.pnpm-store/ \ No newline at end of file diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index 40ce067..0000000 --- a/.prettierrc +++ /dev/null @@ -1,11 +0,0 @@ -{ - "endOfLine": "lf", - "semi": false, - "singleQuote": false, - "tabWidth": 2, - "trailingComma": "es5", - "printWidth": 80, - "plugins": ["prettier-plugin-tailwindcss"], - "tailwindStylesheet": "packages/ui/src/styles/globals.css", - "tailwindFunctions": ["cn", "cva"] -} diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..245db7a --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,7 @@ +{ + "recommendations": [ + "biomejs.biome", + "typescriptteam.native-preview", + "effectful-tech.effect-vscode" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..9ba76e6 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,12 @@ +{ + "editor.defaultFormatter": "biomejs.biome", + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.fixAll.biome": "explicit", + "source.organizeImports.biome": "explicit" + }, + "js/ts.tsdk.additionalLocations": ["./node_modules/typescript/bin"], + "js/ts.tsdk.promptToUseWorkspaceVersion": true, + "js/ts.tsdk.path": "./node_modules/typescript/bin", + "js/ts.experimental.useTsgo": true +} diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..33d9b19 --- /dev/null +++ b/Caddyfile @@ -0,0 +1,9 @@ +http://localhost, http://127.0.0.1 { + encode zstd gzip + reverse_proxy app:3000 +} + +{$DOMAIN:localhost} { + encode zstd gzip + reverse_proxy app:3000 +} diff --git a/Containerfile b/Containerfile new file mode 100644 index 0000000..b3d5d08 --- /dev/null +++ b/Containerfile @@ -0,0 +1,29 @@ +FROM docker.io/library/node:26-bookworm-slim AS build + +WORKDIR /app +RUN npm install --global pnpm@11.25.0 + +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY apps/web/package.json apps/web/package.json +COPY packages/ui/package.json packages/ui/package.json +RUN pnpm install --frozen-lockfile + +COPY . . +RUN pnpm build + +FROM docker.io/library/node:26-bookworm-slim AS runtime + +ENV NODE_ENV=production HOST=0.0.0.0 PORT=3000 +WORKDIR /app/apps/web + +COPY --from=build --chown=node:node /app/node_modules /app/node_modules +COPY --from=build --chown=node:node /app/package.json /app/package.json +COPY --from=build --chown=node:node /app/tsconfig.json /app/tsconfig.json +COPY --from=build --chown=node:node /app/apps/web /app/apps/web +COPY --from=build --chown=node:node /app/packages/ui /app/packages/ui +COPY --chown=node:node --chmod=0755 scripts/entrypoint.sh /app/scripts/entrypoint.sh + +USER node +EXPOSE 3000 +ENTRYPOINT ["/app/scripts/entrypoint.sh"] +CMD ["node", ".output/server/index.mjs"] diff --git a/README.md b/README.md index 4042488..d6afcb7 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,31 @@ -# shadcn/ui monorepo template +# Stellar -This is a TanStack Start monorepo template with shadcn/ui. +Stellar stores links and Markdown text as feeds. +Users can use AI models to make summaries and translations. +The RSS worker selects articles with each user's stored preferences. +Users can forward feeds and add comments to public feeds. +Only an administrator can create user accounts. -## Adding components +## Deploy -To add components to your app, run the following command at the root of your `web` app: +Install Podman, `podman-compose`, Python 3, `python-dotenv`, and curl. +Copy the configuration file. -```bash -pnpm dlx shadcn@latest add button -c apps/web +```sh +cp .env.example .env ``` -This will place the ui components in the `packages/ui/src/components` directory. +Set the AI provider keys in `.env`. +Then start the deployment. -## Using components - -To use the components in your app, import them from the `ui` package. - -```tsx -import { Button } from "@workspace/ui/components/button"; +```sh +./scripts/deploy.sh ``` + +Open `http://localhost:8080`. +Read the administrator name and password from `.env`. + +See [Deployment](docs/deployment.md) for domain names, HTTPS, backup, and restart procedures. +See [Configuration](docs/configuration.md) for environment variables. +See [Architecture](docs/architecture.md) for the program structure. +See [Development](docs/development.md) for local commands. diff --git a/apps/web/drizzle.config.ts b/apps/web/drizzle.config.ts new file mode 100644 index 0000000..8bb05ab --- /dev/null +++ b/apps/web/drizzle.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from "drizzle-kit" +export default defineConfig({ + schema: "./src/server/schema.ts", + out: "./drizzle", + dialect: "postgresql", +}) diff --git a/apps/web/drizzle/0000_stellar.sql b/apps/web/drizzle/0000_stellar.sql new file mode 100644 index 0000000..1b18067 --- /dev/null +++ b/apps/web/drizzle/0000_stellar.sql @@ -0,0 +1,164 @@ +CREATE TABLE "account" ( + "id" text PRIMARY KEY NOT NULL, + "account_id" text NOT NULL, + "provider_id" text NOT NULL, + "user_id" text NOT NULL, + "access_token" text, + "refresh_token" text, + "id_token" text, + "access_token_expires_at" timestamp with time zone, + "refresh_token_expires_at" timestamp with time zone, + "scope" text, + "password" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "comments" ( + "id" text PRIMARY KEY NOT NULL, + "feed_id" text NOT NULL, + "author_id" text NOT NULL, + "content" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "feed_states" ( + "user_id" text NOT NULL, + "feed_id" text NOT NULL, + "saved" boolean DEFAULT false NOT NULL, + "interested" boolean, + "summary" text, + "translation" text, + "language" text, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "feed_states_user_id_feed_id_pk" PRIMARY KEY("user_id","feed_id") +); +--> statement-breakpoint +CREATE TABLE "feeds" ( + "id" text PRIMARY KEY NOT NULL, + "author_id" text NOT NULL, + "title" text NOT NULL, + "url" text, + "content" text NOT NULL, + "summary" text, + "kind" text NOT NULL, + "origin" text DEFAULT 'user' NOT NULL, + "is_public" boolean DEFAULT false NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "forwards" ( + "feed_id" text NOT NULL, + "from_user_id" text NOT NULL, + "to_user_id" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "forwards_feed_id_from_user_id_to_user_id_pk" PRIMARY KEY("feed_id","from_user_id","to_user_id") +); +--> statement-breakpoint +CREATE TABLE "profiles" ( + "user_id" text PRIMARY KEY NOT NULL, + "preferences" text DEFAULT '' NOT NULL, + "learned_preferences" text DEFAULT '' NOT NULL, + "version" integer DEFAULT 0 NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "rss_items" ( + "subscription_id" text NOT NULL, + "item_key" text NOT NULL, + "matched" boolean NOT NULL, + "feed_id" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "rss_items_subscription_id_item_key_pk" PRIMARY KEY("subscription_id","item_key") +); +--> statement-breakpoint +CREATE TABLE "session" ( + "id" text PRIMARY KEY NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "token" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "ip_address" text, + "user_agent" text, + "user_id" text NOT NULL, + "impersonated_by" text, + CONSTRAINT "session_token_unique" UNIQUE("token") +); +--> statement-breakpoint +CREATE TABLE "subscriptions" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "url" text NOT NULL, + "title" text NOT NULL, + "enabled" boolean DEFAULT true NOT NULL, + "last_checked_at" timestamp with time zone, + "last_error" text, + "lease_until" timestamp with time zone, + "lease_token" text, + "next_poll_at" timestamp with time zone DEFAULT now() NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "threads" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "title" text DEFAULT 'New conversation' NOT NULL, + "messages" jsonb DEFAULT '[]'::jsonb NOT NULL, + "lease_until" timestamp with time zone, + "lease_token" text, + "next_poll_at" timestamp with time zone DEFAULT now() NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "user" ( + "id" text PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "email" text NOT NULL, + "email_verified" boolean DEFAULT false NOT NULL, + "image" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "username" text, + "display_username" text, + "role" text DEFAULT 'user' NOT NULL, + "banned" boolean DEFAULT false, + "ban_reason" text, + "ban_expires" timestamp with time zone, + CONSTRAINT "user_email_unique" UNIQUE("email"), + CONSTRAINT "user_username_unique" UNIQUE("username") +); +--> statement-breakpoint +CREATE TABLE "verification" ( + "id" text PRIMARY KEY NOT NULL, + "identifier" text NOT NULL, + "value" text NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "comments" ADD CONSTRAINT "comments_feed_id_feeds_id_fk" FOREIGN KEY ("feed_id") REFERENCES "public"."feeds"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "comments" ADD CONSTRAINT "comments_author_id_user_id_fk" FOREIGN KEY ("author_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "feed_states" ADD CONSTRAINT "feed_states_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "feed_states" ADD CONSTRAINT "feed_states_feed_id_feeds_id_fk" FOREIGN KEY ("feed_id") REFERENCES "public"."feeds"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "feeds" ADD CONSTRAINT "feeds_author_id_user_id_fk" FOREIGN KEY ("author_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "forwards" ADD CONSTRAINT "forwards_feed_id_feeds_id_fk" FOREIGN KEY ("feed_id") REFERENCES "public"."feeds"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "forwards" ADD CONSTRAINT "forwards_from_user_id_user_id_fk" FOREIGN KEY ("from_user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "forwards" ADD CONSTRAINT "forwards_to_user_id_user_id_fk" FOREIGN KEY ("to_user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "profiles" ADD CONSTRAINT "profiles_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "rss_items" ADD CONSTRAINT "rss_items_subscription_id_subscriptions_id_fk" FOREIGN KEY ("subscription_id") REFERENCES "public"."subscriptions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "rss_items" ADD CONSTRAINT "rss_items_feed_id_feeds_id_fk" FOREIGN KEY ("feed_id") REFERENCES "public"."feeds"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "subscriptions" ADD CONSTRAINT "subscriptions_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "threads" ADD CONSTRAINT "threads_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "account_user_idx" ON "account" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "comments_feed_idx" ON "comments" USING btree ("feed_id");--> statement-breakpoint +CREATE UNIQUE INDEX "feeds_public_url_idx" ON "feeds" USING btree ("url") WHERE "feeds"."is_public" = true AND "feeds"."url" IS NOT NULL;--> statement-breakpoint +CREATE UNIQUE INDEX "feeds_author_url_idx" ON "feeds" USING btree ("author_id","url") WHERE "feeds"."url" IS NOT NULL;--> statement-breakpoint +CREATE INDEX "feeds_created_idx" ON "feeds" USING btree ("created_at");--> statement-breakpoint +CREATE INDEX "forwards_recipient_idx" ON "forwards" USING btree ("to_user_id");--> statement-breakpoint +CREATE INDEX "session_user_idx" ON "session" USING btree ("user_id");--> statement-breakpoint +CREATE UNIQUE INDEX "subscriptions_user_url_idx" ON "subscriptions" USING btree ("user_id","url");--> statement-breakpoint +CREATE INDEX "threads_user_idx" ON "threads" USING btree ("user_id"); \ No newline at end of file diff --git a/apps/web/drizzle/0001_profile-learning-and-recommendations.sql b/apps/web/drizzle/0001_profile-learning-and-recommendations.sql new file mode 100644 index 0000000..f6f21e6 --- /dev/null +++ b/apps/web/drizzle/0001_profile-learning-and-recommendations.sql @@ -0,0 +1,3 @@ +ALTER TABLE "feed_states" ADD COLUMN "ai_submitted" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "profiles" ADD COLUMN "learned_version" integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE "threads" DROP COLUMN "next_poll_at"; \ No newline at end of file diff --git a/apps/web/drizzle/meta/0000_snapshot.json b/apps/web/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..9d84233 --- /dev/null +++ b/apps/web/drizzle/meta/0000_snapshot.json @@ -0,0 +1,1127 @@ +{ + "id": "41a5fe61-5a5b-4af4-9d8a-c2e709c35b1f", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_user_idx": { + "name": "account_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.comments": { + "name": "comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "feed_id": { + "name": "feed_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "comments_feed_idx": { + "name": "comments_feed_idx", + "columns": [ + { + "expression": "feed_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "comments_feed_id_feeds_id_fk": { + "name": "comments_feed_id_feeds_id_fk", + "tableFrom": "comments", + "tableTo": "feeds", + "columnsFrom": ["feed_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "comments_author_id_user_id_fk": { + "name": "comments_author_id_user_id_fk", + "tableFrom": "comments", + "tableTo": "user", + "columnsFrom": ["author_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.feed_states": { + "name": "feed_states", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feed_id": { + "name": "feed_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "saved": { + "name": "saved", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "interested": { + "name": "interested", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "translation": { + "name": "translation", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "feed_states_user_id_user_id_fk": { + "name": "feed_states_user_id_user_id_fk", + "tableFrom": "feed_states", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "feed_states_feed_id_feeds_id_fk": { + "name": "feed_states_feed_id_feeds_id_fk", + "tableFrom": "feed_states", + "tableTo": "feeds", + "columnsFrom": ["feed_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "feed_states_user_id_feed_id_pk": { + "name": "feed_states_user_id_feed_id_pk", + "columns": ["user_id", "feed_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.feeds": { + "name": "feeds", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "feeds_public_url_idx": { + "name": "feeds_public_url_idx", + "columns": [ + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"feeds\".\"is_public\" = true AND \"feeds\".\"url\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "feeds_author_url_idx": { + "name": "feeds_author_url_idx", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"feeds\".\"url\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "feeds_created_idx": { + "name": "feeds_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "feeds_author_id_user_id_fk": { + "name": "feeds_author_id_user_id_fk", + "tableFrom": "feeds", + "tableTo": "user", + "columnsFrom": ["author_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.forwards": { + "name": "forwards", + "schema": "", + "columns": { + "feed_id": { + "name": "feed_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_user_id": { + "name": "from_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "to_user_id": { + "name": "to_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "forwards_recipient_idx": { + "name": "forwards_recipient_idx", + "columns": [ + { + "expression": "to_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "forwards_feed_id_feeds_id_fk": { + "name": "forwards_feed_id_feeds_id_fk", + "tableFrom": "forwards", + "tableTo": "feeds", + "columnsFrom": ["feed_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "forwards_from_user_id_user_id_fk": { + "name": "forwards_from_user_id_user_id_fk", + "tableFrom": "forwards", + "tableTo": "user", + "columnsFrom": ["from_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "forwards_to_user_id_user_id_fk": { + "name": "forwards_to_user_id_user_id_fk", + "tableFrom": "forwards", + "tableTo": "user", + "columnsFrom": ["to_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "forwards_feed_id_from_user_id_to_user_id_pk": { + "name": "forwards_feed_id_from_user_id_to_user_id_pk", + "columns": ["feed_id", "from_user_id", "to_user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.profiles": { + "name": "profiles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "preferences": { + "name": "preferences", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "learned_preferences": { + "name": "learned_preferences", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "profiles_user_id_user_id_fk": { + "name": "profiles_user_id_user_id_fk", + "tableFrom": "profiles", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rss_items": { + "name": "rss_items", + "schema": "", + "columns": { + "subscription_id": { + "name": "subscription_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_key": { + "name": "item_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "matched": { + "name": "matched", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feed_id": { + "name": "feed_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "rss_items_subscription_id_subscriptions_id_fk": { + "name": "rss_items_subscription_id_subscriptions_id_fk", + "tableFrom": "rss_items", + "tableTo": "subscriptions", + "columnsFrom": ["subscription_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "rss_items_feed_id_feeds_id_fk": { + "name": "rss_items_feed_id_feeds_id_fk", + "tableFrom": "rss_items", + "tableTo": "feeds", + "columnsFrom": ["feed_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "rss_items_subscription_id_item_key_pk": { + "name": "rss_items_subscription_id_item_key_pk", + "columns": ["subscription_id", "item_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_idx": { + "name": "session_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscriptions": { + "name": "subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_until": { + "name": "lease_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lease_token": { + "name": "lease_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_poll_at": { + "name": "next_poll_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "subscriptions_user_url_idx": { + "name": "subscriptions_user_url_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "subscriptions_user_id_user_id_fk": { + "name": "subscriptions_user_id_user_id_fk", + "tableFrom": "subscriptions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.threads": { + "name": "threads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'New conversation'" + }, + "messages": { + "name": "messages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "lease_until": { + "name": "lease_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lease_token": { + "name": "lease_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_poll_at": { + "name": "next_poll_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "threads_user_idx": { + "name": "threads_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "threads_user_id_user_id_fk": { + "name": "threads_user_id_user_id_fk", + "tableFrom": "threads", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_username": { + "name": "display_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "user_username_unique": { + "name": "user_username_unique", + "nullsNotDistinct": false, + "columns": ["username"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/web/drizzle/meta/0001_snapshot.json b/apps/web/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..038c080 --- /dev/null +++ b/apps/web/drizzle/meta/0001_snapshot.json @@ -0,0 +1,1134 @@ +{ + "id": "abc6caac-fb1c-4332-a42a-7bbe4d7411e8", + "prevId": "41a5fe61-5a5b-4af4-9d8a-c2e709c35b1f", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_user_idx": { + "name": "account_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.comments": { + "name": "comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "feed_id": { + "name": "feed_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "comments_feed_idx": { + "name": "comments_feed_idx", + "columns": [ + { + "expression": "feed_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "comments_feed_id_feeds_id_fk": { + "name": "comments_feed_id_feeds_id_fk", + "tableFrom": "comments", + "tableTo": "feeds", + "columnsFrom": ["feed_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "comments_author_id_user_id_fk": { + "name": "comments_author_id_user_id_fk", + "tableFrom": "comments", + "tableTo": "user", + "columnsFrom": ["author_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.feed_states": { + "name": "feed_states", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feed_id": { + "name": "feed_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "saved": { + "name": "saved", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "interested": { + "name": "interested", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "ai_submitted": { + "name": "ai_submitted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "translation": { + "name": "translation", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "feed_states_user_id_user_id_fk": { + "name": "feed_states_user_id_user_id_fk", + "tableFrom": "feed_states", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "feed_states_feed_id_feeds_id_fk": { + "name": "feed_states_feed_id_feeds_id_fk", + "tableFrom": "feed_states", + "tableTo": "feeds", + "columnsFrom": ["feed_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "feed_states_user_id_feed_id_pk": { + "name": "feed_states_user_id_feed_id_pk", + "columns": ["user_id", "feed_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.feeds": { + "name": "feeds", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "feeds_public_url_idx": { + "name": "feeds_public_url_idx", + "columns": [ + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"feeds\".\"is_public\" = true AND \"feeds\".\"url\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "feeds_author_url_idx": { + "name": "feeds_author_url_idx", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"feeds\".\"url\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "feeds_created_idx": { + "name": "feeds_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "feeds_author_id_user_id_fk": { + "name": "feeds_author_id_user_id_fk", + "tableFrom": "feeds", + "tableTo": "user", + "columnsFrom": ["author_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.forwards": { + "name": "forwards", + "schema": "", + "columns": { + "feed_id": { + "name": "feed_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_user_id": { + "name": "from_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "to_user_id": { + "name": "to_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "forwards_recipient_idx": { + "name": "forwards_recipient_idx", + "columns": [ + { + "expression": "to_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "forwards_feed_id_feeds_id_fk": { + "name": "forwards_feed_id_feeds_id_fk", + "tableFrom": "forwards", + "tableTo": "feeds", + "columnsFrom": ["feed_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "forwards_from_user_id_user_id_fk": { + "name": "forwards_from_user_id_user_id_fk", + "tableFrom": "forwards", + "tableTo": "user", + "columnsFrom": ["from_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "forwards_to_user_id_user_id_fk": { + "name": "forwards_to_user_id_user_id_fk", + "tableFrom": "forwards", + "tableTo": "user", + "columnsFrom": ["to_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "forwards_feed_id_from_user_id_to_user_id_pk": { + "name": "forwards_feed_id_from_user_id_to_user_id_pk", + "columns": ["feed_id", "from_user_id", "to_user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.profiles": { + "name": "profiles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "preferences": { + "name": "preferences", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "learned_preferences": { + "name": "learned_preferences", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "learned_version": { + "name": "learned_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "profiles_user_id_user_id_fk": { + "name": "profiles_user_id_user_id_fk", + "tableFrom": "profiles", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rss_items": { + "name": "rss_items", + "schema": "", + "columns": { + "subscription_id": { + "name": "subscription_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_key": { + "name": "item_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "matched": { + "name": "matched", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feed_id": { + "name": "feed_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "rss_items_subscription_id_subscriptions_id_fk": { + "name": "rss_items_subscription_id_subscriptions_id_fk", + "tableFrom": "rss_items", + "tableTo": "subscriptions", + "columnsFrom": ["subscription_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "rss_items_feed_id_feeds_id_fk": { + "name": "rss_items_feed_id_feeds_id_fk", + "tableFrom": "rss_items", + "tableTo": "feeds", + "columnsFrom": ["feed_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "rss_items_subscription_id_item_key_pk": { + "name": "rss_items_subscription_id_item_key_pk", + "columns": ["subscription_id", "item_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_idx": { + "name": "session_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscriptions": { + "name": "subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_until": { + "name": "lease_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lease_token": { + "name": "lease_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_poll_at": { + "name": "next_poll_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "subscriptions_user_url_idx": { + "name": "subscriptions_user_url_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "subscriptions_user_id_user_id_fk": { + "name": "subscriptions_user_id_user_id_fk", + "tableFrom": "subscriptions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.threads": { + "name": "threads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'New conversation'" + }, + "messages": { + "name": "messages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "lease_until": { + "name": "lease_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lease_token": { + "name": "lease_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "threads_user_idx": { + "name": "threads_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "threads_user_id_user_id_fk": { + "name": "threads_user_id_user_id_fk", + "tableFrom": "threads", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_username": { + "name": "display_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "user_username_unique": { + "name": "user_username_unique", + "nullsNotDistinct": false, + "columns": ["username"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/web/drizzle/meta/_journal.json b/apps/web/drizzle/meta/_journal.json new file mode 100644 index 0000000..1ab135d --- /dev/null +++ b/apps/web/drizzle/meta/_journal.json @@ -0,0 +1,20 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1791087264639, + "tag": "0000_stellar", + "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1791088233093, + "tag": "0001_profile-learning-and-recommendations", + "breakpoints": true + } + ] +} diff --git a/apps/web/eslint.config.js b/apps/web/eslint.config.js deleted file mode 100644 index d49b5cb..0000000 --- a/apps/web/eslint.config.js +++ /dev/null @@ -1,21 +0,0 @@ -// @ts-check - -import { tanstackConfig } from "@tanstack/eslint-config" - -export default [ - ...tanstackConfig, - { - rules: { - "import/no-cycle": "off", - "import/order": "off", - "import/consistent-type-specifier-style": "off", - "sort-imports": "off", - "@typescript-eslint/array-type": "off", - "@typescript-eslint/require-await": "off", - "pnpm/json-enforce-catalog": "off", - }, - }, - { - ignores: ["eslint.config.js", ".prettierrc"], - }, -] diff --git a/apps/web/package.json b/apps/web/package.json index 5dee7b1..06055b7 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -8,33 +8,53 @@ "generate-routes": "tsr generate", "build": "vite build", "preview": "vite preview", - "lint": "eslint", - "format": "prettier --write \"**/*.{ts,tsx}\"", - "typecheck": "tsc --noEmit" + "lint": "biome check .", + "format": "biome check --write .", + "typecheck": "tsc --noEmit", + "test": "node --import tsx --test --test-isolation=none src/server/*.test.ts tests/*.test.ts", + "start": "node .output/server/index.mjs", + "setup": "node --import tsx src/server/setup.ts", + "worker": "node --import tsx src/server/worker.ts", + "db:generate": "drizzle-kit generate" }, "dependencies": { + "@ai-sdk/anthropic": "^4.0.71", + "@ai-sdk/google": "^4.0.87", + "@ai-sdk/openai": "^4.0.83", + "@ai-sdk/react": "^4.0.130", + "@effect/platform-node": "^4.0.0", "@hugeicons/core-free-icons": "^4.3.5", "@hugeicons/react": "^1.1.10", + "@mozilla/readability": "^0.6.0", "@tailwindcss/vite": "^4", - "@tanstack/react-devtools": "latest", "@tanstack/react-router": "latest", - "@tanstack/react-router-devtools": "latest", "@tanstack/react-start": "latest", "@workspace/ui": "workspace:*", + "ai": "^7.0.127", + "better-auth": "^1.7.7", + "drizzle-orm": "^0.45.3", + "effect": "4.0.0", + "ipaddr.js": "^2.5.0", + "linkedom": "^0.18.13", + "postgres": "^3.4.9", "react": "^19.2.8", "react-dom": "^19.2.8", - "tailwindcss": "^4" + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1", + "rss-parser": "^3.13.0", + "tailwindcss": "^4", + "undici": "^8.11.2", + "zod": "^4.6.5" }, "devDependencies": { - "@tanstack/devtools-vite": "latest", - "@tanstack/eslint-config": "latest", "@tanstack/router-cli": "^1.167.39", "@types/node": "^22", "@types/react": "^19", "@types/react-dom": "^19", "@vitejs/plugin-react": "^6", - "eslint": "^10", - "typescript": "^6.0.2", + "drizzle-kit": "^0.31.11", + "nitro": "3.0.260903-beta", + "tsx": "^4.23.15", "vite": "^8" } } diff --git a/apps/web/public/stellar.svg b/apps/web/public/stellar.svg new file mode 100644 index 0000000..5cd38c7 --- /dev/null +++ b/apps/web/public/stellar.svg @@ -0,0 +1 @@ +Stellar diff --git a/apps/web/src/components/chat.tsx b/apps/web/src/components/chat.tsx new file mode 100644 index 0000000..9551511 --- /dev/null +++ b/apps/web/src/components/chat.tsx @@ -0,0 +1,566 @@ +import { useChat } from "@ai-sdk/react" +import { + Add01Icon, + AiChat01Icon, + ArrowUp02Icon, + Delete02Icon, + StopIcon, +} from "@hugeicons/core-free-icons" +import { HugeiconsIcon } from "@hugeicons/react" +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@workspace/ui/components/accordion" +import { + Alert, + AlertDescription, + AlertTitle, +} from "@workspace/ui/components/alert" +import { Bubble, BubbleContent } from "@workspace/ui/components/bubble" +import { Button } from "@workspace/ui/components/button" +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@workspace/ui/components/empty" +import { Field, FieldGroup, FieldLabel } from "@workspace/ui/components/field" +import { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupTextarea, +} from "@workspace/ui/components/input-group" +import { Marker, MarkerContent } from "@workspace/ui/components/marker" +import { + Message, + MessageContent, + MessageHeader, +} from "@workspace/ui/components/message" +import { + MessageScroller, + MessageScrollerButton, + MessageScrollerContent, + MessageScrollerItem, + MessageScrollerProvider, + MessageScrollerViewport, +} from "@workspace/ui/components/message-scroller" +import { + NativeSelect, + NativeSelectOption, +} from "@workspace/ui/components/native-select" +import { Spinner } from "@workspace/ui/components/spinner" +import { DefaultChatTransport, type UIMessage } from "ai" +import { Effect } from "effect" +import { useEffect, useMemo, useState } from "react" +import { Markdown } from "@/components/markdown" +import { ModelPicker } from "@/components/model-picker" +import { api, ClientError, useOperation } from "@/lib/client" +import type { AppData, ModelOption } from "@/shared/contracts" + +const toolLabels: Record = { + listFeeds: "Find articles", + readFeed: "Read an article", + submitFeed: "Add a feed", + subscribe: "Add an RSS source", + listSubscriptions: "Read your sources", + setSubscription: "Update a source", + unsubscribe: "Remove a source", + updatePreferences: "Update your preferences", + listPeople: "Find a person", + forwardFeed: "Forward a feed", + comment: "Post a comment", + saveFeed: "Save an article", + feedback: "Learn from your feedback", + summarize: "Summarize an article", + translate: "Translate an article", +} + +export function ChatPanel({ + data, + onChanged, + onOpenFeed, +}: { + data: AppData + onChanged: () => void + onOpenFeed: (id: string) => void +}) { + const [threadId, setThreadId] = useState(data.threads[0]?.id ?? "") + const [thread, setThread] = useState<{ + id: string + title: string + messages: UIMessage[] + } | null>(null) + const [error, setError] = useState("") + const { run, busy } = useOperation() + useEffect(() => { + setThread(null) + setError("") + if (!threadId) return + return run( + api({ action: "getThread", id: threadId }), + setThread, + (failure) => setError(failure.message), + ) + }, [threadId, run]) + const create = () => + run(api({ action: "createThread" }), (result) => { + setThreadId(result.id) + onChanged() + }) + return ( +
+
+ setThreadId(event.target.value)} + > + {!threadId && ( + + Choose a conversation + + )} + {data.threads.map((item) => ( + + {item.title} + + ))} + {threadId && !data.threads.some((item) => item.id === threadId) && ( + + New conversation + + )} + +
+ {threadId && ( + + )} + +
+
+ {error ? ( +
+ + Cannot open conversation + {error} + +
+ ) : thread ? ( + + ) : threadId ? ( +
+ +
+ ) : ( + + + + + Follow a thought + + Ask about your articles, discover patterns, or shape your reading + preferences. Stellar can help manage your feeds and sources. + + + + + {data.models.length === 0 && ( +

+ Ask your administrator to configure an AI model. +

+ )} +
+
+ )} +
+ ) +} + +function ChatConversation({ + thread, + models, + onChanged, + onOpenFeed, +}: { + thread: { id: string; messages: UIMessage[] } + models: ModelOption[] + onChanged: () => void + onOpenFeed: (id: string) => void +}) { + const [modelId, setModelId] = useState(models[0]?.id ?? "") + const [input, setInput] = useState("") + const { run } = useOperation() + const transport = useMemo( + () => + new DefaultChatTransport({ + api: "/api/chat", + credentials: "same-origin", + }), + [], + ) + const { messages, sendMessage, status, stop, error, regenerate } = useChat({ + id: thread.id, + messages: thread.messages, + transport, + onFinish: onChanged, + }) + const busy = status === "submitted" || status === "streaming" + useEffect( + () => () => { + void stop() + }, + [stop], + ) + const send = (text: string) => { + if (!text.trim() || busy || !modelId) return + setInput("") + run( + Effect.tryPromise({ + try: () => + sendMessage({ text }, { body: { threadId: thread.id, modelId } }), + catch: (failure) => + new ClientError({ + message: + failure instanceof Error + ? failure.message + : "Cannot send message.", + }), + }), + ) + } + return ( +
+ {messages.length === 0 ? ( + + + + + What caught your curiosity? + + Your feeds, sources, and reading profile are part of this + conversation. + + + + {[ + "Find connections between my recent articles.", + "Help me refine my reading preferences.", + "What should I read next?", + ].map((prompt) => ( + + ))} + + + ) : ( + + + + + {messages.map((message) => ( + + + + ))} + {status === "submitted" && ( + + + + Thinking… + + + + )} + + + + + + )} +
+ {error && ( + + Cannot complete the response + + {error.message} + + + + )} +
{ + event.preventDefault() + send(input.trim()) + }} + > + + + + Message Stellar + + + setInput(event.target.value)} + rows={2} + maxLength={20000} + disabled={busy || !modelId} + onKeyDown={(event) => { + if ( + event.key === "Enter" && + !event.shiftKey && + !event.nativeEvent.isComposing + ) { + event.preventDefault() + send(input.trim()) + } + }} + /> + + +
+ {busy ? ( + { + void stop() + }} + > + + ) : ( + + + )} +
+
+
+
+
+
+

+ Check important facts. Press Enter to send, Shift + Enter for a new + line. +

+
+
+ ) +} + +function ChatMessage({ + message, + onOpenFeed, +}: { + message: UIMessage + onOpenFeed: (id: string) => void +}) { + const user = message.role === "user" + return ( + + + {user ? "You" : "Stellar"} + {message.parts.map((part, index) => { + // AI SDK streams append-only parts. Tool calls have their own stable IDs. + const partId = + "toolCallId" in part + ? part.toolCallId + : `${message.id}-${part.type}-${index}` + if (part.type === "text") + return ( + + + {part.text} + + + ) + if (part.type === "reasoning") + return ( + + + Thinking process + + {part.text} + + + + ) + if (part.type === "source-url") + return ( + + ) + if (part.type.startsWith("tool-") || part.type === "dynamic-tool") { + const toolName = + part.type === "dynamic-tool" && "toolName" in part + ? String(part.toolName) + : part.type.slice(5) + const state = "state" in part ? part.state : "" + const failed = state === "output-error" + const complete = state === "output-available" + return ( +
+ + + {!complete && !failed && }{" "} + {toolLabels[toolName] ?? "Use your library"} ·{" "} + {complete ? "Done" : failed ? "Failed" : "Working"} + + + {complete && "output" in part && ( + + )} + {failed && "errorText" in part && ( +

+ {String(part.errorText)} +

+ )} +
+ ) + } + return null + })} +
+
+ ) +} + +function ToolResult({ + output, + onOpenFeed, +}: { + output: unknown + onOpenFeed: (id: string) => void +}) { + const values = Array.isArray(output) ? output : [output] + return ( +
+ {values.slice(0, 6).map((value: unknown) => { + if ( + !value || + typeof value !== "object" || + !("id" in value) || + typeof value.id !== "string" + ) + return null + if (!("title" in value) || typeof value.title !== "string") return null + if (!("content" in value) && !("origin" in value)) return null + const id = value.id + return ( + + ) + })} +
+ ) +} diff --git a/apps/web/src/components/feeds.tsx b/apps/web/src/components/feeds.tsx new file mode 100644 index 0000000..42c1708 --- /dev/null +++ b/apps/web/src/components/feeds.tsx @@ -0,0 +1,879 @@ +import { + Add01Icon, + Bookmark02Icon, + File01Icon, + Link01Icon, + Message01Icon, + Search01Icon, + Share08Icon, + SparklesIcon, + ThumbsDownIcon, + ThumbsUpIcon, +} from "@hugeicons/core-free-icons" +import { HugeiconsIcon } from "@hugeicons/react" +import { + Alert, + AlertDescription, + AlertTitle, +} from "@workspace/ui/components/alert" +import { Avatar, AvatarFallback } from "@workspace/ui/components/avatar" +import { Badge } from "@workspace/ui/components/badge" +import { Button } from "@workspace/ui/components/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@workspace/ui/components/dialog" +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@workspace/ui/components/empty" +import { + Field, + FieldDescription, + FieldGroup, + FieldLabel, +} from "@workspace/ui/components/field" +import { Input } from "@workspace/ui/components/input" +import { + InputGroup, + InputGroupAddon, + InputGroupInput, +} from "@workspace/ui/components/input-group" +import { + Item, + ItemContent, + ItemDescription, + ItemFooter, + ItemGroup, + ItemHeader, + ItemTitle, +} from "@workspace/ui/components/item" +import { + NativeSelect, + NativeSelectOption, +} from "@workspace/ui/components/native-select" +import { Separator } from "@workspace/ui/components/separator" +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@workspace/ui/components/sheet" +import { Skeleton } from "@workspace/ui/components/skeleton" +import { Spinner } from "@workspace/ui/components/spinner" +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@workspace/ui/components/tabs" +import { Textarea } from "@workspace/ui/components/textarea" +import { toast } from "@workspace/ui/components/toast" +import { + ToggleGroup, + ToggleGroupItem, +} from "@workspace/ui/components/toggle-group" +import { useEffect, useState } from "react" +import { + PageLayout, + type View, + viewTitles, +} from "@/components/layout/app-shell" +import { Markdown } from "@/components/markdown" +import { ModelPicker } from "@/components/model-picker" +import { api, useOperation } from "@/lib/client" +import type { AppData, FeedDetail, FeedView } from "@/shared/contracts" + +export const dateLabel = (value: string) => + new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric" }).format( + new Date(value), + ) +const descriptions: Record = { + all: "Everything you collect, and ideas shared with you.", + public: "Good ideas grow when you share them.", + saved: "A quiet place for the articles you want to revisit.", + ai: "Articles selected from your sources, shaped by your interests.", +} + +export function FeedList({ + data, + view, + onOpen, + onSubmit, +}: { + data: AppData + view: Extract + onOpen: (id: string) => void + onSubmit: () => void +}) { + const [feeds, setFeeds] = useState(data.feeds) + const [query, setQuery] = useState("") + const [error, setError] = useState("") + const { run, busy } = useOperation() + useEffect(() => { + if (view === "all" && !query.trim()) { + setFeeds(data.feeds) + return + } + let cancel: (() => void) | undefined + const timer = setTimeout(() => { + setError("") + cancel = run( + api({ action: "listFeeds", view, query }), + setFeeds, + (failure) => setError(failure.message), + ) + }, 180) + return () => { + clearTimeout(timer) + cancel?.() + } + }, [view, query, data.feeds, run]) + return ( + + {feeds.length} {feeds.length === 1 ? "article" : "articles"} + + } + > + + + + + + {busy && } + + + {error ? ( + + Cannot load feeds + {error} + + ) : feeds.length === 0 ? ( + + + + + + {query + ? "No matching articles" + : view === "ai" + ? "Your discoveries start here" + : "Make room for your first idea"} + + + {query + ? "Try a different search." + : view === "ai" + ? "Add an RSS source and tell Stellar what you want to read. Matching articles will appear here." + : view === "saved" + ? "Save an article to find it here later." + : "Add a link or write a note. Your collection starts with one feed."} + + + {!query && view === "all" && ( + + + + )} + + ) : ( + + {feeds.map((feed) => ( + { + event.preventDefault() + onOpen(feed.id) + }} + /> + } + className="py-4" + > + +
+ + {feed.url + ? new URL(feed.url).hostname.replace(/^www\./, "") + : "Markdown"} + + {feed.origin === "ai" && ( + + + )} + {feed.isPublic && Public} +
+ + {dateLabel(feed.createdAt)} + +
+ + {feed.title} + + {feed.summary ?? + feed.content.replace(/[#*_`>]/g, "").slice(0, 220)} + + + + + {feed.authorName} + +
+ {feed.interested !== null && ( + + {feed.interested ? "Interested" : "Not interested"} + + )} + {feed.saved && ( +
+
+
+ ))} +
+ )} +
+ ) +} + +export function SubmitFeed({ + open, + onOpenChange, + onCreated, +}: { + open: boolean + onOpenChange: (value: boolean) => void + onCreated: (id: string) => void +}) { + const [kind, setKind] = useState("link") + const [url, setUrl] = useState("") + const [title, setTitle] = useState("") + const [content, setContent] = useState("") + const { run, busy } = useOperation() + return ( + + + + Add to your feed + + Keep a link or an idea you want to return to. + + +
{ + event.preventDefault() + run( + api({ + action: "submitFeed", + ...(kind === "link" + ? { url: url.trim() } + : { title: title.trim(), content }), + }), + (result) => { + onOpenChange(false) + setUrl("") + setTitle("") + setContent("") + if (result.duplicate) + toast.add({ + title: "Already in the public space", + description: "The existing feed is open.", + type: "info", + }) + onCreated(result.id) + }, + ) + }} + > + + + Feed type + { + if (value[0]) setKind(value[0]) + }} + variant="outline" + > + + + + + + + {kind === "link" ? ( + + Article URL + + + Stellar will read the article. Existing public links open as + one feed. + + + ) : ( + <> + + Title + + + + Markdown +