diff --git a/.env.example b/.env.example index 810b526..7633d2b 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,30 @@ -NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= -CLERK_SECRET_KEY= +# Paddle Billing (Payment Processing) +PADDLE_API_KEY=test_... +PADDLE_WEBHOOK_SECRET=pdl_ntfset_... +NEXT_PUBLIC_PADDLE_CLIENT_TOKEN=test_... +PADDLE_ENVIRONMENT=sandbox -NEXT_PUBLIC_CLERK_SIGN_IN_FORCE_REDIRECT_URL= -NEXT_PUBLIC_CLERK_SIGN_UP_FORCE_REDIRECT_URL= +# Resend (Email Service) +RESEND_API_KEY=re_... +RESEND_FROM_EMAIL=noreply@yourdomain.com + +# App URLs +NEXT_PUBLIC_BASE_URL=http://localhost:3000 + +# AWS Info +AWS_REGION= +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_S3_BUCKET_NAME= + + +NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_... +CLERK_SECRET_KEY=sk_... + +NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in +NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up +NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL=/library +NEXT_PUBLIC_CLERK_SIGN_UP_FALLBACK_REDIRECT_URL=/library # Database @@ -12,15 +34,3 @@ DATABASE_URL=postgres://postgres:postgres@localhost:5432/echo POSTGRES_USER=postgres POSTGRES_PASSWORD=postgres POSTGRES_DB=echo - -# Stripe (Payment Processing) -STRIPE_SECRET_KEY=sk_test_... -NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_... -STRIPE_WEBHOOK_SECRET=whsec_... - -# Resend (Email Service) -RESEND_API_KEY=re_... -RESEND_FROM_EMAIL=noreply@yourdomain.com - -# Application URLs -NEXT_PUBLIC_BASE_URL=http://localhost:3000 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 7b8da95..4616c3b 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,7 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# AI bloat +docs/superpowers/* +.superpowers \ No newline at end of file diff --git a/PADDLE_SETUP.md b/PADDLE_SETUP.md new file mode 100644 index 0000000..cabf2d6 --- /dev/null +++ b/PADDLE_SETUP.md @@ -0,0 +1,319 @@ +# Paddle Billing Setup Guide + +This guide will help you set up Paddle Billing for the Echo application. + +## Prerequisites + +- A Paddle account (sign up at [paddle.com](https://www.paddle.com)) +- Access to the Paddle Dashboard +- Your application deployed or running locally with ngrok for webhook testing + +--- + +## Step 1: Create Products in Paddle Dashboard + +1. **Navigate to Catalog > Products** in your Paddle Dashboard + +2. **Create your subscription products:** + +### Monthly Plan +- Click **"+ Add Product"** +- Product name: `Echo Premium Monthly` +- Description: Monthly subscription to Echo Premium features +- Click **"Continue"** + +### Add Pricing +- Click **"+ Add Price"** +- Billing cycle: `Monthly` +- Price: `€4.99` (or your desired amount) +- Click **"Save"** +- **Copy the Price ID** (format: `pri_xxx`) - you'll need this later + +### Yearly Plan +- Click **"+ Add Product"** +- Product name: `Echo Premium Yearly` +- Description: Yearly subscription to Echo Premium features +- Click **"Continue"** + +### Add Pricing +- Click **"+ Add Price"** +- Billing cycle: `Yearly` +- Price: `€49.99` (or your desired amount) +- Click **"Save"** +- **Copy the Price ID** (format: `pri_xxx`) + +### Lifetime Plan (Optional) +- Click **"+ Add Product"** +- Product name: `Echo Premium Lifetime` +- Description: Lifetime access to Echo Premium features +- Click **"Continue"** + +### Add Pricing +- Click **"+ Add Price"** +- Billing cycle: `One-time` +- Price: `€199.99` (or your desired amount) +- Click **"Save"** +- **Copy the Price ID** (format: `pri_xxx`) + +--- + +## Step 2: Update Database with Paddle Price IDs + +After creating your products and getting the Price IDs, update your database: + +```sql +-- Connect to your database and run these commands + +-- For Monthly Plan +UPDATE subscription_plans +SET paddle_price_id = 'pri_01jb...' +WHERE name = 'Premium Monthly'; + +-- For Yearly Plan +UPDATE subscription_plans +SET paddle_price_id = 'pri_01jc...' +WHERE name = 'Premium Yearly'; + +-- For Lifetime Plan (if applicable) +UPDATE subscription_plans +SET paddle_price_id = 'pri_01jd...' +WHERE name = 'Premium Lifetime'; +``` + +Replace `pri_01jb...` with your actual Paddle Price IDs. + +--- + +## Step 3: Configure Webhooks + +1. **Go to Developer Tools > Notifications** in Paddle Dashboard + +2. **Click "Add Notification Destination"** + +3. **Configure the webhook:** + - Destination Type: `Webhook` + - Webhook URL: `https://yourdomain.com/api/webhooks/paddle` + - For local testing: Use ngrok URL (e.g., `https://abc123.ngrok.io/api/webhooks/paddle`) + - Description: `Echo Production Webhooks` (or `Echo Development Webhooks`) + +4. **Select the following events:** + - ✅ `subscription.created` + - ✅ `subscription.updated` + - ✅ `subscription.canceled` + - ✅ `transaction.completed` + - ✅ `transaction.payment_failed` + +5. **Click "Save"** + +6. **Copy the Webhook Secret Key** + - Format: `pdl_ntfset_xxx` + - Add this to your `.env` file as `PADDLE_WEBHOOK_SECRET` + +--- + +## Step 4: Get API Credentials + +### Get API Key (Server-side) + +1. **Go to Developer Tools > Authentication** +2. **Click "+ Create API Key"** +3. **Configure:** + - Name: `Echo Server API Key` + - Permissions: Select all relevant permissions (at minimum: `subscription:read`, `subscription:write`, `transaction:read`, `customer:read`, `customer:write`) +4. **Click "Create"** +5. **Copy the API Key** (format: `live_xxx` for production or `test_xxx` for sandbox) +6. Add to `.env` as `PADDLE_API_KEY` + +### Get Client-side Token + +1. **Still in Developer Tools > Authentication** +2. **Look for "Client-side Tokens"** section +3. **Click "+ Create Client Token"** (or use the default one) +4. **Copy the token** (format: `test_xxx` for sandbox or `live_xxx` for production) +5. Add to `.env` as `NEXT_PUBLIC_PADDLE_CLIENT_TOKEN` + +--- + +## Step 5: Environment Configuration + +Update your `.env` file with all Paddle credentials: + +```env +# Paddle Billing (use Sandbox values for development) +PADDLE_API_KEY=test_xxx +PADDLE_WEBHOOK_SECRET=pdl_ntfset_xxx +NEXT_PUBLIC_PADDLE_CLIENT_TOKEN=test_xxx +PADDLE_ENVIRONMENT=sandbox + +# For production, use: +# PADDLE_API_KEY=live_xxx +# PADDLE_WEBHOOK_SECRET=pdl_ntfset_xxx +# NEXT_PUBLIC_PADDLE_CLIENT_TOKEN=live_xxx +# PADDLE_ENVIRONMENT=production +``` + +--- + +## Step 6: Database Migration + +Run the database migration to add Paddle fields: + +```bash +# Push the schema changes to your database +pnpm db:push + +# Or if you're using migrations in production +pnpm db:migrate +``` + +This will add the following columns: +- `subscription_plans.paddle_price_id` +- `subscription_plans.paddle_product_id` +- `user_subscriptions.paddle_subscription_id` +- `user_subscriptions.paddle_customer_id` +- `user_subscriptions.paddle_transaction_id` + +--- + +## Step 7: Testing with Sandbox + +### Setup ngrok for Local Testing + +1. **Install ngrok** (if not installed): + ```bash + # macOS + brew install ngrok + + # Or download from https://ngrok.com + ``` + +2. **Start your dev server:** + ```bash + pnpm dev + ``` + +3. **In a new terminal, start ngrok:** + ```bash + ngrok http 3000 + ``` + +4. **Copy the ngrok URL** (e.g., `https://abc123.ngrok.io`) + +5. **Update webhook endpoint in Paddle:** + - Go to Developer Tools > Notifications + - Edit your webhook + - Update URL to: `https://abc123.ngrok.io/api/webhooks/paddle` + +### Test Card Details (Sandbox) + +Use these test card details in Paddle Sandbox: + +- **Card Number:** `4242 4242 4242 4242` +- **Expiry:** Any future date +- **CVC:** Any 3 digits +- **Name:** Any name + +### Testing Checklist + +- [ ] Create a test subscription through your app +- [ ] Verify `subscription.created` webhook is received +- [ ] Check database - new record should appear in `user_subscriptions` +- [ ] Verify welcome email is sent +- [ ] Test cancellation flow +- [ ] Verify `subscription.canceled` webhook downgrades user to free plan +- [ ] Test payment failure scenario (use declined test card if available) + +--- + +## Step 8: Production Deployment + +### Before Going Live + +1. **Switch to Production Mode in Paddle:** + - Go to your Paddle Dashboard settings + - Enable "Go Live" mode + - Complete any required verification steps + +2. **Get Production Credentials:** + - Create new API keys for production (starting with `live_`) + - Get production client token + - Create new webhook with production URL + +3. **Update Environment Variables:** + ```env + PADDLE_API_KEY=live_xxx + PADDLE_WEBHOOK_SECRET=pdl_ntfset_xxx + NEXT_PUBLIC_PADDLE_CLIENT_TOKEN=live_xxx + PADDLE_ENVIRONMENT=production + ``` + +4. **Update webhook endpoint:** + - Change from ngrok URL to your production URL + - Example: `https://echo.yourdomain.com/api/webhooks/paddle` + +5. **Deploy your application** with the new environment variables + +### Monitor Webhooks + +1. **Go to Developer Tools > Event Logs** in Paddle Dashboard +2. Monitor incoming webhook deliveries +3. Check for any failed webhooks and debug as needed + +--- + +## Troubleshooting + +### Webhooks Not Received + +1. **Check webhook signature verification:** + - Ensure `PADDLE_WEBHOOK_SECRET` is correct + - Check server logs for signature verification errors + +2. **Verify webhook endpoint is accessible:** + - Test the URL in a browser (should return 404 or 405 for GET requests) + - Check firewall/security rules + +3. **Check Paddle Event Logs:** + - Go to Developer Tools > Event Logs + - Look for delivery attempts and error messages + +### Invalid Price ID Errors + +1. **Verify Price IDs are correct:** + - Check they match format `pri_xxx` + - Ensure they're from the correct environment (sandbox vs production) + +2. **Check database updates:** + ```sql + SELECT name, paddle_price_id FROM subscription_plans; + ``` + +### Transaction Failures + +1. **Check API credentials:** + - Ensure `PADDLE_API_KEY` is valid and not expired + - Verify it has the correct permissions + +2. **Check environment:** + - Ensure `PADDLE_ENVIRONMENT` matches your credentials + - Don't use sandbox credentials in production mode + +--- + +## Additional Resources + +- [Paddle Documentation](https://developer.paddle.com/) +- [Paddle Webhooks Guide](https://developer.paddle.com/webhooks/overview) +- [Paddle Node.js SDK](https://github.com/PaddleHQ/paddle-node-sdk) +- [Paddle API Reference](https://developer.paddle.com/api-reference/overview) + +--- + +## Support + +If you encounter issues: + +1. Check the Paddle Dashboard Event Logs +2. Review server logs for error messages +3. Contact Paddle support through the dashboard +4. Check webhook delivery status in Paddle Dashboard diff --git a/drizzle/0005_bored_clea.sql b/drizzle/0005_bored_clea.sql new file mode 100644 index 0000000..6d1b9be --- /dev/null +++ b/drizzle/0005_bored_clea.sql @@ -0,0 +1,17 @@ +ALTER TABLE "subscription_usage" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint +DROP TABLE "subscription_usage" CASCADE;--> statement-breakpoint +ALTER TABLE "subscription_plans" ADD COLUMN "paddle_price_id" text;--> statement-breakpoint +ALTER TABLE "subscription_plans" ADD COLUMN "paddle_product_id" text;--> statement-breakpoint +ALTER TABLE "subscription_plans" ADD COLUMN "is_internal" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "user_subscriptions" ADD COLUMN "paddle_subscription_id" text;--> statement-breakpoint +ALTER TABLE "user_subscriptions" ADD COLUMN "paddle_customer_id" text;--> statement-breakpoint +ALTER TABLE "user_subscriptions" ADD COLUMN "paddle_transaction_id" text;--> statement-breakpoint +ALTER TABLE "users" ADD COLUMN "display_name" text;--> statement-breakpoint +ALTER TABLE "users" ADD COLUMN "profile_picture_url" text;--> statement-breakpoint +ALTER TABLE "subscription_plans" DROP COLUMN "features";--> statement-breakpoint +ALTER TABLE "users" DROP COLUMN "is_premium";--> statement-breakpoint +ALTER TABLE "users" DROP COLUMN "stripe_customer_id";--> statement-breakpoint +ALTER TABLE "users" DROP COLUMN "premium_since";--> statement-breakpoint +ALTER TABLE "users" DROP COLUMN "subscription_anniversary";--> statement-breakpoint +ALTER TABLE "subscription_plans" ADD CONSTRAINT "subscription_plans_paddle_price_id_unique" UNIQUE("paddle_price_id");--> statement-breakpoint +ALTER TABLE "user_subscriptions" ADD CONSTRAINT "user_subscriptions_paddle_subscription_id_unique" UNIQUE("paddle_subscription_id"); \ No newline at end of file diff --git a/drizzle/0006_fancy_tinkerer.sql b/drizzle/0006_fancy_tinkerer.sql new file mode 100644 index 0000000..74bbd67 --- /dev/null +++ b/drizzle/0006_fancy_tinkerer.sql @@ -0,0 +1,2 @@ +ALTER TABLE "collections" ADD COLUMN "cover_image_blurhash" text;--> statement-breakpoint +ALTER TABLE "users" ADD COLUMN "profile_picture_blurhash" text; \ No newline at end of file diff --git a/drizzle/0007_add_paddle_fields.sql b/drizzle/0007_add_paddle_fields.sql new file mode 100644 index 0000000..3410e6f --- /dev/null +++ b/drizzle/0007_add_paddle_fields.sql @@ -0,0 +1,15 @@ +-- Add Paddle fields to subscription_plans +ALTER TABLE subscription_plans + ADD COLUMN paddle_price_id TEXT UNIQUE, + ADD COLUMN paddle_product_id TEXT; + +-- Add Paddle fields to user_subscriptions +ALTER TABLE user_subscriptions + ADD COLUMN paddle_subscription_id TEXT UNIQUE, + ADD COLUMN paddle_customer_id TEXT, + ADD COLUMN paddle_transaction_id TEXT; + +-- Add indexes for performance +CREATE INDEX subscription_plans_paddle_price_idx ON subscription_plans(paddle_price_id); +CREATE INDEX user_subscriptions_paddle_sub_idx ON user_subscriptions(paddle_subscription_id); +CREATE INDEX user_subscriptions_paddle_customer_idx ON user_subscriptions(paddle_customer_id); diff --git a/drizzle/meta/0005_snapshot.json b/drizzle/meta/0005_snapshot.json new file mode 100644 index 0000000..301276a --- /dev/null +++ b/drizzle/meta/0005_snapshot.json @@ -0,0 +1,1571 @@ +{ + "id": "a39a73a3-3ca3-4238-a85f-e70887412406", + "prevId": "303d2069-efac-4c79-8cc6-4e127f79a679", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.audit_logs": { + "name": "audit_logs", + "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 + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_logs_user_id_idx": { + "name": "audit_logs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_logs_action_idx": { + "name": "audit_logs_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_logs_created_at_idx": { + "name": "audit_logs_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_logs_user_id_users_id_fk": { + "name": "audit_logs_user_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.books": { + "name": "books", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "isbn": { + "name": "isbn", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cover_url": { + "name": "cover_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pages": { + "name": "pages", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "published_year": { + "name": "published_year", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "books_isbn_idx": { + "name": "books_isbn_idx", + "columns": [ + { + "expression": "isbn", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "books_title_idx": { + "name": "books_title_idx", + "columns": [ + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "books_isbn_unique": { + "name": "books_isbn_unique", + "nullsNotDistinct": false, + "columns": [ + "isbn" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.collection_books": { + "name": "collection_books", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "collection_id": { + "name": "collection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_book_id": { + "name": "user_book_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "added_at": { + "name": "added_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "collection_books_collection_id_idx": { + "name": "collection_books_collection_id_idx", + "columns": [ + { + "expression": "collection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "collection_books_user_book_id_idx": { + "name": "collection_books_user_book_id_idx", + "columns": [ + { + "expression": "user_book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "collection_books_unique_idx": { + "name": "collection_books_unique_idx", + "columns": [ + { + "expression": "collection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "collection_books_collection_id_collections_id_fk": { + "name": "collection_books_collection_id_collections_id_fk", + "tableFrom": "collection_books", + "tableTo": "collections", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "collection_books_user_book_id_user_books_id_fk": { + "name": "collection_books_user_book_id_user_books_id_fk", + "tableFrom": "collection_books", + "tableTo": "user_books", + "columnsFrom": [ + "user_book_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.collection_follows": { + "name": "collection_follows", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "collection_id": { + "name": "collection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "collection_follows_user_id_idx": { + "name": "collection_follows_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "collection_follows_collection_id_idx": { + "name": "collection_follows_collection_id_idx", + "columns": [ + { + "expression": "collection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "collection_follows_user_id_users_id_fk": { + "name": "collection_follows_user_id_users_id_fk", + "tableFrom": "collection_follows", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "collection_follows_collection_id_collections_id_fk": { + "name": "collection_follows_collection_id_collections_id_fk", + "tableFrom": "collection_follows", + "tableTo": "collections", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "collection_follows_user_id_collection_id_pk": { + "name": "collection_follows_user_id_collection_id_pk", + "columns": [ + "user_id", + "collection_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.collections": { + "name": "collections", + "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 + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color_tag": { + "name": "color_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon_name": { + "name": "icon_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_image_url": { + "name": "cover_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "collections_user_id_idx": { + "name": "collections_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "collections_slug_idx": { + "name": "collections_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "collections_user_slug_idx": { + "name": "collections_user_slug_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "collections_is_public_idx": { + "name": "collections_is_public_idx", + "columns": [ + { + "expression": "is_public", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "collections_user_id_users_id_fk": { + "name": "collections_user_id_users_id_fk", + "tableFrom": "collections", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.follows": { + "name": "follows", + "schema": "", + "columns": { + "follower_id": { + "name": "follower_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "following_id": { + "name": "following_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "follows_follower_idx": { + "name": "follows_follower_idx", + "columns": [ + { + "expression": "follower_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "follows_following_idx": { + "name": "follows_following_idx", + "columns": [ + { + "expression": "following_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "follows_follower_id_users_id_fk": { + "name": "follows_follower_id_users_id_fk", + "tableFrom": "follows", + "tableTo": "users", + "columnsFrom": [ + "follower_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "follows_following_id_users_id_fk": { + "name": "follows_following_id_users_id_fk", + "tableFrom": "follows", + "tableTo": "users", + "columnsFrom": [ + "following_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "follows_follower_id_following_id_pk": { + "name": "follows_follower_id_following_id_pk", + "columns": [ + "follower_id", + "following_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reviews": { + "name": "reviews", + "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 + }, + "book_id": { + "name": "book_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_private": { + "name": "is_private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "reviews_user_id_idx": { + "name": "reviews_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reviews_book_id_idx": { + "name": "reviews_book_id_idx", + "columns": [ + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reviews_user_id_users_id_fk": { + "name": "reviews_user_id_users_id_fk", + "tableFrom": "reviews", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reviews_book_id_books_id_fk": { + "name": "reviews_book_id_books_id_fk", + "tableFrom": "reviews", + "tableTo": "books", + "columnsFrom": [ + "book_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription_plans": { + "name": "subscription_plans", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_price_id": { + "name": "stripe_price_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_product_id": { + "name": "stripe_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "paddle_price_id": { + "name": "paddle_price_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "paddle_product_id": { + "name": "paddle_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price": { + "name": "price", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "interval": { + "name": "interval", + "type": "billing_interval", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_internal": { + "name": "is_internal", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "subscription_plans_active_idx": { + "name": "subscription_plans_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "subscription_plans_sort_order_idx": { + "name": "subscription_plans_sort_order_idx", + "columns": [ + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "subscription_plans_stripe_price_id_unique": { + "name": "subscription_plans_stripe_price_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_price_id" + ] + }, + "subscription_plans_paddle_price_id_unique": { + "name": "subscription_plans_paddle_price_id_unique", + "nullsNotDistinct": false, + "columns": [ + "paddle_price_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_books": { + "name": "user_books", + "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 + }, + "book_id": { + "name": "book_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "reading_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'want'" + }, + "current_page": { + "name": "current_page", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "page_count": { + "name": "page_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "rating": { + "name": "rating", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_favorite": { + "name": "is_favorite", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_books_user_id_idx": { + "name": "user_books_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_books_book_id_idx": { + "name": "user_books_book_id_idx", + "columns": [ + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_books_status_idx": { + "name": "user_books_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_books_favorite_idx": { + "name": "user_books_favorite_idx", + "columns": [ + { + "expression": "is_favorite", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_books_user_book_idx": { + "name": "user_books_user_book_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_books_user_id_users_id_fk": { + "name": "user_books_user_id_users_id_fk", + "tableFrom": "user_books", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_books_book_id_books_id_fk": { + "name": "user_books_book_id_books_id_fk", + "tableFrom": "user_books", + "tableTo": "books", + "columnsFrom": [ + "book_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_subscriptions": { + "name": "user_subscriptions", + "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 + }, + "plan_id": { + "name": "plan_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "paddle_subscription_id": { + "name": "paddle_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "paddle_customer_id": { + "name": "paddle_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "paddle_transaction_id": { + "name": "paddle_transaction_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "subscription_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "current_period_start": { + "name": "current_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "current_period_end": { + "name": "current_period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_subscriptions_user_id_idx": { + "name": "user_subscriptions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_subscriptions_status_idx": { + "name": "user_subscriptions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_subscriptions_stripe_sub_idx": { + "name": "user_subscriptions_stripe_sub_idx", + "columns": [ + { + "expression": "stripe_subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_subscriptions_user_id_users_id_fk": { + "name": "user_subscriptions_user_id_users_id_fk", + "tableFrom": "user_subscriptions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_subscriptions_plan_id_subscription_plans_id_fk": { + "name": "user_subscriptions_plan_id_subscription_plans_id_fk", + "tableFrom": "user_subscriptions", + "tableTo": "subscription_plans", + "columnsFrom": [ + "plan_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_subscriptions_user_id_unique": { + "name": "user_subscriptions_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + }, + "user_subscriptions_stripe_subscription_id_unique": { + "name": "user_subscriptions_stripe_subscription_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_subscription_id" + ] + }, + "user_subscriptions_paddle_subscription_id_unique": { + "name": "user_subscriptions_paddle_subscription_id_unique", + "nullsNotDistinct": false, + "columns": [ + "paddle_subscription_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "profile_picture_url": { + "name": "profile_picture_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_email_idx": { + "name": "users_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_username_idx": { + "name": "users_username_idx", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_role_idx": { + "name": "users_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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 + } + }, + "enums": { + "public.billing_interval": { + "name": "billing_interval", + "schema": "public", + "values": [ + "month", + "year", + "lifetime", + "free" + ] + }, + "public.reading_status": { + "name": "reading_status", + "schema": "public", + "values": [ + "want", + "reading", + "finished" + ] + }, + "public.subscription_status": { + "name": "subscription_status", + "schema": "public", + "values": [ + "active", + "canceled", + "past_due", + "unpaid", + "trialing", + "incomplete" + ] + }, + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": [ + "user", + "moderator", + "admin" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0006_snapshot.json b/drizzle/meta/0006_snapshot.json new file mode 100644 index 0000000..830a885 --- /dev/null +++ b/drizzle/meta/0006_snapshot.json @@ -0,0 +1,1583 @@ +{ + "id": "ace6c342-f9d7-4881-b1fb-be8c1361d44b", + "prevId": "a39a73a3-3ca3-4238-a85f-e70887412406", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.audit_logs": { + "name": "audit_logs", + "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 + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_logs_user_id_idx": { + "name": "audit_logs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_logs_action_idx": { + "name": "audit_logs_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_logs_created_at_idx": { + "name": "audit_logs_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_logs_user_id_users_id_fk": { + "name": "audit_logs_user_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.books": { + "name": "books", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "isbn": { + "name": "isbn", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cover_url": { + "name": "cover_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pages": { + "name": "pages", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "published_year": { + "name": "published_year", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "books_isbn_idx": { + "name": "books_isbn_idx", + "columns": [ + { + "expression": "isbn", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "books_title_idx": { + "name": "books_title_idx", + "columns": [ + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "books_isbn_unique": { + "name": "books_isbn_unique", + "nullsNotDistinct": false, + "columns": [ + "isbn" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.collection_books": { + "name": "collection_books", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "collection_id": { + "name": "collection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_book_id": { + "name": "user_book_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "added_at": { + "name": "added_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "collection_books_collection_id_idx": { + "name": "collection_books_collection_id_idx", + "columns": [ + { + "expression": "collection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "collection_books_user_book_id_idx": { + "name": "collection_books_user_book_id_idx", + "columns": [ + { + "expression": "user_book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "collection_books_unique_idx": { + "name": "collection_books_unique_idx", + "columns": [ + { + "expression": "collection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "collection_books_collection_id_collections_id_fk": { + "name": "collection_books_collection_id_collections_id_fk", + "tableFrom": "collection_books", + "tableTo": "collections", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "collection_books_user_book_id_user_books_id_fk": { + "name": "collection_books_user_book_id_user_books_id_fk", + "tableFrom": "collection_books", + "tableTo": "user_books", + "columnsFrom": [ + "user_book_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.collection_follows": { + "name": "collection_follows", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "collection_id": { + "name": "collection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "collection_follows_user_id_idx": { + "name": "collection_follows_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "collection_follows_collection_id_idx": { + "name": "collection_follows_collection_id_idx", + "columns": [ + { + "expression": "collection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "collection_follows_user_id_users_id_fk": { + "name": "collection_follows_user_id_users_id_fk", + "tableFrom": "collection_follows", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "collection_follows_collection_id_collections_id_fk": { + "name": "collection_follows_collection_id_collections_id_fk", + "tableFrom": "collection_follows", + "tableTo": "collections", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "collection_follows_user_id_collection_id_pk": { + "name": "collection_follows_user_id_collection_id_pk", + "columns": [ + "user_id", + "collection_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.collections": { + "name": "collections", + "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 + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color_tag": { + "name": "color_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon_name": { + "name": "icon_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_image_url": { + "name": "cover_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_image_blurhash": { + "name": "cover_image_blurhash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "collections_user_id_idx": { + "name": "collections_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "collections_slug_idx": { + "name": "collections_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "collections_user_slug_idx": { + "name": "collections_user_slug_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "collections_is_public_idx": { + "name": "collections_is_public_idx", + "columns": [ + { + "expression": "is_public", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "collections_user_id_users_id_fk": { + "name": "collections_user_id_users_id_fk", + "tableFrom": "collections", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.follows": { + "name": "follows", + "schema": "", + "columns": { + "follower_id": { + "name": "follower_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "following_id": { + "name": "following_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "follows_follower_idx": { + "name": "follows_follower_idx", + "columns": [ + { + "expression": "follower_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "follows_following_idx": { + "name": "follows_following_idx", + "columns": [ + { + "expression": "following_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "follows_follower_id_users_id_fk": { + "name": "follows_follower_id_users_id_fk", + "tableFrom": "follows", + "tableTo": "users", + "columnsFrom": [ + "follower_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "follows_following_id_users_id_fk": { + "name": "follows_following_id_users_id_fk", + "tableFrom": "follows", + "tableTo": "users", + "columnsFrom": [ + "following_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "follows_follower_id_following_id_pk": { + "name": "follows_follower_id_following_id_pk", + "columns": [ + "follower_id", + "following_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reviews": { + "name": "reviews", + "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 + }, + "book_id": { + "name": "book_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_private": { + "name": "is_private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "reviews_user_id_idx": { + "name": "reviews_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reviews_book_id_idx": { + "name": "reviews_book_id_idx", + "columns": [ + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reviews_user_id_users_id_fk": { + "name": "reviews_user_id_users_id_fk", + "tableFrom": "reviews", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reviews_book_id_books_id_fk": { + "name": "reviews_book_id_books_id_fk", + "tableFrom": "reviews", + "tableTo": "books", + "columnsFrom": [ + "book_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription_plans": { + "name": "subscription_plans", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_price_id": { + "name": "stripe_price_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_product_id": { + "name": "stripe_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "paddle_price_id": { + "name": "paddle_price_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "paddle_product_id": { + "name": "paddle_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price": { + "name": "price", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "interval": { + "name": "interval", + "type": "billing_interval", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_internal": { + "name": "is_internal", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "subscription_plans_active_idx": { + "name": "subscription_plans_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "subscription_plans_sort_order_idx": { + "name": "subscription_plans_sort_order_idx", + "columns": [ + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "subscription_plans_stripe_price_id_unique": { + "name": "subscription_plans_stripe_price_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_price_id" + ] + }, + "subscription_plans_paddle_price_id_unique": { + "name": "subscription_plans_paddle_price_id_unique", + "nullsNotDistinct": false, + "columns": [ + "paddle_price_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_books": { + "name": "user_books", + "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 + }, + "book_id": { + "name": "book_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "reading_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'want'" + }, + "current_page": { + "name": "current_page", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "page_count": { + "name": "page_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "rating": { + "name": "rating", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_favorite": { + "name": "is_favorite", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_books_user_id_idx": { + "name": "user_books_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_books_book_id_idx": { + "name": "user_books_book_id_idx", + "columns": [ + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_books_status_idx": { + "name": "user_books_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_books_favorite_idx": { + "name": "user_books_favorite_idx", + "columns": [ + { + "expression": "is_favorite", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_books_user_book_idx": { + "name": "user_books_user_book_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_books_user_id_users_id_fk": { + "name": "user_books_user_id_users_id_fk", + "tableFrom": "user_books", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_books_book_id_books_id_fk": { + "name": "user_books_book_id_books_id_fk", + "tableFrom": "user_books", + "tableTo": "books", + "columnsFrom": [ + "book_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_subscriptions": { + "name": "user_subscriptions", + "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 + }, + "plan_id": { + "name": "plan_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "paddle_subscription_id": { + "name": "paddle_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "paddle_customer_id": { + "name": "paddle_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "paddle_transaction_id": { + "name": "paddle_transaction_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "subscription_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "current_period_start": { + "name": "current_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "current_period_end": { + "name": "current_period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_subscriptions_user_id_idx": { + "name": "user_subscriptions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_subscriptions_status_idx": { + "name": "user_subscriptions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_subscriptions_stripe_sub_idx": { + "name": "user_subscriptions_stripe_sub_idx", + "columns": [ + { + "expression": "stripe_subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_subscriptions_user_id_users_id_fk": { + "name": "user_subscriptions_user_id_users_id_fk", + "tableFrom": "user_subscriptions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_subscriptions_plan_id_subscription_plans_id_fk": { + "name": "user_subscriptions_plan_id_subscription_plans_id_fk", + "tableFrom": "user_subscriptions", + "tableTo": "subscription_plans", + "columnsFrom": [ + "plan_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_subscriptions_user_id_unique": { + "name": "user_subscriptions_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + }, + "user_subscriptions_stripe_subscription_id_unique": { + "name": "user_subscriptions_stripe_subscription_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_subscription_id" + ] + }, + "user_subscriptions_paddle_subscription_id_unique": { + "name": "user_subscriptions_paddle_subscription_id_unique", + "nullsNotDistinct": false, + "columns": [ + "paddle_subscription_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "profile_picture_url": { + "name": "profile_picture_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "profile_picture_blurhash": { + "name": "profile_picture_blurhash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_email_idx": { + "name": "users_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_username_idx": { + "name": "users_username_idx", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_role_idx": { + "name": "users_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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 + } + }, + "enums": { + "public.billing_interval": { + "name": "billing_interval", + "schema": "public", + "values": [ + "month", + "year", + "lifetime", + "free" + ] + }, + "public.reading_status": { + "name": "reading_status", + "schema": "public", + "values": [ + "want", + "reading", + "finished" + ] + }, + "public.subscription_status": { + "name": "subscription_status", + "schema": "public", + "values": [ + "active", + "canceled", + "past_due", + "unpaid", + "trialing", + "incomplete" + ] + }, + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": [ + "user", + "moderator", + "admin" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index be1ba97..f1d5382 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -36,6 +36,20 @@ "when": 1764502121477, "tag": "0004_grey_anita_blake", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1781977206868, + "tag": "0005_bored_clea", + "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1781981853261, + "tag": "0006_fancy_tinkerer", + "breakpoints": true } ] } \ No newline at end of file diff --git a/messages/en.json b/messages/en.json index 1771aa3..bf7e5be 100644 --- a/messages/en.json +++ b/messages/en.json @@ -21,6 +21,7 @@ "findUsers": "Find Users", "profile": "Profile", "signIn": "Sign In", + "signOut": "Sign Out", "premium": "Premium", "admin": "Admin", "pricing": "Pricing", @@ -212,6 +213,27 @@ "noResults": "No users found. Try a different search term.", "searchPrompt": "Enter a username or email to search for users." }, + "auth": { + "signIn": "Sign In", + "signUp": "Sign Up", + "email": "Email", + "password": "Password", + "confirmPassword": "Confirm Password", + "emailPlaceholder": "Enter your email", + "passwordPlaceholder": "Enter your password", + "continue": "Continue", + "verify": "Verify", + "verifyEmail": "Verify your email", + "enterCode": "Enter the 6-digit code sent to your email", + "noAccount": "Don't have an account?", + "haveAccount": "Already have an account?", + "signInWithGoogle": "Continue with Google", + "signInWithApple": "Continue with Apple", + "or": "or", + "invalidEmail": "Please enter a valid email", + "passwordsDontMatch": "Passwords don't match", + "allFieldsRequired": "All fields are required" + }, "toast": { "bookAdded": "Added \"{title}\" to your library", "bookStatusUpdated": "Book status updated", diff --git a/next.config.ts b/next.config.ts index f486c12..d1fbafb 100644 --- a/next.config.ts +++ b/next.config.ts @@ -3,18 +3,25 @@ import createNextIntlPlugin from "next-intl/plugin"; const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts"); +const baseUrl = new URL(process.env.NEXT_PUBLIC_BASE_URL ?? "http://localhost:3000"); + const nextConfig: NextConfig = { - /* config options here */ output: "standalone", reactCompiler: true, images: { remotePatterns: [ { protocol: "https", - hostname: "covers.openlibrary.org" - } - ] - } + hostname: "covers.openlibrary.org", + }, + { + protocol: baseUrl.protocol.replace(":", "") as "http" | "https", + hostname: baseUrl.hostname, + ...(baseUrl.port ? { port: baseUrl.port } : {}), + pathname: "/api/cdn/**", + }, + ], + }, }; export default withNextIntl(nextConfig); diff --git a/package.json b/package.json index 5a309aa..cdd4196 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@aws-sdk/s3-request-presigner": "^3.940.0", "@clerk/backend": "^2.24.0", "@clerk/nextjs": "^6.35.5", + "@paddle/paddle-node-sdk": "^3.5.0", "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", @@ -36,12 +37,14 @@ "@radix-ui/react-tabs": "^1.1.13", "@stripe/stripe-js": "^8.5.3", "@tanstack/react-virtual": "^3.13.12", + "blurhash": "^2.0.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^4.1.0", "dotenv": "^17.2.3", "drizzle-orm": "^0.44.7", + "input-otp": "^1.4.2", "lucide-react": "^0.555.0", "next": "16.0.5", "next-intl": "^4.5.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 09d49ff..d3565ef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: '@clerk/nextjs': specifier: ^6.35.5 version: 6.35.5(next@16.0.5(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@paddle/paddle-node-sdk': + specifier: ^3.5.0 + version: 3.5.0 '@radix-ui/react-avatar': specifier: ^1.1.11 version: 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -68,6 +71,9 @@ importers: '@tanstack/react-virtual': specifier: ^3.13.12 version: 3.13.12(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + blurhash: + specifier: ^2.0.5 + version: 2.0.5 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -86,6 +92,9 @@ importers: drizzle-orm: specifier: ^0.44.7 version: 0.44.7(postgres@3.4.7) + input-otp: + specifier: ^1.4.2 + version: 1.4.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0) lucide-react: specifier: ^0.555.0 version: 0.555.0(react@19.2.0) @@ -408,6 +417,7 @@ packages: '@clerk/clerk-react@5.57.0': resolution: {integrity: sha512-GCBFF03HjEWvx58myjauJ7NrwTqhxHdetjWWxVM3YJGPOsAVXg4WuquL/hyn8KDuduCYSkRin4Hg6+QVP1NXAg==} engines: {node: '>=18.17.0'} + deprecated: 'This package is no longer supported. Please use @clerk/react instead. See the upgrade guide for more info: https://clerk.com/docs/guides/development/upgrading/upgrade-guides/core-3' peerDependencies: react: ^18.0.0 || ^19.0.0 || ^19.0.0-0 react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-0 @@ -435,6 +445,7 @@ packages: '@clerk/types@4.101.3': resolution: {integrity: sha512-QkYSiR8EDjLhQ3K9aCZ323knzZQggzhi3qxSdFrtI/C8Osyytua3Bu4TOGGRgYSSD4VO3s8WUz3wQf4Qe0ps/g==} engines: {node: '>=18.17.0'} + deprecated: 'This package is no longer supported. Please import types from @clerk/shared/types instead. See the upgrade guide for more info: https://clerk.com/docs/guides/development/upgrading/upgrade-guides/core-3' '@drizzle-team/brocli@0.10.2': resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} @@ -861,89 +872,105 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -1010,24 +1037,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@next/swc-linux-arm64-musl@16.0.5': resolution: {integrity: sha512-aAJtQkvUzz5t0xVAmK931SIhWnSQAaEoTyG/sKPCYq2u835K/E4a14A+WRPd4dkhxIHNudE8dI+FpHekgdrA4g==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@next/swc-linux-x64-gnu@16.0.5': resolution: {integrity: sha512-bYwbjBwooMWRhy6vRxenaYdguTM2hlxFt1QBnUF235zTnU2DhGpETm5WU93UvtAy0uhC5Kgqsl8RyNXlprFJ6Q==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@next/swc-linux-x64-musl@16.0.5': resolution: {integrity: sha512-iGv2K/4gW3mkzh+VcZTf2gEGX5o9xdb5oPqHjgZvHdVzCw0iSAJ7n9vKzl3SIEIIHZmqRsgNasgoLd0cxaD+tg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@next/swc-win32-arm64-msvc@16.0.5': resolution: {integrity: sha512-6xf52Hp4SH9+4jbYmfUleqkuxvdB9JJRwwFlVG38UDuEGPqpIA+0KiJEU9lxvb0RGNo2i2ZUhc5LHajij9H9+A==} @@ -1057,6 +1088,10 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} + '@paddle/paddle-node-sdk@3.5.0': + resolution: {integrity: sha512-/Ac1N5i01/YJhiqOKlfW+Rz+nfhkaCyqpkG2P5ByeUNERNFYbAOkNc/TaXO/Nhgf9r3PoatnXH6YSpEs4KdtjA==} + engines: {node: '>=20'} + '@radix-ui/number@1.1.1': resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} @@ -1796,24 +1831,28 @@ packages: engines: {node: '>=10'} cpu: [arm64] os: [linux] + libc: [glibc] '@swc/core-linux-arm64-musl@1.15.3': resolution: {integrity: sha512-j4SJniZ/qaZ5g8op+p1G9K1z22s/EYGg1UXIb3+Cg4nsxEpF5uSIGEE4mHUfA70L0BR9wKT2QF/zv3vkhfpX4g==} engines: {node: '>=10'} cpu: [arm64] os: [linux] + libc: [musl] '@swc/core-linux-x64-gnu@1.15.3': resolution: {integrity: sha512-aKttAZnz8YB1VJwPQZtyU8Uk0BfMP63iDMkvjhJzRZVgySmqt/apWSdnoIcZlUoGheBrcqbMC17GGUmur7OT5A==} engines: {node: '>=10'} cpu: [x64] os: [linux] + libc: [glibc] '@swc/core-linux-x64-musl@1.15.3': resolution: {integrity: sha512-oe8FctPu1gnUsdtGJRO2rvOUIkkIIaHqsO9xxN0bTR7dFTlPTGi2Fhk1tnvXeyAvCPxLIcwD8phzKg6wLv9yug==} engines: {node: '>=10'} cpu: [x64] os: [linux] + libc: [musl] '@swc/core-win32-arm64-msvc@1.15.3': resolution: {integrity: sha512-L9AjzP2ZQ/Xh58e0lTRMLvEDrcJpR7GwZqAtIeNLcTK7JVE+QineSyHp0kLkO1rttCHyCy0U74kDTj0dRz6raA==} @@ -1889,24 +1928,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.1.17': resolution: {integrity: sha512-HvZLfGr42i5anKtIeQzxdkw/wPqIbpeZqe7vd3V9vI3RQxe3xU1fLjss0TjyhxWcBaipk7NYwSrwTwK1hJARMg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.1.17': resolution: {integrity: sha512-M3XZuORCGB7VPOEDH+nzpJ21XPvK5PyjlkSFkFziNHGLc5d6g3di2McAAblmaSUNl8IOmzYwLx9NsE7bplNkwQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.1.17': resolution: {integrity: sha512-k7f+pf9eXLEey4pBlw+8dgfJHY4PZ5qOUFDyNf7SI6lHjQ9Zt7+NcscjpwdCEbYi6FI5c2KDTDWyf2iHcCSyyQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.1.17': resolution: {integrity: sha512-cEytGqSSoy7zK4JRWiTCx43FsKP/zGr0CsuMawhH67ONlH+T79VteQeJQRO/X7L0juEUA8ZyuYikcRBf0vsxhg==} @@ -2099,41 +2142,49 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] + libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -2244,6 +2295,9 @@ packages: resolution: {integrity: sha512-OPz5aBThlyLFgxyhdwf/s2+8ab3OvT7AdTNvKHBwpXomIYeXqpUUuT8LrdtxZSsWJ4R4CU1un4XGh5Ez3nlTpw==} hasBin: true + blurhash@2.0.5: + resolution: {integrity: sha512-cRygWd7kGBQO3VEhPiTgq4Wc43ctsM+o46urrmPOiuAe+07fzlSB9OJVdpgDL0jPqXUVQ9ht7aq7kxOeJHRK+w==} + bowser@2.13.1: resolution: {integrity: sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw==} @@ -2914,6 +2968,12 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + input-otp@1.4.2: + resolution: {integrity: sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + internal-slot@1.1.0: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} @@ -3129,24 +3189,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.30.2: resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.30.2: resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.30.2: resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.30.2: resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} @@ -3247,6 +3311,7 @@ packages: next@16.0.5: resolution: {integrity: sha512-XUPsFqSqu/NDdPfn/cju9yfIedkDI7ytDoALD9todaSMxk1Z5e3WcbUjfI9xsanFTys7xz62lnRWNFqJordzkQ==} engines: {node: '>=20.9.0'} + deprecated: This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/CVE-2025-66478 for more details. hasBin: true peerDependencies: '@opentelemetry/api': ^1.1.0 @@ -3445,6 +3510,7 @@ packages: recharts@2.15.4: resolution: {integrity: sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==} engines: {node: '>=14'} + deprecated: 1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide peerDependencies: react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -3783,6 +3849,7 @@ packages: uuid@10.0.0: resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true victory-vendor@36.9.2: @@ -4924,6 +4991,8 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} + '@paddle/paddle-node-sdk@3.5.0': {} + '@radix-ui/number@1.1.1': {} '@radix-ui/primitive@1.1.3': {} @@ -6204,6 +6273,8 @@ snapshots: baseline-browser-mapping@2.8.32: {} + blurhash@2.0.5: {} + bowser@2.13.1: {} brace-expansion@1.1.12: @@ -6966,6 +7037,11 @@ snapshots: imurmurhash@0.1.4: {} + input-otp@1.4.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + dependencies: + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + internal-slot@1.1.0: dependencies: es-errors: 1.3.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..0653691 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,6 @@ +allowBuilds: + '@clerk/shared': set this to true or false + '@swc/core': set this to true or false + esbuild: set this to true or false + sharp: set this to true or false + unrs-resolver: set this to true or false diff --git a/public/apple-icon-dark.png b/public/apple-icon-dark.png new file mode 100644 index 0000000..643da8e Binary files /dev/null and b/public/apple-icon-dark.png differ diff --git a/public/apple-icon-light.png b/public/apple-icon-light.png new file mode 100644 index 0000000..a485be0 Binary files /dev/null and b/public/apple-icon-light.png differ diff --git a/public/google-icon.png b/public/google-icon.png new file mode 100644 index 0000000..d4d8878 Binary files /dev/null and b/public/google-icon.png differ diff --git a/src/app/[locale]/(auth)/layout.tsx b/src/app/[locale]/(auth)/layout.tsx new file mode 100644 index 0000000..3b23162 --- /dev/null +++ b/src/app/[locale]/(auth)/layout.tsx @@ -0,0 +1,23 @@ +import { auth } from "@clerk/nextjs/server"; +import { redirect } from "next/navigation"; + +export default async function AuthLayout({ + children, +}: { + children: React.ReactNode; +}) { + const { userId } = await auth(); + + // Redirect authenticated users to library + if (userId) { + redirect("/library"); + } + + return ( +
+
+ {children} +
+
+ ); +} diff --git a/src/app/[locale]/(auth)/sign-in/page.tsx b/src/app/[locale]/(auth)/sign-in/page.tsx new file mode 100644 index 0000000..19fc0a1 --- /dev/null +++ b/src/app/[locale]/(auth)/sign-in/page.tsx @@ -0,0 +1,220 @@ +"use client"; + +import { useState, FormEvent } from "react"; +import { useSignIn } from "@clerk/nextjs"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { toast } from "sonner"; +import Image from "next/image"; +import { Link } from "@/i18n/routing"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { OAuthButton } from "@/components/oauth-button"; +import { + InputOTP, + InputOTPGroup, + InputOTPSlot, +} from "@/components/ui/input-otp"; + +export default function SignInPage() { + const { isLoaded, signIn, setActive } = useSignIn(); + const router = useRouter(); + const t = useTranslations("auth"); + + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [code, setCode] = useState(""); + const [isVerifying, setIsVerifying] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + const validateEmail = (email: string) => { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email); + }; + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + + if (!isLoaded || !signIn) return; + + // Validation + if (!email || !password) { + toast.error(t("allFieldsRequired")); + return; + } + + if (!validateEmail(email)) { + toast.error(t("invalidEmail")); + return; + } + + setIsLoading(true); + + try { + const signInAttempt = await signIn.create({ + identifier: email, + password, + }); + + if (signInAttempt.status === "complete") { + await setActive({ session: signInAttempt.createdSessionId }); + router.push("/library"); + } else if (signInAttempt.status === "needs_second_factor") { + // Prepare email verification + await signInAttempt.prepareSecondFactor({ strategy: "email_code" }); + setIsVerifying(true); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (err: any) { + console.error("Sign-in error:", err); + const errorMessage = err?.errors?.[0]?.message || "An error occurred during sign-in"; + toast.error(errorMessage); + } finally { + setIsLoading(false); + } + }; + + const handleVerify = async (e: FormEvent) => { + e.preventDefault(); + + if (!isLoaded || !signIn) return; + + if (!code || code.length !== 6) { + toast.error("Please enter the 6-digit code"); + return; + } + + setIsLoading(true); + + try { + const signInAttempt = await signIn.attemptSecondFactor({ + strategy: "email_code", + code, + }); + + if (signInAttempt.status === "complete") { + await setActive({ session: signInAttempt.createdSessionId }); + router.push("/library"); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (err: any) { + console.error("Verification error:", err); + const errorMessage = err?.errors?.[0]?.message || "Invalid verification code"; + toast.error(errorMessage); + } finally { + setIsLoading(false); + } + }; + + if (!isLoaded) { + return null; + } + + if (isVerifying) { + return ( +
+
+ Echo +
+ +
+
+

