diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml new file mode 100644 index 000000000..dc8075ed1 --- /dev/null +++ b/.github/workflows/claude-review.yml @@ -0,0 +1,28 @@ +name: Claude Code Review + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + +jobs: + claude-review: + runs-on: ubuntu-latest + if: | + (github.event_name == 'issue_comment' && github.event.issue.pull_request && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) + permissions: + contents: read + pull-requests: write + issues: write + id-token: write + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + fetch-depth: 1 + - uses: anthropics/claude-code-action@df37d2f0760a4b5683a6e617c9325bc1a36443f6 # v1.0.75 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + claude_args: | + --allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*)" diff --git a/CLAUDE.md b/CLAUDE.md index 6cf643f36..515fdd4a5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,8 +27,11 @@ Yarn 4.2.2 monorepo with workspaces in `apps/`, `packages/`, and `submodules/`. # Install dependencies yarn install -# Initialize git submodules (required first time) +# Initialize git submodules (required first time, and in each new worktree) make init-submodules +# ⚠️ Worktrees do NOT inherit initialized submodules from the main repo. +# If `yarn install` fails with "Workspace not found (@auto-files/rpc-apis)", +# run `git submodule update --init --recursive` or `make init-submodules`. # Build everything (submodules + models + ui + s3 + frontend + backend) make all diff --git a/apps/backend/.env.sample b/apps/backend/.env.sample index bd2a566c0..6155579a3 100644 --- a/apps/backend/.env.sample +++ b/apps/backend/.env.sample @@ -4,5 +4,60 @@ FILES_GATEWAY_URL=http://changeme.com # change to your own (or it'll fail when f FILES_GATEWAY_TOKEN=changeme # change to your own (or it'll fail when fetching archived files) AUTH_SERVICE_API_KEY=1234567890 # change to your own if updated in auth RABBITMQ_URL=amqp://guest:guest@localhost:5672 -EVM_CHAIN_ENDPOINT=http://localhost:8545 # update it you want to simulate/test buy credits feature -EVM_CHAIN_CONTRACT_ADDRESS=0x0000000000000000000000000000000000000000 # update it you want to simulate/test buy credits feature \ No newline at end of file + +# --------------------------------------------------------------------------- +# Pay-with-AI3 / purchased credits feature +# --------------------------------------------------------------------------- + +# Feature flags — set BUY_CREDITS_ACTIVE=true to enable the purchase flow. +# BUY_CREDITS_STAFF_ONLY=true restricts it to admin/staff accounts only, +# useful for a staged rollout before opening to all users. +BUY_CREDITS_ACTIVE=false +BUY_CREDITS_STAFF_ONLY=false + +# EVM endpoint for the AutoDriveCreditsReceiver contract. +# Point this at the Auto-EVM RPC for the target network (mainnet or Taurus +# testnet). The payment manager watches this chain for deposit events. +EVM_CHAIN_ENDPOINT=http://localhost:8545 + +# Address of the deployed AutoDriveCreditsReceiver contract. +# Must match the chain pointed to by EVM_CHAIN_ENDPOINT. +EVM_CHAIN_CONTRACT_ADDRESS=0x0000000000000000000000000000000000000000 + +# Number of block confirmations to wait before treating a payment as final. +# Higher values reduce the risk of processing a payment that is later +# reversed by a chain reorganisation. Default: 6. +EVM_CHAIN_CONFIRMATIONS=6 + +# How often (in milliseconds) the payment manager polls for CONFIRMED intents +# that have not yet had credits applied. This is a fallback for cases where +# the event watcher misses a log. Default: 30000 (30 seconds). +EVM_CHAIN_CHECK_INTERVAL=30000 + +# Price multiplier applied on top of the raw Autonomys consensus fee to +# determine the AI3 cost per byte. A value of 5.0 means users pay 5× the +# current on-chain transaction byte fee. Default: 5.00. +CREDITS_PRICE_MULTIPLIER=5.00 + +# --------------------------------------------------------------------------- +# Credit lifecycle +# --------------------------------------------------------------------------- + +# Number of days after purchase before a credit batch expires. +# Users see this value on the purchase confirmation screen and in their credit +# history. Default: 90. +CREDIT_EXPIRY_DAYS=90 + +# Maximum total purchased upload bytes allowed per user across all active +# (non-expired) credit rows. Attempts to purchase beyond this cap result in +# an OVER_CAP intent requiring admin review. Default: 107374182400 (100 GiB). +MAX_CREDITS_PER_USER=107374182400 + +# How often (in milliseconds) the background job runs to mark expired credit +# rows and clean up stale PENDING intents. Default: 3600000 (1 hour). +CREDIT_EXPIRY_CHECK_INTERVAL=3600000 + +# How many minutes a PENDING intent remains valid before it is treated as +# expired. Users must submit their on-chain transaction within this window +# after creating an intent. Default: 10. +INTENT_EXPIRY_MINUTES=10 diff --git a/apps/backend/__tests__/unit/useCases/accounts.spec.ts b/apps/backend/__tests__/unit/useCases/accounts.spec.ts index bdb31f813..f514c797a 100644 --- a/apps/backend/__tests__/unit/useCases/accounts.spec.ts +++ b/apps/backend/__tests__/unit/useCases/accounts.spec.ts @@ -273,7 +273,8 @@ describe('AccountsUseCases', () => { accountId: 'acc123', intentId: 'intent-xyz', uploadBytesOriginal: BigInt(500), - downloadBytesOriginal: BigInt(500), + // Download credits are not allocated on purchase + downloadBytesOriginal: 0n, }), expect.anything(), ) diff --git a/apps/backend/__tests__/unit/useCases/banners.spec.ts b/apps/backend/__tests__/unit/useCases/banners.spec.ts new file mode 100644 index 000000000..f03851791 --- /dev/null +++ b/apps/backend/__tests__/unit/useCases/banners.spec.ts @@ -0,0 +1,285 @@ +import { + jest, + describe, + it, + expect, + beforeEach, + afterEach, +} from '@jest/globals' +import { BannersUseCases } from '../../../src/core/banners.js' +import { bannersRepository } from '../../../src/infrastructure/repositories/banners.js' +import { + Banner, + BannerCriticality, + BannerInteractionType, + UserRole, + UserWithOrganization, +} from '@auto-drive/models' +import { + ForbiddenError, + NotFoundError, + BadRequestError, +} from '../../../src/errors/index.js' +import { v4 as uuidv4 } from 'uuid' + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const makeUser = (role: UserRole): UserWithOrganization => ({ + oauthProvider: 'google', + oauthUserId: uuidv4(), + role, + onboarded: true, + organizationId: uuidv4(), + publicId: uuidv4(), +}) + +const makeBanner = (overrides: Partial = {}): Banner => ({ + id: uuidv4(), + title: 'Test banner', + body: 'Body text', + criticality: BannerCriticality.Info, + dismissable: true, + requiresAcknowledgement: false, + displayStart: new Date(), + displayEnd: null, + active: true, + createdBy: uuidv4(), + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, +}) + +const CREATE_PARAMS = { + title: 'Test', + body: 'Body', + criticality: BannerCriticality.Info, + dismissable: false, + requiresAcknowledgement: false, + displayStart: new Date(), + displayEnd: null, + active: true, +} + +// --------------------------------------------------------------------------- +// Admin-only operations — non-admin user should receive ForbiddenError +// --------------------------------------------------------------------------- + +describe('BannersUseCases — admin-only endpoints return 403 for non-admin users', () => { + const regularUser = makeUser(UserRole.User) + + beforeEach(() => { + jest.clearAllMocks() + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('getAllBanners: returns ForbiddenError for non-admin', async () => { + const result = await BannersUseCases.getAllBanners(regularUser) + expect(result.isErr()).toBe(true) + expect(result._unsafeUnwrapErr()).toBeInstanceOf(ForbiddenError) + }) + + it('createBanner: returns ForbiddenError for non-admin', async () => { + const result = await BannersUseCases.createBanner(regularUser, CREATE_PARAMS) + expect(result.isErr()).toBe(true) + expect(result._unsafeUnwrapErr()).toBeInstanceOf(ForbiddenError) + }) + + it('updateBanner: returns ForbiddenError for non-admin', async () => { + const result = await BannersUseCases.updateBanner(regularUser, uuidv4(), { + title: 'New title', + }) + expect(result.isErr()).toBe(true) + expect(result._unsafeUnwrapErr()).toBeInstanceOf(ForbiddenError) + }) + + it('toggleBannerActive: returns ForbiddenError for non-admin', async () => { + const result = await BannersUseCases.toggleBannerActive( + regularUser, + uuidv4(), + false, + ) + expect(result.isErr()).toBe(true) + expect(result._unsafeUnwrapErr()).toBeInstanceOf(ForbiddenError) + }) + + it('getBannerWithStats: returns ForbiddenError for non-admin', async () => { + const result = await BannersUseCases.getBannerWithStats(regularUser, uuidv4()) + expect(result.isErr()).toBe(true) + expect(result._unsafeUnwrapErr()).toBeInstanceOf(ForbiddenError) + }) + + it('role check fires before any repository call', async () => { + const spy = jest.spyOn(bannersRepository, 'getAllBanners') + await BannersUseCases.getAllBanners(regularUser) + expect(spy).not.toHaveBeenCalled() + }) +}) + +// --------------------------------------------------------------------------- +// Admin-only operations — admin user should succeed (repo mocked) +// --------------------------------------------------------------------------- + +describe('BannersUseCases — admin user can perform admin operations', () => { + const adminUser = makeUser(UserRole.Admin) + + beforeEach(() => { + jest.clearAllMocks() + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('getAllBanners: returns banners for admin', async () => { + const banners = [makeBanner(), makeBanner()] + jest + .spyOn(bannersRepository, 'getAllBanners') + .mockResolvedValue(banners) + + const result = await BannersUseCases.getAllBanners(adminUser) + expect(result.isOk()).toBe(true) + expect(result._unsafeUnwrap()).toEqual(banners) + }) + + it('createBanner: creates and returns a banner for admin', async () => { + const banner = makeBanner() + jest + .spyOn(bannersRepository, 'createBanner') + .mockResolvedValue(banner) + + const result = await BannersUseCases.createBanner(adminUser, CREATE_PARAMS) + expect(result.isOk()).toBe(true) + expect(result._unsafeUnwrap()).toEqual(banner) + }) + + it('updateBanner: updates and returns the banner for admin', async () => { + const banner = makeBanner({ title: 'Updated' }) + jest + .spyOn(bannersRepository, 'updateBanner') + .mockResolvedValue(banner) + + const result = await BannersUseCases.updateBanner(adminUser, banner.id, { + title: 'Updated', + }) + expect(result.isOk()).toBe(true) + expect(result._unsafeUnwrap().title).toBe('Updated') + }) + + it('getBannerWithStats: returns stats for admin', async () => { + const stats = { ...makeBanner(), acknowledgementCount: 3, dismissalCount: 1 } + jest + .spyOn(bannersRepository, 'getBannerWithStats') + .mockResolvedValue(stats) + + const result = await BannersUseCases.getBannerWithStats(adminUser, stats.id) + expect(result.isOk()).toBe(true) + expect(result._unsafeUnwrap().acknowledgementCount).toBe(3) + }) + + it('getBannerWithStats: returns NotFoundError when banner does not exist', async () => { + jest + .spyOn(bannersRepository, 'getBannerWithStats') + .mockResolvedValue(null) + + const result = await BannersUseCases.getBannerWithStats(adminUser, uuidv4()) + expect(result.isErr()).toBe(true) + expect(result._unsafeUnwrapErr()).toBeInstanceOf(NotFoundError) + }) +}) + +// --------------------------------------------------------------------------- +// recordInteraction — user-facing, no admin requirement +// --------------------------------------------------------------------------- + +describe('BannersUseCases.recordInteraction', () => { + const user = makeUser(UserRole.User) + + beforeEach(() => { + jest.clearAllMocks() + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('returns NotFoundError when banner does not exist', async () => { + jest.spyOn(bannersRepository, 'getBannerById').mockResolvedValue(null) + + const result = await BannersUseCases.recordInteraction( + user, + uuidv4(), + BannerInteractionType.Dismissed, + ) + expect(result.isErr()).toBe(true) + expect(result._unsafeUnwrapErr()).toBeInstanceOf(NotFoundError) + }) + + it('returns BadRequestError when dismissing a non-dismissable banner', async () => { + const banner = makeBanner({ dismissable: false }) + jest + .spyOn(bannersRepository, 'getBannerById') + .mockResolvedValue(banner) + + const result = await BannersUseCases.recordInteraction( + user, + banner.id, + BannerInteractionType.Dismissed, + ) + expect(result.isErr()).toBe(true) + expect(result._unsafeUnwrapErr()).toBeInstanceOf(BadRequestError) + }) + + it('returns BadRequestError when acknowledging a non-acknowledgeable banner', async () => { + const banner = makeBanner({ requiresAcknowledgement: false }) + jest + .spyOn(bannersRepository, 'getBannerById') + .mockResolvedValue(banner) + + const result = await BannersUseCases.recordInteraction( + user, + banner.id, + BannerInteractionType.Acknowledged, + ) + expect(result.isErr()).toBe(true) + expect(result._unsafeUnwrapErr()).toBeInstanceOf(BadRequestError) + }) + + it('succeeds for a dismissable banner', async () => { + const banner = makeBanner({ dismissable: true }) + jest + .spyOn(bannersRepository, 'getBannerById') + .mockResolvedValue(banner) + jest + .spyOn(bannersRepository, 'createInteraction') + .mockResolvedValue(null) + + const result = await BannersUseCases.recordInteraction( + user, + banner.id, + BannerInteractionType.Dismissed, + ) + expect(result.isOk()).toBe(true) + }) + + it('succeeds for a banner requiring acknowledgement', async () => { + const banner = makeBanner({ requiresAcknowledgement: true }) + jest + .spyOn(bannersRepository, 'getBannerById') + .mockResolvedValue(banner) + jest + .spyOn(bannersRepository, 'createInteraction') + .mockResolvedValue(null) + + const result = await BannersUseCases.recordInteraction( + user, + banner.id, + BannerInteractionType.Acknowledged, + ) + expect(result.isOk()).toBe(true) + }) +}) diff --git a/apps/backend/__tests__/unit/useCases/credits.spec.ts b/apps/backend/__tests__/unit/useCases/credits.spec.ts new file mode 100644 index 000000000..3a57ca84b --- /dev/null +++ b/apps/backend/__tests__/unit/useCases/credits.spec.ts @@ -0,0 +1,363 @@ +import { jest } from '@jest/globals' +import { CreditsUseCases } from '../../../src/core/users/credits.js' +import { purchasedCreditsRepository } from '../../../src/infrastructure/repositories/users/purchasedCredits.js' +import { AccountsUseCases } from '../../../src/core/users/accounts.js' +import { ForbiddenError } from '../../../src/errors/index.js' +import { + type Account, + type PurchasedCredit, + type User, + type UserWithOrganization, + UserRole, +} from '@auto-drive/models' +import { config } from '../../../src/config.js' + +// ───────────────────────────────────────────────────────────────────────────── +// Test fixtures +// ───────────────────────────────────────────────────────────────────────────── + +const now = new Date() + +const baseUser: UserWithOrganization = { + id: 'user-id', + publicId: 'pub-1', + walletAddress: '0xabc', + createdAt: now, + updatedAt: now, + authProvider: 'google', + oauthProvider: 'google', + oauthUsername: 'test@gmail.com', + organizationId: 'org-1', + role: UserRole.User, +} as unknown as UserWithOrganization + +const adminUser: User = { + ...baseUser, + role: UserRole.Admin, +} as unknown as User + +const nonAdminUser: User = { + ...baseUser, + role: UserRole.User, +} as unknown as User + +const mockAccount: Account = { + id: 'account-id', + organizationId: 'org-1', + model: 'monthly', + uploadLimit: 100, + downloadLimit: 100, +} as unknown as Account + +const FUTURE_EXPIRY = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) +const SOON_EXPIRY = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) // 7 days + +const makeCreditRow = ( + overrides: Partial = {}, +): PurchasedCredit => ({ + id: 'credit-1', + accountId: 'account-id', + intentId: 'intent-1', + uploadBytesOriginal: BigInt(1024 * 1024 * 1024), // 1 GiB + uploadBytesRemaining: BigInt(1024 * 1024 * 1024), + downloadBytesOriginal: BigInt(1024 * 1024 * 1024), + downloadBytesRemaining: BigInt(1024 * 1024 * 1024), + purchasedAt: now, + expiresAt: FUTURE_EXPIRY, + expired: false, + createdAt: now, + updatedAt: now, + ...overrides, +}) + +// ───────────────────────────────────────────────────────────────────────────── +// Test suite +// ───────────────────────────────────────────────────────────────────────────── + +describe('CreditsUseCases', () => { + beforeEach(() => { + jest.clearAllMocks() + jest + .spyOn(AccountsUseCases, 'getOrCreateAccount') + .mockResolvedValue(mockAccount) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + // ────────────────────────────────────────────────────────────────────────── + // getSummary + // ────────────────────────────────────────────────────────────────────────── + + describe('getSummary', () => { + it('returns zeros and canPurchase=true when no credits exist', async () => { + jest + .spyOn(purchasedCreditsRepository, 'getRemainingCredits') + .mockResolvedValue({ + uploadBytesRemaining: 0n, + downloadBytesRemaining: 0n, + nextExpiryDate: null, + activeRowCount: 0, + }) + + const summary = await CreditsUseCases.getSummary(baseUser) + + expect(summary.uploadBytesRemaining).toBe(0n) + expect(summary.downloadBytesRemaining).toBe(0n) + expect(summary.nextExpiryDate).toBeNull() + expect(summary.batchCount).toBe(0) + expect(summary.canPurchase).toBe(true) + expect(summary.maxPurchasableBytes).toBe(config.credits.maxBytesPerUser) + }) + + it('returns googleVerified=true for Google-authed user', async () => { + jest + .spyOn(purchasedCreditsRepository, 'getRemainingCredits') + .mockResolvedValue({ + uploadBytesRemaining: 0n, + downloadBytesRemaining: 0n, + nextExpiryDate: null, + activeRowCount: 0, + }) + + const summary = await CreditsUseCases.getSummary(baseUser) + expect(summary.googleVerified).toBe(true) + }) + + it('returns googleVerified=false for non-Google user', async () => { + const githubUser: UserWithOrganization = { + ...baseUser, + oauthProvider: 'github', + } as unknown as UserWithOrganization + + jest + .spyOn(purchasedCreditsRepository, 'getRemainingCredits') + .mockResolvedValue({ + uploadBytesRemaining: 0n, + downloadBytesRemaining: 0n, + nextExpiryDate: null, + activeRowCount: 0, + }) + + const summary = await CreditsUseCases.getSummary(githubUser) + expect(summary.googleVerified).toBe(false) + }) + + it('computes maxPurchasableBytes using the larger of upload/download remaining', async () => { + // Upload has 80 GiB remaining, download has 50 GiB + // Binding constraint is upload (80 GiB), so room = cap - 80 GiB + const uploadRemaining = BigInt(80 * 1024 ** 3) + const downloadRemaining = BigInt(50 * 1024 ** 3) + const cap = config.credits.maxBytesPerUser // 100 GiB + + jest + .spyOn(purchasedCreditsRepository, 'getRemainingCredits') + .mockResolvedValue({ + uploadBytesRemaining: uploadRemaining, + downloadBytesRemaining: downloadRemaining, + nextExpiryDate: FUTURE_EXPIRY, + activeRowCount: 2, + }) + + const summary = await CreditsUseCases.getSummary(baseUser) + + expect(summary.maxPurchasableBytes).toBe(cap - uploadRemaining) + expect(summary.canPurchase).toBe(true) + }) + + it('returns canPurchase=false and maxPurchasableBytes=0n when at or over cap', async () => { + const cap = config.credits.maxBytesPerUser + + jest + .spyOn(purchasedCreditsRepository, 'getRemainingCredits') + .mockResolvedValue({ + uploadBytesRemaining: cap, + downloadBytesRemaining: cap, + nextExpiryDate: FUTURE_EXPIRY, + activeRowCount: 1, + }) + + const summary = await CreditsUseCases.getSummary(baseUser) + + expect(summary.canPurchase).toBe(false) + expect(summary.maxPurchasableBytes).toBe(0n) + }) + + it('uses upload bytes only for cap even when download remaining is higher', async () => { + const uploadRemaining = BigInt(30 * 1024 ** 3) + const downloadRemaining = BigInt(70 * 1024 ** 3) + const cap = config.credits.maxBytesPerUser + + jest + .spyOn(purchasedCreditsRepository, 'getRemainingCredits') + .mockResolvedValue({ + uploadBytesRemaining: uploadRemaining, + downloadBytesRemaining: downloadRemaining, + nextExpiryDate: FUTURE_EXPIRY, + activeRowCount: 3, + }) + + const summary = await CreditsUseCases.getSummary(baseUser) + + // Cap is upload-only — download bytes are not allocated on purchase + // and do not factor into maxPurchasableBytes. + expect(summary.maxPurchasableBytes).toBe(cap - uploadRemaining) + expect(summary.canPurchase).toBe(true) + }) + + it('populates batchCount and nextExpiryDate from repository', async () => { + jest + .spyOn(purchasedCreditsRepository, 'getRemainingCredits') + .mockResolvedValue({ + uploadBytesRemaining: BigInt(1024), + downloadBytesRemaining: BigInt(1024), + nextExpiryDate: SOON_EXPIRY, + activeRowCount: 5, + }) + + const summary = await CreditsUseCases.getSummary(baseUser) + + expect(summary.batchCount).toBe(5) + expect(summary.nextExpiryDate).toEqual(SOON_EXPIRY) + }) + }) + + // ────────────────────────────────────────────────────────────────────────── + // getBatches + // ────────────────────────────────────────────────────────────────────────── + + describe('getBatches', () => { + it('returns the full purchase history from repository', async () => { + const credits = [makeCreditRow(), makeCreditRow({ id: 'credit-2' })] + jest + .spyOn(purchasedCreditsRepository, 'getByAccountId') + .mockResolvedValue(credits) + + const result = await CreditsUseCases.getBatches(baseUser) + + expect(result).toEqual(credits) + expect( + purchasedCreditsRepository.getByAccountId, + ).toHaveBeenCalledWith(mockAccount.id) + }) + + it('returns an empty array when no purchases exist', async () => { + jest + .spyOn(purchasedCreditsRepository, 'getByAccountId') + .mockResolvedValue([]) + + const result = await CreditsUseCases.getBatches(baseUser) + expect(result).toEqual([]) + }) + }) + + // ────────────────────────────────────────────────────────────────────────── + // getExpiringBatches + // ────────────────────────────────────────────────────────────────────────── + + describe('getExpiringBatches', () => { + it('returns rows expiring soon via per-account repository method', async () => { + const expiring = [makeCreditRow({ expiresAt: SOON_EXPIRY })] + jest + .spyOn(purchasedCreditsRepository, 'getExpiringCreditsByAccountId') + .mockResolvedValue(expiring) + + const result = await CreditsUseCases.getExpiringBatches(baseUser) + + expect(result).toEqual(expiring) + expect( + purchasedCreditsRepository.getExpiringCreditsByAccountId, + ).toHaveBeenCalledWith(mockAccount.id, 30) + }) + + it('returns empty array when nothing is expiring', async () => { + jest + .spyOn(purchasedCreditsRepository, 'getExpiringCreditsByAccountId') + .mockResolvedValue([]) + + const result = await CreditsUseCases.getExpiringBatches(baseUser) + expect(result).toEqual([]) + }) + }) + + // ────────────────────────────────────────────────────────────────────────── + // getEconomics + // ────────────────────────────────────────────────────────────────────────── + + describe('getEconomics', () => { + it('returns 403 ForbiddenError for non-admin user', async () => { + const getAggregateSpy = jest + .spyOn(purchasedCreditsRepository, 'getExpiringCreditsAggregate') + .mockResolvedValue({ + count: 0, + totalUploadBytesRemaining: 0n, + totalDownloadBytesRemaining: 0n, + }) + + const result = await CreditsUseCases.getEconomics(nonAdminUser) + + expect(result.isErr()).toBe(true) + expect(result._unsafeUnwrapErr()).toBeInstanceOf(ForbiddenError) + expect(getAggregateSpy).not.toHaveBeenCalled() + }) + + it('returns aggregated economics for admin user', async () => { + jest + .spyOn(purchasedCreditsRepository, 'getExpiringCreditsAggregate') + .mockResolvedValue({ + count: 2, + totalUploadBytesRemaining: + BigInt(2 * 1024 ** 3) + BigInt(1024 ** 3), + totalDownloadBytesRemaining: + BigInt(3 * 1024 ** 3) + BigInt(2 * 1024 ** 3), + }) + + const result = await CreditsUseCases.getEconomics(adminUser) + + expect(result.isOk()).toBe(true) + const economics = result._unsafeUnwrap() + expect(economics.totalExpiringWithin30Days).toBe(2) + expect(economics.totalExpiringUploadBytes).toBe( + BigInt(2 * 1024 ** 3) + BigInt(1024 ** 3), + ) + expect(economics.totalExpiringDownloadBytes).toBe( + BigInt(3 * 1024 ** 3) + BigInt(2 * 1024 ** 3), + ) + }) + + it('returns zeros when no credits are expiring soon', async () => { + jest + .spyOn(purchasedCreditsRepository, 'getExpiringCreditsAggregate') + .mockResolvedValue({ + count: 0, + totalUploadBytesRemaining: 0n, + totalDownloadBytesRemaining: 0n, + }) + + const result = await CreditsUseCases.getEconomics(adminUser) + + expect(result.isOk()).toBe(true) + const economics = result._unsafeUnwrap() + expect(economics.totalExpiringWithin30Days).toBe(0) + expect(economics.totalExpiringUploadBytes).toBe(0n) + expect(economics.totalExpiringDownloadBytes).toBe(0n) + }) + + it('queries within 30 days window', async () => { + jest + .spyOn(purchasedCreditsRepository, 'getExpiringCreditsAggregate') + .mockResolvedValue({ + count: 0, + totalUploadBytesRemaining: 0n, + totalDownloadBytesRemaining: 0n, + }) + + await CreditsUseCases.getEconomics(adminUser) + + expect( + purchasedCreditsRepository.getExpiringCreditsAggregate, + ).toHaveBeenCalledWith(30) + }) + }) +}) diff --git a/apps/backend/__tests__/unit/useCases/intents.spec.ts b/apps/backend/__tests__/unit/useCases/intents.spec.ts index ed02ffd37..ace5d3947 100644 --- a/apps/backend/__tests__/unit/useCases/intents.spec.ts +++ b/apps/backend/__tests__/unit/useCases/intents.spec.ts @@ -3,9 +3,9 @@ import { IntentsUseCases } from '../../../src/core/users/intents.js' import { intentsRepository } from '../../../src/infrastructure/repositories/users/intents.js' import { EventRouter } from '../../../src/infrastructure/eventRouter/index.js' import { AccountsUseCases } from '../../../src/core/users/accounts.js' -import { ForbiddenError, GoneError } from '../../../src/errors/index.js' -import { IntentStatus, type Intent, type User } from '@auto-drive/models' -import { ok } from 'neverthrow' +import { ConflictError, ForbiddenError, GoneError } from '../../../src/errors/index.js' +import { IntentStatus, UserRole, type Intent, type User } from '@auto-drive/models' +import { ok, err } from 'neverthrow' describe('IntentsUseCases', () => { const now = new Date() @@ -440,7 +440,7 @@ describe('IntentsUseCases', () => { expect(res.isErr()).toBe(true) }) - it('onConfirmedIntent should error when addCreditsToAccount fails', async () => { + it('onConfirmedIntent should mark OVER_CAP (not retry) when cap is exceeded', async () => { const intent: Intent = { id: '0x11', userPublicId: user.publicId, @@ -449,16 +449,47 @@ describe('IntentsUseCases', () => { shannonsPerByte: 1n, } jest.spyOn(intentsRepository, 'getById').mockResolvedValue(intent) - const { err: neverthrowErr } = await import('neverthrow') jest .spyOn(AccountsUseCases, 'addCreditsToAccount') .mockResolvedValue( - neverthrowErr(new ForbiddenError('Add credits failed')), + err(new ForbiddenError('Purchase would exceed per-user credit cap')), ) + const updateSpy = jest + .spyOn(intentsRepository, 'updateIntent') + .mockResolvedValue({ ...intent, status: IntentStatus.OVER_CAP }) const res = await IntentsUseCases.onConfirmedIntent(intent.id) - expect(res.isErr()).toBe(true) + // Must succeed (not error) so the polling loop stops retrying + expect(res.isOk()).toBe(true) + // Intent must be marked OVER_CAP, not COMPLETED or left as CONFIRMED + expect(updateSpy).toHaveBeenCalledWith( + expect.objectContaining({ id: intent.id, status: IntentStatus.OVER_CAP }), + ) + }) + + it('onConfirmedIntent should NOT mark COMPLETED when capped — update must use OVER_CAP status', async () => { + const intent: Intent = { + id: '0x11c', + userPublicId: user.publicId, + status: IntentStatus.CONFIRMED, + paymentAmount: 500n, + shannonsPerByte: 1n, + } + jest.spyOn(intentsRepository, 'getById').mockResolvedValue(intent) + jest + .spyOn(AccountsUseCases, 'addCreditsToAccount') + .mockResolvedValue(err(new ForbiddenError('cap'))) + const updateSpy = jest + .spyOn(intentsRepository, 'updateIntent') + .mockResolvedValue({ ...intent, status: IntentStatus.OVER_CAP }) + + await IntentsUseCases.onConfirmedIntent(intent.id) + + // Verify status is specifically OVER_CAP, not COMPLETED + expect(updateSpy).not.toHaveBeenCalledWith( + expect.objectContaining({ status: IntentStatus.COMPLETED }), + ) }) // ──────────────────────────────────────────────────────────────────────────── @@ -561,6 +592,140 @@ describe('IntentsUseCases', () => { // Miscellaneous // ──────────────────────────────────────────────────────────────────────────── + // ──────────────────────────────────────────────────────────────────────────── + // getOverCapIntents + // ──────────────────────────────────────────────────────────────────────────── + + it('getOverCapIntents should return intents for admin users', async () => { + const admin = { ...user, role: UserRole.Admin } as unknown as User + const overCapIntent: Intent = { + id: '0xoc1', + userPublicId: user.publicId, + status: IntentStatus.OVER_CAP, + paymentAmount: 100n, + shannonsPerByte: 1n, + } + jest + .spyOn(intentsRepository, 'getOverCapIntents') + .mockResolvedValue([overCapIntent]) + + const result = await IntentsUseCases.getOverCapIntents(admin) + + expect(result.isOk()).toBe(true) + expect(result._unsafeUnwrap()).toEqual([overCapIntent]) + }) + + it('getOverCapIntents should return ForbiddenError for non-admin users', async () => { + const nonAdmin = { ...user, role: UserRole.User } as unknown as User + const repoSpy = jest.spyOn(intentsRepository, 'getOverCapIntents') + + const result = await IntentsUseCases.getOverCapIntents(nonAdmin) + + expect(result.isErr()).toBe(true) + expect(result._unsafeUnwrapErr()).toBeInstanceOf(ForbiddenError) + // Repository must not be called — admin check happens first + expect(repoSpy).not.toHaveBeenCalled() + }) + + it('getOverCapIntents should return empty array when no capped intents exist', async () => { + const admin = { ...user, role: UserRole.Admin } as unknown as User + jest.spyOn(intentsRepository, 'getOverCapIntents').mockResolvedValue([]) + + const result = await IntentsUseCases.getOverCapIntents(admin) + + expect(result.isOk()).toBe(true) + expect(result._unsafeUnwrap()).toEqual([]) + }) + + // ──────────────────────────────────────────────────────────────────────────── + // reprocessOverCapIntent + // ──────────────────────────────────────────────────────────────────────────── + + it('reprocessOverCapIntent should reset OVER_CAP intent to CONFIRMED', async () => { + const admin = { ...user, role: UserRole.Admin } as unknown as User + const overCapIntent: Intent = { + id: '0xrp1', + userPublicId: user.publicId, + status: IntentStatus.OVER_CAP, + paymentAmount: 100n, + shannonsPerByte: 1n, + } + jest.spyOn(intentsRepository, 'getById').mockResolvedValue(overCapIntent) + const updateSpy = jest + .spyOn(intentsRepository, 'updateIntent') + .mockResolvedValue({ ...overCapIntent, status: IntentStatus.CONFIRMED }) + + const result = await IntentsUseCases.reprocessOverCapIntent(admin, overCapIntent.id) + + expect(result.isOk()).toBe(true) + expect(updateSpy).toHaveBeenCalledWith( + expect.objectContaining({ + id: overCapIntent.id, + status: IntentStatus.CONFIRMED, + }), + ) + }) + + it('reprocessOverCapIntent should return ForbiddenError for non-admin', async () => { + const nonAdmin = { ...user, role: UserRole.User } as unknown as User + const repoSpy = jest.spyOn(intentsRepository, 'getById') + + const result = await IntentsUseCases.reprocessOverCapIntent(nonAdmin, '0xrp2') + + expect(result.isErr()).toBe(true) + expect(result._unsafeUnwrapErr()).toBeInstanceOf(ForbiddenError) + expect(repoSpy).not.toHaveBeenCalled() + }) + + it('reprocessOverCapIntent should return ObjectNotFoundError when intent missing', async () => { + const admin = { ...user, role: UserRole.Admin } as unknown as User + jest.spyOn(intentsRepository, 'getById').mockResolvedValue(null) + + const result = await IntentsUseCases.reprocessOverCapIntent(admin, '0xrp3') + + expect(result.isErr()).toBe(true) + }) + + it('reprocessOverCapIntent should return ConflictError when intent is not OVER_CAP', async () => { + const admin = { ...user, role: UserRole.Admin } as unknown as User + const completedIntent: Intent = { + id: '0xrp4', + userPublicId: user.publicId, + status: IntentStatus.COMPLETED, + paymentAmount: 100n, + shannonsPerByte: 1n, + } + jest.spyOn(intentsRepository, 'getById').mockResolvedValue(completedIntent) + const updateSpy = jest.spyOn(intentsRepository, 'updateIntent') + + const result = await IntentsUseCases.reprocessOverCapIntent(admin, completedIntent.id) + + expect(result.isErr()).toBe(true) + expect(result._unsafeUnwrapErr()).toBeInstanceOf(ConflictError) + // Must not attempt to update an intent that isn't OVER_CAP + expect(updateSpy).not.toHaveBeenCalled() + }) + + it('reprocessOverCapIntent should return ConflictError for PENDING, CONFIRMED, EXPIRED statuses', async () => { + const admin = { ...user, role: UserRole.Admin } as unknown as User + const statuses = [IntentStatus.PENDING, IntentStatus.CONFIRMED, IntentStatus.EXPIRED] + + for (const status of statuses) { + const intent: Intent = { + id: `0xrp-${status}`, + userPublicId: user.publicId, + status, + shannonsPerByte: 1n, + } + jest.spyOn(intentsRepository, 'getById').mockResolvedValue(intent) + + const result = await IntentsUseCases.reprocessOverCapIntent(admin, intent.id) + + expect(result.isErr()).toBe(true) + expect(result._unsafeUnwrapErr()).toBeInstanceOf(ConflictError) + } + }) + it('getConfirmedIntents should proxy repository', async () => { const intents: Intent[] = [ { diff --git a/apps/backend/__tests__/unit/useCases/tou.spec.ts b/apps/backend/__tests__/unit/useCases/tou.spec.ts new file mode 100644 index 000000000..92a2f8987 --- /dev/null +++ b/apps/backend/__tests__/unit/useCases/tou.spec.ts @@ -0,0 +1,591 @@ +import { + jest, + describe, + it, + expect, + beforeEach, + afterEach, +} from '@jest/globals' +import { TouUseCases } from '../../../src/core/tou.js' +import { touRepository } from '../../../src/infrastructure/repositories/tou.js' +import { bannersRepository } from '../../../src/infrastructure/repositories/banners.js' +import { + TouChangeType, + TouVersion, + TouVersionStatus, + UserRole, + UserWithOrganization, +} from '@auto-drive/models' +import { + ForbiddenError, + NotFoundError, + BadRequestError, +} from '../../../src/errors/index.js' +import { v4 as uuidv4 } from 'uuid' + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const makeUser = (role: UserRole): UserWithOrganization => ({ + oauthProvider: 'google', + oauthUserId: uuidv4(), + role, + onboarded: true, + organizationId: uuidv4(), + publicId: uuidv4(), +}) + +const makeVersion = (overrides: Partial = {}): TouVersion => ({ + id: uuidv4(), + versionLabel: 'v1.0', + effectiveDate: new Date('2026-05-01'), + contentUrl: 'https://example.com/tou', + changeType: TouChangeType.Material, + status: TouVersionStatus.Draft, + adminNotes: null, + createdBy: uuidv4(), + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, +}) + +// --------------------------------------------------------------------------- +// Admin-only operations — non-admin user should receive ForbiddenError +// --------------------------------------------------------------------------- + +describe('TouUseCases — admin-only endpoints return 403 for non-admin users', () => { + const regularUser = makeUser(UserRole.User) + + beforeEach(() => { + jest.clearAllMocks() + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('getAllVersions: returns ForbiddenError for non-admin', async () => { + const result = await TouUseCases.getAllVersions(regularUser) + expect(result.isErr()).toBe(true) + expect(result._unsafeUnwrapErr()).toBeInstanceOf(ForbiddenError) + }) + + it('createTouVersion: returns ForbiddenError for non-admin', async () => { + const result = await TouUseCases.createTouVersion(regularUser, { + versionLabel: 'v1.0', + effectiveDate: new Date(), + contentUrl: 'https://example.com/tou', + changeType: TouChangeType.Material, + adminNotes: null, + }) + expect(result.isErr()).toBe(true) + expect(result._unsafeUnwrapErr()).toBeInstanceOf(ForbiddenError) + }) + + it('updateTouVersion: returns ForbiddenError for non-admin', async () => { + const result = await TouUseCases.updateTouVersion( + regularUser, + uuidv4(), + { versionLabel: 'v2.0' }, + ) + expect(result.isErr()).toBe(true) + expect(result._unsafeUnwrapErr()).toBeInstanceOf(ForbiddenError) + }) + + it('promoteToPending: returns ForbiddenError for non-admin', async () => { + const result = await TouUseCases.promoteToPending( + regularUser, + uuidv4(), + ) + expect(result.isErr()).toBe(true) + expect(result._unsafeUnwrapErr()).toBeInstanceOf(ForbiddenError) + }) + + it('activateVersion: returns ForbiddenError for non-admin', async () => { + const result = await TouUseCases.activateVersion(regularUser, uuidv4()) + expect(result.isErr()).toBe(true) + expect(result._unsafeUnwrapErr()).toBeInstanceOf(ForbiddenError) + }) + + it('archiveVersion: returns ForbiddenError for non-admin', async () => { + const result = await TouUseCases.archiveVersion(regularUser, uuidv4()) + expect(result.isErr()).toBe(true) + expect(result._unsafeUnwrapErr()).toBeInstanceOf(ForbiddenError) + }) + + it('getVersionWithStats: returns ForbiddenError for non-admin', async () => { + const result = await TouUseCases.getVersionWithStats( + regularUser, + uuidv4(), + ) + expect(result.isErr()).toBe(true) + expect(result._unsafeUnwrapErr()).toBeInstanceOf(ForbiddenError) + }) + + it('role check fires before any repository call', async () => { + const spy = jest.spyOn(touRepository, 'getAllVersions') + await TouUseCases.getAllVersions(regularUser) + expect(spy).not.toHaveBeenCalled() + }) +}) + +// --------------------------------------------------------------------------- +// Admin operations — admin user should succeed (repo mocked) +// --------------------------------------------------------------------------- + +describe('TouUseCases — admin user can perform admin operations', () => { + const adminUser = makeUser(UserRole.Admin) + + beforeEach(() => { + jest.clearAllMocks() + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('getAllVersions: returns versions for admin', async () => { + const versions = [makeVersion(), makeVersion()] + jest + .spyOn(touRepository, 'getAllVersions') + .mockResolvedValue(versions) + + const result = await TouUseCases.getAllVersions(adminUser) + expect(result.isOk()).toBe(true) + expect(result._unsafeUnwrap()).toEqual(versions) + }) + + it('createTouVersion: creates and returns a version', async () => { + const version = makeVersion() + jest + .spyOn(touRepository, 'createVersion') + .mockResolvedValue(version) + + const result = await TouUseCases.createTouVersion(adminUser, { + versionLabel: 'v1.0', + effectiveDate: new Date(), + contentUrl: 'https://example.com/tou', + changeType: TouChangeType.Material, + adminNotes: null, + }) + expect(result.isOk()).toBe(true) + expect(result._unsafeUnwrap()).toEqual(version) + }) + + it('updateTouVersion: only allows editing draft versions', async () => { + const version = makeVersion({ status: TouVersionStatus.Active }) + jest + .spyOn(touRepository, 'getVersionById') + .mockResolvedValue(version) + + const result = await TouUseCases.updateTouVersion(adminUser, version.id, { + versionLabel: 'v2.0', + }) + expect(result.isErr()).toBe(true) + expect(result._unsafeUnwrapErr()).toBeInstanceOf(BadRequestError) + }) + + it('updateTouVersion: allows editing draft version', async () => { + const version = makeVersion({ status: TouVersionStatus.Draft }) + const updated = makeVersion({ ...version, versionLabel: 'v2.0' }) + jest + .spyOn(touRepository, 'getVersionById') + .mockResolvedValue(version) + jest + .spyOn(touRepository, 'updateVersion') + .mockResolvedValue(updated) + + const result = await TouUseCases.updateTouVersion(adminUser, version.id, { + versionLabel: 'v2.0', + }) + expect(result.isOk()).toBe(true) + expect(result._unsafeUnwrap().versionLabel).toBe('v2.0') + }) + + it('updateTouVersion: returns NotFoundError for missing version', async () => { + jest.spyOn(touRepository, 'getVersionById').mockResolvedValue(null) + + const result = await TouUseCases.updateTouVersion( + adminUser, + uuidv4(), + { versionLabel: 'v2.0' }, + ) + expect(result.isErr()).toBe(true) + expect(result._unsafeUnwrapErr()).toBeInstanceOf(NotFoundError) + }) +}) + +// --------------------------------------------------------------------------- +// promoteToPending — notice period validation +// --------------------------------------------------------------------------- + +describe('TouUseCases.promoteToPending', () => { + const adminUser = makeUser(UserRole.Admin) + + beforeEach(() => { + jest.clearAllMocks() + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('rejects non-draft versions', async () => { + const version = makeVersion({ status: TouVersionStatus.Active }) + jest.spyOn(touRepository, 'getVersionById').mockResolvedValue(version) + + const result = await TouUseCases.promoteToPending(adminUser, version.id) + expect(result.isErr()).toBe(true) + expect(result._unsafeUnwrapErr()).toBeInstanceOf(BadRequestError) + }) + + it('rejects when another pending version exists', async () => { + const version = makeVersion({ status: TouVersionStatus.Draft }) + jest.spyOn(touRepository, 'getVersionById').mockResolvedValue(version) + jest + .spyOn(touRepository, 'getPendingVersion') + .mockResolvedValue(makeVersion({ status: TouVersionStatus.Pending })) + + const result = await TouUseCases.promoteToPending(adminUser, version.id) + expect(result.isErr()).toBe(true) + expect(result._unsafeUnwrapErr()).toBeInstanceOf(BadRequestError) + expect(result._unsafeUnwrapErr().message).toContain('pending version already exists') + }) + + it('rejects material change with insufficient notice', async () => { + const version = makeVersion({ + status: TouVersionStatus.Draft, + changeType: TouChangeType.Material, + effectiveDate: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000), // 10 days + }) + jest.spyOn(touRepository, 'getVersionById').mockResolvedValue(version) + jest.spyOn(touRepository, 'getPendingVersion').mockResolvedValue(null) + + const result = await TouUseCases.promoteToPending(adminUser, version.id) + expect(result.isErr()).toBe(true) + expect(result._unsafeUnwrapErr()).toBeInstanceOf(BadRequestError) + expect(result._unsafeUnwrapErr().message).toContain('30 days notice') + }) + + it('allows override with reason for insufficient notice', async () => { + const version = makeVersion({ + status: TouVersionStatus.Draft, + changeType: TouChangeType.Material, + effectiveDate: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000), + }) + const promoted = makeVersion({ + ...version, + status: TouVersionStatus.Pending, + }) + jest.spyOn(touRepository, 'getVersionById').mockResolvedValue(version) + jest.spyOn(touRepository, 'getPendingVersion').mockResolvedValue(null) + jest.spyOn(touRepository, 'updateVersion').mockResolvedValue(version) + jest + .spyOn(touRepository, 'updateVersionStatus') + .mockResolvedValue(promoted) + jest + .spyOn(bannersRepository, 'createBanner') + .mockResolvedValue({} as never) + + const result = await TouUseCases.promoteToPending( + adminUser, + version.id, + true, + 'Security emergency', + ) + expect(result.isOk()).toBe(true) + }) + + it('rejects override without reason', async () => { + const version = makeVersion({ + status: TouVersionStatus.Draft, + changeType: TouChangeType.Material, + effectiveDate: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000), + }) + jest.spyOn(touRepository, 'getVersionById').mockResolvedValue(version) + jest.spyOn(touRepository, 'getPendingVersion').mockResolvedValue(null) + + const result = await TouUseCases.promoteToPending( + adminUser, + version.id, + true, + '', + ) + expect(result.isErr()).toBe(true) + expect(result._unsafeUnwrapErr()).toBeInstanceOf(BadRequestError) + expect(result._unsafeUnwrapErr().message).toContain('reason is required') + }) + + it('allows non-material change without 30-day notice', async () => { + const version = makeVersion({ + status: TouVersionStatus.Draft, + changeType: TouChangeType.NonMaterial, + effectiveDate: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000), + }) + const promoted = makeVersion({ + ...version, + status: TouVersionStatus.Pending, + }) + jest.spyOn(touRepository, 'getVersionById').mockResolvedValue(version) + jest.spyOn(touRepository, 'getPendingVersion').mockResolvedValue(null) + jest + .spyOn(touRepository, 'updateVersionStatus') + .mockResolvedValue(promoted) + + const result = await TouUseCases.promoteToPending(adminUser, version.id) + expect(result.isOk()).toBe(true) + }) + + it('creates banner for material change promotion', async () => { + const version = makeVersion({ + status: TouVersionStatus.Draft, + changeType: TouChangeType.Material, + effectiveDate: new Date(Date.now() + 45 * 24 * 60 * 60 * 1000), + }) + const promoted = makeVersion({ + ...version, + status: TouVersionStatus.Pending, + }) + jest.spyOn(touRepository, 'getVersionById').mockResolvedValue(version) + jest.spyOn(touRepository, 'getPendingVersion').mockResolvedValue(null) + jest + .spyOn(touRepository, 'updateVersionStatus') + .mockResolvedValue(promoted) + const bannerSpy = jest + .spyOn(bannersRepository, 'createBanner') + .mockResolvedValue({} as never) + + await TouUseCases.promoteToPending(adminUser, version.id) + expect(bannerSpy).toHaveBeenCalledTimes(1) + }) + + it('does not create banner for non-material change promotion', async () => { + const version = makeVersion({ + status: TouVersionStatus.Draft, + changeType: TouChangeType.NonMaterial, + effectiveDate: new Date(Date.now() + 45 * 24 * 60 * 60 * 1000), + }) + const promoted = makeVersion({ + ...version, + status: TouVersionStatus.Pending, + }) + jest.spyOn(touRepository, 'getVersionById').mockResolvedValue(version) + jest.spyOn(touRepository, 'getPendingVersion').mockResolvedValue(null) + jest + .spyOn(touRepository, 'updateVersionStatus') + .mockResolvedValue(promoted) + const bannerSpy = jest + .spyOn(bannersRepository, 'createBanner') + .mockResolvedValue({} as never) + + await TouUseCases.promoteToPending(adminUser, version.id) + expect(bannerSpy).not.toHaveBeenCalled() + }) +}) + +// --------------------------------------------------------------------------- +// activateVersion +// --------------------------------------------------------------------------- + +describe('TouUseCases.activateVersion', () => { + const adminUser = makeUser(UserRole.Admin) + + beforeEach(() => { + jest.clearAllMocks() + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('rejects non-pending versions', async () => { + const version = makeVersion({ status: TouVersionStatus.Draft }) + jest.spyOn(touRepository, 'getVersionById').mockResolvedValue(version) + + const result = await TouUseCases.activateVersion(adminUser, version.id) + expect(result.isErr()).toBe(true) + expect(result._unsafeUnwrapErr()).toBeInstanceOf(BadRequestError) + }) + + it('activates pending version via transactional method', async () => { + const pending = makeVersion({ status: TouVersionStatus.Pending }) + const activated = makeVersion({ + ...pending, + status: TouVersionStatus.Active, + }) + jest.spyOn(touRepository, 'getVersionById').mockResolvedValue(pending) + const activateSpy = jest + .spyOn(touRepository, 'activateVersionTransactional') + .mockResolvedValue(activated) + + const result = await TouUseCases.activateVersion(adminUser, pending.id) + expect(result.isOk()).toBe(true) + expect(activateSpy).toHaveBeenCalledWith(pending.id) + }) +}) + +// --------------------------------------------------------------------------- +// archiveVersion +// --------------------------------------------------------------------------- + +describe('TouUseCases.archiveVersion', () => { + const adminUser = makeUser(UserRole.Admin) + + beforeEach(() => { + jest.clearAllMocks() + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('rejects draft versions', async () => { + const version = makeVersion({ status: TouVersionStatus.Draft }) + jest.spyOn(touRepository, 'getVersionById').mockResolvedValue(version) + + const result = await TouUseCases.archiveVersion(adminUser, version.id) + expect(result.isErr()).toBe(true) + expect(result._unsafeUnwrapErr()).toBeInstanceOf(BadRequestError) + }) + + it('archives pending version', async () => { + const version = makeVersion({ status: TouVersionStatus.Pending }) + const archived = makeVersion({ + ...version, + status: TouVersionStatus.Archived, + }) + jest.spyOn(touRepository, 'getVersionById').mockResolvedValue(version) + jest + .spyOn(touRepository, 'updateVersionStatus') + .mockResolvedValue(archived) + + const result = await TouUseCases.archiveVersion(adminUser, version.id) + expect(result.isOk()).toBe(true) + expect(result._unsafeUnwrap().status).toBe(TouVersionStatus.Archived) + }) +}) + +// --------------------------------------------------------------------------- +// getTouStatus — user-facing +// --------------------------------------------------------------------------- + +describe('TouUseCases.getTouStatus', () => { + const user = makeUser(UserRole.User) + + beforeEach(() => { + jest.clearAllMocks() + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('returns accepted=true when no active version exists', async () => { + jest + .spyOn(touRepository, 'ensureActiveVersion') + .mockResolvedValue(null) + jest.spyOn(touRepository, 'getPendingVersion').mockResolvedValue(null) + + const status = await TouUseCases.getTouStatus(user) + expect(status.accepted).toBe(true) + expect(status.currentVersion).toBeNull() + }) + + it('returns accepted=false when user has not accepted active version', async () => { + const version = makeVersion({ status: TouVersionStatus.Active }) + jest + .spyOn(touRepository, 'ensureActiveVersion') + .mockResolvedValue(version) + jest.spyOn(touRepository, 'getPendingVersion').mockResolvedValue(null) + jest + .spyOn(touRepository, 'hasUserAcceptedVersion') + .mockResolvedValue(false) + + const status = await TouUseCases.getTouStatus(user) + expect(status.accepted).toBe(false) + expect(status.currentVersion).not.toBeNull() + expect(status.currentVersion!.id).toBe(version.id) + }) + + it('returns accepted=true when user has accepted active version', async () => { + const version = makeVersion({ status: TouVersionStatus.Active }) + jest + .spyOn(touRepository, 'ensureActiveVersion') + .mockResolvedValue(version) + jest.spyOn(touRepository, 'getPendingVersion').mockResolvedValue(null) + jest + .spyOn(touRepository, 'hasUserAcceptedVersion') + .mockResolvedValue(true) + + const status = await TouUseCases.getTouStatus(user) + expect(status.accepted).toBe(true) + }) + + it('includes pending version info when one exists', async () => { + const pending = makeVersion({ + status: TouVersionStatus.Pending, + versionLabel: 'v2.0', + }) + jest + .spyOn(touRepository, 'ensureActiveVersion') + .mockResolvedValue(null) + jest + .spyOn(touRepository, 'getPendingVersion') + .mockResolvedValue(pending) + + const status = await TouUseCases.getTouStatus(user) + expect(status.pendingVersion).not.toBeNull() + expect(status.pendingVersion!.versionLabel).toBe('v2.0') + }) +}) + +// --------------------------------------------------------------------------- +// acceptCurrentVersion — user-facing +// --------------------------------------------------------------------------- + +describe('TouUseCases.acceptCurrentVersion', () => { + const user = makeUser(UserRole.User) + + beforeEach(() => { + jest.clearAllMocks() + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('returns NotFoundError when no active version exists', async () => { + jest + .spyOn(touRepository, 'ensureActiveVersion') + .mockResolvedValue(null) + + const result = await TouUseCases.acceptCurrentVersion(user, '1.2.3.4') + expect(result.isErr()).toBe(true) + expect(result._unsafeUnwrapErr()).toBeInstanceOf(NotFoundError) + }) + + it('creates acceptance record for active version', async () => { + const version = makeVersion({ status: TouVersionStatus.Active }) + jest + .spyOn(touRepository, 'ensureActiveVersion') + .mockResolvedValue(version) + const createSpy = jest + .spyOn(touRepository, 'createAcceptance') + .mockResolvedValue({ + id: uuidv4(), + userId: user.publicId, + versionId: version.id, + ipAddress: '1.2.3.4', + acceptedAt: new Date(), + }) + + const result = await TouUseCases.acceptCurrentVersion(user, '1.2.3.4') + expect(result.isOk()).toBe(true) + expect(createSpy).toHaveBeenCalledWith( + user.publicId, + version.id, + '1.2.3.4', + ) + }) +}) diff --git a/apps/backend/migrations/20260327000000-banners.js b/apps/backend/migrations/20260327000000-banners.js new file mode 100644 index 000000000..f863a538a --- /dev/null +++ b/apps/backend/migrations/20260327000000-banners.js @@ -0,0 +1,53 @@ +'use strict' + +var dbm +var type +var seed +var fs = require('fs') +var path = require('path') +var Promise + +exports.setup = function (options, seedLink) { + dbm = options.dbmigrate + type = dbm.dataType + seed = seedLink + Promise = options.Promise +} + +exports.up = function (db) { + var filePath = path.join( + __dirname, + 'sqls', + '20260327000000-banners-up.sql', + ) + return new Promise(function (resolve, reject) { + fs.readFile(filePath, { encoding: 'utf-8' }, function (err, data) { + if (err) return reject(err) + console.log('received data: ' + data) + resolve(data) + }) + }).then(function (data) { + return db.runSql(data) + }) +} + +exports.down = function (db) { + var filePath = path.join( + __dirname, + 'sqls', + '20260327000000-banners-down.sql', + ) + return new Promise(function (resolve, reject) { + fs.readFile(filePath, { encoding: 'utf-8' }, function (err, data) { + if (err) return reject(err) + console.log('received data: ' + data) + resolve(data) + }) + }).then(function (data) { + return db.runSql(data) + }) +} + +exports._meta = { + version: 1, +} diff --git a/apps/backend/migrations/20260330000000-tou-versions.js b/apps/backend/migrations/20260330000000-tou-versions.js new file mode 100644 index 000000000..a3a532f24 --- /dev/null +++ b/apps/backend/migrations/20260330000000-tou-versions.js @@ -0,0 +1,53 @@ +'use strict' + +var dbm +var type +var seed +var fs = require('fs') +var path = require('path') +var Promise + +exports.setup = function (options, seedLink) { + dbm = options.dbmigrate + type = dbm.dataType + seed = seedLink + Promise = options.Promise +} + +exports.up = function (db) { + var filePath = path.join( + __dirname, + 'sqls', + '20260330000000-tou-versions-up.sql', + ) + return new Promise(function (resolve, reject) { + fs.readFile(filePath, { encoding: 'utf-8' }, function (err, data) { + if (err) return reject(err) + console.log('received data: ' + data) + resolve(data) + }) + }).then(function (data) { + return db.runSql(data) + }) +} + +exports.down = function (db) { + var filePath = path.join( + __dirname, + 'sqls', + '20260330000000-tou-versions-down.sql', + ) + return new Promise(function (resolve, reject) { + fs.readFile(filePath, { encoding: 'utf-8' }, function (err, data) { + if (err) return reject(err) + console.log('received data: ' + data) + resolve(data) + }) + }).then(function (data) { + return db.runSql(data) + }) +} + +exports._meta = { + version: 1, +} diff --git a/apps/backend/migrations/sqls/20260327000000-banners-down.sql b/apps/backend/migrations/sqls/20260327000000-banners-down.sql new file mode 100644 index 000000000..0404fd0b9 --- /dev/null +++ b/apps/backend/migrations/sqls/20260327000000-banners-down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS public.banner_interactions; +DROP TABLE IF EXISTS public.banners; diff --git a/apps/backend/migrations/sqls/20260327000000-banners-up.sql b/apps/backend/migrations/sqls/20260327000000-banners-up.sql new file mode 100644 index 000000000..899ff8448 --- /dev/null +++ b/apps/backend/migrations/sqls/20260327000000-banners-up.sql @@ -0,0 +1,35 @@ +-- Banners table +CREATE TABLE public.banners ( + id text NOT NULL DEFAULT gen_random_uuid()::text, + title text NOT NULL, + body text NOT NULL, + criticality text NOT NULL DEFAULT 'info', + dismissable boolean NOT NULL DEFAULT true, + requires_acknowledgement boolean NOT NULL DEFAULT false, + display_start timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + display_end timestamp NULL, + active boolean NOT NULL DEFAULT true, + created_by text NOT NULL, + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT banners_pkey PRIMARY KEY (id), + CONSTRAINT banners_criticality_check CHECK (criticality IN ('info', 'warning', 'critical')) +); + +CREATE INDEX idx_banners_active ON public.banners (active, display_start, display_end); + +-- Banner interactions table +CREATE TABLE public.banner_interactions ( + id text NOT NULL DEFAULT gen_random_uuid()::text, + user_id text NOT NULL, + banner_id text NOT NULL, + interaction_type text NOT NULL, + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT banner_interactions_pkey PRIMARY KEY (id), + CONSTRAINT banner_interactions_banner_fk FOREIGN KEY (banner_id) REFERENCES public.banners(id) ON DELETE CASCADE, + CONSTRAINT banner_interactions_type_check CHECK (interaction_type IN ('acknowledged', 'dismissed')), + CONSTRAINT banner_interactions_unique UNIQUE (user_id, banner_id, interaction_type) +); + +CREATE INDEX idx_banner_interactions_user ON public.banner_interactions (user_id); +CREATE INDEX idx_banner_interactions_banner ON public.banner_interactions (banner_id); diff --git a/apps/backend/migrations/sqls/20260330000000-tou-versions-down.sql b/apps/backend/migrations/sqls/20260330000000-tou-versions-down.sql new file mode 100644 index 000000000..e25b691ae --- /dev/null +++ b/apps/backend/migrations/sqls/20260330000000-tou-versions-down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS public.user_tou_acceptance; +DROP TABLE IF EXISTS public.tou_versions; diff --git a/apps/backend/migrations/sqls/20260330000000-tou-versions-up.sql b/apps/backend/migrations/sqls/20260330000000-tou-versions-up.sql new file mode 100644 index 000000000..162a7f654 --- /dev/null +++ b/apps/backend/migrations/sqls/20260330000000-tou-versions-up.sql @@ -0,0 +1,33 @@ +CREATE TABLE public.tou_versions ( + id text NOT NULL DEFAULT gen_random_uuid()::text, + version_label text NOT NULL, + effective_date timestamp NOT NULL, + content_url text NOT NULL, + change_type text NOT NULL DEFAULT 'material', + status text NOT NULL DEFAULT 'draft', + admin_notes text, + created_by text NOT NULL, + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT tou_versions_pkey PRIMARY KEY (id), + CONSTRAINT tou_versions_change_type_check CHECK (change_type IN ('material', 'non-material')), + CONSTRAINT tou_versions_status_check CHECK (status IN ('draft', 'pending', 'active', 'archived')), + CONSTRAINT tou_versions_version_label_unique UNIQUE (version_label) +); + +CREATE INDEX idx_tou_versions_status ON public.tou_versions (status); +CREATE INDEX idx_tou_versions_effective_date ON public.tou_versions (effective_date); + +CREATE TABLE public.user_tou_acceptance ( + id text NOT NULL DEFAULT gen_random_uuid()::text, + user_id text NOT NULL, + version_id text NOT NULL, + ip_address text, + accepted_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT user_tou_acceptance_pkey PRIMARY KEY (id), + CONSTRAINT user_tou_acceptance_version_fk FOREIGN KEY (version_id) REFERENCES public.tou_versions(id) ON DELETE CASCADE, + CONSTRAINT user_tou_acceptance_unique UNIQUE (user_id, version_id) +); + +CREATE INDEX idx_user_tou_acceptance_user ON public.user_tou_acceptance (user_id); +CREATE INDEX idx_user_tou_acceptance_version ON public.user_tou_acceptance (version_id); diff --git a/apps/backend/src/app/apis/frontend.ts b/apps/backend/src/app/apis/frontend.ts index cdd88b4c2..3d9c9d8d3 100644 --- a/apps/backend/src/app/apis/frontend.ts +++ b/apps/backend/src/app/apis/frontend.ts @@ -10,6 +10,9 @@ import { config } from '../../config.js' import { createLogger } from '../../infrastructure/drivers/logger.js' import { docsController } from '../controllers/docs.js' import { intentsController } from '../controllers/intents.js' +import { creditsController } from '../controllers/credits.js' +import { bannersController } from '../controllers/banners.js' +import { touController } from '../controllers/tou.js' import { featuresController } from '../controllers/features.js' import { featureFlagMiddleware } from '../../core/featureFlags/express.js' import { IntentsUseCases } from '../../core/users/intents.js' @@ -72,6 +75,9 @@ const createServer = async () => { }), ) app.use('/intents', featureFlagMiddleware('buyCredits'), intentsController) + app.use('/credits', featureFlagMiddleware('buyCredits'), creditsController) + app.use('/banners', bannersController) + app.use('/tou', touController) app.use('/features', featuresController) app.use('/docs', docsController) diff --git a/apps/backend/src/app/controllers/banners.ts b/apps/backend/src/app/controllers/banners.ts new file mode 100644 index 000000000..56cf41372 --- /dev/null +++ b/apps/backend/src/app/controllers/banners.ts @@ -0,0 +1,249 @@ +import { Router } from 'express' +import { asyncSafeHandler } from '../../shared/utils/express.js' +import { handleAuth } from '../../infrastructure/services/auth/express.js' +import { BannersUseCases } from '../../core/banners.js' +import { + handleInternalError, + handleInternalErrorResult, +} from '../../shared/utils/neverthrow.js' +import { handleError } from '../../errors/index.js' +import { BannerInteractionType } from '@auto-drive/models' + +export const bannersController = Router() + +// --------------------------------------------------------------------------- +// GET /banners/active +// Returns active banners for the authenticated user, filtered by interactions. +// --------------------------------------------------------------------------- + +bannersController.get( + '/active', + asyncSafeHandler(async (req, res) => { + const user = await handleAuth(req, res) + if (!user) { + return + } + + const result = await handleInternalError( + BannersUseCases.getActiveBannersForUser(user), + 'Failed to get active banners', + ) + if (result.isErr()) { + handleError(result.error, res) + return + } + + res.status(200).json(result.value) + }), +) + +// --------------------------------------------------------------------------- +// POST /banners/:id/interact +// Records a user interaction (acknowledge or dismiss) with a banner. +// --------------------------------------------------------------------------- + +bannersController.post( + '/:id/interact', + asyncSafeHandler(async (req, res) => { + const user = await handleAuth(req, res) + if (!user) { + return + } + + const { type } = req.body as { type: string } + if ( + type !== BannerInteractionType.Acknowledged && + type !== BannerInteractionType.Dismissed + ) { + res.status(400).json({ error: 'Invalid interaction type' }) + return + } + + const result = await handleInternalErrorResult( + BannersUseCases.recordInteraction(user, req.params.id, type), + 'Failed to record banner interaction', + ) + if (result.isErr()) { + handleError(result.error, res) + return + } + + res.status(204).send() + }), +) + +// --------------------------------------------------------------------------- +// GET /banners/admin +// Admin-only: returns all banners. +// --------------------------------------------------------------------------- + +bannersController.get( + '/admin', + asyncSafeHandler(async (req, res) => { + const user = await handleAuth(req, res) + if (!user) { + return + } + + const result = await handleInternalErrorResult( + BannersUseCases.getAllBanners(user), + 'Failed to get all banners', + ) + if (result.isErr()) { + handleError(result.error, res) + return + } + + res.status(200).json(result.value) + }), +) + +// --------------------------------------------------------------------------- +// POST /banners/admin +// Admin-only: create a new banner. +// --------------------------------------------------------------------------- + +bannersController.post( + '/admin', + asyncSafeHandler(async (req, res) => { + const user = await handleAuth(req, res) + if (!user) { + return + } + + const { + title, + body, + criticality, + dismissable, + requiresAcknowledgement, + displayStart, + displayEnd, + active, + } = req.body + + const result = await handleInternalErrorResult( + BannersUseCases.createBanner(user, { + title, + body, + criticality, + dismissable: dismissable ?? true, + requiresAcknowledgement: requiresAcknowledgement ?? false, + displayStart: displayStart ? new Date(displayStart) : new Date(), + displayEnd: displayEnd ? new Date(displayEnd) : null, + active: active ?? true, + }), + 'Failed to create banner', + ) + if (result.isErr()) { + handleError(result.error, res) + return + } + + res.status(201).json(result.value) + }), +) + +// --------------------------------------------------------------------------- +// PUT /banners/admin/:id +// Admin-only: update an existing banner. +// --------------------------------------------------------------------------- + +bannersController.put( + '/admin/:id', + asyncSafeHandler(async (req, res) => { + const user = await handleAuth(req, res) + if (!user) { + return + } + + const { + title, + body, + criticality, + dismissable, + requiresAcknowledgement, + displayStart, + displayEnd, + active, + } = req.body + + const result = await handleInternalErrorResult( + BannersUseCases.updateBanner(user, req.params.id, { + title, + body, + criticality, + dismissable, + requiresAcknowledgement, + displayStart: displayStart ? new Date(displayStart) : undefined, + displayEnd: displayEnd !== undefined + ? displayEnd + ? new Date(displayEnd) + : null + : undefined, + active, + }), + 'Failed to update banner', + ) + if (result.isErr()) { + handleError(result.error, res) + return + } + + res.status(200).json(result.value) + }), +) + +// --------------------------------------------------------------------------- +// POST /banners/admin/:id/toggle +// Admin-only: activate or deactivate a banner. +// --------------------------------------------------------------------------- + +bannersController.post( + '/admin/:id/toggle', + asyncSafeHandler(async (req, res) => { + const user = await handleAuth(req, res) + if (!user) { + return + } + + const { active } = req.body as { active: boolean } + + const result = await handleInternalErrorResult( + BannersUseCases.toggleBannerActive(user, req.params.id, active), + 'Failed to toggle banner', + ) + if (result.isErr()) { + handleError(result.error, res) + return + } + + res.status(200).json(result.value) + }), +) + +// --------------------------------------------------------------------------- +// GET /banners/admin/:id/stats +// Admin-only: get banner with acknowledgement/dismissal stats. +// --------------------------------------------------------------------------- + +bannersController.get( + '/admin/:id/stats', + asyncSafeHandler(async (req, res) => { + const user = await handleAuth(req, res) + if (!user) { + return + } + + const result = await handleInternalErrorResult( + BannersUseCases.getBannerWithStats(user, req.params.id), + 'Failed to get banner stats', + ) + if (result.isErr()) { + handleError(result.error, res) + return + } + + res.status(200).json(result.value) + }), +) diff --git a/apps/backend/src/app/controllers/credits.ts b/apps/backend/src/app/controllers/credits.ts new file mode 100644 index 000000000..884911870 --- /dev/null +++ b/apps/backend/src/app/controllers/credits.ts @@ -0,0 +1,189 @@ +import { Router } from 'express' +import { asyncSafeHandler } from '../../shared/utils/express.js' +import { handleAuth } from '../../infrastructure/services/auth/express.js' +import { CreditsUseCases } from '../../core/users/credits.js' +import { + handleInternalError, + handleInternalErrorResult, +} from '../../shared/utils/neverthrow.js' +import { handleError } from '../../errors/index.js' +import { PurchasedCredit } from '@auto-drive/models' + +export const creditsController = Router() + +// --------------------------------------------------------------------------- +// Serialisation helpers +// --------------------------------------------------------------------------- + +// PurchasedCredit rows contain bigint fields that cannot be JSON-serialised +// directly. We convert each bigint to a string so the wire format is stable +// and the frontend can parse them with BigInt() or a numeric library. +const serializeCredit = (credit: PurchasedCredit) => ({ + ...credit, + uploadBytesOriginal: credit.uploadBytesOriginal.toString(), + uploadBytesRemaining: credit.uploadBytesRemaining.toString(), + downloadBytesOriginal: credit.downloadBytesOriginal.toString(), + downloadBytesRemaining: credit.downloadBytesRemaining.toString(), +}) + +// --------------------------------------------------------------------------- +// GET /credits/summary +// Returns the authenticated user's credit totals, next expiry, and whether +// they can still make a purchase without hitting the per-user cap. +// --------------------------------------------------------------------------- + +creditsController.get( + '/summary', + asyncSafeHandler(async (req, res) => { + const user = await handleAuth(req, res) + if (!user) { + return + } + + const result = await handleInternalError( + CreditsUseCases.getSummary(user), + 'Failed to get credit summary', + ) + if (result.isErr()) { + handleError(result.error, res) + return + } + + const summary = result.value + res.status(200).json({ + uploadBytesRemaining: summary.uploadBytesRemaining.toString(), + downloadBytesRemaining: summary.downloadBytesRemaining.toString(), + nextExpiryDate: summary.nextExpiryDate ?? null, + batchCount: summary.batchCount, + canPurchase: summary.canPurchase, + maxPurchasableBytes: summary.maxPurchasableBytes.toString(), + googleVerified: summary.googleVerified, + expiryDays: summary.expiryDays, + }) + }), +) + +// --------------------------------------------------------------------------- +// GET /credits/batches +// Returns the full purchase history for the authenticated user, including +// already-expired rows, ordered newest-first. +// --------------------------------------------------------------------------- + +creditsController.get( + '/batches', + asyncSafeHandler(async (req, res) => { + const user = await handleAuth(req, res) + if (!user) { + return + } + + const result = await handleInternalError( + CreditsUseCases.getBatches(user), + 'Failed to get credit batches', + ) + if (result.isErr()) { + handleError(result.error, res) + return + } + + res.status(200).json(result.value.map(serializeCredit)) + }), +) + +// --------------------------------------------------------------------------- +// GET /credits/batches/expiring +// Returns active rows expiring within 30 days for the authenticated user. +// Useful for frontend expiry-warning banners. +// --------------------------------------------------------------------------- + +creditsController.get( + '/batches/expiring', + asyncSafeHandler(async (req, res) => { + const user = await handleAuth(req, res) + if (!user) { + return + } + + const result = await handleInternalError( + CreditsUseCases.getExpiringBatches(user), + 'Failed to get expiring credit batches', + ) + if (result.isErr()) { + handleError(result.error, res) + return + } + + res.status(200).json(result.value.map(serializeCredit)) + }), +) + +// --------------------------------------------------------------------------- +// GET /credits/batches/all +// Admin-only: all credit batches across every user, newest-first. +// Each row includes the owner's userPublicId for easy cross-referencing +// with the admin user table. Returns 403 for non-admin callers. +// +// NOTE: registered BEFORE GET /credits/batches so Express does not attempt +// to match the literal string "all" against the existing /batches route +// (they are separate paths and Express won't confuse them, but ordering +// here keeps the admin routes grouped together). +// --------------------------------------------------------------------------- + +creditsController.get( + '/batches/all', + asyncSafeHandler(async (req, res) => { + const user = await handleAuth(req, res) + if (!user) { + return + } + + const result = await handleInternalErrorResult( + CreditsUseCases.getAllBatches(user), + 'Failed to get all credit batches', + ) + if (result.isErr()) { + handleError(result.error, res) + return + } + + res.status(200).json( + result.value.map((batch) => ({ + ...serializeCredit(batch), + userPublicId: batch.userPublicId, + })), + ) + }), +) + +// --------------------------------------------------------------------------- +// GET /credits/economics +// Admin-only: system-wide credit stats (expiring totals, byte volumes). +// Returns 403 for non-admin users. +// --------------------------------------------------------------------------- + +creditsController.get( + '/economics', + asyncSafeHandler(async (req, res) => { + const user = await handleAuth(req, res) + if (!user) { + return + } + + const result = await handleInternalErrorResult( + CreditsUseCases.getEconomics(user), + 'Failed to get credit economics', + ) + if (result.isErr()) { + handleError(result.error, res) + return + } + + const economics = result.value + res.status(200).json({ + totalExpiringWithin30Days: economics.totalExpiringWithin30Days, + totalExpiringUploadBytes: economics.totalExpiringUploadBytes.toString(), + totalExpiringDownloadBytes: + economics.totalExpiringDownloadBytes.toString(), + }) + }), +) diff --git a/apps/backend/src/app/controllers/intents.ts b/apps/backend/src/app/controllers/intents.ts index bba37c7dc..4caf7a6f4 100644 --- a/apps/backend/src/app/controllers/intents.ts +++ b/apps/backend/src/app/controllers/intents.ts @@ -12,6 +12,11 @@ import { hasGoogleAuth } from '../../core/featureFlags/index.js' export const intentsController = Router() +// --------------------------------------------------------------------------- +// POST /intents/ +// Creates a PENDING intent with the current price locked in. +// --------------------------------------------------------------------------- + intentsController.post( '/', asyncSafeHandler(async (req, res) => { @@ -51,6 +56,48 @@ intentsController.post( }), ) +// --------------------------------------------------------------------------- +// GET /intents/over-cap (admin only) +// Lists all intents that were confirmed on-chain but could not be converted +// to credits because the user was already at the per-user cap. +// These are terminal — the polling loop skips them. An admin must review +// and either raise the cap + reprocess, or arrange a refund out-of-band. +// +// NOTE: this static route must be registered BEFORE GET /:id so Express does +// not match the literal string "over-cap" as a dynamic :id parameter. +// --------------------------------------------------------------------------- + +intentsController.get( + '/over-cap', + asyncSafeHandler(async (req, res) => { + const user = await handleAuth(req, res) + if (!user) { + return + } + + const result = await handleInternalErrorResult( + IntentsUseCases.getOverCapIntents(user), + 'Failed to get over-cap intents', + ) + if (result.isErr()) { + handleError(result.error, res) + return + } + + res.status(200).json( + result.value.map((intent) => ({ + ...intent, + shannonsPerByte: intent.shannonsPerByte.toString(), + paymentAmount: intent.paymentAmount?.toString(), + })), + ) + }), +) + +// --------------------------------------------------------------------------- +// GET /intents/:id +// --------------------------------------------------------------------------- + intentsController.get( '/:id', asyncSafeHandler(async (req, res) => { @@ -76,6 +123,11 @@ intentsController.get( }), ) +// --------------------------------------------------------------------------- +// POST /intents/:id/watch +// Attaches a txHash to a pending intent and queues on-chain watching. +// --------------------------------------------------------------------------- + intentsController.post( '/:id/watch', asyncSafeHandler(async (req, res) => { @@ -108,3 +160,38 @@ intentsController.post( res.sendStatus(204) }), ) + +// --------------------------------------------------------------------------- +// POST /intents/:id/reprocess (admin only) +// Resets an OVER_CAP intent back to CONFIRMED so the payment manager polling +// loop will re-attempt credit grant on its next tick (~30 s). +// +// Typical workflow: +// 1. Admin raises the user's cap via POST /accounts/update. +// 2. Admin calls this endpoint to re-queue the intent. +// 3. The polling loop picks it up within ~30 seconds. +// +// Returns 409 if the intent is not currently in OVER_CAP status, preventing +// accidental re-queuing of COMPLETED or PENDING intents. +// --------------------------------------------------------------------------- + +intentsController.post( + '/:id/reprocess', + asyncSafeHandler(async (req, res) => { + const user = await handleAuth(req, res) + if (!user) { + return + } + + const result = await handleInternalErrorResult( + IntentsUseCases.reprocessOverCapIntent(user, req.params.id), + 'Failed to reprocess intent', + ) + if (result.isErr()) { + handleError(result.error, res) + return + } + + res.sendStatus(204) + }), +) diff --git a/apps/backend/src/app/controllers/tou.ts b/apps/backend/src/app/controllers/tou.ts new file mode 100644 index 000000000..6989b1e43 --- /dev/null +++ b/apps/backend/src/app/controllers/tou.ts @@ -0,0 +1,269 @@ +import { Router } from 'express' +import { asyncSafeHandler } from '../../shared/utils/express.js' +import { handleAuth } from '../../infrastructure/services/auth/express.js' +import { TouUseCases } from '../../core/tou.js' +import { + handleInternalError, + handleInternalErrorResult, +} from '../../shared/utils/neverthrow.js' +import { handleError } from '../../errors/index.js' +import { TouChangeType } from '@auto-drive/models' +import { createLogger } from '../../infrastructure/drivers/logger.js' + +const logger = createLogger('http:controllers:tou') + +export const touController = Router() + +// --------------------------------------------------------------------------- +// GET /tou/status — user-facing: check ToU acceptance status +// --------------------------------------------------------------------------- + +touController.get( + '/status', + asyncSafeHandler(async (req, res) => { + const user = await handleAuth(req, res) + if (!user) return + + const result = await handleInternalError( + TouUseCases.getTouStatus(user), + 'Failed to get ToU status', + ) + if (result.isErr()) { + handleError(result.error, res) + return + } + + res.status(200).json(result.value) + }), +) + +// --------------------------------------------------------------------------- +// POST /tou/accept — user-facing: accept current active version +// --------------------------------------------------------------------------- + +touController.post( + '/accept', + asyncSafeHandler(async (req, res) => { + const user = await handleAuth(req, res) + if (!user) return + + const ipAddress = + (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() || + req.ip || + null + + const result = await handleInternalErrorResult( + TouUseCases.acceptCurrentVersion(user, ipAddress), + 'Failed to accept ToU', + ) + if (result.isErr()) { + handleError(result.error, res) + return + } + + logger.debug('User %s accepted ToU', user.publicId) + res.status(204).send() + }), +) + +// --------------------------------------------------------------------------- +// GET /tou/admin — list all versions +// --------------------------------------------------------------------------- + +touController.get( + '/admin', + asyncSafeHandler(async (req, res) => { + const user = await handleAuth(req, res) + if (!user) return + + const result = await handleInternalErrorResult( + TouUseCases.getAllVersions(user), + 'Failed to get ToU versions', + ) + if (result.isErr()) { + handleError(result.error, res) + return + } + + res.status(200).json(result.value) + }), +) + +// --------------------------------------------------------------------------- +// POST /tou/admin — create draft version +// --------------------------------------------------------------------------- + +touController.post( + '/admin', + asyncSafeHandler(async (req, res) => { + const user = await handleAuth(req, res) + if (!user) return + + const { versionLabel, effectiveDate, contentUrl, changeType, adminNotes } = + req.body + + if (!versionLabel || !effectiveDate || !contentUrl) { + res + .status(400) + .json({ error: 'versionLabel, effectiveDate, and contentUrl are required' }) + return + } + + if ( + changeType && + changeType !== TouChangeType.Material && + changeType !== TouChangeType.NonMaterial + ) { + res.status(400).json({ error: 'Invalid changeType' }) + return + } + + const result = await handleInternalErrorResult( + TouUseCases.createTouVersion(user, { + versionLabel, + effectiveDate: new Date(effectiveDate), + contentUrl, + changeType: changeType || TouChangeType.Material, + adminNotes: adminNotes || null, + }), + 'Failed to create ToU version', + ) + if (result.isErr()) { + handleError(result.error, res) + return + } + + res.status(201).json(result.value) + }), +) + +// --------------------------------------------------------------------------- +// PUT /tou/admin/:id — update draft version +// --------------------------------------------------------------------------- + +touController.put( + '/admin/:id', + asyncSafeHandler(async (req, res) => { + const user = await handleAuth(req, res) + if (!user) return + + const { versionLabel, effectiveDate, contentUrl, changeType, adminNotes } = + req.body + + const result = await handleInternalErrorResult( + TouUseCases.updateTouVersion(user, req.params.id, { + versionLabel, + effectiveDate: effectiveDate ? new Date(effectiveDate) : undefined, + contentUrl, + changeType, + adminNotes: adminNotes !== undefined ? adminNotes : undefined, + }), + 'Failed to update ToU version', + ) + if (result.isErr()) { + handleError(result.error, res) + return + } + + res.status(200).json(result.value) + }), +) + +// --------------------------------------------------------------------------- +// POST /tou/admin/:id/promote — promote draft to pending +// --------------------------------------------------------------------------- + +touController.post( + '/admin/:id/promote', + asyncSafeHandler(async (req, res) => { + const user = await handleAuth(req, res) + if (!user) return + + const { overrideNotice, overrideReason } = req.body || {} + + const result = await handleInternalErrorResult( + TouUseCases.promoteToPending( + user, + req.params.id, + overrideNotice, + overrideReason, + ), + 'Failed to promote ToU version', + ) + if (result.isErr()) { + handleError(result.error, res) + return + } + + res.status(200).json(result.value) + }), +) + +// --------------------------------------------------------------------------- +// POST /tou/admin/:id/activate — manual early activation +// --------------------------------------------------------------------------- + +touController.post( + '/admin/:id/activate', + asyncSafeHandler(async (req, res) => { + const user = await handleAuth(req, res) + if (!user) return + + const result = await handleInternalErrorResult( + TouUseCases.activateVersion(user, req.params.id), + 'Failed to activate ToU version', + ) + if (result.isErr()) { + handleError(result.error, res) + return + } + + res.status(200).json(result.value) + }), +) + +// --------------------------------------------------------------------------- +// POST /tou/admin/:id/archive — archive version +// --------------------------------------------------------------------------- + +touController.post( + '/admin/:id/archive', + asyncSafeHandler(async (req, res) => { + const user = await handleAuth(req, res) + if (!user) return + + const result = await handleInternalErrorResult( + TouUseCases.archiveVersion(user, req.params.id), + 'Failed to archive ToU version', + ) + if (result.isErr()) { + handleError(result.error, res) + return + } + + res.status(200).json(result.value) + }), +) + +// --------------------------------------------------------------------------- +// GET /tou/admin/:id/stats — acceptance statistics +// --------------------------------------------------------------------------- + +touController.get( + '/admin/:id/stats', + asyncSafeHandler(async (req, res) => { + const user = await handleAuth(req, res) + if (!user) return + + const result = await handleInternalErrorResult( + TouUseCases.getVersionWithStats(user, req.params.id), + 'Failed to get ToU version stats', + ) + if (result.isErr()) { + handleError(result.error, res) + return + } + + res.status(200).json(result.value) + }), +) diff --git a/apps/backend/src/core/banners.ts b/apps/backend/src/core/banners.ts new file mode 100644 index 000000000..b805bd539 --- /dev/null +++ b/apps/backend/src/core/banners.ts @@ -0,0 +1,189 @@ +import { + Banner, + BannerCriticality, + BannerInteractionType, + BannerWithStats, + User, + UserRole, + UserWithOrganization, +} from '@auto-drive/models' +import { bannersRepository } from '../infrastructure/repositories/banners.js' +import { + BadRequestError, + ForbiddenError, + NotFoundError, +} from '../errors/index.js' +import { err, ok, Result } from 'neverthrow' +import { createLogger } from '../infrastructure/drivers/logger.js' + +const logger = createLogger('BannersUseCases') + +// --------------------------------------------------------------------------- +// getActiveBannersForUser +// --------------------------------------------------------------------------- + +const getActiveBannersForUser = async ( + user: UserWithOrganization, +): Promise => { + return bannersRepository.getActiveBannersForUser(user.publicId) +} + +// --------------------------------------------------------------------------- +// recordInteraction +// --------------------------------------------------------------------------- + +const recordInteraction = async ( + user: UserWithOrganization, + bannerId: string, + type: BannerInteractionType, +): Promise> => { + const banner = await bannersRepository.getBannerById(bannerId) + if (!banner) { + return err(new NotFoundError('Banner not found')) + } + + if (type === BannerInteractionType.Dismissed && !banner.dismissable) { + return err(new BadRequestError('Banner is not dismissable')) + } + + if ( + type === BannerInteractionType.Acknowledged && + !banner.requiresAcknowledgement + ) { + return err( + new BadRequestError('Banner does not require acknowledgement'), + ) + } + + await bannersRepository.createInteraction(user.publicId, bannerId, type) + return ok(undefined) +} + +// --------------------------------------------------------------------------- +// Admin: createBanner +// --------------------------------------------------------------------------- + +type CreateBannerParams = { + title: string + body: string + criticality: BannerCriticality + dismissable: boolean + requiresAcknowledgement: boolean + displayStart: Date + displayEnd: Date | null + active: boolean +} + +const createBanner = async ( + executor: User, + params: CreateBannerParams, +): Promise> => { + if (executor.role !== UserRole.Admin) { + logger.warn('Non-admin user attempted to create banner', { + publicId: executor.publicId, + }) + return err(new ForbiddenError('Admin access required')) + } + + const banner = await bannersRepository.createBanner({ + ...params, + createdBy: executor.publicId, + }) + return ok(banner) +} + +// --------------------------------------------------------------------------- +// Admin: updateBanner +// --------------------------------------------------------------------------- + +type UpdateBannerParams = { + title?: string + body?: string + criticality?: BannerCriticality + dismissable?: boolean + requiresAcknowledgement?: boolean + displayStart?: Date + displayEnd?: Date | null + active?: boolean +} + +const updateBanner = async ( + executor: User, + bannerId: string, + params: UpdateBannerParams, +): Promise> => { + if (executor.role !== UserRole.Admin) { + return err(new ForbiddenError('Admin access required')) + } + + const banner = await bannersRepository.updateBanner(bannerId, params) + if (banner === null) { + // updateBanner returns null both when no fields were provided and when + // the banner doesn't exist. Disambiguate by checking existence. + const existing = await bannersRepository.getBannerById(bannerId) + if (!existing) { + return err(new NotFoundError('Banner not found')) + } + // Banner exists but no fields to update — return it unchanged. + return ok(existing) + } + + return ok(banner) +} + +// --------------------------------------------------------------------------- +// Admin: toggleBannerActive +// --------------------------------------------------------------------------- + +const toggleBannerActive = async ( + executor: User, + bannerId: string, + active: boolean, +): Promise> => { + return updateBanner(executor, bannerId, { active }) +} + +// --------------------------------------------------------------------------- +// Admin: getAllBanners +// --------------------------------------------------------------------------- + +const getAllBanners = async ( + executor: User, +): Promise> => { + if (executor.role !== UserRole.Admin) { + return err(new ForbiddenError('Admin access required')) + } + + const banners = await bannersRepository.getAllBanners() + return ok(banners) +} + +// --------------------------------------------------------------------------- +// Admin: getBannerWithStats +// --------------------------------------------------------------------------- + +const getBannerWithStats = async ( + executor: User, + bannerId: string, +): Promise> => { + if (executor.role !== UserRole.Admin) { + return err(new ForbiddenError('Admin access required')) + } + + const banner = await bannersRepository.getBannerWithStats(bannerId) + if (!banner) { + return err(new NotFoundError('Banner not found')) + } + + return ok(banner) +} + +export const BannersUseCases = { + getActiveBannersForUser, + recordInteraction, + createBanner, + updateBanner, + toggleBannerActive, + getAllBanners, + getBannerWithStats, +} diff --git a/apps/backend/src/core/tou.ts b/apps/backend/src/core/tou.ts new file mode 100644 index 000000000..681e4813e --- /dev/null +++ b/apps/backend/src/core/tou.ts @@ -0,0 +1,428 @@ +import { + TouAcceptance, + TouChangeType, + TouStatus, + TouVersion, + TouVersionStatus, + TouVersionWithStats, + User, + UserRole, + UserWithOrganization, + BannerCriticality, +} from '@auto-drive/models' +import { touRepository } from '../infrastructure/repositories/tou.js' +import { bannersRepository } from '../infrastructure/repositories/banners.js' +import { + BadRequestError, + ForbiddenError, + NotFoundError, +} from '../errors/index.js' +import { err, ok, Result } from 'neverthrow' +import { createLogger } from '../infrastructure/drivers/logger.js' + +const logger = createLogger('TouUseCases') + +const NOTICE_DAYS = 30 + +// --------------------------------------------------------------------------- +// getTouStatus — user-facing +// --------------------------------------------------------------------------- + +const getTouStatus = async ( + user: UserWithOrganization, +): Promise => { + const activeVersion = await touRepository.ensureActiveVersion() + const pendingVersion = await touRepository.getPendingVersion() + + if (!activeVersion) { + return { + accepted: true, + currentVersion: null, + pendingVersion: pendingVersion + ? { + versionLabel: pendingVersion.versionLabel, + effectiveDate: pendingVersion.effectiveDate, + contentUrl: pendingVersion.contentUrl, + changeType: pendingVersion.changeType, + } + : null, + } + } + + const accepted = await touRepository.hasUserAcceptedVersion( + user.publicId, + activeVersion.id, + ) + + return { + accepted, + currentVersion: { + id: activeVersion.id, + versionLabel: activeVersion.versionLabel, + contentUrl: activeVersion.contentUrl, + changeType: activeVersion.changeType, + effectiveDate: activeVersion.effectiveDate, + }, + pendingVersion: pendingVersion + ? { + versionLabel: pendingVersion.versionLabel, + effectiveDate: pendingVersion.effectiveDate, + contentUrl: pendingVersion.contentUrl, + changeType: pendingVersion.changeType, + } + : null, + } +} + +// --------------------------------------------------------------------------- +// acceptCurrentVersion — user-facing +// --------------------------------------------------------------------------- + +const acceptCurrentVersion = async ( + user: UserWithOrganization, + ipAddress: string | null, +): Promise> => { + const activeVersion = await touRepository.ensureActiveVersion() + if (!activeVersion) { + return err(new NotFoundError('No active ToU version')) + } + + const acceptance = await touRepository.createAcceptance( + user.publicId, + activeVersion.id, + ipAddress, + ) + + logger.info('User accepted ToU version %s', activeVersion.versionLabel, { + userId: user.publicId, + versionId: activeVersion.id, + }) + + return ok(acceptance) +} + +// --------------------------------------------------------------------------- +// Admin: createTouVersion +// --------------------------------------------------------------------------- + +type CreateTouVersionParams = { + versionLabel: string + effectiveDate: Date + contentUrl: string + changeType: TouChangeType + adminNotes: string | null +} + +const createTouVersion = async ( + executor: User, + params: CreateTouVersionParams, +): Promise> => { + if (executor.role !== UserRole.Admin) { + logger.warn('Non-admin user attempted to create ToU version', { + publicId: executor.publicId, + }) + return err(new ForbiddenError('Admin access required')) + } + + const version = await touRepository.createVersion({ + ...params, + createdBy: executor.publicId, + }) + + logger.info('ToU version %s created as draft', version.versionLabel, { + id: version.id, + createdBy: executor.publicId, + }) + + return ok(version) +} + +// --------------------------------------------------------------------------- +// Admin: updateTouVersion — only drafts can be edited +// --------------------------------------------------------------------------- + +type UpdateTouVersionParams = { + versionLabel?: string + effectiveDate?: Date + contentUrl?: string + changeType?: TouChangeType + adminNotes?: string | null +} + +const updateTouVersion = async ( + executor: User, + id: string, + params: UpdateTouVersionParams, +): Promise< + Result +> => { + if (executor.role !== UserRole.Admin) { + return err(new ForbiddenError('Admin access required')) + } + + const existing = await touRepository.getVersionById(id) + if (!existing) { + return err(new NotFoundError('ToU version not found')) + } + + if (existing.status !== TouVersionStatus.Draft) { + return err( + new BadRequestError('Only draft versions can be edited'), + ) + } + + const updated = await touRepository.updateVersion(id, params) + if (!updated) { + return ok(existing) + } + + return ok(updated) +} + +// --------------------------------------------------------------------------- +// Admin: promoteToPending +// Validates 30-day notice for material changes. Admin can override with reason. +// Auto-creates a banner for advance notice of material changes. +// --------------------------------------------------------------------------- + +const promoteToPending = async ( + executor: User, + id: string, + overrideNotice?: boolean, + overrideReason?: string, +): Promise< + Result +> => { + if (executor.role !== UserRole.Admin) { + return err(new ForbiddenError('Admin access required')) + } + + const version = await touRepository.getVersionById(id) + if (!version) { + return err(new NotFoundError('ToU version not found')) + } + + if (version.status !== TouVersionStatus.Draft) { + return err(new BadRequestError('Only draft versions can be promoted')) + } + + const existingPending = await touRepository.getPendingVersion() + if (existingPending) { + return err( + new BadRequestError( + 'A pending version already exists. Archive it before promoting another.', + ), + ) + } + + if (version.changeType === TouChangeType.Material) { + const daysUntilEffective = Math.floor( + (version.effectiveDate.getTime() - Date.now()) / (1000 * 60 * 60 * 24), + ) + + if (daysUntilEffective < NOTICE_DAYS) { + if (!overrideNotice) { + return err( + new BadRequestError( + `Material changes require at least ${NOTICE_DAYS} days notice. ` + + `Effective date is only ${daysUntilEffective} days away. ` + + 'Set overrideNotice=true with a reason for emergency changes.', + ), + ) + } + + if (!overrideReason || overrideReason.trim().length === 0) { + return err( + new BadRequestError( + 'A reason is required when overriding the notice period', + ), + ) + } + + logger.warn( + 'Admin overriding %d-day notice for ToU version %s: %s', + NOTICE_DAYS, + version.versionLabel, + overrideReason, + { adminId: executor.publicId }, + ) + + await touRepository.updateVersion(id, { + adminNotes: `${version.adminNotes ? version.adminNotes + '\n' : ''}[EMERGENCY OVERRIDE] ${overrideReason}`, + }) + } + } + + const promoted = await touRepository.updateVersionStatus( + id, + TouVersionStatus.Pending, + ) + + if (!promoted) { + return err(new NotFoundError('ToU version not found after update')) + } + + if (version.changeType === TouChangeType.Material) { + await bannersRepository.createBanner({ + title: `Terms of Use Update (${version.versionLabel})`, + body: + 'Updated Terms of Use take effect on ' + + `${version.effectiveDate.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}. ` + + 'You will be asked to review and accept the changes when they take effect.', + criticality: BannerCriticality.Warning, + dismissable: true, + requiresAcknowledgement: false, + displayStart: new Date(), + displayEnd: version.effectiveDate, + active: true, + createdBy: executor.publicId, + }) + + logger.info( + 'Created advance notice banner for ToU version %s', + version.versionLabel, + ) + } + + logger.info('ToU version %s promoted to pending', version.versionLabel, { + id, + adminId: executor.publicId, + }) + + return ok(promoted) +} + +// --------------------------------------------------------------------------- +// Admin: activateVersion — manual early activation +// --------------------------------------------------------------------------- + +const activateVersion = async ( + executor: User, + id: string, +): Promise< + Result +> => { + if (executor.role !== UserRole.Admin) { + return err(new ForbiddenError('Admin access required')) + } + + const version = await touRepository.getVersionById(id) + if (!version) { + return err(new NotFoundError('ToU version not found')) + } + + if (version.status !== TouVersionStatus.Pending) { + return err( + new BadRequestError('Only pending versions can be activated'), + ) + } + + const activated = await touRepository.activateVersionTransactional(id) + + if (!activated) { + return err(new NotFoundError('ToU version not found after activation')) + } + + logger.info('ToU version %s manually activated', version.versionLabel, { + id, + adminId: executor.publicId, + }) + + return ok(activated) +} + +// --------------------------------------------------------------------------- +// Admin: archiveVersion +// --------------------------------------------------------------------------- + +const archiveVersion = async ( + executor: User, + id: string, +): Promise< + Result +> => { + if (executor.role !== UserRole.Admin) { + return err(new ForbiddenError('Admin access required')) + } + + const version = await touRepository.getVersionById(id) + if (!version) { + return err(new NotFoundError('ToU version not found')) + } + + if ( + version.status !== TouVersionStatus.Pending && + version.status !== TouVersionStatus.Active + ) { + return err( + new BadRequestError( + 'Only pending or active versions can be archived', + ), + ) + } + + const archived = await touRepository.updateVersionStatus( + id, + TouVersionStatus.Archived, + ) + + if (!archived) { + return err(new NotFoundError('ToU version not found after archival')) + } + + logger.info('ToU version %s archived', version.versionLabel, { + id, + adminId: executor.publicId, + }) + + return ok(archived) +} + +// --------------------------------------------------------------------------- +// Admin: getAllVersions +// --------------------------------------------------------------------------- + +const getAllVersions = async ( + executor: User, +): Promise> => { + if (executor.role !== UserRole.Admin) { + return err(new ForbiddenError('Admin access required')) + } + + const versions = await touRepository.getAllVersions() + return ok(versions) +} + +// --------------------------------------------------------------------------- +// Admin: getVersionWithStats +// --------------------------------------------------------------------------- + +const getVersionWithStats = async ( + executor: User, + id: string, +): Promise< + Result +> => { + if (executor.role !== UserRole.Admin) { + return err(new ForbiddenError('Admin access required')) + } + + const stats = await touRepository.getVersionWithStats(id) + if (!stats) { + return err(new NotFoundError('ToU version not found')) + } + + return ok(stats) +} + +export const TouUseCases = { + getTouStatus, + acceptCurrentVersion, + createTouVersion, + updateTouVersion, + promoteToPending, + activateVersion, + archiveVersion, + getAllVersions, + getVersionWithStats, +} diff --git a/apps/backend/src/core/uploads/uploadProcessing.ts b/apps/backend/src/core/uploads/uploadProcessing.ts index 6858b7e06..fb8f22c59 100644 --- a/apps/backend/src/core/uploads/uploadProcessing.ts +++ b/apps/backend/src/core/uploads/uploadProcessing.ts @@ -227,6 +227,32 @@ const handleFolderUploadFinalization = async ( uploadId, user.oauthUserId, ) + + // Credit guard: verify the user has a non-negative credit balance before + // finalising the folder DAG structure. + // + // NOTE: Each child file upload is individually finalised via + // handleFileUploadFinalization, which calls registerInteraction and deducts + // the file's bytes from the user's credit pool. By the time this function + // runs, all child credit deductions have already been committed. + // + // The folder root itself is a small IPLD directory node whose + // metadata.totalSize equals the SUM of its children's totalSize values — it + // carries no independent byte cost beyond what the children already paid for. + // Calling registerInteraction(metadata.totalSize) here would therefore + // double-charge the user for all folder content, which is incorrect. + // + // Instead we perform a guard-only check: if the balance has somehow gone + // negative (which should never happen under normal operation) we surface the + // error early rather than silently producing an inconsistent folder object. + const pendingCredits = await AccountsUseCases.getPendingCreditsByUserAndType( + user, + InteractionType.Upload, + ) + if (pendingCredits < 0) { + throw new Error('Insufficient upload credits') + } + const { metadata, childrenArtifacts } = await UploadArtifactsUseCase.generateFolderArtifacts(uploadId) diff --git a/apps/backend/src/core/users/accounts.ts b/apps/backend/src/core/users/accounts.ts index b2d43f280..139e72ede 100644 --- a/apps/backend/src/core/users/accounts.ts +++ b/apps/backend/src/core/users/accounts.ts @@ -173,10 +173,14 @@ const getPendingCreditsByAccountAndType = async ( const freeRemaining = limit - spentCredits - // Also include any active purchased credits so the upload/download gate + // For uploads, also include any active purchased credits so the upload gate // (pendingCredits < metadata.totalSize) grants access when the user has // enough purchased bytes, even if their free allocation is exhausted. // + // Download credits are not enforced right now — infrastructure exists for + // future use but purchased download bytes are not allocated on purchase and + // are not counted here. + // // getRemainingCredits is a plain DB query with no awareness of the // buyCredits feature flag. If the flag is OFF and no rows exist in // purchased_credits (because the /intents routes are gated), this returns @@ -184,15 +188,14 @@ const getPendingCreditsByAccountAndType = async ( // If credits were purchased while the flag was ON and it is later turned // OFF, those already-purchased credits remain visible and usable — this is // intentional: users should not lose credits they already paid for. - const purchased = await purchasedCreditsRepository.getRemainingCredits( - account.id, - ) - const purchasedRemaining = - type === InteractionType.Upload - ? Number(purchased.uploadBytesRemaining) - : Number(purchased.downloadBytesRemaining) + if (type === InteractionType.Upload) { + const purchased = await purchasedCreditsRepository.getRemainingCredits( + account.id, + ) + return freeRemaining + Number(purchased.uploadBytesRemaining) + } - return freeRemaining + purchasedRemaining + return freeRemaining } const getAccountInfo = async ( @@ -263,11 +266,14 @@ const registerInteraction = async ( // avoids the TOCTOU race that existed with the previous two-phase approach // where releasing FOR UPDATE locks between calls allowed concurrent requests // to consume the same credits. - const fromPurchased = await purchasedCreditsRepository.consumeUpTo( - account.id, - creditType, - size, - ) + // + // Download credits are not enforced right now — purchased bytes are not + // allocated on purchase and are not consumed here. The consumeUpTo path + // and all compensation logic below is upload-only for now. + const fromPurchased = + type === InteractionType.Upload + ? await purchasedCreditsRepository.consumeUpTo(account.id, creditType, size) + : BigInt(0) const fromFree = size - fromPurchased @@ -406,7 +412,9 @@ const addCreditsToAccount = async ( accountId: account.id, intentId, uploadBytesOriginal: credits, - downloadBytesOriginal: credits, + // Download credits are not allocated on purchase — infrastructure is + // kept for future use but download limits are not enforced right now. + downloadBytesOriginal: 0n, expiresAt, }, config.credits.maxBytesPerUser, diff --git a/apps/backend/src/core/users/credits.ts b/apps/backend/src/core/users/credits.ts new file mode 100644 index 000000000..fa10891a5 --- /dev/null +++ b/apps/backend/src/core/users/credits.ts @@ -0,0 +1,167 @@ +import { PurchasedCredit, User, UserRole, UserWithOrganization } from '@auto-drive/models' +import { + AdminCreditBatchRow, + purchasedCreditsRepository, +} from '../../infrastructure/repositories/users/purchasedCredits.js' +import { AccountsUseCases } from './accounts.js' +import { config } from '../../config.js' +import { ForbiddenError } from '../../errors/index.js' +import { err, ok, Result } from 'neverthrow' +import { hasGoogleAuth } from '../featureFlags/index.js' +import { createLogger } from '../../infrastructure/drivers/logger.js' + +const logger = createLogger('CreditsUseCases') + +// Number of days ahead to consider a batch "expiring soon" for the +// /credits/batches/expiring endpoint and the economics admin view. +const EXPIRING_WITHIN_DAYS = 30 + +// --------------------------------------------------------------------------- +// CreditSummary +// Returned by GET /credits/summary. +// --------------------------------------------------------------------------- + +export type CreditSummary = { + uploadBytesRemaining: bigint + downloadBytesRemaining: bigint + /** Soonest expires_at across active rows, or null when no active credits. */ + nextExpiryDate: Date | null + /** Number of active (non-expired) purchase rows. */ + batchCount: number + /** + * True when the user can still make a purchase without exceeding the cap. + * Cap is enforced on upload bytes only — download credits are not allocated + * on purchase right now, so only uploadBytesRemaining is checked. + */ + canPurchase: boolean + /** Maximum bytes the user could purchase right now without hitting the cap. */ + maxPurchasableBytes: bigint + /** True when the user is authenticated via Google OAuth. */ + googleVerified: boolean + /** + * Number of days after purchase before credits expire. + * Driven by the CREDIT_EXPIRY_DAYS environment variable so the frontend + * can display the correct duration without a separate API call. + */ + expiryDays: number +} + +const getSummary = async ( + user: UserWithOrganization, +): Promise => { + const account = await AccountsUseCases.getOrCreateAccount(user) + const summary = await purchasedCreditsRepository.getRemainingCredits(account.id) + + const cap = config.credits.maxBytesPerUser + + // Cap is enforced on upload bytes only. Download credits are not allocated + // on purchase right now so there is no download cap to check. + const maxPurchasableBytes = + cap > summary.uploadBytesRemaining ? cap - summary.uploadBytesRemaining : 0n + const canPurchase = maxPurchasableBytes > 0n + + return { + uploadBytesRemaining: summary.uploadBytesRemaining, + downloadBytesRemaining: summary.downloadBytesRemaining, + nextExpiryDate: summary.nextExpiryDate, + batchCount: summary.activeRowCount, + canPurchase, + maxPurchasableBytes, + googleVerified: hasGoogleAuth(user), + expiryDays: config.credits.expiryDays, + } +} + +// --------------------------------------------------------------------------- +// getBatches +// Full purchase history (including expired rows) for the authenticated user. +// Returned by GET /credits/batches. +// --------------------------------------------------------------------------- + +const getBatches = async ( + user: UserWithOrganization, +): Promise => { + const account = await AccountsUseCases.getOrCreateAccount(user) + return purchasedCreditsRepository.getByAccountId(account.id) +} + +// --------------------------------------------------------------------------- +// getExpiringBatches +// Active rows expiring within EXPIRING_WITHIN_DAYS for the authenticated user. +// Returned by GET /credits/batches/expiring. +// --------------------------------------------------------------------------- + +const getExpiringBatches = async ( + user: UserWithOrganization, +): Promise => { + const account = await AccountsUseCases.getOrCreateAccount(user) + return purchasedCreditsRepository.getExpiringCreditsByAccountId( + account.id, + EXPIRING_WITHIN_DAYS, + ) +} + +// --------------------------------------------------------------------------- +// CreditEconomics / getEconomics +// System-wide stats for admin users only. +// Returned by GET /credits/economics. +// --------------------------------------------------------------------------- + +export type CreditEconomics = { + /** Count of active rows expiring within 30 days, system-wide. */ + totalExpiringWithin30Days: number + /** Sum of upload bytes remaining across those rows. */ + totalExpiringUploadBytes: bigint + /** Sum of download bytes remaining across those rows. */ + totalExpiringDownloadBytes: bigint +} + +const getEconomics = async ( + executor: User, +): Promise> => { + if (executor.role !== UserRole.Admin) { + logger.warn('Non-admin user attempted to access credit economics', { + publicId: executor.publicId, + }) + return err(new ForbiddenError('Admin access required')) + } + + const aggregate = + await purchasedCreditsRepository.getExpiringCreditsAggregate( + EXPIRING_WITHIN_DAYS, + ) + + return ok({ + totalExpiringWithin30Days: aggregate.count, + totalExpiringUploadBytes: aggregate.totalUploadBytesRemaining, + totalExpiringDownloadBytes: aggregate.totalDownloadBytesRemaining, + }) +} + +// --------------------------------------------------------------------------- +// getAllBatches +// Admin-only: full purchase history across all users with their publicId. +// Returns 403 for non-admin callers. +// --------------------------------------------------------------------------- + +const getAllBatches = async ( + executor: User, +): Promise> => { + if (executor.role !== UserRole.Admin) { + logger.warn('Non-admin user attempted to access all credit batches', { + publicId: executor.publicId, + }) + return err(new ForbiddenError('Admin access required')) + } + + const rows = await purchasedCreditsRepository.getAllWithUserPublicId() + return ok(rows) +} + +export const CreditsUseCases = { + getSummary, + getBatches, + getExpiringBatches, + getEconomics, + getAllBatches, +} diff --git a/apps/backend/src/core/users/intents.ts b/apps/backend/src/core/users/intents.ts index a03c4ce52..0e187dc2d 100644 --- a/apps/backend/src/core/users/intents.ts +++ b/apps/backend/src/core/users/intents.ts @@ -1,8 +1,9 @@ -import { Intent, IntentStatus, User } from '@auto-drive/models' +import { Intent, IntentStatus, User, UserRole } from '@auto-drive/models' import { intentsRepository } from '../../infrastructure/repositories/users/intents.js' import { EventRouter } from '../../infrastructure/eventRouter/index.js' import { MAX_RETRIES } from '../../infrastructure/eventRouter/tasks.js' import { + ConflictError, ForbiddenError, GoneError, ObjectNotFoundError, @@ -154,8 +155,30 @@ const markIntentAsConfirmed = async ({ return err(new ObjectNotFoundError('Intent not found')) } + // Idempotency guard — do not overwrite an intent that is already in a + // post-PENDING state. Duplicate calls arise from: + // • chain reorgs causing the same event to be re-emitted + // • the payment manager reconnecting and re-processing already-seen logs + // • watchTransaction and the _checkConfirmedIntents polling loop racing + // + // We return ok() rather than an error so the caller does not treat a + // duplicate as a failure and does not retry indefinitely. + if ( + intent.status === IntentStatus.CONFIRMED || + intent.status === IntentStatus.COMPLETED || + intent.status === IntentStatus.OVER_CAP || + intent.status === IntentStatus.FAILED || + intent.status === IntentStatus.EXPIRED + ) { + logger.info('markIntentAsConfirmed: intent already processed — skipping', { + intentId, + currentStatus: intent.status, + }) + return ok(intent) + } + return ok( - intentsRepository.updateIntent({ + await intentsRepository.updateIntent({ ...intent, status: IntentStatus.CONFIRMED, paymentAmount, @@ -188,12 +211,57 @@ const onConfirmedIntent = async (intentId: string) => { return err(new Error('Intent has no deposit amount')) } + // Guard: reject payments whose value is too small to purchase even a single + // byte of storage. getIntentCredits divides paymentAmount by shannonsPerByte + // using BigInt integer division, so a dust payment (paymentAmount < + // shannonsPerByte) yields 0 credits. Granting 0 credits would mark the + // intent COMPLETED while giving the user nothing — a misleading outcome that + // wastes a DB row and silently discards the payment. + // + // Both paymentAmount and shannonsPerByte are immutable on a confirmed intent, + // so this condition is permanent. We mark the intent FAILED (terminal) so + // the polling loop stops retrying. The on-chain payment is irreversible; + // resolution requires admin review (similar to OVER_CAP handling). + const creditBytes = IntentsUseCases.getIntentCredits(intent) + if (creditBytes === BigInt(0)) { + logger.warn( + 'onConfirmedIntent: payment too small to yield any credits — marking FAILED', + { + intentId, + paymentAmount: intent.paymentAmount.toString(), + shannonsPerByte: intent.shannonsPerByte.toString(), + }, + ) + await intentsRepository.updateIntent({ + ...intent, + status: IntentStatus.FAILED, + }) + return ok() + } + const addResult = await AccountsUseCases.addCreditsToAccount( intent.userPublicId, - IntentsUseCases.getIntentCredits(intent), + creditBytes, intentId, ) + if (addResult.isErr()) { + if (addResult.error instanceof ForbiddenError) { + // The user's purchased credit balance is at or above the per-user cap. + // Mark the intent OVER_CAP (terminal) so the polling loop stops retrying + // and an admin can review. The payment is on-chain; resolution requires + // a manual decision (adjust cap + reprocess, or arrange a refund). + logger.warn('Intent blocked by per-user cap — marking OVER_CAP', { + intentId, + userPublicId: intent.userPublicId, + paymentAmount: intent.paymentAmount.toString(), + }) + await intentsRepository.updateIntent({ + ...intent, + status: IntentStatus.OVER_CAP, + }) + return ok() + } return err(addResult.error) } @@ -209,6 +277,57 @@ const getConfirmedIntents = async () => { return intentsRepository.getByStatus(IntentStatus.CONFIRMED) } +// Returns all intents stuck in OVER_CAP for admin review. +// Only accessible to admin users — returns ForbiddenError for everyone else. +const getOverCapIntents = async (executor: User) => { + if (executor.role !== UserRole.Admin) { + return err(new ForbiddenError('Admin access required')) + } + const intents = await intentsRepository.getOverCapIntents() + return ok(intents) +} + +// Resets an OVER_CAP intent back to CONFIRMED so the payment manager polling +// loop will attempt to grant credits on its next tick. +// +// Intended admin workflow: +// 1. Admin calls POST /accounts/update to raise the user's credit cap. +// 2. Admin calls POST /intents/:id/reprocess to re-queue this intent. +// 3. The polling loop picks it up within 30 seconds and calls onConfirmedIntent. +// +// Returns ConflictError if the intent is not in OVER_CAP status — this guards +// against accidentally re-queuing an already COMPLETED or PENDING intent. +const reprocessOverCapIntent = async (executor: User, intentId: string) => { + if (executor.role !== UserRole.Admin) { + return err(new ForbiddenError('Admin access required')) + } + + const intent = await intentsRepository.getById(intentId) + if (!intent) { + return err(new ObjectNotFoundError('Intent not found')) + } + + if (intent.status !== IntentStatus.OVER_CAP) { + return err( + new ConflictError( + `Intent is not in OVER_CAP status (current: ${intent.status})`, + ), + ) + } + + await intentsRepository.updateIntent({ + ...intent, + status: IntentStatus.CONFIRMED, + }) + + logger.info('Admin requeued OVER_CAP intent for reprocessing', { + intentId, + adminPublicId: executor.publicId, + }) + + return ok() +} + // Marks all PENDING intents whose price-lock window has expired. // Called periodically by the background job so that stale PENDING rows do not // accumulate. CONFIRMED intents are not touched — once payment is confirmed @@ -257,6 +376,13 @@ const getPrice = async (): Promise<{ price: number; pricePerGB: number }> => { } } +// Returns PENDING intents that already have a tx_hash — used by the payment +// manager startup sweep to re-watch transactions that were submitted but never +// confirmed due to a service restart or RPC outage. +const getPendingWithTxHash = async (): Promise => { + return intentsRepository.getPendingWithTxHash() +} + export const IntentsUseCases = { createIntent, getIntent, @@ -265,6 +391,9 @@ export const IntentsUseCases = { onConfirmedIntent, markIntentAsConfirmed, getConfirmedIntents, + getOverCapIntents, + getPendingWithTxHash, + reprocessOverCapIntent, getIntentCredits, getPrice, cleanupExpiredIntents, diff --git a/apps/backend/src/errors/index.ts b/apps/backend/src/errors/index.ts index 344a445b5..e65d9bb51 100644 --- a/apps/backend/src/errors/index.ts +++ b/apps/backend/src/errors/index.ts @@ -74,6 +74,32 @@ export class ForbiddenError extends HttpError { } } +export class NotFoundError extends HttpError { + static readonly statusCode = 404 + constructor(message: string) { + super(NotFoundError.statusCode, message) + this.name = 'NotFoundError' + } +} + +export class BadRequestError extends HttpError { + static readonly statusCode = 400 + constructor(message: string) { + super(BadRequestError.statusCode, message) + this.name = 'BadRequestError' + } +} + +// 409 Conflict — request is valid but the resource is in the wrong state for +// the operation (e.g. trying to reprocess an intent that is not OVER_CAP). +export class ConflictError extends HttpError { + static readonly statusCode = 409 + constructor(message: string) { + super(ConflictError.statusCode, message) + this.name = 'ConflictError' + } +} + // 410 Gone — resource existed but is no longer available (e.g. expired intent). export class GoneError extends HttpError { static readonly statusCode = 410 diff --git a/apps/backend/src/infrastructure/repositories/banners.ts b/apps/backend/src/infrastructure/repositories/banners.ts new file mode 100644 index 000000000..197708641 --- /dev/null +++ b/apps/backend/src/infrastructure/repositories/banners.ts @@ -0,0 +1,297 @@ +import { + Banner, + BannerInteraction, + BannerInteractionType, + BannerCriticality, + BannerWithStats, +} from '@auto-drive/models' +import { getDatabase } from '../drivers/pg.js' + +// --------------------------------------------------------------------------- +// Internal DB row types +// --------------------------------------------------------------------------- + +type DBBanner = { + id: string + title: string + body: string + criticality: string + dismissable: boolean + requires_acknowledgement: boolean + display_start: Date + display_end: Date | null + active: boolean + created_by: string + created_at: Date + updated_at: Date +} + +type DBBannerInteraction = { + id: string + user_id: string + banner_id: string + interaction_type: string + created_at: Date +} + +const mapBannerRow = (row: DBBanner): Banner => ({ + id: row.id, + title: row.title, + body: row.body, + criticality: row.criticality as BannerCriticality, + dismissable: row.dismissable, + requiresAcknowledgement: row.requires_acknowledgement, + displayStart: row.display_start, + displayEnd: row.display_end, + active: row.active, + createdBy: row.created_by, + createdAt: row.created_at, + updatedAt: row.updated_at, +}) + +const mapInteractionRow = (row: DBBannerInteraction): BannerInteraction => ({ + id: row.id, + userId: row.user_id, + bannerId: row.banner_id, + interactionType: row.interaction_type as BannerInteractionType, + createdAt: row.created_at, +}) + +// --------------------------------------------------------------------------- +// createBanner +// --------------------------------------------------------------------------- + +type CreateBannerParams = { + title: string + body: string + criticality: BannerCriticality + dismissable: boolean + requiresAcknowledgement: boolean + displayStart: Date + displayEnd: Date | null + active: boolean + createdBy: string +} + +const createBanner = async (params: CreateBannerParams): Promise => { + const db = await getDatabase() + const result = await db.query( + `INSERT INTO banners ( + title, body, criticality, dismissable, requires_acknowledgement, + display_start, display_end, active, created_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING *`, + [ + params.title, + params.body, + params.criticality, + params.dismissable, + params.requiresAcknowledgement, + params.displayStart, + params.displayEnd, + params.active, + params.createdBy, + ], + ) + return mapBannerRow(result.rows[0]) +} + +// --------------------------------------------------------------------------- +// updateBanner +// --------------------------------------------------------------------------- + +type UpdateBannerParams = { + title?: string + body?: string + criticality?: BannerCriticality + dismissable?: boolean + requiresAcknowledgement?: boolean + displayStart?: Date + displayEnd?: Date | null + active?: boolean +} + +const updateBanner = async ( + id: string, + params: UpdateBannerParams, +): Promise => { + const fields: string[] = [] + const values: unknown[] = [] + let idx = 1 + + if (params.title !== undefined) { + fields.push(`title = $${idx++}`) + values.push(params.title) + } + if (params.body !== undefined) { + fields.push(`body = $${idx++}`) + values.push(params.body) + } + if (params.criticality !== undefined) { + fields.push(`criticality = $${idx++}`) + values.push(params.criticality) + } + if (params.dismissable !== undefined) { + fields.push(`dismissable = $${idx++}`) + values.push(params.dismissable) + } + if (params.requiresAcknowledgement !== undefined) { + fields.push(`requires_acknowledgement = $${idx++}`) + values.push(params.requiresAcknowledgement) + } + if (params.displayStart !== undefined) { + fields.push(`display_start = $${idx++}`) + values.push(params.displayStart) + } + if (params.displayEnd !== undefined) { + fields.push(`display_end = $${idx++}`) + values.push(params.displayEnd) + } + if (params.active !== undefined) { + fields.push(`active = $${idx++}`) + values.push(params.active) + } + + if (fields.length === 0) return null + + fields.push('updated_at = NOW()') + values.push(id) + + const db = await getDatabase() + const result = await db.query( + `UPDATE banners SET ${fields.join(', ')} WHERE id = $${idx} RETURNING *`, + values, + ) + + return result.rows[0] ? mapBannerRow(result.rows[0]) : null +} + +// --------------------------------------------------------------------------- +// getBannerById +// --------------------------------------------------------------------------- + +const getBannerById = async (id: string): Promise => { + const db = await getDatabase() + const result = await db.query( + 'SELECT * FROM banners WHERE id = $1', + [id], + ) + return result.rows[0] ? mapBannerRow(result.rows[0]) : null +} + +// --------------------------------------------------------------------------- +// getAllBanners — admin listing +// --------------------------------------------------------------------------- + +const getAllBanners = async (): Promise => { + const db = await getDatabase() + const result = await db.query( + 'SELECT * FROM banners ORDER BY created_at DESC', + ) + return result.rows.map(mapBannerRow) +} + +// --------------------------------------------------------------------------- +// getActiveBannersForUser +// Returns currently active banners that the user has not yet interacted with. +// --------------------------------------------------------------------------- + +const getActiveBannersForUser = async ( + userId: string, +): Promise => { + const db = await getDatabase() + const result = await db.query( + `SELECT b.* FROM banners b + LEFT JOIN banner_interactions bi + ON bi.banner_id = b.id AND bi.user_id = $1 + WHERE b.active = true + AND b.display_start <= NOW() + AND (b.display_end IS NULL OR b.display_end >= NOW()) + AND bi.id IS NULL + ORDER BY + CASE b.criticality + WHEN 'critical' THEN 0 + WHEN 'warning' THEN 1 + WHEN 'info' THEN 2 + END`, + [userId], + ) + return result.rows.map(mapBannerRow) +} + +// --------------------------------------------------------------------------- +// createInteraction — idempotent via ON CONFLICT DO NOTHING +// --------------------------------------------------------------------------- + +const createInteraction = async ( + userId: string, + bannerId: string, + interactionType: BannerInteractionType, +): Promise => { + const db = await getDatabase() + const result = await db.query( + `INSERT INTO banner_interactions (user_id, banner_id, interaction_type) + VALUES ($1, $2, $3) + ON CONFLICT (user_id, banner_id, interaction_type) DO NOTHING + RETURNING *`, + [userId, bannerId, interactionType], + ) + return result.rows[0] ? mapInteractionRow(result.rows[0]) : null +} + +// --------------------------------------------------------------------------- +// getBannerWithStats — banner + interaction counts for admin view +// --------------------------------------------------------------------------- + +const getBannerWithStats = async ( + id: string, +): Promise => { + const db = await getDatabase() + + const bannerResult = await db.query( + 'SELECT * FROM banners WHERE id = $1', + [id], + ) + if (!bannerResult.rows[0]) return null + + const statsResult = await db.query<{ + interaction_type: string + count: string + }>( + `SELECT interaction_type, COUNT(*) as count + FROM banner_interactions + WHERE banner_id = $1 + GROUP BY interaction_type`, + [id], + ) + + let acknowledgementCount = 0 + let dismissalCount = 0 + for (const row of statsResult.rows) { + if (row.interaction_type === 'acknowledged') { + acknowledgementCount = Number(row.count) + } else if (row.interaction_type === 'dismissed') { + dismissalCount = Number(row.count) + } + } + + return { + ...mapBannerRow(bannerResult.rows[0]), + acknowledgementCount, + dismissalCount, + } +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +export const bannersRepository = { + createBanner, + updateBanner, + getBannerById, + getAllBanners, + getActiveBannersForUser, + createInteraction, + getBannerWithStats, +} diff --git a/apps/backend/src/infrastructure/repositories/tou.ts b/apps/backend/src/infrastructure/repositories/tou.ts new file mode 100644 index 000000000..04d88772b --- /dev/null +++ b/apps/backend/src/infrastructure/repositories/tou.ts @@ -0,0 +1,367 @@ +import { + TouVersion, + TouAcceptance, + TouChangeType, + TouVersionStatus, +} from '@auto-drive/models' +import { getDatabase } from '../drivers/pg.js' + +// --------------------------------------------------------------------------- +// Internal DB row types +// --------------------------------------------------------------------------- + +type DBTouVersion = { + id: string + version_label: string + effective_date: Date + content_url: string + change_type: string + status: string + admin_notes: string | null + created_by: string + created_at: Date + updated_at: Date +} + +type DBTouAcceptance = { + id: string + user_id: string + version_id: string + ip_address: string | null + accepted_at: Date +} + +// --------------------------------------------------------------------------- +// Mappers +// --------------------------------------------------------------------------- + +const mapVersionRow = (row: DBTouVersion): TouVersion => ({ + id: row.id, + versionLabel: row.version_label, + effectiveDate: row.effective_date, + contentUrl: row.content_url, + changeType: row.change_type as TouChangeType, + status: row.status as TouVersionStatus, + adminNotes: row.admin_notes, + createdBy: row.created_by, + createdAt: row.created_at, + updatedAt: row.updated_at, +}) + +const mapAcceptanceRow = (row: DBTouAcceptance): TouAcceptance => ({ + id: row.id, + userId: row.user_id, + versionId: row.version_id, + ipAddress: row.ip_address, + acceptedAt: row.accepted_at, +}) + +// --------------------------------------------------------------------------- +// ensureActiveVersion — lazy activation +// If a pending version has reached its effective date, atomically promote it +// to active and archive the previous active version. +// --------------------------------------------------------------------------- + +const ensureActiveVersion = async (): Promise => { + const pool = await getDatabase() + const client = await pool.connect() + try { + await client.query('BEGIN') + + const pendingResult = await client.query( + `SELECT * FROM tou_versions + WHERE status = 'pending' AND effective_date <= NOW() + ORDER BY effective_date ASC + LIMIT 1`, + ) + + if (pendingResult.rows[0]) { + await client.query( + `UPDATE tou_versions SET status = 'archived', updated_at = NOW() + WHERE status = 'active'`, + ) + + const promoted = await client.query( + `UPDATE tou_versions SET status = 'active', updated_at = NOW() + WHERE id = $1 RETURNING *`, + [pendingResult.rows[0].id], + ) + + await client.query('COMMIT') + return mapVersionRow(promoted.rows[0]) + } + + await client.query('COMMIT') + + const activeResult = await client.query( + 'SELECT * FROM tou_versions WHERE status = \'active\' LIMIT 1', + ) + return activeResult.rows[0] ? mapVersionRow(activeResult.rows[0]) : null + } catch (e) { + await client.query('ROLLBACK') + throw e + } finally { + client.release() + } +} + +// --------------------------------------------------------------------------- +// getPendingVersion +// --------------------------------------------------------------------------- + +const getPendingVersion = async (): Promise => { + const db = await getDatabase() + const result = await db.query( + 'SELECT * FROM tou_versions WHERE status = \'pending\' LIMIT 1', + ) + return result.rows[0] ? mapVersionRow(result.rows[0]) : null +} + +// --------------------------------------------------------------------------- +// getVersionById +// --------------------------------------------------------------------------- + +const getVersionById = async (id: string): Promise => { + const db = await getDatabase() + const result = await db.query( + 'SELECT * FROM tou_versions WHERE id = $1', + [id], + ) + return result.rows[0] ? mapVersionRow(result.rows[0]) : null +} + +// --------------------------------------------------------------------------- +// getAllVersions — admin listing +// --------------------------------------------------------------------------- + +const getAllVersions = async (): Promise => { + const db = await getDatabase() + const result = await db.query( + 'SELECT * FROM tou_versions ORDER BY created_at DESC', + ) + return result.rows.map(mapVersionRow) +} + +// --------------------------------------------------------------------------- +// createVersion +// --------------------------------------------------------------------------- + +type CreateVersionParams = { + versionLabel: string + effectiveDate: Date + contentUrl: string + changeType: TouChangeType + adminNotes: string | null + createdBy: string +} + +const createVersion = async ( + params: CreateVersionParams, +): Promise => { + const db = await getDatabase() + const result = await db.query( + `INSERT INTO tou_versions ( + version_label, effective_date, content_url, change_type, admin_notes, created_by + ) VALUES ($1, $2, $3, $4, $5, $6) + RETURNING *`, + [ + params.versionLabel, + params.effectiveDate, + params.contentUrl, + params.changeType, + params.adminNotes, + params.createdBy, + ], + ) + return mapVersionRow(result.rows[0]) +} + +// --------------------------------------------------------------------------- +// updateVersion — partial update for draft versions +// --------------------------------------------------------------------------- + +type UpdateVersionParams = { + versionLabel?: string + effectiveDate?: Date + contentUrl?: string + changeType?: TouChangeType + adminNotes?: string | null +} + +const updateVersion = async ( + id: string, + params: UpdateVersionParams, +): Promise => { + const fields: string[] = [] + const values: unknown[] = [] + let idx = 1 + + if (params.versionLabel !== undefined) { + fields.push(`version_label = $${idx++}`) + values.push(params.versionLabel) + } + if (params.effectiveDate !== undefined) { + fields.push(`effective_date = $${idx++}`) + values.push(params.effectiveDate) + } + if (params.contentUrl !== undefined) { + fields.push(`content_url = $${idx++}`) + values.push(params.contentUrl) + } + if (params.changeType !== undefined) { + fields.push(`change_type = $${idx++}`) + values.push(params.changeType) + } + if (params.adminNotes !== undefined) { + fields.push(`admin_notes = $${idx++}`) + values.push(params.adminNotes) + } + + if (fields.length === 0) return null + + fields.push('updated_at = NOW()') + values.push(id) + + const db = await getDatabase() + const result = await db.query( + `UPDATE tou_versions SET ${fields.join(', ')} WHERE id = $${idx} RETURNING *`, + values, + ) + + return result.rows[0] ? mapVersionRow(result.rows[0]) : null +} + +// --------------------------------------------------------------------------- +// updateVersionStatus +// --------------------------------------------------------------------------- + +const updateVersionStatus = async ( + id: string, + status: TouVersionStatus, +): Promise => { + const db = await getDatabase() + const result = await db.query( + `UPDATE tou_versions SET status = $1, updated_at = NOW() + WHERE id = $2 RETURNING *`, + [status, id], + ) + return result.rows[0] ? mapVersionRow(result.rows[0]) : null +} + +// --------------------------------------------------------------------------- +// activateVersionTransactional — atomically archive current active + activate +// --------------------------------------------------------------------------- + +const activateVersionTransactional = async ( + id: string, +): Promise => { + const pool = await getDatabase() + const client = await pool.connect() + try { + await client.query('BEGIN') + + await client.query( + `UPDATE tou_versions SET status = 'archived', updated_at = NOW() + WHERE status = 'active'`, + ) + + const result = await client.query( + `UPDATE tou_versions SET status = 'active', updated_at = NOW() + WHERE id = $1 RETURNING *`, + [id], + ) + + await client.query('COMMIT') + return result.rows[0] ? mapVersionRow(result.rows[0]) : null + } catch (e) { + await client.query('ROLLBACK') + throw e + } finally { + client.release() + } +} + +// --------------------------------------------------------------------------- +// hasUserAcceptedVersion +// --------------------------------------------------------------------------- + +const hasUserAcceptedVersion = async ( + userId: string, + versionId: string, +): Promise => { + const db = await getDatabase() + const result = await db.query<{ exists: boolean }>( + `SELECT EXISTS( + SELECT 1 FROM user_tou_acceptance + WHERE user_id = $1 AND version_id = $2 + ) AS exists`, + [userId, versionId], + ) + return result.rows[0].exists +} + +// --------------------------------------------------------------------------- +// createAcceptance — idempotent via ON CONFLICT DO NOTHING +// --------------------------------------------------------------------------- + +const createAcceptance = async ( + userId: string, + versionId: string, + ipAddress: string | null, +): Promise => { + const db = await getDatabase() + const result = await db.query( + `INSERT INTO user_tou_acceptance (user_id, version_id, ip_address) + VALUES ($1, $2, $3) + ON CONFLICT (user_id, version_id) DO NOTHING + RETURNING *`, + [userId, versionId, ipAddress], + ) + return result.rows[0] ? mapAcceptanceRow(result.rows[0]) : null +} + +// --------------------------------------------------------------------------- +// getVersionWithStats — version + acceptance count +// Note: totalActiveUsers is provided by the core layer via the auth service, +// since the backend DB does not have a users table. +// --------------------------------------------------------------------------- + +const getVersionWithStats = async ( + id: string, +): Promise<(TouVersion & { acceptanceCount: number }) | null> => { + const db = await getDatabase() + + const versionResult = await db.query( + 'SELECT * FROM tou_versions WHERE id = $1', + [id], + ) + if (!versionResult.rows[0]) return null + + const acceptanceResult = await db.query<{ count: string }>( + 'SELECT COUNT(*) as count FROM user_tou_acceptance WHERE version_id = $1', + [id], + ) + + return { + ...mapVersionRow(versionResult.rows[0]), + acceptanceCount: Number(acceptanceResult.rows[0].count), + } +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +export const touRepository = { + ensureActiveVersion, + getPendingVersion, + getVersionById, + getAllVersions, + createVersion, + updateVersion, + updateVersionStatus, + activateVersionTransactional, + hasUserAcceptedVersion, + createAcceptance, + getVersionWithStats, +} diff --git a/apps/backend/src/infrastructure/repositories/users/intents.ts b/apps/backend/src/infrastructure/repositories/users/intents.ts index f1ab42cab..8874cd0b1 100644 --- a/apps/backend/src/infrastructure/repositories/users/intents.ts +++ b/apps/backend/src/infrastructure/repositories/users/intents.ts @@ -117,6 +117,34 @@ const expireIntentIfPending = async (intentId: string): Promise => { return (result.rowCount ?? 0) > 0 } +// Returns PENDING intents that already have an on-chain tx_hash. +// These are intents where the user submitted a transaction but the payment +// manager did not process the confirmation event — typically because the +// service was restarted or the EVM RPC was temporarily unavailable. +// Used by the startup recovery sweep so that no paid transaction is silently +// abandoned across a service restart. +const getPendingWithTxHash = async (): Promise => { + const db = await getDatabase() + const result = await db.query( + `SELECT * FROM intents + WHERE status = $1 + AND tx_hash IS NOT NULL`, + [IntentStatus.PENDING], + ) + return mapRows(result.rows) +} + +// Returns all intents that were blocked by the per-user cap. +// These are terminal — the polling loop skips them — and require admin review. +const getOverCapIntents = async (): Promise => { + const db = await getDatabase() + const result = await db.query( + 'SELECT * FROM intents WHERE status = $1 ORDER BY id', + [IntentStatus.OVER_CAP], + ) + return mapRows(result.rows) +} + export const intentsRepository = { getById, createIntent, @@ -124,4 +152,6 @@ export const intentsRepository = { getByStatus, getExpiredPendingIntents, expireIntentIfPending, + getOverCapIntents, + getPendingWithTxHash, } diff --git a/apps/backend/src/infrastructure/repositories/users/purchasedCredits.ts b/apps/backend/src/infrastructure/repositories/users/purchasedCredits.ts index 1bcd2ee69..603e20939 100644 --- a/apps/backend/src/infrastructure/repositories/users/purchasedCredits.ts +++ b/apps/backend/src/infrastructure/repositories/users/purchasedCredits.ts @@ -222,7 +222,8 @@ const getRemainingCredits = async ( // --------------------------------------------------------------------------- // getExpiringCredits -// Returns active rows expiring within N days. Used by expiry warning banners. +// Returns active rows expiring within N days. System-wide — no account filter. +// Used by the admin /credits/economics endpoint for system-level monitoring. // --------------------------------------------------------------------------- const getExpiringCredits = async ( @@ -242,6 +243,72 @@ const getExpiringCredits = async ( return result.rows.map(mapRow) } +// --------------------------------------------------------------------------- +// getExpiringCreditsAggregate +// Single-query aggregate: count + byte sums for system-wide expiring credits. +// Avoids transferring all rows into Node.js memory. +// --------------------------------------------------------------------------- + +export type ExpiringCreditsAggregate = { + count: number + totalUploadBytesRemaining: bigint + totalDownloadBytesRemaining: bigint +} + +const getExpiringCreditsAggregate = async ( + withinDays: number, +): Promise => { + const db = await getDatabase() + const result = await db.query<{ + count: string + total_upload: string + total_download: string + }>( + `SELECT + COUNT(*) AS count, + COALESCE(SUM(upload_bytes_remaining), 0) AS total_upload, + COALESCE(SUM(download_bytes_remaining), 0) AS total_download + FROM purchased_credits + WHERE expired = FALSE + AND expires_at > NOW() + AND expires_at <= NOW() + ($1 * INTERVAL '1 day') + AND (upload_bytes_remaining > 0 OR download_bytes_remaining > 0)`, + [withinDays], + ) + + const row = result.rows[0] + return { + count: Number(row.count), + totalUploadBytesRemaining: BigInt(row.total_upload), + totalDownloadBytesRemaining: BigInt(row.total_download), + } +} + +// --------------------------------------------------------------------------- +// getExpiringCreditsByAccountId +// Returns active rows for a specific account expiring within N days. +// Used by the per-user /credits/batches/expiring endpoint. +// --------------------------------------------------------------------------- + +const getExpiringCreditsByAccountId = async ( + accountId: string, + withinDays: number, +): Promise => { + const db = await getDatabase() + const result = await db.query( + `SELECT * + FROM purchased_credits + WHERE account_id = $1 + AND expired = FALSE + AND expires_at > NOW() + AND expires_at <= NOW() + ($2 * INTERVAL '1 day') + AND (upload_bytes_remaining > 0 OR download_bytes_remaining > 0) + ORDER BY expires_at ASC`, + [accountId, withinDays], + ) + return result.rows.map(mapRow) +} + // --------------------------------------------------------------------------- // markExpiredCredits // Called by the credit expiry background job (to be added in a later PR). @@ -399,7 +466,10 @@ const refundCredits = async ( // Atomically checks the per-user cap and inserts a new credit row in a // single transaction, using a PostgreSQL advisory lock keyed to the account // to serialize concurrent calls. Returns err('cap_exceeded') if the purchase -// would push either upload or download remaining over the cap. +// would push upload remaining over the cap. +// Download bytes are not capped — they are not allocated on purchase right +// now (downloadBytesOriginal is expected to be 0n). Infrastructure is kept +// for future use. // --------------------------------------------------------------------------- const createPurchasedCreditWithCapCheck = async ( @@ -437,12 +507,8 @@ const createPurchasedCreditWithCapCheck = async ( const currentRow = currentResult.rows[0] const currentUpload = BigInt(currentRow.upload_bytes_remaining) - const currentDownload = BigInt(currentRow.download_bytes_remaining) - if ( - currentUpload + params.uploadBytesOriginal > maxBytesPerUser || - currentDownload + params.downloadBytesOriginal > maxBytesPerUser - ) { + if (currentUpload + params.uploadBytesOriginal > maxBytesPerUser) { await client.query('ROLLBACK') return err('cap_exceeded' as const) } @@ -481,6 +547,30 @@ const createPurchasedCreditWithCapCheck = async ( } } +// --------------------------------------------------------------------------- +// getAllWithUserPublicId +// Admin view: every credit batch across all users, joined with the +// user_public_id from the originating intent row. Ordered newest-first. +// --------------------------------------------------------------------------- + +export type AdminCreditBatchRow = PurchasedCredit & { + userPublicId: string +} + +const getAllWithUserPublicId = async (): Promise => { + const db = await getDatabase() + const result = await db.query( + `SELECT pc.*, i.user_public_id + FROM purchased_credits pc + JOIN intents i ON i.id = pc.intent_id + ORDER BY pc.purchased_at DESC`, + ) + return result.rows.map((row) => ({ + ...mapRow(row), + userPublicId: row.user_public_id, + })) +} + // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- @@ -492,7 +582,10 @@ export const purchasedCreditsRepository = { refundCredits, getRemainingCredits, getExpiringCredits, + getExpiringCreditsAggregate, + getExpiringCreditsByAccountId, createPurchasedCreditWithCapCheck, markExpiredCredits, getByAccountId, + getAllWithUserPublicId, } diff --git a/apps/backend/src/infrastructure/services/paymentManager/index.ts b/apps/backend/src/infrastructure/services/paymentManager/index.ts index 9bc2c9653..0cce7f9d8 100644 --- a/apps/backend/src/infrastructure/services/paymentManager/index.ts +++ b/apps/backend/src/infrastructure/services/paymentManager/index.ts @@ -122,8 +122,56 @@ const parseEventLogs = < let checkInterval: NodeJS.Timeout | null = null let unwatchContractEvent: (() => void) | null = null +// On startup, re-watch any PENDING intents that already have a tx_hash. +// These represent transactions submitted by users before the last service +// restart or during an EVM RPC outage. The cleanup job explicitly skips +// PENDING+txHash rows (they are not abandoned — they are actively watched), +// so without this sweep they would sit in limbo indefinitely: the user paid +// on-chain but receives no credits. +// +// Re-calling watchTransaction for each orphan is safe: +// • waitForTransactionReceipt returns immediately for already-mined txs +// • markIntentAsConfirmed is idempotent — a duplicate CONFIRMED write is a +// no-op if the intent was already processed before the restart +const _recoverOrphanedTransactions = async () => { + const pending = await IntentsUseCases.getPendingWithTxHash() + if (pending.length === 0) { + logger.info('Startup recovery: no orphaned transactions found') + return + } + + logger.info('Startup recovery: re-watching orphaned transactions', { + count: pending.length, + intentIds: pending.map((i) => i.id), + }) + + await Promise.allSettled( + pending.map(async (intent) => { + if (!intent.txHash) return + try { + await paymentManager.watchTransaction(intent.txHash) + logger.info('Startup recovery: transaction recovered', { + intentId: intent.id, + txHash: intent.txHash, + }) + } catch (err) { + logger.error('Startup recovery: failed to recover transaction', { + intentId: intent.id, + txHash: intent.txHash, + err, + }) + } + }), + ) +} + const start = () => { logger.info('Starting payment manager') + + // Run the recovery sweep asynchronously so it does not block startup. + // Errors inside the sweep are caught per-intent and logged individually. + safeCallback(paymentManager._recoverOrphanedTransactions)() + checkInterval = setInterval( safeCallback(paymentManager._checkConfirmedIntents), config.paymentManager.checkInterval, @@ -154,6 +202,7 @@ export const paymentManager = { stop, _onLogs: onLogs, _checkConfirmedIntents: _checkConfirmedIntents, + _recoverOrphanedTransactions: _recoverOrphanedTransactions, _viemClient: viemClient, _parseEventLogs: parseEventLogs, } diff --git a/apps/frontend/__tests__/unit/hooks/useTransactionConfirmation.spec.ts b/apps/frontend/__tests__/unit/hooks/useTransactionConfirmation.spec.ts new file mode 100644 index 000000000..91de94c79 --- /dev/null +++ b/apps/frontend/__tests__/unit/hooks/useTransactionConfirmation.spec.ts @@ -0,0 +1,141 @@ +/** + * Unit tests for the intent-polling decision logic extracted from + * useTransactionConfirmation. These tests verify the correct terminal-state + * handling for completed, over_cap, and expired intents without requiring a + * React rendering environment. + * + * IMPORTANT: The backend returns HTTP 410 Gone for expired intents instead of + * an `{ status: 'expired' }` response body. The `getIntent` call throws an + * `ApiError(410, …)` before the caller ever sees a status string. The polling + * loop detects this via the catch block, not via `intent.status === 'expired'`. + */ + +// --------------------------------------------------------------------------- +// Minimal ApiError replica (mirrors apps/frontend/src/services/api.ts) +// --------------------------------------------------------------------------- + +class ApiError extends Error { + constructor( + public readonly status: number, + message: string, + ) { + super(message) + this.name = 'ApiError' + } +} + +// --------------------------------------------------------------------------- +// Polling decision logic (mirrors the try/catch in the `poll` callback) +// --------------------------------------------------------------------------- + +type IntentStatus = 'pending' | 'confirmed' | 'completed' | 'failed' | 'over_cap' + +interface PollResult { + completed: boolean + overCap: boolean + expired: boolean + shouldContinue: boolean +} + +/** + * Mirrors the try-branch: evaluates the status string returned in the + * response body when the API call succeeds (HTTP 2xx). + */ +function evaluateIntentStatus(status: IntentStatus): PollResult { + if (status === 'completed') { + return { completed: true, overCap: false, expired: false, shouldContinue: false } + } + if (status === 'over_cap') { + return { completed: false, overCap: true, expired: false, shouldContinue: false } + } + return { completed: false, overCap: false, expired: false, shouldContinue: true } +} + +/** + * Mirrors the catch-branch: evaluates the thrown error. The backend returns + * HTTP 410 for expired intents, so this is the only path through which + * `expired` can become true. + */ +function evaluatePollError(error: unknown): PollResult { + if (error instanceof ApiError && error.status === 410) { + return { completed: false, overCap: false, expired: true, shouldContinue: false } + } + return { completed: false, overCap: false, expired: false, shouldContinue: true } +} + +// --------------------------------------------------------------------------- +// Tests — successful response branch (try) +// --------------------------------------------------------------------------- + +describe('evaluateIntentStatus (try branch)', () => { + it('marks completed and stops polling for "completed"', () => { + const result = evaluateIntentStatus('completed') + expect(result.completed).toBe(true) + expect(result.overCap).toBe(false) + expect(result.expired).toBe(false) + expect(result.shouldContinue).toBe(false) + }) + + it('marks overCap and stops polling for "over_cap"', () => { + const result = evaluateIntentStatus('over_cap') + expect(result.completed).toBe(false) + expect(result.overCap).toBe(true) + expect(result.expired).toBe(false) + expect(result.shouldContinue).toBe(false) + }) + + it('continues polling for "pending"', () => { + const result = evaluateIntentStatus('pending') + expect(result).toEqual({ completed: false, overCap: false, expired: false, shouldContinue: true }) + }) + + it('continues polling for "confirmed"', () => { + const result = evaluateIntentStatus('confirmed') + expect(result).toEqual({ completed: false, overCap: false, expired: false, shouldContinue: true }) + }) + + it('continues polling for "failed"', () => { + const result = evaluateIntentStatus('failed') + expect(result).toEqual({ completed: false, overCap: false, expired: false, shouldContinue: true }) + }) + + it('over_cap and completed are mutually exclusive', () => { + expect(evaluateIntentStatus('over_cap').completed).toBe(false) + expect(evaluateIntentStatus('completed').overCap).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// Tests — error branch (catch) +// --------------------------------------------------------------------------- + +describe('evaluatePollError (catch branch — expired intent detection)', () => { + it('marks expired and stops polling on ApiError with status 410', () => { + const error = new ApiError(410, 'Intent has expired') + const result = evaluatePollError(error) + expect(result.expired).toBe(true) + expect(result.completed).toBe(false) + expect(result.overCap).toBe(false) + expect(result.shouldContinue).toBe(false) + }) + + it('continues polling on ApiError with non-410 status (e.g. 500)', () => { + const error = new ApiError(500, 'Internal server error') + const result = evaluatePollError(error) + expect(result.expired).toBe(false) + expect(result.shouldContinue).toBe(true) + }) + + it('continues polling on generic Error (network failure, etc.)', () => { + const error = new Error('fetch failed') + const result = evaluatePollError(error) + expect(result.expired).toBe(false) + expect(result.shouldContinue).toBe(true) + }) + + it('continues polling on non-Error thrown value', () => { + const result = evaluatePollError('unexpected string') + expect(result.expired).toBe(false) + expect(result.shouldContinue).toBe(true) + }) +}) diff --git a/apps/frontend/__tests__/unit/utils/credits.spec.ts b/apps/frontend/__tests__/unit/utils/credits.spec.ts new file mode 100644 index 000000000..ed6c7ee0b --- /dev/null +++ b/apps/frontend/__tests__/unit/utils/credits.spec.ts @@ -0,0 +1,189 @@ +import { isPackageOverCap, daysUntilExpiry, sumExpiringUploadBytes, getBatchStatus } from '../../../src/utils/credits' + +// --------------------------------------------------------------------------- +// isPackageOverCap +// --------------------------------------------------------------------------- + +describe('isPackageOverCap', () => { + it('returns false when maxPurchasableBytes is null (cap not loaded)', () => { + expect(isPackageOverCap(100, null)).toBe(false) + }) + + it('returns false when creditsInMB is undefined', () => { + expect(isPackageOverCap(undefined, 1000n)).toBe(false) + }) + + it('returns false when package fits within the remaining cap', () => { + // 10 MB package, 20 MB remaining cap + const maxBytes = BigInt(20 * 1024 * 1024) + expect(isPackageOverCap(10, maxBytes)).toBe(false) + }) + + it('returns false when package exactly equals the remaining cap', () => { + const maxBytes = BigInt(10 * 1024 * 1024) + expect(isPackageOverCap(10, maxBytes)).toBe(false) + }) + + it('returns true when package exceeds the remaining cap by 1 byte', () => { + // cap is 1 byte short of 10 MB + const maxBytes = BigInt(10 * 1024 * 1024) - 1n + expect(isPackageOverCap(10, maxBytes)).toBe(true) + }) + + it('returns true when the remaining cap is zero', () => { + expect(isPackageOverCap(10, 0n)).toBe(true) + }) + + it('handles large enterprise package (1 GB)', () => { + // 500 MB remaining — 1 GB package should be over cap + const maxBytes = BigInt(500 * 1024 * 1024) + expect(isPackageOverCap(1024, maxBytes)).toBe(true) + }) + + it('handles large enterprise package within generous cap', () => { + // 2 GB remaining — 1 GB package should fit + const maxBytes = BigInt(2 * 1024 * 1024 * 1024) + expect(isPackageOverCap(1024, maxBytes)).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// daysUntilExpiry +// --------------------------------------------------------------------------- + +describe('daysUntilExpiry', () => { + beforeEach(() => { + jest.useFakeTimers() + }) + + afterEach(() => { + jest.useRealTimers() + }) + + it('returns null when expiresAt is null', () => { + expect(daysUntilExpiry(null)).toBeNull() + }) + + it('returns 1 for a date that expires in exactly 1 day', () => { + const now = new Date('2026-01-01T00:00:00Z') + jest.setSystemTime(now) + const expiresAt = new Date('2026-01-02T00:00:00Z') + expect(daysUntilExpiry(expiresAt)).toBe(1) + }) + + it('rounds down partial days so credits expiring today show 0', () => { + const now = new Date('2026-01-01T00:00:00Z') + jest.setSystemTime(now) + // 1.5 days → floor → 1 + const expiresAt = new Date('2026-01-02T12:00:00Z') + expect(daysUntilExpiry(expiresAt)).toBe(1) + }) + + it('returns 0 when credits expire later today (< 1 whole day remaining)', () => { + const now = new Date('2026-01-01T00:00:00Z') + jest.setSystemTime(now) + // 0.5 days → floor → 0 + const expiresAt = new Date('2026-01-01T12:00:00Z') + expect(daysUntilExpiry(expiresAt)).toBe(0) + }) + + it('returns 0 for a past expiry date (clamped)', () => { + const now = new Date('2026-01-05T00:00:00Z') + jest.setSystemTime(now) + const expiresAt = new Date('2026-01-01T00:00:00Z') + expect(daysUntilExpiry(expiresAt)).toBe(0) + }) + + it('returns 30 when expiry is exactly 30 days away', () => { + const now = new Date('2026-01-01T00:00:00Z') + jest.setSystemTime(now) + const expiresAt = new Date('2026-01-31T00:00:00Z') + expect(daysUntilExpiry(expiresAt)).toBe(30) + }) +}) + +// --------------------------------------------------------------------------- +// sumExpiringUploadBytes +// --------------------------------------------------------------------------- + +describe('sumExpiringUploadBytes', () => { + it('returns 0n for an empty array', () => { + expect(sumExpiringUploadBytes([])).toBe(0n) + }) + + it('returns the correct sum for a single batch', () => { + const batches = [{ uploadBytesRemaining: '1048576' }] // 1 MB + expect(sumExpiringUploadBytes(batches)).toBe(1048576n) + }) + + it('sums multiple batches correctly', () => { + const batches = [ + { uploadBytesRemaining: '1048576' }, // 1 MB + { uploadBytesRemaining: '2097152' }, // 2 MB + { uploadBytesRemaining: '5242880' }, // 5 MB + ] + expect(sumExpiringUploadBytes(batches)).toBe(8388608n) // 8 MB total + }) + + it('handles large byte values without overflow', () => { + // 1 GB each, 3 batches → 3 GB total + const oneMiB = BigInt(1024 * 1024 * 1024) + const batches = [ + { uploadBytesRemaining: oneMiB.toString() }, + { uploadBytesRemaining: oneMiB.toString() }, + { uploadBytesRemaining: oneMiB.toString() }, + ] + expect(sumExpiringUploadBytes(batches)).toBe(oneMiB * 3n) + }) +}) + +// --------------------------------------------------------------------------- +// getBatchStatus +// --------------------------------------------------------------------------- + +describe('getBatchStatus', () => { + beforeEach(() => { + jest.useFakeTimers() + jest.setSystemTime(new Date('2026-06-01T00:00:00Z')) + }) + + afterEach(() => { + jest.useRealTimers() + }) + + const makeBatch = (overrides: Partial<{ expired: boolean; uploadBytesRemaining: string; expiresAt: string }> = {}) => ({ + expired: false, + uploadBytesRemaining: '1048576', + expiresAt: '2026-12-01T00:00:00Z', + ...overrides, + }) + + it('returns "expired" when batch.expired is true', () => { + expect(getBatchStatus(makeBatch({ expired: true }))).toBe('expired') + }) + + it('returns "depleted" when uploadBytesRemaining is zero', () => { + expect(getBatchStatus(makeBatch({ uploadBytesRemaining: '0' }))).toBe('depleted') + }) + + it('returns "expiring" when the batch expires within 30 days', () => { + // 15 days from "now" (2026-06-01) + expect(getBatchStatus(makeBatch({ expiresAt: '2026-06-16T00:00:00Z' }))).toBe('expiring') + }) + + it('returns "expiring" when the batch expires in exactly 30 days', () => { + expect(getBatchStatus(makeBatch({ expiresAt: '2026-07-01T00:00:00Z' }))).toBe('expiring') + }) + + it('returns "active" when the batch expires in more than 30 days', () => { + expect(getBatchStatus(makeBatch({ expiresAt: '2026-07-02T00:00:00Z' }))).toBe('active') + }) + + it('prioritises "expired" over "depleted"', () => { + expect(getBatchStatus(makeBatch({ expired: true, uploadBytesRemaining: '0' }))).toBe('expired') + }) + + it('prioritises "depleted" over "expiring"', () => { + expect(getBatchStatus(makeBatch({ uploadBytesRemaining: '0', expiresAt: '2026-06-10T00:00:00Z' }))).toBe('depleted') + }) +}) diff --git a/apps/frontend/jest.config.ts b/apps/frontend/jest.config.ts new file mode 100644 index 000000000..28e240d37 --- /dev/null +++ b/apps/frontend/jest.config.ts @@ -0,0 +1,27 @@ +import type { Config } from 'jest' + +const config: Config = { + testMatch: ['**/__tests__/**/*.spec.ts'], + transform: { + '^.+\\.tsx?$': [ + 'ts-jest', + { + useESM: false, + tsconfig: { + module: 'CommonJS', + moduleResolution: 'Node', + strict: true, + esModuleInterop: true, + skipLibCheck: true, + // ES2020 required for BigInt literal syntax (0n, 1024n, etc.) + target: 'ES2020', + lib: ['ES2020'], + types: ['jest', 'node'], + }, + }, + ], + }, + testEnvironment: 'node', +} + +export default config diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 0f9419bab..f679d1f2d 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -7,7 +7,8 @@ "build": "next build", "start": "next start", "lint": "next lint", - "codegen": "graphql-codegen --config codegen.ts" + "codegen": "graphql-codegen --config codegen.ts", + "test": "node ../../node_modules/jest/bin/jest.js --config jest.config.ts --forceExit" }, "dependencies": { "@apollo/client": "^3.11.10", diff --git a/apps/frontend/src/app/[chain]/drive/admin/banners/page.tsx b/apps/frontend/src/app/[chain]/drive/admin/banners/page.tsx new file mode 100644 index 000000000..49646c25d --- /dev/null +++ b/apps/frontend/src/app/[chain]/drive/admin/banners/page.tsx @@ -0,0 +1,12 @@ +import { BannerAdmin } from '@/components/views/BannerAdmin'; +import { UserProtectedLayout } from '../../../../../components/layouts/UserProtectedLayout'; + +export const dynamic = 'force-dynamic'; + +export default async function Page() { + return ( + + + + ); +} diff --git a/apps/frontend/src/app/[chain]/drive/admin/tou/page.tsx b/apps/frontend/src/app/[chain]/drive/admin/tou/page.tsx new file mode 100644 index 000000000..4291d74c2 --- /dev/null +++ b/apps/frontend/src/app/[chain]/drive/admin/tou/page.tsx @@ -0,0 +1,12 @@ +import { TouAdmin } from '@/components/views/TouAdmin'; +import { UserProtectedLayout } from '../../../../../components/layouts/UserProtectedLayout'; + +export const dynamic = 'force-dynamic'; + +export default async function Page() { + return ( + + + + ); +} diff --git a/apps/frontend/src/app/[chain]/drive/credits/page.tsx b/apps/frontend/src/app/[chain]/drive/credits/page.tsx new file mode 100644 index 000000000..0cc85743f --- /dev/null +++ b/apps/frontend/src/app/[chain]/drive/credits/page.tsx @@ -0,0 +1,10 @@ +import { CreditHistoryView } from '@/components/views/CreditHistory'; +import { UserProtectedLayout } from '../../../../components/layouts/UserProtectedLayout'; + +export default async function Page() { + return ( + + + + ); +} diff --git a/apps/frontend/src/app/[chain]/drive/layout.tsx b/apps/frontend/src/app/[chain]/drive/layout.tsx index 497c91956..aac085b13 100644 --- a/apps/frontend/src/app/[chain]/drive/layout.tsx +++ b/apps/frontend/src/app/[chain]/drive/layout.tsx @@ -9,6 +9,8 @@ import { SidebarProvider } from '@/components/molecules/Sidebar'; import { SideNavbar } from 'frontend/src/components/organisms/SideNavBar'; import { SessionEnsurer } from '@/components/atoms/SessionEnsurer'; import { AutomaticLoginWrapper } from '../../../components/atoms/AutomaticLoginWrapper'; +import { BannerNotifications } from '@/components/organisms/BannerNotifications'; +import { ExpiryWarningBanner } from '../../../components/atoms/ExpiryWarningBanner'; export default function AppLayout({ children, @@ -26,6 +28,10 @@ export default function AppLayout({
+
+ + +
diff --git a/apps/frontend/src/app/api/auth/[...nextauth]/config.ts b/apps/frontend/src/app/api/auth/[...nextauth]/config.ts index f4d6fee10..7f9321dc3 100644 --- a/apps/frontend/src/app/api/auth/[...nextauth]/config.ts +++ b/apps/frontend/src/app/api/auth/[...nextauth]/config.ts @@ -92,40 +92,48 @@ export const authOptions: AuthOptions = { ], callbacks: { async jwt({ account, token }) { - const isTokenSetupAndRefreshable = - token.accessToken && token.authProvider && token.refreshToken; - if (isTokenSetupAndRefreshable) { - // Only refresh if the access token is near expiry — avoids a network - // call to the auth service on every single session check. - // Note: token.exp is the NextAuth session JWT expiry (reset to - // now + maxAge on every encode), NOT the access token's expiry. - // We use accessTokenExp which is set explicitly in jwt.ts. - const nowInSeconds = Math.floor(Date.now() / 1000); - const accessTokenExp = (token.accessTokenExp as number) ?? 0; - const isNearExpiry = - accessTokenExp < nowInSeconds + refreshingTokenThresholdInSeconds; - if (isNearExpiry) { - return refreshAccessToken({ - underlyingUserId: token.underlyingUserId!, - underlyingProvider: token.underlyingProvider!, - refreshToken: token.refreshToken!, + try { + const isTokenSetupAndRefreshable = + token.accessToken && token.authProvider && token.refreshToken; + if (isTokenSetupAndRefreshable) { + // Only refresh if the access token is near expiry — avoids a network + // call to the auth service on every single session check. + // Note: token.exp is the NextAuth session JWT expiry (reset to + // now + maxAge on every encode), NOT the access token's expiry. + // We use accessTokenExp which is set explicitly in jwt.ts. + const nowInSeconds = Math.floor(Date.now() / 1000); + const accessTokenExp = (token.accessTokenExp as number) ?? 0; + const isNearExpiry = + accessTokenExp < nowInSeconds + refreshingTokenThresholdInSeconds; + if (isNearExpiry) { + return refreshAccessToken({ + underlyingUserId: token.underlyingUserId!, + underlyingProvider: token.underlyingProvider!, + refreshToken: token.refreshToken!, + }); + } + return token; + } + + const isOAuthSuccessfullyLoggedIn = account && account.access_token; + if (isOAuthSuccessfullyLoggedIn) { + return generateAccessToken({ + provider: account.provider, + userId: account.providerAccountId, + oauthAccessToken: account.access_token!, }); } - return token; - } - const isOAuthSuccessfullyLoggedIn = account && account.access_token; - if (isOAuthSuccessfullyLoggedIn) { - return generateAccessToken({ - provider: account.provider, - userId: account.providerAccountId, - oauthAccessToken: account.access_token!, - }); + throw new Error('No account or token found'); + } catch (error) { + console.error('JWT callback error, invalidating session:', error); + return { ...token, error: 'RefreshTokenError' }; } - - throw new Error('No account or token found'); }, async session({ session, token }) { + if (token.error === 'RefreshTokenError') { + session.error = 'RefreshTokenError'; + } session.accessToken = token.accessToken; session.authProvider = token.authProvider; session.authUserId = token.authUserId; diff --git a/apps/frontend/src/components/atoms/ExpiryWarningBanner.tsx b/apps/frontend/src/components/atoms/ExpiryWarningBanner.tsx new file mode 100644 index 000000000..13cf90348 --- /dev/null +++ b/apps/frontend/src/components/atoms/ExpiryWarningBanner.tsx @@ -0,0 +1,58 @@ +'use client'; + +import { useQuery } from '@tanstack/react-query'; +import { BannerCriticality } from '@auto-drive/models'; +import { useNetwork } from '../../contexts/network'; +import { ExpiringCreditBatch } from '../../services/api'; +import { SessionContext } from 'next-auth/react'; +import { useContext } from 'react'; +import { + daysUntilExpiry, + sumExpiringUploadBytes, +} from '../../utils/credits'; +import { BannerShell } from '../organisms/BannerNotifications/BannerShell'; + +/** + * Displays a warning banner when the user has purchased credits that will + * expire within the next 30 days. Uses the shared BannerShell for consistent + * styling with admin-created banners. Only shown to authenticated users. + */ +export const ExpiryWarningBanner = () => { + const { api } = useNetwork(); + const session = useContext(SessionContext); + + const { data: expiringBatches } = useQuery({ + queryKey: ['expiringCreditBatches'], + queryFn: () => api.getExpiringCreditBatches(), + // Refresh every 5 minutes – expiry warnings don't need to be real-time + refetchInterval: 5 * 60 * 1000, + enabled: !!session?.data, + }); + + if (!expiringBatches || expiringBatches.length === 0) return null; + + // Find the soonest expiry date across all expiring batches + const soonestExpiry = expiringBatches.reduce((acc, batch) => { + const d = new Date(batch.expiresAt); + return acc === null || d < acc ? d : acc; + }, null); + + const daysLeft = daysUntilExpiry(soonestExpiry); + + // Sum remaining bytes across expiring batches (strings from API → BigInt) + const totalExpiringBytes = sumExpiringUploadBytes(expiringBatches); + const totalMB = Number(totalExpiringBytes / BigInt(1024 * 1024)); + + return ( + +

Credits expiring soon!

+

+ {totalMB > 0 ? `${totalMB.toFixed(0)} MiB of` : 'Some of your'}{' '} + purchased storage credits will expire + {daysLeft !== null && daysLeft > 0 + ? ` in ${daysLeft} day${daysLeft !== 1 ? 's' : ''}` + : ' today'}. Use them before they expire. +

+
+ ); +}; diff --git a/apps/frontend/src/components/atoms/SessionEnsurer.tsx b/apps/frontend/src/components/atoms/SessionEnsurer.tsx index bcadf5018..86692fd9a 100644 --- a/apps/frontend/src/components/atoms/SessionEnsurer.tsx +++ b/apps/frontend/src/components/atoms/SessionEnsurer.tsx @@ -1,10 +1,14 @@ import { SessionContext } from 'next-auth/react'; import { useContext, useEffect } from 'react'; import { useUserStore } from '../../globalStates/user'; +import { useTouStore } from '../../globalStates/tou'; import { useNetwork } from '../../contexts/network'; import { AuthService } from '../../services/auth/auth'; import { useRouter } from 'next/navigation'; -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { CreditSummaryResponse } from '../../services/api'; +import { TouChangeType } from '@auto-drive/models'; +import { TouAcceptanceInterstitial } from '../views/TouAcceptance'; export const SessionEnsurer = ({ children }: { children: React.ReactNode }) => { const router = useRouter(); @@ -12,22 +16,31 @@ export const SessionEnsurer = ({ children }: { children: React.ReactNode }) => { const setUser = useUserStore(({ setUser }) => setUser); const setFeatures = useUserStore(({ setFeatures }) => setFeatures); const setAccount = useUserStore((m) => m.setAccount); + const setCreditSummary = useUserStore((m) => m.setCreditSummary); + const touStatus = useTouStore((m) => m.touStatus); + const setTouStatus = useTouStore((m) => m.setTouStatus); const { api } = useNetwork(); + const queryClient = useQueryClient(); useEffect(() => { if (session === undefined) return; if (session.data === null) { setUser(null); + setTouStatus(null); } else { - AuthService.getMe().then((user) => { - if (user.onboarded) { - setUser(user); - } else { + AuthService.getMe() + .then((user) => { + if (user.onboarded) { + setUser(user); + } else { + setUser(null); + router.push('/onboarding'); + } + }) + .catch(() => { setUser(null); - router.push('/onboarding'); - } - }); + }); } }, [api, router, session, session?.data, setAccount, setUser]); @@ -43,6 +56,24 @@ export const SessionEnsurer = ({ children }: { children: React.ReactNode }) => { enabled: !!session?.data, }); + const { data: creditSummary } = useQuery({ + queryKey: ['creditSummary'], + queryFn: () => api.getCreditSummary(), + // Refresh every 30 s so the cap / balance stays reasonably fresh + refetchInterval: 30_000, + enabled: !!session?.data, + }); + + const { data: touStatusData, isLoading: touStatusLoading } = useQuery({ + queryKey: ['touStatus'], + queryFn: () => api.getTouStatus(), + enabled: !!session?.data, + }); + + useEffect(() => { + setTouStatus(touStatusData ?? null); + }, [touStatusData, setTouStatus]); + useEffect(() => { if (account) setAccount(account); }, [account, setAccount]); @@ -51,10 +82,34 @@ export const SessionEnsurer = ({ children }: { children: React.ReactNode }) => { if (features) setFeatures(features); }, [features, setFeatures]); + useEffect(() => { + setCreditSummary(creditSummary ?? null); + }, [creditSummary, setCreditSummary]); + if (session === undefined) { // TODO: Add a loading state return
Loading...
; } + if (session?.data && touStatusLoading) { + return
Loading...
; + } + + if ( + session?.data && + touStatus && + !touStatus.accepted && + touStatus.currentVersion?.changeType === TouChangeType.Material + ) { + return ( + + queryClient.invalidateQueries({ queryKey: ['touStatus'] }) + } + /> + ); + } + return <>{children}; }; diff --git a/apps/frontend/src/components/molecules/AccountInformation/index.tsx b/apps/frontend/src/components/molecules/AccountInformation/index.tsx index fde765112..8166c1c04 100644 --- a/apps/frontend/src/components/molecules/AccountInformation/index.tsx +++ b/apps/frontend/src/components/molecules/AccountInformation/index.tsx @@ -1,12 +1,30 @@ import { utcToLocalRelativeTime } from '../../../utils/time'; import { AccountModel } from '@auto-drive/models'; import { formatBytes } from '../../../utils/number'; +import Link from 'next/link'; interface CreditLimitsProps { uploadPending: number; uploadLimit: number; renewalDate: Date; model: AccountModel; + /** + * Bytes remaining across all active purchased-credit rows. + * Only passed when the buyCredits feature flag is active for this user. + * Defaults to 0 (no purchased credits section rendered). + */ + purchasedBytesRemaining?: number; + /** + * Soonest expiry date across the user's active purchased-credit rows. + * Shown as a hint when the purchased-credits section is rendered. + */ + nextExpiryDate?: Date | null; + /** + * href to the credit history page. When provided, a "View history" link is + * shown below the purchased credits row. Only passed when the buyCredits + * feature flag is active. + */ + creditHistoryHref?: string; } export const AccountInformation = ({ @@ -14,6 +32,9 @@ export const AccountInformation = ({ uploadPending = 0, uploadLimit = 1000, renewalDate, + purchasedBytesRemaining = 0, + nextExpiryDate = null, + creditHistoryHref, }: CreditLimitsProps) => { const uploadUsed = uploadLimit - uploadPending; @@ -22,6 +43,8 @@ export const AccountInformation = ({ Math.min(100, (uploadUsed / uploadLimit) * 100), ); + const hasPurchasedCredits = purchasedBytesRemaining > 0; + return (
Upload usage
@@ -44,6 +67,33 @@ export const AccountInformation = ({ Renews in {utcToLocalRelativeTime(renewalDate.toISOString())}

)} + + {/* Purchased credits — rendered when the user has remaining balance. */} + {hasPurchasedCredits && ( +
+
Purchased credits
+
+ + {formatBytes(purchasedBytesRemaining, 2)} + + {nextExpiryDate && ( + + expires {utcToLocalRelativeTime(nextExpiryDate.toISOString())} + + )} +
+
+ )} + {creditHistoryHref && ( +
+ + View history → + +
+ )}
); }; diff --git a/apps/frontend/src/components/molecules/UploadingFolderModal.tsx b/apps/frontend/src/components/molecules/UploadingFolderModal.tsx index 7e690942d..3648875e6 100644 --- a/apps/frontend/src/components/molecules/UploadingFolderModal.tsx +++ b/apps/frontend/src/components/molecules/UploadingFolderModal.tsx @@ -1,10 +1,14 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Dialog, Transition } from '@headlessui/react'; import { Button } from '@auto-drive/ui'; -import { FileWarning } from 'lucide-react'; +import { AlertTriangle, FileWarning } from 'lucide-react'; import { useEncryptionStore } from 'globalStates/encryption'; import { useNetwork } from 'contexts/network'; import { useFileTableState } from '@/components/organisms/FileTable/state'; +import { useUserStore } from 'globalStates/user'; +import { AccountModel } from '@auto-drive/models'; +import { BuyMoreCreditsButton } from 'components/atoms/BuyMoreCreditsButton'; +import { formatBytes } from 'utils/number'; export const UploadingFolderModal = ({ data, @@ -90,6 +94,29 @@ export const UploadingFolderModal = ({ setPasswordConfirmed(true); }, [defaultPassword]); + // --------------------------------------------------------------------------- + // Pre-flight credit check + // --------------------------------------------------------------------------- + + const { account, features } = useUserStore(); + + // Total bytes in the selected FileList. + const folderTotalSize = useMemo(() => { + if (!data) return 0; + return Array.from(data).reduce((sum, f) => sum + f.size, 0); + }, [data]); + + // Only block when we have a definitive account read AND the folder is larger + // than the combined free+purchased credit pool. If account is null (still + // loading) we let the upload proceed — the backend enforces the hard limit. + const hasBuyCreditsFeature = + features.buyCredits && + account !== null && + account.model === AccountModel.OneOff; + + const hasInsufficientCredits = + account !== null && folderTotalSize > account.pendingUploadCredits; + return ( @@ -98,7 +125,41 @@ export const UploadingFolderModal = ({

Uploading Folder

- {passwordConfirmed ? ( + {hasInsufficientCredits && !passwordConfirmed ? ( +
+
+ +

+ Not enough upload credits +

+

+ This folder is{' '} + + {formatBytes(folderTotalSize)} + {' '} + but you only have{' '} + + {formatBytes(account.pendingUploadCredits)} + {' '} + of upload credit remaining. +

+
+ {hasBuyCreditsFeature && ( +
+ +
+ )} +
+ +
+
+ ) : passwordConfirmed ? (
{error ? (
diff --git a/apps/frontend/src/components/organisms/BannerNotifications/BannerItem.tsx b/apps/frontend/src/components/organisms/BannerNotifications/BannerItem.tsx new file mode 100644 index 000000000..9df0d91de --- /dev/null +++ b/apps/frontend/src/components/organisms/BannerNotifications/BannerItem.tsx @@ -0,0 +1,39 @@ +'use client'; + +import { Banner, BannerInteractionType } from '@auto-drive/models'; +import { useCallback } from 'react'; +import { BannerShell } from './BannerShell'; + +interface BannerItemProps { + banner: Banner; + onInteract?: (bannerId: string, type: BannerInteractionType) => void; + preview?: boolean; +} + +export const BannerItem = ({ banner, onInteract, preview }: BannerItemProps) => { + const handleDismiss = useCallback(() => { + onInteract?.(banner.id, BannerInteractionType.Dismissed); + }, [banner.id, onInteract]); + + const handleAcknowledge = useCallback(() => { + onInteract?.(banner.id, BannerInteractionType.Acknowledged); + }, [banner.id, onInteract]); + + return ( + +

{banner.title}

+

{banner.body}

+ {banner.requiresAcknowledgement && !preview && ( + + )} +
+ ); +}; diff --git a/apps/frontend/src/components/organisms/BannerNotifications/BannerShell.tsx b/apps/frontend/src/components/organisms/BannerNotifications/BannerShell.tsx new file mode 100644 index 000000000..f967d0185 --- /dev/null +++ b/apps/frontend/src/components/organisms/BannerNotifications/BannerShell.tsx @@ -0,0 +1,59 @@ +'use client'; + +import { BannerCriticality } from '@auto-drive/models'; +import { X, AlertTriangle, AlertCircle, Info } from 'lucide-react'; + +const criticalityStyles: Record< + BannerCriticality, + { container: string; icon: typeof Info } +> = { + [BannerCriticality.Info]: { + container: + 'border-blue-200 bg-blue-50 text-blue-800 dark:border-blue-800 dark:bg-blue-950 dark:text-blue-200', + icon: Info, + }, + [BannerCriticality.Warning]: { + container: + 'border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-200', + icon: AlertTriangle, + }, + [BannerCriticality.Critical]: { + container: + 'border-red-200 bg-red-50 text-red-800 dark:border-red-800 dark:bg-red-950 dark:text-red-200', + icon: AlertCircle, + }, +}; + +interface BannerShellProps { + criticality: BannerCriticality; + onDismiss?: () => void; + children: React.ReactNode; +} + +export const BannerShell = ({ + criticality, + onDismiss, + children, +}: BannerShellProps) => { + const style = criticalityStyles[criticality]; + const IconComponent = style.icon; + + return ( +
+ +
{children}
+ {onDismiss && ( + + )} +
+ ); +}; diff --git a/apps/frontend/src/components/organisms/BannerNotifications/index.tsx b/apps/frontend/src/components/organisms/BannerNotifications/index.tsx new file mode 100644 index 000000000..384b8acb2 --- /dev/null +++ b/apps/frontend/src/components/organisms/BannerNotifications/index.tsx @@ -0,0 +1,49 @@ +'use client'; + +import { useCallback, useEffect } from 'react'; +import { BannerInteractionType } from '@auto-drive/models'; +import { useNetwork } from 'contexts/network'; +import { useBannerStore } from 'globalStates/banners'; +import { useUserStore } from 'globalStates/user'; +import { BannerItem } from './BannerItem'; + +export const BannerNotifications = () => { + const { api } = useNetwork(); + const user = useUserStore((s) => s.user); + const { banners, setBanners, removeBanner } = useBannerStore(); + + useEffect(() => { + if (!user) { + setBanners([]); + return; + } + + api.getActiveBanners().then(setBanners).catch(() => setBanners([])); + }, [api, user, setBanners]); + + const handleInteract = useCallback( + async (bannerId: string, type: BannerInteractionType) => { + try { + await api.interactWithBanner(bannerId, type); + removeBanner(bannerId); + } catch { + // Silently fail — banner stays visible + } + }, + [api, removeBanner], + ); + + if (banners.length === 0) return null; + + return ( + <> + {banners.map((banner) => ( + + ))} + + ); +}; diff --git a/apps/frontend/src/components/organisms/SideNavBar/index.tsx b/apps/frontend/src/components/organisms/SideNavBar/index.tsx index a120435c1..7d2fdfaa9 100644 --- a/apps/frontend/src/components/organisms/SideNavBar/index.tsx +++ b/apps/frontend/src/components/organisms/SideNavBar/index.tsx @@ -24,7 +24,7 @@ export type SideNavbarProps = { export const SideNavbar = ({ networkId }: SideNavbarProps) => { const [isAuthModalOpen, setIsAuthModalOpen] = useState(false); - const { user, account, features } = useUserStore(); + const { user, account, features, creditSummary } = useUserStore(); const { state } = useSidebar(); const session = useContext(SessionContext); @@ -53,6 +53,26 @@ export const SideNavbar = ({ networkId }: SideNavbarProps) => { const hasBuyCreditsFeature = features.buyCredits && isLoggedIn && account?.model === AccountModel.OneOff; + // Purchased-credit fields — only derived when the buyCredits feature is + // active for this user. Both values default to "not shown" otherwise, + // which keeps the sidebar unchanged for Monthly, free-only OneOff, and + // any account whose operator has disabled the feature flag. + const purchasedBytesRemaining = useMemo(() => { + if (!hasBuyCreditsFeature || !creditSummary) return 0; + // creditSummary.uploadBytesRemaining is a decimal-string bigint from the + // API. Max value is 100 GiB which is well within Number's safe range. + return Number(creditSummary.uploadBytesRemaining); + }, [hasBuyCreditsFeature, creditSummary]); + + const nextExpiryDate = useMemo(() => { + if (!hasBuyCreditsFeature || !creditSummary?.nextExpiryDate) return null; + return new Date(creditSummary.nextExpiryDate); + }, [hasBuyCreditsFeature, creditSummary]); + + const creditHistoryHref = hasBuyCreditsFeature + ? `/${networkId}/drive/credits` + : undefined; + return ( { renewalDate={renewalDate} uploadLimit={account?.uploadLimit ?? 0} uploadPending={account?.pendingUploadCredits ?? 0} + purchasedBytesRemaining={purchasedBytesRemaining} + nextExpiryDate={nextExpiryDate} + creditHistoryHref={creditHistoryHref} /> )} {isLoggedIn && account ? ( diff --git a/apps/frontend/src/components/organisms/SideNavBar/items.ts b/apps/frontend/src/components/organisms/SideNavBar/items.ts index b1cb8fc19..4d6d75176 100644 --- a/apps/frontend/src/components/organisms/SideNavBar/items.ts +++ b/apps/frontend/src/components/organisms/SideNavBar/items.ts @@ -6,6 +6,8 @@ import { UserIcon, CodeXmlIcon, SettingsIcon, + MegaphoneIcon, + FileTextIcon, } from 'lucide-react'; import { NetworkId, ROUTES } from '@auto-drive/ui'; import { SidebarSection } from './SideNavBarContent'; @@ -71,6 +73,18 @@ export const SIDEBAR_DEFINITION: SidebarSection[] = [ label: 'Admin', requiresSession: true, }, + { + href: (networkId: NetworkId) => ROUTES.adminBanners(networkId), + icon: MegaphoneIcon, + label: 'Banners', + requiresSession: true, + }, + { + href: (networkId: NetworkId) => ROUTES.adminTou(networkId), + icon: FileTextIcon, + label: 'Terms of Use', + requiresSession: true, + }, ], }, ]; diff --git a/apps/frontend/src/components/views/AdminPanel/AdminCredits.tsx b/apps/frontend/src/components/views/AdminPanel/AdminCredits.tsx new file mode 100644 index 000000000..4b2937d9b --- /dev/null +++ b/apps/frontend/src/components/views/AdminPanel/AdminCredits.tsx @@ -0,0 +1,318 @@ +'use client'; + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useNetwork } from '../../../contexts/network'; +import { formatBytes } from '../../../utils/number'; +import { formatDate } from '../../../utils/time'; +import { RefreshCw, AlertTriangle, RotateCcw } from 'lucide-react'; +import { Button } from '@auto-drive/ui'; +import { getBatchStatus, STATUS_CLASSES, STATUS_LABEL } from '../../../utils/credits'; +import type { + AdminCreditBatch, + CreditEconomicsResponse, + OverCapIntent, +} from '../../../services/api'; + +// --------------------------------------------------------------------------- +// Economics summary card +// --------------------------------------------------------------------------- + +const EconomicsCard = ({ + economics, +}: { + economics: CreditEconomicsResponse; +}) => ( +
+
+

Batches expiring ≤ 30 d

+

+ {economics.totalExpiringWithin30Days} +

+
+
+

Upload bytes expiring

+

+ {formatBytes(Number(BigInt(economics.totalExpiringUploadBytes)), 1)} +

+
+
+

Download bytes expiring

+

+ {formatBytes(Number(BigInt(economics.totalExpiringDownloadBytes)), 1)} +

+
+
+); + +// --------------------------------------------------------------------------- +// OVER_CAP intents panel +// --------------------------------------------------------------------------- + +const OverCapPanel = ({ + intents, + onReprocess, + reprocessingId, + isPending, +}: { + intents: OverCapIntent[]; + onReprocess: (id: string) => void; + reprocessingId: string | null; + isPending: boolean; +}) => { + if (intents.length === 0) { + return ( +

+ No over-cap intents — all payments resolved. +

+ ); + } + + return ( +
+ + + + + + + + + + + + {intents.map((intent) => ( + + + + + + + + ))} + +
Intent IDUserPaid (AI3 shannons)Tx hashAction
+ {intent.id.slice(0, 12)}… + + {intent.userPublicId.slice(0, 12)}… + + {intent.paymentAmount ?? '—'} + + {intent.txHash + ? `${intent.txHash.slice(0, 10)}…` + : '—'} + + +
+
+ ); +}; + +// --------------------------------------------------------------------------- +// All credit batches table +// --------------------------------------------------------------------------- + +const AllBatchesTable = ({ batches }: { batches: AdminCreditBatch[] }) => { + if (batches.length === 0) { + return ( +

+ No credit batches have been purchased yet. +

+ ); + } + + return ( +
+ + + + + + + + + + + + + + {batches.map((batch) => { + const status = getBatchStatus(batch); + const original = Number(BigInt(batch.uploadBytesOriginal)); + const remaining = Number(BigInt(batch.uploadBytesRemaining)); + const usedPct = + original > 0 + ? Math.round(((original - remaining) / original) * 100) + : 0; + + return ( + + + + + + + + + + ); + })} + +
UserStatusPurchasedOriginalRemainingUsed %Expires
+ {batch.userPublicId.slice(0, 14)}… + + + {STATUS_LABEL[status]} + + + {formatDate(batch.purchasedAt)} + + {formatBytes(original, 1)} + + {formatBytes(remaining, 1)} + +
+
+
+
+ + {usedPct}% + +
+
+ {batch.expired ? ( + + Expired {formatDate(batch.expiresAt)} + + ) : ( + formatDate(batch.expiresAt) + )} +
+
+ ); +}; + +// --------------------------------------------------------------------------- +// Main component +// --------------------------------------------------------------------------- + +export const AdminCredits = () => { + const { api } = useNetwork(); + const queryClient = useQueryClient(); + + const { data: economics, isLoading: economicsLoading } = + useQuery({ + queryKey: ['adminCreditEconomics'], + queryFn: () => api.getCreditEconomics(), + staleTime: 60_000, + }); + + const { data: batches = [], isLoading: batchesLoading } = useQuery< + AdminCreditBatch[] + >({ + queryKey: ['adminCreditBatches'], + queryFn: () => api.getAdminCreditBatches(), + staleTime: 60_000, + }); + + const { data: overCapIntents = [], isLoading: overCapLoading } = useQuery< + OverCapIntent[] + >({ + queryKey: ['adminOverCapIntents'], + queryFn: () => api.getOverCapIntents(), + staleTime: 30_000, + }); + + const { mutate: reprocess, variables: reprocessingId, isPending: isReprocessing } = useMutation< + void, + Error, + string + >({ + mutationFn: (intentId: string) => api.reprocessIntent(intentId), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['adminOverCapIntents'] }); + void queryClient.invalidateQueries({ queryKey: ['adminCreditBatches'] }); + }, + }); + + const isLoading = economicsLoading || batchesLoading || overCapLoading; + + return ( +
+
+

Purchased Credits

+ {isLoading && ( + + )} +
+ + {/* Economics summary */} +
+

+ System Economics (expiring ≤ 30 days) +

+ {economics ? ( + + ) : ( + !economicsLoading && ( +

+ No economics data available. +

+ ) + )} +
+ + {/* OVER_CAP intents */} +
+
+

+ Over-Cap Intents +

+ {overCapIntents.length > 0 && ( + + + {overCapIntents.length} need review + + )} +
+

+ Payments confirmed on-chain that could not be converted to credits + because the user hit the per-user cap. Raise the cap via the account + editor, then click Reprocess to retry. +

+ reprocess(id)} + reprocessingId={reprocessingId ?? null} + isPending={isReprocessing} + /> +
+ + {/* All batches table */} +
+

+ All Purchase Batches ({batches.length}) +

+ +
+
+ ); +}; diff --git a/apps/frontend/src/components/views/AdminPanel/index.tsx b/apps/frontend/src/components/views/AdminPanel/index.tsx index 2ad52befe..bc4099bf5 100644 --- a/apps/frontend/src/components/views/AdminPanel/index.tsx +++ b/apps/frontend/src/components/views/AdminPanel/index.tsx @@ -11,6 +11,7 @@ import { import { useNetwork } from 'contexts/network'; import { Button } from '@auto-drive/ui'; import { AdminStats } from './AdminStats'; +import { AdminCredits } from './AdminCredits'; export const AdminPanel = () => { const [accountsWithUsers, setAccountsWithUsers] = useState< @@ -134,6 +135,11 @@ export const AdminPanel = () => { {/* Analytics Section */} + {/* Purchased Credits Section */} +
+ +
+ {/* Users Section */}

Users

diff --git a/apps/frontend/src/components/views/BannerAdmin/BannerForm.tsx b/apps/frontend/src/components/views/BannerAdmin/BannerForm.tsx new file mode 100644 index 000000000..7639760af --- /dev/null +++ b/apps/frontend/src/components/views/BannerAdmin/BannerForm.tsx @@ -0,0 +1,232 @@ +'use client'; + +import { Banner, BannerCriticality } from '@auto-drive/models'; +import { useCallback, useState } from 'react'; +import { BannerItem } from '../../organisms/BannerNotifications/BannerItem'; + +type BannerFormData = { + title: string; + body: string; + criticality: BannerCriticality; + dismissable: boolean; + requiresAcknowledgement: boolean; + displayStart: string; + displayEnd: string; + active: boolean; +}; + +const defaultFormData: BannerFormData = { + title: '', + body: '', + criticality: BannerCriticality.Info, + dismissable: true, + requiresAcknowledgement: false, + displayStart: new Date().toISOString().slice(0, 16), + displayEnd: '', + active: true, +}; + +interface BannerFormProps { + initialData?: Banner; + onSubmit: (data: BannerFormData) => Promise; + onCancel: () => void; + submitLabel: string; +} + +export const BannerForm = ({ + initialData, + onSubmit, + onCancel, + submitLabel, +}: BannerFormProps) => { + const [form, setForm] = useState( + initialData + ? { + title: initialData.title, + body: initialData.body, + criticality: initialData.criticality, + dismissable: initialData.dismissable, + requiresAcknowledgement: initialData.requiresAcknowledgement, + displayStart: new Date(initialData.displayStart) + .toISOString() + .slice(0, 16), + displayEnd: initialData.displayEnd + ? new Date(initialData.displayEnd).toISOString().slice(0, 16) + : '', + active: initialData.active, + } + : defaultFormData, + ); + const [showPreview, setShowPreview] = useState(false); + const [submitting, setSubmitting] = useState(false); + + const handleSubmit = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + setSubmitting(true); + try { + await onSubmit(form); + } finally { + setSubmitting(false); + } + }, + [form, onSubmit], + ); + + const previewBanner: Banner = { + id: 'preview', + title: form.title || 'Preview Title', + body: form.body || 'Preview body text', + criticality: form.criticality, + dismissable: form.dismissable, + requiresAcknowledgement: form.requiresAcknowledgement, + displayStart: new Date(form.displayStart), + displayEnd: form.displayEnd ? new Date(form.displayEnd) : null, + active: form.active, + createdBy: '', + createdAt: new Date(), + updatedAt: new Date(), + }; + + return ( +
+
+ + setForm({ ...form, title: e.target.value })} + className='w-full rounded-md border border-border bg-background px-3 py-2 text-sm' + required + /> +
+ +
+ +