From 684bbdff8281aae568a6983d4dede6c538ae0ed2 Mon Sep 17 00:00:00 2001 From: javayhu Date: Mon, 30 Jun 2025 00:25:26 +0800 Subject: [PATCH] chore: add credit related tables --- src/db/migrations/0001_woozy_jigsaw.sql | 25 + src/db/migrations/meta/0001_snapshot.json | 635 ++++++++++++++++++++++ src/db/migrations/meta/_journal.json | 7 + src/db/schema.ts | 22 +- src/lib/credits.ts | 42 +- 5 files changed, 696 insertions(+), 35 deletions(-) create mode 100644 src/db/migrations/0001_woozy_jigsaw.sql create mode 100644 src/db/migrations/meta/0001_snapshot.json diff --git a/src/db/migrations/0001_woozy_jigsaw.sql b/src/db/migrations/0001_woozy_jigsaw.sql new file mode 100644 index 0000000..dd997f8 --- /dev/null +++ b/src/db/migrations/0001_woozy_jigsaw.sql @@ -0,0 +1,25 @@ +CREATE TABLE "credit_transaction" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "type" text NOT NULL, + "description" text, + "amount" integer NOT NULL, + "remaining_amount" integer, + "payment_id" text, + "expiration_date" timestamp, + "expiration_date_processed_at" timestamp, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "user_credit" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "current_credits" integer DEFAULT 0 NOT NULL, + "last_refresh_at" timestamp, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "credit_transaction" ADD CONSTRAINT "credit_transaction_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "user_credit" ADD CONSTRAINT "user_credit_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/src/db/migrations/meta/0001_snapshot.json b/src/db/migrations/meta/0001_snapshot.json new file mode 100644 index 0000000..83c624e --- /dev/null +++ b/src/db/migrations/meta/0001_snapshot.json @@ -0,0 +1,635 @@ +{ + "id": "6ed4f085-66bb-42c4-a708-2e5d86438ca2", + "prevId": "7ecbd97a-94eb-4a46-996e-dbff727fc0c7", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credit_transaction": { + "name": "credit_transaction", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "remaining_amount": { + "name": "remaining_amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "payment_id": { + "name": "payment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expiration_date": { + "name": "expiration_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expiration_date_processed_at": { + "name": "expiration_date_processed_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": {}, + "foreignKeys": { + "credit_transaction_user_id_user_id_fk": { + "name": "credit_transaction_user_id_user_id_fk", + "tableFrom": "credit_transaction", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payment": { + "name": "payment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "price_id": { + "name": "price_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "interval": { + "name": "interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subscription_id": { + "name": "subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "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": {}, + "foreignKeys": { + "payment_user_id_user_id_fk": { + "name": "payment_user_id_user_id_fk", + "tableFrom": "payment", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_credit": { + "name": "user_credit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_credits": { + "name": "current_credits", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_refresh_at": { + "name": "last_refresh_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": {}, + "foreignKeys": { + "user_credit_user_id_user_id_fk": { + "name": "user_credit_user_id_user_id_fk", + "tableFrom": "user_credit", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index 578b9e1..8f5787e 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1744304844165, "tag": "0000_fine_sir_ram", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1751214200582, + "tag": "0001_woozy_jigsaw", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/schema.ts b/src/db/schema.ts index 0ccd085..0f05063 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,4 +1,4 @@ -import { boolean, pgTable, text, timestamp } from "drizzle-orm/pg-core"; +import { boolean, integer, pgTable, text, timestamp } from "drizzle-orm/pg-core"; export const user = pgTable("user", { id: text("id").primaryKey(), @@ -70,27 +70,25 @@ export const payment = pgTable("payment", { updatedAt: timestamp('updated_at').notNull().defaultNow(), }); -// Credits table: stores user's current credit balance and last refresh date export const userCredit = pgTable("user_credit", { id: text("id").primaryKey(), userId: text("user_id").notNull().references(() => user.id, { onDelete: 'cascade' }), - balance: text("balance").notNull(), // store as string for bigints, or use integer if preferred - lastRefresh: timestamp("last_refresh"), // last time free/monthly credits were refreshed + currentCredits: integer("current_credits").notNull().default(0), + lastRefreshAt: timestamp("last_refresh_at"), createdAt: timestamp("created_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(), }); -// Credit transaction table: records all credit changes (earn/spend/expire) export const creditTransaction = pgTable("credit_transaction", { id: text("id").primaryKey(), userId: text("user_id").notNull().references(() => user.id, { onDelete: 'cascade' }), - type: text("type").notNull(), // main type, e.g. REGISTER_GIFT, MONTHLY_REFRESH, PURCHASE, USAGE, EXPIRE - description: text("description"), // description, e.g. "Register gift credits: 100" - amount: text("amount").notNull(), // positive for earn, negative for spend - remainingAmount: text("remaining_amount"), // for FIFO consumption - paymentId: text("payment_id"), // associated payment order, can be null, only has value when purchasing credits - expirationDate: timestamp("expiration_date"), // when these credits expire, null for no expiration - expirationDateProcessedAt: timestamp("expiration_date_processed_at"), // when expired credits were processed + type: text("type").notNull(), + description: text("description"), + amount: integer("amount").notNull(), + remainingAmount: integer("remaining_amount"), + paymentId: text("payment_id"), + expirationDate: timestamp("expiration_date"), + expirationDateProcessedAt: timestamp("expiration_date_processed_at"), createdAt: timestamp("created_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(), }); diff --git a/src/lib/credits.ts b/src/lib/credits.ts index b6d0728..9edff94 100644 --- a/src/lib/credits.ts +++ b/src/lib/credits.ts @@ -17,7 +17,7 @@ export async function getUserCredits(userId: string): Promise { .from(userCredit) .where(eq(userCredit.userId, userId)) .limit(1); - return record[0]?.balance ? Number.parseInt(record[0].balance, 10) : 0; + return record[0]?.currentCredits || 0; } // Write a credit transaction record @@ -40,8 +40,8 @@ async function logCreditTransaction(params: { id: crypto.randomUUID(), userId: params.userId, type: params.type, - amount: params.amount.toString(), - remainingAmount: params.amount > 0 ? params.amount.toString() : undefined, + amount: params.amount, + remainingAmount: params.amount > 0 ? params.amount : null, description: params.description, paymentId: params.paymentId, expirationDate: params.expirationDate, @@ -84,15 +84,13 @@ export async function addCredits({ .from(userCredit) .where(eq(userCredit.userId, userId)) .limit(1); - const newBalance = ( - Number.parseInt(current[0]?.balance || '0', 10) + amount - ).toString(); + const newBalance = (current[0]?.currentCredits || 0) + amount; if (current.length > 0) { await db .update(userCredit) .set({ - balance: newBalance, - lastRefresh: new Date(), + currentCredits: newBalance, + lastRefreshAt: new Date(), updatedAt: new Date(), }) .where(eq(userCredit.userId, userId)); @@ -100,8 +98,8 @@ export async function addCredits({ await db.insert(userCredit).values({ id: crypto.randomUUID(), userId, - balance: newBalance, - lastRefresh: new Date(), + currentCredits: newBalance, + lastRefreshAt: new Date(), createdAt: new Date(), updatedAt: new Date(), }); @@ -163,14 +161,14 @@ export async function consumeCredits({ let left = amount; for (const tx of txs) { if (left <= 0) break; - const remain = Number.parseInt(tx.remainingAmount || '0', 10); + const remain = tx.remainingAmount || 0; if (remain <= 0) continue; // credits to consume at most in this transaction const consume = Math.min(remain, left); await db .update(creditTransaction) .set({ - remainingAmount: (remain - consume).toString(), + remainingAmount: remain - consume, updatedAt: new Date(), }) .where(eq(creditTransaction.id, tx.id)); @@ -182,12 +180,10 @@ export async function consumeCredits({ .from(userCredit) .where(eq(userCredit.userId, userId)) .limit(1); - const newBalance = ( - Number.parseInt(current[0]?.balance || '0', 10) - amount - ).toString(); + const newBalance = (current[0]?.currentCredits || 0) - amount; await db .update(userCredit) - .set({ balance: newBalance, updatedAt: new Date() }) + .set({ currentCredits: newBalance, updatedAt: new Date() }) .where(eq(userCredit.userId, userId)); // Write usage record await logCreditTransaction({ @@ -226,13 +222,13 @@ export async function processExpiredCredits(userId: string) { isAfter(now, tx.expirationDate) && !tx.expirationDateProcessedAt ) { - const remain = Number.parseInt(tx.remainingAmount || '0', 10); + const remain = tx.remainingAmount || 0; if (remain > 0) { expiredTotal += remain; await db .update(creditTransaction) .set({ - remainingAmount: '0', + remainingAmount: 0, expirationDateProcessedAt: now, updatedAt: now, }) @@ -249,11 +245,11 @@ export async function processExpiredCredits(userId: string) { .limit(1); const newBalance = Math.max( 0, - Number.parseInt(current[0]?.balance || '0', 10) - expiredTotal - ).toString(); + (current[0]?.currentCredits || 0) - expiredTotal + ); await db .update(userCredit) - .set({ balance: newBalance, updatedAt: now }) + .set({ currentCredits: newBalance, updatedAt: now }) .where(eq(userCredit.userId, userId)); // Write expire record await logCreditTransaction({ @@ -302,10 +298,10 @@ export async function addMonthlyFreeCredits(userId: string) { const now = new Date(); let canAdd = false; // never added credits before - if (!record[0]?.lastRefresh) { + if (!record[0]?.lastRefreshAt) { canAdd = true; } else { - const last = new Date(record[0].lastRefresh); + const last = new Date(record[0].lastRefreshAt); canAdd = now.getMonth() !== last.getMonth() || now.getFullYear() !== last.getFullYear();