{t("verifyEmail")}

+

{t("enterCode")}

+
+ +
+
+ setCode(value)} + > + + + + + + + + + +
+ + +
+
+
+ ); + } + + return ( +
+
+ Echo +
+ +
+
+ + {t("signInWithGoogle")} + + + {t("signInWithApple")} + +
+ +
+
+ +
+
+ + {t("or")} + +
+
+ +
+
+ + setEmail(e.target.value)} + disabled={isLoading} + /> +
+ +
+ + setPassword(e.target.value)} + disabled={isLoading} + /> +
+ + +
+ +

+ {t("noAccount")}{" "} + + {t("signUp")} + +

+
+
+ ); +} diff --git a/src/app/[locale]/(auth)/sign-up/page.tsx b/src/app/[locale]/(auth)/sign-up/page.tsx new file mode 100644 index 0000000..df46101 --- /dev/null +++ b/src/app/[locale]/(auth)/sign-up/page.tsx @@ -0,0 +1,232 @@ +"use client"; + +import { useState, FormEvent } from "react"; +import { useSignUp } from "@clerk/nextjs"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { toast } from "sonner"; +import Image from "next/image"; +import { Link } from "@/i18n/routing"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { OAuthButton } from "@/components/oauth-button"; +import { + InputOTP, + InputOTPGroup, + InputOTPSlot, +} from "@/components/ui/input-otp"; + +export default function SignUpPage() { + const { isLoaded, signUp, setActive } = useSignUp(); + const router = useRouter(); + const t = useTranslations("auth"); + + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [code, setCode] = useState(""); + const [isVerifying, setIsVerifying] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + const validateEmail = (email: string) => { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email); + }; + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + + if (!isLoaded || !signUp) return; + + // Validation + if (!email || !password || !confirmPassword) { + toast.error(t("allFieldsRequired")); + return; + } + + if (!validateEmail(email)) { + toast.error(t("invalidEmail")); + return; + } + + if (password !== confirmPassword) { + toast.error(t("passwordsDontMatch")); + return; + } + + setIsLoading(true); + + try { + await signUp.create({ + emailAddress: email, + password, + }); + + // Prepare email verification + await signUp.prepareEmailAddressVerification({ strategy: "email_code" }); + setIsVerifying(true); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (err: any) { + console.error("Sign-up error:", err); + const errorMessage = err?.errors?.[0]?.message || "An error occurred during sign-up"; + toast.error(errorMessage); + } finally { + setIsLoading(false); + } + }; + + const handleVerify = async (e: FormEvent) => { + e.preventDefault(); + + if (!isLoaded || !signUp) return; + + if (!code || code.length !== 6) { + toast.error("Please enter the 6-digit code"); + return; + } + + setIsLoading(true); + + try { + const completeSignUp = await signUp.attemptEmailAddressVerification({ + code, + }); + + if (completeSignUp.status === "complete") { + await setActive({ session: completeSignUp.createdSessionId }); + router.push("/library"); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (err: any) { + console.error("Verification error:", err); + const errorMessage = err?.errors?.[0]?.message || "Invalid verification code"; + toast.error(errorMessage); + } finally { + setIsLoading(false); + } + }; + + if (!isLoaded) { + return null; + } + + if (isVerifying) { + return ( +
+
+ Echo +
+ +
+
+

