diff --git a/apps/server/drizzle/0004_bouncy_devos.sql b/apps/server/drizzle/0004_bouncy_devos.sql new file mode 100644 index 000000000..ecc406433 --- /dev/null +++ b/apps/server/drizzle/0004_bouncy_devos.sql @@ -0,0 +1,11 @@ +CREATE TABLE "flux_audit_log" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "type" text NOT NULL, + "amount" integer NOT NULL, + "description" text NOT NULL, + "metadata" jsonb, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "flux_audit_log" ADD CONSTRAINT "flux_audit_log_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/apps/server/drizzle/meta/0004_snapshot.json b/apps/server/drizzle/meta/0004_snapshot.json new file mode 100644 index 000000000..8e510c0bb --- /dev/null +++ b/apps/server/drizzle/meta/0004_snapshot.json @@ -0,0 +1,2200 @@ +{ + "id": "45bc0dad-65f5-4695-9115-3d7d376b6440", + "prevId": "7c979ba8-a696-484d-b97a-9ddabe5c9aec", + "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, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.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, + "default": "now()" + }, + "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 + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "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": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.avatar_model": { + "name": "avatar_model", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "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()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "avatar_model_character_id_characters_id_fk": { + "name": "avatar_model_character_id_characters_id_fk", + "tableFrom": "avatar_model", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.characters": { + "name": "characters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cover_url": { + "name": "cover_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "creator_role": { + "name": "creator_role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price_credit": { + "name": "price_credit", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "likes_count": { + "name": "likes_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bookmarks_count": { + "name": "bookmarks_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "interactions_count": { + "name": "interactions_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "forks_count": { + "name": "forks_count", + "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()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "characters_creator_id_user_id_fk": { + "name": "characters_creator_id_user_id_fk", + "tableFrom": "characters", + "tableTo": "user", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "characters_owner_id_user_id_fk": { + "name": "characters_owner_id_user_id_fk", + "tableFrom": "characters", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.character_capabilities": { + "name": "character_capabilities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "character_capabilities_character_id_characters_id_fk": { + "name": "character_capabilities_character_id_characters_id_fk", + "tableFrom": "character_capabilities", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.character_covers": { + "name": "character_covers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "foreground_url": { + "name": "foreground_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "background_url": { + "name": "background_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "character_covers_character_id_characters_id_fk": { + "name": "character_covers_character_id_characters_id_fk", + "tableFrom": "character_covers", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.character_i18n": { + "name": "character_i18n", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tagline": { + "name": "tagline", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "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()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "character_i18n_character_id_characters_id_fk": { + "name": "character_i18n_character_id_characters_id_fk", + "tableFrom": "character_i18n", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.character_prompts": { + "name": "character_prompts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "character_prompts_character_id_characters_id_fk": { + "name": "character_prompts_character_id_characters_id_fk", + "tableFrom": "character_prompts", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_members": { + "name": "chat_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "member_type": { + "name": "member_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "chat_members_chat_id_chats_id_fk": { + "name": "chat_members_chat_id_chats_id_fk", + "tableFrom": "chat_members", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chats": { + "name": "chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "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()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.media": { + "name": "media", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "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": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.messages": { + "name": "messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sender_id": { + "name": "sender_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "media_ids": { + "name": "media_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "sticker_ids": { + "name": "sticker_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "reply_message_id": { + "name": "reply_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "forward_from_message_id": { + "name": "forward_from_message_id", + "type": "text", + "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()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "messages_chat_id_chats_id_fk": { + "name": "messages_chat_id_chats_id_fk", + "tableFrom": "messages", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sticker_packs": { + "name": "sticker_packs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stickers": { + "name": "stickers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.flux_audit_log": { + "name": "flux_audit_log", + "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 + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "flux_audit_log_user_id_user_id_fk": { + "name": "flux_audit_log_user_id_user_id_fk", + "tableFrom": "flux_audit_log", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_flux": { + "name": "user_flux", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "flux": { + "name": "flux", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_flux_user_id_user_id_fk": { + "name": "user_flux_user_id_user_id_fk", + "tableFrom": "user_flux", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.llm_request_log": { + "name": "llm_request_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "flux_consumed": { + "name": "flux_consumed", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "prompt_tokens": { + "name": "prompt_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "completion_tokens": { + "name": "completion_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "settled": { + "name": "settled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "llm_request_log_user_id_user_id_fk": { + "name": "llm_request_log_user_id_user_id_fk", + "tableFrom": "llm_request_log", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_provider_configs": { + "name": "system_provider_configs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "definition_id": { + "name": "definition_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "validated": { + "name": "validated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "validation_bypassed": { + "name": "validation_bypassed", + "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()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_provider_configs": { + "name": "user_provider_configs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "definition_id": { + "name": "definition_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "validated": { + "name": "validated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "validation_bypassed": { + "name": "validation_bypassed", + "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()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_provider_configs_owner_id_user_id_fk": { + "name": "user_provider_configs_owner_id_user_id_fk", + "tableFrom": "user_provider_configs", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stripe_checkout_session": { + "name": "stripe_checkout_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_session_id": { + "name": "stripe_session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payment_status": { + "name": "payment_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount_total": { + "name": "amount_total", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "success_url": { + "name": "success_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cancel_url": { + "name": "cancel_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_payment_intent_id": { + "name": "stripe_payment_intent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_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": { + "stripe_checkout_session_user_id_user_id_fk": { + "name": "stripe_checkout_session_user_id_user_id_fk", + "tableFrom": "stripe_checkout_session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "stripe_checkout_session_stripe_session_id_unique": { + "name": "stripe_checkout_session_stripe_session_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_session_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stripe_customer": { + "name": "stripe_customer", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "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": { + "stripe_customer_user_id_user_id_fk": { + "name": "stripe_customer_user_id_user_id_fk", + "tableFrom": "stripe_customer", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "stripe_customer_stripe_customer_id_unique": { + "name": "stripe_customer_stripe_customer_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_customer_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stripe_invoice": { + "name": "stripe_invoice", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_invoice_id": { + "name": "stripe_invoice_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount_due": { + "name": "amount_due", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "amount_paid": { + "name": "amount_paid", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "invoice_url": { + "name": "invoice_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "invoice_pdf": { + "name": "invoice_pdf", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "paid_at": { + "name": "paid_at", + "type": "timestamp", + "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()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "stripe_invoice_user_id_user_id_fk": { + "name": "stripe_invoice_user_id_user_id_fk", + "tableFrom": "stripe_invoice", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "stripe_invoice_stripe_invoice_id_unique": { + "name": "stripe_invoice_stripe_invoice_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_invoice_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stripe_subscription": { + "name": "stripe_subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_price_id": { + "name": "stripe_price_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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": "text", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "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()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "stripe_subscription_user_id_user_id_fk": { + "name": "stripe_subscription_user_id_user_id_fk", + "tableFrom": "stripe_subscription", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "stripe_subscription_stripe_subscription_id_unique": { + "name": "stripe_subscription_stripe_subscription_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_subscription_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_character_bookmarks": { + "name": "user_character_bookmarks", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_character_bookmarks_user_id_user_id_fk": { + "name": "user_character_bookmarks_user_id_user_id_fk", + "tableFrom": "user_character_bookmarks", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_character_bookmarks_character_id_characters_id_fk": { + "name": "user_character_bookmarks_character_id_characters_id_fk", + "tableFrom": "user_character_bookmarks", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_character_bookmarks_user_id_character_id_pk": { + "name": "user_character_bookmarks_user_id_character_id_pk", + "columns": [ + "user_id", + "character_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_character_likes": { + "name": "user_character_likes", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_character_likes_user_id_user_id_fk": { + "name": "user_character_likes_user_id_user_id_fk", + "tableFrom": "user_character_likes", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_character_likes_character_id_characters_id_fk": { + "name": "user_character_likes_character_id_characters_id_fk", + "tableFrom": "user_character_likes", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_character_likes_user_id_character_id_pk": { + "name": "user_character_likes_user_id_character_id_pk", + "columns": [ + "user_id", + "character_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/server/drizzle/meta/_journal.json b/apps/server/drizzle/meta/_journal.json index 62cbcc534..38fb9d328 100644 --- a/apps/server/drizzle/meta/_journal.json +++ b/apps/server/drizzle/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1773229668722, "tag": "0003_old_titania", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1773421458174, + "tag": "0004_bouncy_devos", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index ea60239ea..79044f18d 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -28,7 +28,7 @@ import { createCharacterService } from './services/characters' import { createChatService } from './services/chats' import { createConfigKVService } from './services/config-kv' import { createFluxService } from './services/flux' -import { createFluxWriteBack } from './services/flux-write-back' +import { createFluxAuditService } from './services/flux-audit' import { createProviderService } from './services/providers' import { createRequestLogService } from './services/request-log' import { createStripeService } from './services/stripe' @@ -41,6 +41,7 @@ type ChatService = ReturnType type ProviderService = ReturnType type FluxService = ReturnType type ConfigKVService = ReturnType +type FluxAuditService = ReturnType type RequestLogService = ReturnType type StripeDBService = ReturnType @@ -52,6 +53,7 @@ interface AppDeps { chatService: ChatService providerService: ProviderService fluxService: FluxService + fluxAuditService: FluxAuditService requestLogService: RequestLogService stripeService: StripeDBService configKV: ConfigKVService @@ -65,6 +67,7 @@ function buildApp({ chatService, providerService, fluxService, + fluxAuditService, requestLogService, stripeService, configKV, @@ -143,7 +146,7 @@ function buildApp({ /** * Flux routes. */ - .route('/api/flux', createFluxRoutes(fluxService)) + .route('/api/flux', createFluxRoutes(fluxService, fluxAuditService)) /** * Stripe routes. @@ -224,9 +227,14 @@ async function createApp() { build: ({ dependsOn }) => createStripeService(dependsOn.db), }) + const fluxAuditService = injeca.provide('services:fluxAudit', { + dependsOn: { db }, + build: ({ dependsOn }) => createFluxAuditService(dependsOn.db), + }) + const fluxService = injeca.provide('services:flux', { - dependsOn: { db, redis, configKV }, - build: ({ dependsOn }) => createFluxService(dependsOn.db, dependsOn.redis, dependsOn.configKV), + dependsOn: { db, redis, configKV, fluxAuditService }, + build: ({ dependsOn }) => createFluxService(dependsOn.db, dependsOn.redis, dependsOn.configKV, dependsOn.fluxAuditService), }) const requestLogService = injeca.provide('services:requestLog', { @@ -234,19 +242,6 @@ async function createApp() { build: ({ dependsOn }) => createRequestLogService(dependsOn.db), }) - const fluxWriteBack = injeca.provide('services:fluxWriteBack', { - dependsOn: { db, lifecycle }, - build: ({ dependsOn }) => { - const wb = createFluxWriteBack(dependsOn.db) - wb.start() - dependsOn.lifecycle.appHooks.onStop(async () => { - wb.stop() - await wb.flush() - }) - return wb - }, - }) - await injeca.start() const resolved = await injeca.resolve({ auth, @@ -254,12 +249,12 @@ async function createApp() { chatService, providerService, fluxService, + fluxAuditService, requestLogService, stripeService, configKV, env: parsedEnv, otel, - fluxWriteBack, }) const app = buildApp({ auth: resolved.auth, @@ -267,6 +262,7 @@ async function createApp() { chatService: resolved.chatService, providerService: resolved.providerService, fluxService: resolved.fluxService, + fluxAuditService: resolved.fluxAuditService, requestLogService: resolved.requestLogService, stripeService: resolved.stripeService, configKV: resolved.configKV, diff --git a/apps/server/src/libs/db.ts b/apps/server/src/libs/db.ts index 1d882ea25..07045acab 100644 --- a/apps/server/src/libs/db.ts +++ b/apps/server/src/libs/db.ts @@ -1,3 +1,4 @@ +import { useLogger } from '@guiiai/logg' import { migrate } from '@proj-airi/drizzle-orm-browser-migrator/pg' import { migrations } from '@proj-airi/server-schema' import { drizzle } from 'drizzle-orm/node-postgres' @@ -5,10 +6,24 @@ import { Pool } from 'pg' import * as fullSchema from '../schemas' +const logger = useLogger('db') + export type Database = ReturnType['db'] export function createDrizzle(dsn: string) { - const pool = new Pool({ connectionString: dsn }) + const pool = new Pool({ + connectionString: dsn, + max: 20, + idleTimeoutMillis: 30_000, + connectionTimeoutMillis: 5_000, + keepAlive: true, + keepAliveInitialDelayMillis: 10_000, + }) + + pool.on('error', (err) => { + logger.withError(err).error('Unexpected pool error on idle client') + }) + const db = drizzle(pool, { schema: fullSchema }) return { db, pool } } diff --git a/apps/server/src/middlewares/auth.ts b/apps/server/src/middlewares/auth.ts index 194aff34e..1a63456a6 100644 --- a/apps/server/src/middlewares/auth.ts +++ b/apps/server/src/middlewares/auth.ts @@ -3,8 +3,12 @@ import type { MiddlewareHandler } from 'hono' import type { createAuth } from '../libs/auth' import type { HonoEnv } from '../types/hono' +import { useLogger } from '@guiiai/logg' + import { createUnauthorizedError } from '../utils/error' +const logger = useLogger('auth') + type AuthInstance = ReturnType /** @@ -34,6 +38,7 @@ export function sessionMiddleware(auth: AuthInstance): MiddlewareHandler = async (c, next) => { const user = c.get('user') if (!user) { + logger.withFields({ path: c.req.path, method: c.req.method }).warn('Unauthorized request blocked') throw createUnauthorizedError() } await next() diff --git a/apps/server/src/routes/__test__/v1completions.test.ts b/apps/server/src/routes/__test__/v1completions.test.ts index c5845883c..68df007f5 100644 --- a/apps/server/src/routes/__test__/v1completions.test.ts +++ b/apps/server/src/routes/__test__/v1completions.test.ts @@ -150,7 +150,10 @@ describe('v1CompletionsRoutes', () => { expect(data.id).toBe('chatcmpl-1') // Verify flux was consumed - expect(fluxService.consumeFlux).toHaveBeenCalledWith('user-1', 1) + expect(fluxService.consumeFlux).toHaveBeenCalledWith('user-1', 1, { + description: 'openai/gpt-5-mini', + metadata: undefined, + }) // Verify upstream was called with correct URL and resolved model expect(globalThis.fetch).toHaveBeenCalledWith( diff --git a/apps/server/src/routes/flux.ts b/apps/server/src/routes/flux.ts index 1feae6092..a3899cc0a 100644 --- a/apps/server/src/routes/flux.ts +++ b/apps/server/src/routes/flux.ts @@ -1,11 +1,12 @@ import type { FluxService } from '../services/flux' +import type { FluxAuditService } from '../services/flux-audit' import type { HonoEnv } from '../types/hono' import { Hono } from 'hono' import { authGuard } from '../middlewares/auth' -export function createFluxRoutes(fluxService: FluxService) { +export function createFluxRoutes(fluxService: FluxService, fluxAuditService: FluxAuditService) { return new Hono() .use('*', authGuard) .get('/', async (c) => { @@ -13,4 +14,23 @@ export function createFluxRoutes(fluxService: FluxService) { const flux = await fluxService.getFlux(user.id) return c.json(flux) }) + .get('/history', async (c) => { + const user = c.get('user')! + const limit = Math.min(Math.max(Number(c.req.query('limit') || '20'), 1), 100) + const offset = Math.max(Number(c.req.query('offset') || '0'), 0) + + const { records, hasMore } = await fluxAuditService.getHistory(user.id, limit, offset) + + return c.json({ + records: records.map(r => ({ + id: r.id, + type: r.type, + amount: r.amount, + description: r.description, + metadata: r.metadata, + createdAt: r.createdAt.toISOString(), + })), + hasMore, + }) + }) } diff --git a/apps/server/src/routes/stripe.ts b/apps/server/src/routes/stripe.ts index ad461f605..0b6792c9c 100644 --- a/apps/server/src/routes/stripe.ts +++ b/apps/server/src/routes/stripe.ts @@ -6,6 +6,7 @@ import type { HonoEnv } from '../types/hono' import Stripe from 'stripe' +import { useLogger } from '@guiiai/logg' import { Hono } from 'hono' import { integer, minValue, number, object, pipe, safeParse } from 'valibot' @@ -13,6 +14,8 @@ import { authGuard } from '../middlewares/auth' import { configGuard } from '../middlewares/config-guard' import { createBadRequestError, createServiceUnavailableError } from '../utils/error' +const logger = useLogger('stripe') + const CheckoutBodySchema = object({ amount: pipe(number(), integer(), minValue(1)), }) @@ -140,6 +143,8 @@ export function createStripeRoutes(fluxService: FluxService, stripeService: Stri throw createBadRequestError(`Webhook Error: ${message}`, 'WEBHOOK_ERROR') } + logger.withFields({ type: event.type, id: event.id }).log('Webhook event received') + switch (event.type) { case 'checkout.session.completed': { await handleCheckoutSessionCompleted(event.data.object, fluxService, stripeService, configKV) @@ -178,8 +183,12 @@ async function handleCheckoutSessionCompleted( configKV: ConfigKVService, ) { const userId = session.metadata?.userId - if (!userId) + if (!userId) { + logger.withFields({ sessionId: session.id }).warn('Checkout session missing userId in metadata') return + } + + logger.withFields({ userId, sessionId: session.id, mode: session.mode, amount: session.amount_total, currency: session.currency }).log('Processing checkout session') // Upsert customer record if we got a customer back if (session.customer) { @@ -214,7 +223,9 @@ async function handleCheckoutSessionCompleted( // Add flux for one-time payments if (session.mode === 'payment' && session.amount_total) { const fluxPerCent = await configKV.getOrThrow('FLUX_PER_CENT') - await fluxService.addFlux(userId, session.amount_total * fluxPerCent) + const fluxAmount = session.amount_total * fluxPerCent + logger.withFields({ userId, fluxAmount, fluxPerCent, amountTotal: session.amount_total }).log('Adding flux for one-time payment') + await fluxService.addFlux(userId, fluxAmount, `Stripe payment ${session.currency?.toUpperCase()} ${(session.amount_total / 100).toFixed(2)}`) } } @@ -304,6 +315,8 @@ async function handleInvoiceEvent( // Add flux when a subscription invoice is paid if (invoice.status === 'paid' && invoice.amount_paid && subscriptionId) { const fluxPerCent = await configKV.getOrThrow('FLUX_PER_CENT') - await fluxService.addFlux(customer.userId, invoice.amount_paid * fluxPerCent) + const fluxAmount = invoice.amount_paid * fluxPerCent + logger.withFields({ userId: customer.userId, fluxAmount, invoiceId: invoice.id }).log('Adding flux for subscription invoice') + await fluxService.addFlux(customer.userId, fluxAmount, `Subscription invoice ${invoice.currency?.toUpperCase()} ${(invoice.amount_paid / 100).toFixed(2)}`) } } diff --git a/apps/server/src/routes/v1completions.ts b/apps/server/src/routes/v1completions.ts index 0c3421364..6df2628eb 100644 --- a/apps/server/src/routes/v1completions.ts +++ b/apps/server/src/routes/v1completions.ts @@ -61,6 +61,15 @@ function calculateFluxFromUsage(usage: UsageInfo, fluxPer1kTokens: number, fallb return fallbackRate } +function buildFluxAuditMetadata(usage: UsageInfo): Record | undefined { + const metadata: Record = {} + if (usage.promptTokens != null) + metadata.promptTokens = usage.promptTokens + if (usage.completionTokens != null) + metadata.completionTokens = usage.completionTokens + return Object.keys(metadata).length > 0 ? metadata : undefined +} + export function createV1CompletionsRoutes(fluxService: FluxService, configKV: ConfigKVService, requestLogService: RequestLogService, otel: OtelMetrics | null) { const logger = useLogger('v1-completions').useGlobalConfig() @@ -173,7 +182,10 @@ export function createV1CompletionsRoutes(fluxService: FluxService, configKV: Co // Best-effort billing — don't throw on insufficient flux during streaming try { - await fluxService.consumeFlux(user.id, fluxConsumed) + await fluxService.consumeFlux(user.id, fluxConsumed, { + description: requestModel, + metadata: buildFluxAuditMetadata(usage), + }) } catch (err) { logger.withError(err).withFields({ userId: user.id, fluxConsumed }).warn('Failed to consume flux after streaming') } @@ -211,7 +223,10 @@ export function createV1CompletionsRoutes(fluxService: FluxService, configKV: Co // Best-effort billing — gateway already processed the request, // don't return 402 after work is done try { - await fluxService.consumeFlux(user.id, fluxConsumed) + await fluxService.consumeFlux(user.id, fluxConsumed, { + description: requestModel, + metadata: buildFluxAuditMetadata(usage), + }) } catch (err) { logger.withError(err).withFields({ userId: user.id, fluxConsumed }).warn('Failed to consume flux') } diff --git a/apps/server/src/schemas/flux-audit-log.ts b/apps/server/src/schemas/flux-audit-log.ts new file mode 100644 index 000000000..79993a501 --- /dev/null +++ b/apps/server/src/schemas/flux-audit-log.ts @@ -0,0 +1,14 @@ +import { integer, jsonb, pgTable, text, timestamp } from 'drizzle-orm/pg-core' + +import { nanoid } from '../utils/id' +import { user } from './accounts' + +export const fluxAuditLog = pgTable('flux_audit_log', { + id: text('id').primaryKey().$defaultFn(() => nanoid()), + userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), + type: text('type').notNull(), // 'consumption' | 'addition' | 'initial' + amount: integer('amount').notNull(), // positive = gain, negative = spend + description: text('description').notNull(), // model name, "Stripe payment", "Initial grant", etc. + metadata: jsonb('metadata'), // { promptTokens, completionTokens, stripeSessionId, ... } + createdAt: timestamp('created_at').defaultNow().notNull(), +}) diff --git a/apps/server/src/schemas/index.ts b/apps/server/src/schemas/index.ts index 8cafa00de..1e0247be5 100644 --- a/apps/server/src/schemas/index.ts +++ b/apps/server/src/schemas/index.ts @@ -2,6 +2,7 @@ export * from './accounts' export * from './characters' export * from './chats' export * from './flux' +export * from './flux-audit-log' export * from './llm-request-log' export * from './providers' export * from './stripe' diff --git a/apps/server/src/services/__test__/flux-audit.test.ts b/apps/server/src/services/__test__/flux-audit.test.ts new file mode 100644 index 000000000..0428dd639 --- /dev/null +++ b/apps/server/src/services/__test__/flux-audit.test.ts @@ -0,0 +1,77 @@ +import { beforeAll, describe, expect, it } from 'vitest' + +import { mockDB } from '../../libs/mock-db' +import { createFluxAuditService } from '../flux-audit' + +import * as schema from '../../schemas' + +describe('fluxAuditService', () => { + let db: any + let service: ReturnType + + beforeAll(async () => { + db = await mockDB(schema) + await db.insert(schema.user).values({ + id: 'user-audit', + name: 'Audit User', + email: 'audit@example.com', + }) + service = createFluxAuditService(db) + }) + + it('log should insert a single audit entry', async () => { + await service.log({ + userId: 'user-audit', + type: 'addition', + amount: 500, + description: 'Stripe payment', + metadata: { stripeSessionId: 'sess_123' }, + }) + + const { records } = await service.getHistory('user-audit', 10, 0) + expect(records).toHaveLength(1) + expect(records[0].type).toBe('addition') + expect(records[0].amount).toBe(500) + }) + + it('logBatch should insert multiple entries', async () => { + await service.logBatch([ + { userId: 'user-audit', type: 'consumption', amount: -10, description: 'gpt-4o' }, + { userId: 'user-audit', type: 'consumption', amount: -5, description: 'gpt-4o-mini' }, + ]) + + const { records } = await service.getHistory('user-audit', 10, 0) + expect(records).toHaveLength(3) // 1 from previous test + 2 batch + }) + + it('logBatch with empty array should be a no-op', async () => { + await service.logBatch([]) + const { records } = await service.getHistory('user-audit', 10, 0) + expect(records).toHaveLength(3) + }) + + it('getHistory should paginate correctly with hasMore', async () => { + const { records, hasMore } = await service.getHistory('user-audit', 2, 0) + expect(records).toHaveLength(2) + expect(hasMore).toBe(true) + }) + + it('getHistory should return hasMore=false on last page', async () => { + const { records, hasMore } = await service.getHistory('user-audit', 10, 0) + expect(records).toHaveLength(3) + expect(hasMore).toBe(false) + }) + + it('getHistory should respect offset', async () => { + const { records } = await service.getHistory('user-audit', 10, 2) + expect(records).toHaveLength(1) + }) + + it('getHistory should return records ordered by createdAt desc', async () => { + const { records } = await service.getHistory('user-audit', 10, 0) + for (let i = 1; i < records.length; i++) { + expect(new Date(records[i - 1].createdAt).getTime()) + .toBeGreaterThanOrEqual(new Date(records[i].createdAt).getTime()) + } + }) +}) diff --git a/apps/server/src/services/__test__/flux-write-back.test.ts b/apps/server/src/services/__test__/flux-write-back.test.ts deleted file mode 100644 index c501f1b06..000000000 --- a/apps/server/src/services/__test__/flux-write-back.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { eq } from 'drizzle-orm' -import { beforeAll, describe, expect, it } from 'vitest' - -import { mockDB } from '../../libs/mock-db' -import { createFluxWriteBack } from '../flux-write-back' - -import * as schema from '../../schemas' - -describe('fluxWriteBack', () => { - let db: any - let testUser: any - let writeBack: ReturnType - - beforeAll(async () => { - db = await mockDB(schema) - - const [user] = await db.insert(schema.user).values({ - id: 'user-wb-1', - name: 'Write-back User', - email: 'wb@example.com', - }).returning() - testUser = user - - await db.insert(schema.userFlux).values({ - userId: testUser.id, - flux: 1000, - }) - - writeBack = createFluxWriteBack(db) - }) - - it('should aggregate unsettled logs and deduct from user_flux', async () => { - await db.insert(schema.llmRequestLog).values([ - { userId: testUser.id, model: 'gpt-4', status: 200, durationMs: 100, fluxConsumed: 10, settled: false }, - { userId: testUser.id, model: 'gpt-4', status: 200, durationMs: 200, fluxConsumed: 20, settled: false }, - { userId: testUser.id, model: 'gpt-4', status: 200, durationMs: 150, fluxConsumed: 30, settled: false }, - ]) - - await writeBack.flush() - - const record = await db.query.userFlux.findFirst({ - where: eq(schema.userFlux.userId, testUser.id), - }) - expect(record.flux).toBe(940) - - const unsettled = await db.query.llmRequestLog.findMany({ - where: eq(schema.llmRequestLog.settled, false), - }) - expect(unsettled).toHaveLength(0) - }) - - it('should not re-settle already settled logs', async () => { - await db.insert(schema.llmRequestLog).values({ - userId: testUser.id, - model: 'gpt-4', - status: 200, - durationMs: 100, - fluxConsumed: 5, - settled: false, - }) - - await writeBack.flush() - - const record = await db.query.userFlux.findFirst({ - where: eq(schema.userFlux.userId, testUser.id), - }) - expect(record.flux).toBe(935) - }) - - it('should be a no-op when there are no unsettled logs', async () => { - await writeBack.flush() - - const record = await db.query.userFlux.findFirst({ - where: eq(schema.userFlux.userId, testUser.id), - }) - expect(record.flux).toBe(935) - }) - - it('should aggregate across multiple users correctly', async () => { - const [user2] = await db.insert(schema.user).values({ - id: 'user-wb-2', - name: 'Write-back User 2', - email: 'wb2@example.com', - }).returning() - await db.insert(schema.userFlux).values({ userId: user2.id, flux: 500 }) - - await db.insert(schema.llmRequestLog).values([ - { userId: testUser.id, model: 'gpt-4', status: 200, durationMs: 100, fluxConsumed: 15, settled: false }, - { userId: user2.id, model: 'gpt-4', status: 200, durationMs: 100, fluxConsumed: 25, settled: false }, - { userId: user2.id, model: 'gpt-4', status: 200, durationMs: 100, fluxConsumed: 35, settled: false }, - ]) - - await writeBack.flush() - - const record1 = await db.query.userFlux.findFirst({ - where: eq(schema.userFlux.userId, testUser.id), - }) - expect(record1.flux).toBe(920) - - const record2 = await db.query.userFlux.findFirst({ - where: eq(schema.userFlux.userId, user2.id), - }) - expect(record2.flux).toBe(440) - }) -}) diff --git a/apps/server/src/services/__test__/flux.test.ts b/apps/server/src/services/__test__/flux.test.ts index b81283637..1208080f1 100644 --- a/apps/server/src/services/__test__/flux.test.ts +++ b/apps/server/src/services/__test__/flux.test.ts @@ -6,6 +6,7 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { mockDB } from '../../libs/mock-db' import { createFluxService } from '../flux' +import { createFluxAuditService } from '../flux-audit' import * as schema from '../../schemas' @@ -23,7 +24,10 @@ function createMockRedis(): Redis { const store = new Map() return { get: vi.fn(async (key: string) => store.get(key) ?? null), - set: vi.fn(async (key: string, value: string) => { store.set(key, value); return 'OK' }), + set: vi.fn(async (key: string, value: string) => { + store.set(key, value) + return 'OK' + }), decrby: vi.fn(async (key: string, amount: number) => { const current = Number.parseInt(store.get(key) ?? '0', 10) const next = current - amount @@ -43,10 +47,12 @@ describe('fluxService (Redis-backed)', () => { let db: any let redis: Redis let service: ReturnType + let fluxAuditService: ReturnType let testUser: any beforeAll(async () => { db = await mockDB(schema) + fluxAuditService = createFluxAuditService(db) const [user] = await db.insert(schema.user).values({ id: 'user-1', @@ -58,7 +64,7 @@ describe('fluxService (Redis-backed)', () => { beforeEach(() => { redis = createMockRedis() - service = createFluxService(db, redis, createMockConfigKV()) + service = createFluxService(db, redis, createMockConfigKV(), fluxAuditService) }) it('getFlux should load from DB on cache miss and populate Redis', async () => { @@ -73,6 +79,21 @@ describe('fluxService (Redis-backed)', () => { expect(redis.get).toHaveBeenCalledTimes(2) }) + it('getFlux should create an initial audit entry for a new user', async () => { + const [user] = await db.insert(schema.user).values({ + id: 'user-audit-initial', + name: 'Audit Initial', + email: 'audit-initial@example.com', + }).returning() + + await service.getFlux(user.id) + + const { records } = await fluxAuditService.getHistory(user.id, 10, 0) + expect(records).toHaveLength(1) + expect(records[0].type).toBe('initial') + expect(records[0].amount).toBe(100) + }) + it('consumeFlux should deduct via Redis DECRBY', async () => { await service.getFlux(testUser.id) const result = await service.consumeFlux(testUser.id, 10) @@ -89,10 +110,61 @@ describe('fluxService (Redis-backed)', () => { }) it('addFlux should update both DB and Redis', async () => { - await service.getFlux(testUser.id) - const result = await service.addFlux(testUser.id, 50) + const [user] = await db.insert(schema.user).values({ + id: 'user-add', + name: 'Add User', + email: 'add@example.com', + }).returning() + await service.getFlux(user.id) + const result = await service.addFlux(user.id, 50) expect(result.flux).toBe(150) - expect(redis.incrby).toHaveBeenCalledWith(`flux:${testUser.id}`, 50) + expect(redis.incrby).toHaveBeenCalledWith(`flux:${user.id}`, 50) + }) + + it('consumeFlux should write a consumption audit entry with metadata', async () => { + const [user] = await db.insert(schema.user).values({ + id: 'user-audit-consume', + name: 'Audit Consume', + email: 'audit-consume@example.com', + }).returning() + + await service.getFlux(user.id) + await service.consumeFlux(user.id, 10, { + description: 'openai/gpt-5-mini', + metadata: { + promptTokens: 12, + completionTokens: 34, + }, + }) + + const { records } = await fluxAuditService.getHistory(user.id, 10, 0) + expect(records).toHaveLength(2) + const consumptionRecord = records.find(record => record.type === 'consumption') + expect(consumptionRecord).toBeDefined() + expect(consumptionRecord!.amount).toBe(-10) + expect(consumptionRecord!.description).toBe('openai/gpt-5-mini') + expect(consumptionRecord!.metadata).toEqual({ + promptTokens: 12, + completionTokens: 34, + }) + }) + + it('addFlux should write an addition audit entry', async () => { + const [user] = await db.insert(schema.user).values({ + id: 'user-audit-add', + name: 'Audit Add', + email: 'audit-add@example.com', + }).returning() + + await service.getFlux(user.id) + await service.addFlux(user.id, 50, 'Stripe payment USD 0.50') + + const { records } = await fluxAuditService.getHistory(user.id, 10, 0) + expect(records).toHaveLength(2) + const additionRecord = records.find(record => record.type === 'addition') + expect(additionRecord).toBeDefined() + expect(additionRecord!.amount).toBe(50) + expect(additionRecord!.description).toBe('Stripe payment USD 0.50') }) it('consumeFlux should lazy-load cache if not preloaded', async () => { diff --git a/apps/server/src/services/characters.ts b/apps/server/src/services/characters.ts index d3684574e..b22cb3985 100644 --- a/apps/server/src/services/characters.ts +++ b/apps/server/src/services/characters.ts @@ -1,10 +1,13 @@ import type { Database } from '../libs/db' +import { useLogger } from '@guiiai/logg' import { and, eq, isNull, sql } from 'drizzle-orm' import * as schema from '../schemas/characters' import * as userCharacterSchema from '../schemas/user-character' +const logger = useLogger('characters') + export function createCharacterService(db: Database) { return { async findById(id: string) { @@ -140,6 +143,7 @@ export function createCharacterService(db: Database) { }) { return await db.transaction(async (tx) => { const [inserted] = await tx.insert(schema.character).values(data.character).returning() + logger.withFields({ id: inserted.id, ownerId: data.character.ownerId }).log('Created character') if (data.cover) { await tx.insert(schema.characterCovers).values({ @@ -177,23 +181,27 @@ export function createCharacterService(db: Database) { }, async update(id: string, data: Partial) { - return await db.update(schema.character) + const result = await db.update(schema.character) .set({ ...data, updatedAt: new Date() }) .where(and( eq(schema.character.id, id), isNull(schema.character.deletedAt), )) .returning() + logger.withFields({ id }).log('Updated character') + return result }, async delete(id: string) { - return await db.update(schema.character) + const result = await db.update(schema.character) .set({ deletedAt: new Date() }) .where(and( eq(schema.character.id, id), isNull(schema.character.deletedAt), )) .returning() + logger.withFields({ id }).log('Deleted character') + return result }, } } diff --git a/apps/server/src/services/chats.ts b/apps/server/src/services/chats.ts index 6b30121a3..ef6840722 100644 --- a/apps/server/src/services/chats.ts +++ b/apps/server/src/services/chats.ts @@ -1,11 +1,14 @@ import type { Database } from '../libs/db' +import { useLogger } from '@guiiai/logg' import { and, eq, inArray, sql } from 'drizzle-orm' import { createConflictError, createForbiddenError } from '../utils/error' import * as schema from '../schemas/chats' +const logger = useLogger('chats') + type ChatType = 'private' | 'bot' | 'group' | 'channel' type MessageRole = 'system' | 'user' | 'assistant' | 'tool' | 'error' type ChatMemberType = 'user' | 'character' | 'bot' @@ -67,8 +70,10 @@ export function createChatService(db: Database) { ), }) - if (!member) + if (!member) { + logger.withFields({ userId, chatId }).warn('User not a member of chat, sync forbidden') throw createForbiddenError() + } } if (!existingChat) { @@ -134,8 +139,10 @@ export function createChatService(db: Database) { .where(inArray(schema.messages.id, messageIds)) const conflicting = existingMessages.find(m => m.chatId !== chatId) - if (conflicting) + if (conflicting) { + logger.withFields({ messageId: conflicting.id, expectedChatId: chatId, actualChatId: conflicting.chatId }).warn('Message conflict detected') throw createConflictError('Message already belongs to another chat') + } await tx.insert(schema.messages) .values(payload.messages.map(message => ({ @@ -160,6 +167,7 @@ export function createChatService(db: Database) { }) } + logger.withFields({ userId, chatId, messageCount: payload.messages.length, isNew: !existingChat }).log('Synced chat') return { chatId } }) }, diff --git a/apps/server/src/services/flux-audit.ts b/apps/server/src/services/flux-audit.ts new file mode 100644 index 000000000..41a158309 --- /dev/null +++ b/apps/server/src/services/flux-audit.ts @@ -0,0 +1,49 @@ +import type { Database } from '../libs/db' + +import { useLogger } from '@guiiai/logg' +import { desc, eq } from 'drizzle-orm' + +import * as schema from '../schemas/flux-audit-log' + +const logger = useLogger('flux-audit') + +export interface AuditEntry { + userId: string + type: 'consumption' | 'addition' | 'initial' + amount: number + description: string + metadata?: Record +} + +export function createFluxAuditService(db: Database) { + return { + async log(entry: AuditEntry) { + await db.insert(schema.fluxAuditLog).values(entry) + logger.withFields({ userId: entry.userId, type: entry.type, amount: entry.amount }).log('Audit entry recorded') + }, + + async logBatch(entries: AuditEntry[]) { + if (entries.length === 0) + return + await db.insert(schema.fluxAuditLog).values(entries) + logger.withFields({ count: entries.length }).log('Audit batch recorded') + }, + + async getHistory(userId: string, limit: number, offset: number) { + const records = await db.query.fluxAuditLog.findMany({ + where: eq(schema.fluxAuditLog.userId, userId), + orderBy: [desc(schema.fluxAuditLog.createdAt)], + limit: limit + 1, // fetch one extra to determine hasMore + offset, + }) + + const hasMore = records.length > limit + if (hasMore) + records.pop() + + return { records, hasMore } + }, + } +} + +export type FluxAuditService = ReturnType diff --git a/apps/server/src/services/flux-write-back.ts b/apps/server/src/services/flux-write-back.ts deleted file mode 100644 index de1c799e3..000000000 --- a/apps/server/src/services/flux-write-back.ts +++ /dev/null @@ -1,74 +0,0 @@ -import type { Database } from '../libs/db' - -import { useLogger } from '@guiiai/logg' -import { and, eq, lte, sql } from 'drizzle-orm' - -import * as fluxSchema from '../schemas/flux' -import * as logSchema from '../schemas/llm-request-log' - -/** - * NOTE: Flux balances are deducted in real-time via Redis (DECRBY) in FluxService.consumeFlux(). - * This write-back service only syncs the DB — it does NOT touch Redis. - * It periodically aggregates unsettled request logs and batch-updates the DB's user_flux table - * so that the persistent balance stays consistent with the Redis cache. - */ -export function createFluxWriteBack(db: Database) { - const logger = useLogger('flux-write-back').useGlobalConfig() - let timer: ReturnType | null = null - - async function flush() { - const snapshotTime = new Date() - - // 1. Aggregate unsettled logs inserted before (or at) this tick - const totals = await db - .select({ - userId: logSchema.llmRequestLog.userId, - total: sql`SUM(${logSchema.llmRequestLog.fluxConsumed})`.as('total'), - }) - .from(logSchema.llmRequestLog) - .where(and(eq(logSchema.llmRequestLog.settled, false), lte(logSchema.llmRequestLog.createdAt, snapshotTime))) - .groupBy(logSchema.llmRequestLog.userId) - - if (totals.length === 0) - return - - // 2. Batch update in transaction - await db.transaction(async (tx) => { - for (const { userId, total } of totals) { - await tx.update(fluxSchema.userFlux) - .set({ - flux: sql`${fluxSchema.userFlux.flux} - ${total}`, - updatedAt: new Date(), - }) - .where(eq(fluxSchema.userFlux.userId, userId)) - } - - await tx.update(logSchema.llmRequestLog) - .set({ settled: true }) - .where(and(eq(logSchema.llmRequestLog.settled, false), lte(logSchema.llmRequestLog.createdAt, snapshotTime))) - }) - - logger.withFields({ userCount: totals.length }).log('Write-back completed') - } - - return { - flush, - - start(intervalMs = 60_000) { - timer = setInterval(() => { - flush().catch((err) => { - logger.withError(err).error('Write-back failed') - }) - }, intervalMs) - }, - - stop() { - if (timer) { - clearInterval(timer) - timer = null - } - }, - } -} - -export type FluxWriteBack = ReturnType diff --git a/apps/server/src/services/flux.ts b/apps/server/src/services/flux.ts index 4b25ec00e..6976eb494 100644 --- a/apps/server/src/services/flux.ts +++ b/apps/server/src/services/flux.ts @@ -2,18 +2,26 @@ import type Redis from 'ioredis' import type { Database } from '../libs/db' import type { ConfigKVService } from './config-kv' +import type { FluxAuditService } from './flux-audit' +import { useLogger } from '@guiiai/logg' import { eq, sql } from 'drizzle-orm' import { createPaymentRequiredError } from '../utils/error' import * as schema from '../schemas/flux' +const logger = useLogger('flux-service') + function redisKey(userId: string): string { return `flux:${userId}` } -export function createFluxService(db: Database, redis: Redis, configKV: ConfigKVService) { +function buildAuditMetadata(metadata?: Record) { + return metadata && Object.keys(metadata).length > 0 ? metadata : undefined +} + +export function createFluxService(db: Database, redis: Redis, configKV: ConfigKVService, fluxAuditService: FluxAuditService) { return { async getFlux(userId: string) { // 1. Try Redis cache @@ -33,6 +41,16 @@ export function createFluxService(db: Database, redis: Redis, configKV: ConfigKV userId, flux: initialFlux, }).returning() + + logger.withFields({ userId, initialFlux }).log('Initialized new user flux') + + // Audit: initial grant + await fluxAuditService.log({ + userId, + type: 'initial', + amount: initialFlux, + description: 'Initial grant', + }) } // 3. Populate Redis cache @@ -41,7 +59,7 @@ export function createFluxService(db: Database, redis: Redis, configKV: ConfigKV return record }, - async consumeFlux(userId: string, amount: number) { + async consumeFlux(userId: string, amount: number, options?: { description?: string, metadata?: Record }) { // Ensure Redis key exists before DECRBY // (DECRBY on a nonexistent key creates it at 0, giving wrong balance) await this.getFlux(userId) @@ -55,13 +73,41 @@ export function createFluxService(db: Database, redis: Redis, configKV: ConfigKV const newBalance = await redis.decrby(redisKey(userId), amount) if (newBalance < 0) { await redis.incrby(redisKey(userId), amount) + logger.withFields({ userId, amount }).warn('Insufficient flux, rolled back') throw createPaymentRequiredError('Insufficient flux') } + try { + const [updated] = await db.update(schema.userFlux) + .set({ + flux: sql`${schema.userFlux.flux} - ${amount}`, + updatedAt: new Date(), + }) + .where(eq(schema.userFlux.userId, userId)) + .returning() + + if (!updated) { + throw new Error(`Flux record missing for user ${userId}`) + } + + await fluxAuditService.log({ + userId, + type: 'consumption', + amount: -amount, + description: options?.description ?? 'Usage', + metadata: buildAuditMetadata(options?.metadata), + }) + } + catch (error) { + await redis.incrby(redisKey(userId), amount) + throw error + } + + logger.withFields({ userId, amount, newBalance }).log('Consumed flux') return { userId, flux: newBalance } }, - async addFlux(userId: string, amount: number) { + async addFlux(userId: string, amount: number, description = 'Top-up') { // Ensure user record exists in DB await this.getFlux(userId) @@ -76,6 +122,16 @@ export function createFluxService(db: Database, redis: Redis, configKV: ConfigKV // Sync Redis cache const newBalance = await redis.incrby(redisKey(userId), amount) + logger.withFields({ userId, amount, newBalance, description }).log('Added flux') + + // Audit: addition + await fluxAuditService.log({ + userId, + type: 'addition', + amount, + description, + }) + return { userId, flux: newBalance } }, diff --git a/apps/server/src/services/llm-request-log.ts b/apps/server/src/services/llm-request-log.ts new file mode 100644 index 000000000..1fcd1b715 --- /dev/null +++ b/apps/server/src/services/llm-request-log.ts @@ -0,0 +1,23 @@ +import type { Database } from '../libs/db' + +import * as schema from '../schemas/llm-request-log' + +export interface RequestLogEntry { + userId: string + model: string + status: number + durationMs: number + fluxConsumed: number + promptTokens?: number + completionTokens?: number +} + +export function createLLMRequestLogService(db: Database) { + return { + async logRequest(entry: RequestLogEntry) { + await db.insert(schema.llmRequestLog).values(entry) + }, + } +} + +export type RequestLogService = ReturnType diff --git a/apps/server/src/services/providers.ts b/apps/server/src/services/providers.ts index 45ca699ec..7bb20292f 100644 --- a/apps/server/src/services/providers.ts +++ b/apps/server/src/services/providers.ts @@ -1,9 +1,12 @@ import type { Database } from '../libs/db' +import { useLogger } from '@guiiai/logg' import { and, eq, isNull, sql } from 'drizzle-orm' import * as schema from '../schemas/providers' +const logger = useLogger('providers') + export function createProviderService(db: Database) { return { async findAll(ownerId: string) { @@ -92,6 +95,7 @@ export function createProviderService(db: Database) { async createUserConfig(data: schema.NewUserProviderConfig) { const [inserted] = await db.insert(schema.userProviderConfigs).values(data).returning() + logger.withFields({ id: inserted.id, ownerId: data.ownerId, definitionId: data.definitionId }).log('Created user provider config') return inserted }, @@ -103,17 +107,20 @@ export function createProviderService(db: Database) { isNull(schema.userProviderConfigs.deletedAt), )) .returning() + logger.withFields({ id }).log('Updated user provider config') return updated }, async deleteUserConfig(id: string) { - return await db.update(schema.userProviderConfigs) + const result = await db.update(schema.userProviderConfigs) .set({ deletedAt: new Date() }) .where(and( eq(schema.userProviderConfigs.id, id), isNull(schema.userProviderConfigs.deletedAt), )) .returning() + logger.withFields({ id }).log('Deleted user provider config') + return result }, // System Provider Configs @@ -134,6 +141,7 @@ export function createProviderService(db: Database) { async createSystemConfig(data: schema.NewSystemProviderConfig) { const [inserted] = await db.insert(schema.systemProviderConfigs).values(data).returning() + logger.withFields({ id: inserted.id, definitionId: data.definitionId }).log('Created system provider config') return inserted }, @@ -145,17 +153,20 @@ export function createProviderService(db: Database) { isNull(schema.systemProviderConfigs.deletedAt), )) .returning() + logger.withFields({ id }).log('Updated system provider config') return updated }, async deleteSystemConfig(id: string) { - return await db.update(schema.systemProviderConfigs) + const result = await db.update(schema.systemProviderConfigs) .set({ deletedAt: new Date() }) .where(and( eq(schema.systemProviderConfigs.id, id), isNull(schema.systemProviderConfigs.deletedAt), )) .returning() + logger.withFields({ id }).log('Deleted system provider config') + return result }, } } diff --git a/apps/server/src/services/stripe.ts b/apps/server/src/services/stripe.ts index f0e9baffd..489087256 100644 --- a/apps/server/src/services/stripe.ts +++ b/apps/server/src/services/stripe.ts @@ -1,10 +1,13 @@ import type { Database } from '../libs/db' import type { NewStripeCheckoutSession, NewStripeCustomer, NewStripeInvoice, NewStripeSubscription } from '../schemas/stripe' +import { useLogger } from '@guiiai/logg' import { eq } from 'drizzle-orm' import * as schema from '../schemas/stripe' +const logger = useLogger('stripe-service') + export function createStripeService(db: Database) { return { // ---- Customer ---- @@ -19,12 +22,14 @@ export function createStripeService(db: Database) { .set({ ...data, updatedAt: new Date() }) .where(eq(schema.stripeCustomer.stripeCustomerId, data.stripeCustomerId)) .returning() + logger.withFields({ userId: data.userId, stripeCustomerId: data.stripeCustomerId }).log('Updated Stripe customer') return updated } const [created] = await db.insert(schema.stripeCustomer) .values(data) .returning() + logger.withFields({ userId: data.userId, stripeCustomerId: data.stripeCustomerId }).log('Created Stripe customer') return created }, @@ -52,12 +57,14 @@ export function createStripeService(db: Database) { .set({ ...data, updatedAt: new Date() }) .where(eq(schema.stripeCheckoutSession.stripeSessionId, data.stripeSessionId)) .returning() + logger.withFields({ userId: data.userId, sessionId: data.stripeSessionId, status: data.status }).log('Updated checkout session') return updated } const [created] = await db.insert(schema.stripeCheckoutSession) .values(data) .returning() + logger.withFields({ userId: data.userId, sessionId: data.stripeSessionId, status: data.status }).log('Created checkout session') return created }, @@ -80,12 +87,14 @@ export function createStripeService(db: Database) { .set({ ...data, updatedAt: new Date() }) .where(eq(schema.stripeSubscription.stripeSubscriptionId, data.stripeSubscriptionId)) .returning() + logger.withFields({ userId: data.userId, subscriptionId: data.stripeSubscriptionId, status: data.status }).log('Updated subscription') return updated } const [created] = await db.insert(schema.stripeSubscription) .values(data) .returning() + logger.withFields({ userId: data.userId, subscriptionId: data.stripeSubscriptionId, status: data.status }).log('Created subscription') return created }, @@ -108,12 +117,14 @@ export function createStripeService(db: Database) { .set({ ...data, updatedAt: new Date() }) .where(eq(schema.stripeInvoice.stripeInvoiceId, data.stripeInvoiceId)) .returning() + logger.withFields({ userId: data.userId, invoiceId: data.stripeInvoiceId, status: data.status }).log('Updated invoice') return updated } const [created] = await db.insert(schema.stripeInvoice) .values(data) .returning() + logger.withFields({ userId: data.userId, invoiceId: data.stripeInvoiceId, status: data.status }).log('Created invoice') return created }, diff --git a/packages/i18n/src/locales/en/settings.yaml b/packages/i18n/src/locales/en/settings.yaml index fd480a6bf..3750d5cda 100644 --- a/packages/i18n/src/locales/en/settings.yaml +++ b/packages/i18n/src/locales/en/settings.yaml @@ -20,12 +20,8 @@ dialogs: baseUrlHelp: API endpoint URL (use default if unsure) accountId: Account ID validationSuccess: Configuration validation success - validationPartial: Configuration partially validated validationFailed: Configuration validation failed validationError: 'Validation error: {error}' - testGeneration: Test Generation - testGenerationRunning: Testing... - testGenerationFailed: Generation test failed skipForNow: Skip for now saveAndContinue: Save and Continue next: Next @@ -56,7 +52,9 @@ dialogs: stateNotGranted: Not granted language: title: Language - description: UI language + description: > + Change the language of the AIRI interface. This will not affect the language + of the character's responses. controls-island: icon-size: title: Controls Island Icon Size @@ -64,19 +62,6 @@ controls-island: auto: Auto (responsive) large: Large (default) small: Small -analytics: - notice: - title: Usage analytics - description: AIRI collects anonymous usage analytics to help us understand how the app is used and improve stability. No personal data is collected. - privacyPrefix: Read the - privacyLink: privacy policy - onboardingHint: You can turn analytics off later in Settings > System > General. - settingsHint: You can turn analytics off at any time. - toggle: - title: Enable usage analytics - description: Turn this off to opt out of analytics collection. - disabled: - title: Analytics disabled for this development build live2d: change-model: from-file: Load from File @@ -242,10 +227,6 @@ pages: title: Delete all data description: Wipe every local setting, provider config, and model. delete: Delete all data - desktop-folder: - title: Open app data folder - description: Open AIRI's data folder in your file manager. - open: Open folder desktop: title: Reset desktop settings & states description: Clear AIRI desktop settings and runtime state. @@ -492,6 +473,19 @@ pages: success: Payment successful! Your Flux has been topped up. canceled: Payment was canceled. error: Something went wrong. Please try again later. + audit: + title: Transaction History + time: Time + type: Type + detail: Detail + amount: Amount + typeAddition: Top-up + typeConsumption: Usage + typeInitial: Initial Grant + loading: Loading history... + empty: No transaction records yet. + loadMore: Load More + delayHint: Usage records may be delayed by up to 1 minute packages: title: Flux Packages buy: Charge @@ -800,9 +794,6 @@ pages: openai: description: openai.com title: OpenAI - azure-openai: - description: Azure OpenAI API - title: Azure OpenAI openai-compatible: description: OpenAI Compatible title: OpenAI Compatible @@ -815,9 +806,6 @@ pages: openrouter: description: openrouter.ai title: OpenRouter - openrouter-audio-speech: - description: openrouter.ai - title: OpenRouter perplexity: description: perplexity.ai title: Perplexity @@ -853,9 +841,6 @@ pages: xai: description: x.ai title: xAI - zai: - description: z.ai - title: Z.ai 302-ai: description: 302.AI title: 302.AI diff --git a/packages/i18n/src/locales/zh-Hans/settings.yaml b/packages/i18n/src/locales/zh-Hans/settings.yaml index 5955f1b5e..3e23d26e2 100644 --- a/packages/i18n/src/locales/zh-Hans/settings.yaml +++ b/packages/i18n/src/locales/zh-Hans/settings.yaml @@ -19,12 +19,8 @@ dialogs: baseUrlHelp: API 端点 URL(如果不确定请使用默认值) accountId: 账户 ID validationSuccess: 配置验证成功 - validationPartial: Configuration partially validated validationFailed: 配置验证失败 validationError: '验证错误:{error}' - testGeneration: Test Generation - testGenerationRunning: Testing... - testGenerationFailed: Generation test failed skipForNow: 暂时跳过 saveAndContinue: 保存并继续 next: 下一步 @@ -54,7 +50,8 @@ dialogs: stateNotGranted: Not granted language: title: 语言 - description: UI language + description: > + 切换显示界面的语言 controls-island: icon-size: title: 控制岛图标大小 @@ -62,19 +59,6 @@ controls-island: auto: 自动(响应式) large: 大(默认) small: 小 -analytics: - notice: - title: Usage analytics - description: AIRI collects anonymous usage analytics to help us understand how the app is used and improve stability. No personal data is collected. - privacyPrefix: Read the - privacyLink: privacy policy - onboardingHint: You can turn analytics off later in Settings > System > General. - settingsHint: You can turn analytics off at any time. - toggle: - title: Enable usage analytics - description: Turn this off to opt out of analytics collection. - disabled: - title: Analytics disabled for this development build live2d: change-model: from-file: 从文件加载 @@ -193,7 +177,7 @@ pages: scenario: 场景 search: 搜索角色卡... sort_by: 排序方式 - body-model: Body / Display Model + body-model: 形体 / 显示模型 speech: provider: 语音 / 提供商 model: 声音 / 模型 @@ -235,10 +219,6 @@ pages: title: 删除所有数据 description: 清除每个本地设置、提供商配置和模型。 delete: 删除所有数据 - desktop-folder: - title: Open app data folder - description: Open AIRI's data folder in your file manager. - open: Open folder desktop: title: 重置桌面设置和状态 description: 清除 AIRI 桌面设置和运行状态。 @@ -406,7 +386,7 @@ pages: stop: label: 停止 select-voice: - loading: 正在加载模型…… + loading: 正在加载模型... required: 请选择声线 provider-voice-selection: custom_model_placeholder: 输入指定模型名称... @@ -478,6 +458,19 @@ pages: success: 支付成功!Flux 已充值。 canceled: 支付已取消。 error: 出了点问题,请稍后再试。 + audit: + title: 交易记录 + time: 时间 + type: 类型 + detail: 详情 + amount: 数量 + typeAddition: 充值 + typeConsumption: 消耗 + typeInitial: 初始赠送 + loading: 加载中... + empty: 暂无交易记录 + loadMore: 加载更多 + delayHint: 消耗记录可能有最多 1 分钟的延迟 packages: amount_500: label: 500 Flux @@ -782,9 +775,6 @@ pages: openrouter: description: OpenRouter.ai title: OpenRouter - openrouter-audio-speech: - description: openrouter.ai - title: OpenRouter perplexity: description: perplexity.ai title: Perplexity @@ -820,9 +810,6 @@ pages: xai: description: X.AI title: xAI - zai: - description: z.ai - title: Z.ai 302-ai: description: 302.AI title: 302.AI diff --git a/packages/stage-pages/src/pages/settings/flux.vue b/packages/stage-pages/src/pages/settings/flux.vue index c2de74858..d829337c0 100644 --- a/packages/stage-pages/src/pages/settings/flux.vue +++ b/packages/stage-pages/src/pages/settings/flux.vue @@ -17,6 +17,52 @@ const loadingAmount = ref(null) const message = ref<{ type: 'success' | 'error', text: string } | null>(null) const packages = ref<{ amount: number, label: string, price: string }[]>([]) +interface AuditRecord { + id: string + type: 'consumption' | 'addition' | 'initial' + amount: number + description: string + metadata: { promptTokens?: number, completionTokens?: number } | null + createdAt: string +} + +const auditRecords = ref([]) +const auditLoading = ref(false) +const auditHasMore = ref(false) +const auditOffset = ref(0) +const AUDIT_PAGE_SIZE = 20 + +async function fetchAuditHistory(loadMore = false) { + auditLoading.value = true + try { + const offset = loadMore ? auditOffset.value : 0 + const res = await client.api.flux.history.$get({ + query: { limit: String(AUDIT_PAGE_SIZE), offset: String(offset) }, + }) + if (res.ok) { + const data = await res.json() as { records: AuditRecord[], hasMore: boolean } + if (loadMore) { + auditRecords.value.push(...data.records) + } + else { + auditRecords.value = data.records + } + auditHasMore.value = data.hasMore + auditOffset.value = offset + data.records.length + } + } + catch { + // silently fail + } + finally { + auditLoading.value = false + } +} + +function formatDate(iso: string): string { + return new Date(iso).toLocaleString() +} + async function fetchPackages() { try { const res = await client.api.stripe.packages.$get() @@ -29,7 +75,7 @@ async function fetchPackages() { } onMounted(async () => { - Promise.allSettled([fetchPackages(), authStore.updateCredits()]) + Promise.allSettled([fetchPackages(), authStore.updateCredits(), fetchAuditHistory()]) if (route.query.success === 'true') { message.value = { type: 'success', text: t('settings.pages.flux.checkout.success') } @@ -107,6 +153,134 @@ async function handleBuy(amount: number) { /> + + +
+
+

+ {{ t('settings.pages.flux.audit.title') }} +

+ + {{ t('settings.pages.flux.audit.delayHint') }} + +
+ +
+ {{ t('settings.pages.flux.audit.loading') }} +
+ +
+ {{ t('settings.pages.flux.audit.empty') }} +
+ + + + + +
+
+
+ + {{ record.type === 'consumption' + ? t('settings.pages.flux.audit.typeConsumption') + : record.type === 'addition' + ? t('settings.pages.flux.audit.typeAddition') + : t('settings.pages.flux.audit.typeInitial') }} + + + {{ record.amount >= 0 ? `+${record.amount}` : record.amount }} + +
+
+ {{ record.description }} + + ({{ record.metadata.promptTokens }}+{{ record.metadata.completionTokens }} tokens) + +
+
+ {{ formatDate(record.createdAt) }} +
+
+
+ +
+
+
diff --git a/packages/stage-ui/src/components/scenarios/dialogs/onboarding/step-welcome.vue b/packages/stage-ui/src/components/scenarios/dialogs/onboarding/step-welcome.vue index c8019a5b9..c6c3dce2d 100644 --- a/packages/stage-ui/src/components/scenarios/dialogs/onboarding/step-welcome.vue +++ b/packages/stage-ui/src/components/scenarios/dialogs/onboarding/step-welcome.vue @@ -75,7 +75,7 @@ function handleLocalSetup() { :enter="{ opacity: 1, y: 0 }" :duration="500" :delay="150" - :class="['mx-auto', 'mt-6', 'w-full', 'max-w-sm', 'rounded-2xl', 'bg-neutral-100/80', 'backdrop-blur-sm', 'dark:bg-neutral-800/80', 'p-4']" + :class="['mx-auto', 'mt-6', 'w-full', 'max-w-sm', 'rounded-2xl', 'bg-neutral-100/80', 'p-4', 'backdrop-blur-sm', 'dark:bg-neutral-800/80']" > { + for (const { id } of AUTH_ACTIVATED_PROVIDERS) { + providersStore.setProviderUnconfigured(id) + } + }) } diff --git a/packages/stage-ui/src/stores/providers.ts b/packages/stage-ui/src/stores/providers.ts index 994036201..ce14431e6 100644 --- a/packages/stage-ui/src/stores/providers.ts +++ b/packages/stage-ui/src/stores/providers.ts @@ -1946,6 +1946,14 @@ export const useProvidersStore = defineStore('providers', () => { markProviderAdded(providerId) } + function setProviderUnconfigured(providerId: string) { + if (providerRuntimeState.value[providerId]) { + providerRuntimeState.value[providerId].isConfigured = false + providerRuntimeState.value[providerId].validatedCredentialHash = undefined + } + unmarkProviderAdded(providerId) + } + async function resetProviderSettings() { providerCredentials.value = {} addedProviders.value = {} @@ -2237,6 +2245,7 @@ export const useProvidersStore = defineStore('providers', () => { disposeProviderInstance, resetProviderSettings, forceProviderConfigured, + setProviderUnconfigured, availableProvidersMetadata, allChatProvidersMetadata, allAudioSpeechProvidersMetadata,