diff --git a/flow.toml b/flow.toml index 36d90322..e66da657 100644 --- a/flow.toml +++ b/flow.toml @@ -128,6 +128,116 @@ description = "Set up Linsa: create .env, install deps, push schema to Neon." dependencies = ["node", "pnpm"] shortcuts = ["s"] +[[tasks]] +name = "setup-worker-admin" +interactive = true +command = """ +set -euo pipefail + +ROOT="$(pwd)" +WORKER_DIR="$ROOT/packages/worker" +WORKER_VARS="$WORKER_DIR/.dev.vars" +WEB_ENV_FILE="$ROOT/packages/web/.env" + +echo "=== Worker Admin Setup ===" +echo "" + +if [ ! -f "$WORKER_VARS" ]; then + touch "$WORKER_VARS" + echo "Created $WORKER_VARS" +else + echo "$WORKER_VARS exists" +fi + +CURRENT_ADMIN=$(grep -E "^ADMIN_API_KEY=" "$WORKER_VARS" 2>/dev/null | tail -1 | cut -d'=' -f2- || true) +if [ -z "$CURRENT_ADMIN" ]; then + echo "" + read -s -p "Enter ADMIN_API_KEY (leave empty to generate): " ADMIN_API_KEY + echo "" + if [ -z "$ADMIN_API_KEY" ]; then + ADMIN_API_KEY=$(openssl rand -hex 32) + echo "Generated ADMIN_API_KEY." + fi +else + ADMIN_API_KEY="$CURRENT_ADMIN" + echo "ADMIN_API_KEY already set in .dev.vars" +fi + +CURRENT_DB=$(grep -E "^DATABASE_URL=" "$WORKER_VARS" 2>/dev/null | tail -1 | cut -d'=' -f2- || true) +DATABASE_URL="" +if [ -n "$CURRENT_DB" ] && [[ "$CURRENT_DB" != *"user:password"* ]]; then + DATABASE_URL="$CURRENT_DB" + echo "DATABASE_URL already set in .dev.vars" +else + WEB_DB="" + if [ -f "$WEB_ENV_FILE" ]; then + WEB_DB=$(grep -E "^DATABASE_URL=" "$WEB_ENV_FILE" 2>/dev/null | cut -d'=' -f2- || true) + fi + + if [ -n "$WEB_DB" ] && [[ "$WEB_DB" != *"user:password"* ]]; then + read -p "Use DATABASE_URL from packages/web/.env for worker? (Y/n): " USE_WEB_DB + if [ -z "$USE_WEB_DB" ] || [ "$USE_WEB_DB" = "y" ] || [ "$USE_WEB_DB" = "Y" ]; then + DATABASE_URL="$WEB_DB" + fi + fi + + if [ -z "$DATABASE_URL" ]; then + read -p "Paste DATABASE_URL for worker (optional, press Enter to skip): " DATABASE_URL + fi +fi + +ADMIN_API_KEY="$ADMIN_API_KEY" DATABASE_URL="$DATABASE_URL" node - <<'NODE' +const fs = require("fs") +const path = require("path") + +const varsPath = path.join("packages", "worker", ".dev.vars") +let text = "" +if (fs.existsSync(varsPath)) { + text = fs.readFileSync(varsPath, "utf8") +} + +const ensureKey = (key, value) => { + if (!value) return + const pattern = new RegExp(`^${key}=.*$`, "m") + if (pattern.test(text)) { + text = text.replace(pattern, `${key}=${value}`) + } else { + if (text.length > 0 && !text.endsWith("\n")) { + text += "\n" + } + text += `${key}=${value}\n` + } + console.log(` Set ${key}`) +} + +ensureKey("ADMIN_API_KEY", process.env.ADMIN_API_KEY || "") +ensureKey("DATABASE_URL", process.env.DATABASE_URL || "") + +fs.writeFileSync(varsPath, text) +NODE + +echo "" +read -p "Set ADMIN_API_KEY for production worker via wrangler now? (y/N): " SET_PROD +if [ "$SET_PROD" = "y" ] || [ "$SET_PROD" = "Y" ]; then + cd "$WORKER_DIR" + if ! pnpm exec wrangler whoami >/dev/null 2>&1; then + echo "Not logged in to Cloudflare. Running wrangler login..." + pnpm exec wrangler login + fi + + echo "$ADMIN_API_KEY" | pnpm exec wrangler secret put ADMIN_API_KEY + echo "ADMIN_API_KEY set for worker" + cd "$ROOT" +fi + +echo "" +echo "Worker admin setup complete." +echo "Run 'pnpm -C packages/worker dev' to start the worker." +""" +description = "Set up worker admin API env (.dev.vars) and optionally push ADMIN_API_KEY to Cloudflare." +dependencies = ["node", "pnpm"] +shortcuts = ["swa"] + [[tasks]] name = "seed" command = """ diff --git a/packages/web/drizzle/0005_outgoing_proteus.sql b/packages/web/drizzle/0005_outgoing_proteus.sql new file mode 100644 index 00000000..4945acdf --- /dev/null +++ b/packages/web/drizzle/0005_outgoing_proteus.sql @@ -0,0 +1,109 @@ +CREATE TABLE "archives" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" text NOT NULL, + "title" text NOT NULL, + "description" text, + "type" varchar(32) NOT NULL, + "content_url" text, + "content_text" text, + "thumbnail_url" text, + "file_size_bytes" integer DEFAULT 0, + "duration_seconds" integer, + "mime_type" varchar(128), + "is_public" boolean DEFAULT false 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 "storage_usage" ( + "id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "storage_usage_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1), + "user_id" text NOT NULL, + "archives_used" integer DEFAULT 0 NOT NULL, + "archives_limit" integer DEFAULT 10 NOT NULL, + "storage_bytes_used" integer DEFAULT 0 NOT NULL, + "storage_bytes_limit" integer DEFAULT 1073741824 NOT NULL, + "period_start" timestamp with time zone NOT NULL, + "period_end" 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 +CREATE TABLE "stream_comments" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "stream_username" text NOT NULL, + "user_id" text NOT NULL, + "content" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "stream_replays" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "stream_id" uuid NOT NULL, + "user_id" text NOT NULL, + "title" text DEFAULT 'Stream Replay' NOT NULL, + "description" text, + "status" varchar(32) DEFAULT 'processing' NOT NULL, + "jazz_replay_id" text, + "playback_url" text, + "thumbnail_url" text, + "duration_seconds" integer, + "started_at" timestamp with time zone, + "ended_at" timestamp with time zone, + "is_public" boolean DEFAULT false 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 "streams" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" text NOT NULL, + "title" text DEFAULT 'Live Stream' NOT NULL, + "description" text, + "is_live" boolean DEFAULT false NOT NULL, + "viewer_count" integer DEFAULT 0 NOT NULL, + "stream_key" text NOT NULL, + "hls_url" text, + "webrtc_url" text, + "thumbnail_url" text, + "started_at" timestamp with time zone, + "ended_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "streams_stream_key_unique" UNIQUE("stream_key") +); +--> statement-breakpoint +CREATE TABLE "stripe_customers" ( + "id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "stripe_customers_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1), + "user_id" text NOT NULL, + "stripe_customer_id" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "stripe_customers_user_id_unique" UNIQUE("user_id"), + CONSTRAINT "stripe_customers_stripe_customer_id_unique" UNIQUE("stripe_customer_id") +); +--> statement-breakpoint +CREATE TABLE "stripe_subscriptions" ( + "id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "stripe_subscriptions_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1), + "user_id" text NOT NULL, + "stripe_subscription_id" text NOT NULL, + "stripe_customer_id" text NOT NULL, + "stripe_price_id" text NOT NULL, + "status" varchar(32) NOT NULL, + "current_period_start" timestamp with time zone, + "current_period_end" timestamp with time zone, + "cancel_at_period_end" boolean DEFAULT false, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "stripe_subscriptions_stripe_subscription_id_unique" UNIQUE("stripe_subscription_id") +); +--> statement-breakpoint +ALTER TABLE "users" ADD COLUMN "username" text;--> statement-breakpoint +ALTER TABLE "users" ADD COLUMN "tier" varchar(32) DEFAULT 'free' NOT NULL;--> statement-breakpoint +ALTER TABLE "archives" ADD CONSTRAINT "archives_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "storage_usage" ADD CONSTRAINT "storage_usage_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "stream_comments" ADD CONSTRAINT "stream_comments_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "stream_replays" ADD CONSTRAINT "stream_replays_stream_id_streams_id_fk" FOREIGN KEY ("stream_id") REFERENCES "public"."streams"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "stream_replays" ADD CONSTRAINT "stream_replays_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "streams" ADD CONSTRAINT "streams_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "stripe_customers" ADD CONSTRAINT "stripe_customers_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "stripe_subscriptions" ADD CONSTRAINT "stripe_subscriptions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "users" ADD CONSTRAINT "users_username_unique" UNIQUE("username"); \ No newline at end of file diff --git a/packages/web/drizzle/meta/0005_snapshot.json b/packages/web/drizzle/meta/0005_snapshot.json new file mode 100644 index 00000000..7042096c --- /dev/null +++ b/packages/web/drizzle/meta/0005_snapshot.json @@ -0,0 +1,1811 @@ +{ + "id": "d2515af7-f514-4199-b339-8e35bc07b9bf", + "prevId": "c18816f4-ba1c-4533-8b15-805862502a8d", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "accountId": { + "name": "accountId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "providerId": { + "name": "providerId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "accessToken": { + "name": "accessToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refreshToken": { + "name": "refreshToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idToken": { + "name": "idToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accessTokenExpiresAt": { + "name": "accessTokenExpiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refreshTokenExpiresAt": { + "name": "refreshTokenExpiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "accounts_userId_users_id_fk": { + "name": "accounts_userId_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.archives": { + "name": "archives", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "content_url": { + "name": "content_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_text": { + "name": "content_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thumbnail_url": { + "name": "thumbnail_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_size_bytes": { + "name": "file_size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "duration_seconds": { + "name": "duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "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()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "archives_user_id_users_id_fk": { + "name": "archives_user_id_users_id_fk", + "tableFrom": "archives", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blocks": { + "name": "blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "blocks_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.browser_session_tabs": { + "name": "browser_session_tabs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "browser_session_tabs_session_id_browser_sessions_id_fk": { + "name": "browser_session_tabs_session_id_browser_sessions_id_fk", + "tableFrom": "browser_session_tabs", + "tableTo": "browser_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.browser_sessions": { + "name": "browser_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "browser": { + "name": "browser", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'safari'" + }, + "tab_count": { + "name": "tab_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_favorite": { + "name": "is_favorite", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "captured_at": { + "name": "captured_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": {}, + "foreignKeys": { + "browser_sessions_user_id_users_id_fk": { + "name": "browser_sessions_user_id_users_id_fk", + "tableFrom": "browser_sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.canvas": { + "name": "canvas", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Untitled Canvas'" + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1024 + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1024 + }, + "default_model": { + "name": "default_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'gemini-2.5-flash-image-preview'" + }, + "default_style": { + "name": "default_style", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "background_prompt": { + "name": "background_prompt", + "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": {}, + "foreignKeys": { + "canvas_owner_id_users_id_fk": { + "name": "canvas_owner_id_users_id_fk", + "tableFrom": "canvas", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.canvas_images": { + "name": "canvas_images", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "canvas_id": { + "name": "canvas_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Untitled Image'" + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'gemini-2.0-flash-exp-image-generation'" + }, + "model_used": { + "name": "model_used", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "style_id": { + "name": "style_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 512 + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 512 + }, + "position": { + "name": "position", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "rotation": { + "name": "rotation", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "content_base64": { + "name": "content_base64", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "branch_parent_id": { + "name": "branch_parent_id", + "type": "uuid", + "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": {}, + "foreignKeys": { + "canvas_images_canvas_id_canvas_id_fk": { + "name": "canvas_images_canvas_id_canvas_id_fk", + "tableFrom": "canvas_images", + "tableTo": "canvas", + "columnsFrom": [ + "canvas_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "canvas_images_branch_parent_id_canvas_images_id_fk": { + "name": "canvas_images_branch_parent_id_canvas_images_id_fk", + "tableFrom": "canvas_images", + "tableTo": "canvas_images", + "columnsFrom": [ + "branch_parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_messages": { + "name": "chat_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "chat_messages_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "thread_id": { + "name": "thread_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar(32)", + "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": {}, + "foreignKeys": { + "chat_messages_thread_id_chat_threads_id_fk": { + "name": "chat_messages_thread_id_chat_threads_id_fk", + "tableFrom": "chat_messages", + "tableTo": "chat_threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_threads": { + "name": "chat_threads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "chat_threads_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_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": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.context_items": { + "name": "context_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "context_items_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refreshing": { + "name": "refreshing", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "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": {}, + "foreignKeys": { + "context_items_user_id_users_id_fk": { + "name": "context_items_user_id_users_id_fk", + "tableFrom": "context_items", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ipAddress": { + "name": "ipAddress", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "userAgent": { + "name": "userAgent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_userId_users_id_fk": { + "name": "sessions_userId_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.storage_usage": { + "name": "storage_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "storage_usage_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "archives_used": { + "name": "archives_used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "archives_limit": { + "name": "archives_limit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "storage_bytes_used": { + "name": "storage_bytes_used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "storage_bytes_limit": { + "name": "storage_bytes_limit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1073741824 + }, + "period_start": { + "name": "period_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "period_end": { + "name": "period_end", + "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": { + "storage_usage_user_id_users_id_fk": { + "name": "storage_usage_user_id_users_id_fk", + "tableFrom": "storage_usage", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stream_comments": { + "name": "stream_comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "stream_username": { + "name": "stream_username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_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": {}, + "foreignKeys": { + "stream_comments_user_id_users_id_fk": { + "name": "stream_comments_user_id_users_id_fk", + "tableFrom": "stream_comments", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stream_replays": { + "name": "stream_replays", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "stream_id": { + "name": "stream_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Stream Replay'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'processing'" + }, + "jazz_replay_id": { + "name": "jazz_replay_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "playback_url": { + "name": "playback_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thumbnail_url": { + "name": "thumbnail_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration_seconds": { + "name": "duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "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()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "stream_replays_stream_id_streams_id_fk": { + "name": "stream_replays_stream_id_streams_id_fk", + "tableFrom": "stream_replays", + "tableTo": "streams", + "columnsFrom": [ + "stream_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "stream_replays_user_id_users_id_fk": { + "name": "stream_replays_user_id_users_id_fk", + "tableFrom": "stream_replays", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.streams": { + "name": "streams", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Live Stream'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_live": { + "name": "is_live", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "viewer_count": { + "name": "viewer_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "stream_key": { + "name": "stream_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hls_url": { + "name": "hls_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "webrtc_url": { + "name": "webrtc_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thumbnail_url": { + "name": "thumbnail_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp with time zone", + "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": {}, + "foreignKeys": { + "streams_user_id_users_id_fk": { + "name": "streams_user_id_users_id_fk", + "tableFrom": "streams", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "streams_stream_key_unique": { + "name": "streams_stream_key_unique", + "nullsNotDistinct": false, + "columns": [ + "stream_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stripe_customers": { + "name": "stripe_customers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "stripe_customers_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "stripe_customers_user_id_users_id_fk": { + "name": "stripe_customers_user_id_users_id_fk", + "tableFrom": "stripe_customers", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "stripe_customers_user_id_unique": { + "name": "stripe_customers_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + }, + "stripe_customers_stripe_customer_id_unique": { + "name": "stripe_customers_stripe_customer_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_customer_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stripe_subscriptions": { + "name": "stripe_subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "stripe_subscriptions_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_price_id": { + "name": "stripe_price_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "current_period_start": { + "name": "current_period_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "current_period_end": { + "name": "current_period_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": 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": {}, + "foreignKeys": { + "stripe_subscriptions_user_id_users_id_fk": { + "name": "stripe_subscriptions_user_id_users_id_fk", + "tableFrom": "stripe_subscriptions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "stripe_subscriptions_stripe_subscription_id_unique": { + "name": "stripe_subscriptions_stripe_subscription_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_subscription_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.thread_context_items": { + "name": "thread_context_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "thread_context_items_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "thread_id": { + "name": "thread_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "context_item_id": { + "name": "context_item_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "thread_context_items_thread_id_chat_threads_id_fk": { + "name": "thread_context_items_thread_id_chat_threads_id_fk", + "tableFrom": "thread_context_items", + "tableTo": "chat_threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "thread_context_items_context_item_id_context_items_id_fk": { + "name": "thread_context_items_context_item_id_context_items_id_fk", + "tableFrom": "thread_context_items", + "tableTo": "context_items", + "columnsFrom": [ + "context_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "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 + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tier": { + "name": "tier", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "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 + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/web/drizzle/meta/_journal.json b/packages/web/drizzle/meta/_journal.json index 672f766a..7bac99d2 100644 --- a/packages/web/drizzle/meta/_journal.json +++ b/packages/web/drizzle/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1769000000000, "tag": "0004_add_stream_replays", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1766620803496, + "tag": "0005_outgoing_proteus", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/packages/web/src/components/BillingProvider.tsx b/packages/web/src/components/BillingProvider.tsx index 305f8a4f..acdaece6 100644 --- a/packages/web/src/components/BillingProvider.tsx +++ b/packages/web/src/components/BillingProvider.tsx @@ -1,28 +1,177 @@ -import { FlowgladProvider } from "@flowglad/react" +import { + createContext, + useContext, + useState, + useEffect, + useCallback, + type ReactNode, +} from "react" import { authClient } from "@/lib/auth-client" +type UsageSnapshot = { + used: number + limit: number + remaining: number +} + +type StorageUsage = { + archives?: UsageSnapshot + storage?: UsageSnapshot +} + +type BillingStatus = { + isGuest: boolean + isPaid: boolean + planName: string + currentPeriodEnd?: string + cancelAtPeriodEnd?: boolean + isLoading: boolean + error?: string + usage?: StorageUsage +} + +type BillingContextValue = BillingStatus & { + refresh: () => Promise + openCheckout: () => Promise + openPortal: () => Promise +} + +const BillingContext = createContext(null) + +export function useBilling() { + const context = useContext(BillingContext) + if (!context) { + return { + isGuest: true, + isPaid: false, + planName: "Guest", + isLoading: false, + usage: undefined, + refresh: async () => {}, + openCheckout: async () => {}, + openPortal: async () => {}, + } as BillingContextValue + } + return context +} + type BillingProviderProps = { - children: React.ReactNode + children: ReactNode } export function BillingProvider({ children }: BillingProviderProps) { - const flowgladEnabled = import.meta.env.VITE_FLOWGLAD_ENABLED === "true" - - // Skip billing entirely when Flowglad isn't configured - if (!flowgladEnabled) { - return <>{children} - } - const { data: session, isPending } = authClient.useSession() + const [status, setStatus] = useState({ + isGuest: true, + isPaid: false, + planName: "Guest", + isLoading: true, + usage: undefined, + }) - // Don't load billing until we know auth state - if (isPending) { - return <>{children} + const fetchBillingStatus = useCallback(async () => { + try { + const response = await fetch("/api/stripe/billing") + if (response.ok) { + const data = (await response.json()) as Partial + setStatus({ + isGuest: data.isGuest ?? true, + isPaid: data.isPaid ?? false, + planName: data.planName ?? "Guest", + usage: data.usage, + currentPeriodEnd: data.currentPeriodEnd, + cancelAtPeriodEnd: data.cancelAtPeriodEnd, + isLoading: false, + }) + } else { + setStatus((prev) => ({ + ...prev, + isLoading: false, + error: "Failed to load billing status", + })) + } + } catch (error) { + console.error("[billing] Failed to fetch status:", error) + setStatus((prev) => ({ + ...prev, + isLoading: false, + error: "Failed to load billing status", + })) + } + }, []) + + useEffect(() => { + if (isPending) return + + if (!session?.user) { + setStatus({ + isGuest: true, + isPaid: false, + planName: "Guest", + isLoading: false, + usage: undefined, + }) + return + } + + fetchBillingStatus() + }, [session?.user, isPending, fetchBillingStatus]) + + const openCheckout = useCallback(async () => { + try { + const response = await fetch("/api/stripe/checkout", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + successUrl: `${window.location.origin}/archive?billing=success`, + cancelUrl: `${window.location.origin}/archive?billing=canceled`, + }), + }) + + if (response.ok) { + const data = (await response.json()) as { url?: string } + if (data.url) { + window.location.href = data.url + } + } else { + console.error("[billing] Failed to create checkout session") + } + } catch (error) { + console.error("[billing] Checkout error:", error) + } + }, []) + + const openPortal = useCallback(async () => { + try { + const response = await fetch("/api/stripe/portal", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + returnUrl: window.location.href, + }), + }) + + if (response.ok) { + const data = (await response.json()) as { url?: string } + if (data.url) { + window.location.href = data.url + } + } else { + console.error("[billing] Failed to create portal session") + } + } catch (error) { + console.error("[billing] Portal error:", error) + } + }, []) + + const value: BillingContextValue = { + ...status, + refresh: fetchBillingStatus, + openCheckout, + openPortal, } return ( - - {children} - + {children} ) } diff --git a/packages/web/src/components/CommentBox.tsx b/packages/web/src/components/CommentBox.tsx new file mode 100644 index 00000000..e11171ad --- /dev/null +++ b/packages/web/src/components/CommentBox.tsx @@ -0,0 +1,312 @@ +import { useState, useEffect, useRef } from "react" +import { Send, LogIn } from "lucide-react" +import { authClient } from "@/lib/auth-client" + +type Comment = { + id: string + user_id: string + user_name: string + user_email: string + content: string + created_at: string +} + +type AuthStep = "idle" | "email" | "otp" + +interface CommentBoxProps { + username: string +} + +export function CommentBox({ username }: CommentBoxProps) { + const { data: session, isPending: sessionLoading } = authClient.useSession() + const [comments, setComments] = useState([]) + const [newComment, setNewComment] = useState("") + const [isSubmitting, setIsSubmitting] = useState(false) + const [isLoading, setIsLoading] = useState(true) + + // Auth state + const [authStep, setAuthStep] = useState("idle") + const [email, setEmail] = useState("") + const [otp, setOtp] = useState("") + const [authLoading, setAuthLoading] = useState(false) + const [authError, setAuthError] = useState("") + + const commentsEndRef = useRef(null) + const emailInputRef = useRef(null) + const otpInputRef = useRef(null) + + // Focus inputs when auth step changes + useEffect(() => { + if (authStep === "email") { + emailInputRef.current?.focus() + } else if (authStep === "otp") { + otpInputRef.current?.focus() + } + }, [authStep]) + + // Fetch comments + useEffect(() => { + const fetchComments = async () => { + try { + const res = await fetch(`/api/stream-comments?username=${username}`) + if (res.ok) { + const data = (await res.json()) as { comments?: Comment[] } + setComments(data.comments || []) + } + } catch (err) { + console.error("Failed to fetch comments:", err) + } finally { + setIsLoading(false) + } + } + + fetchComments() + const interval = setInterval(fetchComments, 5000) // Poll every 5 seconds + + return () => clearInterval(interval) + }, [username]) + + // Scroll to bottom when new comments arrive + useEffect(() => { + commentsEndRef.current?.scrollIntoView({ behavior: "smooth" }) + }, [comments]) + + const handleSendOTP = async (e: React.FormEvent) => { + e.preventDefault() + if (!email.trim()) return + + setAuthLoading(true) + setAuthError("") + + try { + const result = await authClient.emailOtp.sendVerificationOtp({ + email, + type: "sign-in", + }) + + if (result.error) { + setAuthError(result.error.message || "Failed to send code") + } else { + setAuthStep("otp") + } + } catch (err) { + setAuthError(err instanceof Error ? err.message : "Failed to send verification code") + } finally { + setAuthLoading(false) + } + } + + const handleVerifyOTP = async (e: React.FormEvent) => { + e.preventDefault() + if (!otp.trim()) return + + setAuthLoading(true) + setAuthError("") + + try { + const result = await authClient.signIn.emailOtp({ + email, + otp, + }) + + if (result.error) { + setAuthError(result.error.message || "Invalid code") + } else { + // Success - close auth form + setAuthStep("idle") + setEmail("") + setOtp("") + } + } catch (err) { + setAuthError(err instanceof Error ? err.message : "Failed to verify code") + } finally { + setAuthLoading(false) + } + } + + const handleSubmitComment = async (e: React.FormEvent) => { + e.preventDefault() + if (!newComment.trim() || !session?.user) return + + setIsSubmitting(true) + try { + const res = await fetch("/api/stream-comments", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username, + content: newComment.trim(), + }), + }) + + if (res.ok) { + const data = (await res.json()) as { comment: Comment } + setComments((prev) => [...prev, data.comment]) + setNewComment("") + } + } catch (err) { + console.error("Failed to post comment:", err) + } finally { + setIsSubmitting(false) + } + } + + const formatTime = (dateStr: string) => { + const date = new Date(dateStr) + return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) + } + + const isAuthenticated = !!session?.user + + return ( +
+ {/* Header */} +
+

Chat

+
+ + {/* Comments list */} +
+ {isLoading ? ( +
Loading...
+ ) : comments.length === 0 ? ( +
+ No messages yet. Be the first to say hi! +
+ ) : ( + comments.map((comment) => ( +
+
+
+ + {comment.user_name?.charAt(0).toUpperCase() || "?"} + +
+
+
+ + {comment.user_name || "Anonymous"} + + + {formatTime(comment.created_at)} + +
+

{comment.content}

+
+
+
+ )) + )} +
+
+ + {/* Input area */} +
+ {sessionLoading ? ( +
Loading...
+ ) : isAuthenticated ? ( +
+ setNewComment(e.target.value)} + placeholder="Send a message..." + className="flex-1 bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder:text-white/30 focus:outline-none focus:border-white/20" + disabled={isSubmitting} + /> + +
+ ) : authStep === "idle" ? ( + + ) : authStep === "email" ? ( +
+ setEmail(e.target.value)} + className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder:text-white/30 focus:outline-none focus:border-white/20" + /> + {authError && ( +

{authError}

+ )} +
+ + +
+
+ ) : ( +
+

+ Code sent to {email} +

+ setOtp(e.target.value.replace(/\D/g, ""))} + className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-center text-lg font-mono tracking-widest text-white placeholder:text-white/30 focus:outline-none focus:border-white/20" + /> + {authError && ( +

{authError}

+ )} +
+ + +
+
+ )} +
+
+ ) +} diff --git a/packages/web/src/db/schema.ts b/packages/web/src/db/schema.ts index 0dafd09b..68df35f4 100644 --- a/packages/web/src/db/schema.ts +++ b/packages/web/src/db/schema.ts @@ -297,6 +297,25 @@ export const stream_replays = pgTable("stream_replays", { export const selectStreamReplaySchema = createSelectSchema(stream_replays) export type StreamReplay = z.infer +// ============================================================================= +// Stream Comments (live chat for streams) +// ============================================================================= + +export const stream_comments = pgTable("stream_comments", { + id: uuid("id").primaryKey().defaultRandom(), + stream_username: text("stream_username").notNull(), // Username of the streamer + user_id: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + content: text("content").notNull(), + created_at: timestamp("created_at", { withTimezone: true }) + .defaultNow() + .notNull(), +}) + +export const selectStreamCommentSchema = createSelectSchema(stream_comments) +export type StreamComment = z.infer + // ============================================================================= // Stripe Billing // ============================================================================= diff --git a/packages/web/src/lib/billing-helpers.ts b/packages/web/src/lib/billing-helpers.ts index 4936efb7..55faba37 100644 --- a/packages/web/src/lib/billing-helpers.ts +++ b/packages/web/src/lib/billing-helpers.ts @@ -1,4 +1,11 @@ -import type { BillingWithChecks, Price, UsageMeter, Product } from "@flowglad/server" +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type BillingWithChecks = any +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Price = any +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type UsageMeter = any +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Product = any /** * Computes the total usage credits for a given usage meter slug from the current subscription's feature items. diff --git a/packages/web/src/lib/billing.ts b/packages/web/src/lib/billing.ts index e2498a05..9fae8fa5 100644 --- a/packages/web/src/lib/billing.ts +++ b/packages/web/src/lib/billing.ts @@ -508,3 +508,23 @@ export function formatBytes(bytes: number): string { const i = Math.floor(Math.log(bytes) / Math.log(k)) return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}` } + +/** + * Check if a user has an active subscription (server-side only) + */ +export async function hasActiveSubscription(userId: string): Promise { + const database = db() + + const [subscription] = await database + .select() + .from(stripe_subscriptions) + .where( + and( + eq(stripe_subscriptions.user_id, userId), + eq(stripe_subscriptions.status, "active") + ) + ) + .limit(1) + + return !!subscription +} diff --git a/packages/web/src/routeTree.gen.ts b/packages/web/src/routeTree.gen.ts index fc93977a..fa272666 100644 --- a/packages/web/src/routeTree.gen.ts +++ b/packages/web/src/routeTree.gen.ts @@ -29,6 +29,7 @@ import { Route as ArchiveArchiveIdRouteImport } from './routes/archive.$archiveI import { Route as ApiUsersRouteImport } from './routes/api/users' import { Route as ApiUsageEventsRouteImport } from './routes/api/usage-events' import { Route as ApiStreamReplaysRouteImport } from './routes/api/stream-replays' +import { Route as ApiStreamCommentsRouteImport } from './routes/api/stream-comments' import { Route as ApiStreamRouteImport } from './routes/api/stream' import { Route as ApiProfileRouteImport } from './routes/api/profile' import { Route as ApiContextItemsRouteImport } from './routes/api/context-items' @@ -43,7 +44,9 @@ import { Route as DemoApiNamesRouteImport } from './routes/demo/api.names' import { Route as ApiUsersUsernameRouteImport } from './routes/api/users.username' import { Route as ApiUsageEventsCreateRouteImport } from './routes/api/usage-events.create' import { Route as ApiStripeWebhooksRouteImport } from './routes/api/stripe/webhooks' +import { Route as ApiStripePortalRouteImport } from './routes/api/stripe/portal' import { Route as ApiStripeCheckoutRouteImport } from './routes/api/stripe/checkout' +import { Route as ApiStripeBillingRouteImport } from './routes/api/stripe/billing' import { Route as ApiStreamsUsernameRouteImport } from './routes/api/streams.$username' import { Route as ApiStreamReplaysReplayIdRouteImport } from './routes/api/stream-replays.$replayId' import { Route as ApiSpotifyNowPlayingRouteImport } from './routes/api/spotify.now-playing' @@ -165,6 +168,11 @@ const ApiStreamReplaysRoute = ApiStreamReplaysRouteImport.update({ path: '/api/stream-replays', getParentRoute: () => rootRouteImport, } as any) +const ApiStreamCommentsRoute = ApiStreamCommentsRouteImport.update({ + id: '/api/stream-comments', + path: '/api/stream-comments', + getParentRoute: () => rootRouteImport, +} as any) const ApiStreamRoute = ApiStreamRouteImport.update({ id: '/api/stream', path: '/api/stream', @@ -235,11 +243,21 @@ const ApiStripeWebhooksRoute = ApiStripeWebhooksRouteImport.update({ path: '/api/stripe/webhooks', getParentRoute: () => rootRouteImport, } as any) +const ApiStripePortalRoute = ApiStripePortalRouteImport.update({ + id: '/api/stripe/portal', + path: '/api/stripe/portal', + getParentRoute: () => rootRouteImport, +} as any) const ApiStripeCheckoutRoute = ApiStripeCheckoutRouteImport.update({ id: '/api/stripe/checkout', path: '/api/stripe/checkout', getParentRoute: () => rootRouteImport, } as any) +const ApiStripeBillingRoute = ApiStripeBillingRouteImport.update({ + id: '/api/stripe/billing', + path: '/api/stripe/billing', + getParentRoute: () => rootRouteImport, +} as any) const ApiStreamsUsernameRoute = ApiStreamsUsernameRouteImport.update({ id: '/api/streams/$username', path: '/api/streams/$username', @@ -368,6 +386,7 @@ export interface FileRoutesByFullPath { '/api/context-items': typeof ApiContextItemsRoute '/api/profile': typeof ApiProfileRoute '/api/stream': typeof ApiStreamRoute + '/api/stream-comments': typeof ApiStreamCommentsRoute '/api/stream-replays': typeof ApiStreamReplaysRouteWithChildren '/api/usage-events': typeof ApiUsageEventsRouteWithChildren '/api/users': typeof ApiUsersRouteWithChildren @@ -387,7 +406,9 @@ export interface FileRoutesByFullPath { '/api/spotify/now-playing': typeof ApiSpotifyNowPlayingRoute '/api/stream-replays/$replayId': typeof ApiStreamReplaysReplayIdRoute '/api/streams/$username': typeof ApiStreamsUsernameRouteWithChildren + '/api/stripe/billing': typeof ApiStripeBillingRoute '/api/stripe/checkout': typeof ApiStripeCheckoutRoute + '/api/stripe/portal': typeof ApiStripePortalRoute '/api/stripe/webhooks': typeof ApiStripeWebhooksRoute '/api/usage-events/create': typeof ApiUsageEventsCreateRoute '/api/users/username': typeof ApiUsersUsernameRoute @@ -424,6 +445,7 @@ export interface FileRoutesByTo { '/api/context-items': typeof ApiContextItemsRoute '/api/profile': typeof ApiProfileRoute '/api/stream': typeof ApiStreamRoute + '/api/stream-comments': typeof ApiStreamCommentsRoute '/api/stream-replays': typeof ApiStreamReplaysRouteWithChildren '/api/usage-events': typeof ApiUsageEventsRouteWithChildren '/api/users': typeof ApiUsersRouteWithChildren @@ -443,7 +465,9 @@ export interface FileRoutesByTo { '/api/spotify/now-playing': typeof ApiSpotifyNowPlayingRoute '/api/stream-replays/$replayId': typeof ApiStreamReplaysReplayIdRoute '/api/streams/$username': typeof ApiStreamsUsernameRouteWithChildren + '/api/stripe/billing': typeof ApiStripeBillingRoute '/api/stripe/checkout': typeof ApiStripeCheckoutRoute + '/api/stripe/portal': typeof ApiStripePortalRoute '/api/stripe/webhooks': typeof ApiStripeWebhooksRoute '/api/usage-events/create': typeof ApiUsageEventsCreateRoute '/api/users/username': typeof ApiUsersUsernameRoute @@ -482,6 +506,7 @@ export interface FileRoutesById { '/api/context-items': typeof ApiContextItemsRoute '/api/profile': typeof ApiProfileRoute '/api/stream': typeof ApiStreamRoute + '/api/stream-comments': typeof ApiStreamCommentsRoute '/api/stream-replays': typeof ApiStreamReplaysRouteWithChildren '/api/usage-events': typeof ApiUsageEventsRouteWithChildren '/api/users': typeof ApiUsersRouteWithChildren @@ -501,7 +526,9 @@ export interface FileRoutesById { '/api/spotify/now-playing': typeof ApiSpotifyNowPlayingRoute '/api/stream-replays/$replayId': typeof ApiStreamReplaysReplayIdRoute '/api/streams/$username': typeof ApiStreamsUsernameRouteWithChildren + '/api/stripe/billing': typeof ApiStripeBillingRoute '/api/stripe/checkout': typeof ApiStripeCheckoutRoute + '/api/stripe/portal': typeof ApiStripePortalRoute '/api/stripe/webhooks': typeof ApiStripeWebhooksRoute '/api/usage-events/create': typeof ApiUsageEventsCreateRoute '/api/users/username': typeof ApiUsersUsernameRoute @@ -541,6 +568,7 @@ export interface FileRouteTypes { | '/api/context-items' | '/api/profile' | '/api/stream' + | '/api/stream-comments' | '/api/stream-replays' | '/api/usage-events' | '/api/users' @@ -560,7 +588,9 @@ export interface FileRouteTypes { | '/api/spotify/now-playing' | '/api/stream-replays/$replayId' | '/api/streams/$username' + | '/api/stripe/billing' | '/api/stripe/checkout' + | '/api/stripe/portal' | '/api/stripe/webhooks' | '/api/usage-events/create' | '/api/users/username' @@ -597,6 +627,7 @@ export interface FileRouteTypes { | '/api/context-items' | '/api/profile' | '/api/stream' + | '/api/stream-comments' | '/api/stream-replays' | '/api/usage-events' | '/api/users' @@ -616,7 +647,9 @@ export interface FileRouteTypes { | '/api/spotify/now-playing' | '/api/stream-replays/$replayId' | '/api/streams/$username' + | '/api/stripe/billing' | '/api/stripe/checkout' + | '/api/stripe/portal' | '/api/stripe/webhooks' | '/api/usage-events/create' | '/api/users/username' @@ -654,6 +687,7 @@ export interface FileRouteTypes { | '/api/context-items' | '/api/profile' | '/api/stream' + | '/api/stream-comments' | '/api/stream-replays' | '/api/usage-events' | '/api/users' @@ -673,7 +707,9 @@ export interface FileRouteTypes { | '/api/spotify/now-playing' | '/api/stream-replays/$replayId' | '/api/streams/$username' + | '/api/stripe/billing' | '/api/stripe/checkout' + | '/api/stripe/portal' | '/api/stripe/webhooks' | '/api/usage-events/create' | '/api/users/username' @@ -712,6 +748,7 @@ export interface RootRouteChildren { ApiContextItemsRoute: typeof ApiContextItemsRoute ApiProfileRoute: typeof ApiProfileRoute ApiStreamRoute: typeof ApiStreamRoute + ApiStreamCommentsRoute: typeof ApiStreamCommentsRoute ApiStreamReplaysRoute: typeof ApiStreamReplaysRouteWithChildren ApiUsageEventsRoute: typeof ApiUsageEventsRouteWithChildren ApiUsersRoute: typeof ApiUsersRouteWithChildren @@ -723,7 +760,9 @@ export interface RootRouteChildren { ApiFlowgladSplatRoute: typeof ApiFlowgladSplatRoute ApiSpotifyNowPlayingRoute: typeof ApiSpotifyNowPlayingRoute ApiStreamsUsernameRoute: typeof ApiStreamsUsernameRouteWithChildren + ApiStripeBillingRoute: typeof ApiStripeBillingRoute ApiStripeCheckoutRoute: typeof ApiStripeCheckoutRoute + ApiStripePortalRoute: typeof ApiStripePortalRoute ApiStripeWebhooksRoute: typeof ApiStripeWebhooksRoute DemoApiNamesRoute: typeof DemoApiNamesRoute DemoStartApiRequestRoute: typeof DemoStartApiRequestRoute @@ -876,6 +915,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiStreamReplaysRouteImport parentRoute: typeof rootRouteImport } + '/api/stream-comments': { + id: '/api/stream-comments' + path: '/api/stream-comments' + fullPath: '/api/stream-comments' + preLoaderRoute: typeof ApiStreamCommentsRouteImport + parentRoute: typeof rootRouteImport + } '/api/stream': { id: '/api/stream' path: '/api/stream' @@ -974,6 +1020,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiStripeWebhooksRouteImport parentRoute: typeof rootRouteImport } + '/api/stripe/portal': { + id: '/api/stripe/portal' + path: '/api/stripe/portal' + fullPath: '/api/stripe/portal' + preLoaderRoute: typeof ApiStripePortalRouteImport + parentRoute: typeof rootRouteImport + } '/api/stripe/checkout': { id: '/api/stripe/checkout' path: '/api/stripe/checkout' @@ -981,6 +1034,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiStripeCheckoutRouteImport parentRoute: typeof rootRouteImport } + '/api/stripe/billing': { + id: '/api/stripe/billing' + path: '/api/stripe/billing' + fullPath: '/api/stripe/billing' + preLoaderRoute: typeof ApiStripeBillingRouteImport + parentRoute: typeof rootRouteImport + } '/api/streams/$username': { id: '/api/streams/$username' path: '/api/streams/$username' @@ -1281,6 +1341,7 @@ const rootRouteChildren: RootRouteChildren = { ApiContextItemsRoute: ApiContextItemsRoute, ApiProfileRoute: ApiProfileRoute, ApiStreamRoute: ApiStreamRoute, + ApiStreamCommentsRoute: ApiStreamCommentsRoute, ApiStreamReplaysRoute: ApiStreamReplaysRouteWithChildren, ApiUsageEventsRoute: ApiUsageEventsRouteWithChildren, ApiUsersRoute: ApiUsersRouteWithChildren, @@ -1292,7 +1353,9 @@ const rootRouteChildren: RootRouteChildren = { ApiFlowgladSplatRoute: ApiFlowgladSplatRoute, ApiSpotifyNowPlayingRoute: ApiSpotifyNowPlayingRoute, ApiStreamsUsernameRoute: ApiStreamsUsernameRouteWithChildren, + ApiStripeBillingRoute: ApiStripeBillingRoute, ApiStripeCheckoutRoute: ApiStripeCheckoutRoute, + ApiStripePortalRoute: ApiStripePortalRoute, ApiStripeWebhooksRoute: ApiStripeWebhooksRoute, DemoApiNamesRoute: DemoApiNamesRoute, DemoStartApiRequestRoute: DemoStartApiRequestRoute, diff --git a/packages/web/src/routes/$username.tsx b/packages/web/src/routes/$username.tsx index 4cd996ed..5fb32a90 100644 --- a/packages/web/src/routes/$username.tsx +++ b/packages/web/src/routes/$username.tsx @@ -7,6 +7,7 @@ import { WebRTCPlayer } from "@/components/WebRTCPlayer" import { resolveStreamPlayback } from "@/lib/stream/playback" import { JazzProvider } from "@/lib/jazz/provider" import { ViewerCount } from "@/components/ViewerCount" +import { CommentBox } from "@/components/CommentBox" import { getSpotifyNowPlaying, type SpotifyNowPlayingResponse, @@ -359,13 +360,15 @@ function StreamPage() { return ( -
- {/* Viewer count overlay */} -
- -
+
+ {/* Main content area */} +
+ {/* Viewer count overlay */} +
+ +
- {isActuallyLive && activePlayback && showPlayer ? ( + {isActuallyLive && activePlayback && showPlayer ? ( activePlayback.type === "webrtc" ? (
)} +
+ + {/* Chat sidebar */} +
+ +
) diff --git a/packages/web/src/routes/api/stream-comments.ts b/packages/web/src/routes/api/stream-comments.ts new file mode 100644 index 00000000..1693ec83 --- /dev/null +++ b/packages/web/src/routes/api/stream-comments.ts @@ -0,0 +1,125 @@ +import { createFileRoute } from "@tanstack/react-router" +import { getAuth } from "@/lib/auth" +import { db } from "@/db/connection" +import { stream_comments, users } from "@/db/schema" +import { eq } from "drizzle-orm" + +export const Route = createFileRoute("/api/stream-comments")({ + server: { + handlers: { + GET: async ({ request }) => { + const url = new URL(request.url) + const username = url.searchParams.get("username") + + if (!username) { + return new Response(JSON.stringify({ error: "username is required" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }) + } + + try { + const database = db() + const comments = await database + .select({ + id: stream_comments.id, + user_id: stream_comments.user_id, + user_name: users.name, + user_email: users.email, + content: stream_comments.content, + created_at: stream_comments.created_at, + }) + .from(stream_comments) + .leftJoin(users, eq(stream_comments.user_id, users.id)) + .where(eq(stream_comments.stream_username, username)) + .orderBy(stream_comments.created_at) + .limit(100) + + return new Response(JSON.stringify({ comments }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + } catch (err) { + console.error("[stream-comments] GET error:", err) + return new Response(JSON.stringify({ error: "Failed to fetch comments" }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }) + } + }, + + POST: async ({ request }) => { + const session = await getAuth().api.getSession({ headers: request.headers }) + if (!session?.user?.id) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }) + } + + try { + const body = await request.json() + const { username, content } = body as { username?: string; content?: string } + + if (!username || !content?.trim()) { + return new Response(JSON.stringify({ error: "username and content are required" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }) + } + + const database = db() + const [newComment] = await database + .insert(stream_comments) + .values({ + stream_username: username, + user_id: session.user.id, + content: content.trim(), + }) + .returning() + + // Get user info for response + const [user] = await database + .select({ name: users.name, email: users.email }) + .from(users) + .where(eq(users.id, session.user.id)) + .limit(1) + + return new Response( + JSON.stringify({ + comment: { + id: newComment.id, + user_id: newComment.user_id, + user_name: user?.name || "Anonymous", + user_email: user?.email || "", + content: newComment.content, + created_at: newComment.created_at, + }, + }), + { + status: 201, + headers: { "Content-Type": "application/json" }, + } + ) + } catch (err) { + console.error("[stream-comments] POST error:", err) + return new Response(JSON.stringify({ error: "Failed to post comment" }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }) + } + }, + + OPTIONS: () => { + return new Response(null, { + status: 204, + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + }, + }) + }, + }, + }, +}) diff --git a/packages/web/src/routes/api/stream-replays.$replayId.ts b/packages/web/src/routes/api/stream-replays.$replayId.ts index 13c1a57e..d506fd7c 100644 --- a/packages/web/src/routes/api/stream-replays.$replayId.ts +++ b/packages/web/src/routes/api/stream-replays.$replayId.ts @@ -2,6 +2,7 @@ import { createFileRoute } from "@tanstack/react-router" import { and, eq } from "drizzle-orm" import { db } from "@/db/connection" import { getAuth } from "@/lib/auth" +import { hasActiveSubscription } from "@/lib/billing" import { stream_replays, streams } from "@/db/schema" const json = (data: unknown, status = 200) => @@ -80,6 +81,8 @@ const handleGet = async ({ params: { replayId: string } }) => { const database = db() + const auth = getAuth() + const session = await auth.api.getSession({ headers: request.headers }) const replay = await database.query.stream_replays.findFirst({ where: eq(stream_replays.id, params.replayId), @@ -89,8 +92,31 @@ const handleGet = async ({ return json({ error: "Replay not found" }, 404) } - const isOwner = await canAccessReplay(request, replay.user_id) - if (!isOwner && (!replay.is_public || replay.status !== "ready")) { + const isOwner = session?.user?.id === replay.user_id + + // Owners can always view their own replays + if (isOwner) { + return json({ replay }) + } + + // Non-owners need subscription to view replays + if (!session?.user?.id) { + return json( + { error: "Subscription required", code: "SUBSCRIPTION_REQUIRED" }, + 403 + ) + } + + const hasSubscription = await hasActiveSubscription(session.user.id) + if (!hasSubscription) { + return json( + { error: "Subscription required", code: "SUBSCRIPTION_REQUIRED" }, + 403 + ) + } + + // With subscription, can view public ready replays + if (!replay.is_public || replay.status !== "ready") { return json({ error: "Forbidden" }, 403) } diff --git a/packages/web/src/routes/api/streams.$username.replays.ts b/packages/web/src/routes/api/streams.$username.replays.ts index ef199c89..bb3cc007 100644 --- a/packages/web/src/routes/api/streams.$username.replays.ts +++ b/packages/web/src/routes/api/streams.$username.replays.ts @@ -2,6 +2,7 @@ import { createFileRoute } from "@tanstack/react-router" import { and, desc, eq } from "drizzle-orm" import { db } from "@/db/connection" import { getAuth } from "@/lib/auth" +import { hasActiveSubscription } from "@/lib/billing" import { stream_replays, users } from "@/db/schema" const json = (data: unknown, status = 200) => @@ -37,17 +38,52 @@ const handleGet = async ({ const session = await auth.api.getSession({ headers: request.headers }) const isOwner = session?.user?.id === user.id - const conditions = [eq(stream_replays.user_id, user.id)] - if (!isOwner) { - conditions.push(eq(stream_replays.is_public, true)) - conditions.push(eq(stream_replays.status, "ready")) + // Owners can always see their own replays + if (isOwner) { + try { + const replays = await database + .select() + .from(stream_replays) + .where(eq(stream_replays.user_id, user.id)) + .orderBy( + desc(stream_replays.started_at), + desc(stream_replays.created_at) + ) + return json({ replays }) + } catch (error) { + console.error("[stream-replays] Error fetching replays:", error) + return json({ error: "Failed to fetch replays" }, 500) + } } + // Non-owners need subscription to view replays + if (!session?.user?.id) { + return json( + { error: "Subscription required", code: "SUBSCRIPTION_REQUIRED" }, + 403 + ) + } + + const hasSubscription = await hasActiveSubscription(session.user.id) + if (!hasSubscription) { + return json( + { error: "Subscription required", code: "SUBSCRIPTION_REQUIRED" }, + 403 + ) + } + + // With subscription, can view public ready replays try { const replays = await database .select() .from(stream_replays) - .where(and(...conditions)) + .where( + and( + eq(stream_replays.user_id, user.id), + eq(stream_replays.is_public, true), + eq(stream_replays.status, "ready") + ) + ) .orderBy(desc(stream_replays.started_at), desc(stream_replays.created_at)) return json({ replays }) diff --git a/packages/web/src/routes/api/stripe/billing.ts b/packages/web/src/routes/api/stripe/billing.ts new file mode 100644 index 00000000..5b9aafe4 --- /dev/null +++ b/packages/web/src/routes/api/stripe/billing.ts @@ -0,0 +1,105 @@ +import { createFileRoute } from "@tanstack/react-router" +import { getAuth } from "@/lib/auth" +import { db } from "@/db/connection" +import { stripe_subscriptions, storage_usage } from "@/db/schema" +import { eq, and, gte, lte } from "drizzle-orm" + +const json = (data: unknown, status = 200) => + new Response(JSON.stringify(data), { + status, + headers: { "content-type": "application/json" }, + }) + +export const Route = createFileRoute("/api/stripe/billing")({ + server: { + handlers: { + GET: async ({ request }) => { + const session = await getAuth().api.getSession({ + headers: request.headers, + }) + + // Guest user + if (!session?.user?.id) { + return json({ + isGuest: true, + isPaid: false, + planName: "Guest", + }) + } + + const database = db() + + try { + // Check for active subscription + const [subscription] = await database + .select() + .from(stripe_subscriptions) + .where( + and( + eq(stripe_subscriptions.user_id, session.user.id), + eq(stripe_subscriptions.status, "active") + ) + ) + .limit(1) + + if (subscription) { + // Get usage for current billing period + const now = new Date() + const [usage] = await database + .select() + .from(storage_usage) + .where( + and( + eq(storage_usage.user_id, session.user.id), + lte(storage_usage.period_start, now), + gte(storage_usage.period_end, now) + ) + ) + .limit(1) + + return json({ + isGuest: false, + isPaid: true, + planName: "Archive Pro", + usage: { + archives: { + used: usage?.archives_used ?? 0, + limit: usage?.archives_limit ?? 10, + remaining: Math.max( + 0, + (usage?.archives_limit ?? 10) - (usage?.archives_used ?? 0) + ), + }, + storage: { + used: usage?.storage_bytes_used ?? 0, + limit: usage?.storage_bytes_limit ?? 1073741824, + remaining: Math.max( + 0, + (usage?.storage_bytes_limit ?? 1073741824) - + (usage?.storage_bytes_used ?? 0) + ), + }, + }, + currentPeriodEnd: subscription.current_period_end, + cancelAtPeriodEnd: subscription.cancel_at_period_end, + }) + } + + // Free authenticated user + return json({ + isGuest: false, + isPaid: false, + planName: "Free", + }) + } catch (error) { + console.error("[billing] Error getting status:", error) + return json({ + isGuest: false, + isPaid: false, + planName: "Free", + }) + } + }, + }, + }, +}) diff --git a/packages/web/src/routes/api/stripe/portal.ts b/packages/web/src/routes/api/stripe/portal.ts new file mode 100644 index 00000000..b44e5838 --- /dev/null +++ b/packages/web/src/routes/api/stripe/portal.ts @@ -0,0 +1,66 @@ +import { createFileRoute } from "@tanstack/react-router" +import { getAuth } from "@/lib/auth" +import { getStripe } from "@/lib/stripe" +import { db } from "@/db/connection" +import { stripe_customers } from "@/db/schema" +import { eq } from "drizzle-orm" + +const json = (data: unknown, status = 200) => + new Response(JSON.stringify(data), { + status, + headers: { "content-type": "application/json" }, + }) + +export const Route = createFileRoute("/api/stripe/portal")({ + server: { + handlers: { + POST: async ({ request }) => { + const session = await getAuth().api.getSession({ + headers: request.headers, + }) + if (!session?.user?.id) { + return json({ error: "Unauthorized" }, 401) + } + + const stripe = getStripe() + if (!stripe) { + return json({ error: "Stripe not configured" }, 500) + } + + const database = db() + + try { + // Get Stripe customer + const [customer] = await database + .select() + .from(stripe_customers) + .where(eq(stripe_customers.user_id, session.user.id)) + .limit(1) + + if (!customer) { + return json({ error: "No billing account found" }, 404) + } + + // Parse request body for return URL + const body = (await request.json().catch(() => ({}))) as { + returnUrl?: string + } + + const origin = new URL(request.url).origin + const returnUrl = body.returnUrl ?? `${origin}/archive` + + // Create portal session + const portalSession = await stripe.billingPortal.sessions.create({ + customer: customer.stripe_customer_id, + return_url: returnUrl, + }) + + return json({ url: portalSession.url }) + } catch (error) { + console.error("[stripe] Portal error:", error) + return json({ error: "Failed to create portal session" }, 500) + } + }, + }, + }, +}) diff --git a/packages/web/src/routes/auth.tsx b/packages/web/src/routes/auth.tsx index 5ab6b762..0d042503 100644 --- a/packages/web/src/routes/auth.tsx +++ b/packages/web/src/routes/auth.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef } from "react" import { createFileRoute } from "@tanstack/react-router" -import { Mail, Apple, Github } from "lucide-react" +import { Mail } from "lucide-react" import { authClient } from "@/lib/auth-client" export const Route = createFileRoute("/auth")({ @@ -10,29 +10,6 @@ export const Route = createFileRoute("/auth")({ type Step = "email" | "otp" -function ChromeIcon({ className }: { className?: string }) { - return ( - - - - - - - - ) -} - function AuthPage() { const [step, setStep] = useState("email") const emailInputRef = useRef(null) @@ -252,38 +229,6 @@ function AuthPage() {
)} - -
-

- Coming soon -

-
- - - -
-
diff --git a/packages/web/src/routes/index.tsx b/packages/web/src/routes/index.tsx index e24fce9a..16ce4729 100644 --- a/packages/web/src/routes/index.tsx +++ b/packages/web/src/routes/index.tsx @@ -1,4 +1,4 @@ -import { createFileRoute } from "@tanstack/react-router" +import { createFileRoute, Link } from "@tanstack/react-router" import { ShaderBackground } from "@/components/ShaderBackground" const galleryItems = [ @@ -25,6 +25,12 @@ function LandingPage() {

Save anything privately. Share it.

+ + Sign up +
{ app.post("/api/v1/admin/context-items", async (c) => { const body = await parseBody(c) const userId = typeof body.userId === "string" ? body.userId.trim() : "" - const type = typeof body.type === "string" ? body.type.trim() : "" + const type = + typeof body.type === "string" ? body.type.trim().toLowerCase() : "" const url = typeof body.url === "string" ? body.url.trim() : null const name = typeof body.name === "string" && body.name.trim() @@ -537,7 +538,7 @@ app.patch("/api/v1/admin/context-items/:itemId", async (c) => { if (typeof body.name === "string") updates.name = body.name if (typeof body.type === "string") { - const nextType = body.type.trim() + const nextType = body.type.trim().toLowerCase() if (nextType !== "url" && nextType !== "file") { return c.json({ error: "type must be 'url' or 'file'" }, 400) }