Initial import
This commit is contained in:
8
packages/AGENTS.md
Normal file
8
packages/AGENTS.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Scope
|
||||
Applies within `packages/` unless a deeper file overrides it.
|
||||
|
||||
## Rules
|
||||
- Shared packages are the source of business and integration logic.
|
||||
- Keep boundaries clear: `domain` decides policy, `providers` talks to external systems, `db` owns schema.
|
||||
8
packages/config/README.md
Normal file
8
packages/config/README.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# packages/config
|
||||
|
||||
Shared runtime configuration package for environment parsing and normalization.
|
||||
|
||||
## Implemented in this iteration
|
||||
- Typed environment loader
|
||||
- Normalized app, database, provider, storage, Telegram, email, and key-pool settings
|
||||
- Small helpers for required values, integers, booleans, and URL parsing
|
||||
21
packages/config/package.json
Normal file
21
packages/config/package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@nproxy/config",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"check": "tsc -p tsconfig.json --noEmit"
|
||||
}
|
||||
}
|
||||
0
packages/config/src/.gitkeep
Normal file
0
packages/config/src/.gitkeep
Normal file
160
packages/config/src/index.ts
Normal file
160
packages/config/src/index.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
export interface RuntimeUrls {
|
||||
appBaseUrl: URL;
|
||||
adminBaseUrl: URL;
|
||||
nanoBananaApiBaseUrl: URL;
|
||||
s3Endpoint: URL;
|
||||
}
|
||||
|
||||
export interface DatabaseConfig {
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface AuthConfig {
|
||||
sessionSecret: string;
|
||||
passwordPepper: string;
|
||||
}
|
||||
|
||||
export interface ProviderConfig {
|
||||
nanoBananaDefaultModel: string;
|
||||
}
|
||||
|
||||
export interface PaymentConfig {
|
||||
provider: string;
|
||||
apiKey: string;
|
||||
webhookSecret: string;
|
||||
}
|
||||
|
||||
export interface StorageConfig {
|
||||
region: string;
|
||||
bucket: string;
|
||||
accessKey: string;
|
||||
secretKey: string;
|
||||
forcePathStyle: boolean;
|
||||
}
|
||||
|
||||
export interface TelegramConfig {
|
||||
botToken: string;
|
||||
botMode: "polling";
|
||||
}
|
||||
|
||||
export interface EmailConfig {
|
||||
provider: string;
|
||||
from: string;
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
export interface KeyPoolConfig {
|
||||
cooldownMinutes: number;
|
||||
failuresBeforeManualReview: number;
|
||||
balancePollSeconds: number;
|
||||
}
|
||||
|
||||
export interface AppRuntimeConfig {
|
||||
nodeEnv: string;
|
||||
urls: RuntimeUrls;
|
||||
database: DatabaseConfig;
|
||||
auth: AuthConfig;
|
||||
provider: ProviderConfig;
|
||||
payment: PaymentConfig;
|
||||
storage: StorageConfig;
|
||||
telegram: TelegramConfig;
|
||||
email: EmailConfig;
|
||||
keyPool: KeyPoolConfig;
|
||||
}
|
||||
|
||||
export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppRuntimeConfig {
|
||||
return {
|
||||
nodeEnv: readString(env, "NODE_ENV"),
|
||||
urls: {
|
||||
appBaseUrl: readUrl(env, "APP_BASE_URL"),
|
||||
adminBaseUrl: readUrl(env, "ADMIN_BASE_URL"),
|
||||
nanoBananaApiBaseUrl: readUrl(env, "NANO_BANANA_API_BASE_URL"),
|
||||
s3Endpoint: readUrl(env, "S3_ENDPOINT"),
|
||||
},
|
||||
database: {
|
||||
url: readString(env, "DATABASE_URL"),
|
||||
},
|
||||
auth: {
|
||||
sessionSecret: readString(env, "SESSION_SECRET"),
|
||||
passwordPepper: readString(env, "PASSWORD_PEPPER"),
|
||||
},
|
||||
provider: {
|
||||
nanoBananaDefaultModel: readString(env, "NANO_BANANA_DEFAULT_MODEL"),
|
||||
},
|
||||
payment: {
|
||||
provider: readString(env, "PAYMENT_PROVIDER"),
|
||||
apiKey: readString(env, "PAYMENT_PROVIDER_API_KEY"),
|
||||
webhookSecret: readString(env, "PAYMENT_PROVIDER_WEBHOOK_SECRET"),
|
||||
},
|
||||
storage: {
|
||||
region: readString(env, "S3_REGION"),
|
||||
bucket: readString(env, "S3_BUCKET"),
|
||||
accessKey: readString(env, "S3_ACCESS_KEY"),
|
||||
secretKey: readString(env, "S3_SECRET_KEY"),
|
||||
forcePathStyle: readBoolean(env, "S3_FORCE_PATH_STYLE"),
|
||||
},
|
||||
telegram: {
|
||||
botToken: readString(env, "TELEGRAM_BOT_TOKEN"),
|
||||
botMode: readTelegramMode(env, "TELEGRAM_BOT_MODE"),
|
||||
},
|
||||
email: {
|
||||
provider: readString(env, "EMAIL_PROVIDER"),
|
||||
from: readString(env, "EMAIL_FROM"),
|
||||
apiKey: readString(env, "EMAIL_API_KEY"),
|
||||
},
|
||||
keyPool: {
|
||||
cooldownMinutes: readInteger(env, "KEY_COOLDOWN_MINUTES"),
|
||||
failuresBeforeManualReview: readInteger(env, "KEY_FAILURES_BEFORE_MANUAL_REVIEW"),
|
||||
balancePollSeconds: readInteger(env, "KEY_BALANCE_POLL_SECONDS"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function readString(env: NodeJS.ProcessEnv, key: string): string {
|
||||
const value = env[key];
|
||||
|
||||
if (!value) {
|
||||
throw new Error(`Missing required environment variable: ${key}`);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function readInteger(env: NodeJS.ProcessEnv, key: string): number {
|
||||
const value = readString(env, key);
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
|
||||
if (!Number.isInteger(parsed)) {
|
||||
throw new Error(`Environment variable ${key} must be an integer`);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function readBoolean(env: NodeJS.ProcessEnv, key: string): boolean {
|
||||
const value = readString(env, key).toLowerCase();
|
||||
|
||||
if (value === "true") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (value === "false") {
|
||||
return false;
|
||||
}
|
||||
|
||||
throw new Error(`Environment variable ${key} must be "true" or "false"`);
|
||||
}
|
||||
|
||||
function readUrl(env: NodeJS.ProcessEnv, key: string): URL {
|
||||
return new URL(readString(env, key));
|
||||
}
|
||||
|
||||
function readTelegramMode(env: NodeJS.ProcessEnv, key: string): "polling" {
|
||||
const value = readString(env, key);
|
||||
|
||||
if (value !== "polling") {
|
||||
throw new Error(`Environment variable ${key} must be "polling" for MVP`);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
12
packages/config/tsconfig.json
Normal file
12
packages/config/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
15
packages/db/AGENTS.md
Normal file
15
packages/db/AGENTS.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Scope
|
||||
Applies within `packages/db`.
|
||||
|
||||
## Responsibilities
|
||||
- Prisma schema
|
||||
- migrations
|
||||
- database-level helpers
|
||||
- shared transaction helpers
|
||||
|
||||
## Rules
|
||||
- Database schema is the canonical model for persisted state.
|
||||
- Keep request-level and attempt-level generation data separate.
|
||||
- Keep provider key status events auditable.
|
||||
17
packages/db/README.md
Normal file
17
packages/db/README.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# packages/db
|
||||
|
||||
Database package for `nproxy`.
|
||||
|
||||
## Implemented in this iteration
|
||||
- Prisma package scaffold
|
||||
- Initial Prisma schema for MVP persisted state
|
||||
- Shared schema path export for runtime tooling
|
||||
|
||||
## Current scope
|
||||
- Users and subscription state
|
||||
- Manual crypto invoices
|
||||
- Generation requests and provider-key attempts
|
||||
- Usage ledger
|
||||
- Provider keys, optional proxies, and auditable state events
|
||||
- Telegram pairing and admin allowlist
|
||||
- Admin audit log
|
||||
34
packages/db/package.json
Normal file
34
packages/db/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@nproxy/db",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"prisma"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"check": "prisma validate",
|
||||
"db:push": "prisma db push",
|
||||
"generate": "prisma generate",
|
||||
"migrate:deploy": "prisma migrate deploy",
|
||||
"format": "prisma format"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nproxy/domain": "workspace:*",
|
||||
"@nproxy/providers": "workspace:*",
|
||||
"@prisma/client": "^6.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"prisma": "^6.5.0"
|
||||
}
|
||||
}
|
||||
0
packages/db/prisma/.gitkeep
Normal file
0
packages/db/prisma/.gitkeep
Normal file
366
packages/db/prisma/migrations/20260309181500_init/migration.sql
Normal file
366
packages/db/prisma/migrations/20260309181500_init/migration.sql
Normal file
@@ -0,0 +1,366 @@
|
||||
-- CreateSchema
|
||||
CREATE SCHEMA IF NOT EXISTS "public";
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "SubscriptionStatus" AS ENUM ('pending_activation', 'active', 'past_due', 'canceled', 'expired');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "PaymentInvoiceStatus" AS ENUM ('pending', 'paid', 'expired', 'canceled');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "GenerationMode" AS ENUM ('text_to_image', 'image_to_image');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "GenerationRequestStatus" AS ENUM ('queued', 'running', 'succeeded', 'failed', 'canceled');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "GenerationAttemptStatus" AS ENUM ('started', 'succeeded', 'failed');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ProviderFailureCategory" AS ENUM ('transport', 'timeout', 'provider_5xx', 'provider_4xx_user', 'insufficient_funds', 'unknown');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ProviderKeyState" AS ENUM ('active', 'cooldown', 'out_of_funds', 'manual_review', 'disabled');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "UsageLedgerEntryType" AS ENUM ('cycle_reset', 'generation_success', 'manual_adjustment', 'refund');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "TelegramPairingStatus" AS ENUM ('pending', 'completed', 'expired', 'revoked');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AdminActorType" AS ENUM ('system', 'web_admin', 'telegram_admin', 'cli_operator');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"passwordHash" TEXT NOT NULL,
|
||||
"passwordResetVersion" INTEGER NOT NULL DEFAULT 0,
|
||||
"isAdmin" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SubscriptionPlan" (
|
||||
"id" TEXT NOT NULL,
|
||||
"code" TEXT NOT NULL,
|
||||
"displayName" TEXT NOT NULL,
|
||||
"monthlyRequestLimit" INTEGER NOT NULL,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "SubscriptionPlan_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Subscription" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"planId" TEXT NOT NULL,
|
||||
"status" "SubscriptionStatus" NOT NULL,
|
||||
"renewsManually" BOOLEAN NOT NULL DEFAULT true,
|
||||
"activatedAt" TIMESTAMP(3),
|
||||
"currentPeriodStart" TIMESTAMP(3),
|
||||
"currentPeriodEnd" TIMESTAMP(3),
|
||||
"canceledAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Subscription_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "PaymentInvoice" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"subscriptionId" TEXT,
|
||||
"provider" TEXT NOT NULL,
|
||||
"providerInvoiceId" TEXT,
|
||||
"status" "PaymentInvoiceStatus" NOT NULL,
|
||||
"currency" TEXT NOT NULL,
|
||||
"amountCrypto" DECIMAL(20,8) NOT NULL,
|
||||
"amountUsd" DECIMAL(12,2),
|
||||
"paymentAddress" TEXT,
|
||||
"expiresAt" TIMESTAMP(3),
|
||||
"paidAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "PaymentInvoice_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "GenerationRequest" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"mode" "GenerationMode" NOT NULL,
|
||||
"status" "GenerationRequestStatus" NOT NULL DEFAULT 'queued',
|
||||
"providerModel" TEXT NOT NULL,
|
||||
"prompt" TEXT NOT NULL,
|
||||
"sourceImageKey" TEXT,
|
||||
"resolutionPreset" TEXT NOT NULL,
|
||||
"batchSize" INTEGER NOT NULL,
|
||||
"imageStrength" DECIMAL(4,3),
|
||||
"idempotencyKey" TEXT,
|
||||
"terminalErrorCode" TEXT,
|
||||
"terminalErrorText" TEXT,
|
||||
"requestedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"startedAt" TIMESTAMP(3),
|
||||
"completedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "GenerationRequest_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "GenerationAttempt" (
|
||||
"id" TEXT NOT NULL,
|
||||
"generationRequestId" TEXT NOT NULL,
|
||||
"providerKeyId" TEXT NOT NULL,
|
||||
"attemptIndex" INTEGER NOT NULL,
|
||||
"status" "GenerationAttemptStatus" NOT NULL DEFAULT 'started',
|
||||
"usedProxy" BOOLEAN NOT NULL DEFAULT false,
|
||||
"directFallbackUsed" BOOLEAN NOT NULL DEFAULT false,
|
||||
"failureCategory" "ProviderFailureCategory",
|
||||
"providerHttpStatus" INTEGER,
|
||||
"providerErrorCode" TEXT,
|
||||
"providerErrorText" TEXT,
|
||||
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"completedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "GenerationAttempt_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "GeneratedAsset" (
|
||||
"id" TEXT NOT NULL,
|
||||
"generationRequestId" TEXT NOT NULL,
|
||||
"objectKey" TEXT NOT NULL,
|
||||
"mimeType" TEXT NOT NULL,
|
||||
"width" INTEGER,
|
||||
"height" INTEGER,
|
||||
"bytes" INTEGER,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "GeneratedAsset_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "UsageLedgerEntry" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"generationRequestId" TEXT,
|
||||
"entryType" "UsageLedgerEntryType" NOT NULL,
|
||||
"deltaRequests" INTEGER NOT NULL,
|
||||
"cycleStartedAt" TIMESTAMP(3),
|
||||
"cycleEndsAt" TIMESTAMP(3),
|
||||
"note" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "UsageLedgerEntry_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ProviderProxy" (
|
||||
"id" TEXT NOT NULL,
|
||||
"label" TEXT NOT NULL,
|
||||
"baseUrl" TEXT NOT NULL,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ProviderProxy_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ProviderKey" (
|
||||
"id" TEXT NOT NULL,
|
||||
"providerCode" TEXT NOT NULL,
|
||||
"label" TEXT NOT NULL,
|
||||
"apiKeyCiphertext" TEXT NOT NULL,
|
||||
"apiKeyLastFour" TEXT NOT NULL,
|
||||
"state" "ProviderKeyState" NOT NULL DEFAULT 'active',
|
||||
"roundRobinOrder" INTEGER NOT NULL,
|
||||
"consecutiveRetryableFailures" INTEGER NOT NULL DEFAULT 0,
|
||||
"cooldownUntil" TIMESTAMP(3),
|
||||
"lastErrorCategory" "ProviderFailureCategory",
|
||||
"lastErrorCode" TEXT,
|
||||
"lastErrorAt" TIMESTAMP(3),
|
||||
"balanceMinorUnits" BIGINT,
|
||||
"balanceCurrency" TEXT,
|
||||
"balanceRefreshedAt" TIMESTAMP(3),
|
||||
"proxyId" TEXT,
|
||||
"disabledAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ProviderKey_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ProviderKeyStatusEvent" (
|
||||
"id" TEXT NOT NULL,
|
||||
"providerKeyId" TEXT NOT NULL,
|
||||
"fromState" "ProviderKeyState",
|
||||
"toState" "ProviderKeyState" NOT NULL,
|
||||
"reason" TEXT NOT NULL,
|
||||
"errorCategory" "ProviderFailureCategory",
|
||||
"errorCode" TEXT,
|
||||
"actorType" "AdminActorType" NOT NULL,
|
||||
"actorRef" TEXT,
|
||||
"metadata" JSONB,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "ProviderKeyStatusEvent_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TelegramPairing" (
|
||||
"id" TEXT NOT NULL,
|
||||
"telegramUserId" TEXT NOT NULL,
|
||||
"telegramUsername" TEXT,
|
||||
"displayNameSnapshot" TEXT NOT NULL,
|
||||
"codeHash" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"status" "TelegramPairingStatus" NOT NULL DEFAULT 'pending',
|
||||
"completedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "TelegramPairing_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TelegramAdminAllowlistEntry" (
|
||||
"telegramUserId" TEXT NOT NULL,
|
||||
"telegramUsername" TEXT,
|
||||
"displayNameSnapshot" TEXT NOT NULL,
|
||||
"pairedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"revokedAt" TIMESTAMP(3),
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "TelegramAdminAllowlistEntry_pkey" PRIMARY KEY ("telegramUserId")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AdminAuditLog" (
|
||||
"id" TEXT NOT NULL,
|
||||
"actorType" "AdminActorType" NOT NULL,
|
||||
"actorRef" TEXT,
|
||||
"action" TEXT NOT NULL,
|
||||
"targetType" TEXT NOT NULL,
|
||||
"targetId" TEXT,
|
||||
"metadata" JSONB,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "AdminAuditLog_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "SubscriptionPlan_code_key" ON "SubscriptionPlan"("code");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Subscription_userId_status_idx" ON "Subscription"("userId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "PaymentInvoice_providerInvoiceId_key" ON "PaymentInvoice"("providerInvoiceId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "PaymentInvoice_userId_status_idx" ON "PaymentInvoice"("userId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "GenerationRequest_idempotencyKey_key" ON "GenerationRequest"("idempotencyKey");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "GenerationRequest_userId_status_requestedAt_idx" ON "GenerationRequest"("userId", "status", "requestedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "GenerationAttempt_providerKeyId_startedAt_idx" ON "GenerationAttempt"("providerKeyId", "startedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "GenerationAttempt_generationRequestId_attemptIndex_key" ON "GenerationAttempt"("generationRequestId", "attemptIndex");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "GeneratedAsset_objectKey_key" ON "GeneratedAsset"("objectKey");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "GeneratedAsset_generationRequestId_idx" ON "GeneratedAsset"("generationRequestId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "UsageLedgerEntry_generationRequestId_key" ON "UsageLedgerEntry"("generationRequestId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "UsageLedgerEntry_userId_createdAt_idx" ON "UsageLedgerEntry"("userId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ProviderProxy_label_key" ON "ProviderProxy"("label");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ProviderKey_label_key" ON "ProviderKey"("label");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ProviderKey_providerCode_state_roundRobinOrder_idx" ON "ProviderKey"("providerCode", "state", "roundRobinOrder");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ProviderKeyStatusEvent_providerKeyId_createdAt_idx" ON "ProviderKeyStatusEvent"("providerKeyId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TelegramPairing_telegramUserId_status_idx" ON "TelegramPairing"("telegramUserId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TelegramPairing_expiresAt_status_idx" ON "TelegramPairing"("expiresAt", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AdminAuditLog_targetType_targetId_createdAt_idx" ON "AdminAuditLog"("targetType", "targetId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AdminAuditLog_actorType_createdAt_idx" ON "AdminAuditLog"("actorType", "createdAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Subscription" ADD CONSTRAINT "Subscription_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Subscription" ADD CONSTRAINT "Subscription_planId_fkey" FOREIGN KEY ("planId") REFERENCES "SubscriptionPlan"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PaymentInvoice" ADD CONSTRAINT "PaymentInvoice_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PaymentInvoice" ADD CONSTRAINT "PaymentInvoice_subscriptionId_fkey" FOREIGN KEY ("subscriptionId") REFERENCES "Subscription"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "GenerationRequest" ADD CONSTRAINT "GenerationRequest_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "GenerationAttempt" ADD CONSTRAINT "GenerationAttempt_generationRequestId_fkey" FOREIGN KEY ("generationRequestId") REFERENCES "GenerationRequest"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "GenerationAttempt" ADD CONSTRAINT "GenerationAttempt_providerKeyId_fkey" FOREIGN KEY ("providerKeyId") REFERENCES "ProviderKey"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "GeneratedAsset" ADD CONSTRAINT "GeneratedAsset_generationRequestId_fkey" FOREIGN KEY ("generationRequestId") REFERENCES "GenerationRequest"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "UsageLedgerEntry" ADD CONSTRAINT "UsageLedgerEntry_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "UsageLedgerEntry" ADD CONSTRAINT "UsageLedgerEntry_generationRequestId_fkey" FOREIGN KEY ("generationRequestId") REFERENCES "GenerationRequest"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ProviderKey" ADD CONSTRAINT "ProviderKey_proxyId_fkey" FOREIGN KEY ("proxyId") REFERENCES "ProviderProxy"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ProviderKeyStatusEvent" ADD CONSTRAINT "ProviderKeyStatusEvent_providerKeyId_fkey" FOREIGN KEY ("providerKeyId") REFERENCES "ProviderKey"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
CREATE TABLE "UserSession" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"tokenHash" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"revokedAt" TIMESTAMP(3),
|
||||
"lastSeenAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "UserSession_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "UserSession_tokenHash_key" ON "UserSession"("tokenHash");
|
||||
CREATE INDEX "UserSession_userId_createdAt_idx" ON "UserSession"("userId", "createdAt");
|
||||
CREATE INDEX "UserSession_expiresAt_revokedAt_idx" ON "UserSession"("expiresAt", "revokedAt");
|
||||
|
||||
ALTER TABLE "UserSession"
|
||||
ADD CONSTRAINT "UserSession_userId_fkey"
|
||||
FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,18 @@
|
||||
CREATE TABLE "PasswordResetToken" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"tokenHash" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"consumedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "PasswordResetToken_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "PasswordResetToken_tokenHash_key" ON "PasswordResetToken"("tokenHash");
|
||||
CREATE INDEX "PasswordResetToken_userId_createdAt_idx" ON "PasswordResetToken"("userId", "createdAt");
|
||||
CREATE INDEX "PasswordResetToken_expiresAt_consumedAt_idx" ON "PasswordResetToken"("expiresAt", "consumedAt");
|
||||
|
||||
ALTER TABLE "PasswordResetToken"
|
||||
ADD CONSTRAINT "PasswordResetToken_userId_fkey"
|
||||
FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE "SubscriptionPlan"
|
||||
ADD COLUMN "monthlyPriceUsd" DECIMAL(12,2) NOT NULL DEFAULT 9.99,
|
||||
ADD COLUMN "billingCurrency" TEXT NOT NULL DEFAULT 'USDT';
|
||||
2
packages/db/prisma/migrations/migration_lock.toml
Normal file
2
packages/db/prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
# Do not edit by hand unless you are intentionally resetting migration history.
|
||||
provider = "postgresql"
|
||||
351
packages/db/prisma/schema.prisma
Normal file
351
packages/db/prisma/schema.prisma
Normal file
@@ -0,0 +1,351 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
enum SubscriptionStatus {
|
||||
pending_activation
|
||||
active
|
||||
past_due
|
||||
canceled
|
||||
expired
|
||||
}
|
||||
|
||||
enum PaymentInvoiceStatus {
|
||||
pending
|
||||
paid
|
||||
expired
|
||||
canceled
|
||||
}
|
||||
|
||||
enum GenerationMode {
|
||||
text_to_image
|
||||
image_to_image
|
||||
}
|
||||
|
||||
enum GenerationRequestStatus {
|
||||
queued
|
||||
running
|
||||
succeeded
|
||||
failed
|
||||
canceled
|
||||
}
|
||||
|
||||
enum GenerationAttemptStatus {
|
||||
started
|
||||
succeeded
|
||||
failed
|
||||
}
|
||||
|
||||
enum ProviderFailureCategory {
|
||||
transport
|
||||
timeout
|
||||
provider_5xx
|
||||
provider_4xx_user
|
||||
insufficient_funds
|
||||
unknown
|
||||
}
|
||||
|
||||
enum ProviderKeyState {
|
||||
active
|
||||
cooldown
|
||||
out_of_funds
|
||||
manual_review
|
||||
disabled
|
||||
}
|
||||
|
||||
enum UsageLedgerEntryType {
|
||||
cycle_reset
|
||||
generation_success
|
||||
manual_adjustment
|
||||
refund
|
||||
}
|
||||
|
||||
enum TelegramPairingStatus {
|
||||
pending
|
||||
completed
|
||||
expired
|
||||
revoked
|
||||
}
|
||||
|
||||
enum AdminActorType {
|
||||
system
|
||||
web_admin
|
||||
telegram_admin
|
||||
cli_operator
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
passwordHash String
|
||||
passwordResetVersion Int @default(0)
|
||||
isAdmin Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
subscriptions Subscription[]
|
||||
invoices PaymentInvoice[]
|
||||
generationRequests GenerationRequest[]
|
||||
usageLedgerEntries UsageLedgerEntry[]
|
||||
sessions UserSession[]
|
||||
passwordResetTokens PasswordResetToken[]
|
||||
}
|
||||
|
||||
model UserSession {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
tokenHash String @unique
|
||||
expiresAt DateTime
|
||||
revokedAt DateTime?
|
||||
lastSeenAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId, createdAt])
|
||||
@@index([expiresAt, revokedAt])
|
||||
}
|
||||
|
||||
model PasswordResetToken {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
tokenHash String @unique
|
||||
expiresAt DateTime
|
||||
consumedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId, createdAt])
|
||||
@@index([expiresAt, consumedAt])
|
||||
}
|
||||
|
||||
model SubscriptionPlan {
|
||||
id String @id @default(cuid())
|
||||
code String @unique
|
||||
displayName String
|
||||
monthlyRequestLimit Int
|
||||
monthlyPriceUsd Decimal @db.Decimal(12, 2)
|
||||
billingCurrency String
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
subscriptions Subscription[]
|
||||
}
|
||||
|
||||
model Subscription {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
planId String
|
||||
status SubscriptionStatus
|
||||
renewsManually Boolean @default(true)
|
||||
activatedAt DateTime?
|
||||
currentPeriodStart DateTime?
|
||||
currentPeriodEnd DateTime?
|
||||
canceledAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
plan SubscriptionPlan @relation(fields: [planId], references: [id], onDelete: Restrict)
|
||||
invoices PaymentInvoice[]
|
||||
|
||||
@@index([userId, status])
|
||||
}
|
||||
|
||||
model PaymentInvoice {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
subscriptionId String?
|
||||
provider String
|
||||
providerInvoiceId String? @unique
|
||||
status PaymentInvoiceStatus
|
||||
currency String
|
||||
amountCrypto Decimal @db.Decimal(20, 8)
|
||||
amountUsd Decimal? @db.Decimal(12, 2)
|
||||
paymentAddress String?
|
||||
expiresAt DateTime?
|
||||
paidAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
subscription Subscription? @relation(fields: [subscriptionId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([userId, status])
|
||||
}
|
||||
|
||||
model GenerationRequest {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
mode GenerationMode
|
||||
status GenerationRequestStatus @default(queued)
|
||||
providerModel String
|
||||
prompt String
|
||||
sourceImageKey String?
|
||||
resolutionPreset String
|
||||
batchSize Int
|
||||
imageStrength Decimal? @db.Decimal(4, 3)
|
||||
idempotencyKey String? @unique
|
||||
terminalErrorCode String?
|
||||
terminalErrorText String?
|
||||
requestedAt DateTime @default(now())
|
||||
startedAt DateTime?
|
||||
completedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
attempts GenerationAttempt[]
|
||||
assets GeneratedAsset[]
|
||||
usageLedgerEntry UsageLedgerEntry?
|
||||
|
||||
@@index([userId, status, requestedAt])
|
||||
}
|
||||
|
||||
model GenerationAttempt {
|
||||
id String @id @default(cuid())
|
||||
generationRequestId String
|
||||
providerKeyId String
|
||||
attemptIndex Int
|
||||
status GenerationAttemptStatus @default(started)
|
||||
usedProxy Boolean @default(false)
|
||||
directFallbackUsed Boolean @default(false)
|
||||
failureCategory ProviderFailureCategory?
|
||||
providerHttpStatus Int?
|
||||
providerErrorCode String?
|
||||
providerErrorText String?
|
||||
startedAt DateTime @default(now())
|
||||
completedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
generationRequest GenerationRequest @relation(fields: [generationRequestId], references: [id], onDelete: Cascade)
|
||||
providerKey ProviderKey @relation(fields: [providerKeyId], references: [id], onDelete: Restrict)
|
||||
|
||||
@@unique([generationRequestId, attemptIndex])
|
||||
@@index([providerKeyId, startedAt])
|
||||
}
|
||||
|
||||
model GeneratedAsset {
|
||||
id String @id @default(cuid())
|
||||
generationRequestId String
|
||||
objectKey String @unique
|
||||
mimeType String
|
||||
width Int?
|
||||
height Int?
|
||||
bytes Int?
|
||||
createdAt DateTime @default(now())
|
||||
generationRequest GenerationRequest @relation(fields: [generationRequestId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([generationRequestId])
|
||||
}
|
||||
|
||||
model UsageLedgerEntry {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
generationRequestId String? @unique
|
||||
entryType UsageLedgerEntryType
|
||||
deltaRequests Int
|
||||
cycleStartedAt DateTime?
|
||||
cycleEndsAt DateTime?
|
||||
note String?
|
||||
createdAt DateTime @default(now())
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
generationRequest GenerationRequest? @relation(fields: [generationRequestId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([userId, createdAt])
|
||||
}
|
||||
|
||||
model ProviderProxy {
|
||||
id String @id @default(cuid())
|
||||
label String @unique
|
||||
baseUrl String
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
providerKeys ProviderKey[]
|
||||
}
|
||||
|
||||
model ProviderKey {
|
||||
id String @id @default(cuid())
|
||||
providerCode String
|
||||
label String @unique
|
||||
apiKeyCiphertext String
|
||||
apiKeyLastFour String
|
||||
state ProviderKeyState @default(active)
|
||||
roundRobinOrder Int
|
||||
consecutiveRetryableFailures Int @default(0)
|
||||
cooldownUntil DateTime?
|
||||
lastErrorCategory ProviderFailureCategory?
|
||||
lastErrorCode String?
|
||||
lastErrorAt DateTime?
|
||||
balanceMinorUnits BigInt?
|
||||
balanceCurrency String?
|
||||
balanceRefreshedAt DateTime?
|
||||
proxyId String?
|
||||
disabledAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
proxy ProviderProxy? @relation(fields: [proxyId], references: [id], onDelete: SetNull)
|
||||
attempts GenerationAttempt[]
|
||||
statusEvents ProviderKeyStatusEvent[]
|
||||
|
||||
@@index([providerCode, state, roundRobinOrder])
|
||||
}
|
||||
|
||||
model ProviderKeyStatusEvent {
|
||||
id String @id @default(cuid())
|
||||
providerKeyId String
|
||||
fromState ProviderKeyState?
|
||||
toState ProviderKeyState
|
||||
reason String
|
||||
errorCategory ProviderFailureCategory?
|
||||
errorCode String?
|
||||
actorType AdminActorType
|
||||
actorRef String?
|
||||
metadata Json?
|
||||
createdAt DateTime @default(now())
|
||||
providerKey ProviderKey @relation(fields: [providerKeyId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([providerKeyId, createdAt])
|
||||
}
|
||||
|
||||
model TelegramPairing {
|
||||
id String @id @default(cuid())
|
||||
telegramUserId String
|
||||
telegramUsername String?
|
||||
displayNameSnapshot String
|
||||
codeHash String
|
||||
expiresAt DateTime
|
||||
status TelegramPairingStatus @default(pending)
|
||||
completedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([telegramUserId, status])
|
||||
@@index([expiresAt, status])
|
||||
}
|
||||
|
||||
model TelegramAdminAllowlistEntry {
|
||||
telegramUserId String @id
|
||||
telegramUsername String?
|
||||
displayNameSnapshot String
|
||||
pairedAt DateTime @default(now())
|
||||
revokedAt DateTime?
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model AdminAuditLog {
|
||||
id String @id @default(cuid())
|
||||
actorType AdminActorType
|
||||
actorRef String?
|
||||
action String
|
||||
targetType String
|
||||
targetId String?
|
||||
metadata Json?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([targetType, targetId, createdAt])
|
||||
@@index([actorType, createdAt])
|
||||
}
|
||||
146
packages/db/src/account-store.ts
Normal file
146
packages/db/src/account-store.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { getApproximateQuotaBucket, type QuotaBucket } from "@nproxy/domain";
|
||||
import type { PrismaClient, SubscriptionStatus } from "@prisma/client";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma as defaultPrisma } from "./prisma-client.js";
|
||||
|
||||
export interface UserAccountOverview {
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
isAdmin: boolean;
|
||||
createdAt: Date;
|
||||
};
|
||||
subscription: {
|
||||
id: string;
|
||||
status: SubscriptionStatus;
|
||||
renewsManually: boolean;
|
||||
activatedAt?: Date;
|
||||
currentPeriodStart?: Date;
|
||||
currentPeriodEnd?: Date;
|
||||
canceledAt?: Date;
|
||||
plan: {
|
||||
id: string;
|
||||
code: string;
|
||||
displayName: string;
|
||||
monthlyRequestLimit: number;
|
||||
monthlyPriceUsd: number;
|
||||
billingCurrency: string;
|
||||
isActive: boolean;
|
||||
};
|
||||
} | null;
|
||||
quota: {
|
||||
approximateBucket: QuotaBucket;
|
||||
usedSuccessfulRequests: number;
|
||||
monthlyRequestLimit: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export function createPrismaAccountStore(database: PrismaClient = defaultPrisma) {
|
||||
return {
|
||||
async getUserAccountOverview(userId: string): Promise<UserAccountOverview | null> {
|
||||
const user = await database.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const subscription = await database.subscription.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
include: {
|
||||
plan: true,
|
||||
},
|
||||
orderBy: [
|
||||
{ currentPeriodEnd: "desc" },
|
||||
{ createdAt: "desc" },
|
||||
],
|
||||
});
|
||||
|
||||
const quota = subscription
|
||||
? await buildQuotaSnapshot(database, userId, {
|
||||
monthlyRequestLimit: subscription.plan.monthlyRequestLimit,
|
||||
cycleStart:
|
||||
subscription.currentPeriodStart ??
|
||||
subscription.activatedAt ??
|
||||
subscription.createdAt,
|
||||
})
|
||||
: null;
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
isAdmin: user.isAdmin,
|
||||
createdAt: user.createdAt,
|
||||
},
|
||||
subscription: subscription
|
||||
? {
|
||||
id: subscription.id,
|
||||
status: subscription.status,
|
||||
renewsManually: subscription.renewsManually,
|
||||
...(subscription.activatedAt ? { activatedAt: subscription.activatedAt } : {}),
|
||||
...(subscription.currentPeriodStart
|
||||
? { currentPeriodStart: subscription.currentPeriodStart }
|
||||
: {}),
|
||||
...(subscription.currentPeriodEnd
|
||||
? { currentPeriodEnd: subscription.currentPeriodEnd }
|
||||
: {}),
|
||||
...(subscription.canceledAt ? { canceledAt: subscription.canceledAt } : {}),
|
||||
plan: {
|
||||
id: subscription.plan.id,
|
||||
code: subscription.plan.code,
|
||||
displayName: subscription.plan.displayName,
|
||||
monthlyRequestLimit: subscription.plan.monthlyRequestLimit,
|
||||
monthlyPriceUsd: decimalToNumber(subscription.plan.monthlyPriceUsd),
|
||||
billingCurrency: subscription.plan.billingCurrency,
|
||||
isActive: subscription.plan.isActive,
|
||||
},
|
||||
}
|
||||
: null,
|
||||
quota,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function decimalToNumber(value: Prisma.Decimal | { toNumber(): number }): number {
|
||||
return value.toNumber();
|
||||
}
|
||||
|
||||
async function buildQuotaSnapshot(
|
||||
database: PrismaClient,
|
||||
userId: string,
|
||||
input: {
|
||||
monthlyRequestLimit: number;
|
||||
cycleStart: Date;
|
||||
},
|
||||
): Promise<UserAccountOverview["quota"]> {
|
||||
const usageAggregation = await database.usageLedgerEntry.aggregate({
|
||||
where: {
|
||||
userId,
|
||||
entryType: "generation_success",
|
||||
createdAt: {
|
||||
gte: input.cycleStart,
|
||||
},
|
||||
},
|
||||
_sum: {
|
||||
deltaRequests: true,
|
||||
},
|
||||
});
|
||||
|
||||
const usedSuccessfulRequests = usageAggregation._sum.deltaRequests ?? 0;
|
||||
|
||||
return {
|
||||
approximateBucket: getApproximateQuotaBucket({
|
||||
used: usedSuccessfulRequests,
|
||||
limit: input.monthlyRequestLimit,
|
||||
}),
|
||||
usedSuccessfulRequests,
|
||||
monthlyRequestLimit: input.monthlyRequestLimit,
|
||||
};
|
||||
}
|
||||
399
packages/db/src/auth-store.ts
Normal file
399
packages/db/src/auth-store.ts
Normal file
@@ -0,0 +1,399 @@
|
||||
import {
|
||||
AuthError,
|
||||
createPasswordResetToken,
|
||||
createSessionToken,
|
||||
hashPasswordResetToken,
|
||||
hashPassword,
|
||||
hashSessionToken,
|
||||
normalizeEmail,
|
||||
validateEmail,
|
||||
validatePassword,
|
||||
verifyPassword,
|
||||
} from "@nproxy/domain";
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import { prisma as defaultPrisma } from "./prisma-client.js";
|
||||
|
||||
export interface AuthenticatedUserRecord {
|
||||
id: string;
|
||||
email: string;
|
||||
isAdmin: boolean;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface SessionRecord {
|
||||
token: string;
|
||||
user: AuthenticatedUserRecord;
|
||||
expiresAt: Date;
|
||||
}
|
||||
|
||||
export interface UserSessionRecord {
|
||||
id: string;
|
||||
expiresAt: Date;
|
||||
revokedAt?: Date;
|
||||
lastSeenAt?: Date;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface AuthenticatedSessionRecord {
|
||||
session: UserSessionRecord;
|
||||
user: AuthenticatedUserRecord;
|
||||
}
|
||||
|
||||
export interface PasswordResetChallengeRecord {
|
||||
email: string;
|
||||
token: string;
|
||||
expiresAt: Date;
|
||||
}
|
||||
|
||||
export function createPrismaAuthStore(database: PrismaClient = defaultPrisma) {
|
||||
return {
|
||||
async registerUser(input: {
|
||||
email: string;
|
||||
password: string;
|
||||
passwordPepper: string;
|
||||
sessionTtlDays?: number;
|
||||
}): Promise<SessionRecord> {
|
||||
const email = validateEmail(input.email);
|
||||
const password = validatePassword(input.password);
|
||||
const existing = await database.user.findUnique({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new AuthError("email_already_exists", `User ${email} already exists.`);
|
||||
}
|
||||
|
||||
const passwordHash = hashPassword(password, input.passwordPepper);
|
||||
const token = createSessionToken();
|
||||
const tokenHash = hashSessionToken(token);
|
||||
const expiresAt = addDays(new Date(), input.sessionTtlDays ?? 30);
|
||||
|
||||
return database.$transaction(async (transaction) => {
|
||||
const defaultPlan = await transaction.subscriptionPlan.findFirst({
|
||||
where: {
|
||||
code: "mvp_monthly",
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
const user = await transaction.user.create({
|
||||
data: {
|
||||
email,
|
||||
passwordHash,
|
||||
},
|
||||
});
|
||||
|
||||
await transaction.userSession.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
tokenHash,
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
if (defaultPlan) {
|
||||
await transaction.subscription.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
planId: defaultPlan.id,
|
||||
status: "pending_activation",
|
||||
renewsManually: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
expiresAt,
|
||||
user: mapAuthenticatedUser(user),
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
async loginUser(input: {
|
||||
email: string;
|
||||
password: string;
|
||||
passwordPepper: string;
|
||||
sessionTtlDays?: number;
|
||||
}): Promise<SessionRecord> {
|
||||
const email = normalizeEmail(input.email);
|
||||
const user = await database.user.findUnique({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user || !verifyPassword(input.password, user.passwordHash, input.passwordPepper)) {
|
||||
throw new AuthError("invalid_credentials", "Invalid email or password.");
|
||||
}
|
||||
|
||||
const token = createSessionToken();
|
||||
const tokenHash = hashSessionToken(token);
|
||||
const expiresAt = addDays(new Date(), input.sessionTtlDays ?? 30);
|
||||
|
||||
await database.userSession.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
tokenHash,
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
token,
|
||||
expiresAt,
|
||||
user: mapAuthenticatedUser(user),
|
||||
};
|
||||
},
|
||||
|
||||
async getUserBySessionToken(
|
||||
sessionToken: string,
|
||||
): Promise<AuthenticatedSessionRecord | null> {
|
||||
const tokenHash = hashSessionToken(sessionToken);
|
||||
const now = new Date();
|
||||
const session = await database.userSession.findUnique({
|
||||
where: {
|
||||
tokenHash,
|
||||
},
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!session || session.revokedAt || session.expiresAt <= now) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await database.userSession.update({
|
||||
where: {
|
||||
id: session.id,
|
||||
},
|
||||
data: {
|
||||
lastSeenAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
session: mapUserSession(session),
|
||||
user: mapAuthenticatedUser(session.user),
|
||||
};
|
||||
},
|
||||
|
||||
async revokeSession(sessionToken: string): Promise<void> {
|
||||
const tokenHash = hashSessionToken(sessionToken);
|
||||
await database.userSession.updateMany({
|
||||
where: {
|
||||
tokenHash,
|
||||
revokedAt: null,
|
||||
},
|
||||
data: {
|
||||
revokedAt: new Date(),
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async listUserSessions(userId: string): Promise<UserSessionRecord[]> {
|
||||
const sessions = await database.userSession.findMany({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
return sessions.map(mapUserSession);
|
||||
},
|
||||
|
||||
async revokeUserSession(input: {
|
||||
userId: string;
|
||||
sessionId: string;
|
||||
}): Promise<boolean> {
|
||||
const result = await database.userSession.updateMany({
|
||||
where: {
|
||||
id: input.sessionId,
|
||||
userId: input.userId,
|
||||
revokedAt: null,
|
||||
},
|
||||
data: {
|
||||
revokedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return result.count > 0;
|
||||
},
|
||||
|
||||
async revokeAllUserSessions(input: {
|
||||
userId: string;
|
||||
exceptSessionId?: string;
|
||||
}): Promise<number> {
|
||||
const result = await database.userSession.updateMany({
|
||||
where: {
|
||||
userId: input.userId,
|
||||
revokedAt: null,
|
||||
...(input.exceptSessionId
|
||||
? {
|
||||
id: {
|
||||
not: input.exceptSessionId,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
data: {
|
||||
revokedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return result.count;
|
||||
},
|
||||
|
||||
async createPasswordResetChallenge(input: {
|
||||
email: string;
|
||||
ttlMinutes?: number;
|
||||
}): Promise<PasswordResetChallengeRecord | null> {
|
||||
const email = normalizeEmail(input.email);
|
||||
const user = await database.user.findUnique({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const token = createPasswordResetToken();
|
||||
const tokenHash = hashPasswordResetToken(token);
|
||||
const expiresAt = addMinutes(new Date(), input.ttlMinutes ?? 30);
|
||||
|
||||
await database.$transaction([
|
||||
database.passwordResetToken.updateMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
consumedAt: null,
|
||||
},
|
||||
data: {
|
||||
consumedAt: new Date(),
|
||||
},
|
||||
}),
|
||||
database.passwordResetToken.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
tokenHash,
|
||||
expiresAt,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
email: user.email,
|
||||
token,
|
||||
expiresAt,
|
||||
};
|
||||
},
|
||||
|
||||
async resetPassword(input: {
|
||||
token: string;
|
||||
newPassword: string;
|
||||
passwordPepper: string;
|
||||
}): Promise<void> {
|
||||
const tokenHash = hashPasswordResetToken(input.token);
|
||||
const newPassword = validatePassword(input.newPassword);
|
||||
const passwordHash = hashPassword(newPassword, input.passwordPepper);
|
||||
const now = new Date();
|
||||
|
||||
await database.$transaction(async (transaction) => {
|
||||
const resetToken = await transaction.passwordResetToken.findUnique({
|
||||
where: {
|
||||
tokenHash,
|
||||
},
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (
|
||||
!resetToken ||
|
||||
resetToken.consumedAt ||
|
||||
resetToken.expiresAt <= now
|
||||
) {
|
||||
throw new AuthError(
|
||||
"reset_token_invalid",
|
||||
"Password reset token is invalid or expired.",
|
||||
);
|
||||
}
|
||||
|
||||
await transaction.user.update({
|
||||
where: {
|
||||
id: resetToken.userId,
|
||||
},
|
||||
data: {
|
||||
passwordHash,
|
||||
passwordResetVersion: {
|
||||
increment: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await transaction.passwordResetToken.update({
|
||||
where: {
|
||||
id: resetToken.id,
|
||||
},
|
||||
data: {
|
||||
consumedAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
await transaction.userSession.updateMany({
|
||||
where: {
|
||||
userId: resetToken.userId,
|
||||
revokedAt: null,
|
||||
},
|
||||
data: {
|
||||
revokedAt: now,
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mapAuthenticatedUser(user: {
|
||||
id: string;
|
||||
email: string;
|
||||
isAdmin: boolean;
|
||||
createdAt: Date;
|
||||
}): AuthenticatedUserRecord {
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
isAdmin: user.isAdmin,
|
||||
createdAt: user.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
function mapUserSession(session: {
|
||||
id: string;
|
||||
expiresAt: Date;
|
||||
revokedAt: Date | null;
|
||||
lastSeenAt: Date | null;
|
||||
createdAt: Date;
|
||||
}): UserSessionRecord {
|
||||
return {
|
||||
id: session.id,
|
||||
expiresAt: session.expiresAt,
|
||||
createdAt: session.createdAt,
|
||||
...(session.revokedAt ? { revokedAt: session.revokedAt } : {}),
|
||||
...(session.lastSeenAt ? { lastSeenAt: session.lastSeenAt } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function addDays(value: Date, days: number): Date {
|
||||
return new Date(value.getTime() + days * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
function addMinutes(value: Date, minutes: number): Date {
|
||||
return new Date(value.getTime() + minutes * 60 * 1000);
|
||||
}
|
||||
254
packages/db/src/billing-store.ts
Normal file
254
packages/db/src/billing-store.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
import type { PaymentProviderAdapter } from "@nproxy/providers";
|
||||
import { Prisma, type PaymentInvoiceStatus, type PrismaClient, type SubscriptionStatus } from "@prisma/client";
|
||||
import { prisma as defaultPrisma } from "./prisma-client.js";
|
||||
|
||||
export interface BillingInvoiceRecord {
|
||||
id: string;
|
||||
subscriptionId?: string;
|
||||
provider: string;
|
||||
providerInvoiceId?: string;
|
||||
status: PaymentInvoiceStatus;
|
||||
currency: string;
|
||||
amountCrypto: number;
|
||||
amountUsd?: number;
|
||||
paymentAddress?: string;
|
||||
expiresAt?: Date;
|
||||
paidAt?: Date;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface SubscriptionBillingRecord {
|
||||
id: string;
|
||||
status: SubscriptionStatus;
|
||||
renewsManually: boolean;
|
||||
activatedAt?: Date;
|
||||
currentPeriodStart?: Date;
|
||||
currentPeriodEnd?: Date;
|
||||
canceledAt?: Date;
|
||||
plan: {
|
||||
id: string;
|
||||
code: string;
|
||||
displayName: string;
|
||||
monthlyRequestLimit: number;
|
||||
monthlyPriceUsd: number;
|
||||
billingCurrency: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function createPrismaBillingStore(database: PrismaClient = defaultPrisma) {
|
||||
return {
|
||||
async listUserInvoices(userId: string): Promise<BillingInvoiceRecord[]> {
|
||||
const invoices = await database.paymentInvoice.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
return invoices.map(mapInvoice);
|
||||
},
|
||||
|
||||
async getCurrentSubscription(userId: string): Promise<SubscriptionBillingRecord | null> {
|
||||
const subscription = await database.subscription.findFirst({
|
||||
where: { userId },
|
||||
include: { plan: true },
|
||||
orderBy: [{ currentPeriodEnd: "desc" }, { createdAt: "desc" }],
|
||||
});
|
||||
|
||||
return subscription ? mapSubscription(subscription) : null;
|
||||
},
|
||||
|
||||
async createSubscriptionInvoice(input: {
|
||||
userId: string;
|
||||
paymentProvider: string;
|
||||
paymentProviderAdapter: PaymentProviderAdapter;
|
||||
}): Promise<BillingInvoiceRecord> {
|
||||
const subscription = await database.subscription.findFirst({
|
||||
where: { userId: input.userId },
|
||||
include: { plan: true },
|
||||
orderBy: [{ currentPeriodEnd: "desc" }, { createdAt: "desc" }],
|
||||
});
|
||||
|
||||
if (!subscription) {
|
||||
throw new Error("Subscription not found.");
|
||||
}
|
||||
|
||||
const existingPending = await database.paymentInvoice.findFirst({
|
||||
where: {
|
||||
userId: input.userId,
|
||||
subscriptionId: subscription.id,
|
||||
status: "pending",
|
||||
expiresAt: {
|
||||
gt: new Date(),
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
if (existingPending) {
|
||||
return mapInvoice(existingPending);
|
||||
}
|
||||
|
||||
const amountUsd = subscription.plan.monthlyPriceUsd.toNumber();
|
||||
const currency = subscription.plan.billingCurrency;
|
||||
const amountCrypto = amountUsd;
|
||||
const providerInvoice = await input.paymentProviderAdapter.createInvoice({
|
||||
userId: input.userId,
|
||||
planCode: subscription.plan.code,
|
||||
amountUsd,
|
||||
amountCrypto,
|
||||
currency,
|
||||
});
|
||||
|
||||
const invoice = await database.paymentInvoice.create({
|
||||
data: {
|
||||
userId: input.userId,
|
||||
subscriptionId: subscription.id,
|
||||
provider: input.paymentProvider,
|
||||
providerInvoiceId: providerInvoice.providerInvoiceId,
|
||||
status: "pending",
|
||||
currency: providerInvoice.currency,
|
||||
amountCrypto: new Prisma.Decimal(providerInvoice.amountCrypto),
|
||||
amountUsd: new Prisma.Decimal(providerInvoice.amountUsd),
|
||||
paymentAddress: providerInvoice.paymentAddress,
|
||||
expiresAt: providerInvoice.expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
return mapInvoice(invoice);
|
||||
},
|
||||
|
||||
async markInvoicePaid(input: {
|
||||
invoiceId: string;
|
||||
}): Promise<BillingInvoiceRecord> {
|
||||
return database.$transaction(async (transaction) => {
|
||||
const invoice = await transaction.paymentInvoice.findUnique({
|
||||
where: { id: input.invoiceId },
|
||||
include: {
|
||||
subscription: {
|
||||
include: {
|
||||
plan: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!invoice) {
|
||||
throw new Error("Invoice not found.");
|
||||
}
|
||||
|
||||
const paidAt = invoice.paidAt ?? new Date();
|
||||
const updatedInvoice =
|
||||
invoice.status === "paid"
|
||||
? invoice
|
||||
: await transaction.paymentInvoice.update({
|
||||
where: { id: invoice.id },
|
||||
data: {
|
||||
status: "paid",
|
||||
paidAt,
|
||||
},
|
||||
});
|
||||
|
||||
if (invoice.subscription) {
|
||||
const periodStart = paidAt;
|
||||
const periodEnd = addDays(periodStart, 30);
|
||||
|
||||
await transaction.subscription.update({
|
||||
where: { id: invoice.subscription.id },
|
||||
data: {
|
||||
status: "active",
|
||||
activatedAt: invoice.subscription.activatedAt ?? paidAt,
|
||||
currentPeriodStart: periodStart,
|
||||
currentPeriodEnd: periodEnd,
|
||||
canceledAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
await transaction.usageLedgerEntry.create({
|
||||
data: {
|
||||
userId: invoice.userId,
|
||||
entryType: "cycle_reset",
|
||||
deltaRequests: 0,
|
||||
cycleStartedAt: periodStart,
|
||||
cycleEndsAt: periodEnd,
|
||||
note: `Cycle activated from invoice ${invoice.id}.`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return mapInvoice(updatedInvoice);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mapInvoice(invoice: {
|
||||
id: string;
|
||||
subscriptionId: string | null;
|
||||
provider: string;
|
||||
providerInvoiceId: string | null;
|
||||
status: PaymentInvoiceStatus;
|
||||
currency: string;
|
||||
amountCrypto: Prisma.Decimal;
|
||||
amountUsd: Prisma.Decimal | null;
|
||||
paymentAddress: string | null;
|
||||
expiresAt: Date | null;
|
||||
paidAt: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}): BillingInvoiceRecord {
|
||||
return {
|
||||
id: invoice.id,
|
||||
provider: invoice.provider,
|
||||
status: invoice.status,
|
||||
currency: invoice.currency,
|
||||
amountCrypto: invoice.amountCrypto.toNumber(),
|
||||
createdAt: invoice.createdAt,
|
||||
updatedAt: invoice.updatedAt,
|
||||
...(invoice.subscriptionId ? { subscriptionId: invoice.subscriptionId } : {}),
|
||||
...(invoice.providerInvoiceId ? { providerInvoiceId: invoice.providerInvoiceId } : {}),
|
||||
...(invoice.amountUsd !== null ? { amountUsd: invoice.amountUsd.toNumber() } : {}),
|
||||
...(invoice.paymentAddress ? { paymentAddress: invoice.paymentAddress } : {}),
|
||||
...(invoice.expiresAt ? { expiresAt: invoice.expiresAt } : {}),
|
||||
...(invoice.paidAt ? { paidAt: invoice.paidAt } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function mapSubscription(subscription: {
|
||||
id: string;
|
||||
status: SubscriptionStatus;
|
||||
renewsManually: boolean;
|
||||
activatedAt: Date | null;
|
||||
currentPeriodStart: Date | null;
|
||||
currentPeriodEnd: Date | null;
|
||||
canceledAt: Date | null;
|
||||
plan: {
|
||||
id: string;
|
||||
code: string;
|
||||
displayName: string;
|
||||
monthlyRequestLimit: number;
|
||||
monthlyPriceUsd: Prisma.Decimal;
|
||||
billingCurrency: string;
|
||||
};
|
||||
}): SubscriptionBillingRecord {
|
||||
return {
|
||||
id: subscription.id,
|
||||
status: subscription.status,
|
||||
renewsManually: subscription.renewsManually,
|
||||
plan: {
|
||||
id: subscription.plan.id,
|
||||
code: subscription.plan.code,
|
||||
displayName: subscription.plan.displayName,
|
||||
monthlyRequestLimit: subscription.plan.monthlyRequestLimit,
|
||||
monthlyPriceUsd: subscription.plan.monthlyPriceUsd.toNumber(),
|
||||
billingCurrency: subscription.plan.billingCurrency,
|
||||
},
|
||||
...(subscription.activatedAt ? { activatedAt: subscription.activatedAt } : {}),
|
||||
...(subscription.currentPeriodStart ? { currentPeriodStart: subscription.currentPeriodStart } : {}),
|
||||
...(subscription.currentPeriodEnd ? { currentPeriodEnd: subscription.currentPeriodEnd } : {}),
|
||||
...(subscription.canceledAt ? { canceledAt: subscription.canceledAt } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function addDays(value: Date, days: number): Date {
|
||||
return new Date(value.getTime() + days * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
16
packages/db/src/bootstrap-main.ts
Normal file
16
packages/db/src/bootstrap-main.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { ensureDefaultSubscriptionPlan } from "./bootstrap.js";
|
||||
import { prisma } from "./prisma-client.js";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
await ensureDefaultSubscriptionPlan(prisma);
|
||||
console.log("default subscription plan ensured");
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((error) => {
|
||||
console.error("failed to ensure default subscription plan", error);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
50
packages/db/src/bootstrap.ts
Normal file
50
packages/db/src/bootstrap.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Prisma, type PrismaClient } from "@prisma/client";
|
||||
import { prisma as defaultPrisma } from "./prisma-client.js";
|
||||
|
||||
export interface SubscriptionPlanSeedInput {
|
||||
code: string;
|
||||
displayName: string;
|
||||
monthlyRequestLimit: number;
|
||||
monthlyPriceUsd: number;
|
||||
billingCurrency: string;
|
||||
}
|
||||
|
||||
export const defaultSubscriptionPlanSeed: SubscriptionPlanSeedInput = {
|
||||
code: "mvp_monthly",
|
||||
displayName: "MVP Monthly",
|
||||
monthlyRequestLimit: 100,
|
||||
monthlyPriceUsd: 9.99,
|
||||
billingCurrency: "USDT",
|
||||
};
|
||||
|
||||
export async function ensureSubscriptionPlan(
|
||||
input: SubscriptionPlanSeedInput,
|
||||
database: PrismaClient = defaultPrisma,
|
||||
): Promise<void> {
|
||||
await database.subscriptionPlan.upsert({
|
||||
where: {
|
||||
code: input.code,
|
||||
},
|
||||
update: {
|
||||
displayName: input.displayName,
|
||||
monthlyRequestLimit: input.monthlyRequestLimit,
|
||||
monthlyPriceUsd: new Prisma.Decimal(input.monthlyPriceUsd),
|
||||
billingCurrency: input.billingCurrency,
|
||||
isActive: true,
|
||||
},
|
||||
create: {
|
||||
code: input.code,
|
||||
displayName: input.displayName,
|
||||
monthlyRequestLimit: input.monthlyRequestLimit,
|
||||
monthlyPriceUsd: new Prisma.Decimal(input.monthlyPriceUsd),
|
||||
billingCurrency: input.billingCurrency,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function ensureDefaultSubscriptionPlan(
|
||||
database: PrismaClient = defaultPrisma,
|
||||
): Promise<void> {
|
||||
await ensureSubscriptionPlan(defaultSubscriptionPlanSeed, database);
|
||||
}
|
||||
211
packages/db/src/generation-store.ts
Normal file
211
packages/db/src/generation-store.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import {
|
||||
type ActiveSubscriptionContext,
|
||||
type CreateGenerationRequestInput,
|
||||
type CreateGenerationRequestDeps,
|
||||
type GenerationRequestRecord,
|
||||
type MarkGenerationSucceededDeps,
|
||||
type SuccessfulGenerationRecord,
|
||||
} from "@nproxy/domain";
|
||||
import { Prisma, type PrismaClient } from "@prisma/client";
|
||||
import { prisma as defaultPrisma } from "./prisma-client.js";
|
||||
|
||||
export interface GenerationStore
|
||||
extends CreateGenerationRequestDeps,
|
||||
MarkGenerationSucceededDeps {}
|
||||
|
||||
export function createPrismaGenerationStore(
|
||||
database: PrismaClient = defaultPrisma,
|
||||
): GenerationStore {
|
||||
return {
|
||||
async findReusableRequest(userId: string, idempotencyKey: string) {
|
||||
const request = await database.generationRequest.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
idempotencyKey,
|
||||
},
|
||||
});
|
||||
|
||||
return request ? mapGenerationRequest(request) : null;
|
||||
},
|
||||
|
||||
async findActiveSubscriptionContext(
|
||||
userId: string,
|
||||
): Promise<ActiveSubscriptionContext | null> {
|
||||
const subscription = await database.subscription.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
status: "active",
|
||||
},
|
||||
include: {
|
||||
plan: true,
|
||||
},
|
||||
orderBy: [
|
||||
{ currentPeriodEnd: "desc" },
|
||||
{ createdAt: "desc" },
|
||||
],
|
||||
});
|
||||
|
||||
if (!subscription) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cycleStart =
|
||||
subscription.currentPeriodStart ?? subscription.activatedAt ?? subscription.createdAt;
|
||||
|
||||
const usageAggregation = await database.usageLedgerEntry.aggregate({
|
||||
where: {
|
||||
userId,
|
||||
entryType: "generation_success",
|
||||
createdAt: { gte: cycleStart },
|
||||
},
|
||||
_sum: {
|
||||
deltaRequests: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
subscriptionId: subscription.id,
|
||||
planId: subscription.planId,
|
||||
monthlyRequestLimit: subscription.plan.monthlyRequestLimit,
|
||||
usedSuccessfulRequests: usageAggregation._sum.deltaRequests ?? 0,
|
||||
};
|
||||
},
|
||||
|
||||
async createGenerationRequest(
|
||||
input: CreateGenerationRequestInput,
|
||||
): Promise<GenerationRequestRecord> {
|
||||
const request = await database.generationRequest.create({
|
||||
data: {
|
||||
userId: input.userId,
|
||||
mode: input.mode,
|
||||
providerModel: input.providerModel,
|
||||
prompt: input.prompt.trim(),
|
||||
resolutionPreset: input.resolutionPreset,
|
||||
batchSize: input.batchSize,
|
||||
...(input.sourceImageKey !== undefined
|
||||
? { sourceImageKey: input.sourceImageKey }
|
||||
: {}),
|
||||
...(input.imageStrength !== undefined
|
||||
? { imageStrength: new Prisma.Decimal(input.imageStrength) }
|
||||
: {}),
|
||||
...(input.idempotencyKey !== undefined
|
||||
? { idempotencyKey: input.idempotencyKey }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
return mapGenerationRequest(request);
|
||||
},
|
||||
|
||||
async getGenerationRequest(requestId: string): Promise<GenerationRequestRecord | null> {
|
||||
const request = await database.generationRequest.findUnique({
|
||||
where: {
|
||||
id: requestId,
|
||||
},
|
||||
});
|
||||
|
||||
return request ? mapGenerationRequest(request) : null;
|
||||
},
|
||||
|
||||
async markGenerationSucceeded(requestId: string): Promise<SuccessfulGenerationRecord> {
|
||||
return database.$transaction(async (transaction) => {
|
||||
const request = await transaction.generationRequest.findUnique({
|
||||
where: {
|
||||
id: requestId,
|
||||
},
|
||||
include: {
|
||||
usageLedgerEntry: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!request) {
|
||||
throw new Error(`Generation request ${requestId} was not found.`);
|
||||
}
|
||||
|
||||
const completedAt = request.completedAt ?? new Date();
|
||||
const nextStatus =
|
||||
request.status === "succeeded" ? request.status : "succeeded";
|
||||
|
||||
const updatedRequest =
|
||||
request.status === "succeeded" && request.completedAt
|
||||
? request
|
||||
: await transaction.generationRequest.update({
|
||||
where: {
|
||||
id: requestId,
|
||||
},
|
||||
data: {
|
||||
status: nextStatus,
|
||||
completedAt,
|
||||
},
|
||||
});
|
||||
|
||||
if (!request.usageLedgerEntry) {
|
||||
await transaction.usageLedgerEntry.create({
|
||||
data: {
|
||||
userId: request.userId,
|
||||
generationRequestId: request.id,
|
||||
entryType: "generation_success",
|
||||
deltaRequests: 1,
|
||||
note: "Consumed after first successful generation result.",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
request: mapGenerationRequest(updatedRequest),
|
||||
quotaConsumed: !request.usageLedgerEntry,
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mapGenerationRequest(
|
||||
request: {
|
||||
id: string;
|
||||
userId: string;
|
||||
mode: string;
|
||||
status: string;
|
||||
providerModel: string;
|
||||
prompt: string;
|
||||
sourceImageKey: string | null;
|
||||
resolutionPreset: string;
|
||||
batchSize: number;
|
||||
imageStrength: Prisma.Decimal | null;
|
||||
idempotencyKey: string | null;
|
||||
terminalErrorCode: string | null;
|
||||
terminalErrorText: string | null;
|
||||
requestedAt: Date;
|
||||
startedAt: Date | null;
|
||||
completedAt: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
},
|
||||
): GenerationRequestRecord {
|
||||
return {
|
||||
id: request.id,
|
||||
userId: request.userId,
|
||||
mode: request.mode as GenerationRequestRecord["mode"],
|
||||
status: request.status as GenerationRequestRecord["status"],
|
||||
providerModel: request.providerModel,
|
||||
prompt: request.prompt,
|
||||
resolutionPreset: request.resolutionPreset,
|
||||
batchSize: request.batchSize,
|
||||
requestedAt: request.requestedAt,
|
||||
createdAt: request.createdAt,
|
||||
updatedAt: request.updatedAt,
|
||||
...(request.sourceImageKey !== null ? { sourceImageKey: request.sourceImageKey } : {}),
|
||||
...(request.imageStrength !== null
|
||||
? { imageStrength: request.imageStrength.toNumber() }
|
||||
: {}),
|
||||
...(request.idempotencyKey !== null ? { idempotencyKey: request.idempotencyKey } : {}),
|
||||
...(request.terminalErrorCode !== null
|
||||
? { terminalErrorCode: request.terminalErrorCode }
|
||||
: {}),
|
||||
...(request.terminalErrorText !== null
|
||||
? { terminalErrorText: request.terminalErrorText }
|
||||
: {}),
|
||||
...(request.startedAt !== null ? { startedAt: request.startedAt } : {}),
|
||||
...(request.completedAt !== null ? { completedAt: request.completedAt } : {}),
|
||||
};
|
||||
}
|
||||
10
packages/db/src/index.ts
Normal file
10
packages/db/src/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export { prisma } from "./prisma-client.js";
|
||||
export { prismaSchemaPath } from "./schema-path.js";
|
||||
export * from "./account-store.js";
|
||||
export * from "./auth-store.js";
|
||||
export * from "./billing-store.js";
|
||||
export * from "./bootstrap.js";
|
||||
export * from "./generation-store.js";
|
||||
export * from "./telegram-bot-store.js";
|
||||
export * from "./telegram-pairing-store.js";
|
||||
export * from "./worker-store.js";
|
||||
11
packages/db/src/prisma-client.ts
Normal file
11
packages/db/src/prisma-client.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const globalForPrisma = globalThis as {
|
||||
prisma?: PrismaClient;
|
||||
};
|
||||
|
||||
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
globalForPrisma.prisma = prisma;
|
||||
}
|
||||
1
packages/db/src/schema-path.ts
Normal file
1
packages/db/src/schema-path.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const prismaSchemaPath = new URL("../prisma/schema.prisma", import.meta.url);
|
||||
106
packages/db/src/telegram-bot-store.ts
Normal file
106
packages/db/src/telegram-bot-store.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { hashPairingCode, isPairingExpired } from "@nproxy/domain";
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import { prisma as defaultPrisma } from "./prisma-client.js";
|
||||
|
||||
export interface TelegramUserSnapshot {
|
||||
telegramUserId: string;
|
||||
telegramUsername?: string;
|
||||
displayNameSnapshot: string;
|
||||
}
|
||||
|
||||
export interface PendingPairingChallenge {
|
||||
pairingId: string;
|
||||
code: string;
|
||||
expiresAt: Date;
|
||||
}
|
||||
|
||||
export function createPrismaTelegramBotStore(database: PrismaClient = defaultPrisma) {
|
||||
return {
|
||||
async isTelegramAdminAllowed(telegramUserId: string): Promise<boolean> {
|
||||
const entry = await database.telegramAdminAllowlistEntry.findUnique({
|
||||
where: {
|
||||
telegramUserId,
|
||||
},
|
||||
});
|
||||
|
||||
return Boolean(entry?.isActive);
|
||||
},
|
||||
|
||||
async getOrCreatePendingPairingChallenge(
|
||||
user: TelegramUserSnapshot,
|
||||
expiresInMinutes: number,
|
||||
): Promise<PendingPairingChallenge> {
|
||||
const now = new Date();
|
||||
const existing = await database.telegramPairing.findFirst({
|
||||
where: {
|
||||
telegramUserId: user.telegramUserId,
|
||||
status: "pending",
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
if (existing && !isPairingExpired(existing.expiresAt, now)) {
|
||||
await database.telegramPairing.update({
|
||||
where: {
|
||||
id: existing.id,
|
||||
},
|
||||
data: {
|
||||
status: "revoked",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (existing && isPairingExpired(existing.expiresAt, now)) {
|
||||
await database.telegramPairing.update({
|
||||
where: {
|
||||
id: existing.id,
|
||||
},
|
||||
data: {
|
||||
status: "expired",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const code = generatePairingCode();
|
||||
const expiresAt = new Date(now.getTime() + expiresInMinutes * 60 * 1000);
|
||||
const pairing = await database.telegramPairing.create({
|
||||
data: {
|
||||
telegramUserId: user.telegramUserId,
|
||||
...(user.telegramUsername ? { telegramUsername: user.telegramUsername } : {}),
|
||||
displayNameSnapshot: user.displayNameSnapshot,
|
||||
codeHash: hashPairingCode(code),
|
||||
expiresAt,
|
||||
status: "pending",
|
||||
},
|
||||
});
|
||||
|
||||
await database.adminAuditLog.create({
|
||||
data: {
|
||||
actorType: "system",
|
||||
action: "telegram_pair_pending_created",
|
||||
targetType: "telegram_pairing",
|
||||
targetId: pairing.id,
|
||||
metadata: {
|
||||
telegramUserId: user.telegramUserId,
|
||||
telegramUsername: user.telegramUsername ?? null,
|
||||
displayNameSnapshot: user.displayNameSnapshot,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
pairingId: pairing.id,
|
||||
code,
|
||||
expiresAt,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function generatePairingCode(): string {
|
||||
return randomBytes(4).toString("hex").toUpperCase();
|
||||
}
|
||||
291
packages/db/src/telegram-pairing-store.ts
Normal file
291
packages/db/src/telegram-pairing-store.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
import { isPairingExpired } from "@nproxy/domain";
|
||||
import type { PrismaClient, TelegramPairingStatus } from "@prisma/client";
|
||||
import { prisma as defaultPrisma } from "./prisma-client.js";
|
||||
|
||||
export interface PendingTelegramPairingRecord {
|
||||
id: string;
|
||||
telegramUserId: string;
|
||||
telegramUsername?: string;
|
||||
displayNameSnapshot: string;
|
||||
codeHash: string;
|
||||
expiresAt: Date;
|
||||
status: TelegramPairingStatus;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface ActiveTelegramAdminRecord {
|
||||
telegramUserId: string;
|
||||
telegramUsername?: string;
|
||||
displayNameSnapshot: string;
|
||||
pairedAt: Date;
|
||||
}
|
||||
|
||||
export function createPrismaTelegramPairingStore(database: PrismaClient = defaultPrisma) {
|
||||
return {
|
||||
async findPendingPairingByCodeHash(
|
||||
codeHash: string,
|
||||
): Promise<PendingTelegramPairingRecord | null> {
|
||||
const record = await database.telegramPairing.findFirst({
|
||||
where: {
|
||||
codeHash,
|
||||
status: "pending",
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return mapPendingPairingRecord(record);
|
||||
},
|
||||
|
||||
async listTelegramPairings(): Promise<{
|
||||
pending: PendingTelegramPairingRecord[];
|
||||
activeAdmins: ActiveTelegramAdminRecord[];
|
||||
}> {
|
||||
const [pending, activeAdmins] = await Promise.all([
|
||||
database.telegramPairing.findMany({
|
||||
where: {
|
||||
status: "pending",
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
}),
|
||||
database.telegramAdminAllowlistEntry.findMany({
|
||||
where: {
|
||||
isActive: true,
|
||||
},
|
||||
orderBy: {
|
||||
pairedAt: "desc",
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
pending: pending.map(mapPendingPairingRecord),
|
||||
activeAdmins: activeAdmins.map((entry) => ({
|
||||
telegramUserId: entry.telegramUserId,
|
||||
...(entry.telegramUsername ? { telegramUsername: entry.telegramUsername } : {}),
|
||||
displayNameSnapshot: entry.displayNameSnapshot,
|
||||
pairedAt: entry.pairedAt,
|
||||
})),
|
||||
};
|
||||
},
|
||||
|
||||
async completePendingPairing(input: {
|
||||
pairingId: string;
|
||||
actorRef?: string;
|
||||
}): Promise<ActiveTelegramAdminRecord> {
|
||||
return database.$transaction(async (transaction) => {
|
||||
const pairing = await transaction.telegramPairing.findUnique({
|
||||
where: {
|
||||
id: input.pairingId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!pairing || pairing.status !== "pending") {
|
||||
throw new Error("Pending pairing not found.");
|
||||
}
|
||||
|
||||
if (isPairingExpired(pairing.expiresAt)) {
|
||||
await transaction.telegramPairing.update({
|
||||
where: {
|
||||
id: pairing.id,
|
||||
},
|
||||
data: {
|
||||
status: "expired",
|
||||
},
|
||||
});
|
||||
|
||||
throw new Error("Pairing code has expired.");
|
||||
}
|
||||
|
||||
const allowlistEntry = await transaction.telegramAdminAllowlistEntry.upsert({
|
||||
where: {
|
||||
telegramUserId: pairing.telegramUserId,
|
||||
},
|
||||
update: {
|
||||
telegramUsername: pairing.telegramUsername,
|
||||
displayNameSnapshot: pairing.displayNameSnapshot,
|
||||
pairedAt: new Date(),
|
||||
revokedAt: null,
|
||||
isActive: true,
|
||||
},
|
||||
create: {
|
||||
telegramUserId: pairing.telegramUserId,
|
||||
telegramUsername: pairing.telegramUsername,
|
||||
displayNameSnapshot: pairing.displayNameSnapshot,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
await transaction.telegramPairing.update({
|
||||
where: {
|
||||
id: pairing.id,
|
||||
},
|
||||
data: {
|
||||
status: "completed",
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await transaction.adminAuditLog.create({
|
||||
data: {
|
||||
actorType: "cli_operator",
|
||||
...(input.actorRef ? { actorRef: input.actorRef } : {}),
|
||||
action: "telegram_pair_complete",
|
||||
targetType: "telegram_admin_allowlist_entry",
|
||||
targetId: allowlistEntry.telegramUserId,
|
||||
metadata: {
|
||||
pairingId: pairing.id,
|
||||
telegramUsername: pairing.telegramUsername,
|
||||
displayNameSnapshot: pairing.displayNameSnapshot,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
telegramUserId: allowlistEntry.telegramUserId,
|
||||
...(allowlistEntry.telegramUsername
|
||||
? { telegramUsername: allowlistEntry.telegramUsername }
|
||||
: {}),
|
||||
displayNameSnapshot: allowlistEntry.displayNameSnapshot,
|
||||
pairedAt: allowlistEntry.pairedAt,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
async revokeTelegramAdmin(input: {
|
||||
telegramUserId: string;
|
||||
actorRef?: string;
|
||||
}): Promise<ActiveTelegramAdminRecord | null> {
|
||||
return database.$transaction(async (transaction) => {
|
||||
const entry = await transaction.telegramAdminAllowlistEntry.findUnique({
|
||||
where: {
|
||||
telegramUserId: input.telegramUserId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!entry || !entry.isActive) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const revokedAt = new Date();
|
||||
const updated = await transaction.telegramAdminAllowlistEntry.update({
|
||||
where: {
|
||||
telegramUserId: input.telegramUserId,
|
||||
},
|
||||
data: {
|
||||
isActive: false,
|
||||
revokedAt,
|
||||
},
|
||||
});
|
||||
|
||||
await transaction.telegramPairing.updateMany({
|
||||
where: {
|
||||
telegramUserId: input.telegramUserId,
|
||||
status: "pending",
|
||||
},
|
||||
data: {
|
||||
status: "revoked",
|
||||
},
|
||||
});
|
||||
|
||||
await transaction.adminAuditLog.create({
|
||||
data: {
|
||||
actorType: "cli_operator",
|
||||
...(input.actorRef ? { actorRef: input.actorRef } : {}),
|
||||
action: "telegram_pair_revoke",
|
||||
targetType: "telegram_admin_allowlist_entry",
|
||||
targetId: updated.telegramUserId,
|
||||
metadata: {
|
||||
telegramUsername: updated.telegramUsername,
|
||||
displayNameSnapshot: updated.displayNameSnapshot,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
telegramUserId: updated.telegramUserId,
|
||||
...(updated.telegramUsername ? { telegramUsername: updated.telegramUsername } : {}),
|
||||
displayNameSnapshot: updated.displayNameSnapshot,
|
||||
pairedAt: updated.pairedAt,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
async cleanupExpiredPendingPairings(input?: {
|
||||
actorRef?: string;
|
||||
now?: Date;
|
||||
}): Promise<number> {
|
||||
const now = input?.now ?? new Date();
|
||||
const expired = await database.telegramPairing.findMany({
|
||||
where: {
|
||||
status: "pending",
|
||||
expiresAt: {
|
||||
lte: now,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (expired.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
await database.$transaction([
|
||||
database.telegramPairing.updateMany({
|
||||
where: {
|
||||
id: {
|
||||
in: expired.map((item) => item.id),
|
||||
},
|
||||
},
|
||||
data: {
|
||||
status: "expired",
|
||||
},
|
||||
}),
|
||||
database.adminAuditLog.create({
|
||||
data: {
|
||||
actorType: "cli_operator",
|
||||
...(input?.actorRef ? { actorRef: input.actorRef } : {}),
|
||||
action: "telegram_pair_cleanup",
|
||||
targetType: "telegram_pairing",
|
||||
metadata: {
|
||||
expiredCount: expired.length,
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return expired.length;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mapPendingPairingRecord(record: {
|
||||
id: string;
|
||||
telegramUserId: string;
|
||||
telegramUsername: string | null;
|
||||
displayNameSnapshot: string;
|
||||
codeHash: string;
|
||||
expiresAt: Date;
|
||||
status: TelegramPairingStatus;
|
||||
createdAt: Date;
|
||||
}): PendingTelegramPairingRecord {
|
||||
return {
|
||||
id: record.id,
|
||||
telegramUserId: record.telegramUserId,
|
||||
...(record.telegramUsername ? { telegramUsername: record.telegramUsername } : {}),
|
||||
displayNameSnapshot: record.displayNameSnapshot,
|
||||
codeHash: record.codeHash,
|
||||
expiresAt: record.expiresAt,
|
||||
status: record.status,
|
||||
createdAt: record.createdAt,
|
||||
};
|
||||
}
|
||||
502
packages/db/src/worker-store.ts
Normal file
502
packages/db/src/worker-store.ts
Normal file
@@ -0,0 +1,502 @@
|
||||
import {
|
||||
buildAttemptPlan,
|
||||
evaluateAttempt,
|
||||
markGenerationRequestSucceeded,
|
||||
type GenerationRequestRecord,
|
||||
type ProviderFailureKind,
|
||||
type ProviderKeySnapshot,
|
||||
} from "@nproxy/domain";
|
||||
import type { AdminActorType, PrismaClient, ProviderKeyState } from "@prisma/client";
|
||||
import { prisma as defaultPrisma } from "./prisma-client.js";
|
||||
import type { GeneratedAssetPayload, ProviderExecutionResult } from "@nproxy/providers";
|
||||
import { createPrismaGenerationStore } from "./generation-store.js";
|
||||
|
||||
export interface WorkerGenerationRequest extends GenerationRequestRecord {}
|
||||
|
||||
export interface WorkerProviderKey extends ProviderKeySnapshot {
|
||||
providerCode: string;
|
||||
label: string;
|
||||
apiKeyLastFour: string;
|
||||
roundRobinOrder: number;
|
||||
proxyBaseUrl?: string;
|
||||
proxyLabel?: string;
|
||||
}
|
||||
|
||||
export interface ClaimedGenerationJob {
|
||||
request: WorkerGenerationRequest;
|
||||
providerKeys: WorkerProviderKey[];
|
||||
lastUsedKeyId?: string;
|
||||
}
|
||||
|
||||
export interface ProcessGenerationJobResult {
|
||||
requestId: string;
|
||||
finalStatus: "succeeded" | "failed";
|
||||
attemptsCreated: number;
|
||||
consumedQuota: boolean;
|
||||
}
|
||||
|
||||
export interface RecoverCooldownKeysResult {
|
||||
recoveredCount: number;
|
||||
}
|
||||
|
||||
export type WorkerKeyExecutionResult = ProviderExecutionResult & {
|
||||
usedProxy: boolean;
|
||||
directFallbackUsed: boolean;
|
||||
};
|
||||
|
||||
export interface WorkerExecutionPolicy {
|
||||
cooldownMinutes: number;
|
||||
failuresBeforeManualReview: number;
|
||||
}
|
||||
|
||||
const defaultWorkerExecutionPolicy: WorkerExecutionPolicy = {
|
||||
cooldownMinutes: 5,
|
||||
failuresBeforeManualReview: 10,
|
||||
};
|
||||
|
||||
export function createPrismaWorkerStore(
|
||||
database: PrismaClient = defaultPrisma,
|
||||
policy: WorkerExecutionPolicy = defaultWorkerExecutionPolicy,
|
||||
) {
|
||||
const generationStore = createPrismaGenerationStore(database);
|
||||
|
||||
return {
|
||||
async recoverCooldownProviderKeys(now: Date = new Date()): Promise<RecoverCooldownKeysResult> {
|
||||
const eligibleKeys = await database.providerKey.findMany({
|
||||
where: {
|
||||
state: "cooldown",
|
||||
cooldownUntil: {
|
||||
lte: now,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
proxy: true,
|
||||
},
|
||||
orderBy: {
|
||||
cooldownUntil: "asc",
|
||||
},
|
||||
});
|
||||
|
||||
for (const providerKey of eligibleKeys) {
|
||||
await updateProviderKeyState(database, {
|
||||
providerKey: mapWorkerProviderKey(providerKey),
|
||||
toState: "active",
|
||||
reason: "recovered",
|
||||
nextConsecutiveRetryableFailures: providerKey.consecutiveRetryableFailures,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
recoveredCount: eligibleKeys.length,
|
||||
};
|
||||
},
|
||||
|
||||
async claimNextQueuedGenerationJob(): Promise<ClaimedGenerationJob | null> {
|
||||
return database.$transaction(async (transaction) => {
|
||||
const queuedRequest = await transaction.generationRequest.findFirst({
|
||||
where: {
|
||||
status: "queued",
|
||||
},
|
||||
orderBy: {
|
||||
requestedAt: "asc",
|
||||
},
|
||||
});
|
||||
|
||||
if (!queuedRequest) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const claimResult = await transaction.generationRequest.updateMany({
|
||||
where: {
|
||||
id: queuedRequest.id,
|
||||
status: "queued",
|
||||
},
|
||||
data: {
|
||||
status: "running",
|
||||
startedAt: queuedRequest.startedAt ?? new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
if (claimResult.count === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const request = await transaction.generationRequest.findUnique({
|
||||
where: {
|
||||
id: queuedRequest.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!request) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const providerKeys = await transaction.providerKey.findMany({
|
||||
where: {
|
||||
providerCode: request.providerModel,
|
||||
},
|
||||
include: {
|
||||
proxy: true,
|
||||
},
|
||||
orderBy: {
|
||||
roundRobinOrder: "asc",
|
||||
},
|
||||
});
|
||||
|
||||
const lastAttempt = await transaction.generationAttempt.findFirst({
|
||||
where: {
|
||||
providerKey: {
|
||||
providerCode: request.providerModel,
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
startedAt: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
request: mapGenerationRequest(request),
|
||||
providerKeys: providerKeys.map(mapWorkerProviderKey),
|
||||
...(lastAttempt ? { lastUsedKeyId: lastAttempt.providerKeyId } : {}),
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
async processClaimedGenerationJob(
|
||||
job: ClaimedGenerationJob,
|
||||
executeWithKey: (
|
||||
request: WorkerGenerationRequest,
|
||||
providerKey: WorkerProviderKey,
|
||||
) => Promise<WorkerKeyExecutionResult>,
|
||||
): Promise<ProcessGenerationJobResult> {
|
||||
const attemptPlan = buildAttemptPlan({
|
||||
keys: job.providerKeys,
|
||||
...(job.lastUsedKeyId ? { lastUsedKeyId: job.lastUsedKeyId } : {}),
|
||||
});
|
||||
|
||||
if (attemptPlan.keyIdsInAttemptOrder.length === 0) {
|
||||
await markRequestFailed(
|
||||
database,
|
||||
job.request.id,
|
||||
"no_provider_keys",
|
||||
"No active provider keys are available for the configured model.",
|
||||
);
|
||||
|
||||
return {
|
||||
requestId: job.request.id,
|
||||
finalStatus: "failed",
|
||||
attemptsCreated: 0,
|
||||
consumedQuota: false,
|
||||
};
|
||||
}
|
||||
|
||||
let attemptsCreated = 0;
|
||||
|
||||
for (const providerKeyId of attemptPlan.keyIdsInAttemptOrder) {
|
||||
const providerKey = job.providerKeys.find((key) => key.id === providerKeyId);
|
||||
|
||||
if (!providerKey) {
|
||||
continue;
|
||||
}
|
||||
|
||||
attemptsCreated += 1;
|
||||
|
||||
const executionResult = await executeWithKey(job.request, providerKey);
|
||||
|
||||
const attempt = await database.generationAttempt.create({
|
||||
data: {
|
||||
generationRequestId: job.request.id,
|
||||
providerKeyId: providerKey.id,
|
||||
attemptIndex: attemptsCreated,
|
||||
status: executionResult.ok ? "succeeded" : "failed",
|
||||
usedProxy: executionResult.usedProxy,
|
||||
directFallbackUsed: executionResult.directFallbackUsed,
|
||||
...(executionResult.ok
|
||||
? {}
|
||||
: {
|
||||
failureCategory: mapFailureCategory(executionResult.failureKind),
|
||||
providerHttpStatus: executionResult.providerHttpStatus ?? null,
|
||||
providerErrorCode: executionResult.providerErrorCode ?? null,
|
||||
providerErrorText: executionResult.providerErrorText ?? null,
|
||||
}),
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
if (executionResult.ok) {
|
||||
if (providerKey.state === "cooldown") {
|
||||
await updateProviderKeyState(database, {
|
||||
providerKey,
|
||||
toState: "active",
|
||||
reason: "recovered",
|
||||
nextConsecutiveRetryableFailures: 0,
|
||||
});
|
||||
} else if (providerKey.consecutiveRetryableFailures !== 0) {
|
||||
await database.providerKey.update({
|
||||
where: {
|
||||
id: providerKey.id,
|
||||
},
|
||||
data: {
|
||||
consecutiveRetryableFailures: 0,
|
||||
lastErrorCategory: null,
|
||||
lastErrorCode: null,
|
||||
lastErrorAt: null,
|
||||
cooldownUntil: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await persistGeneratedAssets(database, job.request.id, executionResult.assets);
|
||||
const successRecord = await markGenerationRequestSucceeded(
|
||||
{
|
||||
getGenerationRequest: generationStore.getGenerationRequest,
|
||||
markGenerationSucceeded: generationStore.markGenerationSucceeded,
|
||||
},
|
||||
attempt.generationRequestId,
|
||||
);
|
||||
|
||||
return {
|
||||
requestId: job.request.id,
|
||||
finalStatus: "succeeded",
|
||||
attemptsCreated,
|
||||
consumedQuota: successRecord.quotaConsumed,
|
||||
};
|
||||
}
|
||||
|
||||
const evaluation = evaluateAttempt(providerKey, executionResult, {
|
||||
failuresBeforeManualReview: policy.failuresBeforeManualReview,
|
||||
});
|
||||
await updateProviderKeyState(database, {
|
||||
providerKey,
|
||||
toState: evaluation.transition.to,
|
||||
reason: evaluation.transition.reason,
|
||||
nextConsecutiveRetryableFailures: evaluation.nextConsecutiveRetryableFailures,
|
||||
failureKind: executionResult.failureKind,
|
||||
...(executionResult.providerErrorCode !== undefined
|
||||
? { errorCode: executionResult.providerErrorCode }
|
||||
: {}),
|
||||
cooldownMinutes: policy.cooldownMinutes,
|
||||
});
|
||||
|
||||
if (evaluation.retryDisposition === "stop_request") {
|
||||
await markRequestFailed(
|
||||
database,
|
||||
job.request.id,
|
||||
executionResult.providerErrorCode ?? "request_failed",
|
||||
executionResult.providerErrorText ?? "Generation failed.",
|
||||
);
|
||||
|
||||
return {
|
||||
requestId: job.request.id,
|
||||
finalStatus: "failed",
|
||||
attemptsCreated,
|
||||
consumedQuota: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
await markRequestFailed(
|
||||
database,
|
||||
job.request.id,
|
||||
"eligible_keys_exhausted",
|
||||
"All eligible provider keys were exhausted by retryable failures.",
|
||||
);
|
||||
|
||||
return {
|
||||
requestId: job.request.id,
|
||||
finalStatus: "failed",
|
||||
attemptsCreated,
|
||||
consumedQuota: false,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function persistGeneratedAssets(
|
||||
database: PrismaClient,
|
||||
generationRequestId: string,
|
||||
assets: GeneratedAssetPayload[],
|
||||
): Promise<void> {
|
||||
for (const asset of assets) {
|
||||
await database.generatedAsset.create({
|
||||
data: {
|
||||
generationRequestId,
|
||||
objectKey: asset.objectKey,
|
||||
mimeType: asset.mimeType,
|
||||
...(asset.width !== undefined ? { width: asset.width } : {}),
|
||||
...(asset.height !== undefined ? { height: asset.height } : {}),
|
||||
...(asset.bytes !== undefined ? { bytes: asset.bytes } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function updateProviderKeyState(
|
||||
database: PrismaClient,
|
||||
input: {
|
||||
providerKey: WorkerProviderKey;
|
||||
toState: ProviderKeyState;
|
||||
reason: string;
|
||||
nextConsecutiveRetryableFailures: number;
|
||||
cooldownMinutes?: number;
|
||||
failureKind?: ProviderFailureKind;
|
||||
errorCode?: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
const now = new Date();
|
||||
const fromState = input.providerKey.state;
|
||||
const lastErrorCategory = input.failureKind
|
||||
? mapFailureCategory(input.failureKind)
|
||||
: null;
|
||||
|
||||
await database.providerKey.update({
|
||||
where: {
|
||||
id: input.providerKey.id,
|
||||
},
|
||||
data: {
|
||||
state: input.toState,
|
||||
consecutiveRetryableFailures: input.nextConsecutiveRetryableFailures,
|
||||
cooldownUntil:
|
||||
input.toState === "cooldown"
|
||||
? addMinutes(now, input.cooldownMinutes ?? 5)
|
||||
: null,
|
||||
lastErrorCategory,
|
||||
lastErrorCode: input.errorCode ?? null,
|
||||
lastErrorAt: input.failureKind ? now : null,
|
||||
disabledAt: input.toState === "disabled" ? now : null,
|
||||
},
|
||||
});
|
||||
|
||||
if (fromState !== input.toState || input.reason !== "none") {
|
||||
await database.providerKeyStatusEvent.create({
|
||||
data: {
|
||||
providerKeyId: input.providerKey.id,
|
||||
fromState,
|
||||
toState: input.toState,
|
||||
reason: input.reason,
|
||||
errorCategory: lastErrorCategory,
|
||||
errorCode: input.errorCode ?? null,
|
||||
actorType: "system" satisfies AdminActorType,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function markRequestFailed(
|
||||
database: PrismaClient,
|
||||
requestId: string,
|
||||
terminalErrorCode: string,
|
||||
terminalErrorText: string,
|
||||
): Promise<void> {
|
||||
await database.generationRequest.update({
|
||||
where: {
|
||||
id: requestId,
|
||||
},
|
||||
data: {
|
||||
status: "failed",
|
||||
terminalErrorCode,
|
||||
terminalErrorText,
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function mapFailureCategory(failureKind: ProviderFailureKind) {
|
||||
switch (failureKind) {
|
||||
case "transport":
|
||||
return "transport";
|
||||
case "timeout":
|
||||
return "timeout";
|
||||
case "provider_5xx":
|
||||
return "provider_5xx";
|
||||
case "provider_4xx_user":
|
||||
return "provider_4xx_user";
|
||||
case "insufficient_funds":
|
||||
return "insufficient_funds";
|
||||
case "unknown":
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
function addMinutes(value: Date, minutes: number): Date {
|
||||
return new Date(value.getTime() + minutes * 60 * 1000);
|
||||
}
|
||||
|
||||
function mapGenerationRequest(request: {
|
||||
id: string;
|
||||
userId: string;
|
||||
mode: string;
|
||||
status: string;
|
||||
providerModel: string;
|
||||
prompt: string;
|
||||
sourceImageKey: string | null;
|
||||
resolutionPreset: string;
|
||||
batchSize: number;
|
||||
imageStrength: { toNumber(): number } | null;
|
||||
idempotencyKey: string | null;
|
||||
terminalErrorCode: string | null;
|
||||
terminalErrorText: string | null;
|
||||
requestedAt: Date;
|
||||
startedAt: Date | null;
|
||||
completedAt: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}): WorkerGenerationRequest {
|
||||
return {
|
||||
id: request.id,
|
||||
userId: request.userId,
|
||||
mode: request.mode as WorkerGenerationRequest["mode"],
|
||||
status: request.status as WorkerGenerationRequest["status"],
|
||||
providerModel: request.providerModel,
|
||||
prompt: request.prompt,
|
||||
resolutionPreset: request.resolutionPreset,
|
||||
batchSize: request.batchSize,
|
||||
requestedAt: request.requestedAt,
|
||||
createdAt: request.createdAt,
|
||||
updatedAt: request.updatedAt,
|
||||
...(request.sourceImageKey !== null ? { sourceImageKey: request.sourceImageKey } : {}),
|
||||
...(request.imageStrength !== null
|
||||
? { imageStrength: request.imageStrength.toNumber() }
|
||||
: {}),
|
||||
...(request.idempotencyKey !== null ? { idempotencyKey: request.idempotencyKey } : {}),
|
||||
...(request.terminalErrorCode !== null
|
||||
? { terminalErrorCode: request.terminalErrorCode }
|
||||
: {}),
|
||||
...(request.terminalErrorText !== null
|
||||
? { terminalErrorText: request.terminalErrorText }
|
||||
: {}),
|
||||
...(request.startedAt !== null ? { startedAt: request.startedAt } : {}),
|
||||
...(request.completedAt !== null ? { completedAt: request.completedAt } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function mapWorkerProviderKey(providerKey: {
|
||||
id: string;
|
||||
providerCode: string;
|
||||
label: string;
|
||||
apiKeyLastFour: string;
|
||||
state: string;
|
||||
roundRobinOrder: number;
|
||||
consecutiveRetryableFailures: number;
|
||||
proxy: {
|
||||
label: string;
|
||||
baseUrl: string;
|
||||
isActive: boolean;
|
||||
} | null;
|
||||
}): WorkerProviderKey {
|
||||
return {
|
||||
id: providerKey.id,
|
||||
providerCode: providerKey.providerCode,
|
||||
label: providerKey.label,
|
||||
apiKeyLastFour: providerKey.apiKeyLastFour,
|
||||
state: providerKey.state as WorkerProviderKey["state"],
|
||||
roundRobinOrder: providerKey.roundRobinOrder,
|
||||
consecutiveRetryableFailures: providerKey.consecutiveRetryableFailures,
|
||||
...(providerKey.proxy && providerKey.proxy.isActive
|
||||
? {
|
||||
proxyBaseUrl: providerKey.proxy.baseUrl,
|
||||
proxyLabel: providerKey.proxy.label,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
12
packages/db/tsconfig.json
Normal file
12
packages/db/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
17
packages/domain/AGENTS.md
Normal file
17
packages/domain/AGENTS.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Scope
|
||||
Applies within `packages/domain`.
|
||||
|
||||
## Responsibilities
|
||||
- subscription lifecycle
|
||||
- billing cycle rules
|
||||
- quota ledger rules
|
||||
- generation orchestration
|
||||
- provider-key state transitions
|
||||
- admin and pairing policies
|
||||
|
||||
## Rules
|
||||
- This package owns the business meaning of key states and retry decisions.
|
||||
- Do not hide exact quota from admins, but never expose exact quota to normal users.
|
||||
- A successful user request may have multiple provider attempts but may consume quota only once.
|
||||
20
packages/domain/README.md
Normal file
20
packages/domain/README.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# packages/domain
|
||||
|
||||
Business rules for `nproxy`.
|
||||
|
||||
## Implemented in this iteration
|
||||
- Approximate quota bucket contract: `100/80/60/40/20/0`
|
||||
- Provider key pool round-robin active-key selection
|
||||
- Provider attempt classification for retry vs terminal outcomes
|
||||
- Key-state transition policy for cooldown/manual_review/out_of_funds
|
||||
|
||||
## Current exports
|
||||
- `getApproximateQuotaBucket`
|
||||
- `isRetryableFailure`
|
||||
- `evaluateAttempt`
|
||||
- `selectActiveKeysRoundRobin`
|
||||
- `buildAttemptPlan`
|
||||
|
||||
## Notes
|
||||
- Domain logic stays provider-agnostic.
|
||||
- Transport code must live in `packages/providers`.
|
||||
21
packages/domain/package.json
Normal file
21
packages/domain/package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@nproxy/domain",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"check": "tsc -p tsconfig.json --noEmit"
|
||||
}
|
||||
}
|
||||
0
packages/domain/src/.gitkeep
Normal file
0
packages/domain/src/.gitkeep
Normal file
97
packages/domain/src/auth.ts
Normal file
97
packages/domain/src/auth.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { randomBytes, scryptSync, timingSafeEqual, createHash } from "node:crypto";
|
||||
|
||||
const PASSWORD_KEY_LENGTH = 64;
|
||||
const PASSWORD_SALT_LENGTH = 16;
|
||||
|
||||
export interface PasswordPolicyOptions {
|
||||
minLength?: number;
|
||||
}
|
||||
|
||||
export class AuthError extends Error {
|
||||
readonly code:
|
||||
| "invalid_email"
|
||||
| "invalid_password"
|
||||
| "email_already_exists"
|
||||
| "invalid_credentials"
|
||||
| "session_not_found"
|
||||
| "reset_token_invalid";
|
||||
|
||||
constructor(code: AuthError["code"], message: string) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeEmail(email: string): string {
|
||||
return email.trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function validateEmail(email: string): string {
|
||||
const normalized = normalizeEmail(email);
|
||||
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalized)) {
|
||||
throw new AuthError("invalid_email", "Email must be a valid email address.");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function validatePassword(
|
||||
password: string,
|
||||
options: PasswordPolicyOptions = {},
|
||||
): string {
|
||||
const minLength = options.minLength ?? 8;
|
||||
|
||||
if (password.length < minLength) {
|
||||
throw new AuthError(
|
||||
"invalid_password",
|
||||
`Password must be at least ${minLength} characters long.`,
|
||||
);
|
||||
}
|
||||
|
||||
return password;
|
||||
}
|
||||
|
||||
export function hashPassword(password: string, pepper: string): string {
|
||||
const salt = randomBytes(PASSWORD_SALT_LENGTH);
|
||||
const derived = scryptSync(`${password}${pepper}`, salt, PASSWORD_KEY_LENGTH);
|
||||
|
||||
return ["scrypt", salt.toString("hex"), derived.toString("hex")].join("$");
|
||||
}
|
||||
|
||||
export function verifyPassword(
|
||||
password: string,
|
||||
passwordHash: string,
|
||||
pepper: string,
|
||||
): boolean {
|
||||
const [algorithm, saltHex, hashHex] = passwordHash.split("$");
|
||||
|
||||
if (algorithm !== "scrypt" || !saltHex || !hashHex) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expected = Buffer.from(hashHex, "hex");
|
||||
const actual = scryptSync(
|
||||
`${password}${pepper}`,
|
||||
Buffer.from(saltHex, "hex"),
|
||||
expected.length,
|
||||
);
|
||||
|
||||
return timingSafeEqual(expected, actual);
|
||||
}
|
||||
|
||||
export function createSessionToken(): string {
|
||||
return randomBytes(32).toString("base64url");
|
||||
}
|
||||
|
||||
export function hashSessionToken(token: string): string {
|
||||
return createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
export function createPasswordResetToken(): string {
|
||||
return randomBytes(32).toString("base64url");
|
||||
}
|
||||
|
||||
export function hashPasswordResetToken(token: string): string {
|
||||
return createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
221
packages/domain/src/generation.ts
Normal file
221
packages/domain/src/generation.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { getApproximateQuotaBucket, type QuotaBucket } from "./quota.js";
|
||||
|
||||
export type GenerationMode = "text_to_image" | "image_to_image";
|
||||
|
||||
export type GenerationRequestStatus =
|
||||
| "queued"
|
||||
| "running"
|
||||
| "succeeded"
|
||||
| "failed"
|
||||
| "canceled";
|
||||
|
||||
export interface ActiveSubscriptionContext {
|
||||
subscriptionId: string;
|
||||
planId: string;
|
||||
monthlyRequestLimit: number;
|
||||
usedSuccessfulRequests: number;
|
||||
}
|
||||
|
||||
export interface GenerationRequestRecord {
|
||||
id: string;
|
||||
userId: string;
|
||||
mode: GenerationMode;
|
||||
status: GenerationRequestStatus;
|
||||
providerModel: string;
|
||||
prompt: string;
|
||||
sourceImageKey?: string;
|
||||
resolutionPreset: string;
|
||||
batchSize: number;
|
||||
imageStrength?: number;
|
||||
idempotencyKey?: string;
|
||||
terminalErrorCode?: string;
|
||||
terminalErrorText?: string;
|
||||
requestedAt: Date;
|
||||
startedAt?: Date;
|
||||
completedAt?: Date;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface CreateGenerationRequestInput {
|
||||
userId: string;
|
||||
mode: GenerationMode;
|
||||
providerModel: string;
|
||||
prompt: string;
|
||||
sourceImageKey?: string;
|
||||
resolutionPreset: string;
|
||||
batchSize: number;
|
||||
imageStrength?: number;
|
||||
idempotencyKey?: string;
|
||||
}
|
||||
|
||||
export interface CreateGenerationRequestResult {
|
||||
request: GenerationRequestRecord;
|
||||
reusedExistingRequest: boolean;
|
||||
approximateQuotaBucket: QuotaBucket;
|
||||
}
|
||||
|
||||
export interface CreateGenerationRequestDeps {
|
||||
findReusableRequest(
|
||||
userId: string,
|
||||
idempotencyKey: string,
|
||||
): Promise<GenerationRequestRecord | null>;
|
||||
findActiveSubscriptionContext(userId: string): Promise<ActiveSubscriptionContext | null>;
|
||||
createGenerationRequest(
|
||||
input: CreateGenerationRequestInput,
|
||||
): Promise<GenerationRequestRecord>;
|
||||
}
|
||||
|
||||
export interface SuccessfulGenerationRecord {
|
||||
request: GenerationRequestRecord;
|
||||
quotaConsumed: boolean;
|
||||
}
|
||||
|
||||
export interface MarkGenerationSucceededDeps {
|
||||
getGenerationRequest(requestId: string): Promise<GenerationRequestRecord | null>;
|
||||
markGenerationSucceeded(requestId: string): Promise<SuccessfulGenerationRecord>;
|
||||
}
|
||||
|
||||
export class GenerationRequestError extends Error {
|
||||
readonly code:
|
||||
| "missing_active_subscription"
|
||||
| "quota_exhausted"
|
||||
| "invalid_prompt"
|
||||
| "invalid_batch_size"
|
||||
| "missing_source_image"
|
||||
| "unexpected_source_image"
|
||||
| "missing_image_strength"
|
||||
| "unexpected_image_strength"
|
||||
| "request_not_found"
|
||||
| "request_not_completable";
|
||||
|
||||
constructor(code: GenerationRequestError["code"], message: string) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createGenerationRequest(
|
||||
deps: CreateGenerationRequestDeps,
|
||||
input: CreateGenerationRequestInput,
|
||||
): Promise<CreateGenerationRequestResult> {
|
||||
validateGenerationRequestInput(input);
|
||||
|
||||
if (input.idempotencyKey) {
|
||||
const existing = await deps.findReusableRequest(input.userId, input.idempotencyKey);
|
||||
|
||||
if (existing) {
|
||||
const subscription = await deps.findActiveSubscriptionContext(input.userId);
|
||||
const approximateQuotaBucket = subscription
|
||||
? getApproximateQuotaBucket({
|
||||
used: subscription.usedSuccessfulRequests,
|
||||
limit: subscription.monthlyRequestLimit,
|
||||
})
|
||||
: 0;
|
||||
|
||||
return {
|
||||
request: existing,
|
||||
reusedExistingRequest: true,
|
||||
approximateQuotaBucket,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const subscription = await deps.findActiveSubscriptionContext(input.userId);
|
||||
|
||||
if (!subscription) {
|
||||
throw new GenerationRequestError(
|
||||
"missing_active_subscription",
|
||||
"An active subscription is required before creating generation requests.",
|
||||
);
|
||||
}
|
||||
|
||||
if (subscription.usedSuccessfulRequests >= subscription.monthlyRequestLimit) {
|
||||
throw new GenerationRequestError(
|
||||
"quota_exhausted",
|
||||
"The current billing cycle has no remaining successful generation quota.",
|
||||
);
|
||||
}
|
||||
|
||||
const request = await deps.createGenerationRequest(input);
|
||||
|
||||
return {
|
||||
request,
|
||||
reusedExistingRequest: false,
|
||||
approximateQuotaBucket: getApproximateQuotaBucket({
|
||||
used: subscription.usedSuccessfulRequests,
|
||||
limit: subscription.monthlyRequestLimit,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function markGenerationRequestSucceeded(
|
||||
deps: MarkGenerationSucceededDeps,
|
||||
requestId: string,
|
||||
): Promise<SuccessfulGenerationRecord> {
|
||||
const request = await deps.getGenerationRequest(requestId);
|
||||
|
||||
if (!request) {
|
||||
throw new GenerationRequestError(
|
||||
"request_not_found",
|
||||
`Generation request ${requestId} was not found.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (request.status === "failed" || request.status === "canceled") {
|
||||
throw new GenerationRequestError(
|
||||
"request_not_completable",
|
||||
`Generation request ${requestId} is terminal and cannot succeed.`,
|
||||
);
|
||||
}
|
||||
|
||||
return deps.markGenerationSucceeded(requestId);
|
||||
}
|
||||
|
||||
function validateGenerationRequestInput(input: CreateGenerationRequestInput): void {
|
||||
if (input.prompt.trim().length === 0) {
|
||||
throw new GenerationRequestError(
|
||||
"invalid_prompt",
|
||||
"Prompt must not be empty after trimming.",
|
||||
);
|
||||
}
|
||||
|
||||
if (!Number.isInteger(input.batchSize) || input.batchSize <= 0) {
|
||||
throw new GenerationRequestError(
|
||||
"invalid_batch_size",
|
||||
"Batch size must be a positive integer.",
|
||||
);
|
||||
}
|
||||
|
||||
if (input.mode === "image_to_image") {
|
||||
if (!input.sourceImageKey) {
|
||||
throw new GenerationRequestError(
|
||||
"missing_source_image",
|
||||
"Image-to-image requests require a source image key.",
|
||||
);
|
||||
}
|
||||
|
||||
if (input.imageStrength === undefined) {
|
||||
throw new GenerationRequestError(
|
||||
"missing_image_strength",
|
||||
"Image-to-image requests require image strength.",
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (input.sourceImageKey) {
|
||||
throw new GenerationRequestError(
|
||||
"unexpected_source_image",
|
||||
"Text-to-image requests must not include a source image key.",
|
||||
);
|
||||
}
|
||||
|
||||
if (input.imageStrength !== undefined) {
|
||||
throw new GenerationRequestError(
|
||||
"unexpected_image_strength",
|
||||
"Text-to-image requests must not include image strength.",
|
||||
);
|
||||
}
|
||||
}
|
||||
5
packages/domain/src/index.ts
Normal file
5
packages/domain/src/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from "./quota.js";
|
||||
export * from "./provider-key-pool.js";
|
||||
export * from "./generation.js";
|
||||
export * from "./auth.js";
|
||||
export * from "./telegram-pairing.js";
|
||||
172
packages/domain/src/provider-key-pool.ts
Normal file
172
packages/domain/src/provider-key-pool.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
export type ProviderKeyState =
|
||||
| "active"
|
||||
| "cooldown"
|
||||
| "out_of_funds"
|
||||
| "manual_review"
|
||||
| "disabled";
|
||||
|
||||
export interface ProviderKeySnapshot {
|
||||
id: string;
|
||||
state: ProviderKeyState;
|
||||
consecutiveRetryableFailures: number;
|
||||
}
|
||||
|
||||
export type ProviderFailureKind =
|
||||
| "transport"
|
||||
| "timeout"
|
||||
| "provider_5xx"
|
||||
| "provider_4xx_user"
|
||||
| "insufficient_funds"
|
||||
| "unknown";
|
||||
|
||||
export interface ProviderAttemptResultSuccess {
|
||||
ok: true;
|
||||
}
|
||||
|
||||
export interface ProviderAttemptResultFailure {
|
||||
ok: false;
|
||||
failureKind: ProviderFailureKind;
|
||||
}
|
||||
|
||||
export type ProviderAttemptResult =
|
||||
| ProviderAttemptResultSuccess
|
||||
| ProviderAttemptResultFailure;
|
||||
|
||||
export type RetryDisposition = "retry_next_key" | "stop_request";
|
||||
|
||||
export interface KeyStateTransition {
|
||||
from: ProviderKeyState;
|
||||
to: ProviderKeyState;
|
||||
reason:
|
||||
| "retryable_failure"
|
||||
| "insufficient_funds"
|
||||
| "recovered"
|
||||
| "manual_action"
|
||||
| "none";
|
||||
}
|
||||
|
||||
export interface EvaluateAttemptOutput {
|
||||
retryDisposition: RetryDisposition;
|
||||
transition: KeyStateTransition;
|
||||
nextConsecutiveRetryableFailures: number;
|
||||
}
|
||||
|
||||
export interface EvaluateAttemptOptions {
|
||||
failuresBeforeManualReview?: number;
|
||||
}
|
||||
|
||||
export function isRetryableFailure(kind: ProviderFailureKind): boolean {
|
||||
return kind === "transport" || kind === "timeout" || kind === "provider_5xx";
|
||||
}
|
||||
|
||||
export function evaluateAttempt(
|
||||
key: ProviderKeySnapshot,
|
||||
result: ProviderAttemptResult,
|
||||
options: EvaluateAttemptOptions = {},
|
||||
): EvaluateAttemptOutput {
|
||||
if (result.ok) {
|
||||
return {
|
||||
retryDisposition: "stop_request",
|
||||
transition: {
|
||||
from: key.state,
|
||||
to: key.state === "cooldown" ? "active" : key.state,
|
||||
reason: key.state === "cooldown" ? "recovered" : "none",
|
||||
},
|
||||
nextConsecutiveRetryableFailures: 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (result.failureKind === "insufficient_funds") {
|
||||
return {
|
||||
retryDisposition: "stop_request",
|
||||
transition: {
|
||||
from: key.state,
|
||||
to: "out_of_funds",
|
||||
reason: "insufficient_funds",
|
||||
},
|
||||
nextConsecutiveRetryableFailures: key.consecutiveRetryableFailures,
|
||||
};
|
||||
}
|
||||
|
||||
if (isRetryableFailure(result.failureKind)) {
|
||||
const nextFailures = key.consecutiveRetryableFailures + 1;
|
||||
const failuresBeforeManualReview = options.failuresBeforeManualReview ?? 10;
|
||||
const shouldEscalateToManualReview =
|
||||
nextFailures > failuresBeforeManualReview;
|
||||
|
||||
return {
|
||||
retryDisposition: "retry_next_key",
|
||||
transition: {
|
||||
from: key.state,
|
||||
to: shouldEscalateToManualReview ? "manual_review" : "cooldown",
|
||||
reason: "retryable_failure",
|
||||
},
|
||||
nextConsecutiveRetryableFailures: nextFailures,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
retryDisposition: "stop_request",
|
||||
transition: {
|
||||
from: key.state,
|
||||
to: key.state,
|
||||
reason: "none",
|
||||
},
|
||||
nextConsecutiveRetryableFailures: key.consecutiveRetryableFailures,
|
||||
};
|
||||
}
|
||||
|
||||
export interface KeySelectionInput {
|
||||
keys: ReadonlyArray<ProviderKeySnapshot>;
|
||||
lastUsedKeyId?: string;
|
||||
}
|
||||
|
||||
export interface KeySelectionOutput {
|
||||
orderedActiveKeyIds: string[];
|
||||
}
|
||||
|
||||
export function selectActiveKeysRoundRobin(input: KeySelectionInput): KeySelectionOutput {
|
||||
const active = input.keys.filter((key) => key.state === "active");
|
||||
|
||||
if (active.length === 0) {
|
||||
return { orderedActiveKeyIds: [] };
|
||||
}
|
||||
|
||||
if (!input.lastUsedKeyId) {
|
||||
return {
|
||||
orderedActiveKeyIds: active.map((key) => key.id),
|
||||
};
|
||||
}
|
||||
|
||||
const lastIndex = active.findIndex((key) => key.id === input.lastUsedKeyId);
|
||||
|
||||
if (lastIndex < 0) {
|
||||
return {
|
||||
orderedActiveKeyIds: active.map((key) => key.id),
|
||||
};
|
||||
}
|
||||
|
||||
const nextStart = (lastIndex + 1) % active.length;
|
||||
const ordered = active.slice(nextStart).concat(active.slice(0, nextStart));
|
||||
|
||||
return {
|
||||
orderedActiveKeyIds: ordered.map((key) => key.id),
|
||||
};
|
||||
}
|
||||
|
||||
export interface AttemptPlanInput {
|
||||
keys: ReadonlyArray<ProviderKeySnapshot>;
|
||||
lastUsedKeyId?: string;
|
||||
}
|
||||
|
||||
export interface AttemptPlanOutput {
|
||||
keyIdsInAttemptOrder: string[];
|
||||
}
|
||||
|
||||
export function buildAttemptPlan(input: AttemptPlanInput): AttemptPlanOutput {
|
||||
const selection = selectActiveKeysRoundRobin(input);
|
||||
|
||||
return {
|
||||
keyIdsInAttemptOrder: selection.orderedActiveKeyIds,
|
||||
};
|
||||
}
|
||||
43
packages/domain/src/quota.ts
Normal file
43
packages/domain/src/quota.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
export type QuotaBucket = 100 | 80 | 60 | 40 | 20 | 0;
|
||||
|
||||
export interface QuotaUsageInput {
|
||||
used: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export function getApproximateQuotaBucket(input: QuotaUsageInput): QuotaBucket {
|
||||
const { used, limit } = input;
|
||||
|
||||
if (limit <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const safeUsed = clamp(used, 0, limit);
|
||||
const remainingRatio = ((limit - safeUsed) / limit) * 100;
|
||||
|
||||
if (remainingRatio >= 81) {
|
||||
return 100;
|
||||
}
|
||||
|
||||
if (remainingRatio >= 61) {
|
||||
return 80;
|
||||
}
|
||||
|
||||
if (remainingRatio >= 41) {
|
||||
return 60;
|
||||
}
|
||||
|
||||
if (remainingRatio >= 21) {
|
||||
return 40;
|
||||
}
|
||||
|
||||
if (remainingRatio > 0) {
|
||||
return 20;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
13
packages/domain/src/telegram-pairing.ts
Normal file
13
packages/domain/src/telegram-pairing.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
export function normalizePairingCode(code: string): string {
|
||||
return code.trim().toUpperCase();
|
||||
}
|
||||
|
||||
export function hashPairingCode(code: string): string {
|
||||
return createHash("sha256").update(normalizePairingCode(code)).digest("hex");
|
||||
}
|
||||
|
||||
export function isPairingExpired(expiresAt: Date, now: Date = new Date()): boolean {
|
||||
return expiresAt.getTime() <= now.getTime();
|
||||
}
|
||||
11
packages/domain/tsconfig.json
Normal file
11
packages/domain/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
16
packages/providers/AGENTS.md
Normal file
16
packages/providers/AGENTS.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Scope
|
||||
Applies within `packages/providers`.
|
||||
|
||||
## Responsibilities
|
||||
- image provider adapters
|
||||
- payment processor adapter
|
||||
- storage adapter
|
||||
- email adapter
|
||||
- Telegram transport adapter
|
||||
|
||||
## Rules
|
||||
- Provider adapters classify errors but do not decide subscription or quota policy.
|
||||
- Balance-fetch APIs for provider keys belong here.
|
||||
- Distinguish proxy transport failures from provider API failures.
|
||||
10
packages/providers/README.md
Normal file
10
packages/providers/README.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# packages/providers
|
||||
|
||||
Planned external adapter package.
|
||||
|
||||
Expected ownership:
|
||||
- `nano_banana` API adapter
|
||||
- crypto payment processor adapter
|
||||
- storage adapter
|
||||
- email provider adapter
|
||||
- Telegram transport adapter
|
||||
24
packages/providers/package.json
Normal file
24
packages/providers/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@nproxy/providers",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"check": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nproxy/domain": "workspace:*"
|
||||
}
|
||||
}
|
||||
0
packages/providers/src/.gitkeep
Normal file
0
packages/providers/src/.gitkeep
Normal file
48
packages/providers/src/email.ts
Normal file
48
packages/providers/src/email.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
export interface SendEmailInput {
|
||||
to: string;
|
||||
subject: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface EmailTransport {
|
||||
send(input: SendEmailInput): Promise<void>;
|
||||
}
|
||||
|
||||
export function createEmailTransport(config: {
|
||||
provider: string;
|
||||
from: string;
|
||||
apiKey: string;
|
||||
}): EmailTransport {
|
||||
if (config.provider === "example") {
|
||||
return {
|
||||
async send(input) {
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
service: "email",
|
||||
provider: config.provider,
|
||||
from: config.from,
|
||||
to: input.to,
|
||||
subject: input.subject,
|
||||
text: input.text,
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
async send(input) {
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
service: "email",
|
||||
provider: config.provider,
|
||||
mode: "noop_fallback",
|
||||
from: config.from,
|
||||
to: input.to,
|
||||
subject: input.subject,
|
||||
text: input.text,
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
4
packages/providers/src/index.ts
Normal file
4
packages/providers/src/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./email.js";
|
||||
export * from "./nano-banana.js";
|
||||
export * from "./payments.js";
|
||||
export * from "./telegram.js";
|
||||
147
packages/providers/src/nano-banana.ts
Normal file
147
packages/providers/src/nano-banana.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import type {
|
||||
GenerationRequestRecord,
|
||||
ProviderFailureKind,
|
||||
} from "@nproxy/domain";
|
||||
|
||||
export interface ProviderExecutionKey {
|
||||
id: string;
|
||||
providerCode: string;
|
||||
label: string;
|
||||
apiKeyLastFour: string;
|
||||
}
|
||||
|
||||
export interface ProviderExecutionRoute {
|
||||
kind: "proxy" | "direct";
|
||||
proxyBaseUrl?: string;
|
||||
}
|
||||
|
||||
export interface GeneratedAssetPayload {
|
||||
objectKey: string;
|
||||
mimeType: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
bytes?: number;
|
||||
}
|
||||
|
||||
export interface SuccessfulGenerationExecution {
|
||||
ok: true;
|
||||
assets: GeneratedAssetPayload[];
|
||||
}
|
||||
|
||||
export interface FailedGenerationExecution {
|
||||
ok: false;
|
||||
failureKind: ProviderFailureKind;
|
||||
providerHttpStatus?: number;
|
||||
providerErrorCode?: string;
|
||||
providerErrorText?: string;
|
||||
}
|
||||
|
||||
export type ProviderExecutionResult =
|
||||
| SuccessfulGenerationExecution
|
||||
| FailedGenerationExecution;
|
||||
|
||||
export interface NanoBananaAdapter {
|
||||
executeGeneration(input: {
|
||||
request: GenerationRequestRecord;
|
||||
providerKey: ProviderExecutionKey;
|
||||
route: ProviderExecutionRoute;
|
||||
}): Promise<ProviderExecutionResult>;
|
||||
}
|
||||
|
||||
export function createNanoBananaSimulatedAdapter(): NanoBananaAdapter {
|
||||
return {
|
||||
async executeGeneration({ request, providerKey, route }) {
|
||||
const lowerPrompt = request.prompt.toLowerCase();
|
||||
const simulatedFailure = matchSimulatedFailure(lowerPrompt, route.kind);
|
||||
|
||||
if (simulatedFailure) {
|
||||
return simulatedFailure;
|
||||
}
|
||||
|
||||
const assetCount = request.batchSize;
|
||||
const safeKeySuffix = providerKey.apiKeyLastFour.replace(/[^a-z0-9]/gi, "").toLowerCase();
|
||||
const assets = Array.from({ length: assetCount }, (_, index) => ({
|
||||
objectKey: [
|
||||
"generated",
|
||||
request.userId,
|
||||
request.id,
|
||||
`${index + 1}-${safeKeySuffix || "key"}.png`,
|
||||
].join("/"),
|
||||
mimeType: "image/png",
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
bytes: 512_000,
|
||||
}));
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
assets,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function matchSimulatedFailure(
|
||||
prompt: string,
|
||||
routeKind: ProviderExecutionRoute["kind"],
|
||||
): FailedGenerationExecution | null {
|
||||
if (routeKind === "proxy" && prompt.includes("[fail:proxy_transport]")) {
|
||||
return buildFailure(
|
||||
"transport",
|
||||
502,
|
||||
"proxy_transport_error",
|
||||
"Simulated proxy transport failure.",
|
||||
);
|
||||
}
|
||||
|
||||
if (prompt.includes("[fail:transport]")) {
|
||||
return buildFailure("transport", 502, "transport_error", "Simulated transport failure.");
|
||||
}
|
||||
|
||||
if (prompt.includes("[fail:timeout]")) {
|
||||
return buildFailure("timeout", 504, "timeout", "Simulated upstream timeout.");
|
||||
}
|
||||
|
||||
if (prompt.includes("[fail:provider_5xx]")) {
|
||||
return buildFailure("provider_5xx", 503, "provider_5xx", "Simulated provider 5xx.");
|
||||
}
|
||||
|
||||
if (prompt.includes("[fail:provider_4xx_user]")) {
|
||||
return buildFailure(
|
||||
"provider_4xx_user",
|
||||
400,
|
||||
"invalid_request",
|
||||
"Simulated provider validation failure.",
|
||||
);
|
||||
}
|
||||
|
||||
if (prompt.includes("[fail:insufficient_funds]")) {
|
||||
return buildFailure(
|
||||
"insufficient_funds",
|
||||
402,
|
||||
"insufficient_funds",
|
||||
"Simulated provider balance exhaustion.",
|
||||
);
|
||||
}
|
||||
|
||||
if (prompt.includes("[fail:unknown]")) {
|
||||
return buildFailure("unknown", 500, "unknown_error", "Simulated unknown provider failure.");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildFailure(
|
||||
failureKind: ProviderFailureKind,
|
||||
providerHttpStatus: number,
|
||||
providerErrorCode: string,
|
||||
providerErrorText: string,
|
||||
): FailedGenerationExecution {
|
||||
return {
|
||||
ok: false,
|
||||
failureKind,
|
||||
providerHttpStatus,
|
||||
providerErrorCode,
|
||||
providerErrorText,
|
||||
};
|
||||
}
|
||||
55
packages/providers/src/payments.ts
Normal file
55
packages/providers/src/payments.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
export interface PaymentInvoiceDraft {
|
||||
userId: string;
|
||||
planCode: string;
|
||||
amountUsd: number;
|
||||
amountCrypto: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export interface CreatedProviderInvoice {
|
||||
providerInvoiceId: string;
|
||||
paymentAddress: string;
|
||||
amountCrypto: number;
|
||||
amountUsd: number;
|
||||
currency: string;
|
||||
expiresAt: Date;
|
||||
}
|
||||
|
||||
export interface PaymentProviderAdapter {
|
||||
createInvoice(input: PaymentInvoiceDraft): Promise<CreatedProviderInvoice>;
|
||||
}
|
||||
|
||||
export function createPaymentProviderAdapter(config: {
|
||||
provider: string;
|
||||
apiKey: string;
|
||||
}): PaymentProviderAdapter {
|
||||
if (config.provider === "example_processor") {
|
||||
return {
|
||||
async createInvoice(input) {
|
||||
return {
|
||||
providerInvoiceId: `inv_${randomUUID()}`,
|
||||
paymentAddress: `example_${input.currency.toLowerCase()}_${randomUUID().slice(0, 16)}`,
|
||||
amountCrypto: input.amountCrypto,
|
||||
amountUsd: input.amountUsd,
|
||||
currency: input.currency,
|
||||
expiresAt: new Date(Date.now() + 30 * 60 * 1000),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
async createInvoice(input) {
|
||||
return {
|
||||
providerInvoiceId: `noop_${randomUUID()}`,
|
||||
paymentAddress: `noop_${input.currency.toLowerCase()}_${randomUUID().slice(0, 16)}`,
|
||||
amountCrypto: input.amountCrypto,
|
||||
amountUsd: input.amountUsd,
|
||||
currency: input.currency,
|
||||
expiresAt: new Date(Date.now() + 30 * 60 * 1000),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
88
packages/providers/src/telegram.ts
Normal file
88
packages/providers/src/telegram.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
export interface TelegramUpdateUser {
|
||||
id: number;
|
||||
username?: string;
|
||||
first_name: string;
|
||||
last_name?: string;
|
||||
}
|
||||
|
||||
export interface TelegramUpdateMessage {
|
||||
message_id: number;
|
||||
text?: string;
|
||||
from?: TelegramUpdateUser;
|
||||
chat: {
|
||||
id: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TelegramUpdate {
|
||||
update_id: number;
|
||||
message?: TelegramUpdateMessage;
|
||||
}
|
||||
|
||||
export interface TelegramBotTransport {
|
||||
getUpdates(input: {
|
||||
offset?: number;
|
||||
timeoutSeconds: number;
|
||||
}): Promise<TelegramUpdate[]>;
|
||||
sendMessage(input: {
|
||||
chatId: number;
|
||||
text: string;
|
||||
}): Promise<void>;
|
||||
}
|
||||
|
||||
export function createTelegramBotApiTransport(botToken: string): TelegramBotTransport {
|
||||
const baseUrl = `https://api.telegram.org/bot${botToken}`;
|
||||
|
||||
return {
|
||||
async getUpdates({ offset, timeoutSeconds }) {
|
||||
const response = await fetch(`${baseUrl}/getUpdates`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
timeout: timeoutSeconds,
|
||||
...(offset !== undefined ? { offset } : {}),
|
||||
}),
|
||||
});
|
||||
|
||||
const payload = (await response.json()) as {
|
||||
ok: boolean;
|
||||
result?: TelegramUpdate[];
|
||||
description?: string;
|
||||
};
|
||||
|
||||
if (!response.ok || !payload.ok || !payload.result) {
|
||||
throw new Error(
|
||||
payload.description ?? `Telegram getUpdates failed with status ${response.status}.`,
|
||||
);
|
||||
}
|
||||
|
||||
return payload.result;
|
||||
},
|
||||
|
||||
async sendMessage({ chatId, text }) {
|
||||
const response = await fetch(`${baseUrl}/sendMessage`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
chat_id: chatId,
|
||||
text,
|
||||
}),
|
||||
});
|
||||
|
||||
const payload = (await response.json()) as {
|
||||
ok: boolean;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
if (!response.ok || !payload.ok) {
|
||||
throw new Error(
|
||||
payload.description ?? `Telegram sendMessage failed with status ${response.status}.`,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
11
packages/providers/tsconfig.json
Normal file
11
packages/providers/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user