diff --git a/apps/server/drizzle/0014_vengeful_blonde_phantom.sql b/apps/server/drizzle/0014_vengeful_blonde_phantom.sql new file mode 100644 index 000000000..30721f324 --- /dev/null +++ b/apps/server/drizzle/0014_vengeful_blonde_phantom.sql @@ -0,0 +1,17 @@ +CREATE TABLE "product_events" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "feature" text NOT NULL, + "action" text NOT NULL, + "status" text NOT NULL, + "source" text, + "model" text, + "provider" text, + "reason" text, + "metadata" jsonb, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE INDEX "product_events_feature_action_created_at_idx" ON "product_events" USING btree ("feature","action","created_at");--> statement-breakpoint +CREATE INDEX "product_events_user_id_created_at_idx" ON "product_events" USING btree ("user_id","created_at");--> statement-breakpoint +CREATE INDEX "product_events_created_at_idx" ON "product_events" USING btree ("created_at"); \ No newline at end of file diff --git a/apps/server/drizzle/meta/0014_snapshot.json b/apps/server/drizzle/meta/0014_snapshot.json new file mode 100644 index 000000000..51820981c --- /dev/null +++ b/apps/server/drizzle/meta/0014_snapshot.json @@ -0,0 +1,2936 @@ +{ + "id": "62e2578e-592c-40a5-85e3-b527aeee5eb8", + "prevId": "37ccaf38-4e30-4edc-a41a-325327701805", + "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.jwks": { + "name": "jwks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_access_token": { + "name": "oauth_access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_id": { + "name": "refresh_id", + "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": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_access_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_access_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_session_id_session_id_fk": { + "name": "oauth_access_token_session_id_session_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_access_token_user_id_user_id_fk": { + "name": "oauth_access_token_user_id_user_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_refresh_id_oauth_refresh_token_id_fk": { + "name": "oauth_access_token_refresh_id_oauth_refresh_token_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_refresh_token", + "columnsFrom": [ + "refresh_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_token_token_unique": { + "name": "oauth_access_token_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "skip_consent": { + "name": "skip_consent", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enable_end_session": { + "name": "enable_end_session", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contacts": { + "name": "contacts", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "tos": { + "name": "tos", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_id": { + "name": "software_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_statement": { + "name": "software_statement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "post_logout_redirect_uris": { + "name": "post_logout_redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_types": { + "name": "grant_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "response_types": { + "name": "response_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "require_pkce": { + "name": "require_pkce", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_client_user_id_user_id_fk": { + "name": "oauth_client_user_id_user_id_fk", + "tableFrom": "oauth_client", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_client_client_id_unique": { + "name": "oauth_client_client_id_unique", + "nullsNotDistinct": false, + "columns": [ + "client_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_consent": { + "name": "oauth_consent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_consent_client_id_oauth_client_client_id_fk": { + "name": "oauth_consent_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consent_user_id_user_id_fk": { + "name": "oauth_consent_user_id_user_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_refresh_token": { + "name": "oauth_refresh_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "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": false + }, + "revoked": { + "name": "revoked", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "auth_time": { + "name": "auth_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_refresh_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_refresh_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_token_session_id_session_id_fk": { + "name": "oauth_refresh_token_session_id_session_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_refresh_token_user_id_user_id_fk": { + "name": "oauth_refresh_token_user_id_user_id_fk", + "tableFrom": "oauth_refresh_token", + "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 + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "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 + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_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": {}, + "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": {}, + "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": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "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_transaction": { + "name": "flux_transaction", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "balance_before": { + "name": "balance_before", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "balance_after": { + "name": "balance_after", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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": { + "flux_tx_user_id_idx": { + "name": "flux_tx_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "flux_tx_created_at_idx": { + "name": "flux_tx_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "flux_tx_user_request_uniq": { + "name": "flux_tx_user_request_uniq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "request_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "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": "bigint", + "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()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "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": "bigint", + "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 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.product_events": { + "name": "product_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feature": { + "name": "feature", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "product_events_feature_action_created_at_idx": { + "name": "product_events_feature_action_created_at_idx", + "columns": [ + { + "expression": "feature", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "product_events_user_id_created_at_idx": { + "name": "product_events_user_id_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "product_events_created_at_idx": { + "name": "product_events_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "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": {}, + "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 + }, + "flux_credited": { + "name": "flux_credited", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "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()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "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 + }, + "flux_credited": { + "name": "flux_credited", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "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": "boolean", + "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()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "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()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "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()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "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 3fb8c8c0d..e8cd1b8df 100644 --- a/apps/server/drizzle/meta/_journal.json +++ b/apps/server/drizzle/meta/_journal.json @@ -99,6 +99,13 @@ "when": 1779728850192, "tag": "0013_naive_groot", "breakpoints": true + }, + { + "idx": 14, + "version": "7", + "when": 1780498188307, + "tag": "0014_vengeful_blonde_phantom", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/server/src/app.test.ts b/apps/server/src/app.test.ts index fbfa0f08c..60f4c08cb 100644 --- a/apps/server/src/app.test.ts +++ b/apps/server/src/app.test.ts @@ -51,6 +51,10 @@ function createTestDeps() { adminUsersService: {} as any, ttsMeter: {} as any, requestLogService: {} as any, + productEventService: { + track: vi.fn(async () => undefined), + countDistinctUsersByFeature: vi.fn(async () => []), + }, configKV: { getOrThrow: vi.fn(async (key: string) => { switch (key) { diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 3607aa948..be827a3d4 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -16,6 +16,7 @@ import type { ChatService } from './services/domain/chats' import type { FluxService } from './services/domain/flux' import type { FluxTransactionService } from './services/domain/flux-transaction' import type { LlmRouterService } from './services/domain/llm-router' +import type { ProductEventService } from './services/domain/product-events' import type { ProviderService } from './services/domain/providers' import type { RequestLogService } from './services/domain/request-log' import type { StripeService } from './services/domain/stripe' @@ -75,6 +76,7 @@ import { createChatService } from './services/domain/chats' import { createFluxService } from './services/domain/flux' import { createFluxTransactionService } from './services/domain/flux-transaction' import { createConfigSyncSubscriber, createLlmRouterService } from './services/domain/llm-router' +import { createProductEventService } from './services/domain/product-events' import { createProviderService } from './services/domain/providers' import { createRequestLogService } from './services/domain/request-log' import { createStripeService } from './services/domain/stripe' @@ -99,6 +101,7 @@ interface AppDeps { adminUsersService: AdminUsersService ttsMeter: FluxMeter requestLogService: RequestLogService + productEventService: ProductEventService configKV: ConfigKVService envelopeCrypto: EnvelopeCrypto redis: Redis @@ -186,6 +189,7 @@ export async function buildApp(deps: AppDeps) { fluxService: deps.fluxService, ttsMeter: deps.ttsMeter, requestLogService: deps.requestLogService, + productEventService: deps.productEventService, }) app.get('/api/v1/audio/speech/ws', upgradeWebSocket(async (c) => { const token = c.req.query('token') @@ -221,11 +225,13 @@ export async function buildApp(deps: AppDeps) { billingService: deps.billingService, configKV: deps.configKV, requestLogService: deps.requestLogService, + productEventService: deps.productEventService, ttsMeter: deps.ttsMeter, llmRouter: deps.llmRouter, genAi: deps.otel?.genAi, revenue: deps.otel?.revenue, rateLimitMetrics: deps.otel?.rateLimit, + posthog: deps.posthog, }) const builtApp = app @@ -351,7 +357,7 @@ export async function buildApp(deps: AppDeps) { /** * Stripe routes. */ - .route('/api/v1/stripe', createStripeRoutes(deps.fluxService, deps.stripeService, deps.billingService, deps.configKV, deps.env, deps.redis, deps.otel?.revenue, deps.otel?.rateLimit, deps.posthog)) + .route('/api/v1/stripe', createStripeRoutes(deps.fluxService, deps.stripeService, deps.billingService, deps.configKV, deps.env, deps.redis, deps.otel?.revenue, deps.otel?.rateLimit, deps.posthog, deps.productEventService)) /** * Admin routes — guarded by the `adminGuard` role check (`role === 'admin'`, @@ -519,6 +525,11 @@ export async function createApp() { }, }) + const productEventService = injeca.provide('services:productEvents', { + dependsOn: { db, otel }, + build: ({ dependsOn }) => createProductEventService(dependsOn.db, dependsOn.otel?.product), + }) + const characterService = injeca.provide('services:characters', { dependsOn: { db, otel }, build: ({ dependsOn }) => createCharacterService(dependsOn.db, dependsOn.otel?.engagement), @@ -530,8 +541,8 @@ export async function createApp() { }) const chatService = injeca.provide('services:chats', { - dependsOn: { db, otel }, - build: ({ dependsOn }) => createChatService(dependsOn.db, dependsOn.otel?.engagement), + dependsOn: { db, otel, productEventService }, + build: ({ dependsOn }) => createChatService(dependsOn.db, dependsOn.otel?.engagement, dependsOn.productEventService), }) const stripeService = injeca.provide('services:stripe', { @@ -579,7 +590,7 @@ export async function createApp() { }) const auth = injeca.provide('services:auth', { - dependsOn: { db, env: parsedEnv, otel, email: emailService, userDeletionService, posthog }, + dependsOn: { db, env: parsedEnv, otel, email: emailService, userDeletionService, posthog, productEventService }, build: async ({ dependsOn }) => { // Seed trusted OIDC clients into DB so FK constraints on oauth_access_token are satisfied await seedTrustedClients(dependsOn.db, dependsOn.env) @@ -592,7 +603,7 @@ export async function createApp() { redirectUris: client.redirectUris.join(', '), }).log('OIDC trusted client ready') } - return createAuth(dependsOn.db, dependsOn.env, dependsOn.email, dependsOn.otel?.auth, dependsOn.userDeletionService, dependsOn.posthog) + return createAuth(dependsOn.db, dependsOn.env, dependsOn.email, dependsOn.otel?.auth, dependsOn.userDeletionService, dependsOn.posthog, dependsOn.productEventService) }, }) @@ -688,6 +699,7 @@ export async function createApp() { fluxService, fluxTransactionService, requestLogService, + productEventService, stripeService, billingService, adminFluxGrantsService, @@ -735,6 +747,7 @@ export async function createApp() { adminUsersService: resolved.adminUsersService, ttsMeter: resolved.ttsMeter, requestLogService: resolved.requestLogService, + productEventService: resolved.productEventService, configKV: resolved.configKV, envelopeCrypto: resolved.envelopeCrypto, redis: resolved.redis, diff --git a/apps/server/src/libs/auth.ts b/apps/server/src/libs/auth.ts index 49bc18998..eee9d775b 100644 --- a/apps/server/src/libs/auth.ts +++ b/apps/server/src/libs/auth.ts @@ -2,6 +2,7 @@ import type { PostHog } from 'posthog-node' import type { AuthMetrics } from '../otel' import type { EmailService } from '../services/adapters/email' +import type { ProductEventService } from '../services/domain/product-events' import type { UserDeletionService } from '../services/domain/user-deletion' import type { Database } from './db' import type { Env } from './env' @@ -365,6 +366,7 @@ export function createAuth( metrics?: AuthMetrics | null, userDeletionService?: UserDeletionService, posthog?: PostHog | null, + productEventService?: ProductEventService, ) { return betterAuth({ secret: env.BETTER_AUTH_SECRET, @@ -663,6 +665,13 @@ export function createAuth( create: { after: async (user) => { metrics?.userRegistered.add(1) + await productEventService?.track({ + userId: user.id, + feature: 'auth', + action: 'user_signed_up', + status: 'succeeded', + source: 'better-auth.user.create', + }) await captureSafe(posthog ?? null, { event: 'user_signed_up', distinctId: user.id, @@ -701,6 +710,13 @@ export function createAuth( .update(authSchema.user) .set({ lastSeenAt: new Date() }) .where(eq(authSchema.user.id, session.userId)) + await productEventService?.track({ + userId: session.userId, + feature: 'auth', + action: 'session_started', + status: 'succeeded', + source: 'better-auth.session.create', + }) await captureSafe(posthog ?? null, { event: 'session_started', distinctId: session.userId, diff --git a/apps/server/src/otel/index.ts b/apps/server/src/otel/index.ts index d750a55e7..daa8df756 100644 --- a/apps/server/src/otel/index.ts +++ b/apps/server/src/otel/index.ts @@ -30,6 +30,7 @@ import { METRIC_AIRI_GEN_AI_GATEWAY_UPSTREAM_ERRORS, METRIC_AIRI_GEN_AI_STREAM_INTERRUPTED, METRIC_AIRI_OBSERVABILITY_READ_ERRORS, + METRIC_AIRI_PRODUCT_EVENTS, METRIC_AIRI_RATE_LIMIT_BLOCKED, METRIC_AIRI_STRIPE_REVENUE, METRIC_AIRI_TTS_CHARS, @@ -299,6 +300,21 @@ export interface ObservabilityMetrics { metricReadErrors: Counter } +export interface ProductMetrics { + /** + * Low-cardinality product event counter. + * + * Use when: + * - Reporting feature/event volume in Prometheus and Grafana. + * + * Expects: + * - Labels stay bounded (`feature`, `action`, `status`, optional + * `source`). Never attach `user_id`, `session_id`, request ids, models + * with unbounded aliases, or free-form error messages here. + */ + events: Counter +} + export interface OtelInstance { auth: AuthMetrics engagement: EngagementMetrics @@ -308,6 +324,7 @@ export interface OtelInstance { email: EmailMetrics rateLimit: RateLimitMetrics observability: ObservabilityMetrics + product: ProductMetrics } /** @@ -511,6 +528,12 @@ export function initOtel(env: Env): OtelInstance | null { }), } + const product: ProductMetrics = { + events: meter.createCounter(METRIC_AIRI_PRODUCT_EVENTS, { + description: 'Low-cardinality product event volume. Distinct users live in Postgres product_events, not Prometheus labels.', + }), + } + // NOTICE: // OTel SDK only emits a Counter time series after .add() runs the first time. // Without this priming step, low-traffic counters (auth_failures_total, @@ -560,10 +583,11 @@ export function initOtel(env: Env): OtelInstance | null { email.failures, rateLimit.blocked, observability.metricReadErrors, + product.events, ] for (const counter of counters) counter.add(0) - return { auth, engagement, revenue, genAi, gateway, email, rateLimit, observability } + return { auth, engagement, revenue, genAi, gateway, email, rateLimit, observability, product } } const severityMap: Record = { diff --git a/apps/server/src/routes/audio-speech-ws/route.test.ts b/apps/server/src/routes/audio-speech-ws/route.test.ts index 86b625fc6..6d6e677c9 100644 --- a/apps/server/src/routes/audio-speech-ws/route.test.ts +++ b/apps/server/src/routes/audio-speech-ws/route.test.ts @@ -166,6 +166,10 @@ function makeFakeDeps(overrides: { const requestLogService = { logRequest: vi.fn(async () => undefined), } + const productEventService = { + track: vi.fn(async () => undefined), + countDistinctUsersByFeature: vi.fn(async () => []), + } const configKV = { getOptional: vi.fn(async (key: string) => { if (key === 'UNSPEECH_UPSTREAM') { @@ -185,7 +189,7 @@ function makeFakeDeps(overrides: { decryptKey: vi.fn(() => Buffer.from(overrides.decryptedKey ?? 'mock-upstream-token', 'utf8')), } - return { configKV, envelopeCrypto, fluxService, ttsMeter, requestLogService } + return { configKV, envelopeCrypto, fluxService, ttsMeter, requestLogService, productEventService } } /** Drives the WSEvents lifecycle as if a real client had connected. */ diff --git a/apps/server/src/routes/audio-speech-ws/session.ts b/apps/server/src/routes/audio-speech-ws/session.ts index f4266da93..d086f025d 100644 --- a/apps/server/src/routes/audio-speech-ws/session.ts +++ b/apps/server/src/routes/audio-speech-ws/session.ts @@ -91,6 +91,15 @@ export function createSessionState(userId: string, opts: AudioSpeechWsHandlersOp } async function dialUpstream() { + void opts.productEventService.track({ + userId, + feature: 'tts', + action: 'speech_requested', + status: 'started', + source: 'audio.speech.ws', + model: modelLabel, + }) + let unspeech: Awaited> try { unspeech = await opts.configKV.getOptional('UNSPEECH_UPSTREAM') @@ -191,6 +200,18 @@ export function createSessionState(userId: string, opts: AudioSpeechWsHandlersOp log.withError(err).withFields({ userId }).warn('upstream ws error') span.recordException(err) span.setStatus({ code: SpanStatusCode.ERROR, message: err.message }) + void opts.productEventService.track({ + userId, + feature: 'tts', + action: 'speech_failed', + status: 'failed', + source: 'audio.speech.ws', + model: modelLabel, + reason: 'upstream_error', + metadata: { + duration_ms: Date.now() - startedAt, + }, + }) try { clientWs?.send(JSON.stringify({ event: 'error', @@ -383,6 +404,20 @@ export function createSessionState(userId: string, opts: AudioSpeechWsHandlersOp log.withError(err).warn('failed to write request log for streaming tts') } + void opts.productEventService.track({ + userId, + feature: 'tts', + action: 'speech_succeeded', + status: 'succeeded', + source: 'audio.speech.ws', + model: modelLabel, + metadata: { + input_chars: units, + duration_ms: durationMs, + flux_consumed: fluxConsumed, + }, + }) + finalize() } @@ -405,6 +440,19 @@ export function createSessionState(userId: string, opts: AudioSpeechWsHandlersOp if (closed) return span.setStatus({ code: SpanStatusCode.ERROR, message: reason }) + void opts.productEventService.track({ + userId, + feature: 'tts', + action: 'speech_failed', + status: 'failed', + source: 'audio.speech.ws', + model: modelLabel, + reason, + metadata: { + close_code: code, + duration_ms: Date.now() - startedAt, + }, + }) if (clientWs) { try { clientWs.send(JSON.stringify({ event: 'error', code: reason, message: reason })) diff --git a/apps/server/src/routes/audio-speech-ws/types.ts b/apps/server/src/routes/audio-speech-ws/types.ts index e3fb19c68..aea679f25 100644 --- a/apps/server/src/routes/audio-speech-ws/types.ts +++ b/apps/server/src/routes/audio-speech-ws/types.ts @@ -1,6 +1,7 @@ import type { ConfigKVService } from '../../services/adapters/config-kv' import type { FluxMeter } from '../../services/domain/billing/flux-meter' import type { FluxService } from '../../services/domain/flux' +import type { ProductEventService } from '../../services/domain/product-events' import type { RequestLogService } from '../../services/domain/request-log' import type { EnvelopeCrypto } from '../../utils/envelope-crypto' @@ -18,4 +19,6 @@ export interface AudioSpeechWsHandlersOptions { ttsMeter: FluxMeter /** Persists request accounting after a stream finishes. */ requestLogService: RequestLogService + /** Writes first-party product analytics for distinct-user aggregation. */ + productEventService: ProductEventService } diff --git a/apps/server/src/routes/openai/v1/operations/chat-completions/index.ts b/apps/server/src/routes/openai/v1/operations/chat-completions/index.ts index 71d273fc7..c819be162 100644 --- a/apps/server/src/routes/openai/v1/operations/chat-completions/index.ts +++ b/apps/server/src/routes/openai/v1/operations/chat-completions/index.ts @@ -55,6 +55,18 @@ export function chatCompletions(deps: V1RouteDeps): GatewayCallback<'chat.comple stream, messageCount: Array.isArray(body.messages) ? body.messages.length : undefined, }).log('chat completion request') + void deps.productEventService.track({ + userId: input.userId, + feature: 'gen_ai_chat', + action: 'completion_requested', + status: 'started', + source: 'openai.chat.completions', + model: requestModel, + metadata: { + stream, + message_count: Array.isArray(body.messages) ? body.messages.length : null, + }, + }) // Server-connection attrs come from the router (which knows the actual // upstream baseURL it dispatched to) — it enriches the active span with @@ -90,6 +102,20 @@ export function chatCompletions(deps: V1RouteDeps): GatewayCallback<'chat.comple sessionId: input.sessionId, }).fail('Router exhausted or unknown model') telemetry.recordMetrics({ model: requestModel, status: 502, type: 'chat', provider: routeCtx.provider, durationMs: Date.now() - startedAt, fluxConsumed: 0 }) + void deps.productEventService.track({ + userId: input.userId, + feature: 'gen_ai_chat', + action: 'completion_failed', + status: 'failed', + source: 'openai.chat.completions', + model: requestModel, + provider: routeCtx.provider, + reason: 'router_exhausted', + metadata: { + duration_ms: Date.now() - startedAt, + stream, + }, + }) throw err } @@ -115,6 +141,21 @@ export function chatCompletions(deps: V1RouteDeps): GatewayCallback<'chat.comple telemetry.failSpan(span, `Gateway ${response.status}`) generationTrace.fail(`Gateway ${response.status}`) telemetry.recordMetrics({ model: requestModel, status: response.status, type: 'chat', provider: routeCtx.provider, durationMs, fluxConsumed: 0 }) + void deps.productEventService.track({ + userId: input.userId, + feature: 'gen_ai_chat', + action: 'completion_failed', + status: 'failed', + source: 'openai.chat.completions', + model: requestModel, + provider: routeCtx.provider, + reason: 'upstream_error', + metadata: { + http_status: response.status, + duration_ms: durationMs, + stream, + }, + }) // Emit server-side so funnels see real HTTP status — the client only // ever observes "stream closed" and cannot tell 401 / 429 / 5xx apart. void captureSafe(deps.posthog ?? null, { @@ -254,6 +295,21 @@ function streamChatCompletion(input: { input.telemetry.endSpan(input.span) input.generationTrace.fail('Gateway stream interrupted') input.telemetry.recordMetrics({ model: input.requestModel, status: input.response.status, type: 'chat', provider: input.routeCtxProvider, durationMs: input.durationMs, fluxConsumed: 0 }) + void input.deps.productEventService.track({ + userId: input.userId, + feature: 'gen_ai_chat', + action: 'completion_failed', + status: 'failed', + source: 'openai.chat.completions', + model: input.requestModel, + provider: input.routeCtxProvider, + reason: 'stream_interrupted', + metadata: { + http_status: input.response.status, + duration_ms: input.durationMs, + stream: true, + }, + }) } else if (streamCompleted) { try { @@ -326,6 +382,23 @@ function streamChatCompletion(input: { promptTokens: usage.promptTokens, completionTokens: usage.completionTokens, }) + void input.deps.productEventService.track({ + userId: input.userId, + feature: 'gen_ai_chat', + action: 'completion_succeeded', + status: 'succeeded', + source: 'openai.chat.completions', + model: input.requestModel, + provider: input.routeCtxProvider, + metadata: { + http_status: input.response.status, + duration_ms: input.durationMs, + prompt_tokens: usage.promptTokens ?? 0, + completion_tokens: usage.completionTokens ?? 0, + flux_consumed: actualCharged, + stream: true, + }, + }) void captureSafe(input.deps.posthog ?? null, { distinctId: input.userId, @@ -390,6 +463,21 @@ async function completeNonStreamingChat(input: { input.telemetry.failSpan(input.span, 'Failed to parse upstream response body') input.generationTrace.fail('Failed to parse upstream response body') input.telemetry.recordMetrics({ model: input.requestModel, status: input.response.status, type: 'chat', provider: input.routeCtxProvider, durationMs: input.durationMs, fluxConsumed: 0 }) + void input.deps.productEventService.track({ + userId: input.userId, + feature: 'gen_ai_chat', + action: 'completion_failed', + status: 'failed', + source: 'openai.chat.completions', + model: input.requestModel, + provider: input.routeCtxProvider, + reason: 'malformed_upstream_response', + metadata: { + http_status: input.response.status, + duration_ms: input.durationMs, + stream: false, + }, + }) throw err } const usage = extractUsageFromBody(responseBody) @@ -428,6 +516,23 @@ async function completeNonStreamingChat(input: { promptTokens: usage.promptTokens, completionTokens: usage.completionTokens, }) + void input.deps.productEventService.track({ + userId: input.userId, + feature: 'gen_ai_chat', + action: 'completion_succeeded', + status: 'succeeded', + source: 'openai.chat.completions', + model: input.requestModel, + provider: input.routeCtxProvider, + metadata: { + http_status: input.response.status, + duration_ms: input.durationMs, + prompt_tokens: usage.promptTokens ?? 0, + completion_tokens: usage.completionTokens ?? 0, + flux_consumed: actualCharged, + stream: false, + }, + }) void captureSafe(input.deps.posthog ?? null, { distinctId: input.userId, diff --git a/apps/server/src/routes/openai/v1/operations/speech-generation/index.ts b/apps/server/src/routes/openai/v1/operations/speech-generation/index.ts index f93d8da5e..b6242459b 100644 --- a/apps/server/src/routes/openai/v1/operations/speech-generation/index.ts +++ b/apps/server/src/routes/openai/v1/operations/speech-generation/index.ts @@ -17,6 +17,7 @@ export function speechGeneration(deps: V1RouteDeps): GatewayCallback<'speech.gen genAi: deps.genAi, llmRouter: deps.llmRouter, llmTracing: deps.llmTracing, + productEventService: deps.productEventService, requestLogService: deps.requestLogService, ttsMeter: deps.ttsMeter, }) diff --git a/apps/server/src/routes/openai/v1/route.test.ts b/apps/server/src/routes/openai/v1/route.test.ts index 7b919f547..b7117752a 100644 --- a/apps/server/src/routes/openai/v1/route.test.ts +++ b/apps/server/src/routes/openai/v1/route.test.ts @@ -142,6 +142,10 @@ function createTestApp( billingService: billingService ?? createMockBillingService(), configKV, requestLogService: requestLogService ?? createMockRequestLogService(), + productEventService: { + track: vi.fn(async () => undefined), + countDistinctUsersByFeature: vi.fn(async () => []), + }, ttsMeter: ttsMeter ?? createMockTtsMeter(), llmRouter: llmRouter ?? createMockLlmRouter(), genAi: null, diff --git a/apps/server/src/routes/openai/v1/types.ts b/apps/server/src/routes/openai/v1/types.ts index f453e9ba1..e1b1e9490 100644 --- a/apps/server/src/routes/openai/v1/types.ts +++ b/apps/server/src/routes/openai/v1/types.ts @@ -7,6 +7,7 @@ import type { FluxMeter } from '../../../services/domain/billing/flux-meter' import type { FluxService } from '../../../services/domain/flux' import type { LlmRouterService } from '../../../services/domain/llm-router' import type { ChatGenerationTrace, TtsGenerationTrace } from '../../../services/domain/llm-tracing' +import type { ProductEventService } from '../../../services/domain/product-events' import type { RequestLogService } from '../../../services/domain/request-log' import { startChatGeneration, startTtsGeneration } from '../../../services/domain/llm-tracing' @@ -21,6 +22,7 @@ export interface V1RouteDeps { billingService: BillingService configKV: ConfigKVService requestLogService: RequestLogService + productEventService: ProductEventService ttsMeter: FluxMeter llmRouter: LlmRouterService genAi?: GenAiMetrics | null diff --git a/apps/server/src/routes/stripe/index.ts b/apps/server/src/routes/stripe/index.ts index d96446070..23af9218f 100644 --- a/apps/server/src/routes/stripe/index.ts +++ b/apps/server/src/routes/stripe/index.ts @@ -6,6 +6,7 @@ import type { RateLimitMetrics, RevenueMetrics } from '../../otel' import type { ConfigKVService } from '../../services/adapters/config-kv' import type { BillingService } from '../../services/domain/billing/billing-service' import type { FluxService } from '../../services/domain/flux' +import type { ProductEventService } from '../../services/domain/product-events' import type { StripeService } from '../../services/domain/stripe' import type { HonoEnv } from '../../types/hono' @@ -47,10 +48,11 @@ export function createStripeRoutes( metrics?: RevenueMetrics | null, rateLimitMetrics?: RateLimitMetrics | null, posthog?: PostHog | null, + productEventService?: ProductEventService, ) { const stripe = env.STRIPE_SECRET_KEY ? new Stripe(env.STRIPE_SECRET_KEY) : null const priceCatalog = stripe ? createStripePriceCatalog(stripe, redis) : null - const checkout = createCheckoutOperation({ stripe, priceCatalog, stripeService, configKV, env, metrics }) + const checkout = createCheckoutOperation({ stripe, priceCatalog, stripeService, configKV, env, metrics, productEventService }) const webhook = createWebhookOperation({ stripe, webhookSecret: env.STRIPE_WEBHOOK_SECRET, @@ -59,6 +61,7 @@ export function createStripeRoutes( billingService, metrics, posthog, + productEventService, }) return new Hono() diff --git a/apps/server/src/routes/stripe/operations/checkout.ts b/apps/server/src/routes/stripe/operations/checkout.ts index 2122186ff..dc8e29f94 100644 --- a/apps/server/src/routes/stripe/operations/checkout.ts +++ b/apps/server/src/routes/stripe/operations/checkout.ts @@ -3,6 +3,7 @@ import type Stripe from 'stripe' import type { Env } from '../../../libs/env' import type { RevenueMetrics } from '../../../otel' import type { ConfigKVService } from '../../../services/adapters/config-kv' +import type { ProductEventService } from '../../../services/domain/product-events' import type { StripeService } from '../../../services/domain/stripe' import type { HonoEnv } from '../../../types/hono' import type { StripePriceCatalog } from '../price-catalog' @@ -23,6 +24,7 @@ export interface CheckoutOperationDeps { configKV: ConfigKVService env: Env metrics?: RevenueMetrics | null + productEventService?: ProductEventService } export interface CheckoutOperationInput { @@ -121,6 +123,18 @@ export function createCheckoutOperation(deps: CheckoutOperationDeps) { }) deps.metrics?.stripeCheckoutCreated.add(1) + void deps.productEventService?.track({ + userId: input.user.id, + feature: 'billing', + action: 'checkout_started', + status: 'succeeded', + source: 'stripe.checkout', + metadata: { + flux_amount: fluxAmount, + amount_total: session.amount_total, + currency: session.currency, + }, + }) return { url: session.url } } diff --git a/apps/server/src/routes/stripe/operations/webhook.ts b/apps/server/src/routes/stripe/operations/webhook.ts index 0ed9418a9..9f7661958 100644 --- a/apps/server/src/routes/stripe/operations/webhook.ts +++ b/apps/server/src/routes/stripe/operations/webhook.ts @@ -4,6 +4,7 @@ import type Stripe from 'stripe' import type { RevenueMetrics } from '../../../otel' import type { BillingService } from '../../../services/domain/billing/billing-service' import type { FluxService } from '../../../services/domain/flux' +import type { ProductEventService } from '../../../services/domain/product-events' import type { StripeService } from '../../../services/domain/stripe' import { useLogger } from '@guiiai/logg' @@ -22,6 +23,7 @@ export interface WebhookOperationDeps { billingService: BillingService metrics?: RevenueMetrics | null posthog?: PostHog | null + productEventService?: ProductEventService } export interface WebhookOperationInput { @@ -80,8 +82,25 @@ export function createWebhookOperation(deps: WebhookOperationDeps) { // is the Better Auth user id so it merges with the browser's // `posthog.identify(userId)` and the prior `checkout_started` // event lines up. See docs/ai-context/metrics-ownership.md. - if (result.processed) + if (result.processed) { + const userId = event.data.object.metadata?.userId + if (userId) { + const fluxAmount = Number(event.data.object.metadata?.fluxAmount) + void deps.productEventService?.track({ + userId, + feature: 'billing', + action: 'payment_completed', + status: 'succeeded', + source: 'stripe.webhook', + metadata: { + amount_total: event.data.object.amount_total, + currency: event.data.object.currency, + flux_amount: Number.isFinite(fluxAmount) ? fluxAmount : null, + }, + }) + } await capturePaymentCompleted(deps.posthog, event.data.object) + } break } case 'customer.created': diff --git a/apps/server/src/schemas/index.ts b/apps/server/src/schemas/index.ts index 4a0090f7b..deeb0b522 100644 --- a/apps/server/src/schemas/index.ts +++ b/apps/server/src/schemas/index.ts @@ -4,6 +4,7 @@ export * from './chats' export * from './flux' export * from './flux-transaction' export * from './llm-request-log' +export * from './product-events' export * from './providers' export * from './stripe' export * from './user-character' diff --git a/apps/server/src/schemas/product-events.ts b/apps/server/src/schemas/product-events.ts new file mode 100644 index 000000000..3aef1d736 --- /dev/null +++ b/apps/server/src/schemas/product-events.ts @@ -0,0 +1,32 @@ +import type { InferInsertModel, InferSelectModel } from 'drizzle-orm' + +import { index, jsonb, pgTable, text, timestamp } from 'drizzle-orm/pg-core' + +import { nanoid } from '../utils/id' + +export type ProductEventMetadata = Record + +export const productEvents = pgTable( + 'product_events', + { + id: text('id').primaryKey().$defaultFn(() => nanoid()), + userId: text('user_id').notNull(), + feature: text('feature').notNull(), + action: text('action').notNull(), + status: text('status').notNull(), + source: text('source'), + model: text('model'), + provider: text('provider'), + reason: text('reason'), + metadata: jsonb('metadata').$type(), + createdAt: timestamp('created_at').defaultNow().notNull(), + }, + table => [ + index('product_events_feature_action_created_at_idx').on(table.feature, table.action, table.createdAt), + index('product_events_user_id_created_at_idx').on(table.userId, table.createdAt), + index('product_events_created_at_idx').on(table.createdAt), + ], +) + +export type ProductEvent = InferSelectModel +export type NewProductEvent = InferInsertModel diff --git a/apps/server/src/services/domain/chats.ts b/apps/server/src/services/domain/chats.ts index 3b7c1db73..b3e7a5303 100644 --- a/apps/server/src/services/domain/chats.ts +++ b/apps/server/src/services/domain/chats.ts @@ -2,6 +2,7 @@ import type { MessageRole, WireMessage } from '@proj-airi/server-sdk-shared' import type { Database } from '../../libs/db' import type { EngagementMetrics } from '../../otel' +import type { ProductEventService } from './product-events' import { useLogger } from '@guiiai/logg' import { and, eq, gt, inArray, isNull, sql } from 'drizzle-orm' @@ -49,7 +50,7 @@ export function resolveSenderId(role: string, userId: string, characterId?: stri // Service factory // --------------------------------------------------------------------------- -export function createChatService(db: Database, metrics?: EngagementMetrics | null) { +export function createChatService(db: Database, metrics?: EngagementMetrics | null, productEventService?: ProductEventService) { // ---- internal helpers --------------------------------------------------- async function verifyMembership(tx: Parameters[0]>[0], chatId: string, userId: string) { @@ -299,6 +300,17 @@ export function createChatService(db: Database, metrics?: EngagementMetrics | nu if (result.totalCount > 0) { metrics?.chatMessages.add(result.totalCount) + void productEventService?.track({ + userId, + feature: 'chat', + action: 'message_pushed', + status: 'succeeded', + source: 'chat.ws.push_messages', + metadata: { + message_count: result.totalCount, + new_count: result.newCount, + }, + }) } metrics?.wsMessagesReceived.add(result.totalCount) diff --git a/apps/server/src/services/domain/openai-speech/index.ts b/apps/server/src/services/domain/openai-speech/index.ts index 22259db0b..1fdbf56ab 100644 --- a/apps/server/src/services/domain/openai-speech/index.ts +++ b/apps/server/src/services/domain/openai-speech/index.ts @@ -4,6 +4,7 @@ import type { FluxMeter } from '../billing/flux-meter' import type { FluxService } from '../flux' import type { LlmRouterService } from '../llm-router' import type { startTtsGeneration, TtsGenerationTrace } from '../llm-tracing' +import type { ProductEventService } from '../product-events' import type { RequestLogService } from '../request-log' import { useLogger } from '@guiiai/logg' @@ -32,6 +33,7 @@ export interface OpenAiSpeechServiceDeps { requestLogService: RequestLogService ttsMeter: FluxMeter llmRouter: LlmRouterService + productEventService: ProductEventService genAi?: GenAiMetrics | null llmTracing: { startTtsGeneration: (input: Parameters[0]) => TtsGenerationTrace @@ -78,6 +80,18 @@ export function createOpenAiSpeechService(deps: OpenAiSpeechServiceDeps) { voice: typeof input.body.voice === 'string' ? input.body.voice : undefined, }).log('tts speech request') + void deps.productEventService.track({ + userId: input.userId, + feature: 'tts', + action: 'speech_requested', + status: 'started', + source: 'audio.speech', + model: requestModel, + metadata: { + input_chars: inputText.length, + }, + }) + const flux = await deps.fluxService.getFlux(input.userId) if (flux.flux <= 0) throw createPaymentRequiredError('Insufficient flux') @@ -127,6 +141,19 @@ export function createOpenAiSpeechService(deps: OpenAiSpeechServiceDeps) { provider: routeCtx.provider, status: 502, }) + void deps.productEventService.track({ + userId: input.userId, + feature: 'tts', + action: 'speech_failed', + status: 'failed', + source: 'audio.speech', + model: requestModel, + provider: routeCtx.provider, + reason: 'router_exhausted', + metadata: { + duration_ms: Date.now() - startedAt, + }, + }) throw err } @@ -138,6 +165,20 @@ export function createOpenAiSpeechService(deps: OpenAiSpeechServiceDeps) { span.end() generationTrace.fail(`Gateway ${response.status}`) recordMetrics({ model: requestModel, status: response.status, provider: routeCtx.provider, durationMs, fluxConsumed: 0 }) + void deps.productEventService.track({ + userId: input.userId, + feature: 'tts', + action: 'speech_failed', + status: 'failed', + source: 'audio.speech', + model: requestModel, + provider: routeCtx.provider, + reason: 'upstream_error', + metadata: { + http_status: response.status, + duration_ms: durationMs, + }, + }) logger.withFields({ requestId, userId: input.userId, model: requestModel, status: response.status, durationMs }) .warn('tts speech delivered with upstream error status') return new Response(response.body, { @@ -172,6 +213,21 @@ export function createOpenAiSpeechService(deps: OpenAiSpeechServiceDeps) { } recordMetrics({ model: requestModel, status: response.status, provider: routeCtx.provider, durationMs, fluxConsumed }) + void deps.productEventService.track({ + userId: input.userId, + feature: 'tts', + action: 'speech_succeeded', + status: 'succeeded', + source: 'audio.speech', + model: requestModel, + provider: routeCtx.provider, + metadata: { + http_status: response.status, + input_chars: inputText.length, + duration_ms: durationMs, + flux_consumed: fluxConsumed, + }, + }) deps.requestLogService.logRequest({ userId: input.userId, model: requestModel, diff --git a/apps/server/src/services/domain/product-events.test.ts b/apps/server/src/services/domain/product-events.test.ts new file mode 100644 index 000000000..5a49956bb --- /dev/null +++ b/apps/server/src/services/domain/product-events.test.ts @@ -0,0 +1,102 @@ +import type { Database } from '../../libs/db' +import type { ProductMetrics } from '../../otel' + +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +import { mockDB } from '../../libs/mock-db' +import { createProductEventService } from './product-events' + +import * as schema from '../../schemas' + +describe('productEventService', () => { + let db: Database + + beforeAll(async () => { + db = await mockDB(schema) + }) + + beforeEach(async () => { + await db.delete(schema.productEvents) + }) + + it('writes first-party events and increments only low-cardinality metric labels', async () => { + const events = { add: vi.fn() } + const service = createProductEventService(db, { events } as unknown as ProductMetrics) + + await service.track({ + userId: 'user-1', + feature: 'gen_ai_chat', + action: 'completion_succeeded', + status: 'succeeded', + source: 'openai.chat.completions', + model: 'openrouter/anthropic/claude-sonnet-4', + provider: 'openrouter', + metadata: { + stream: false, + flux_consumed: 3, + }, + }) + + const rows = await db.select().from(schema.productEvents) + expect(rows).toHaveLength(1) + expect(rows[0]).toMatchObject({ + userId: 'user-1', + feature: 'gen_ai_chat', + action: 'completion_succeeded', + status: 'succeeded', + source: 'openai.chat.completions', + model: 'openrouter/anthropic/claude-sonnet-4', + provider: 'openrouter', + }) + + expect(events.add).toHaveBeenCalledWith(1, { + feature: 'gen_ai_chat', + action: 'completion_succeeded', + status: 'succeeded', + source: 'openai.chat.completions', + }) + }) + + it('aggregates event volume and distinct users by feature/action/status', async () => { + const service = createProductEventService(db) + const createdAt = new Date('2026-06-03T00:00:00.000Z') + + await service.track({ + userId: 'user-1', + feature: 'tts', + action: 'speech_succeeded', + status: 'succeeded', + source: 'audio.speech', + createdAt, + }) + await service.track({ + userId: 'user-1', + feature: 'tts', + action: 'speech_succeeded', + status: 'succeeded', + source: 'audio.speech.ws', + createdAt, + }) + await service.track({ + userId: 'user-2', + feature: 'tts', + action: 'speech_succeeded', + status: 'succeeded', + source: 'audio.speech', + createdAt, + }) + + const rows = await service.countDistinctUsersByFeature({ + from: new Date('2026-06-02T00:00:00.000Z'), + to: new Date('2026-06-04T00:00:00.000Z'), + }) + + expect(rows).toEqual([{ + feature: 'tts', + action: 'speech_succeeded', + status: 'succeeded', + eventCount: 3, + distinctUsers: 2, + }]) + }) +}) diff --git a/apps/server/src/services/domain/product-events.ts b/apps/server/src/services/domain/product-events.ts new file mode 100644 index 000000000..74cc3adb8 --- /dev/null +++ b/apps/server/src/services/domain/product-events.ts @@ -0,0 +1,152 @@ +import type { Database } from '../../libs/db' +import type { ProductMetrics } from '../../otel' +import type { ProductEventMetadata } from '../../schemas/product-events' + +import { useLogger } from '@guiiai/logg' +import { and, asc, count, gte, lt, sql } from 'drizzle-orm' + +import * as schema from '../../schemas/product-events' + +const logger = useLogger('product-events') + +export type ProductFeature = 'auth' | 'chat' | 'gen_ai_chat' | 'tts' | 'billing' + +export type ProductEventStatus = 'started' | 'succeeded' | 'failed' + +export type ProductAction + = | 'user_signed_up' + | 'session_started' + | 'message_pushed' + | 'completion_requested' + | 'completion_succeeded' + | 'completion_failed' + | 'speech_requested' + | 'speech_succeeded' + | 'speech_failed' + | 'checkout_started' + | 'payment_completed' + +/** + * Product event fact written to AIRI's own Postgres analytics table. + */ +export interface ProductEventInput { + /** Better Auth user id. Kept in Postgres only; never emitted as a Prometheus label. */ + userId: string + /** Bounded product area used for product dashboards and funnels. */ + feature: ProductFeature + /** Bounded user/business action within the feature. */ + action: ProductAction + /** Lifecycle state for the action. */ + status: ProductEventStatus + /** Optional bounded route/surface label such as `openai.chat.completions`. */ + source?: string + /** Optional model alias for DB-side drilldown. Do not expose as a Prometheus label. */ + model?: string + /** Optional provider name for DB-side drilldown. */ + provider?: string + /** Optional bounded failure reason or business outcome. */ + reason?: string + /** Optional primitive metadata for product analysis. Avoid PII and raw prompts. */ + metadata?: ProductEventMetadata + /** Override for tests/backfills. Defaults to database/server current time. */ + createdAt?: Date +} + +export interface ProductEventAggregateInput { + /** Inclusive lower time bound. */ + from: Date + /** Exclusive upper time bound. Omit for open-ended queries. */ + to?: Date +} + +export interface ProductEventAggregateRow { + feature: string + action: string + status: string + eventCount: number + distinctUsers: number +} + +/** + * Creates AIRI's first-party product analytics event writer. + * + * Use when: + * - Server-side product behavior has a user id and should be queryable by + * distinct users, funnels, or retention windows. + * - Grafana needs low-cardinality event volume while Postgres keeps user-level + * detail. + * + * Expects: + * - Callers pass only bounded `feature` / `action` / `status` values. + * - PII, prompts, request ids, sessions, and user ids are not written into + * Prometheus labels. User id is stored only in the DB row. + * + * Returns: + * - Best-effort event writer plus a DB aggregation helper for analytics jobs. + */ +export function createProductEventService(db: Database, metrics?: ProductMetrics | null) { + return { + async track(input: ProductEventInput): Promise { + try { + await db.insert(schema.productEvents).values({ + userId: input.userId, + feature: input.feature, + action: input.action, + status: input.status, + source: input.source, + model: input.model, + provider: input.provider, + reason: input.reason, + metadata: input.metadata, + createdAt: input.createdAt, + }) + + const attrs: Record = { + feature: input.feature, + action: input.action, + status: input.status, + } + if (input.source) + attrs.source = input.source + metrics?.events.add(1, attrs) + } + catch (err) { + logger.withError(err).withFields({ + userId: input.userId, + feature: input.feature, + action: input.action, + status: input.status, + }).warn('Failed to write product event; swallowing to protect caller') + } + }, + + async countDistinctUsersByFeature(input: ProductEventAggregateInput): Promise { + const where = input.to + ? and(gte(schema.productEvents.createdAt, input.from), lt(schema.productEvents.createdAt, input.to)) + : gte(schema.productEvents.createdAt, input.from) + + const rows = await db + .select({ + feature: schema.productEvents.feature, + action: schema.productEvents.action, + status: schema.productEvents.status, + eventCount: count(), + distinctUsers: sql`count(distinct ${schema.productEvents.userId})::int`, + }) + .from(schema.productEvents) + .where(where) + .groupBy(schema.productEvents.feature, schema.productEvents.action, schema.productEvents.status) + .orderBy(asc(schema.productEvents.feature), asc(schema.productEvents.action), asc(schema.productEvents.status)) + + return rows.map(row => ({ + feature: row.feature, + action: row.action, + status: row.status, + eventCount: Number(row.eventCount), + distinctUsers: Number(row.distinctUsers), + })) + }, + } +} + +export type ProductEventService = ReturnType diff --git a/apps/server/src/utils/observability.ts b/apps/server/src/utils/observability.ts index 1ca596b06..7db85b95a 100644 --- a/apps/server/src/utils/observability.ts +++ b/apps/server/src/utils/observability.ts @@ -97,6 +97,11 @@ export const METRIC_AIRI_TTS_PREFLIGHT_REJECTIONS = 'airi.billing.tts.preflight_ // AIRI observability — self-monitoring for the metric pipeline export const METRIC_AIRI_OBSERVABILITY_READ_ERRORS = 'airi.observability.read_errors' +// Product analytics — low-cardinality event volume only. User-level product +// analytics live in Postgres `product_events`; never add user identifiers to +// this metric's labels. +export const METRIC_AIRI_PRODUCT_EVENTS = 'airi.product.events' + // AIRI revenue — actual money in (smallest currency unit, e.g. cents) export const METRIC_AIRI_STRIPE_REVENUE = 'airi.stripe.revenue'