{t("verifyEmail")}

+

{t("enterCode")}

+
+ +
+
+ setCode(value)} + > + + + + + + + + + +
+ + +
+
+
+ ); + } + + return ( +
+
+ Echo +
+ +
+
+ + {t("signInWithGoogle")} + + + {t("signInWithApple")} + +
+ +
+
+ +
+
+ + {t("or")} + +
+
+ +
+
+ + setEmail(e.target.value)} + disabled={isLoading} + /> +
+ +
+ + setPassword(e.target.value)} + disabled={isLoading} + /> +
+ +
+ + setConfirmPassword(e.target.value)} + disabled={isLoading} + /> +
+ + +
+ +

+ {t("haveAccount")}{" "} + + {t("signIn")} + +

+
+
+ ); +} diff --git a/src/app/[locale]/actions/admin.ts b/src/app/[locale]/actions/admin.ts index 28defbc..acf6cc6 100644 --- a/src/app/[locale]/actions/admin.ts +++ b/src/app/[locale]/actions/admin.ts @@ -263,8 +263,8 @@ export async function deleteUserAsAdmin(targetUserId: string) { */ export async function createSubscriptionPlan(data: { name: string; - stripePriceId: string | null; - stripeProductId: string | null; + paddlePriceId: string | null; + paddleProductId: string | null; price: number; interval: "month" | "year" | "lifetime" | "free"; isActive: boolean; @@ -273,14 +273,6 @@ export async function createSubscriptionPlan(data: { const currentUser = await requireRole(["admin"]); try { - // Validate Stripe IDs if provided - if (data.stripePriceId) { - const price = await stripe.prices.retrieve(data.stripePriceId); - if (!price) { - return { error: "Invalid Stripe Price ID" }; - } - } - const [plan] = await db .insert(subscriptionPlans) .values(data) @@ -310,8 +302,8 @@ export async function updateSubscriptionPlan( planId: string, data: Partial<{ name: string; - stripePriceId: string | null; - stripeProductId: string | null; + paddlePriceId: string | null; + paddleProductId: string | null; price: number; interval: "month" | "year" | "lifetime" | "free"; isActive: boolean; @@ -322,14 +314,6 @@ export async function updateSubscriptionPlan( const currentUser = await requireRole(["admin"]); try { - // Validate Stripe IDs if provided - if (data.stripePriceId) { - const price = await stripe.prices.retrieve(data.stripePriceId); - if (!price) { - return { error: "Invalid Stripe Price ID" }; - } - } - await db .update(subscriptionPlans) .set({ ...data, updatedAt: new Date() }) diff --git a/src/app/[locale]/actions/collections.ts b/src/app/[locale]/actions/collections.ts index 4cb0126..bc8eeb1 100644 --- a/src/app/[locale]/actions/collections.ts +++ b/src/app/[locale]/actions/collections.ts @@ -132,6 +132,7 @@ export async function updateCollection( colorTag?: string; iconName?: string; coverImageUrl?: string; + coverImageBlurhash?: string; } ) { const { userId } = await auth(); diff --git a/src/app/[locale]/actions/profile.ts b/src/app/[locale]/actions/profile.ts index e8e69a9..152efe7 100644 --- a/src/app/[locale]/actions/profile.ts +++ b/src/app/[locale]/actions/profile.ts @@ -15,7 +15,8 @@ export async function updateProfile( username: string, bio: string, displayName?: string, - profilePictureUrl?: string + profilePictureUrl?: string, + profilePictureBlurhash?: string ) { const { userId } = await auth(); @@ -80,6 +81,7 @@ export async function updateProfile( bio: bio || null, displayName: displayName || null, profilePictureUrl: profilePictureUrl || null, + profilePictureBlurhash: profilePictureBlurhash || null, }) .onConflictDoUpdate({ target: users.id, @@ -89,6 +91,7 @@ export async function updateProfile( bio: bio || null, displayName: displayName || null, profilePictureUrl: profilePictureUrl || null, + profilePictureBlurhash: profilePictureBlurhash || null, updatedAt: new Date(), }, }); diff --git a/src/app/[locale]/actions/subscriptions.ts b/src/app/[locale]/actions/subscriptions.ts index 4392990..43b0047 100644 --- a/src/app/[locale]/actions/subscriptions.ts +++ b/src/app/[locale]/actions/subscriptions.ts @@ -5,16 +5,19 @@ import { db } from "@/db"; import { users, userSubscriptions, subscriptionPlans } from "@/db/schema"; import { eq, and } from "drizzle-orm"; import { revalidatePath } from "next/cache"; -import Stripe from "stripe"; +import { Paddle, Environment } from "@paddle/paddle-node-sdk"; -const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { - apiVersion: "2025-11-17.clover", +const paddle = new Paddle(process.env.PADDLE_API_KEY!, { + environment: + process.env.PADDLE_ENVIRONMENT === "production" + ? Environment.production + : Environment.sandbox, }); /** - * Create a Stripe Checkout session for subscription upgrade + * Create a Paddle Checkout transaction for subscription upgrade */ -export async function createCheckoutSession(planId: string) { +export async function createPaddleCheckout(planId: string) { const { userId } = await auth(); if (!userId) return { error: "Unauthorized" }; @@ -32,78 +35,90 @@ export async function createCheckoutSession(planId: string) { return { error: "Plan not found" }; } - if (!plan.stripePriceId) { + if (!plan.paddlePriceId) { return { error: "This plan does not require checkout" }; } - // Get or create Stripe customer ID - // Check if user has existing subscription with customer ID + // Get or create Paddle customer const existingSubscription = await db.query.userSubscriptions.findFirst({ where: eq(userSubscriptions.userId, userId), }); - let customerId = existingSubscription?.stripeCustomerId; + let customerId = existingSubscription?.paddleCustomerId; if (!customerId) { - // Create new Stripe customer (will be saved to userSubscriptions by webhook) - const customer = await stripe.customers.create({ - email: user?.email, - metadata: { userId }, - }); - customerId = customer.id; + try { + // Try to create new Paddle customer + const customer = await paddle.customers.create({ + email: user?.email ?? "", + customData: { userId }, + }); + customerId = customer.id; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (customerError: any) { + // If customer already exists, extract customer ID from error message + if (customerError.code === "customer_already_exists") { + console.log("Customer already exists, extracting ID from error"); + // Error message format: "customer email conflicts with customer of id ctm_xxx" + const match = customerError.detail?.match(/customer of id (ctm_[a-z0-9]+)/i); + if (match && match[1]) { + customerId = match[1]; + console.log("Using existing customer ID from error:", customerId); + } else { + console.error("Could not extract customer ID from error:", customerError.detail); + return { error: "Customer already exists but ID could not be determined" }; + } + } else { + throw customerError; + } + } } - // Create checkout session - const session = await stripe.checkout.sessions.create({ - customer: customerId, - mode: "subscription", - payment_method_types: ["card"], - line_items: [ - { - price: plan.stripePriceId, - quantity: 1, - }, - ], - success_url: `${process.env.NEXT_PUBLIC_BASE_URL}/subscription/success?session_id={CHECKOUT_SESSION_ID}`, - cancel_url: `${process.env.NEXT_PUBLIC_BASE_URL}/subscription/canceled`, - metadata: { - userId, - planId, - }, + if (!customerId) { + return { error: "Failed to get or create customer" }; + } + + // Create checkout transaction + const transaction = await paddle.transactions.create({ + items: [{ priceId: plan.paddlePriceId, quantity: 1 }], + customerId, + customData: { userId, planId }, }); - return { sessionId: session.id, url: session.url }; + return { + transactionId: transaction.id, + checkoutUrl: transaction.checkout?.url, + }; } catch (error) { - console.error("Error creating checkout session:", error); - return { error: "Failed to create checkout session" }; + console.error("Error creating Paddle checkout:", error); + return { error: "Failed to create checkout" }; } } /** - * Create a Stripe Customer Portal session + * Cancel subscription (replaces Stripe Customer Portal) */ -export async function createPortalSession() { +export async function cancelSubscription() { const { userId } = await auth(); if (!userId) return { error: "Unauthorized" }; try { - // Get user's subscription to find Stripe customer ID const subscription = await db.query.userSubscriptions.findFirst({ where: eq(userSubscriptions.userId, userId), }); - if (!subscription?.stripeCustomerId) { - return { error: "No Stripe customer found" }; + if (!subscription?.paddleSubscriptionId) { + return { error: "No subscription found" }; } - const session = await stripe.billingPortal.sessions.create({ - customer: subscription.stripeCustomerId, - return_url: `${process.env.NEXT_PUBLIC_BASE_URL}/subscription`, + await paddle.subscriptions.cancel(subscription.paddleSubscriptionId, { + effectiveFrom: "next_billing_period", }); - return { url: session.url }; + revalidatePath("/subscription"); + return { success: true }; } catch (error) { - console.error("Error creating portal session:", error); - return { error: "Failed to create portal session" }; + console.error("Error canceling subscription:", error); + return { error: "Failed to cancel subscription" }; } } @@ -175,8 +190,8 @@ export async function upgradeToFreePlan(planId: string) { return { error: "Plan not found" }; } - // Verify it's actually a free plan (no Stripe price ID) - if (plan.stripePriceId) { + // Verify it's actually a free plan (no Paddle price ID) + if (plan.paddlePriceId) { return { error: "This plan requires payment" }; } diff --git a/src/app/[locale]/feed/page.tsx b/src/app/[locale]/feed/page.tsx index a55d155..3c23dc3 100644 --- a/src/app/[locale]/feed/page.tsx +++ b/src/app/[locale]/feed/page.tsx @@ -5,6 +5,7 @@ import { getTranslations } from "next-intl/server"; import { db } from "@/db"; import { follows, userBooks, reviews } from "@/db/schema"; import { eq, and, inArray, desc, or } from "drizzle-orm"; +import Link from "next/link"; export default async function FeedPage() { const t = await getTranslations("feed"); @@ -26,16 +27,16 @@ export default async function FeedPage() {

{t("title")}

-

{t("empty")}

+

{t("empty.title")}

- {t("emptyDescription")} + {t("empty.description")}

- - {t("findUsers")} - + {t("empty.action")} +
); @@ -148,7 +149,7 @@ export default async function FeedPage() {

{activity.data.user.username || activity.data.user.email}

-

{t("reviewed")}

+

{t("activities.reviewed")}

0 || subscription.plan.stripePriceId !== null || subscription.plan.interval === "lifetime")); + hasPaidPlan = !!(subscription?.plan && (subscription.plan.price > 0 || subscription.plan.paddlePriceId !== null || subscription.plan.interval === "lifetime")); } return ( @@ -204,7 +221,12 @@ export default async function LocaleLayout({ className={`${eb_garamond.variable} ${eb_garamond_body.variable} ${ibm_plex_mono.variable} antialiased`} > - + +
{children}