diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml deleted file mode 100644 index 2129167ef..000000000 --- a/.github/workflows/check.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: Check - -on: - - pull_request -permissions: - contents: read - -jobs: - check: - runs-on: ubuntu-latest - env: - SKIP_ENV_VALIDATION: true - - steps: - - name: Checkout - uses: actions/checkout@v6 - - - uses: pnpm/action-setup@v5 - name: Install pnpm - with: - run_install: false - - - name: Install Node.js - uses: actions/setup-node@v6 - with: - node-version: 22.16.0 - cache: pnpm - - - name: Install dependencies - run: pnpm install - - - name: Apply prettier formatting - run: pnpm prettier --check . - - - name: Run lint - run: pnpm lint - - - name: Run tsc - run: pnpm tsgo --noEmit - - - name: Run tests - run: pnpm test - - # Database seed hash verification is currently disabled because it is failing in CI. - # - name: Verify seed hash - # run: pnpm db:verify-seed-hash - - - name: Run build - run: pnpm build --no-lint diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..8ea861a6a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,92 @@ +name: CI + +on: + push: + branches: [cloudflare-workers, main] + pull_request: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +# Target: the whole workflow finishes in under 2 minutes. The three jobs run in parallel and every +# one of them restores the pnpm store (and, for e2e, the Playwright browsers and the build) from cache. +jobs: + typecheck: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile --prefer-offline + - run: npx tsc --noEmit -p . + + integration: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile --prefer-offline + # Real D1 (workerd) with the production migrations: authorization, balances, expenses, recurrence. + - run: pnpm test:integration + + e2e: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile --prefer-offline + + - name: Resolve Playwright version + id: pw + run: echo "version=$(node -p "require('@playwright/test/package.json').version")" >> "$GITHUB_OUTPUT" + - name: Cache Playwright browsers + id: pw-cache + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ steps.pw.outputs.version }} + - name: Install Chromium + run: | + if [ "${{ steps.pw-cache.outputs.cache-hit }}" = "true" ]; then + npx playwright install-deps chromium + else + npx playwright install --with-deps chromium + fi + + - name: Cache the production build + id: build-cache + uses: actions/cache@v4 + with: + path: dist + key: build-${{ runner.os }}-${{ hashFiles('src/**', 'worker/**', 'public/**', 'migrations/**', 'package.json', 'pnpm-lock.yaml', 'vite*.ts', 'next.config.js', 'wrangler.example.jsonc', 'tsconfig.json') }} + - name: Build + if: steps.build-cache.outputs.cache-hit != 'true' + run: | + cp wrangler.example.jsonc wrangler.jsonc + SKIP_ENV_VALIDATION=1 pnpm build + + - run: pnpm test:e2e + env: + CI: '1' + - name: Upload Playwright traces on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-traces + path: test-results + retention-days: 3 diff --git a/.gitignore b/.gitignore index 662b9383d..005e75d8d 100644 --- a/.gitignore +++ b/.gitignore @@ -64,3 +64,17 @@ SEED_STATISTICS.md # Agents .worktrees/ docs/superpowers/ + +.dev.vars +.wrangler/ +dist/ +.vinext/ + +# Personal Cloudflare deployment config (copy wrangler.example.jsonc to wrangler.jsonc) +wrangler.jsonc + +# e2e artifacts +.e2e-state +.e2e-seed.sql +test-results +playwright-report diff --git a/.npmrc b/.npmrc new file mode 100644 index 000000000..7e896aa85 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +public-hoist-pattern[]=@radix-ui/* diff --git a/README.md b/README.md index 507733c27..53df55798 100644 --- a/README.md +++ b/README.md @@ -1,179 +1,104 @@ -

- - SplitPro Logo - - -

SplitPro

-

An open source alternative to Splitwise

- -## About - -SplitPro is a self-hosted, open source way to share expenses with friends. It is designed as a replacement for Splitwise. - -## Quick start - -1. Use Docker Compose from [docker/prod/compose.yml](docker/prod/compose.yml). -2. Copy `.env.example` to `.env` and configure auth, database, and uploads. -3. Start the stack and log in to create your first account. - -See [docker/README.md](docker/README.md) and [docs/CONFIGURATION.md](docs/CONFIGURATION.md) for the full setup steps. - -## Core features - -- Add expenses with a friend or a group. -- Split methods: equal, percentage, share, exact, adjustments, and settlements. -- Categories, currencies, dates, and receipt attachments (stored locally). -- Negative expenses are supported for refunds and corrections. -- PWA support with push notifications. -- Activity feed with edits and deletions. -- Detailed balances per person and per group. - -## UI preview - -![SplitPro banner](public/og_banner.png) - -![Desktop balances view](public/Desktop.webp) - -![Mobile balances view](public/hero.webp) - -## Usage overview - -### 1) Expenses, balances, and activity - -Create expenses with categories, currencies, dates, and receipt attachments. SplitPro supports negative expenses for refunds and corrections. View per-person balances, detailed group balances, and an activity feed that includes edits and deletions. - -### 2) Application info - -SplitPro is a PWA and that is the recommended way to use the app. It supports push notifications for new expenses and updates, but push delivery requires additional configuration. See [docs/CONFIGURATION.md](docs/CONFIGURATION.md). - -### 3) Groups - -Groups are the primary way to use SplitPro. You can invite friends by email, or add them directly if they are already in your friends list. Group debt simplification is optional, and the group balance view provides a detailed breakdown. - -### 4) Data utilities - -Splitwise import supports friends and groups (partial import). Expenses themselves are not imported yet. You can export data from the balances view and account settings. - -### 5) Translations - -Translations are managed in Weblate. When a language reaches 100%, it is enabled in the next update. - -### 6) Authentication - -SplitPro uses NextAuth. At least one provider must be configured. - -- Email sign-in (magic link) -- OAuth (Google) -- OIDC (Authentik, Keycloak, or custom OIDC) - -Username/password login is not supported. You can lock down an instance by disabling signups and invites. See [docs/CONFIGURATION.md](docs/CONFIGURATION.md) and [docs/AUTHENTICATION.md](docs/AUTHENTICATION.md) for details. - -### 7) Currency conversions - -SplitPro can display balances in a single currency, convert expense amounts, and convert group balances. Provider availability and rate limits depend on the configured rate provider. See [docs/CURRENCY_CONVERSIONS.md](docs/CURRENCY_CONVERSIONS.md). - -### 8) Recurring transactions - -Recurring expenses require a PostgreSQL database with the `pg_cron` extension. We publish a prebuilt Postgres image with `pg_cron`; example usage is in [docker/prod/compose.yml](docker/prod/compose.yml). If you use another database, you must enable the extension and adjust configuration. A non-superuser database role is supported when `pg_cron` is preinstalled; see [docker/README.md](docker/README.md). See [docs/RECURRING_TRANSACTIONS.md](docs/RECURRING_TRANSACTIONS.md). - -### 9) Bank transaction integration - -Bank integration allows you to load transactions from providers like Plaid and convert them into expenses. This feature was provided by @alexanderwassbjer, who is currently maintaining related issues. See [docs/BANK_TRANSACTIONS.md](docs/BANK_TRANSACTIONS.md). - -## Limitations and notes - -- SplitPro computes balances from expenses on the fly using database views. Expenses are the source of truth, which keeps balances consistent and trustworthy. For self hosted deployments the efficiency of database aggregations is entirely sufficient, but please do report any performance issues. -- Recurring transactions require `pg_cron`, which does not support cron ranges or lists. -- Currency conversion accuracy and coverage depend on the selected provider. -- Receipts are stored on local disk; make sure your uploads volume is persistent. - -## Supporting docs - -- [docs/CONFIGURATION.md](docs/CONFIGURATION.md) -- [docs/AUTHENTICATION.md](docs/AUTHENTICATION.md) -- [docs/MIGRATING_FROM_V1.md](docs/MIGRATING_FROM_V1.md) -- [docs/CURRENCY_CONVERSIONS.md](docs/CURRENCY_CONVERSIONS.md) -- [docs/RECURRING_TRANSACTIONS.md](docs/RECURRING_TRANSACTIONS.md) -- [docs/BANK_TRANSACTIONS.md](docs/BANK_TRANSACTIONS.md) -- [docker/README.md](docker/README.md) - -## Versions - -SplitPro is for self hosting. To get the most recent features, build an image from source. Stabilized changes (GitHub releases) are available as Docker images on DockerHub and GHCR. The hosted instance at https://splitpro.app is managed in [oss-apps/split-pro-cloud](https://github.com/oss-apps/split-pro-cloud). - -## Why - -Splitwise is one of the best apps to add expenses and bills. - -We understand that every app needs to make money, after all, lots of effort has been put into Splitwise. The main problem is how they implemented this. - -Monetising on pro features or ads is fine, but asking money for adding expenses (core feature) is frustrating. - -We were searching for other open-source alternatives (Let's be honest, any closed-source product might do the same and we don't have any reason to believe otherwise). - -We managed to find a good app [spliit.app](https://spliit.app/) by [Sebastien Castiel](https://scastiel.dev/) but it's not a complete replacement and didn't suit my workflow sadly. Check it out to see if it fits you. - -## Translations - -The app translations are managed using [a Weblate project](https://hosted.weblate.org/projects/splitpro/). -You can easily add missing translations, fix issues you find and a new language! Just be aware that a new language -also needs to be added in the code and open an issue for that once you finish translating the files. -Here is the current state of translation: - - -Translation status - - -## FAQ - -#### How numerically stable is the internal logic? - -All numbers are stored in the DB as `BigInt` data, with no floats what so ever, safeguarding your expenses from rounding errors or lack of precision. This holds true for currencies with large nominal values that might outgrow the safe range of JS number type. - -#### How are leftover pennies handled? - -In case of an expense that cannot be split evenly, leftover amounts are distributed deterministically across participants, based on amount and date. - -#### Currency rate providers - -See [docs/CURRENCY_CONVERSIONS.md](docs/CURRENCY_CONVERSIONS.md) for provider details and limits. - -## Getting started - -### Deployment with Docker - -We provide Docker images on DockerHub and GHCR. - -- https://hub.docker.com/r/ossapps/splitpro -- https://ghcr.io/oss-apps/splitpro - -For setup instructions, see [docker/README.md](docker/README.md). For environment variables, see [docs/CONFIGURATION.md](docs/CONFIGURATION.md). - -### Deployment as Proxmox LXC - -Thanks to @johanngrobe there is now also a Proxmox Community Script available [here](https://community-scripts.org/scripts/split-pro). - -### Development and contributing - -See the [CONTRIBUTING.md](CONTRIBUTING.md) document. - -## Sponsors - -We are grateful for the support of our sponsors. - -### Our Sponsors - - - hekuta - -User avatar: igorrrpawlowski -User avatar: Marcel Szmeterowicz -User avatar: mexicanhatman -User avatar: FelixDz -User avatar: pchampio -User avatar: J4nsen -User avatar: nachogrxdev - -## Star History - -[![Star History Chart](https://star-history.dera.page/svg?repos=oss-apps/split-pro&type=Date)](https://star-history.dera.page/#oss-apps/split-pro&Date) +# SplitPro on Cloudflare Workers + +> [!WARNING] +> **EXPERIMENTAL and VIBE-CODED.** This is an AI-assisted port of SplitPro to Cloudflare Workers. +> It is a hobby project. It has **no automated tests**, it has not been security-reviewed, and parts +> of it are untested (bank sync, Splitwise import, recurring expenses under load). It handles money +> records. Use at your own risk, and do not trust it with anything important. + +This is a fork of **[oss-apps/split-pro](https://github.com/oss-apps/split-pro)**, the open source +Splitwise alternative. All credit for the app, UI, and features goes to the original authors. For the +original project, its documentation, and the supported Docker/Postgres setup, go to +**https://github.com/oss-apps/split-pro** (website: https://splitpro.app). + +This fork is **not affiliated with or endorsed by** the upstream project. License: MIT (see `LICENSE`). + +## What is different from upstream + +Everything runs on Cloudflare Workers. There is no Node server, no Docker, and no Postgres. + +| Area | Upstream | This fork | +| ------------------------- | ----------------- | ------------------------------------------------------------------------------------------------- | +| Runtime | Next.js on Node | Next.js on Workers via [vinext](https://github.com/cloudflare/vinext) + `@cloudflare/vite-plugin` | +| Database | Postgres + Prisma | Cloudflare D1 + Drizzle (money writes use atomic `db.batch`) | +| Auth | next-auth | better-auth, Google sign-in only | +| Uploads | local disk | R2 | +| Email | nodemailer / SMTP | Cloudflare Email Sending binding | +| Cron / recurring expenses | `pg_cron` | A Workflow with a `* * * * *` schedule, driven by the `job` table | +| Push | `web-push` | `web-push` on Workers (VAPID keys as secrets) | +| Observability | none | Workers Logs, Traces, and Issues, unsampled; tRPC, Worker, and browser errors are logged | + +The `Dockerfile`, `docker/`, `start.sh`, `flake.nix`, and most of `docs/` are leftovers from upstream. +They describe the Postgres/Prisma setup, which this fork removed. They no longer work here. + +## Deploy + +You need a Cloudflare account on a plan that supports Workers, D1, R2, Workflows, and Email Sending, +a domain on Cloudflare, and a Google OAuth client. + +1. Install: `pnpm install` +2. Create resources: + ```sh + npx wrangler d1 create splitpro + npx wrangler r2 bucket create splitpro-uploads + ``` +3. Copy `wrangler.example.jsonc` to `wrangler.jsonc`. Set your domain, the D1 `database_id`, and the + sender address (`FROM_EMAIL`). Your domain must have Email Sending enabled. +4. Create a Google OAuth client (Web application). Add the redirect URI + `https://YOUR_DOMAIN/api/auth/callback/google`. +5. Apply the migrations: `npx wrangler d1 migrations apply splitpro --remote` +6. Set the secrets (`npx wrangler secret put NAME`): + `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `NEXTAUTH_SECRET` (a random string; the name is kept + from upstream), and for push notifications `WEB_PUSH_PUBLIC_KEY`, `WEB_PUSH_PRIVATE_KEY`, + `WEB_PUSH_EMAIL`. Generate VAPID keys with `npx web-push generate-vapid-keys`. +7. Build and deploy: + ```sh + SKIP_ENV_VALIDATION=1 pnpm build && npx wrangler deploy + ``` + +Local development: put the same values in `.dev.vars` (git-ignored, with `NEXTAUTH_URL=http://localhost:8787`), +run `npx wrangler d1 migrations apply splitpro --local`, then `npx wrangler dev --port 8787`. + +## Cloudflare Worker Previews + +`wrangler.example.jsonc` includes a `previews` block with non-production settings and D1/R2 bindings. +Run `npx wrangler preview --name ` from the branch you want to preview; Wrangler +defaults the name to the current Git branch. Set `NEXTAUTH_URL` in that branch's `previews.vars` to +its actual Preview URL. Preview URLs are public unless protected by Cloudflare Access. + +The example D1 ID and Access audience are placeholders. Create a non-production D1 database and R2 +bucket, then set their IDs/names in the `previews` block. D1/R2 resources are shared whenever +previews use the same ID/name. For per-branch isolation, create separate resources for each branch, +update that branch's `previews` bindings and D1 migration target, and apply migrations to that D1 +before deploying its Preview. Do not point Preview bindings at production resources. + +When protecting a Preview with Cloudflare Access, set `PREVIEW_ACCESS_AUTH=true`, set +`PREVIEW_ACCESS_AUD` to the Access application's audience tag, and ensure the Access policy covers +the Preview hostname. The Worker verifies the Access identity and maps it to an app session; this +setting belongs only in `previews.vars`, not production `vars`. + +## Known gaps + +- Test coverage is small and focused on the riskiest paths (see below). There are no unit or component tests. +- Plaid / GoCardless bank sync and the Splitwise import are untested on Workers. +- Only Google sign-in is wired up. There is no email or password login. +- `drizzle-kit` and `wrangler d1 migrations` own the schema. `migrations/0001_balance_view_and_triggers.sql` + is hand-written (the `BalanceView` view and a trigger). Back it up before regenerating migrations. + +## Tests + +```sh +pnpm test:integration # Vitest in workerd with a real local D1 and the production migrations +pnpm build && pnpm test:e2e # Playwright against the built Worker (wrangler dev), 4 parallel workers +``` + +- `src/tests/integration`: authorization, balances and simplification, expense create/edit/delete + atomicity, settlements, recurring expenses (including idempotent retries), and regression tests + for D1 query bugs. +- `tests/e2e`: sign-in redirect, signed-in session, groups, expenses, settle up, a stranger being + blocked, and recurring expenses, all against the production bundle. Google sign-in cannot be + automated, so the tests seed users and signed `better-auth` session cookies in a throwaway D1. +- CI (`.github/workflows/ci.yml`) runs typecheck, integration, and e2e as three parallel jobs with + the pnpm store, Playwright browsers, and the build cached. The target is under 2 minutes. + +Bug reports for this fork go to this repository's issues. Please do not report them upstream. diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 000000000..812b78ba1 --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'drizzle-kit'; + +// Generates SQL migrations only. They are applied with `wrangler d1 migrations apply`. +export default defineConfig({ + dialect: 'sqlite', + schema: './src/server/db/schema.ts', + out: './migrations', +}); diff --git a/jest.config.ts b/jest.config.ts deleted file mode 100644 index f45658bc4..000000000 --- a/jest.config.ts +++ /dev/null @@ -1,214 +0,0 @@ -/** - * For a detailed explanation regarding each configuration property, visit: - * https://jestjs.io/docs/configuration - */ - -import type { Config } from 'jest'; -import nextJest from 'next/jest.js'; - -// @ts-expect-error we are extending BigInt prototype for JSON serialization -// oxlint-disable-next-line no-extend-native -BigInt.prototype.toJSON = function toJSON() { - // Custom JSON serialization for BigInt to avoid errors in Jest - return this.toString(); -}; - -const createJestConfig = nextJest({ - // Provide the path to your Next.js app to load next.config.js and .env files in your test environment - dir: './', -}); - -const config: Config = { - // All imported modules in your tests should be mocked automatically - // automock: false, - - // Stop running tests after `n` failures - // bail: 0, - - // The directory where Jest should store its cached dependency information - // cacheDirectory: "C:\\Users\\Wiktor\\AppData\\Local\\Temp\\jest", - - // Automatically clear mock calls, instances, contexts and results before every test - // clearMocks: false, - - // Indicates whether the coverage information should be collected while executing the test - // collectCoverage: false, - - // An array of glob patterns indicating a set of files for which coverage information should be collected - // collectCoverageFrom: undefined, - - // The directory where Jest should output its coverage files - // coverageDirectory: undefined, - - // An array of regexp pattern strings used to skip coverage collection - // coveragePathIgnorePatterns: [ - // "\\\\node_modules\\\\" - // ], - - // Indicates which provider should be used to instrument code for coverage - coverageProvider: 'v8', - - // A list of reporter names that Jest uses when writing coverage reports - // coverageReporters: [ - // "json", - // "text", - // "lcov", - // "clover" - // ], - - // An object that configures minimum threshold enforcement for coverage results - // coverageThreshold: undefined, - - // A path to a custom dependency extractor - // dependencyExtractor: undefined, - - // Make calling deprecated APIs throw helpful error messages - // errorOnDeprecated: false, - - // The default configuration for fake timers - // fakeTimers: { - // "enableGlobally": false - // }, - - // Force coverage collection from ignored files using an array of glob patterns - // forceCoverageMatch: [], - - // A path to a module which exports an async function that is triggered once before all test suites - // globalSetup: undefined, - - // A path to a module which exports an async function that is triggered once after all test suites - // globalTeardown: undefined, - - // A set of global variables that need to be available in all test environments - // globals: {}, - - // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. - // maxWorkers: "50%", - - // An array of directory names to be searched recursively up from the requiring module's location - // moduleDirectories: [ - // "node_modules" - // ], - - // An array of file extensions your modules use - // moduleFileExtensions: [ - // "js", - // "mjs", - // "cjs", - // "jsx", - // "ts", - // "tsx", - // "json", - // "node" - // ], - - // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module - moduleNameMapper: { - '^~/(.*)$': '/src/$1', - }, - - // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader - // modulePathIgnorePatterns: [], - - // Activates notifications for test results - // notify: false, - - // An enum that specifies notification mode. Requires { notify: true } - // notifyMode: "failure-change", - - // A preset that is used as a base for Jest's configuration - // preset: undefined, - - // Run tests from one or more projects - // projects: undefined, - - // Use this configuration option to add custom reporters to Jest - // reporters: undefined, - - // Automatically reset mock state before every test - // resetMocks: false, - - // Reset the module registry before running each individual test - // resetModules: false, - - // A path to a custom resolver - // resolver: undefined, - - // Automatically restore mock state and implementation before every test - // restoreMocks: false, - - // The root directory that Jest should scan for tests and modules within - // rootDir: undefined, - - // A list of paths to directories that Jest should use to search for files in - // roots: [ - // "" - // ], - - // Allows you to use a custom runner instead of Jest's default test runner - // runner: "jest-runner", - - // The paths to modules that run some code to configure or set up the testing environment before each test - // setupFiles: [], - - // A list of paths to modules that run some code to configure or set up the testing framework before each test - // setupFilesAfterEnv: [], - - // The number of seconds after which a test is considered as slow and reported as such in the results. - // slowTestThreshold: 5, - - // A list of paths to snapshot serializer modules Jest should use for snapshot testing - // snapshotSerializers: [], - - // The test environment that will be used for testing - testEnvironment: 'jsdom', - - // Options that will be passed to the testEnvironment - // testEnvironmentOptions: {}, - - // Adds a location field to test results - // testLocationInResults: false, - - // The glob patterns Jest uses to detect test files - // testMatch: [ - // "**/__tests__/**/*.[jt]s?(x)", - // "**/?(*.)+(spec|test).[tj]s?(x)" - // ], - - // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped - // testPathIgnorePatterns: [ - // "\\\\node_modules\\\\" - // ], - - // The regexp pattern or array of patterns that Jest uses to detect test files - // testRegex: [], - - // This option allows the use of a custom results processor - // testResultsProcessor: undefined, - - // This option allows use of a custom test runner - // testRunner: "jest-circus/runner", - - // A map from regular expressions to paths to transformers - // transform: undefined, - - // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation - // transformIgnorePatterns: [ - // "\\\\node_modules\\\\", - // "\\.pnp\\.[^\\\\]+$" - // ], - - // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them - // unmockedModulePathPatterns: undefined, - - // Indicates whether each individual test should be reported during the run - // verbose: undefined, - - // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode - // watchPathIgnorePatterns: [], - - // Whether to use watchman for file crawling - // watchman: true, -}; - -export default createJestConfig(config); diff --git a/migrations/0000_init.sql b/migrations/0000_init.sql new file mode 100644 index 000000000..30e3f6b2b --- /dev/null +++ b/migrations/0000_init.sql @@ -0,0 +1,217 @@ +CREATE TABLE `Account` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `userId` integer NOT NULL, + `accountId` text NOT NULL, + `providerId` text NOT NULL, + `accessToken` text, + `refreshToken` text, + `idToken` text, + `accessTokenExpiresAt` integer, + `refreshTokenExpiresAt` integer, + `scope` text, + `password` text, + `createdAt` integer NOT NULL, + `updatedAt` integer NOT NULL, + FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `Account_providerId_accountId_key` ON `Account` (`providerId`,`accountId`);--> statement-breakpoint +CREATE INDEX `Account_userId_idx` ON `Account` (`userId`);--> statement-breakpoint +CREATE TABLE `AppMetadata` ( + `key` text PRIMARY KEY NOT NULL, + `value` text NOT NULL, + `createdAt` integer NOT NULL, + `updatedAt` integer NOT NULL +); +--> statement-breakpoint +CREATE TABLE `CachedBankData` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `obapiProviderId` text NOT NULL, + `data` text NOT NULL, + `userId` integer NOT NULL, + `lastFetched` integer NOT NULL, + `createdAt` integer NOT NULL, + `updatedAt` integer NOT NULL, + FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `CachedBankData_obapiProviderId_unique` ON `CachedBankData` (`obapiProviderId`);--> statement-breakpoint +CREATE TABLE `CachedCurrencyRate` ( + `from` text NOT NULL, + `to` text NOT NULL, + `date` integer NOT NULL, + `rate` real NOT NULL, + `lastFetched` integer NOT NULL, + `createdAt` integer NOT NULL, + `updatedAt` integer NOT NULL, + PRIMARY KEY(`from`, `to`, `date`) +); +--> statement-breakpoint +CREATE TABLE `ExpenseNote` ( + `id` text PRIMARY KEY NOT NULL, + `note` text NOT NULL, + `createdById` integer NOT NULL, + `createdAt` integer NOT NULL, + `expenseId` text NOT NULL, + FOREIGN KEY (`createdById`) REFERENCES `User`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`expenseId`) REFERENCES `Expense`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `ExpenseParticipant` ( + `expenseId` text NOT NULL, + `userId` integer NOT NULL, + `amount` integer NOT NULL, + PRIMARY KEY(`expenseId`, `userId`), + FOREIGN KEY (`expenseId`) REFERENCES `Expense`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `ExpenseParticipant_userId_idx` ON `ExpenseParticipant` (`userId`);--> statement-breakpoint +CREATE TABLE `ExpenseRecurrence` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `jobId` integer NOT NULL, + `notified` integer DEFAULT true NOT NULL, + FOREIGN KEY (`jobId`) REFERENCES `job`(`jobid`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `ExpenseRecurrence_jobId_unique` ON `ExpenseRecurrence` (`jobId`);--> statement-breakpoint +CREATE TABLE `Expense` ( + `id` text PRIMARY KEY NOT NULL, + `paidBy` integer NOT NULL, + `addedBy` integer NOT NULL, + `name` text NOT NULL, + `category` text NOT NULL, + `amount` integer NOT NULL, + `splitType` text DEFAULT 'EQUAL' NOT NULL, + `expenseDate` integer NOT NULL, + `createdAt` integer NOT NULL, + `updatedAt` integer NOT NULL, + `currency` text NOT NULL, + `fileKey` text, + `groupId` integer, + `deletedAt` integer, + `deletedBy` integer, + `updatedBy` integer, + `transactionId` text, + `recurrenceId` integer, + `conversionToId` text, + FOREIGN KEY (`paidBy`) REFERENCES `User`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`addedBy`) REFERENCES `User`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`groupId`) REFERENCES `Group`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`deletedBy`) REFERENCES `User`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`updatedBy`) REFERENCES `User`(`id`) ON UPDATE no action ON DELETE set null, + FOREIGN KEY (`recurrenceId`) REFERENCES `ExpenseRecurrence`(`id`) ON UPDATE no action ON DELETE set null, + FOREIGN KEY (`conversionToId`) REFERENCES `Expense`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `Expense_conversionToId_unique` ON `Expense` (`conversionToId`);--> statement-breakpoint +CREATE INDEX `Expense_groupId_idx` ON `Expense` (`groupId`);--> statement-breakpoint +CREATE INDEX `Expense_paidBy_idx` ON `Expense` (`paidBy`);--> statement-breakpoint +CREATE INDEX `Expense_recurrenceId_idx` ON `Expense` (`recurrenceId`);--> statement-breakpoint +CREATE TABLE `FriendDefaultSplit` ( + `userAId` integer NOT NULL, + `userBId` integer NOT NULL, + `splitType` text NOT NULL, + `shares` text NOT NULL, + `createdAt` integer NOT NULL, + `updatedAt` integer NOT NULL, + PRIMARY KEY(`userAId`, `userBId`), + FOREIGN KEY (`userAId`) REFERENCES `User`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`userBId`) REFERENCES `User`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `GroupDefaultSplit` ( + `groupId` integer PRIMARY KEY NOT NULL, + `splitType` text NOT NULL, + `shares` text NOT NULL, + `createdAt` integer NOT NULL, + `updatedAt` integer NOT NULL, + FOREIGN KEY (`groupId`) REFERENCES `Group`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `GroupUser` ( + `groupId` integer NOT NULL, + `userId` integer NOT NULL, + PRIMARY KEY(`groupId`, `userId`), + FOREIGN KEY (`groupId`) REFERENCES `Group`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `GroupUser_userId_idx` ON `GroupUser` (`userId`);--> statement-breakpoint +CREATE TABLE `Group` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `publicId` text NOT NULL, + `name` text NOT NULL, + `image` text, + `userId` integer NOT NULL, + `defaultCurrency` text, + `createdAt` integer NOT NULL, + `updatedAt` integer NOT NULL, + `splitwiseGroupId` text, + `simplifyDebts` integer DEFAULT false NOT NULL, + `archivedAt` integer, + FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `Group_publicId_unique` ON `Group` (`publicId`);--> statement-breakpoint +CREATE UNIQUE INDEX `Group_splitwiseGroupId_unique` ON `Group` (`splitwiseGroupId`);--> statement-breakpoint +CREATE TABLE `job` ( + `jobid` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `schedule` text NOT NULL, + `command` text NOT NULL, + `jobname` text NOT NULL, + `active` integer DEFAULT true NOT NULL, + `nextRunAt` integer, + `lastRunAt` integer +); +--> statement-breakpoint +CREATE UNIQUE INDEX `job_jobname_unique` ON `job` (`jobname`);--> statement-breakpoint +CREATE INDEX `job_active_nextRunAt_idx` ON `job` (`active`,`nextRunAt`);--> statement-breakpoint +CREATE TABLE `PushNotification` ( + `userId` integer NOT NULL, + `endpoint` text NOT NULL, + `subscription` text NOT NULL, + PRIMARY KEY(`userId`, `endpoint`) +); +--> statement-breakpoint +CREATE TABLE `Session` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `token` text NOT NULL, + `userId` integer NOT NULL, + `expiresAt` integer NOT NULL, + `ipAddress` text, + `userAgent` text, + `createdAt` integer NOT NULL, + `updatedAt` integer NOT NULL, + FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `Session_token_unique` ON `Session` (`token`);--> statement-breakpoint +CREATE INDEX `Session_userId_idx` ON `Session` (`userId`);--> statement-breakpoint +CREATE TABLE `User` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `name` text, + `email` text, + `emailVerified` integer DEFAULT false NOT NULL, + `image` text, + `currency` text DEFAULT 'USD' NOT NULL, + `defaultCurrency` text, + `preferredLanguage` text DEFAULT '' NOT NULL, + `bankingId` text, + `obapiProviderId` text, + `hiddenFriendIds` text DEFAULT '[]' NOT NULL, + `createdAt` integer NOT NULL, + `updatedAt` integer NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `User_email_unique` ON `User` (`email`);--> statement-breakpoint +CREATE TABLE `Verification` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `identifier` text NOT NULL, + `value` text NOT NULL, + `expiresAt` integer NOT NULL, + `createdAt` integer NOT NULL, + `updatedAt` integer NOT NULL +); +--> statement-breakpoint +CREATE INDEX `Verification_identifier_idx` ON `Verification` (`identifier`); \ No newline at end of file diff --git a/migrations/0001_balance_view_and_triggers.sql b/migrations/0001_balance_view_and_triggers.sql new file mode 100644 index 000000000..27b4bd20c --- /dev/null +++ b/migrations/0001_balance_view_and_triggers.sql @@ -0,0 +1,56 @@ +-- Port of the Postgres BalanceView. Balances are derived from non-deleted expenses. +-- Each pair of users appears twice: once from each user's perspective. +CREATE VIEW `BalanceView` AS +WITH `BaseBalance` AS ( + SELECT + CASE WHEN ep.`userId` < e.`paidBy` THEN ep.`userId` ELSE e.`paidBy` END AS `user_id_A`, + CASE WHEN ep.`userId` < e.`paidBy` THEN e.`paidBy` ELSE ep.`userId` END AS `user_id_B`, + e.`groupId` AS `groupId`, + e.`currency` AS `currency`, + SUM(ep.`amount` * (CASE WHEN ep.`userId` < e.`paidBy` THEN 1 ELSE -1 END)) AS `net_amount`, + MIN(e.`createdAt`) AS `createdAt`, + MAX(e.`updatedAt`) AS `updatedAt` + FROM `ExpenseParticipant` AS ep + JOIN `Expense` AS e ON ep.`expenseId` = e.`id` + WHERE ep.`userId` != e.`paidBy` + AND e.`deletedAt` IS NULL + GROUP BY `user_id_A`, `user_id_B`, e.`groupId`, e.`currency` +) +SELECT `user_id_A` AS `userId`, `user_id_B` AS `friendId`, `groupId`, `currency`, + `net_amount` AS `amount`, `createdAt`, `updatedAt` +FROM `BaseBalance` +UNION ALL +SELECT `user_id_B` AS `userId`, `user_id_A` AS `friendId`, `groupId`, `currency`, + -`net_amount` AS `amount`, `createdAt`, `updatedAt` +FROM `BaseBalance`; +--> statement-breakpoint +-- Port of the plpgsql trigger auto_unhide_friend: a new expense between two users removes +-- each user from the other's hiddenFriendIds (a JSON array of user ids). +CREATE TRIGGER `trigger_auto_unhide_friend` +AFTER INSERT ON `ExpenseParticipant` +FOR EACH ROW +BEGIN + UPDATE `User` + SET `hiddenFriendIds` = ( + SELECT COALESCE(json_group_array(j.value), '[]') + FROM json_each(`User`.`hiddenFriendIds`) AS j + WHERE j.value != (SELECT `paidBy` FROM `Expense` WHERE `id` = NEW.`expenseId`) + ) + WHERE `id` = NEW.`userId` + AND EXISTS ( + SELECT 1 FROM json_each(`User`.`hiddenFriendIds`) AS j + WHERE j.value = (SELECT `paidBy` FROM `Expense` WHERE `id` = NEW.`expenseId`) + ); + + UPDATE `User` + SET `hiddenFriendIds` = ( + SELECT COALESCE(json_group_array(j.value), '[]') + FROM json_each(`User`.`hiddenFriendIds`) AS j + WHERE j.value != NEW.`userId` + ) + WHERE `id` = (SELECT `paidBy` FROM `Expense` WHERE `id` = NEW.`expenseId`) + AND EXISTS ( + SELECT 1 FROM json_each(`User`.`hiddenFriendIds`) AS j + WHERE j.value = NEW.`userId` + ); +END; diff --git a/migrations/meta/0000_snapshot.json b/migrations/meta/0000_snapshot.json new file mode 100644 index 000000000..e57147ba0 --- /dev/null +++ b/migrations/meta/0000_snapshot.json @@ -0,0 +1,1432 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "443527a6-cf22-4b16-a364-b3689a6b7faa", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "Account": { + "name": "Account", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accountId": { + "name": "accountId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "providerId": { + "name": "providerId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accessToken": { + "name": "accessToken", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refreshToken": { + "name": "refreshToken", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "idToken": { + "name": "idToken", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "accessTokenExpiresAt": { + "name": "accessTokenExpiresAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refreshTokenExpiresAt": { + "name": "refreshTokenExpiresAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "Account_providerId_accountId_key": { + "name": "Account_providerId_accountId_key", + "columns": ["providerId", "accountId"], + "isUnique": true + }, + "Account_userId_idx": { + "name": "Account_userId_idx", + "columns": ["userId"], + "isUnique": false + } + }, + "foreignKeys": { + "Account_userId_User_id_fk": { + "name": "Account_userId_User_id_fk", + "tableFrom": "Account", + "tableTo": "User", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "AppMetadata": { + "name": "AppMetadata", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "CachedBankData": { + "name": "CachedBankData", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "obapiProviderId": { + "name": "obapiProviderId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lastFetched": { + "name": "lastFetched", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "CachedBankData_obapiProviderId_unique": { + "name": "CachedBankData_obapiProviderId_unique", + "columns": ["obapiProviderId"], + "isUnique": true + } + }, + "foreignKeys": { + "CachedBankData_userId_User_id_fk": { + "name": "CachedBankData_userId_User_id_fk", + "tableFrom": "CachedBankData", + "tableTo": "User", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "CachedCurrencyRate": { + "name": "CachedCurrencyRate", + "columns": { + "from": { + "name": "from", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "to": { + "name": "to", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rate": { + "name": "rate", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lastFetched": { + "name": "lastFetched", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "CachedCurrencyRate_from_to_date_pk": { + "columns": ["from", "to", "date"], + "name": "CachedCurrencyRate_from_to_date_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ExpenseNote": { + "name": "ExpenseNote", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdById": { + "name": "createdById", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expenseId": { + "name": "expenseId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "ExpenseNote_createdById_User_id_fk": { + "name": "ExpenseNote_createdById_User_id_fk", + "tableFrom": "ExpenseNote", + "tableTo": "User", + "columnsFrom": ["createdById"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ExpenseNote_expenseId_Expense_id_fk": { + "name": "ExpenseNote_expenseId_Expense_id_fk", + "tableFrom": "ExpenseNote", + "tableTo": "Expense", + "columnsFrom": ["expenseId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ExpenseParticipant": { + "name": "ExpenseParticipant", + "columns": { + "expenseId": { + "name": "expenseId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "ExpenseParticipant_userId_idx": { + "name": "ExpenseParticipant_userId_idx", + "columns": ["userId"], + "isUnique": false + } + }, + "foreignKeys": { + "ExpenseParticipant_expenseId_Expense_id_fk": { + "name": "ExpenseParticipant_expenseId_Expense_id_fk", + "tableFrom": "ExpenseParticipant", + "tableTo": "Expense", + "columnsFrom": ["expenseId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ExpenseParticipant_userId_User_id_fk": { + "name": "ExpenseParticipant_userId_User_id_fk", + "tableFrom": "ExpenseParticipant", + "tableTo": "User", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ExpenseParticipant_expenseId_userId_pk": { + "columns": ["expenseId", "userId"], + "name": "ExpenseParticipant_expenseId_userId_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ExpenseRecurrence": { + "name": "ExpenseRecurrence", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "jobId": { + "name": "jobId", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "notified": { + "name": "notified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + } + }, + "indexes": { + "ExpenseRecurrence_jobId_unique": { + "name": "ExpenseRecurrence_jobId_unique", + "columns": ["jobId"], + "isUnique": true + } + }, + "foreignKeys": { + "ExpenseRecurrence_jobId_job_jobid_fk": { + "name": "ExpenseRecurrence_jobId_job_jobid_fk", + "tableFrom": "ExpenseRecurrence", + "tableTo": "job", + "columnsFrom": ["jobId"], + "columnsTo": ["jobid"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "Expense": { + "name": "Expense", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "paidBy": { + "name": "paidBy", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "addedBy": { + "name": "addedBy", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "splitType": { + "name": "splitType", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'EQUAL'" + }, + "expenseDate": { + "name": "expenseDate", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fileKey": { + "name": "fileKey", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "groupId": { + "name": "groupId", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deletedAt": { + "name": "deletedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deletedBy": { + "name": "deletedBy", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updatedBy": { + "name": "updatedBy", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "transactionId": { + "name": "transactionId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recurrenceId": { + "name": "recurrenceId", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "conversionToId": { + "name": "conversionToId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "Expense_conversionToId_unique": { + "name": "Expense_conversionToId_unique", + "columns": ["conversionToId"], + "isUnique": true + }, + "Expense_groupId_idx": { + "name": "Expense_groupId_idx", + "columns": ["groupId"], + "isUnique": false + }, + "Expense_paidBy_idx": { + "name": "Expense_paidBy_idx", + "columns": ["paidBy"], + "isUnique": false + }, + "Expense_recurrenceId_idx": { + "name": "Expense_recurrenceId_idx", + "columns": ["recurrenceId"], + "isUnique": false + } + }, + "foreignKeys": { + "Expense_paidBy_User_id_fk": { + "name": "Expense_paidBy_User_id_fk", + "tableFrom": "Expense", + "tableTo": "User", + "columnsFrom": ["paidBy"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "Expense_addedBy_User_id_fk": { + "name": "Expense_addedBy_User_id_fk", + "tableFrom": "Expense", + "tableTo": "User", + "columnsFrom": ["addedBy"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "Expense_groupId_Group_id_fk": { + "name": "Expense_groupId_Group_id_fk", + "tableFrom": "Expense", + "tableTo": "Group", + "columnsFrom": ["groupId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "Expense_deletedBy_User_id_fk": { + "name": "Expense_deletedBy_User_id_fk", + "tableFrom": "Expense", + "tableTo": "User", + "columnsFrom": ["deletedBy"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "Expense_updatedBy_User_id_fk": { + "name": "Expense_updatedBy_User_id_fk", + "tableFrom": "Expense", + "tableTo": "User", + "columnsFrom": ["updatedBy"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "Expense_recurrenceId_ExpenseRecurrence_id_fk": { + "name": "Expense_recurrenceId_ExpenseRecurrence_id_fk", + "tableFrom": "Expense", + "tableTo": "ExpenseRecurrence", + "columnsFrom": ["recurrenceId"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "Expense_conversionToId_Expense_id_fk": { + "name": "Expense_conversionToId_Expense_id_fk", + "tableFrom": "Expense", + "tableTo": "Expense", + "columnsFrom": ["conversionToId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "FriendDefaultSplit": { + "name": "FriendDefaultSplit", + "columns": { + "userAId": { + "name": "userAId", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userBId": { + "name": "userBId", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "splitType": { + "name": "splitType", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "shares": { + "name": "shares", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "FriendDefaultSplit_userAId_User_id_fk": { + "name": "FriendDefaultSplit_userAId_User_id_fk", + "tableFrom": "FriendDefaultSplit", + "tableTo": "User", + "columnsFrom": ["userAId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "FriendDefaultSplit_userBId_User_id_fk": { + "name": "FriendDefaultSplit_userBId_User_id_fk", + "tableFrom": "FriendDefaultSplit", + "tableTo": "User", + "columnsFrom": ["userBId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "FriendDefaultSplit_userAId_userBId_pk": { + "columns": ["userAId", "userBId"], + "name": "FriendDefaultSplit_userAId_userBId_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "GroupDefaultSplit": { + "name": "GroupDefaultSplit", + "columns": { + "groupId": { + "name": "groupId", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "splitType": { + "name": "splitType", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "shares": { + "name": "shares", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "GroupDefaultSplit_groupId_Group_id_fk": { + "name": "GroupDefaultSplit_groupId_Group_id_fk", + "tableFrom": "GroupDefaultSplit", + "tableTo": "Group", + "columnsFrom": ["groupId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "GroupUser": { + "name": "GroupUser", + "columns": { + "groupId": { + "name": "groupId", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "GroupUser_userId_idx": { + "name": "GroupUser_userId_idx", + "columns": ["userId"], + "isUnique": false + } + }, + "foreignKeys": { + "GroupUser_groupId_Group_id_fk": { + "name": "GroupUser_groupId_Group_id_fk", + "tableFrom": "GroupUser", + "tableTo": "Group", + "columnsFrom": ["groupId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "GroupUser_userId_User_id_fk": { + "name": "GroupUser_userId_User_id_fk", + "tableFrom": "GroupUser", + "tableTo": "User", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "GroupUser_groupId_userId_pk": { + "columns": ["groupId", "userId"], + "name": "GroupUser_groupId_userId_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "Group": { + "name": "Group", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "publicId": { + "name": "publicId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "defaultCurrency": { + "name": "defaultCurrency", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "splitwiseGroupId": { + "name": "splitwiseGroupId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "simplifyDebts": { + "name": "simplifyDebts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "archivedAt": { + "name": "archivedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "Group_publicId_unique": { + "name": "Group_publicId_unique", + "columns": ["publicId"], + "isUnique": true + }, + "Group_splitwiseGroupId_unique": { + "name": "Group_splitwiseGroupId_unique", + "columns": ["splitwiseGroupId"], + "isUnique": true + } + }, + "foreignKeys": { + "Group_userId_User_id_fk": { + "name": "Group_userId_User_id_fk", + "tableFrom": "Group", + "tableTo": "User", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "job": { + "name": "job", + "columns": { + "jobid": { + "name": "jobid", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "schedule": { + "name": "schedule", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "jobname": { + "name": "jobname", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active": { + "name": "active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "nextRunAt": { + "name": "nextRunAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastRunAt": { + "name": "lastRunAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "job_jobname_unique": { + "name": "job_jobname_unique", + "columns": ["jobname"], + "isUnique": true + }, + "job_active_nextRunAt_idx": { + "name": "job_active_nextRunAt_idx", + "columns": ["active", "nextRunAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "PushNotification": { + "name": "PushNotification", + "columns": { + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subscription": { + "name": "subscription", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "PushNotification_userId_endpoint_pk": { + "columns": ["userId", "endpoint"], + "name": "PushNotification_userId_endpoint_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "Session": { + "name": "Session", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ipAddress": { + "name": "ipAddress", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "userAgent": { + "name": "userAgent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "Session_token_unique": { + "name": "Session_token_unique", + "columns": ["token"], + "isUnique": true + }, + "Session_userId_idx": { + "name": "Session_userId_idx", + "columns": ["userId"], + "isUnique": false + } + }, + "foreignKeys": { + "Session_userId_User_id_fk": { + "name": "Session_userId_User_id_fk", + "tableFrom": "Session", + "tableTo": "User", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "User": { + "name": "User", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'USD'" + }, + "defaultCurrency": { + "name": "defaultCurrency", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "preferredLanguage": { + "name": "preferredLanguage", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "bankingId": { + "name": "bankingId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "obapiProviderId": { + "name": "obapiProviderId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hiddenFriendIds": { + "name": "hiddenFriendIds", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "User_email_unique": { + "name": "User_email_unique", + "columns": ["email"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "Verification": { + "name": "Verification", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "Verification_identifier_idx": { + "name": "Verification_identifier_idx", + "columns": ["identifier"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": { + "BalanceView": { + "columns": { + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "friendId": { + "name": "friendId", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "groupId": { + "name": "groupId", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "name": "BalanceView", + "isExisting": true + } + }, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/migrations/meta/0001_snapshot.json b/migrations/meta/0001_snapshot.json new file mode 100644 index 000000000..b947337e7 --- /dev/null +++ b/migrations/meta/0001_snapshot.json @@ -0,0 +1,1432 @@ +{ + "id": "f87ff28c-f7b9-4cf9-a679-996cf895a292", + "prevId": "443527a6-cf22-4b16-a364-b3689a6b7faa", + "version": "6", + "dialect": "sqlite", + "tables": { + "Account": { + "name": "Account", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accountId": { + "name": "accountId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "providerId": { + "name": "providerId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accessToken": { + "name": "accessToken", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refreshToken": { + "name": "refreshToken", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "idToken": { + "name": "idToken", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "accessTokenExpiresAt": { + "name": "accessTokenExpiresAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refreshTokenExpiresAt": { + "name": "refreshTokenExpiresAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "Account_providerId_accountId_key": { + "name": "Account_providerId_accountId_key", + "columns": ["providerId", "accountId"], + "isUnique": true + }, + "Account_userId_idx": { + "name": "Account_userId_idx", + "columns": ["userId"], + "isUnique": false + } + }, + "foreignKeys": { + "Account_userId_User_id_fk": { + "name": "Account_userId_User_id_fk", + "tableFrom": "Account", + "columnsFrom": ["userId"], + "tableTo": "User", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "AppMetadata": { + "name": "AppMetadata", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "CachedBankData": { + "name": "CachedBankData", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "obapiProviderId": { + "name": "obapiProviderId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lastFetched": { + "name": "lastFetched", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "CachedBankData_obapiProviderId_unique": { + "name": "CachedBankData_obapiProviderId_unique", + "columns": ["obapiProviderId"], + "isUnique": true + } + }, + "foreignKeys": { + "CachedBankData_userId_User_id_fk": { + "name": "CachedBankData_userId_User_id_fk", + "tableFrom": "CachedBankData", + "columnsFrom": ["userId"], + "tableTo": "User", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "CachedCurrencyRate": { + "name": "CachedCurrencyRate", + "columns": { + "from": { + "name": "from", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "to": { + "name": "to", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rate": { + "name": "rate", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lastFetched": { + "name": "lastFetched", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "CachedCurrencyRate_from_to_date_pk": { + "columns": ["from", "to", "date"], + "name": "CachedCurrencyRate_from_to_date_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ExpenseNote": { + "name": "ExpenseNote", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdById": { + "name": "createdById", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expenseId": { + "name": "expenseId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "ExpenseNote_createdById_User_id_fk": { + "name": "ExpenseNote_createdById_User_id_fk", + "tableFrom": "ExpenseNote", + "columnsFrom": ["createdById"], + "tableTo": "User", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "ExpenseNote_expenseId_Expense_id_fk": { + "name": "ExpenseNote_expenseId_Expense_id_fk", + "tableFrom": "ExpenseNote", + "columnsFrom": ["expenseId"], + "tableTo": "Expense", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ExpenseParticipant": { + "name": "ExpenseParticipant", + "columns": { + "expenseId": { + "name": "expenseId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "ExpenseParticipant_userId_idx": { + "name": "ExpenseParticipant_userId_idx", + "columns": ["userId"], + "isUnique": false + } + }, + "foreignKeys": { + "ExpenseParticipant_expenseId_Expense_id_fk": { + "name": "ExpenseParticipant_expenseId_Expense_id_fk", + "tableFrom": "ExpenseParticipant", + "columnsFrom": ["expenseId"], + "tableTo": "Expense", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "ExpenseParticipant_userId_User_id_fk": { + "name": "ExpenseParticipant_userId_User_id_fk", + "tableFrom": "ExpenseParticipant", + "columnsFrom": ["userId"], + "tableTo": "User", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": { + "ExpenseParticipant_expenseId_userId_pk": { + "columns": ["expenseId", "userId"], + "name": "ExpenseParticipant_expenseId_userId_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ExpenseRecurrence": { + "name": "ExpenseRecurrence", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "jobId": { + "name": "jobId", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "notified": { + "name": "notified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + } + }, + "indexes": { + "ExpenseRecurrence_jobId_unique": { + "name": "ExpenseRecurrence_jobId_unique", + "columns": ["jobId"], + "isUnique": true + } + }, + "foreignKeys": { + "ExpenseRecurrence_jobId_job_jobid_fk": { + "name": "ExpenseRecurrence_jobId_job_jobid_fk", + "tableFrom": "ExpenseRecurrence", + "columnsFrom": ["jobId"], + "tableTo": "job", + "columnsTo": ["jobid"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "Expense": { + "name": "Expense", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "paidBy": { + "name": "paidBy", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "addedBy": { + "name": "addedBy", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "splitType": { + "name": "splitType", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'EQUAL'" + }, + "expenseDate": { + "name": "expenseDate", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fileKey": { + "name": "fileKey", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "groupId": { + "name": "groupId", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deletedAt": { + "name": "deletedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deletedBy": { + "name": "deletedBy", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updatedBy": { + "name": "updatedBy", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "transactionId": { + "name": "transactionId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recurrenceId": { + "name": "recurrenceId", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "conversionToId": { + "name": "conversionToId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "Expense_conversionToId_unique": { + "name": "Expense_conversionToId_unique", + "columns": ["conversionToId"], + "isUnique": true + }, + "Expense_groupId_idx": { + "name": "Expense_groupId_idx", + "columns": ["groupId"], + "isUnique": false + }, + "Expense_paidBy_idx": { + "name": "Expense_paidBy_idx", + "columns": ["paidBy"], + "isUnique": false + }, + "Expense_recurrenceId_idx": { + "name": "Expense_recurrenceId_idx", + "columns": ["recurrenceId"], + "isUnique": false + } + }, + "foreignKeys": { + "Expense_paidBy_User_id_fk": { + "name": "Expense_paidBy_User_id_fk", + "tableFrom": "Expense", + "columnsFrom": ["paidBy"], + "tableTo": "User", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "Expense_addedBy_User_id_fk": { + "name": "Expense_addedBy_User_id_fk", + "tableFrom": "Expense", + "columnsFrom": ["addedBy"], + "tableTo": "User", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "Expense_groupId_Group_id_fk": { + "name": "Expense_groupId_Group_id_fk", + "tableFrom": "Expense", + "columnsFrom": ["groupId"], + "tableTo": "Group", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "Expense_deletedBy_User_id_fk": { + "name": "Expense_deletedBy_User_id_fk", + "tableFrom": "Expense", + "columnsFrom": ["deletedBy"], + "tableTo": "User", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "Expense_updatedBy_User_id_fk": { + "name": "Expense_updatedBy_User_id_fk", + "tableFrom": "Expense", + "columnsFrom": ["updatedBy"], + "tableTo": "User", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + }, + "Expense_recurrenceId_ExpenseRecurrence_id_fk": { + "name": "Expense_recurrenceId_ExpenseRecurrence_id_fk", + "tableFrom": "Expense", + "columnsFrom": ["recurrenceId"], + "tableTo": "ExpenseRecurrence", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + }, + "Expense_conversionToId_Expense_id_fk": { + "name": "Expense_conversionToId_Expense_id_fk", + "tableFrom": "Expense", + "columnsFrom": ["conversionToId"], + "tableTo": "Expense", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "FriendDefaultSplit": { + "name": "FriendDefaultSplit", + "columns": { + "userAId": { + "name": "userAId", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userBId": { + "name": "userBId", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "splitType": { + "name": "splitType", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "shares": { + "name": "shares", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "FriendDefaultSplit_userAId_User_id_fk": { + "name": "FriendDefaultSplit_userAId_User_id_fk", + "tableFrom": "FriendDefaultSplit", + "columnsFrom": ["userAId"], + "tableTo": "User", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "FriendDefaultSplit_userBId_User_id_fk": { + "name": "FriendDefaultSplit_userBId_User_id_fk", + "tableFrom": "FriendDefaultSplit", + "columnsFrom": ["userBId"], + "tableTo": "User", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": { + "FriendDefaultSplit_userAId_userBId_pk": { + "columns": ["userAId", "userBId"], + "name": "FriendDefaultSplit_userAId_userBId_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "GroupDefaultSplit": { + "name": "GroupDefaultSplit", + "columns": { + "groupId": { + "name": "groupId", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "splitType": { + "name": "splitType", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "shares": { + "name": "shares", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "GroupDefaultSplit_groupId_Group_id_fk": { + "name": "GroupDefaultSplit_groupId_Group_id_fk", + "tableFrom": "GroupDefaultSplit", + "columnsFrom": ["groupId"], + "tableTo": "Group", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "GroupUser": { + "name": "GroupUser", + "columns": { + "groupId": { + "name": "groupId", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "GroupUser_userId_idx": { + "name": "GroupUser_userId_idx", + "columns": ["userId"], + "isUnique": false + } + }, + "foreignKeys": { + "GroupUser_groupId_Group_id_fk": { + "name": "GroupUser_groupId_Group_id_fk", + "tableFrom": "GroupUser", + "columnsFrom": ["groupId"], + "tableTo": "Group", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "GroupUser_userId_User_id_fk": { + "name": "GroupUser_userId_User_id_fk", + "tableFrom": "GroupUser", + "columnsFrom": ["userId"], + "tableTo": "User", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": { + "GroupUser_groupId_userId_pk": { + "columns": ["groupId", "userId"], + "name": "GroupUser_groupId_userId_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "Group": { + "name": "Group", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "publicId": { + "name": "publicId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "defaultCurrency": { + "name": "defaultCurrency", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "splitwiseGroupId": { + "name": "splitwiseGroupId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "simplifyDebts": { + "name": "simplifyDebts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "archivedAt": { + "name": "archivedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "Group_publicId_unique": { + "name": "Group_publicId_unique", + "columns": ["publicId"], + "isUnique": true + }, + "Group_splitwiseGroupId_unique": { + "name": "Group_splitwiseGroupId_unique", + "columns": ["splitwiseGroupId"], + "isUnique": true + } + }, + "foreignKeys": { + "Group_userId_User_id_fk": { + "name": "Group_userId_User_id_fk", + "tableFrom": "Group", + "columnsFrom": ["userId"], + "tableTo": "User", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "job": { + "name": "job", + "columns": { + "jobid": { + "name": "jobid", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "schedule": { + "name": "schedule", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "jobname": { + "name": "jobname", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active": { + "name": "active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "nextRunAt": { + "name": "nextRunAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastRunAt": { + "name": "lastRunAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "job_jobname_unique": { + "name": "job_jobname_unique", + "columns": ["jobname"], + "isUnique": true + }, + "job_active_nextRunAt_idx": { + "name": "job_active_nextRunAt_idx", + "columns": ["active", "nextRunAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "PushNotification": { + "name": "PushNotification", + "columns": { + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subscription": { + "name": "subscription", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "PushNotification_userId_endpoint_pk": { + "columns": ["userId", "endpoint"], + "name": "PushNotification_userId_endpoint_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "Session": { + "name": "Session", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ipAddress": { + "name": "ipAddress", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "userAgent": { + "name": "userAgent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "Session_token_unique": { + "name": "Session_token_unique", + "columns": ["token"], + "isUnique": true + }, + "Session_userId_idx": { + "name": "Session_userId_idx", + "columns": ["userId"], + "isUnique": false + } + }, + "foreignKeys": { + "Session_userId_User_id_fk": { + "name": "Session_userId_User_id_fk", + "tableFrom": "Session", + "columnsFrom": ["userId"], + "tableTo": "User", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "User": { + "name": "User", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'USD'" + }, + "defaultCurrency": { + "name": "defaultCurrency", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "preferredLanguage": { + "name": "preferredLanguage", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "bankingId": { + "name": "bankingId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "obapiProviderId": { + "name": "obapiProviderId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hiddenFriendIds": { + "name": "hiddenFriendIds", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "User_email_unique": { + "name": "User_email_unique", + "columns": ["email"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "Verification": { + "name": "Verification", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "Verification_identifier_idx": { + "name": "Verification_identifier_idx", + "columns": ["identifier"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": { + "BalanceView": { + "name": "BalanceView", + "columns": { + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "friendId": { + "name": "friendId", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "groupId": { + "name": "groupId", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "isExisting": true + } + }, + "enums": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json new file mode 100644 index 000000000..f6805ee9c --- /dev/null +++ b/migrations/meta/_journal.json @@ -0,0 +1,20 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1791732987022, + "tag": "0000_init", + "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1791732989082, + "tag": "0001_balance_view_and_triggers", + "breakpoints": true + } + ] +} diff --git a/next-i18next.config.js b/next-i18next.config.js index b1c29aa5c..897584a08 100644 --- a/next-i18next.config.js +++ b/next-i18next.config.js @@ -23,6 +23,7 @@ const config = { localeDetection: false, }, localePath: './public/locales', + fallbackLng: 'en', }; export default config; diff --git a/next.config.js b/next.config.js index 2b98da871..3abce9f73 100644 --- a/next.config.js +++ b/next.config.js @@ -1,49 +1,16 @@ -import { PHASE_DEVELOPMENT_SERVER, PHASE_PRODUCTION_BUILD } from 'next/constants.js'; import i18nConfig from './next-i18next.config.js'; -import { fileURLToPath } from 'node:url'; -import { createJiti } from 'jiti'; -const jiti = createJiti(fileURLToPath(import.meta.url)); -import withSerwistInit from '@serwist/next'; - -/** @type {typeof import('./src/utils/env')} */ -const envUtils = await jiti.import('./src/utils/env'); -const { parseEnvBoolean } = envUtils; - -/** - * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially useful - * for Docker builds. - */ -await jiti.import('./src/env'); /** @type {import("next").NextConfig} */ const nextConfig = { reactStrictMode: true, - output: parseEnvBoolean(process.env.DOCKER_OUTPUT) ? 'standalone' : undefined, transpilePackages: ['@t3-oss/env-nextjs', '@t3-oss/env-core'], - /** - * If you are using `appDir` then you must comment the below `i18n` config out. - * - * @see https://github.com/vercel/next.js/issues/41980 - */ i18n: i18nConfig.i18n, images: { remotePatterns: [ - { - protocol: 'https', - hostname: '**', - }, - { - protocol: 'http', - hostname: '**', - }, + { protocol: 'https', hostname: '**' }, + { protocol: 'http', hostname: '**' }, ], }, }; -const withSerwist = withSerwistInit({ - swSrc: 'worker/index.ts', - swDest: 'public/sw.js', - disable: process.env.NODE_ENV === 'development', // Incompatible with Turbopack https://github.com/serwist/serwist/issues/54 -}); - -export default withSerwist(nextConfig); +export default nextConfig; diff --git a/package.json b/package.json index 995ff0f52..f53deda62 100644 --- a/package.json +++ b/package.json @@ -4,41 +4,34 @@ "private": true, "type": "module", "scripts": { - "build": "next build --no-lint", - "just-build": "next build --no-lint", - "db:push": "prisma db push", - "db:studio": "prisma studio", - "db:dev": "prisma migrate dev", - "db:seed": "prisma db seed", + "build": "pnpm build:sw && vite build", + "just-build": "pnpm build", "db:verify-seed-hash": "tsx src/dummies/index.ts --strict", - "prisma:prod": "prisma migrate deploy", - "dev": "next dev --turbopack", - "postinstall": "prisma generate", - "generate": "prisma generate", + "dev": "vite dev", "lint": "oxlint --type-aware", - "start": "sleep 3 && pnpm prisma:prod && next start", - "start-with-latest-migrations": "prisma migrate deploy && next start", - "d": "pnpm dx && pnpm dev", - "dx": "pnpm i && pnpm dx:up && pnpm db:dev", - "dx:up": "bash scripts/dev-db.sh up", - "dx:down": "bash scripts/dev-db.sh down", - "test": "jest", - "test:watch": "jest --watch", - "prepare": "husky" + "test": "vitest run --project integration", + "test:e2e": "playwright test", + "test:integration": "vitest run --project integration", + "prepare": "husky", + "db:generate": "drizzle-kit generate", + "db:migrate:local": "wrangler d1 migrations apply splitpro --local", + "db:migrate:remote": "wrangler d1 migrations apply splitpro --remote", + "preview": "pnpm build && wrangler dev", + "deploy": "pnpm build && wrangler deploy", + "cf-typegen": "wrangler types", + "build:sw": "vite build -c vite.sw.config.ts" }, "dependencies": { "@heroicons/react": "^2.1.1", "@hookform/resolvers": "^3.3.4", "@icons-pack/react-simple-icons": "^13.7.0", - "@next-auth/prisma-adapter": "^1.0.7", - "@prisma/client": "^6.16.2", - "@serwist/next": "^9.2.1", "@t3-oss/env-nextjs": "^0.13.8", "@tanstack/react-query": "^5.79.2", "@trpc/client": "^11.2.0", - "@trpc/next": "^11.2.0", + "@trpc/next": "11.2.0", "@trpc/react-query": "^11.2.0", "@trpc/server": "^11.2.0", + "better-auth": "1.7.7", "boring-avatars": "^1.10.1", "browser-image-compression": "^2.0.2", "class-variance-authority": "^0.7.0", @@ -47,18 +40,16 @@ "cron-parser": "^4.9.0", "cronstrue": "^3.3.0", "date-fns": "^3.3.1", - "formidable": "^3.5.4", + "drizzle-orm": "0.45.4", "i18next": "^25.10.10", "i18next-browser-languagedetector": "^8.2.1", "i18next-http-backend": "^3.0.6", "input-otp": "^1.2.3", + "jose": "6.2.12", "lucide-react": "^0.544.0", "nanoid": "^5.0.6", - "next": "15.5.18", - "next-auth": "^4.24.14", "next-i18next": "^15.4.3", "next-themes": "^0.4.6", - "nodemailer": "^9.0.1", "nordigen-node": "^1.4.1", "plaid": "^38.1.0", "radix-ui": "^1.4.2", @@ -69,42 +60,50 @@ "react-hook-form": "^7.50.1", "react-i18next": "^15.5.3", "react-plaid-link": "4.1.1", - "sharp": "^0.34.5", "sonner": "^1.4.0", "superjson": "^2.2.1", "vaul": "^1.1.2", + "vinext": "1.1.0", "web-push": "^3.6.7", "zod": "^3.22.4", "zustand": "^4.5.0" }, "devDependencies": { + "@cloudflare/vite-plugin": "1.63.1", + "@cloudflare/vitest-plugin": "1.4.0", + "@cloudflare/workers-types": "5.20261011.1", "@faker-js/faker": "10.1.0", + "@playwright/test": "1.64.0", + "@serwist/vite": "9.5.13", "@tailwindcss/postcss": "^4.1.10", - "@types/formidable": "^3.4.6", - "@types/jest": "^29.5.14", "@types/node": "^22.15.21", - "@types/nodemailer": "^8.0.0", "@types/react": "19.2.1", "@types/react-dom": "19.2.1", "@types/web-push": "^3.6.3", "@typescript/native-preview": "7.0.0-dev.20251016.1", + "@vinext/cloudflare": "1.1.0", + "@vitejs/plugin-react": "6.1.2", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "drizzle-kit": "0.31.11", "husky": "^9.1.7", - "jest": "30.3.0", - "jest-environment-jsdom": "30.3.0", "jiti": "^2.5.1", + "jsdom": "30.1.2", "lint-staged": "^16.4.0", "oxlint": "1.57.0", "oxlint-tsgolint": "0.18.1", "postcss": "^8.5.14", "prettier": "^3.2.4", "prettier-plugin-tailwindcss": "^0.6.12", - "prisma": "^6.19.3", "serwist": "^9.2.1", "tailwind-merge": "^2.2.0", "tailwindcss": "^4.1.10", "ts-node": "^10.9.2", "tsx": "^4.7.1", - "typescript": "~5.7.2" + "typescript": "~5.7.2", + "vite": "8.3.4", + "vitest": "4.1.11", + "wrangler": "4.149.0" }, "ct3aMetadata": { "initVersion": "7.25.2" @@ -112,14 +111,11 @@ "packageManager": "pnpm@10.11.0", "pnpm": { "onlyBuiltDependencies": [ - "@prisma/client", - "@prisma/engines", "@tailwindcss/oxide", "core-js", "esbuild", - "prisma", - "sharp", - "unrs-resolver" + "unrs-resolver", + "workerd" ], "overrides": { "axios": "1.16.0", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 000000000..293784d03 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from '@playwright/test'; + +const PORT = 3176; + +export default defineConfig({ + testDir: './tests/e2e', + fullyParallel: true, + workers: 4, + retries: 0, + timeout: 30_000, + reporter: [['list']], + use: { baseURL: `http://127.0.0.1:${PORT}`, trace: 'retain-on-failure' }, + projects: [{ name: 'chromium', use: { browserName: 'chromium' } }], + webServer: { + // Runs the *built* Worker (dist/) in workerd with a throwaway local D1 under .e2e-state. + command: `node tests/e2e/prepare.mjs && npx wrangler dev -c dist/splitpro/wrangler.json --local --persist-to .e2e-state --ip 127.0.0.1 --port ${PORT} --var NEXTAUTH_URL:http://127.0.0.1:${PORT} --var NEXTAUTH_SECRET:e2e-secret-e2e-secret-e2e-secret-123456 --var GOOGLE_CLIENT_ID:e2e --var GOOGLE_CLIENT_SECRET:e2e`, + url: `http://127.0.0.1:${PORT}/api/healthz`, + timeout: 90_000, + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b86928770..e42f12bcb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,15 +34,6 @@ importers: '@icons-pack/react-simple-icons': specifier: ^13.7.0 version: 13.8.0(react@19.2.1) - '@next-auth/prisma-adapter': - specifier: ^1.0.7 - version: 1.0.7(@prisma/client@6.19.1(prisma@6.19.3(typescript@5.7.3))(typescript@5.7.3))(next-auth@4.24.14(next@15.5.18(@babel/core@7.28.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(nodemailer@9.0.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)) - '@prisma/client': - specifier: ^6.16.2 - version: 6.19.1(prisma@6.19.3(typescript@5.7.3))(typescript@5.7.3) - '@serwist/next': - specifier: ^9.2.1 - version: 9.2.3(next@15.5.18(@babel/core@7.28.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(typescript@5.7.3) '@t3-oss/env-nextjs': specifier: ^0.13.8 version: 0.13.10(typescript@5.7.3)(zod@3.25.76) @@ -53,14 +44,17 @@ importers: specifier: ^11.2.0 version: 11.8.0(@trpc/server@11.8.0(typescript@5.7.3))(typescript@5.7.3) '@trpc/next': - specifier: ^11.2.0 - version: 11.8.0(@tanstack/react-query@5.90.12(react@19.2.1))(@trpc/client@11.8.0(@trpc/server@11.8.0(typescript@5.7.3))(typescript@5.7.3))(@trpc/react-query@11.8.0(@tanstack/react-query@5.90.12(react@19.2.1))(@trpc/client@11.8.0(@trpc/server@11.8.0(typescript@5.7.3))(typescript@5.7.3))(@trpc/server@11.8.0(typescript@5.7.3))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.7.3))(@trpc/server@11.8.0(typescript@5.7.3))(next@15.5.18(@babel/core@7.28.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.7.3) + specifier: 11.2.0 + version: 11.2.0(@tanstack/react-query@5.90.12(react@19.2.1))(@trpc/client@11.8.0(@trpc/server@11.8.0(typescript@5.7.3))(typescript@5.7.3))(@trpc/react-query@11.8.0(@tanstack/react-query@5.90.12(react@19.2.1))(@trpc/client@11.8.0(@trpc/server@11.8.0(typescript@5.7.3))(typescript@5.7.3))(@trpc/server@11.8.0(typescript@5.7.3))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.7.3))(@trpc/server@11.8.0(typescript@5.7.3))(next@15.5.27(@playwright/test@1.64.0)(@types/node@22.19.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.7.3) '@trpc/react-query': specifier: ^11.2.0 version: 11.8.0(@tanstack/react-query@5.90.12(react@19.2.1))(@trpc/client@11.8.0(@trpc/server@11.8.0(typescript@5.7.3))(typescript@5.7.3))(@trpc/server@11.8.0(typescript@5.7.3))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.7.3) '@trpc/server': specifier: ^11.2.0 version: 11.8.0(typescript@5.7.3) + better-auth: + specifier: 1.7.7 + version: 1.7.7(drizzle-kit@0.31.11)(drizzle-orm@0.45.4(@cloudflare/workers-types@5.20261011.1)(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)(kysely@0.29.6))(next@15.5.27(@playwright/test@1.64.0)(@types/node@22.19.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(vitest@4.1.11(@types/node@22.19.3)(jsdom@30.1.2(@noble/hashes@2.4.0))(vite@8.3.4(@types/node@22.19.3)(esbuild@0.28.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))) boring-avatars: specifier: ^1.10.1 version: 1.11.2 @@ -85,9 +79,9 @@ importers: date-fns: specifier: ^3.3.1 version: 3.6.0 - formidable: - specifier: ^3.5.4 - version: 3.5.4 + drizzle-orm: + specifier: 0.45.4 + version: 0.45.4(@cloudflare/workers-types@5.20261011.1)(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)(kysely@0.29.6) i18next: specifier: ^25.10.10 version: 25.10.10(typescript@5.7.3) @@ -100,27 +94,21 @@ importers: input-otp: specifier: ^1.2.3 version: 1.4.2(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + jose: + specifier: 6.2.12 + version: 6.2.12 lucide-react: specifier: ^0.544.0 version: 0.544.0(react@19.2.1) nanoid: specifier: ^5.0.6 version: 5.1.6 - next: - specifier: 15.5.18 - version: 15.5.18(@babel/core@7.28.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - next-auth: - specifier: ^4.24.14 - version: 4.24.14(next@15.5.18(@babel/core@7.28.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(nodemailer@9.0.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) next-i18next: specifier: ^15.4.3 - version: 15.4.3(i18next@25.10.10(typescript@5.7.3))(next@15.5.18(@babel/core@7.28.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-i18next@15.7.4(i18next@25.10.10(typescript@5.7.3))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.7.3))(react@19.2.1) + version: 15.4.3(i18next@25.10.10(typescript@5.7.3))(next@15.5.27(@playwright/test@1.64.0)(@types/node@22.19.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-i18next@15.7.4(i18next@25.10.10(typescript@5.7.3))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.7.3))(react@19.2.1) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - nodemailer: - specifier: ^9.0.1 - version: 9.0.1 nordigen-node: specifier: ^1.4.1 version: 1.4.1 @@ -151,9 +139,6 @@ importers: react-plaid-link: specifier: 4.1.1 version: 4.1.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - sharp: - specifier: ^0.34.5 - version: 0.34.5 sonner: specifier: ^1.4.0 version: 1.7.4(react-dom@19.2.1(react@19.2.1))(react@19.2.1) @@ -163,6 +148,9 @@ importers: vaul: specifier: ^1.1.2 version: 1.1.2(@types/react-dom@19.2.1(@types/react@19.2.1))(@types/react@19.2.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + vinext: + specifier: 1.1.0 + version: 1.1.0(@types/node@22.19.3)(@vitejs/plugin-react@6.1.2(vite@8.3.4(@types/node@22.19.3)(esbuild@0.28.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)))(next@15.5.27(@playwright/test@1.64.0)(@types/node@22.19.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(vite@8.3.4(@types/node@22.19.3)(esbuild@0.28.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) web-push: specifier: ^3.6.7 version: 3.6.7 @@ -173,24 +161,30 @@ importers: specifier: ^4.5.0 version: 4.5.7(@types/react@19.2.1)(react@19.2.1) devDependencies: + '@cloudflare/vite-plugin': + specifier: 1.63.1 + version: 1.63.1(@types/node@22.19.3)(vite@8.3.4(@types/node@22.19.3)(esbuild@0.28.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))(wrangler@4.149.0(@cloudflare/workers-types@5.20261011.1)(@types/node@22.19.3)) + '@cloudflare/vitest-plugin': + specifier: 1.4.0 + version: 1.4.0(@cloudflare/workers-types@5.20261011.1)(@types/node@22.19.3)(vitest@4.1.11(@types/node@22.19.3)(jsdom@30.1.2(@noble/hashes@2.4.0))(vite@8.3.4(@types/node@22.19.3)(esbuild@0.28.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))) + '@cloudflare/workers-types': + specifier: 5.20261011.1 + version: 5.20261011.1 '@faker-js/faker': specifier: 10.1.0 version: 10.1.0 + '@playwright/test': + specifier: 1.64.0 + version: 1.64.0 + '@serwist/vite': + specifier: 9.5.13 + version: 9.5.13(browserslist@4.28.1)(typescript@5.7.3)(vite@8.3.4(@types/node@22.19.3)(esbuild@0.28.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) '@tailwindcss/postcss': specifier: ^4.1.10 version: 4.1.18 - '@types/formidable': - specifier: ^3.4.6 - version: 3.4.6 - '@types/jest': - specifier: ^29.5.14 - version: 29.5.14 '@types/node': specifier: ^22.15.21 version: 22.19.3 - '@types/nodemailer': - specifier: ^8.0.0 - version: 8.0.0 '@types/react': specifier: 19.2.1 version: 19.2.1 @@ -203,18 +197,30 @@ importers: '@typescript/native-preview': specifier: 7.0.0-dev.20251016.1 version: 7.0.0-dev.20251016.1 + '@vinext/cloudflare': + specifier: 1.1.0 + version: 1.1.0(@cloudflare/vite-plugin@1.63.1(@types/node@22.19.3)(vite@8.3.4(@types/node@22.19.3)(esbuild@0.28.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))(wrangler@4.149.0(@cloudflare/workers-types@5.20261011.1)(@types/node@22.19.3)))(vinext@1.1.0(@types/node@22.19.3)(@vitejs/plugin-react@6.1.2(vite@8.3.4(@types/node@22.19.3)(esbuild@0.28.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)))(next@15.5.27(@playwright/test@1.64.0)(@types/node@22.19.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(vite@8.3.4(@types/node@22.19.3)(esbuild@0.28.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))) + '@vitejs/plugin-react': + specifier: 6.1.2 + version: 6.1.2(vite@8.3.4(@types/node@22.19.3)(esbuild@0.28.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + '@vitest/runner': + specifier: 4.1.11 + version: 4.1.11 + '@vitest/snapshot': + specifier: 4.1.11 + version: 4.1.11 + drizzle-kit: + specifier: 0.31.11 + version: 0.31.11 husky: specifier: ^9.1.7 version: 9.1.7 - jest: - specifier: 30.3.0 - version: 30.3.0(@types/node@22.19.3)(ts-node@10.9.2(@types/node@22.19.3)(typescript@5.7.3)) - jest-environment-jsdom: - specifier: 30.3.0 - version: 30.3.0 jiti: specifier: ^2.5.1 version: 2.6.1 + jsdom: + specifier: 30.1.2 + version: 30.1.2(@noble/hashes@2.4.0) lint-staged: specifier: ^16.4.0 version: 16.4.0 @@ -233,9 +239,6 @@ importers: prettier-plugin-tailwindcss: specifier: ^0.6.12 version: 0.6.14(prettier@3.7.4) - prisma: - specifier: ^6.19.3 - version: 6.19.3(typescript@5.7.3) serwist: specifier: ^9.2.1 version: 9.2.3(typescript@5.7.3) @@ -254,6 +257,15 @@ importers: typescript: specifier: ~5.7.2 version: 5.7.3 + vite: + specifier: 8.3.4 + version: 8.3.4(@types/node@22.19.3)(esbuild@0.28.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3) + vitest: + specifier: 4.1.11 + version: 4.1.11(@types/node@22.19.3)(jsdom@30.1.2(@noble/hashes@2.4.0))(vite@8.3.4(@types/node@22.19.3)(esbuild@0.28.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + wrangler: + specifier: 4.149.0 + version: 4.149.0(@cloudflare/workers-types@5.20261011.1)(@types/node@22.19.3) packages: @@ -261,233 +273,233 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} - '@ampproject/remapping@2.3.0': - resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} - engines: {node: '>=6.0.0'} - - '@asamuzakjp/css-color@3.2.0': - resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} - - '@babel/code-frame@7.27.1': - resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} - engines: {node: '>=6.9.0'} - - '@babel/compat-data@7.27.2': - resolution: {integrity: sha512-TUtMJYRPyUb/9aU8f3K0mjmjf6M9N5Woshn2CS6nqJSeJtTtQcpLUXjGt9vbF8ZGff0El99sWkLgzwW3VXnxZQ==} - engines: {node: '>=6.9.0'} - - '@babel/core@7.28.3': - resolution: {integrity: sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==} - engines: {node: '>=6.9.0'} - - '@babel/generator@7.28.3': - resolution: {integrity: sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-compilation-targets@7.27.2': - resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==} - engines: {node: '>=6.9.0'} - - '@babel/helper-globals@7.28.0': - resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-module-imports@7.27.1': - resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==} - engines: {node: '>=6.9.0'} - - '@babel/helper-module-transforms@7.28.3': - resolution: {integrity: sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/helper-plugin-utils@7.27.1': - resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==} - engines: {node: '>=6.9.0'} + '@asamuzakjp/css-color@7.1.5': + resolution: {integrity: sha512-jCpAvj8apa7SKHZYogk0rbXB3Kb0hLFrpkWF+Ap+vBA5rirItnpv9NKyAyEuDuFizvl0qwnj6JWR70RGJ0rOlw==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} - '@babel/helper-string-parser@7.27.1': - resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} - engines: {node: '>=6.9.0'} + '@asamuzakjp/dom-selector@9.2.4': + resolution: {integrity: sha512-YnVzxDxLaqL0t7Q4wfcgHZjg55Wm6y5EaO7Bhu3q9J7wq/8iw3PhsnWiOQhYekx4VexnbkgWpTT6n+QIV7kczw==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} - '@babel/helper-validator-identifier@7.27.1': - resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==} + '@babel/runtime@7.27.6': + resolution: {integrity: sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-option@7.27.1': - resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + '@babel/runtime@7.28.4': + resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} engines: {node: '>=6.9.0'} - '@babel/helpers@7.28.3': - resolution: {integrity: sha512-PTNtvUQihsAsDHMOP5pfobP8C6CM4JWXmP8DrEIt46c3r2bf87Ua1zoqevsMo9g+tWDwgWrFP5EIxuBx5RudAw==} + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} engines: {node: '>=6.9.0'} - '@babel/parser@7.28.3': - resolution: {integrity: sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA==} - engines: {node: '>=6.0.0'} - hasBin: true - - '@babel/plugin-syntax-async-generators@7.8.4': - resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-bigint@7.8.3': - resolution: {integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-class-properties@7.12.13': - resolution: {integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==} + '@better-auth/core@1.7.7': + resolution: {integrity: sha512-srgFzHEjB2WWlokFz1i4BfG41sNhsA105Pukgqu6RrEh5A+LSkzf1mCHydVipkjt9l5qswtBMi7IL6ouHbZL4Q==} peerDependencies: - '@babel/core': ^7.0.0-0 + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.2 + '@opentelemetry/api': ^1.9.0 + better-call: 1.4.0 + jose: ^6.1.0 + kysely: ^0.28.5 || ^0.29.0 + nanostores: ^1.0.1 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true - '@babel/plugin-syntax-class-static-block@7.14.5': - resolution: {integrity: sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==} - engines: {node: '>=6.9.0'} + '@better-auth/drizzle-adapter@1.7.7': + resolution: {integrity: sha512-qon0U94PR5FQysuyGoAlGVQpIIiNg5dtDpwjE63sN/HjoRWZadZzHPcS9mn7JsJyQZK6iTNhd7LD0AaXt2f78w==} peerDependencies: - '@babel/core': ^7.0.0-0 + '@better-auth/core': ^1.7.7 + '@better-auth/utils': 0.4.2 + drizzle-orm: ^0.45.2 || >=1.0.0-rc.1 <2.0.0 + peerDependenciesMeta: + drizzle-orm: + optional: true - '@babel/plugin-syntax-import-attributes@7.27.1': - resolution: {integrity: sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==} - engines: {node: '>=6.9.0'} + '@better-auth/kysely-adapter@1.7.7': + resolution: {integrity: sha512-9FONOCgOcrQI9wJ5v1oDGIg9sT7pa9RjZZQ4fsCTudo8R87SrIGNUvUxDFTJhZvUblvFfGaIBR9Vb8LA+8bqLQ==} peerDependencies: - '@babel/core': ^7.0.0-0 + '@better-auth/core': ^1.7.7 + '@better-auth/utils': 0.4.2 + kysely: ^0.28.17 || ^0.29.0 + peerDependenciesMeta: + kysely: + optional: true - '@babel/plugin-syntax-import-meta@7.10.4': - resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==} + '@better-auth/memory-adapter@1.7.7': + resolution: {integrity: sha512-FqKFEe+b5tXXV0gRbyirca+qXgMpLOeJ8Wk19yykb/scQKy0WJ4k22WzEV3TTPctQV9DZhwCXPlDtQhnlOv5Gg==} peerDependencies: - '@babel/core': ^7.0.0-0 + '@better-auth/core': ^1.7.7 + '@better-auth/utils': 0.4.2 - '@babel/plugin-syntax-json-strings@7.8.3': - resolution: {integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==} + '@better-auth/mongo-adapter@1.7.7': + resolution: {integrity: sha512-ZnVDuRrXqbf0oHphrEN27oTImNH5NTO26dtWatq5cXyYAsH7goof9QIoTOmxTbBxUQahKBw9T6+9h6n+RtOE+g==} peerDependencies: - '@babel/core': ^7.0.0-0 + '@better-auth/core': ^1.7.7 + '@better-auth/utils': 0.4.2 + mongodb: ^6.0.0 || ^7.0.0 + peerDependenciesMeta: + mongodb: + optional: true - '@babel/plugin-syntax-jsx@7.27.1': - resolution: {integrity: sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==} - engines: {node: '>=6.9.0'} + '@better-auth/prisma-adapter@1.7.7': + resolution: {integrity: sha512-4YcnrcVdvWiAZw0sZl63tWUClPXg5r3QsLn7C2sG7kKEBY06zOng0ibecyxRBixKq1zE1ceHuEUqez5Rhi+nQA==} peerDependencies: - '@babel/core': ^7.0.0-0 + '@better-auth/core': ^1.7.7 + '@better-auth/utils': 0.4.2 + '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 + prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 + peerDependenciesMeta: + '@prisma/client': + optional: true + prisma: + optional: true - '@babel/plugin-syntax-logical-assignment-operators@7.10.4': - resolution: {integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==} + '@better-auth/telemetry@1.7.7': + resolution: {integrity: sha512-PaRA6i+kAioVYmza2gHPcdrxP0hAotph4KV9hft2GeNJq+AZXDiBu3FuLM9HxvcmiZeUT74Vufqd9ypmB8veTA==} peerDependencies: - '@babel/core': ^7.0.0-0 + '@better-auth/core': ^1.7.7 + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.2 - '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3': - resolution: {integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@better-auth/utils@0.4.2': + resolution: {integrity: sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A==} - '@babel/plugin-syntax-numeric-separator@7.10.4': - resolution: {integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@better-auth/utils@0.5.0': + resolution: {integrity: sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA==} - '@babel/plugin-syntax-object-rest-spread@7.8.3': - resolution: {integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@better-fetch/fetch@1.3.2': + resolution: {integrity: sha512-Gs7n99b5tqUC6cQAPbV0uED3IraHB6xQbHLQ/C3l7ZFafHScOx9pQ+DYmP5blbLFShVWLqxNUlI9wi4xU/X+ow==} - '@babel/plugin-syntax-optional-catch-binding@7.8.3': - resolution: {integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@bramus/specificity@2.4.3': + resolution: {integrity: sha512-0GEz8qJWokkUVYQYRukmkSHIFNoBH8QTreeHrAFbvz1XFYc3Y+puhMs8PQ5x8GNugiC2l5jvDBXKLbBIByeSBw==} + hasBin: true - '@babel/plugin-syntax-optional-chaining@7.8.3': - resolution: {integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@cloudflare/kv-asset-handler@0.5.0': + resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} + engines: {node: '>=22.0.0'} - '@babel/plugin-syntax-private-property-in-object@7.14.5': - resolution: {integrity: sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==} - engines: {node: '>=6.9.0'} + '@cloudflare/unenv-preset@2.16.2': + resolution: {integrity: sha512-JBP1+Z7ZSNG/d4mRP+y8VC5dka3tZVMLEZRvS+rzQ4DGV1EoxRFQckcJTTkXbHSQiTj0DtNI01Zwb/V2fX0mvQ==} peerDependencies: - '@babel/core': ^7.0.0-0 + unenv: 2.0.0-rc.24 + workerd: '>1.20260305.0 <2.0.0-0' + peerDependenciesMeta: + workerd: + optional: true - '@babel/plugin-syntax-top-level-await@7.14.5': - resolution: {integrity: sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==} - engines: {node: '>=6.9.0'} + '@cloudflare/vite-plugin@1.63.1': + resolution: {integrity: sha512-6EtAoRqWryl43bYiCl7yvCBHi3R+L/fM11zu8zRyvBMe0Yk63NseyrhA19nO5Y1yrrk6dD+Su7NBZhDuUbjh0g==} + hasBin: true peerDependencies: - '@babel/core': ^7.0.0-0 + vite: ^6.1.0 || ^7.0.0 || ^8.0.0 + wrangler: ^4.149.0 - '@babel/plugin-syntax-typescript@7.27.1': - resolution: {integrity: sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==} - engines: {node: '>=6.9.0'} + '@cloudflare/vitest-plugin@1.4.0': + resolution: {integrity: sha512-SgIhyhpVH+2MVSJxidG8gosqrpzF25IwKevQhPykwulTgAFe4s0Nkj71pNzqCkwV2+A4NKkHJP2ReyyRsoIxsg==} peerDependencies: - '@babel/core': ^7.0.0-0 + vitest: ^4.1.0 || ^5.0.0 - '@babel/runtime@7.27.6': - resolution: {integrity: sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==} - engines: {node: '>=6.9.0'} + '@cloudflare/workerd-darwin-64@1.20261006.1': + resolution: {integrity: sha512-lCX6avJO2It9AyDEYitjmOtJRVp/xmaW0TziyoLAnfxlzozyyRbSFfIIMMCiDx/sTP1++E4VLbV+7BHSD33f0Q==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] - '@babel/runtime@7.28.4': - resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} - engines: {node: '>=6.9.0'} + '@cloudflare/workerd-darwin-arm64@1.20261006.1': + resolution: {integrity: sha512-PyFGjOaldtppnLuKsdkoYWXfLVpDjuf8u+uoykX+1Xvxo2EPmCVViq0iDKm+U7c7PRno85ehS3/MPVXyk2HwHw==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] - '@babel/runtime@7.29.2': - resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} - engines: {node: '>=6.9.0'} + '@cloudflare/workerd-linux-64@1.20261006.1': + resolution: {integrity: sha512-Ntf9S8eesmVufdjaT2kzWlz6Ng9XGYTc6cHQVRKAg59lFufwsIabGeMT6U4KkrpEeSIxgHq8aPtuLbZXE19tCA==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] - '@babel/template@7.27.2': - resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} - engines: {node: '>=6.9.0'} + '@cloudflare/workerd-linux-arm64@1.20261006.1': + resolution: {integrity: sha512-JyeDBymVWw5tVRebSsVE8ojDYxd6UlZrAseHU8G+NpIyd5wF61tXlF/axLeCUqu0pOhBFHb8I5QKi0jwrCK41Q==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] - '@babel/traverse@7.28.3': - resolution: {integrity: sha512-7w4kZYHneL3A6NP2nxzHvT3HCZ7puDZZjFMqDpBPECub79sTtSO5CGXDkKrTQq8ksAwfD/XI2MRFX23njdDaIQ==} - engines: {node: '>=6.9.0'} + '@cloudflare/workerd-windows-64@1.20261006.1': + resolution: {integrity: sha512-nWP0URNqOBN9fa/gZ7eh/w051DwjOXN8j4lDZDYVXyQPsux6sV3DfyYZT+CjR/rt9nKBSmJT9QE9/bWT18+84w==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] - '@babel/types@7.28.2': - resolution: {integrity: sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==} - engines: {node: '>=6.9.0'} + '@cloudflare/workers-response-store@1.1.0': + resolution: {integrity: sha512-qQnh6eP7+cQmLntL8xii48lw6tdvJsr2Lnsk7ZLUyFdIGA6llrM0IskVxqzPshDJXS40iRHWBcgxNXmrOwSyeQ==} + engines: {node: '>=22'} - '@bcoe/v8-coverage@0.2.3': - resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + '@cloudflare/workers-types@5.20261011.1': + resolution: {integrity: sha512-6dgFMnwq6Wn238QWGHHb93gqIXjbSQMALcurXmI+9SHkM0QeEdHDhbyxwnE8v1m41MUzCtNO/gnTRwI4FsS9fQ==} '@cspotcode/source-map-support@0.8.1': resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} - '@csstools/color-helpers@5.1.0': - resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} - engines: {node: '>=18'} + '@csstools/color-helpers@6.1.2': + resolution: {integrity: sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==} + engines: {node: '>=20.19.0'} - '@csstools/css-calc@2.1.4': - resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} - engines: {node: '>=18'} + '@csstools/css-calc@3.4.3': + resolution: {integrity: sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==} + engines: {node: '>=20.19.0'} peerDependencies: - '@csstools/css-parser-algorithms': ^3.0.5 - '@csstools/css-tokenizer': ^3.0.4 + '@csstools/css-parser-algorithms': ^4.0.2 + '@csstools/css-tokenizer': ^4.0.2 - '@csstools/css-color-parser@3.1.0': - resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} - engines: {node: '>=18'} + '@csstools/css-color-parser@4.2.6': + resolution: {integrity: sha512-iiPQ3iRWwnJkeEn6RIu6SJPr7hYrLz6XZ9s/QZl+2/LI5KQVjpl2fdmDSZKuD4xP6GMmMPgHFFXg6k1Wkz0Trg==} + engines: {node: '>=20.19.0'} peerDependencies: - '@csstools/css-parser-algorithms': ^3.0.5 - '@csstools/css-tokenizer': ^3.0.4 + '@csstools/css-parser-algorithms': ^4.0.2 + '@csstools/css-tokenizer': ^4.0.2 - '@csstools/css-parser-algorithms@3.0.5': - resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} - engines: {node: '>=18'} + '@csstools/css-parser-algorithms@4.0.2': + resolution: {integrity: sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==} + engines: {node: '>=20.19.0'} peerDependencies: - '@csstools/css-tokenizer': ^3.0.4 + '@csstools/css-tokenizer': ^4.0.2 - '@csstools/css-tokenizer@3.0.4': - resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} - engines: {node: '>=18'} + '@csstools/css-syntax-patches-for-csstree@1.1.15': + resolution: {integrity: sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.2': + resolution: {integrity: sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==} + engines: {node: '>=20.19.0'} '@date-fns/tz@1.4.1': resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==} - '@emnapi/core@1.4.5': - resolution: {integrity: sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q==} + '@drizzle-team/brocli@0.10.2': + resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} + + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + + '@esbuild-kit/core-utils@3.3.2': + resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} + deprecated: 'Merged into tsx: https://tsx.hirok.io' - '@emnapi/runtime@1.7.1': - resolution: {integrity: sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==} + '@esbuild-kit/esm-loader@2.6.5': + resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} + deprecated: 'Merged into tsx: https://tsx.hirok.io' - '@emnapi/wasi-threads@1.0.4': - resolution: {integrity: sha512-PJR+bOmMOPH8AtcTGAyYNiuJ3/Fcoj2XN/gBEWzDIKh254XO+mM9XoXHk5GNEhodxeMznbg7BlRojVbKN+gC6g==} + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] '@esbuild/aix-ppc64@0.27.2': resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} @@ -495,156 +507,603 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.18.20': + resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.27.2': resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.18.20': + resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.27.2': resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.18.20': + resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.27.2': resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.18.20': + resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.27.2': resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.18.20': + resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.27.2': resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.18.20': + resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.27.2': resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.18.20': + resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.27.2': resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.18.20': + resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.27.2': resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.18.20': + resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.27.2': resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.27.2': - resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==} + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.18.20': + resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==} + engines: {node: '>=12'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.27.2': - resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==} + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} engines: {node: '>=18'} - cpu: [loong64] + cpu: [ia32] os: [linux] - '@esbuild/linux-mips64el@0.27.2': - resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==} + '@esbuild/linux-ia32@0.27.2': + resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==} engines: {node: '>=18'} - cpu: [mips64el] + cpu: [ia32] os: [linux] - '@esbuild/linux-ppc64@0.27.2': - resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==} + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} engines: {node: '>=18'} - cpu: [ppc64] + cpu: [ia32] os: [linux] - '@esbuild/linux-riscv64@0.27.2': - resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==} - engines: {node: '>=18'} - cpu: [riscv64] + '@esbuild/linux-loong64@0.18.20': + resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==} + engines: {node: '>=12'} + cpu: [loong64] os: [linux] - '@esbuild/linux-s390x@0.27.2': - resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==} + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} engines: {node: '>=18'} - cpu: [s390x] + cpu: [loong64] os: [linux] - '@esbuild/linux-x64@0.27.2': - resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==} + '@esbuild/linux-loong64@0.27.2': + resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==} engines: {node: '>=18'} - cpu: [x64] + cpu: [loong64] os: [linux] - '@esbuild/netbsd-arm64@0.27.2': + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.18.20': + resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.27.2': + resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.18.20': + resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.27.2': + resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.18.20': + resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.2': + resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.18.20': + resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.27.2': + resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.18.20': + resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.27.2': + resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-arm64@0.27.2': resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.18.20': + resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.27.2': resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-arm64@0.27.2': resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.18.20': + resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.27.2': resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/openharmony-arm64@0.27.2': resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.18.20': + resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.27.2': resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.18.20': + resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.27.2': resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.18.20': + resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.27.2': resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.18.20': + resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.27.2': resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==} engines: {node: '>=18'} cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@exodus/bytes@1.16.0': + resolution: {integrity: sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@faker-js/faker@10.1.0': resolution: {integrity: sha512-C3mrr3b5dRVlKPJdfrAXS8+dq+rq8Qm5SNRazca0JKgw1HQERFmrVb0towvMmw5uu8hHKNiQasMaR/tydf3Zsg==} engines: {node: ^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0, npm: '>=10'} @@ -679,259 +1138,152 @@ packages: peerDependencies: react: ^16.13 || ^17 || ^18 || ^19 - '@img/colour@1.0.0': - resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==} + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} - '@img/sharp-darwin-arm64@0.34.5': - resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-darwin-arm64@0.35.5': + resolution: {integrity: sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [darwin] - '@img/sharp-darwin-x64@0.34.5': - resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-darwin-x64@0.35.5': + resolution: {integrity: sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [darwin] - '@img/sharp-libvips-darwin-arm64@1.2.4': - resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + '@img/sharp-freebsd-wasm32@0.35.5': + resolution: {integrity: sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==} + engines: {node: '>=20.9.0'} + os: [freebsd] + + '@img/sharp-libvips-darwin-arm64@1.3.4': + resolution: {integrity: sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==} cpu: [arm64] os: [darwin] - '@img/sharp-libvips-darwin-x64@1.2.4': - resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + '@img/sharp-libvips-darwin-x64@1.3.4': + resolution: {integrity: sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==} cpu: [x64] os: [darwin] - '@img/sharp-libvips-linux-arm64@1.2.4': - resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + '@img/sharp-libvips-linux-arm64@1.3.4': + resolution: {integrity: sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==} cpu: [arm64] os: [linux] - '@img/sharp-libvips-linux-arm@1.2.4': - resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + '@img/sharp-libvips-linux-arm@1.3.4': + resolution: {integrity: sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==} cpu: [arm] os: [linux] - '@img/sharp-libvips-linux-ppc64@1.2.4': - resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + '@img/sharp-libvips-linux-ppc64@1.3.4': + resolution: {integrity: sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==} cpu: [ppc64] os: [linux] - '@img/sharp-libvips-linux-riscv64@1.2.4': - resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + '@img/sharp-libvips-linux-riscv64@1.3.4': + resolution: {integrity: sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==} cpu: [riscv64] os: [linux] - '@img/sharp-libvips-linux-s390x@1.2.4': - resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + '@img/sharp-libvips-linux-s390x@1.3.4': + resolution: {integrity: sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==} cpu: [s390x] os: [linux] - '@img/sharp-libvips-linux-x64@1.2.4': - resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + '@img/sharp-libvips-linux-x64@1.3.4': + resolution: {integrity: sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==} cpu: [x64] os: [linux] - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + '@img/sharp-libvips-linuxmusl-arm64@1.3.4': + resolution: {integrity: sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==} cpu: [arm64] os: [linux] - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + '@img/sharp-libvips-linuxmusl-x64@1.3.4': + resolution: {integrity: sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==} cpu: [x64] os: [linux] - '@img/sharp-linux-arm64@0.34.5': - resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-arm64@0.35.5': + resolution: {integrity: sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] - '@img/sharp-linux-arm@0.34.5': - resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-arm@0.35.5': + resolution: {integrity: sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==} + engines: {node: '>=20.9.0'} cpu: [arm] os: [linux] - '@img/sharp-linux-ppc64@0.34.5': - resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-ppc64@0.35.5': + resolution: {integrity: sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==} + engines: {node: '>=20.9.0'} cpu: [ppc64] os: [linux] - '@img/sharp-linux-riscv64@0.34.5': - resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-riscv64@0.35.5': + resolution: {integrity: sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==} + engines: {node: '>=20.9.0'} cpu: [riscv64] os: [linux] - '@img/sharp-linux-s390x@0.34.5': - resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-s390x@0.35.5': + resolution: {integrity: sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==} + engines: {node: '>=20.9.0'} cpu: [s390x] os: [linux] - '@img/sharp-linux-x64@0.34.5': - resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-x64@0.35.5': + resolution: {integrity: sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] - '@img/sharp-linuxmusl-arm64@0.34.5': - resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linuxmusl-arm64@0.35.5': + resolution: {integrity: sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] - '@img/sharp-linuxmusl-x64@0.34.5': - resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linuxmusl-x64@0.35.5': + resolution: {integrity: sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] - '@img/sharp-wasm32@0.34.5': - resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-wasm32@0.35.5': + resolution: {integrity: sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.5': + resolution: {integrity: sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==} + engines: {node: '>=20.9.0'} cpu: [wasm32] - '@img/sharp-win32-arm64@0.34.5': - resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-win32-arm64@0.35.5': + resolution: {integrity: sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [win32] - '@img/sharp-win32-ia32@0.34.5': - resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-win32-ia32@0.35.5': + resolution: {integrity: sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==} + engines: {node: ^20.9.0} cpu: [ia32] os: [win32] - '@img/sharp-win32-x64@0.34.5': - resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-win32-x64@0.35.5': + resolution: {integrity: sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [win32] - '@isaacs/cliui@8.0.2': - resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} - engines: {node: '>=12'} - - '@istanbuljs/load-nyc-config@1.1.0': - resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} - engines: {node: '>=8'} - - '@istanbuljs/schema@0.1.3': - resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} - engines: {node: '>=8'} - - '@jest/console@30.3.0': - resolution: {integrity: sha512-PAwCvFJ4696XP2qZj+LAn1BWjZaJ6RjG6c7/lkMaUJnkyMS34ucuIsfqYvfskVNvUI27R/u4P1HMYFnlVXG/Ww==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - '@jest/core@30.3.0': - resolution: {integrity: sha512-U5mVPsBxLSO6xYbf+tgkymLx+iAhvZX43/xI1+ej2ZOPnPdkdO1CzDmFKh2mZBn2s4XZixszHeQnzp1gm/DIxw==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 - peerDependenciesMeta: - node-notifier: - optional: true - - '@jest/diff-sequences@30.3.0': - resolution: {integrity: sha512-cG51MVnLq1ecVUaQ3fr6YuuAOitHK1S4WUJHnsPFE/quQr33ADUx1FfrTCpMCRxvy0Yr9BThKpDjSlcTi91tMA==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - '@jest/environment-jsdom-abstract@30.3.0': - resolution: {integrity: sha512-0hNFs5N6We3DMCwobzI0ydhkY10sT1tZSC0AAiy+0g2Dt/qEWgrcV5BrMxPczhe41cxW4qm6X+jqZaUdpZIajA==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - peerDependencies: - canvas: ^3.0.0 - jsdom: '*' - peerDependenciesMeta: - canvas: - optional: true - - '@jest/environment@30.3.0': - resolution: {integrity: sha512-SlLSF4Be735yQXyh2+mctBOzNDx5s5uLv88/j8Qn1wH679PDcwy67+YdADn8NJnGjzlXtN62asGH/T4vWOkfaw==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - '@jest/expect-utils@29.7.0': - resolution: {integrity: sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/expect-utils@30.3.0': - resolution: {integrity: sha512-j0+W5iQQ8hBh7tHZkTQv3q2Fh/M7Je72cIsYqC4OaktgtO7v1So9UTjp6uPBHIaB6beoF/RRsCgMJKvti0wADA==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - '@jest/expect@30.3.0': - resolution: {integrity: sha512-76Nlh4xJxk2D/9URCn3wFi98d2hb19uWE1idLsTt2ywhvdOldbw3S570hBgn25P4ICUZ/cBjybrBex2g17IDbg==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - '@jest/fake-timers@30.3.0': - resolution: {integrity: sha512-WUQDs8SOP9URStX1DzhD425CqbN/HxUYCTwVrT8sTVBfMvFqYt/s61EK5T05qnHu0po6RitXIvP9otZxYDzTGQ==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - '@jest/get-type@30.1.0': - resolution: {integrity: sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - '@jest/globals@30.3.0': - resolution: {integrity: sha512-+owLCBBdfpgL3HU+BD5etr1SvbXpSitJK0is1kiYjJxAAJggYMRQz5hSdd5pq1sSggfxPbw2ld71pt4x5wwViA==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - '@jest/pattern@30.0.1': - resolution: {integrity: sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - '@jest/reporters@30.3.0': - resolution: {integrity: sha512-a09z89S+PkQnL055bVj8+pe2Caed2PBOaczHcXCykW5ngxX9EWx/1uAwncxc/HiU0oZqfwseMjyhxgRjS49qPw==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 - peerDependenciesMeta: - node-notifier: - optional: true - - '@jest/schemas@29.6.3': - resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/schemas@30.0.5': - resolution: {integrity: sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - '@jest/snapshot-utils@30.3.0': - resolution: {integrity: sha512-ORbRN9sf5PP82v3FXNSwmO1OTDR2vzR2YTaR+E3VkSBZ8zadQE6IqYdYEeFH1NIkeB2HIGdF02dapb6K0Mj05g==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - '@jest/source-map@30.0.1': - resolution: {integrity: sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - '@jest/test-result@30.3.0': - resolution: {integrity: sha512-e/52nJGuD74AKTSe0P4y5wFRlaXP0qmrS17rqOMHeSwm278VyNyXE3gFO/4DTGF9w+65ra3lo3VKj0LBrzmgdQ==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - '@jest/test-sequencer@30.3.0': - resolution: {integrity: sha512-dgbWy9b8QDlQeRZcv7LNF+/jFiiYHTKho1xirauZ7kVwY7avjFF6uTT0RqlgudB5OuIPagFdVtfFMosjVbk1eA==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - '@jest/transform@30.3.0': - resolution: {integrity: sha512-TLKY33fSLVd/lKB2YI1pH69ijyUblO/BQvCj566YvnwuzoTNr648iE0j22vRvVNk2HsPwByPxATg3MleS3gf5A==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - '@jest/types@29.6.3': - resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/types@30.3.0': - resolution: {integrity: sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -954,69 +1306,83 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} - '@napi-rs/wasm-runtime@0.2.12': - resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} - - '@next-auth/prisma-adapter@1.0.7': - resolution: {integrity: sha512-Cdko4KfcmKjsyHFrWwZ//lfLUbcLqlyFqjd/nYE2m3aZ7tjMNUjpks47iw7NTCnXf+5UWz5Ypyt1dSs1EP5QJw==} - peerDependencies: - '@prisma/client': '>=2.26.0 || >=3' - next-auth: ^4 - - '@next/env@15.5.18': - resolution: {integrity: sha512-hAV85Ckd9QR6RvH04MEKwsfLTksvFpO47j9xwtoIuvuPnlwecpSi+uZTtm8HirVbtlI2Fnz//xpcSTjFdyJk+g==} + '@next/env@15.5.27': + resolution: {integrity: sha512-WC5hvVqiuFOcRyz6gKPeJkiVvSMf3scNeAV49CdWtVqxmUlzh8UKR6YHt9yiNkh8LngtzqSlVz07/Ma3ZAzchA==} - '@next/swc-darwin-arm64@15.5.18': - resolution: {integrity: sha512-w0WvQf1n+txiwns/9pwIQteCJpZTbxzO2SE0FLcwuD4v0WEh1JPOjdyxWL21XwJsdpx8cFRjyzxzCS/siP7HcQ==} + '@next/swc-darwin-arm64@15.5.27': + resolution: {integrity: sha512-fBA9QurmK4+uj4wNhmRTuO5zMVRaBHCvp6H93zTG/5i6Yi6TewbvbesXB8YQNj8T99Cwrow87hHiFV6wL/hyMA==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@15.5.18': - resolution: {integrity: sha512-znn71QmDuxm+BOaglihMZfvyySMnNljkVIY5Z2TCssBmm+WqL6c19VhtH5ktFkHa8EZ2bnTUpcNcmNSQsg67og==} + '@next/swc-darwin-x64@15.5.27': + resolution: {integrity: sha512-FGZwJwry8qZNHxD/nfwrCwCHHrqx6epqItkkxbYcBQEQ2uw5S43bY83fQB5zbAsZE7TELMjoYkbhsQ4oKM7wwg==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@15.5.18': - resolution: {integrity: sha512-yPPe5MNL+igZUa+OsqQJisqSfh6oarIuA1Q0BDxljGJhRQyZeP+WRHh7rs/jZUGMh5aY0YdIjXZG0VohkKkUdw==} + '@next/swc-linux-arm64-gnu@15.5.27': + resolution: {integrity: sha512-GQulAaJYNF1RYuatlsw9/uzbKz4if2V7Ic9nVO+P0ljYPm4yBRTFIl6g5YkKTw40lMLbpy9edLoH44g3jSHMYA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-arm64-musl@15.5.18': - resolution: {integrity: sha512-glaCczEWIrHsokFZ3pP08U4BpKxwIdnT+txdOM32OBgpL9Yw4aqx8NejmgtZQZOdstQ5f0L3CasIZudzCuD+nw==} + '@next/swc-linux-arm64-musl@15.5.27': + resolution: {integrity: sha512-URFFsUmu8Pi0/1qdnUI7tx7keZw1Mt5zHfrIX1RfDG9Nih8DYxK54WZsDJUX2ZTlii1jgotL7qcbwKnSAES6xQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-x64-gnu@15.5.18': - resolution: {integrity: sha512-oUfg2EgJmU3R0OCOWiokGFUTvZiPfXtriXiuF3YNxRoROCdgvTedHIzYoeKH34gsZxS/V7mHbfq2hpAHwhH1/A==} + '@next/swc-linux-x64-gnu@15.5.27': + resolution: {integrity: sha512-aJp74z7uvU5jQhyH7lyk+4qSRcTdKbKT9NQE3jiO9kWYkoHZjKi0zzHDbCoZ+v2sJDEiJ/z58R0UlWMGWNF3HA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-linux-x64-musl@15.5.18': - resolution: {integrity: sha512-JLxSP3KTd9iu/bvUMQxH7RJo9xKSHf55/6RPE4a6FTSZygGn7uvZbCej0AHXydwkggQGSD9UddSjwv6Xz5ESfA==} + '@next/swc-linux-x64-musl@15.5.27': + resolution: {integrity: sha512-ILl2bQ8MbpvyMpY8AXljb3BXs0MJSC8HXZ6QV5AWpVS7ZELqNPtrHvhUk+SNarvjvICXX8bCS287TCi+gUFVYg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-win32-arm64-msvc@15.5.18': - resolution: {integrity: sha512-ir1v7enP52K2HNz3tQQvwF+x7VNxBk1ciiZ18WBPvxf4C59IqdfmHPJYK3vH7rSxpuCVw/8C712wTXNAtEp+NA==} + '@next/swc-win32-arm64-msvc@15.5.27': + resolution: {integrity: sha512-1MB9Rh5y8TOPN2x7naNTJNkCZQav9c70AI7EBYXxsEewwOffC4DjjwBwKf20NBvcA+u+5jDv7nVmHxunlP2d7Q==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@15.5.18': - resolution: {integrity: sha512-LIu5me6QTANCd25E7I5uIEfvgQ06RK7tvHAbYo3zCb3VpxQEPvMcSpd87NwUABDT6MbGPdEGR5VRiK4PPTJhQg==} + '@next/swc-win32-x64-msvc@15.5.27': + resolution: {integrity: sha512-XtYgpGOswgnyBWXzOEG6q9RhvnSw0CEZ0MbWqCsl5PISgt9EzvJvNW5mU5n1PsphX/lnamzxjgQXH2GUJL8nqg==} engines: {node: '>= 10'} cpu: [x64] os: [win32] - '@noble/hashes@1.8.0': - resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} - engines: {node: ^14.21.3 || >=16} + '@noble/ciphers@2.4.0': + resolution: {integrity: sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==} + engines: {node: '>= 20.19.0'} + + '@noble/hashes@2.4.0': + resolution: {integrity: sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==} + engines: {node: '>= 20.19.0'} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@opentelemetry/semantic-conventions@1.43.0': + resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} + engines: {node: '>=14'} + + '@oxc-project/types@0.153.0': + resolution: {integrity: sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==} '@oxlint-tsgolint/darwin-arm64@0.18.1': resolution: {integrity: sha512-CxSd15ZwHn70UJFTXVvy76bZ9zwI097cVyjvUFmYRJwvkQF3VnrTf2oe1gomUacErksvtqLgn9OKvZhLMYwvog==} @@ -1162,49 +1528,19 @@ packages: cpu: [x64] os: [win32] - '@panva/hkdf@1.2.1': - resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==} + '@playwright/test@1.64.0': + resolution: {integrity: sha512-oTN9FNYuN6bqQMwiho6jJKLCG2/QOYCixGXuCjRmzOq23eODeukuY2XiAVoVpAECbuezMt1MHQjAiQxK8F+Y+g==} + engines: {node: '>=20'} + hasBin: true - '@paralleldrive/cuid2@2.3.1': - resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} + '@poppinss/colors@4.1.6': + resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} - '@pkgjs/parseargs@0.11.0': - resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} - engines: {node: '>=14'} + '@poppinss/dumper@0.6.5': + resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==} - '@pkgr/core@0.2.9': - resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} - engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} - - '@prisma/client@6.19.1': - resolution: {integrity: sha512-4SXj4Oo6HyQkLUWT8Ke5R0PTAfVOKip5Roo+6+b2EDTkFg5be0FnBWiuRJc0BC0sRQIWGMLKW1XguhVfW/z3/A==} - engines: {node: '>=18.18'} - peerDependencies: - prisma: '*' - typescript: '>=5.1.0' - peerDependenciesMeta: - prisma: - optional: true - typescript: - optional: true - - '@prisma/config@6.19.3': - resolution: {integrity: sha512-CBPT44BjlQxEt8kiMEauji2WHTDoVBOKl7UlewXmUgBPnr/oPRZC3psci5chJnYmH0ivEIog2OU9PGWoki3DLQ==} - - '@prisma/debug@6.19.3': - resolution: {integrity: sha512-ljkJ+SgpXNktLG0Q/n4JGYCkKf0f8oYLyjImS2I8e2q2WCfdRRtWER062ZV/ixaNP2M2VKlWXVJiGzZaUgbKZw==} - - '@prisma/engines-version@7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7': - resolution: {integrity: sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==} - - '@prisma/engines@6.19.3': - resolution: {integrity: sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g==} - - '@prisma/fetch-engine@6.19.3': - resolution: {integrity: sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA==} - - '@prisma/get-platform@6.19.3': - resolution: {integrity: sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==} + '@poppinss/exception@1.2.3': + resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} '@radix-ui/number@1.1.1': resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} @@ -1947,8 +2283,105 @@ packages: '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} - '@serwist/build@9.2.3': - resolution: {integrity: sha512-UU38GDsTerzoCRDIT5v62W/CcTMLfGZm/tAa+u8XLBU0y0f2aJ2GCfsHnI1eXhEuWvt4Y7Imx/uG2ww2KWIBWQ==} + '@resvg/resvg-wasm@2.4.1': + resolution: {integrity: sha512-yi6R0HyHtsoWTRA06Col4WoDs7SvlXU3DLMNP2bdAgs7HK18dTEVl1weXgxRzi8gwLteGUbIg29zulxIB3GSdg==} + engines: {node: '>= 10'} + + '@rolldown/binding-android-arm-eabi@1.2.13': + resolution: {integrity: sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.13': + resolution: {integrity: sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.13': + resolution: {integrity: sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.13': + resolution: {integrity: sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.13': + resolution: {integrity: sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.13': + resolution: {integrity: sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.13': + resolution: {integrity: sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.2.13': + resolution: {integrity: sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-ppc64-gnu@1.2.13': + resolution: {integrity: sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@rolldown/binding-linux-s390x-gnu@1.2.13': + resolution: {integrity: sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.2.13': + resolution: {integrity: sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.2.13': + resolution: {integrity: sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.2.13': + resolution: {integrity: sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.13': + resolution: {integrity: sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.13': + resolution: {integrity: sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@serwist/build@9.5.13': + resolution: {integrity: sha512-3seu43B26paWNKixMJTJWXBJx8c07e+WZvPOUH5V/AByYfAoL4TKh9vMri8zukyj52shDyChs2Qjji5NObprMQ==} engines: {node: '>=18.0.0'} peerDependencies: typescript: '>=5.0.0' @@ -1956,47 +2389,38 @@ packages: typescript: optional: true - '@serwist/next@9.2.3': - resolution: {integrity: sha512-sbStIaen8H6ZhNCtfxAuZ2haujJe8nPFRBiq5KQFYYi5iyFrDGLpYICmRTBP6Oc1pQBmsAqbBZzIjjC60d+7zg==} - engines: {node: '>=18.0.0'} + '@serwist/utils@9.5.13': + resolution: {integrity: sha512-jJJSvl0pSc6udJ7pN2u0oduOfQqSY6oJNjbIc82YcVnkpzj1O+qFHYdrR6tu9bspa30Nc8ZVV+VncOcyguvk5w==} peerDependencies: - next: '>=14.0.0' - typescript: '>=5.0.0' + browserslist: '>=4' peerDependenciesMeta: - typescript: + browserslist: optional: true - '@serwist/webpack-plugin@9.2.3': - resolution: {integrity: sha512-y1Ag1a7LARo/LtPVRSrfGO5PG4BZdc1NiMGAq+CGlBH/PJa7RQimo4iq9C94pjxusEsP4yHnsDQ06+GVTQ6gww==} + '@serwist/vite@9.5.13': + resolution: {integrity: sha512-ZJYRMTaG5ZigJj7N+pYNVaQbkJezbbSLoXySDO643TpZpbmD8mIzb5DfB94UYtXOy1TVkxw+oiW/VGYkhuMI9Q==} engines: {node: '>=18.0.0'} peerDependencies: + rollup: '>=4.0.0' typescript: '>=5.0.0' - webpack: '>=5.104.0' + vite: '>=5.0.0' peerDependenciesMeta: - typescript: + rollup: optional: true - webpack: - optional: true - - '@serwist/window@9.2.3': - resolution: {integrity: sha512-6bK01QAaFI+oXTmDSQHvsw0kTTNlj79PFkox+0Z9XYNqTVLoXY9C6BFmZNvxNxEinOqCSm7cms4I5V54E9/1XA==} - peerDependencies: - typescript: '>=5.0.0' - peerDependenciesMeta: typescript: optional: true - '@sinclair/typebox@0.27.8': - resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} - - '@sinclair/typebox@0.34.40': - resolution: {integrity: sha512-gwBNIP8ZAYev/ORDWW0QvxdwPXwxBtLsdsJgSc7eDIRt8ubP+rxUBzPsrwnu16fgEF8Bx4lh/+mvQvJzcTM6Kw==} + '@shuding/opentype.js@1.4.0-beta.0': + resolution: {integrity: sha512-3NgmNyH3l/Hv6EvsWJbsvpcpUba6R8IREQ83nH83cyakCw7uM1arZKNfHwv1Wz6jgqrF/j4x5ELvR6PnK9nTcA==} + engines: {node: '>= 8.0.0'} + hasBin: true - '@sinonjs/commons@3.0.1': - resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} + '@sindresorhus/is@7.2.0': + resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} + engines: {node: '>=18'} - '@sinonjs/fake-timers@15.2.2': - resolution: {integrity: sha512-KK6HfcJHaxmQNzHSsCrRzp1igwAWUXEtD64EScEUsaHw3ViNHgpFR/ACgHpesDR/YDbu6IZMzNbQkTDMlnZHGw==} + '@speed-highlight/core@1.2.24': + resolution: {integrity: sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==} '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -2140,13 +2564,13 @@ packages: '@trpc/server': 11.8.0 typescript: '>=5.7.2' - '@trpc/next@11.8.0': - resolution: {integrity: sha512-aMS1itkddKCeZEmd4fV9jm9+HMob4R684FfNqXdnGkw0acfdeUpk4hihs8gml+fMqgPRv6LcgsZlOb4YpOOjbg==} + '@trpc/next@11.2.0': + resolution: {integrity: sha512-PyTe8GXn14NaTnHqm7ggR9PtgQhBUiW+3HPVIvwTy9/P9P4xw+A4Kl6V8BZV4Sq4jkmapuEY2rLquVtV3nhYzw==} peerDependencies: '@tanstack/react-query': ^5.59.15 - '@trpc/client': 11.8.0 - '@trpc/react-query': 11.8.0 - '@trpc/server': 11.8.0 + '@trpc/client': 11.2.0 + '@trpc/react-query': 11.2.0 + '@trpc/server': 11.2.0 next: '*' react: '>=16.8.0' react-dom: '>=16.8.0' @@ -2184,48 +2608,24 @@ packages: '@tsconfig/node16@1.0.4': resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} - '@tybys/wasm-util@0.10.0': - resolution: {integrity: sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ==} + '@types/better-sqlite3@9.6.0': + resolution: {integrity: sha512-ZEEwBSgMu7GYJOynoagg5X9JbxfL6dTJDsgViJIqh67jV44kyOr9RXfmFjLK5rzC4MWssP06t9hu/JwGDnUbCg==} - '@types/babel__core@7.20.5': - resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} - '@types/babel__generator@7.27.0': - resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} - '@types/babel__template@7.4.4': - resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} - - '@types/babel__traverse@7.20.7': - resolution: {integrity: sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==} - - '@types/formidable@3.4.6': - resolution: {integrity: sha512-LI4Hk+KNsM5q7br4oMVoaWeb+gUqJpz1N8+Y2Q6Cz9cVH33ybahRKUWaRmMboVlkwSbOUGgwc/pEkS7yMSzoWg==} + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} '@types/hoist-non-react-statics@3.3.6': resolution: {integrity: sha512-lPByRJUer/iN/xa4qpyL0qmL11DqNW81iU/IG1S3uvRUq4oKagz8VCxZjiWkumgt66YT3vOdDgZ0o32sGKtCEw==} - '@types/istanbul-lib-coverage@2.0.6': - resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} - - '@types/istanbul-lib-report@3.0.3': - resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} - - '@types/istanbul-reports@3.0.4': - resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} - - '@types/jest@29.5.14': - resolution: {integrity: sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==} - - '@types/jsdom@21.1.7': - resolution: {integrity: sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==} - '@types/node@22.19.3': resolution: {integrity: sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==} - '@types/nodemailer@8.0.0': - resolution: {integrity: sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==} - '@types/react-dom@19.2.1': resolution: {integrity: sha512-/EEvYBdT3BflCWvTMO7YkYBHVE9Ci6XdqZciZANQgKpaiDRGOLIlRo91jbTNRQjgPFWVaRxcYc0luVNFitz57A==} peerDependencies: @@ -2234,24 +2634,9 @@ packages: '@types/react@19.2.1': resolution: {integrity: sha512-1U5NQWh/GylZQ50ZMnnPjkYHEaGhg6t5i/KI0LDDh3t4E3h3T3vzm+GLY2BRzMfIjSBwzm6tginoZl5z0O/qsA==} - '@types/stack-utils@2.0.3': - resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} - - '@types/tough-cookie@4.0.5': - resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} - - '@types/trusted-types@2.0.7': - resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} - '@types/web-push@3.6.4': resolution: {integrity: sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==} - '@types/yargs-parser@21.0.3': - resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} - - '@types/yargs@17.0.33': - resolution: {integrity: sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==} - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20251016.1': resolution: {integrity: sha512-TBSMWAhEaHGMwDRYxY5Lpjgr0JMdtdYEr/ha1pdvAP7g+D23EnKNsIpf2bv2FoBkWb5gsh33tT6GcZV0N2bICg==} cpu: [arm64] @@ -2291,103 +2676,85 @@ packages: resolution: {integrity: sha512-OMnnupqcqdcbf4lryAKyIDWNJHc1dP13OcKmV5aPUY2dJIYBD0ud4xgOkp/zdOoLBCpdRMIMjzziI6PYATMWdw==} hasBin: true - '@ungap/structured-clone@1.3.0': - resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} - - '@unrs/resolver-binding-android-arm-eabi@1.11.1': - resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} - cpu: [arm] - os: [android] - - '@unrs/resolver-binding-android-arm64@1.11.1': - resolution: {integrity: sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==} - cpu: [arm64] - os: [android] - - '@unrs/resolver-binding-darwin-arm64@1.11.1': - resolution: {integrity: sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==} - cpu: [arm64] - os: [darwin] - - '@unrs/resolver-binding-darwin-x64@1.11.1': - resolution: {integrity: sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==} - cpu: [x64] - os: [darwin] - - '@unrs/resolver-binding-freebsd-x64@1.11.1': - resolution: {integrity: sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==} - cpu: [x64] - os: [freebsd] - - '@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1': - resolution: {integrity: sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==} - cpu: [arm] - os: [linux] - - '@unrs/resolver-binding-linux-arm-musleabihf@1.11.1': - resolution: {integrity: sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==} - cpu: [arm] - os: [linux] + '@unpic/core@1.0.3': + resolution: {integrity: sha512-aum9YNVUGso7MjGLD0Rp/08kywCGLqZ03/q6VQBFFakDBOXWEc8D4kPGcZ8v5wEnGRex3lE+++bOuucBp3KJ/w==} - '@unrs/resolver-binding-linux-arm64-gnu@1.11.1': - resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} - cpu: [arm64] - os: [linux] + '@unpic/react@1.0.2': + resolution: {integrity: sha512-5RmRfELwTF8w+4zjtQGqjpvX+RU2VLvis3xDCS1O2uWk0PZN2cvatL+3/KAR3mshAuRrkFGTX1XwyAezSXaoCA==} + peerDependencies: + next: ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + next: + optional: true - '@unrs/resolver-binding-linux-arm64-musl@1.11.1': - resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} - cpu: [arm64] - os: [linux] + '@vercel/og@1.0.3': + resolution: {integrity: sha512-/iYJ34PDu4lty1TXdL86qHEw8viveHTllG1PWLuJX8tSFfqKL7kyVJeUORJmQ250bHB0h+alcalXUlza3c/NSA==} + engines: {node: '>=22'} - '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': - resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} - cpu: [ppc64] - os: [linux] + '@vinext/cloudflare@1.1.0': + resolution: {integrity: sha512-AQVYlrGuu85HckaENIi87yN84eOFvXI29aBA0yp16Sq7iol2BKlNJTv09RSix0R8CxyUgQmJ5f3aHZifahGYHQ==} + engines: {node: '>=22'} + hasBin: true + peerDependencies: + '@cloudflare/vite-plugin': '*' + cf: '*' + vinext: ^1.1.0 + peerDependenciesMeta: + '@cloudflare/vite-plugin': + optional: true + cf: + optional: true - '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': - resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} - cpu: [riscv64] - os: [linux] + '@vinext/types@1.0.1': + resolution: {integrity: sha512-zH4WbVl47rNGxG8Y7XoWJAc8e0VQFQ+r8Cuh0crv1L2SuNovDD3jMtxQDjzoS1b9hYaZXbds0CjRo5tSm7F9Zw==} + engines: {node: '>=22'} - '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': - resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} - cpu: [riscv64] - os: [linux] + '@vitejs/plugin-react@6.1.2': + resolution: {integrity: sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + oxc-transform-react: ^0.152.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + oxc-transform-react: + optional: true - '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': - resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} - cpu: [s390x] - os: [linux] + '@vitest/expect@4.1.11': + resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} - '@unrs/resolver-binding-linux-x64-gnu@1.11.1': - resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} - cpu: [x64] - os: [linux] + '@vitest/mocker@4.1.11': + resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true - '@unrs/resolver-binding-linux-x64-musl@1.11.1': - resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} - cpu: [x64] - os: [linux] + '@vitest/pretty-format@4.1.11': + resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==} - '@unrs/resolver-binding-wasm32-wasi@1.11.1': - resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} - engines: {node: '>=14.0.0'} - cpu: [wasm32] + '@vitest/runner@4.1.11': + resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==} - '@unrs/resolver-binding-win32-arm64-msvc@1.11.1': - resolution: {integrity: sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==} - cpu: [arm64] - os: [win32] + '@vitest/snapshot@4.1.11': + resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==} - '@unrs/resolver-binding-win32-ia32-msvc@1.11.1': - resolution: {integrity: sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==} - cpu: [ia32] - os: [win32] + '@vitest/spy@4.1.11': + resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==} - '@unrs/resolver-binding-win32-x64-msvc@1.11.1': - resolution: {integrity: sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==} - cpu: [x64] - os: [win32] + '@vitest/utils@4.1.11': + resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} acorn-walk@8.3.4: resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} @@ -2402,91 +2769,125 @@ packages: resolution: {integrity: sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==} engines: {node: '>= 14'} - ansi-escapes@4.3.2: - resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} - engines: {node: '>=8'} - ansi-escapes@7.2.0: resolution: {integrity: sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw==} engines: {node: '>=18'} - ansi-regex@5.0.1: - resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} - engines: {node: '>=8'} - ansi-regex@6.2.2: resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} engines: {node: '>=12'} - ansi-styles@4.3.0: - resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} - engines: {node: '>=8'} - - ansi-styles@5.2.0: - resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} - engines: {node: '>=10'} - ansi-styles@6.2.3: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} - anymatch@3.1.3: - resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} - engines: {node: '>= 8'} - arg@4.1.3: resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} - argparse@1.0.10: - resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} - aria-hidden@1.2.6: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} - asap@2.0.6: - resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} - asn1.js@5.4.1: resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} axios@1.16.0: resolution: {integrity: sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==} - babel-jest@30.3.0: - resolution: {integrity: sha512-gRpauEU2KRrCox5Z296aeVHR4jQ98BCnu0IO332D/xpHNOsIH/bgSRk9k6GbKIbBw8vFeN6ctuu6tV8WOyVfYQ==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - peerDependencies: - '@babel/core': ^7.11.0 || ^8.0.0-0 + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - babel-plugin-istanbul@7.0.1: - resolution: {integrity: sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA==} - engines: {node: '>=12'} + base64-js@0.0.8: + resolution: {integrity: sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==} + engines: {node: '>= 0.4'} - babel-plugin-jest-hoist@30.3.0: - resolution: {integrity: sha512-+TRkByhsws6sfPjVaitzadk1I0F5sPvOVUH5tyTSzhePpsGIVrdeunHSw/C36QeocS95OOk8lunc4rlu5Anwsg==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + baseline-browser-mapping@2.9.9: + resolution: {integrity: sha512-V8fbOCSeOFvlDj7LLChUcqbZrdKD9RU/VR260piF1790vT0mfLSwGc/Qzxv3IqiTukOpNtItePa0HBpMAj7MDg==} + hasBin: true - babel-preset-current-node-syntax@1.2.0: - resolution: {integrity: sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==} - peerDependencies: - '@babel/core': ^7.0.0 || ^8.0.0-0 + better-auth@1.7.7: + resolution: {integrity: sha512-Zhgxi/c5ylmfB/sX+nwnBbsFf/j6k0L8DvL6CJXZSrXgxc5pPMC/e7j812zNd4L4B4ELmdURCPw3X8nAOUdcjw==} + peerDependencies: + '@lynx-js/react': '*' + '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 + '@sveltejs/kit': ^2.0.0 + '@tanstack/react-start': ^1.0.0 + '@tanstack/solid-start': ^1.0.0 + drizzle-kit: '>=0.31.4 || >=1.0.0-beta.1' + drizzle-orm: ^0.45.2 || >=1.0.0-rc.1 <2.0.0 + mongodb: ^6.0.0 || ^7.0.0 + mysql2: ^3.0.0 + next: ^14.0.0 || ^15.0.0 || ^16.0.0 + pg: ^8.0.0 + prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + solid-js: ^1.0.0 + svelte: ^4.0.0 || ^5.0.0 + vitest: ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 + vue: ^3.0.0 + peerDependenciesMeta: + '@lynx-js/react': + optional: true + '@prisma/client': + optional: true + '@sveltejs/kit': + optional: true + '@tanstack/react-start': + optional: true + '@tanstack/solid-start': + optional: true + drizzle-kit: + optional: true + drizzle-orm: + optional: true + mongodb: + optional: true + mysql2: + optional: true + next: + optional: true + pg: + optional: true + prisma: + optional: true + react: + optional: true + react-dom: + optional: true + solid-js: + optional: true + svelte: + optional: true + vitest: + optional: true + vue: + optional: true - babel-preset-jest@30.3.0: - resolution: {integrity: sha512-6ZcUbWHC+dMz2vfzdNwi87Z1gQsLNK2uLuK1Q89R11xdvejcivlYYwDlEv0FHX3VwEXpbBQ9uufB/MUNpZGfhQ==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + better-call@1.4.0: + resolution: {integrity: sha512-bBKOT4vv1kZLDgxVePdilk/Jwkn+dtRRsmi3DzHcDP+WnswyVl6dR59l2HEeP/0cB+bDoopASAesWDPIdd/zZA==} peerDependencies: - '@babel/core': ^7.11.0 || ^8.0.0-beta.1 + zod: ^4.0.0 + peerDependenciesMeta: + zod: + optional: true - balanced-match@1.0.2: - resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + better-sqlite3@13.0.3: + resolution: {integrity: sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==} + engines: {node: '>=22'} - baseline-browser-mapping@2.9.9: - resolution: {integrity: sha512-V8fbOCSeOFvlDj7LLChUcqbZrdKD9RU/VR260piF1790vT0mfLSwGc/Qzxv3IqiTukOpNtItePa0HBpMAj7MDg==} - hasBin: true + bidi-js@1.1.0: + resolution: {integrity: sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==} + + blake3-wasm@2.1.5: + resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} bn.js@5.2.3: resolution: {integrity: sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w==} @@ -2509,71 +2910,28 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true - bser@2.1.1: - resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} - buffer-equal-constant-time@1.0.1: resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} - c12@3.1.0: - resolution: {integrity: sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==} - peerDependencies: - magicast: ^0.3.5 - peerDependenciesMeta: - magicast: - optional: true - call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} - callsites@3.1.0: - resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} - engines: {node: '>=6'} - - camelcase@5.3.1: - resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} - engines: {node: '>=6'} - - camelcase@6.3.0: - resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} - engines: {node: '>=10'} + camelize@1.0.1: + resolution: {integrity: sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==} caniuse-lite@1.0.30001760: resolution: {integrity: sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==} - chalk@4.1.2: - resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} - engines: {node: '>=10'} - - chalk@5.6.2: - resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} - engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} - - char-regex@1.0.2: - resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} - engines: {node: '>=10'} - - chokidar@4.0.3: - resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} - engines: {node: '>= 14.16.0'} - - ci-info@3.9.0: - resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} - engines: {node: '>=8'} - - ci-info@4.3.0: - resolution: {integrity: sha512-l+2bNRMiQgcfILUi33labAZYIWlH1kWDp+ecNo5iisRKrbm0xcRyCww71/YU0Fkw0mAFpz9bJayXPjey6vkmaQ==} - engines: {node: '>=8'} - - citty@0.1.6: - resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} + chai@6.3.0: + resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==} + engines: {node: '>=18'} - cjs-module-lexer@2.1.0: - resolution: {integrity: sha512-UX0OwmYRYQQetfrLEZeewIFFI+wSTofC+pMBLNuH3RUuu/xzG1oz84UCEDOSoQlN3fZ4+AzmV50ZYvGqkMh9yA==} + cjs-module-lexer@1.2.3: + resolution: {integrity: sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==} class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} @@ -2589,10 +2947,6 @@ packages: client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} - cliui@8.0.1: - resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} - engines: {node: '>=12'} - clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -2603,17 +2957,6 @@ packages: react: ^18 || ^19 || ^19.0.0-rc react-dom: ^18 || ^19 || ^19.0.0-rc - co@4.6.0: - resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} - engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} - - collect-v8-coverage@1.0.2: - resolution: {integrity: sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==} - - color-convert@2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} - engines: {node: '>=7.0.0'} - color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} @@ -2632,19 +2975,12 @@ packages: resolution: {integrity: sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==} engines: {node: '>=4.0.0'} - confbox@0.2.2: - resolution: {integrity: sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==} - - consola@3.4.2: - resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} - engines: {node: ^14.18.0 || >=16.10.0} - convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - cookie@0.7.2: - resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} - engines: {node: '>= 0.6'} + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} copy-anything@4.0.5: resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} @@ -2667,20 +3003,33 @@ packages: cross-fetch@4.1.0: resolution: {integrity: sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==} - cross-spawn@7.0.6: - resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} - engines: {node: '>= 8'} + css-background-parser@0.1.0: + resolution: {integrity: sha512-2EZLisiZQ+7m4wwur/qiYJRniHX4K5Tc9w93MT3AS0WS1u5kaZ4FKXlOTBhOjc+CgEgPiGY+fX1yWD8UwpEqUA==} - cssstyle@4.6.0: - resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} - engines: {node: '>=18'} + css-box-shadow@1.0.0-3: + resolution: {integrity: sha512-9jaqR6e7Ohds+aWwmhe6wILJ99xYQbfmK9QQB9CcMjDbTxPZjwEmUQpU91OG05Xgm8BahT5fW+svbsQGjS/zPg==} - csstype@3.1.3: - resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + css-color-keywords@1.0.0: + resolution: {integrity: sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==} + engines: {node: '>=4'} - data-urls@5.0.0: - resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} - engines: {node: '>=18'} + css-gradient-parser@0.0.17: + resolution: {integrity: sha512-w2Xy9UMMwlKtou0vlRnXvWglPAceXCTtcmVSo8ZBUvqCV5aXEFP/PC6d+I464810I9FT++UACwTD5511bmGPUg==} + engines: {node: '>=16'} + + css-to-react-native@3.2.0: + resolution: {integrity: sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==} + + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + csstype@3.1.3: + resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + + data-urls@8.0.0: + resolution: {integrity: sha512-JQUCCK+/CAGbGGx618PlFZMXH81Jtj7t73CHv9dd/zVWvxgGT2zLFRjnKb+Ng46GIwcy+f7f88qd4duqllK+WQ==} + engines: {node: ^22.14.0 || >=24.0.0} date-fns-jalali@4.1.0-0: resolution: {integrity: sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==} @@ -2700,53 +3049,23 @@ packages: supports-color: optional: true - decimal.js@10.5.0: - resolution: {integrity: sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==} - - dedent@1.6.0: - resolution: {integrity: sha512-F1Z+5UCFpmQUzJa11agbyPVMbpgT/qA3/SKyJ1jyBgm7dUcUEa8v9JwDkerSQXfakBwFljIxhOJqGkjUwZ9FSA==} - peerDependencies: - babel-plugin-macros: ^3.1.0 - peerDependenciesMeta: - babel-plugin-macros: - optional: true - - deepmerge-ts@7.1.5: - resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==} - engines: {node: '>=16.0.0'} - - deepmerge@4.3.1: - resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} - engines: {node: '>=0.10.0'} + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} - defu@6.1.4: - resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} - destr@2.0.5: - resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} - detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} - detect-newline@3.1.0: - resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} - engines: {node: '>=8'} - detect-node-es@1.1.0: resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} - dezalgo@1.0.4: - resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} - - diff-sequences@29.6.3: - resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - diff@4.0.4: resolution: {integrity: sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==} engines: {node: '>=0.3.1'} @@ -2755,57 +3074,136 @@ packages: resolution: {integrity: sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q==} engines: {node: '>=10'} - dotenv@16.6.1: - resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} - engines: {node: '>=12'} + drizzle-kit@0.31.11: + resolution: {integrity: sha512-YCYqxTLIB2OCqf6w9/Vef13baBVbwQIPcbT4Y56AfO6B9ajZhDhD8Jkveg/hJvT/iuMloKD/IYYkyMMNhr7Kqg==} + hasBin: true + + drizzle-orm@0.45.4: + resolution: {integrity: sha512-NiM1YZpJ2UxyeE67DpDDZ+Tsw4YLX3trSlkNT7Tm9eKoUNb1LvaAaQzXgYQsWs+AQ9TUmmwMmBOCXuqqxUWKKQ==} + peerDependencies: + '@aws-sdk/client-rds-data': '>=3' + '@cloudflare/workers-types': '>=4' + '@electric-sql/pglite': '>=0.2.0' + '@libsql/client': '>=0.10.0' + '@libsql/client-wasm': '>=0.10.0' + '@neondatabase/serverless': '>=0.10.0' + '@netlify/db': '>=0.4.0' + '@op-engineering/op-sqlite': '>=2' + '@opentelemetry/api': ^1.4.1 + '@planetscale/database': '>=1.13' + '@prisma/client': '*' + '@tidbcloud/serverless': '*' + '@types/better-sqlite3': '*' + '@types/pg': '*' + '@types/sql.js': '*' + '@upstash/redis': '>=1.34.7' + '@vercel/postgres': '>=0.8.0' + '@xata.io/client': '*' + better-sqlite3: '>=7' + bun-types: '*' + expo-sqlite: '>=14.0.0' + gel: '>=2' + knex: '*' + kysely: '*' + mysql2: '>=2' + pg: '>=8' + postgres: '>=3' + prisma: '*' + sql.js: '>=1' + sqlite3: '>=5' + peerDependenciesMeta: + '@aws-sdk/client-rds-data': + optional: true + '@cloudflare/workers-types': + optional: true + '@electric-sql/pglite': + optional: true + '@libsql/client': + optional: true + '@libsql/client-wasm': + optional: true + '@neondatabase/serverless': + optional: true + '@netlify/db': + optional: true + '@op-engineering/op-sqlite': + optional: true + '@opentelemetry/api': + optional: true + '@planetscale/database': + optional: true + '@prisma/client': + optional: true + '@tidbcloud/serverless': + optional: true + '@types/better-sqlite3': + optional: true + '@types/pg': + optional: true + '@types/sql.js': + optional: true + '@upstash/redis': + optional: true + '@vercel/postgres': + optional: true + '@xata.io/client': + optional: true + better-sqlite3: + optional: true + bun-types: + optional: true + expo-sqlite: + optional: true + gel: + optional: true + knex: + optional: true + kysely: + optional: true + mysql2: + optional: true + pg: + optional: true + postgres: + optional: true + prisma: + optional: true + sql.js: + optional: true + sqlite3: + optional: true dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} - eastasianwidth@0.2.0: - resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} - ecdsa-sig-formatter@1.0.11: resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} - effect@3.21.0: - resolution: {integrity: sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==} - electron-to-chromium@1.5.267: resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} - emittery@0.13.1: - resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} - engines: {node: '>=12'} + emoji-regex-xs@2.0.1: + resolution: {integrity: sha512-1QFuh8l7LqUcKe24LsPUNzjrzJQ7pgRwp1QMcZ5MX6mFplk2zQ08NVCM84++1cveaUUYtcCYHmeFEuNg16sU4g==} + engines: {node: '>=10.0.0'} emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} - emoji-regex@8.0.0: - resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - - emoji-regex@9.2.2: - resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} - - empathic@2.0.0: - resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} - engines: {node: '>=14'} - enhanced-resolve@5.18.4: resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==} engines: {node: '>=10.13.0'} - entities@6.0.0: - resolution: {integrity: sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw==} - engines: {node: '>=0.12'} + entities@8.1.0: + resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==} + engines: {node: '>=20.19.0'} environment@1.1.0: resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} engines: {node: '>=18'} - error-ex@1.3.2: - resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} + error-stack-parser-es@1.0.5: + resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} @@ -2815,6 +3213,12 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -2823,64 +3227,66 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} + esbuild@0.18.20: + resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} + engines: {node: '>=12'} + hasBin: true + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + esbuild@0.27.2: resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} engines: {node: '>=18'} hasBin: true + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} - escape-string-regexp@2.0.0: - resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} - engines: {node: '>=8'} + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} - esprima@4.0.1: - resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} - engines: {node: '>=4'} - hasBin: true + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} eventemitter3@5.0.1: resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} - execa@5.1.1: - resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} - engines: {node: '>=10'} - - exit-x@0.2.2: - resolution: {integrity: sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==} - engines: {node: '>= 0.8.0'} - - expect@29.7.0: - resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - expect@30.3.0: - resolution: {integrity: sha512-1zQrciTiQfRdo7qJM1uG4navm8DayFa2TgCSRlzUyNkhcJ6XUZF3hjnpkyr3VhAqPH7i/9GkG7Tv5abz6fqz0Q==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} - exsolve@1.0.8: - resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} - fast-check@3.23.2: - resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} - engines: {node: '>=8.0.0'} + fastq@1.20.3: + resolution: {integrity: sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==} - fast-json-stable-stringify@2.1.0: - resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true - fb-watchman@2.0.2: - resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} + fflate@0.7.3: + resolution: {integrity: sha512-0Zz1jOzJWERhyhsimS54VTqOteCNwRtIlh8isdL0AXLo0g7xNTfTL7oWrkmCnPhZGocKIkWHBistBrrpoNH3aw==} fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} - find-up@4.1.0: - resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} - engines: {node: '>=8'} - follow-redirects@1.16.0: resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} engines: {node: '>=4.0'} @@ -2890,21 +3296,10 @@ packages: debug: optional: true - foreground-child@3.3.1: - resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} - engines: {node: '>=14'} - form-data@4.0.5: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} - formidable@3.5.4: - resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==} - engines: {node: '>=14.0.0'} - - fs.realpath@1.0.0: - resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} - fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -2913,14 +3308,6 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - gensync@1.0.0-beta.2: - resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} - engines: {node: '>=6.9.0'} - - get-caller-file@2.0.5: - resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} - engines: {node: 6.* || 8.* || >= 10.*} - get-east-asian-width@1.4.0: resolution: {integrity: sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==} engines: {node: '>=18'} @@ -2933,32 +3320,20 @@ packages: resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} engines: {node: '>=6'} - get-package-type@0.1.0: - resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} - engines: {node: '>=8.0.0'} - get-proto@1.0.1: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} - get-stream@6.0.1: - resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} - engines: {node: '>=10'} - get-tsconfig@4.10.1: resolution: {integrity: sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==} - giget@2.0.0: - resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==} - hasBin: true - - glob@10.5.0: - resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} - hasBin: true + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} - glob@7.2.3: - resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} @@ -2967,9 +3342,8 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - has-flag@4.0.0: - resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} - engines: {node: '>=8'} + harfbuzzjs@0.10.0: + resolution: {integrity: sha512-SN8LVwCOzvTq3OPNd0+EAghgXugItz56wst567D1vs7LnnKGWprhi3EG58aVOBwhN8HEbQxL4I9NDWkcXUutRw==} has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} @@ -2983,23 +3357,20 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hex-rgb@4.3.0: + resolution: {integrity: sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw==} + engines: {node: '>=6'} + hoist-non-react-statics@3.3.2: resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} - html-encoding-sniffer@4.0.0: - resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} - engines: {node: '>=18'} - - html-escaper@2.0.2: - resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html-encoding-sniffer@7.0.0: + resolution: {integrity: sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==} + engines: {node: ^22.13.0 || >=24.0.0} html-parse-stringify@3.0.1: resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} - http-proxy-agent@7.0.2: - resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} - engines: {node: '>= 14'} - http_ece@1.2.0: resolution: {integrity: sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==} engines: {node: '>=16'} @@ -3008,10 +3379,6 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} - human-signals@2.1.0: - resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} - engines: {node: '>=10.17.0'} - husky@9.1.7: resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} engines: {node: '>=18'} @@ -3034,26 +3401,9 @@ packages: typescript: optional: true - iconv-lite@0.6.3: - resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} - engines: {node: '>=0.10.0'} - idb@8.0.3: resolution: {integrity: sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==} - import-local@3.2.0: - resolution: {integrity: sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==} - engines: {node: '>=8'} - hasBin: true - - imurmurhash@0.1.4: - resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} - engines: {node: '>=0.8.19'} - - inflight@1.0.6: - resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} - deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. - inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -3063,20 +3413,21 @@ packages: react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc - is-arrayish@0.2.1: - resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + ipaddr.js@2.5.0: + resolution: {integrity: sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==} + engines: {node: '>= 10'} - is-fullwidth-code-point@3.0.0: - resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} - engines: {node: '>=8'} + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} is-fullwidth-code-point@5.1.0: resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} engines: {node: '>=18'} - is-generator-fn@2.1.0: - resolution: {integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==} - engines: {node: '>=6'} + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} @@ -3085,315 +3436,188 @@ packages: is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} - is-stream@2.0.1: - resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} - engines: {node: '>=8'} - is-what@5.5.0: resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==} engines: {node: '>=18'} - isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - - istanbul-lib-coverage@3.2.2: - resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} - engines: {node: '>=8'} - - istanbul-lib-instrument@6.0.3: - resolution: {integrity: sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==} - engines: {node: '>=10'} - - istanbul-lib-report@3.0.1: - resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} - engines: {node: '>=10'} - - istanbul-lib-source-maps@5.0.6: - resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} - engines: {node: '>=10'} - - istanbul-reports@3.1.7: - resolution: {integrity: sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==} - engines: {node: '>=8'} - - jackspeak@3.4.3: - resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} - - jest-changed-files@30.3.0: - resolution: {integrity: sha512-B/7Cny6cV5At6M25EWDgf9S617lHivamL8vl6KEpJqkStauzcG4e+WPfDgMMF+H4FVH4A2PLRyvgDJan4441QA==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - jest-circus@30.3.0: - resolution: {integrity: sha512-PyXq5szeSfR/4f1lYqCmmQjh0vqDkURUYi9N6whnHjlRz4IUQfMcXkGLeEoiJtxtyPqgUaUUfyQlApXWBSN1RA==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - jest-cli@30.3.0: - resolution: {integrity: sha512-l6Tqx+j1fDXJEW5bqYykDQQ7mQg+9mhWXtnj+tQZrTWYHyHoi6Be8HPumDSA+UiX2/2buEgjA58iJzdj146uCw==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - hasBin: true - peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 - peerDependenciesMeta: - node-notifier: - optional: true - - jest-config@30.3.0: - resolution: {integrity: sha512-WPMAkMAtNDY9P/oKObtsRG/6KTrhtgPJoBTmk20uDn4Uy6/3EJnnaZJre/FMT1KVRx8cve1r7/FlMIOfRVWL4w==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - peerDependencies: - '@types/node': '*' - esbuild-register: '>=3.4.0' - ts-node: '>=9.0.0' - peerDependenciesMeta: - '@types/node': - optional: true - esbuild-register: - optional: true - ts-node: - optional: true - - jest-diff@29.7.0: - resolution: {integrity: sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-diff@30.3.0: - resolution: {integrity: sha512-n3q4PDQjS4LrKxfWB3Z5KNk1XjXtZTBwQp71OP0Jo03Z6V60x++K5L8k6ZrW8MY8pOFylZvHM0zsjS1RqlHJZQ==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - jest-docblock@30.2.0: - resolution: {integrity: sha512-tR/FFgZKS1CXluOQzZvNH3+0z9jXr3ldGSD8bhyuxvlVUwbeLOGynkunvlTMxchC5urrKndYiwCFC0DLVjpOCA==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - jest-each@30.3.0: - resolution: {integrity: sha512-V8eMndg/aZ+3LnCJgSm13IxS5XSBM22QSZc9BtPK8Dek6pm+hfUNfwBdvsB3d342bo1q7wnSkC38zjX259qZNA==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - jest-environment-jsdom@30.3.0: - resolution: {integrity: sha512-RLEOJy6ip1lpw0yqJ8tB3i88FC7VBz7i00Zvl2qF71IdxjS98gC9/0SPWYIBVXHm5hgCYK0PAlSlnHGGy9RoMg==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - peerDependencies: - canvas: ^3.0.0 - peerDependenciesMeta: - canvas: - optional: true - - jest-environment-node@30.3.0: - resolution: {integrity: sha512-4i6HItw/JSiJVsC5q0hnKIe/hbYfZLVG9YJ/0pU9Hz2n/9qZe3Rhn5s5CUZA5ORZlcdT/vmAXRMyONXJwPrmYQ==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - jest-get-type@29.6.3: - resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-haste-map@30.3.0: - resolution: {integrity: sha512-mMi2oqG4KRU0R9QEtscl87JzMXfUhbKaFqOxmjb2CKcbHcUGFrJCBWHmnTiUqi6JcnzoBlO4rWfpdl2k/RfLCA==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - jest-leak-detector@30.3.0: - resolution: {integrity: sha512-cuKmUUGIjfXZAiGJ7TbEMx0bcqNdPPI6P1V+7aF+m/FUJqFDxkFR4JqkTu8ZOiU5AaX/x0hZ20KaaIPXQzbMGQ==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - jest-matcher-utils@29.7.0: - resolution: {integrity: sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-matcher-utils@30.3.0: - resolution: {integrity: sha512-HEtc9uFQgaUHkC7nLSlQL3Tph4Pjxt/yiPvkIrrDCt9jhoLIgxaubo1G+CFOnmHYMxHwwdaSN7mkIFs6ZK8OhA==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - jest-message-util@29.7.0: - resolution: {integrity: sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-message-util@30.3.0: - resolution: {integrity: sha512-Z/j4Bo+4ySJ+JPJN3b2Qbl9hDq3VrXmnjjGEWD/x0BCXeOXPTV1iZYYzl2X8c1MaCOL+ewMyNBcm88sboE6YWw==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - jest-mock@30.3.0: - resolution: {integrity: sha512-OTzICK8CpE+t4ndhKrwlIdbM6Pn8j00lvmSmq5ejiO+KxukbLjgOflKWMn3KE34EZdQm5RqTuKj+5RIEniYhog==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - jest-pnp-resolver@1.2.3: - resolution: {integrity: sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==} - engines: {node: '>=6'} - peerDependencies: - jest-resolve: '*' - peerDependenciesMeta: - jest-resolve: - optional: true - - jest-regex-util@30.0.1: - resolution: {integrity: sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - jest-resolve-dependencies@30.3.0: - resolution: {integrity: sha512-9ev8s3YN6Hsyz9LV75XUwkCVFlwPbaFn6Wp75qnI0wzAINYWY8Fb3+6y59Rwd3QaS3kKXffHXsZMziMavfz/nw==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - jest-resolve@30.3.0: - resolution: {integrity: sha512-NRtTAHQlpd15F9rUR36jqwelbrDV/dY4vzNte3S2kxCKUJRYNd5/6nTSbYiak1VX5g8IoFF23Uj5TURkUW8O5g==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - jest-runner@30.3.0: - resolution: {integrity: sha512-gDv6C9LGKWDPLia9TSzZwf4h3kMQCqyTpq+95PODnTRDO0g9os48XIYYkS6D236vjpBir2fF63YmJFtqkS5Duw==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - jest-runtime@30.3.0: - resolution: {integrity: sha512-CgC+hIBJbuh78HEffkhNKcbXAytQViplcl8xupqeIWyKQF50kCQA8J7GeJCkjisC6hpnC9Muf8jV5RdtdFbGng==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - jest-snapshot@30.3.0: - resolution: {integrity: sha512-f14c7atpb4O2DeNhwcvS810Y63wEn8O1HqK/luJ4F6M4NjvxmAKQwBUWjbExUtMxWJQ0wVgmCKymeJK6NZMnfQ==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - jest-util@29.7.0: - resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-util@30.3.0: - resolution: {integrity: sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - jest-validate@30.3.0: - resolution: {integrity: sha512-I/xzC8h5G+SHCb2P2gWkJYrNiTbeL47KvKeW5EzplkyxzBRBw1ssSHlI/jXec0ukH2q7x2zAWQm7015iusg62Q==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - jest-watcher@30.3.0: - resolution: {integrity: sha512-PJ1d9ThtTR8aMiBWUdcownq9mDdLXsQzJayTk4kmaBRHKvwNQn+ANveuhEBUyNI2hR1TVhvQ8D5kHubbzBHR/w==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - jest-worker@30.3.0: - resolution: {integrity: sha512-DrCKkaQwHexjRUFTmPzs7sHQe0TSj9nvDALKGdwmK5mW9v7j90BudWirKAJHt3QQ9Dhrg1F7DogPzhChppkJpQ==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - jest@30.3.0: - resolution: {integrity: sha512-AkXIIFcaazymvey2i/+F94XRnM6TsVLZDhBMLsd1Sf/W0wzsvvpjeyUrCZD6HGG4SDYPgDJDBKeiJTBb10WzMg==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - hasBin: true - peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 - peerDependenciesMeta: - node-notifier: - optional: true - jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true - jose@4.15.9: - resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==} + jose@6.2.12: + resolution: {integrity: sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==} js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@3.14.2: - resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} - hasBin: true - - jsdom@26.1.0: - resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==} - engines: {node: '>=18'} + jsdom@30.1.2: + resolution: {integrity: sha512-0FFE/jE1rppmVfUrJUgxqXjcwolZYIGtAgj1pTussMhNZxIxi7W/PvfWaMNroGi2B36X1IKHbexpZ9DhkoOPiQ==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} peerDependencies: - canvas: ^3.0.0 + canvas: ^3.2.3 peerDependenciesMeta: canvas: optional: true - jsesc@3.1.0: - resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} - engines: {node: '>=6'} - hasBin: true - - json-parse-even-better-errors@2.3.1: - resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} - - json5@2.2.3: - resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} - engines: {node: '>=6'} - hasBin: true - jwa@2.0.1: resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} jws@4.0.1: resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} - leven@3.1.0: - resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} + kolorist@1.8.0: + resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} + + kysely@0.29.6: + resolution: {integrity: sha512-hHaB8C/rfzDDtr/t8YZwxAuPJTT0zHyaPoVzcXwDYhYNAgH/4sIfVhi/XLLIY+bL/FqaIJnjATDbi8ObSELmxg==} + engines: {node: '>=22.0.0'} + lightningcss-android-arm64@1.30.2: resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [android] + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + lightningcss-darwin-arm64@1.30.2: resolution: {integrity: sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [darwin] + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + lightningcss-darwin-x64@1.30.2: resolution: {integrity: sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [darwin] + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + lightningcss-freebsd-x64@1.30.2: resolution: {integrity: sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [freebsd] + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + lightningcss-linux-arm-gnueabihf@1.30.2: resolution: {integrity: sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==} engines: {node: '>= 12.0.0'} cpu: [arm] os: [linux] + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + lightningcss-linux-arm64-gnu@1.30.2: resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + lightningcss-linux-arm64-musl@1.30.2: resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + lightningcss-linux-x64-gnu@1.30.2: resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + lightningcss-linux-x64-musl@1.30.2: resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + lightningcss-win32-arm64-msvc@1.30.2: resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [win32] + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + lightningcss-win32-x64-msvc@1.30.2: resolution: {integrity: sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [win32] + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + lightningcss@1.30.2: resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==} engines: {node: '>= 12.0.0'} - lines-and-columns@1.2.4: - resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + linebreak@1.1.0: + resolution: {integrity: sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==} lint-staged@16.4.0: resolution: {integrity: sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw==} @@ -3404,13 +3628,6 @@ packages: resolution: {integrity: sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==} engines: {node: '>=20.0.0'} - locate-path@5.0.0: - resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} - engines: {node: '>=8'} - - lodash.sortby@4.7.0: - resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==} - log-update@6.1.0: resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} engines: {node: '>=18'} @@ -3419,15 +3636,9 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true - lru-cache@10.4.3: - resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - - lru-cache@5.1.1: - resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - - lru-cache@6.0.0: - resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} - engines: {node: '>=10'} + lru-cache@11.5.3: + resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==} + engines: {node: 20 || >=22} lucide-react@0.544.0: resolution: {integrity: sha512-t5tS44bqd825zAW45UQxpG2CvcC4urOwn2TrwSH8u+MjeE+1NnWl6QqeQ/6NdjMqdOygyiT9p3Ev0p1NJykxjw==} @@ -3441,22 +3652,19 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - make-dir@4.0.0: - resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} - engines: {node: '>=10'} - make-error@1.3.6: resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} - makeerror@1.0.12: - resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} - math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} - merge-stream@2.0.0: - resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} @@ -3470,14 +3678,14 @@ packages: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} - mimic-fn@2.1.0: - resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} - engines: {node: '>=6'} - mimic-function@5.0.1: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} + miniflare@5.20261006.1-alpha: + resolution: {integrity: sha512-6ylv8tamkrspDI2R/cAYfv+YWmfGNdXe76Pi9TauEUWsFVXn3LNF9Yk8oduzf1a+TYsSRYTqlOE7+nGGRsMHdw==} + engines: {node: '>=22.0.0'} + minimalistic-assert@1.0.1: resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} @@ -3488,8 +3696,8 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - minipass@7.1.2: - resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} ms@2.1.3: @@ -3500,32 +3708,19 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@3.3.20: + resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + nanoid@5.1.6: resolution: {integrity: sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==} engines: {node: ^18 || >=20} hasBin: true - napi-postinstall@0.3.3: - resolution: {integrity: sha512-uTp172LLXSxuSYHv/kou+f6KW3SMppU9ivthaVTXian9sOt3XM/zHYHpRZiLgQoxeWfYUnslNWQHF1+G71xcow==} - engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} - hasBin: true - - natural-compare@1.4.0: - resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - - next-auth@4.24.14: - resolution: {integrity: sha512-YRz6xFDXKUwiXSMMChbrBEWyFktZ1qZXEgeSHQQ3nsy08B4c/xLk6REeutRsIFwkjY/1+ShHnu07DN3JeJguig==} - peerDependencies: - '@auth/core': 0.34.3 - next: ^12.2.5 || ^13 || ^14 || ^15 || ^16 - nodemailer: ^7.0.7 - react: ^17.0.2 || ^18 || ^19 - react-dom: ^17.0.2 || ^18 || ^19 - peerDependenciesMeta: - '@auth/core': - optional: true - nodemailer: - optional: true + nanostores@1.5.5: + resolution: {integrity: sha512-FixtE239Gl6Gz0ZGCK0WzvNwObNCIwVT+L7zQcf1QgcA6AzBxblzqe9OT88qiucNkhsjSOqiogSaF5jVbJiqSA==} + engines: {node: ^20.0.0 || >=22.0.0} next-i18next@15.4.3: resolution: {integrity: sha512-ZRmiz72o1Jvh2ZghCUQX1Ua5F/f2W1/Ila/L1ZeKVuSWiH7J4zfUedfDxNBEhj9lajREC7aoJuPXMFtKi2bdIg==} @@ -3542,8 +3737,8 @@ packages: react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc - next@15.5.18: - resolution: {integrity: sha512-eKL8zUJkX9Y5lE+RX/2YJoItVdGlIscyVyboeD9wSpp0PaGqjoA4tTpT2qPqz9ax+5IzGESyLSeZ/RCwbSZ2uQ==} + next@15.5.27: + resolution: {integrity: sha512-F82CrlPZ8GRxBH9RIIoR3qU1t5RT3dv1K8moe8SjlDEquiFipvEI13R1Om90TaUOEwShkWCMlg8+gvTNzfMhvg==} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} hasBin: true peerDependencies: @@ -3563,8 +3758,9 @@ packages: sass: optional: true - node-fetch-native@1.6.7: - resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + node-addon-api@8.9.2: + resolution: {integrity: sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==} + engines: {node: ^18 || ^20 || >= 21} node-fetch@2.7.0: resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} @@ -3575,70 +3771,27 @@ packages: encoding: optional: true - node-int64@0.4.0: - resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} - node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} - nodemailer@9.0.1: - resolution: {integrity: sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw==} - engines: {node: '>=6.0.0'} - nordigen-node@1.4.1: resolution: {integrity: sha512-m7U9scf9GEkLEwy03l+cxcfcDGAiRMtD1sWrEH/mpYrZEC1hDZ2wRg+eMndzDOnADZaUoPvcimushUUA4+csvg==} - normalize-path@3.0.0: - resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} - engines: {node: '>=0.10.0'} - normalize-wheel@1.0.1: resolution: {integrity: sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==} - npm-run-path@4.0.1: - resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} - engines: {node: '>=8'} - - nwsapi@2.2.20: - resolution: {integrity: sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==} - - nypm@0.6.2: - resolution: {integrity: sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g==} - engines: {node: ^14.16.0 || >=16.10.0} - hasBin: true - - oauth@0.9.15: - resolution: {integrity: sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==} - object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} - object-hash@2.2.0: - resolution: {integrity: sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==} - engines: {node: '>= 6'} - - ohash@2.0.11: - resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} - - oidc-token-hash@5.1.0: - resolution: {integrity: sha512-y0W+X7Ppo7oZX6eovsRkuzcSM40Bicg2JEJkDJ4irIt1wsYAP5MLSNv+QAogO8xivMffw/9OvV3um1pxXgt1uA==} - engines: {node: ^10.13.0 || >=12.0.0} - - once@1.4.0: - resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - - onetime@5.1.2: - resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} - engines: {node: '>=6'} + obug@2.2.1: + resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} + engines: {node: '>=12.20.0'} onetime@7.0.0: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} - openid-client@5.7.1: - resolution: {integrity: sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==} - oxlint-tsgolint@0.18.1: resolution: {integrity: sha512-Hgb0wMfuXBYL0ddY+1hAG8IIfC40ADwPnBuUaC6ENAuCtTF4dHwsy7mCYtQ2e7LoGvfoSJRY0+kqQRiembJ/jQ==} hasBin: true @@ -3653,54 +3806,25 @@ packages: oxlint-tsgolint: optional: true - p-limit@2.3.0: - resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} - engines: {node: '>=6'} + pako@0.2.9: + resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==} - p-limit@3.1.0: - resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} - engines: {node: '>=10'} + parse-css-color@0.2.1: + resolution: {integrity: sha512-bwS/GGIFV3b6KS4uwpzCFj4w297Yl3uqnSgIPsoQkx7GMLROXfMnWvxfNkL0oh8HVhZA4hvJoEoEIqonfJ3BWg==} - p-locate@4.1.0: - resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} - engines: {node: '>=8'} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} - p-try@2.2.0: - resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} - engines: {node: '>=6'} - - package-json-from-dist@1.0.1: - resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} - - parse-json@5.2.0: - resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} - engines: {node: '>=8'} - - parse5@7.3.0: - resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} - - path-exists@4.0.0: - resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} - engines: {node: '>=8'} + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} - path-is-absolute@1.0.1: - resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} - engines: {node: '>=0.10.0'} - - path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} - engines: {node: '>=8'} - - path-scurry@1.11.1: - resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} - engines: {node: '>=16 || 14 >=14.18'} + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} - perfect-debounce@1.0.0: - resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} - picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -3708,21 +3832,27 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} - pirates@4.0.7: - resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} - engines: {node: '>= 6'} - - pkg-dir@4.2.0: - resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} - engines: {node: '>=8'} - - pkg-types@2.3.0: - resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} plaid@38.1.0: resolution: {integrity: sha512-5CxHxiTEJUEohvxOhfNclWIrIKJYN6pODBoF7X6Iwr9nYYUsk2XsYzOMUMBybAFRTcanU2C2F5STPmsNxgT0PQ==} engines: {node: '>=10.0.0'} + playwright-core@1.64.0: + resolution: {integrity: sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.64.0: + resolution: {integrity: sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==} + engines: {node: '>=20'} + hasBin: true + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + postcss@8.4.31: resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} engines: {node: ^10 || ^12 || >=14} @@ -3731,13 +3861,9 @@ packages: resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} engines: {node: ^10 || ^12 || >=14} - preact-render-to-string@5.2.6: - resolution: {integrity: sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==} - peerDependencies: - preact: ^10.26.10 - - preact@10.28.3: - resolution: {integrity: sha512-tCmoRkPQLpBeWzpmbhryairGnhW9tKV6c6gr/w+RhoRoKEJwsjzipwp//1oCpGPOchvSLaAPlpcJi9MwMmoPyA==} + postcss@8.5.29: + resolution: {integrity: sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==} + engines: {node: ^10 || ^12 || >=14} prettier-plugin-tailwindcss@0.6.14: resolution: {integrity: sha512-pi2e/+ZygeIqntN+vC573BcW5Cve8zUB0SSAGxqpB4f96boZF4M3phPVoOFCeypwkpRYdi7+jQ5YJJUwrkGUAg==} @@ -3809,27 +3935,6 @@ packages: resolution: {integrity: sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==} engines: {node: ^14.13.1 || >=16.0.0} - pretty-format@29.7.0: - resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - pretty-format@3.8.0: - resolution: {integrity: sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==} - - pretty-format@30.3.0: - resolution: {integrity: sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - prisma@6.19.3: - resolution: {integrity: sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==} - engines: {node: '>=18.18'} - hasBin: true - peerDependencies: - typescript: '>=5.1.0' - peerDependenciesMeta: - typescript: - optional: true - prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} @@ -3841,11 +3946,8 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - pure-rand@6.1.0: - resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} - - pure-rand@7.0.1: - resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==} + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} radix-ui@1.4.3: resolution: {integrity: sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==} @@ -3860,9 +3962,6 @@ packages: '@types/react-dom': optional: true - rc9@2.1.2: - resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} - react-day-picker@9.12.0: resolution: {integrity: sha512-t8OvG/Zrciso5CQJu5b1A7yzEmebvST+S3pOVQJWxwjjVngyG/CA2htN/D15dLI4uTEuLLkbZyS4YYt480FAtA==} engines: {node: '>=18'} @@ -3905,9 +4004,6 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} - react-is@18.3.1: - resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} - react-plaid-link@4.1.1: resolution: {integrity: sha512-xzAYWQIT/gk+u6lwFAMEZ20f9+AUsCwVyfm64/iudMsyuWANta4wm3Jb7N+APSwuKIR9VUlTkYDhPjLamIGcPA==} peerDependencies: @@ -3958,22 +4054,10 @@ packages: resolution: {integrity: sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==} engines: {node: '>=0.10.0'} - readdirp@4.1.2: - resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} - engines: {node: '>= 14.18.0'} - - require-directory@2.1.1: - resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} - resolve-cwd@3.0.0: - resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} - engines: {node: '>=8'} - - resolve-from@5.0.0: - resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} - engines: {node: '>=8'} - resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} @@ -3981,11 +4065,23 @@ packages: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} - rrweb-cssom@0.8.0: - resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + rolldown@1.2.13: + resolution: {integrity: sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + rou3@0.9.2: + resolution: {integrity: sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ==} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} @@ -3993,6 +4089,10 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + satori@0.33.5: + resolution: {integrity: sha512-PNW6ktrQ8SM6kk7u4aOuTkkUewkzyTOrj+QeEzXSsnNMAdff4ire9CAqIz8+4C3rCiBtK8QTELv52AZEd1PbRw==} + engines: {node: '>=16'} + saxes@6.0.0: resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} engines: {node: '>=v12.22.7'} @@ -4000,12 +4100,8 @@ packages: scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} - semver@6.3.1: - resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} - hasBin: true - - semver@7.7.3: - resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} hasBin: true @@ -4017,29 +4113,33 @@ packages: typescript: optional: true - sharp@0.34.5: - resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + serwist@9.5.13: + resolution: {integrity: sha512-ZpskCei2d5Zcljeb6QvYP94pV5xY3GCdbOk0hOzMSEPFOqJDwJvMpQq7i1r/psuR1WQCsKX6p7UpLle6sTPYjw==} + peerDependencies: + typescript: '>=5.0.0' + peerDependenciesMeta: + typescript: + optional: true - shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} - engines: {node: '>=8'} + set-cookie-parser@3.1.3: + resolution: {integrity: sha512-xletSdAI5efyVJjsatBbzDkXl3M/Zmad3xfnQe7ZXuBB0yNwW7C2WFsNGjdRNfZunmNHQuX79xs/ACMTLCdcew==} - shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} - engines: {node: '>=8'} + sharp@0.35.5: + resolution: {integrity: sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true - signal-exit@3.0.7: - resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} - slash@3.0.0: - resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} - engines: {node: '>=8'} - slice-ansi@7.1.2: resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} engines: {node: '>=18'} @@ -4054,41 +4154,31 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} - source-map-support@0.5.13: - resolution: {integrity: sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==} + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} + engines: {node: '>=0.10.0'} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} source-map@0.6.1: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} - source-map@0.8.0-beta.0: - resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==} - engines: {node: '>= 8'} - deprecated: The work that was done in this beta branch won't be included in future versions + source-map@0.8.0: + resolution: {integrity: sha512-d8EqvL+k/SOXCreS/SUzg2ciyHqBBLcN/yuRjFsbvVhHTE2pgei7oAhmPM7kWFbkX6OSMQfUq4KbkF3au9lhYQ==} + engines: {node: '>= 12'} - sprintf-js@1.0.3: - resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} - stack-utils@2.0.6: - resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} - engines: {node: '>=10'} + std-env@4.3.0: + resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==} string-argv@0.3.2: resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} engines: {node: '>=0.6.19'} - string-length@4.0.2: - resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} - engines: {node: '>=10'} - - string-width@4.2.3: - resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} - engines: {node: '>=8'} - - string-width@5.1.2: - resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} - engines: {node: '>=12'} - string-width@7.2.0: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} @@ -4097,26 +4187,13 @@ packages: resolution: {integrity: sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==} engines: {node: '>=20'} - strip-ansi@6.0.1: - resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} - engines: {node: '>=8'} + string.prototype.codepointat@0.2.1: + resolution: {integrity: sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg==} strip-ansi@7.1.2: resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} engines: {node: '>=12'} - strip-bom@4.0.0: - resolution: {integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==} - engines: {node: '>=8'} - - strip-final-newline@2.0.0: - resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} - engines: {node: '>=6'} - - strip-json-comments@3.1.1: - resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} - engines: {node: '>=8'} - styled-jsx@5.1.6: resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} engines: {node: '>= 12.0.0'} @@ -4134,20 +4211,13 @@ packages: resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==} engines: {node: '>=16'} - supports-color@7.2.0: - resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} - engines: {node: '>=8'} - - supports-color@8.1.1: - resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} - engines: {node: '>=10'} - - symbol-tree@3.2.4: - resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} - synckit@0.11.11: - resolution: {integrity: sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==} - engines: {node: ^14.18.0 || >=16.0.0} + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} tailwind-merge@2.6.0: resolution: {integrity: sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==} @@ -4159,41 +4229,45 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} - test-exclude@6.0.0: - resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} - engines: {node: '>=8'} + tiny-inflate@1.0.3: + resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} tinyexec@1.0.4: resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==} engines: {node: '>=18'} - tldts-core@6.1.86: - resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} - tldts@6.1.86: - resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} - hasBin: true + tinyrainbow@3.2.0: + resolution: {integrity: sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==} + engines: {node: '>=14.0.0'} + + tldts-core@7.4.18: + resolution: {integrity: sha512-KI2PKvCmvQI/xD6ypPqkU0i7oxNXR/O58RLRaHR4svsgVUQ2JE/XC8BtMbG6PSehD27I8sZJRcC1ASnCy6lp3g==} - tmpl@1.0.5: - resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} + tldts@7.4.18: + resolution: {integrity: sha512-somxA8xKAUzlAxl0TX/jMSYIbRNFIVGnI96pVGJPeMc5BsHmfVXn6mOJT6U++ELnl04eGfzpfcEuZe8WiASkXg==} + hasBin: true to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} - tough-cookie@5.1.2: - resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} engines: {node: '>=16'} tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} - tr46@1.0.1: - resolution: {integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==} - - tr46@5.1.1: - resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} - engines: {node: '>=18'} + tr46@7.0.0: + resolution: {integrity: sha512-BcDSDW+SIGWVOns1Zm6Yg91q9AEj7eaID0QDY9oIqJD1A8dscNwMcl9Sms2DeuWCNov1X0/oXanHGYQBSLwZsg==} + engines: {node: ^22.14.0 || >=24.0.0} ts-node@10.9.2: resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} @@ -4217,13 +4291,9 @@ packages: engines: {node: '>=18.0.0'} hasBin: true - type-detect@4.0.8: - resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} - engines: {node: '>=4'} - - type-fest@0.21.3: - resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} - engines: {node: '>=10'} + type-fest@5.10.0: + resolution: {integrity: sha512-NoSdpq/WEiAg5sjmBkmV/hfxv6HJH4NqPNrqjtSO5CwRmpsDfaf4begxW34KdJykH/l1yHtwBWQkCRdoXO8mPA==} + engines: {node: '>=20'} typescript@5.7.3: resolution: {integrity: sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==} @@ -4233,8 +4303,22 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - unrs-resolver@1.11.1: - resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} + undici@7.29.1: + resolution: {integrity: sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==} + engines: {node: '>=20.18.1'} + + undici@8.11.2: + resolution: {integrity: sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==} + engines: {node: '>=22.19.0'} + + unenv@2.0.0-rc.24: + resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} + + unicode-trie@2.0.0: + resolution: {integrity: sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==} + + unpic@4.2.2: + resolution: {integrity: sha512-z6T2ScMgRV2y2H8MwwhY5xHZWXhUx/YxtOCGJwfURSl7ypVy4HpLIMWoIZKnnxQa/RKzM0kg8hUh0paIrpLfvw==} update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} @@ -4267,98 +4351,188 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - uuid@8.3.2: - resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} - deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). - hasBin: true - uzip@0.20201231.0: resolution: {integrity: sha512-OZeJfZP+R0z9D6TmBgLq2LHzSSptGMGDGigGiEe0pr8UBe/7fdflgHlHBNDASTXB5jnFuxHpNaJywSg8YFeGng==} v8-compile-cache-lib@3.0.1: resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} - v8-to-istanbul@9.3.0: - resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} - engines: {node: '>=10.12.0'} - vaul@1.1.2: resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==} peerDependencies: react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + vinext@1.1.0: + resolution: {integrity: sha512-ZvmmkprGpni9N4ml4hmV0Z24PIJerTWWTbr8A15R36kvsJvDvgp0pL0UUY6432+9CJ9cftEAcd/7zecbQ0RiGg==} + engines: {node: '>=22'} + hasBin: true + peerDependencies: + '@mdx-js/rollup': ^3.0.0 + '@vitejs/plugin-react': ^5.1.4 || ^6.0.0 + '@vitejs/plugin-rsc': ^0.5.34 + react: ^19.2.6 + react-dom: ^19.2.6 + react-server-dom-webpack: ^19.2.6 + vite: ^8.0.0 + peerDependenciesMeta: + '@mdx-js/rollup': + optional: true + '@vitejs/plugin-rsc': + optional: true + react-server-dom-webpack: + optional: true + + vite-plugin-commonjs@0.10.4: + resolution: {integrity: sha512-eWQuvQKCcx0QYB5e5xfxBNjQKyrjEWZIR9UOkOV6JAgxVhtbZvCOF+FNC2ZijBJ3U3Px04ZMMyyMyFBVWIJ5+g==} + + vite-plugin-dynamic-import@1.6.0: + resolution: {integrity: sha512-TM0sz70wfzTIo9YCxVFwS8OA9lNREsh+0vMHGSkWDTZ7bgd1Yjs5RV8EgB634l/91IsXJReg0xtmuQqP0mf+rg==} + + vite@8.3.4: + resolution: {integrity: sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.7.1 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.11: + resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.11 + '@vitest/browser-preview': 4.1.11 + '@vitest/browser-webdriverio': 4.1.11 + '@vitest/coverage-istanbul': 4.1.11 + '@vitest/coverage-v8': 4.1.11 + '@vitest/ui': 4.1.11 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + void-elements@3.1.0: resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} engines: {node: '>=0.10.0'} - w3c-xmlserializer@5.0.0: - resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} - engines: {node: '>=18'} - - walker@1.0.8: - resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} + w3c-xmlserializer@6.0.0: + resolution: {integrity: sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} web-push@3.6.7: resolution: {integrity: sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==} engines: {node: '>= 16'} hasBin: true + web-vitals@4.2.4: + resolution: {integrity: sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==} + webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} - webidl-conversions@4.0.2: - resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==} - - webidl-conversions@7.0.0: - resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} - engines: {node: '>=12'} - - whatwg-encoding@3.1.1: - resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} - engines: {node: '>=18'} - deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} - whatwg-mimetype@4.0.0: - resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} - engines: {node: '>=18'} + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} - whatwg-url@14.2.0: - resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} - engines: {node: '>=18'} + whatwg-url@17.2.0: + resolution: {integrity: sha512-IhLIHNcBrzIixC5XXsbr4DxdjajyA5vQ5RwGZMtrUp+GbtE5Nrsge9QKau7jXrC0aTXLYGV0xiW7Tb4YS2aomw==} + engines: {node: ^22.14.0 || >=24.0.0} whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} - whatwg-url@7.1.0: - resolution: {integrity: sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==} - - which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} hasBin: true - wrap-ansi@7.0.0: - resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} - engines: {node: '>=10'} + workerd@1.20261006.1: + resolution: {integrity: sha512-8MadBuffZ5NdpLwZjUmsqT5QlaKXq4gjgT6wzcKeynZTICNXyePhc+5QVoKiK7+MMhFTLNrRO2KXLlpk8Q6BkA==} + engines: {node: '>=16'} + hasBin: true - wrap-ansi@8.1.0: - resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} - engines: {node: '>=12'} + wrangler@4.149.0: + resolution: {integrity: sha512-OzK7xmB5r5iLKb3cIT3783g13fe6T7xyeKu9LEdUyl+DKAM2KP/jkjnE0OUhyw+mB/XJM9ycjgF3MWm8PR9amg==} + engines: {node: '>=22.0.0'} + hasBin: true + peerDependencies: + '@cloudflare/workers-types': ^5.20261006.1 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true wrap-ansi@9.0.2: resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} engines: {node: '>=18'} - wrappy@1.0.2: - resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - - write-file-atomic@5.0.1: - resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - - ws@8.18.2: - resolution: {integrity: sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==} + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -4376,42 +4550,29 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} - y18n@5.0.8: - resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} - engines: {node: '>=10'} - - yallist@3.1.1: - resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} - - yallist@4.0.0: - resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} - yaml@2.8.3: resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} engines: {node: '>= 14.6'} hasBin: true - yargs-parser@21.1.1: - resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} - engines: {node: '>=12'} - - yargs@17.7.2: - resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} - engines: {node: '>=12'} - yn@3.1.1: resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} engines: {node: '>=6'} - yocto-queue@0.1.0: - resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} - engines: {node: '>=10'} + yoga-layout@3.2.1: + resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} + + youch-core@0.3.3: + resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} + + youch@4.1.0-beta.10: + resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} - zod@4.1.12: - resolution: {integrity: sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==} + zod@4.6.5: + resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==} zustand@4.5.7: resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} @@ -4432,334 +4593,494 @@ snapshots: '@alloc/quick-lru@5.2.0': {} - '@ampproject/remapping@2.3.0': + '@asamuzakjp/css-color@7.1.5': dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 + '@csstools/css-calc': 3.4.3(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2) + '@csstools/css-color-parser': 4.2.6(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2) + '@csstools/css-parser-algorithms': 4.0.2(@csstools/css-tokenizer@4.0.2) + '@csstools/css-tokenizer': 4.0.2 + lru-cache: 11.5.3 - '@asamuzakjp/css-color@3.2.0': + '@asamuzakjp/dom-selector@9.2.4': dependencies: - '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) - '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) - '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) - '@csstools/css-tokenizer': 3.0.4 - lru-cache: 10.4.3 + bidi-js: 1.1.0 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.3 - '@babel/code-frame@7.27.1': - dependencies: - '@babel/helper-validator-identifier': 7.27.1 - js-tokens: 4.0.0 - picocolors: 1.1.1 + '@babel/runtime@7.27.6': {} - '@babel/compat-data@7.27.2': {} + '@babel/runtime@7.28.4': {} - '@babel/core@7.28.3': - dependencies: - '@ampproject/remapping': 2.3.0 - '@babel/code-frame': 7.27.1 - '@babel/generator': 7.28.3 - '@babel/helper-compilation-targets': 7.27.2 - '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.3) - '@babel/helpers': 7.28.3 - '@babel/parser': 7.28.3 - '@babel/template': 7.27.2 - '@babel/traverse': 7.28.3 - '@babel/types': 7.28.2 - convert-source-map: 2.0.0 - debug: 4.4.1 - gensync: 1.0.0-beta.2 - json5: 2.2.3 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color + '@babel/runtime@7.29.2': {} - '@babel/generator@7.28.3': + '@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@3.25.76))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5)': dependencies: - '@babel/parser': 7.28.3 - '@babel/types': 7.28.2 - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - jsesc: 3.1.0 + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.2 + '@opentelemetry/semantic-conventions': 1.43.0 + '@standard-schema/spec': 1.1.0 + better-call: 1.4.0(zod@4.6.5) + jose: 6.2.12 + kysely: 0.29.6 + nanostores: 1.5.5 + zod: 4.6.5 - '@babel/helper-compilation-targets@7.27.2': + '@better-auth/drizzle-adapter@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@3.25.76))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.4(@cloudflare/workers-types@5.20261011.1)(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)(kysely@0.29.6))': dependencies: - '@babel/compat-data': 7.27.2 - '@babel/helper-validator-option': 7.27.1 - browserslist: 4.28.1 - lru-cache: 5.1.1 - semver: 6.3.1 - - '@babel/helper-globals@7.28.0': {} + '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@3.25.76))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) + '@better-auth/utils': 0.4.2 + optionalDependencies: + drizzle-orm: 0.45.4(@cloudflare/workers-types@5.20261011.1)(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)(kysely@0.29.6) - '@babel/helper-module-imports@7.27.1': + '@better-auth/kysely-adapter@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@3.25.76))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2)(kysely@0.29.6)': dependencies: - '@babel/traverse': 7.28.3 - '@babel/types': 7.28.2 - transitivePeerDependencies: - - supports-color + '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@3.25.76))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) + '@better-auth/utils': 0.4.2 + optionalDependencies: + kysely: 0.29.6 - '@babel/helper-module-transforms@7.28.3(@babel/core@7.28.3)': + '@better-auth/memory-adapter@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@3.25.76))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2)': dependencies: - '@babel/core': 7.28.3 - '@babel/helper-module-imports': 7.27.1 - '@babel/helper-validator-identifier': 7.27.1 - '@babel/traverse': 7.28.3 - transitivePeerDependencies: - - supports-color - - '@babel/helper-plugin-utils@7.27.1': {} - - '@babel/helper-string-parser@7.27.1': {} - - '@babel/helper-validator-identifier@7.27.1': {} + '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@3.25.76))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) + '@better-auth/utils': 0.4.2 - '@babel/helper-validator-option@7.27.1': {} - - '@babel/helpers@7.28.3': + '@better-auth/mongo-adapter@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@3.25.76))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2)': dependencies: - '@babel/template': 7.27.2 - '@babel/types': 7.28.2 + '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@3.25.76))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) + '@better-auth/utils': 0.4.2 - '@babel/parser@7.28.3': + '@better-auth/prisma-adapter@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@3.25.76))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2)': dependencies: - '@babel/types': 7.28.2 + '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@3.25.76))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) + '@better-auth/utils': 0.4.2 - '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.28.3)': + '@better-auth/telemetry@1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@3.25.76))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)': dependencies: - '@babel/core': 7.28.3 - '@babel/helper-plugin-utils': 7.27.1 + '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@3.25.76))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.2 - '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.28.3)': + '@better-auth/utils@0.4.2': dependencies: - '@babel/core': 7.28.3 - '@babel/helper-plugin-utils': 7.27.1 + '@noble/hashes': 2.4.0 - '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.28.3)': + '@better-auth/utils@0.5.0': dependencies: - '@babel/core': 7.28.3 - '@babel/helper-plugin-utils': 7.27.1 + '@noble/hashes': 2.4.0 - '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.28.3)': - dependencies: - '@babel/core': 7.28.3 - '@babel/helper-plugin-utils': 7.27.1 + '@better-fetch/fetch@1.3.2': {} - '@babel/plugin-syntax-import-attributes@7.27.1(@babel/core@7.28.3)': + '@bramus/specificity@2.4.3': dependencies: - '@babel/core': 7.28.3 - '@babel/helper-plugin-utils': 7.27.1 + css-tree: 3.2.1 - '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.28.3)': - dependencies: - '@babel/core': 7.28.3 - '@babel/helper-plugin-utils': 7.27.1 + '@cloudflare/kv-asset-handler@0.5.0': {} - '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.28.3)': + '@cloudflare/unenv-preset@2.16.2(unenv@2.0.0-rc.24)(workerd@1.20261006.1)': dependencies: - '@babel/core': 7.28.3 - '@babel/helper-plugin-utils': 7.27.1 + unenv: 2.0.0-rc.24 + optionalDependencies: + workerd: 1.20261006.1 - '@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.28.3)': + '@cloudflare/vite-plugin@1.63.1(@types/node@22.19.3)(vite@8.3.4(@types/node@22.19.3)(esbuild@0.28.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))(wrangler@4.149.0(@cloudflare/workers-types@5.20261011.1)(@types/node@22.19.3))': dependencies: - '@babel/core': 7.28.3 - '@babel/helper-plugin-utils': 7.27.1 + '@cloudflare/unenv-preset': 2.16.2(unenv@2.0.0-rc.24)(workerd@1.20261006.1) + miniflare: 5.20261006.1-alpha(@types/node@22.19.3) + unenv: 2.0.0-rc.24 + vite: 8.3.4(@types/node@22.19.3)(esbuild@0.28.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3) + workerd: 1.20261006.1 + wrangler: 4.149.0(@cloudflare/workers-types@5.20261011.1)(@types/node@22.19.3) + ws: 8.21.0 + transitivePeerDependencies: + - '@types/node' + - bufferutil + - utf-8-validate - '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.28.3)': + '@cloudflare/vitest-plugin@1.4.0(@cloudflare/workers-types@5.20261011.1)(@types/node@22.19.3)(vitest@4.1.11(@types/node@22.19.3)(jsdom@30.1.2(@noble/hashes@2.4.0))(vite@8.3.4(@types/node@22.19.3)(esbuild@0.28.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)))': dependencies: - '@babel/core': 7.28.3 - '@babel/helper-plugin-utils': 7.27.1 + cjs-module-lexer: 1.2.3 + esbuild: 0.28.2 + miniflare: 5.20261006.1-alpha(@types/node@22.19.3) + vitest: 4.1.11(@types/node@22.19.3)(jsdom@30.1.2(@noble/hashes@2.4.0))(vite@8.3.4(@types/node@22.19.3)(esbuild@0.28.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + wrangler: 4.149.0(@cloudflare/workers-types@5.20261011.1)(@types/node@22.19.3) + zod: 4.6.5 + transitivePeerDependencies: + - '@cloudflare/workers-types' + - '@types/node' + - bufferutil + - utf-8-validate - '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.28.3)': - dependencies: - '@babel/core': 7.28.3 - '@babel/helper-plugin-utils': 7.27.1 + '@cloudflare/workerd-darwin-64@1.20261006.1': + optional: true - '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.28.3)': - dependencies: - '@babel/core': 7.28.3 - '@babel/helper-plugin-utils': 7.27.1 + '@cloudflare/workerd-darwin-arm64@1.20261006.1': + optional: true - '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.28.3)': - dependencies: - '@babel/core': 7.28.3 - '@babel/helper-plugin-utils': 7.27.1 + '@cloudflare/workerd-linux-64@1.20261006.1': + optional: true - '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.28.3)': - dependencies: - '@babel/core': 7.28.3 - '@babel/helper-plugin-utils': 7.27.1 + '@cloudflare/workerd-linux-arm64@1.20261006.1': + optional: true - '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.28.3)': - dependencies: - '@babel/core': 7.28.3 - '@babel/helper-plugin-utils': 7.27.1 + '@cloudflare/workerd-windows-64@1.20261006.1': + optional: true - '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.28.3)': - dependencies: - '@babel/core': 7.28.3 - '@babel/helper-plugin-utils': 7.27.1 + '@cloudflare/workers-response-store@1.1.0': {} - '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.28.3)': - dependencies: - '@babel/core': 7.28.3 - '@babel/helper-plugin-utils': 7.27.1 + '@cloudflare/workers-types@5.20261011.1': {} - '@babel/plugin-syntax-typescript@7.27.1(@babel/core@7.28.3)': + '@cspotcode/source-map-support@0.8.1': dependencies: - '@babel/core': 7.28.3 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/runtime@7.27.6': {} - - '@babel/runtime@7.28.4': {} + '@jridgewell/trace-mapping': 0.3.9 - '@babel/runtime@7.29.2': {} + '@csstools/color-helpers@6.1.2': {} - '@babel/template@7.27.2': + '@csstools/css-calc@3.4.3(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2)': dependencies: - '@babel/code-frame': 7.27.1 - '@babel/parser': 7.28.3 - '@babel/types': 7.28.2 + '@csstools/css-parser-algorithms': 4.0.2(@csstools/css-tokenizer@4.0.2) + '@csstools/css-tokenizer': 4.0.2 - '@babel/traverse@7.28.3': + '@csstools/css-color-parser@4.2.6(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2)': dependencies: - '@babel/code-frame': 7.27.1 - '@babel/generator': 7.28.3 - '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.28.3 - '@babel/template': 7.27.2 - '@babel/types': 7.28.2 - debug: 4.4.1 - transitivePeerDependencies: - - supports-color + '@csstools/color-helpers': 6.1.2 + '@csstools/css-calc': 3.4.3(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2) + '@csstools/css-parser-algorithms': 4.0.2(@csstools/css-tokenizer@4.0.2) + '@csstools/css-tokenizer': 4.0.2 - '@babel/types@7.28.2': + '@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2)': dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.27.1 + '@csstools/css-tokenizer': 4.0.2 - '@bcoe/v8-coverage@0.2.3': {} + '@csstools/css-syntax-patches-for-csstree@1.1.15(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 - '@cspotcode/source-map-support@0.8.1': - dependencies: - '@jridgewell/trace-mapping': 0.3.9 + '@csstools/css-tokenizer@4.0.2': {} - '@csstools/color-helpers@5.1.0': {} + '@date-fns/tz@1.4.1': {} - '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': - dependencies: - '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) - '@csstools/css-tokenizer': 3.0.4 + '@drizzle-team/brocli@0.10.2': {} - '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + '@emnapi/runtime@1.11.3': dependencies: - '@csstools/color-helpers': 5.1.0 - '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) - '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) - '@csstools/css-tokenizer': 3.0.4 + tslib: 2.8.1 + optional: true - '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + '@esbuild-kit/core-utils@3.3.2': dependencies: - '@csstools/css-tokenizer': 3.0.4 + esbuild: 0.18.20 + source-map-support: 0.5.21 - '@csstools/css-tokenizer@3.0.4': {} + '@esbuild-kit/esm-loader@2.6.5': + dependencies: + '@esbuild-kit/core-utils': 3.3.2 + get-tsconfig: 4.10.1 - '@date-fns/tz@1.4.1': {} + '@esbuild/aix-ppc64@0.25.12': + optional: true - '@emnapi/core@1.4.5': - dependencies: - '@emnapi/wasi-threads': 1.0.4 - tslib: 2.8.1 + '@esbuild/aix-ppc64@0.27.2': optional: true - '@emnapi/runtime@1.7.1': - dependencies: - tslib: 2.8.1 + '@esbuild/aix-ppc64@0.28.2': optional: true - '@emnapi/wasi-threads@1.0.4': - dependencies: - tslib: 2.8.1 + '@esbuild/android-arm64@0.18.20': optional: true - '@esbuild/aix-ppc64@0.27.2': + '@esbuild/android-arm64@0.25.12': optional: true '@esbuild/android-arm64@0.27.2': optional: true + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.18.20': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + '@esbuild/android-arm@0.27.2': optional: true + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.18.20': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + '@esbuild/android-x64@0.27.2': optional: true + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.18.20': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + '@esbuild/darwin-arm64@0.27.2': optional: true + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.18.20': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + '@esbuild/darwin-x64@0.27.2': optional: true + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.18.20': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + '@esbuild/freebsd-arm64@0.27.2': optional: true + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.18.20': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + '@esbuild/freebsd-x64@0.27.2': optional: true + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.18.20': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + '@esbuild/linux-arm64@0.27.2': optional: true + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.18.20': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + '@esbuild/linux-arm@0.27.2': optional: true + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.18.20': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + '@esbuild/linux-ia32@0.27.2': optional: true + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.18.20': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + '@esbuild/linux-loong64@0.27.2': optional: true + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.18.20': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + '@esbuild/linux-mips64el@0.27.2': optional: true + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.18.20': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + '@esbuild/linux-ppc64@0.27.2': optional: true + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.18.20': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + '@esbuild/linux-riscv64@0.27.2': optional: true + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.18.20': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + '@esbuild/linux-s390x@0.27.2': optional: true - '@esbuild/linux-x64@0.27.2': + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.18.20': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/linux-x64@0.27.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.27.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.18.20': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.27.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.27.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.18.20': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.27.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.27.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.18.20': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.27.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.18.20': + optional: true + + '@esbuild/win32-arm64@0.25.12': optional: true - '@esbuild/netbsd-arm64@0.27.2': + '@esbuild/win32-arm64@0.27.2': optional: true - '@esbuild/netbsd-x64@0.27.2': + '@esbuild/win32-arm64@0.28.2': optional: true - '@esbuild/openbsd-arm64@0.27.2': + '@esbuild/win32-ia32@0.18.20': optional: true - '@esbuild/openbsd-x64@0.27.2': + '@esbuild/win32-ia32@0.25.12': optional: true - '@esbuild/openharmony-arm64@0.27.2': + '@esbuild/win32-ia32@0.27.2': optional: true - '@esbuild/sunos-x64@0.27.2': + '@esbuild/win32-ia32@0.28.2': optional: true - '@esbuild/win32-arm64@0.27.2': + '@esbuild/win32-x64@0.18.20': optional: true - '@esbuild/win32-ia32@0.27.2': + '@esbuild/win32-x64@0.25.12': optional: true '@esbuild/win32-x64@0.27.2': optional: true + '@esbuild/win32-x64@0.28.2': + optional: true + + '@exodus/bytes@1.16.0(@noble/hashes@2.4.0)': + optionalDependencies: + '@noble/hashes': 2.4.0 + '@faker-js/faker@10.1.0': {} '@floating-ui/core@1.7.3': @@ -4791,325 +5112,111 @@ snapshots: dependencies: react: 19.2.1 - '@img/colour@1.0.0': {} + '@img/colour@1.1.0': {} - '@img/sharp-darwin-arm64@0.34.5': + '@img/sharp-darwin-arm64@0.35.5': optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-arm64': 1.3.4 optional: true - '@img/sharp-darwin-x64@0.34.5': + '@img/sharp-darwin-x64@0.35.5': optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.3.4 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.5': + dependencies: + '@img/sharp-wasm32': 0.35.5 optional: true - '@img/sharp-libvips-darwin-arm64@1.2.4': + '@img/sharp-libvips-darwin-arm64@1.3.4': optional: true - '@img/sharp-libvips-darwin-x64@1.2.4': + '@img/sharp-libvips-darwin-x64@1.3.4': optional: true - '@img/sharp-libvips-linux-arm64@1.2.4': + '@img/sharp-libvips-linux-arm64@1.3.4': optional: true - '@img/sharp-libvips-linux-arm@1.2.4': + '@img/sharp-libvips-linux-arm@1.3.4': optional: true - '@img/sharp-libvips-linux-ppc64@1.2.4': + '@img/sharp-libvips-linux-ppc64@1.3.4': optional: true - '@img/sharp-libvips-linux-riscv64@1.2.4': + '@img/sharp-libvips-linux-riscv64@1.3.4': optional: true - '@img/sharp-libvips-linux-s390x@1.2.4': + '@img/sharp-libvips-linux-s390x@1.3.4': optional: true - '@img/sharp-libvips-linux-x64@1.2.4': + '@img/sharp-libvips-linux-x64@1.3.4': optional: true - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + '@img/sharp-libvips-linuxmusl-arm64@1.3.4': optional: true - '@img/sharp-libvips-linuxmusl-x64@1.2.4': + '@img/sharp-libvips-linuxmusl-x64@1.3.4': optional: true - '@img/sharp-linux-arm64@0.34.5': + '@img/sharp-linux-arm64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.3.4 optional: true - '@img/sharp-linux-arm@0.34.5': + '@img/sharp-linux-arm@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.3.4 optional: true - '@img/sharp-linux-ppc64@0.34.5': + '@img/sharp-linux-ppc64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.3.4 optional: true - '@img/sharp-linux-riscv64@0.34.5': + '@img/sharp-linux-riscv64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.3.4 optional: true - '@img/sharp-linux-s390x@0.34.5': + '@img/sharp-linux-s390x@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.3.4 optional: true - '@img/sharp-linux-x64@0.34.5': + '@img/sharp-linux-x64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.3.4 optional: true - '@img/sharp-linuxmusl-arm64@0.34.5': + '@img/sharp-linuxmusl-arm64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 optional: true - '@img/sharp-linuxmusl-x64@0.34.5': + '@img/sharp-linuxmusl-x64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.3.4 optional: true - '@img/sharp-wasm32@0.34.5': + '@img/sharp-wasm32@0.35.5': dependencies: - '@emnapi/runtime': 1.7.1 + '@emnapi/runtime': 1.11.3 optional: true - '@img/sharp-win32-arm64@0.34.5': + '@img/sharp-webcontainers-wasm32@0.35.5': + dependencies: + '@img/sharp-wasm32': 0.35.5 optional: true - '@img/sharp-win32-ia32@0.34.5': + '@img/sharp-win32-arm64@0.35.5': optional: true - '@img/sharp-win32-x64@0.34.5': + '@img/sharp-win32-ia32@0.35.5': optional: true - '@isaacs/cliui@8.0.2': - dependencies: - string-width: 5.1.2 - string-width-cjs: string-width@4.2.3 - strip-ansi: 7.1.2 - strip-ansi-cjs: strip-ansi@6.0.1 - wrap-ansi: 8.1.0 - wrap-ansi-cjs: wrap-ansi@7.0.0 - - '@istanbuljs/load-nyc-config@1.1.0': - dependencies: - camelcase: 5.3.1 - find-up: 4.1.0 - get-package-type: 0.1.0 - js-yaml: 3.14.2 - resolve-from: 5.0.0 - - '@istanbuljs/schema@0.1.3': {} - - '@jest/console@30.3.0': - dependencies: - '@jest/types': 30.3.0 - '@types/node': 22.19.3 - chalk: 4.1.2 - jest-message-util: 30.3.0 - jest-util: 30.3.0 - slash: 3.0.0 - - '@jest/core@30.3.0(ts-node@10.9.2(@types/node@22.19.3)(typescript@5.7.3))': - dependencies: - '@jest/console': 30.3.0 - '@jest/pattern': 30.0.1 - '@jest/reporters': 30.3.0 - '@jest/test-result': 30.3.0 - '@jest/transform': 30.3.0 - '@jest/types': 30.3.0 - '@types/node': 22.19.3 - ansi-escapes: 4.3.2 - chalk: 4.1.2 - ci-info: 4.3.0 - exit-x: 0.2.2 - graceful-fs: 4.2.11 - jest-changed-files: 30.3.0 - jest-config: 30.3.0(@types/node@22.19.3)(ts-node@10.9.2(@types/node@22.19.3)(typescript@5.7.3)) - jest-haste-map: 30.3.0 - jest-message-util: 30.3.0 - jest-regex-util: 30.0.1 - jest-resolve: 30.3.0 - jest-resolve-dependencies: 30.3.0 - jest-runner: 30.3.0 - jest-runtime: 30.3.0 - jest-snapshot: 30.3.0 - jest-util: 30.3.0 - jest-validate: 30.3.0 - jest-watcher: 30.3.0 - pretty-format: 30.3.0 - slash: 3.0.0 - transitivePeerDependencies: - - babel-plugin-macros - - esbuild-register - - supports-color - - ts-node - - '@jest/diff-sequences@30.3.0': {} - - '@jest/environment-jsdom-abstract@30.3.0(jsdom@26.1.0)': - dependencies: - '@jest/environment': 30.3.0 - '@jest/fake-timers': 30.3.0 - '@jest/types': 30.3.0 - '@types/jsdom': 21.1.7 - '@types/node': 22.19.3 - jest-mock: 30.3.0 - jest-util: 30.3.0 - jsdom: 26.1.0 - - '@jest/environment@30.3.0': - dependencies: - '@jest/fake-timers': 30.3.0 - '@jest/types': 30.3.0 - '@types/node': 22.19.3 - jest-mock: 30.3.0 - - '@jest/expect-utils@29.7.0': - dependencies: - jest-get-type: 29.6.3 - - '@jest/expect-utils@30.3.0': - dependencies: - '@jest/get-type': 30.1.0 - - '@jest/expect@30.3.0': - dependencies: - expect: 30.3.0 - jest-snapshot: 30.3.0 - transitivePeerDependencies: - - supports-color - - '@jest/fake-timers@30.3.0': - dependencies: - '@jest/types': 30.3.0 - '@sinonjs/fake-timers': 15.2.2 - '@types/node': 22.19.3 - jest-message-util: 30.3.0 - jest-mock: 30.3.0 - jest-util: 30.3.0 - - '@jest/get-type@30.1.0': {} - - '@jest/globals@30.3.0': - dependencies: - '@jest/environment': 30.3.0 - '@jest/expect': 30.3.0 - '@jest/types': 30.3.0 - jest-mock: 30.3.0 - transitivePeerDependencies: - - supports-color - - '@jest/pattern@30.0.1': - dependencies: - '@types/node': 22.19.3 - jest-regex-util: 30.0.1 - - '@jest/reporters@30.3.0': - dependencies: - '@bcoe/v8-coverage': 0.2.3 - '@jest/console': 30.3.0 - '@jest/test-result': 30.3.0 - '@jest/transform': 30.3.0 - '@jest/types': 30.3.0 - '@jridgewell/trace-mapping': 0.3.31 - '@types/node': 22.19.3 - chalk: 4.1.2 - collect-v8-coverage: 1.0.2 - exit-x: 0.2.2 - glob: 10.5.0 - graceful-fs: 4.2.11 - istanbul-lib-coverage: 3.2.2 - istanbul-lib-instrument: 6.0.3 - istanbul-lib-report: 3.0.1 - istanbul-lib-source-maps: 5.0.6 - istanbul-reports: 3.1.7 - jest-message-util: 30.3.0 - jest-util: 30.3.0 - jest-worker: 30.3.0 - slash: 3.0.0 - string-length: 4.0.2 - v8-to-istanbul: 9.3.0 - transitivePeerDependencies: - - supports-color - - '@jest/schemas@29.6.3': - dependencies: - '@sinclair/typebox': 0.27.8 - - '@jest/schemas@30.0.5': - dependencies: - '@sinclair/typebox': 0.34.40 - - '@jest/snapshot-utils@30.3.0': - dependencies: - '@jest/types': 30.3.0 - chalk: 4.1.2 - graceful-fs: 4.2.11 - natural-compare: 1.4.0 - - '@jest/source-map@30.0.1': - dependencies: - '@jridgewell/trace-mapping': 0.3.31 - callsites: 3.1.0 - graceful-fs: 4.2.11 - - '@jest/test-result@30.3.0': - dependencies: - '@jest/console': 30.3.0 - '@jest/types': 30.3.0 - '@types/istanbul-lib-coverage': 2.0.6 - collect-v8-coverage: 1.0.2 - - '@jest/test-sequencer@30.3.0': - dependencies: - '@jest/test-result': 30.3.0 - graceful-fs: 4.2.11 - jest-haste-map: 30.3.0 - slash: 3.0.0 - - '@jest/transform@30.3.0': - dependencies: - '@babel/core': 7.28.3 - '@jest/types': 30.3.0 - '@jridgewell/trace-mapping': 0.3.31 - babel-plugin-istanbul: 7.0.1 - chalk: 4.1.2 - convert-source-map: 2.0.0 - fast-json-stable-stringify: 2.1.0 - graceful-fs: 4.2.11 - jest-haste-map: 30.3.0 - jest-regex-util: 30.0.1 - jest-util: 30.3.0 - pirates: 4.0.7 - slash: 3.0.0 - write-file-atomic: 5.0.1 - transitivePeerDependencies: - - supports-color - - '@jest/types@29.6.3': - dependencies: - '@jest/schemas': 29.6.3 - '@types/istanbul-lib-coverage': 2.0.6 - '@types/istanbul-reports': 3.0.4 - '@types/node': 22.19.3 - '@types/yargs': 17.0.33 - chalk: 4.1.2 - - '@jest/types@30.3.0': - dependencies: - '@jest/pattern': 30.0.1 - '@jest/schemas': 30.0.5 - '@types/istanbul-lib-coverage': 2.0.6 - '@types/istanbul-reports': 3.0.4 - '@types/node': 22.19.3 - '@types/yargs': 17.0.33 - chalk: 4.1.2 + '@img/sharp-win32-x64@0.35.5': + optional: true '@jridgewell/gen-mapping@0.3.13': dependencies: @@ -5137,45 +5244,51 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.0 - '@napi-rs/wasm-runtime@0.2.12': - dependencies: - '@emnapi/core': 1.4.5 - '@emnapi/runtime': 1.7.1 - '@tybys/wasm-util': 0.10.0 - optional: true - - '@next-auth/prisma-adapter@1.0.7(@prisma/client@6.19.1(prisma@6.19.3(typescript@5.7.3))(typescript@5.7.3))(next-auth@4.24.14(next@15.5.18(@babel/core@7.28.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(nodemailer@9.0.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))': - dependencies: - '@prisma/client': 6.19.1(prisma@6.19.3(typescript@5.7.3))(typescript@5.7.3) - next-auth: 4.24.14(next@15.5.18(@babel/core@7.28.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(nodemailer@9.0.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - - '@next/env@15.5.18': {} + '@next/env@15.5.27': {} - '@next/swc-darwin-arm64@15.5.18': + '@next/swc-darwin-arm64@15.5.27': optional: true - '@next/swc-darwin-x64@15.5.18': + '@next/swc-darwin-x64@15.5.27': optional: true - '@next/swc-linux-arm64-gnu@15.5.18': + '@next/swc-linux-arm64-gnu@15.5.27': optional: true - '@next/swc-linux-arm64-musl@15.5.18': + '@next/swc-linux-arm64-musl@15.5.27': optional: true - '@next/swc-linux-x64-gnu@15.5.18': + '@next/swc-linux-x64-gnu@15.5.27': optional: true - '@next/swc-linux-x64-musl@15.5.18': + '@next/swc-linux-x64-musl@15.5.27': optional: true - '@next/swc-win32-arm64-msvc@15.5.18': + '@next/swc-win32-arm64-msvc@15.5.27': optional: true - '@next/swc-win32-x64-msvc@15.5.18': + '@next/swc-win32-x64-msvc@15.5.27': optional: true - '@noble/hashes@1.8.0': {} + '@noble/ciphers@2.4.0': {} + + '@noble/hashes@2.4.0': {} + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.3 + + '@opentelemetry/semantic-conventions@1.43.0': {} + + '@oxc-project/types@0.153.0': {} '@oxlint-tsgolint/darwin-arm64@0.18.1': optional: true @@ -5252,51 +5365,21 @@ snapshots: '@oxlint/binding-win32-x64-msvc@1.57.0': optional: true - '@panva/hkdf@1.2.1': {} - - '@paralleldrive/cuid2@2.3.1': - dependencies: - '@noble/hashes': 1.8.0 - - '@pkgjs/parseargs@0.11.0': - optional: true - - '@pkgr/core@0.2.9': {} - - '@prisma/client@6.19.1(prisma@6.19.3(typescript@5.7.3))(typescript@5.7.3)': - optionalDependencies: - prisma: 6.19.3(typescript@5.7.3) - typescript: 5.7.3 - - '@prisma/config@6.19.3': + '@playwright/test@1.64.0': dependencies: - c12: 3.1.0 - deepmerge-ts: 7.1.5 - effect: 3.21.0 - empathic: 2.0.0 - transitivePeerDependencies: - - magicast - - '@prisma/debug@6.19.3': {} - - '@prisma/engines-version@7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7': {} + playwright: 1.64.0 - '@prisma/engines@6.19.3': + '@poppinss/colors@4.1.6': dependencies: - '@prisma/debug': 6.19.3 - '@prisma/engines-version': 7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7 - '@prisma/fetch-engine': 6.19.3 - '@prisma/get-platform': 6.19.3 + kleur: 4.1.5 - '@prisma/fetch-engine@6.19.3': + '@poppinss/dumper@0.6.5': dependencies: - '@prisma/debug': 6.19.3 - '@prisma/engines-version': 7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7 - '@prisma/get-platform': 6.19.3 + '@poppinss/colors': 4.1.6 + '@sindresorhus/is': 7.2.0 + supports-color: 10.2.2 - '@prisma/get-platform@6.19.3': - dependencies: - '@prisma/debug': 6.19.3 + '@poppinss/exception@1.2.3': {} '@radix-ui/number@1.1.1': {} @@ -6098,57 +6181,95 @@ snapshots: '@radix-ui/rect@1.1.1': {} - '@serwist/build@9.2.3(typescript@5.7.3)': + '@resvg/resvg-wasm@2.4.1': {} + + '@rolldown/binding-android-arm-eabi@1.2.13': + optional: true + + '@rolldown/binding-android-arm64@1.2.13': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.13': + optional: true + + '@rolldown/binding-darwin-x64@1.2.13': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.13': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.13': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.13': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.13': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.13': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.13': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.13': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.13': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.13': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.13': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.13': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@serwist/build@9.5.13(browserslist@4.28.1)(typescript@5.7.3)': dependencies: + '@serwist/utils': 9.5.13(browserslist@4.28.1) common-tags: 1.8.2 - glob: 10.5.0 + glob: 13.0.6 pretty-bytes: 6.1.1 - source-map: 0.8.0-beta.0 - zod: 4.1.12 - optionalDependencies: - typescript: 5.7.3 - - '@serwist/next@9.2.3(next@15.5.18(@babel/core@7.28.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(typescript@5.7.3)': - dependencies: - '@serwist/build': 9.2.3(typescript@5.7.3) - '@serwist/webpack-plugin': 9.2.3(typescript@5.7.3) - '@serwist/window': 9.2.3(typescript@5.7.3) - chalk: 5.6.2 - glob: 10.5.0 - next: 15.5.18(@babel/core@7.28.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - serwist: 9.2.3(typescript@5.7.3) - zod: 4.1.12 + source-map: 0.8.0 + type-fest: 5.10.0 + zod: 4.6.5 optionalDependencies: typescript: 5.7.3 transitivePeerDependencies: - - webpack + - browserslist - '@serwist/webpack-plugin@9.2.3(typescript@5.7.3)': - dependencies: - '@serwist/build': 9.2.3(typescript@5.7.3) - pretty-bytes: 6.1.1 - zod: 4.1.12 + '@serwist/utils@9.5.13(browserslist@4.28.1)': optionalDependencies: - typescript: 5.7.3 + browserslist: 4.28.1 - '@serwist/window@9.2.3(typescript@5.7.3)': + '@serwist/vite@9.5.13(browserslist@4.28.1)(typescript@5.7.3)(vite@8.3.4(@types/node@22.19.3)(esbuild@0.28.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))': dependencies: - '@types/trusted-types': 2.0.7 - serwist: 9.2.3(typescript@5.7.3) + '@serwist/build': 9.5.13(browserslist@4.28.1)(typescript@5.7.3) + '@serwist/utils': 9.5.13(browserslist@4.28.1) + glob: 13.0.6 + kolorist: 1.8.0 + serwist: 9.5.13(browserslist@4.28.1)(typescript@5.7.3) + vite: 8.3.4(@types/node@22.19.3)(esbuild@0.28.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3) + zod: 4.6.5 optionalDependencies: typescript: 5.7.3 + transitivePeerDependencies: + - browserslist - '@sinclair/typebox@0.27.8': {} - - '@sinclair/typebox@0.34.40': {} - - '@sinonjs/commons@3.0.1': + '@shuding/opentype.js@1.4.0-beta.0': dependencies: - type-detect: 4.0.8 + fflate: 0.7.3 + string.prototype.codepointat: 0.2.1 - '@sinonjs/fake-timers@15.2.2': - dependencies: - '@sinonjs/commons': 3.0.1 + '@sindresorhus/is@7.2.0': {} + + '@speed-highlight/core@1.2.24': {} '@standard-schema/spec@1.1.0': {} @@ -6249,11 +6370,11 @@ snapshots: '@trpc/server': 11.8.0(typescript@5.7.3) typescript: 5.7.3 - '@trpc/next@11.8.0(@tanstack/react-query@5.90.12(react@19.2.1))(@trpc/client@11.8.0(@trpc/server@11.8.0(typescript@5.7.3))(typescript@5.7.3))(@trpc/react-query@11.8.0(@tanstack/react-query@5.90.12(react@19.2.1))(@trpc/client@11.8.0(@trpc/server@11.8.0(typescript@5.7.3))(typescript@5.7.3))(@trpc/server@11.8.0(typescript@5.7.3))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.7.3))(@trpc/server@11.8.0(typescript@5.7.3))(next@15.5.18(@babel/core@7.28.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.7.3)': + '@trpc/next@11.2.0(@tanstack/react-query@5.90.12(react@19.2.1))(@trpc/client@11.8.0(@trpc/server@11.8.0(typescript@5.7.3))(typescript@5.7.3))(@trpc/react-query@11.8.0(@tanstack/react-query@5.90.12(react@19.2.1))(@trpc/client@11.8.0(@trpc/server@11.8.0(typescript@5.7.3))(typescript@5.7.3))(@trpc/server@11.8.0(typescript@5.7.3))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.7.3))(@trpc/server@11.8.0(typescript@5.7.3))(next@15.5.27(@playwright/test@1.64.0)(@types/node@22.19.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.7.3)': dependencies: '@trpc/client': 11.8.0(@trpc/server@11.8.0(typescript@5.7.3))(typescript@5.7.3) '@trpc/server': 11.8.0(typescript@5.7.3) - next: 15.5.18(@babel/core@7.28.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + next: 15.5.27(@playwright/test@1.64.0)(@types/node@22.19.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) react: 19.2.1 react-dom: 19.2.1(react@19.2.1) typescript: 5.7.3 @@ -6282,70 +6403,29 @@ snapshots: '@tsconfig/node16@1.0.4': {} - '@tybys/wasm-util@0.10.0': + '@types/better-sqlite3@9.6.0': dependencies: - tslib: 2.8.1 + '@types/node': 22.19.3 optional: true - '@types/babel__core@7.20.5': + '@types/chai@5.2.3': dependencies: - '@babel/parser': 7.28.3 - '@babel/types': 7.28.2 - '@types/babel__generator': 7.27.0 - '@types/babel__template': 7.4.4 - '@types/babel__traverse': 7.20.7 + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 - '@types/babel__generator@7.27.0': - dependencies: - '@babel/types': 7.28.2 - - '@types/babel__template@7.4.4': - dependencies: - '@babel/parser': 7.28.3 - '@babel/types': 7.28.2 - - '@types/babel__traverse@7.20.7': - dependencies: - '@babel/types': 7.28.2 + '@types/deep-eql@4.0.2': {} - '@types/formidable@3.4.6': - dependencies: - '@types/node': 22.19.3 + '@types/estree@1.0.9': {} '@types/hoist-non-react-statics@3.3.6': dependencies: '@types/react': 19.2.1 hoist-non-react-statics: 3.3.2 - '@types/istanbul-lib-coverage@2.0.6': {} - - '@types/istanbul-lib-report@3.0.3': - dependencies: - '@types/istanbul-lib-coverage': 2.0.6 - - '@types/istanbul-reports@3.0.4': - dependencies: - '@types/istanbul-lib-report': 3.0.3 - - '@types/jest@29.5.14': - dependencies: - expect: 29.7.0 - pretty-format: 29.7.0 - - '@types/jsdom@21.1.7': - dependencies: - '@types/node': 22.19.3 - '@types/tough-cookie': 4.0.5 - parse5: 7.3.0 - '@types/node@22.19.3': dependencies: undici-types: 6.21.0 - '@types/nodemailer@8.0.0': - dependencies: - '@types/node': 22.19.3 - '@types/react-dom@19.2.1(@types/react@19.2.1)': dependencies: '@types/react': 19.2.1 @@ -6354,22 +6434,10 @@ snapshots: dependencies: csstype: 3.1.3 - '@types/stack-utils@2.0.3': {} - - '@types/tough-cookie@4.0.5': {} - - '@types/trusted-types@2.0.7': {} - '@types/web-push@3.6.4': dependencies: '@types/node': 22.19.3 - '@types/yargs-parser@21.0.3': {} - - '@types/yargs@17.0.33': - dependencies: - '@types/yargs-parser': 21.0.3 - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20251016.1': optional: true @@ -6401,66 +6469,81 @@ snapshots: '@typescript/native-preview-win32-arm64': 7.0.0-dev.20251016.1 '@typescript/native-preview-win32-x64': 7.0.0-dev.20251016.1 - '@ungap/structured-clone@1.3.0': {} - - '@unrs/resolver-binding-android-arm-eabi@1.11.1': - optional: true - - '@unrs/resolver-binding-android-arm64@1.11.1': - optional: true - - '@unrs/resolver-binding-darwin-arm64@1.11.1': - optional: true - - '@unrs/resolver-binding-darwin-x64@1.11.1': - optional: true - - '@unrs/resolver-binding-freebsd-x64@1.11.1': - optional: true - - '@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1': - optional: true - - '@unrs/resolver-binding-linux-arm-musleabihf@1.11.1': - optional: true + '@unpic/core@1.0.3': + dependencies: + unpic: 4.2.2 - '@unrs/resolver-binding-linux-arm64-gnu@1.11.1': - optional: true + '@unpic/react@1.0.2(next@15.5.27(@playwright/test@1.64.0)(@types/node@22.19.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + dependencies: + '@unpic/core': 1.0.3 + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + optionalDependencies: + next: 15.5.27(@playwright/test@1.64.0)(@types/node@22.19.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - '@unrs/resolver-binding-linux-arm64-musl@1.11.1': - optional: true + '@vercel/og@1.0.3(@types/node@22.19.3)': + dependencies: + '@resvg/resvg-wasm': 2.4.1 + satori: 0.33.5 + optionalDependencies: + sharp: 0.35.5(@types/node@22.19.3) + transitivePeerDependencies: + - '@types/node' - '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': - optional: true + '@vinext/cloudflare@1.1.0(@cloudflare/vite-plugin@1.63.1(@types/node@22.19.3)(vite@8.3.4(@types/node@22.19.3)(esbuild@0.28.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))(wrangler@4.149.0(@cloudflare/workers-types@5.20261011.1)(@types/node@22.19.3)))(vinext@1.1.0(@types/node@22.19.3)(@vitejs/plugin-react@6.1.2(vite@8.3.4(@types/node@22.19.3)(esbuild@0.28.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)))(next@15.5.27(@playwright/test@1.64.0)(@types/node@22.19.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(vite@8.3.4(@types/node@22.19.3)(esbuild@0.28.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)))': + dependencies: + '@cloudflare/workers-response-store': 1.1.0 + vinext: 1.1.0(@types/node@22.19.3)(@vitejs/plugin-react@6.1.2(vite@8.3.4(@types/node@22.19.3)(esbuild@0.28.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)))(next@15.5.27(@playwright/test@1.64.0)(@types/node@22.19.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(vite@8.3.4(@types/node@22.19.3)(esbuild@0.28.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + optionalDependencies: + '@cloudflare/vite-plugin': 1.63.1(@types/node@22.19.3)(vite@8.3.4(@types/node@22.19.3)(esbuild@0.28.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))(wrangler@4.149.0(@cloudflare/workers-types@5.20261011.1)(@types/node@22.19.3)) - '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': - optional: true + '@vinext/types@1.0.1': {} - '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': - optional: true + '@vitejs/plugin-react@6.1.2(vite@8.3.4(@types/node@22.19.3)(esbuild@0.28.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.3.4(@types/node@22.19.3)(esbuild@0.28.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3) - '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': - optional: true + '@vitest/expect@4.1.11': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + chai: 6.3.0 + tinyrainbow: 3.2.0 - '@unrs/resolver-binding-linux-x64-gnu@1.11.1': - optional: true + '@vitest/mocker@4.1.11(vite@8.3.4(@types/node@22.19.3)(esbuild@0.28.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))': + dependencies: + '@vitest/spy': 4.1.11 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.3.4(@types/node@22.19.3)(esbuild@0.28.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3) - '@unrs/resolver-binding-linux-x64-musl@1.11.1': - optional: true + '@vitest/pretty-format@4.1.11': + dependencies: + tinyrainbow: 3.2.0 - '@unrs/resolver-binding-wasm32-wasi@1.11.1': + '@vitest/runner@4.1.11': dependencies: - '@napi-rs/wasm-runtime': 0.2.12 - optional: true + '@vitest/utils': 4.1.11 + pathe: 2.0.3 - '@unrs/resolver-binding-win32-arm64-msvc@1.11.1': - optional: true + '@vitest/snapshot@4.1.11': + dependencies: + '@vitest/pretty-format': 4.1.11 + '@vitest/utils': 4.1.11 + magic-string: 0.30.21 + pathe: 2.0.3 - '@unrs/resolver-binding-win32-ia32-msvc@1.11.1': - optional: true + '@vitest/spy@4.1.11': {} - '@unrs/resolver-binding-win32-x64-msvc@1.11.1': - optional: true + '@vitest/utils@4.1.11': + dependencies: + '@vitest/pretty-format': 4.1.11 + convert-source-map: 2.0.0 + tinyrainbow: 3.2.0 acorn-walk@8.3.4: dependencies: @@ -6470,43 +6553,20 @@ snapshots: agent-base@7.1.3: {} - ansi-escapes@4.3.2: - dependencies: - type-fest: 0.21.3 - ansi-escapes@7.2.0: dependencies: environment: 1.1.0 - ansi-regex@5.0.1: {} - ansi-regex@6.2.2: {} - ansi-styles@4.3.0: - dependencies: - color-convert: 2.0.1 - - ansi-styles@5.2.0: {} - ansi-styles@6.2.3: {} - anymatch@3.1.3: - dependencies: - normalize-path: 3.0.0 - picomatch: 4.0.4 - arg@4.1.3: {} - argparse@1.0.10: - dependencies: - sprintf-js: 1.0.3 - aria-hidden@1.2.6: dependencies: tslib: 2.8.1 - asap@2.0.6: {} - asn1.js@5.4.1: dependencies: bn.js: 5.2.3 @@ -6514,6 +6574,8 @@ snapshots: minimalistic-assert: 1.0.1 safer-buffer: 2.1.2 + assertion-error@2.0.1: {} + asynckit@0.4.0: {} axios@1.16.0: @@ -6524,61 +6586,61 @@ snapshots: transitivePeerDependencies: - debug - babel-jest@30.3.0(@babel/core@7.28.3): - dependencies: - '@babel/core': 7.28.3 - '@jest/transform': 30.3.0 - '@types/babel__core': 7.20.5 - babel-plugin-istanbul: 7.0.1 - babel-preset-jest: 30.3.0(@babel/core@7.28.3) - chalk: 4.1.2 - graceful-fs: 4.2.11 - slash: 3.0.0 - transitivePeerDependencies: - - supports-color + balanced-match@1.0.2: {} - babel-plugin-istanbul@7.0.1: - dependencies: - '@babel/helper-plugin-utils': 7.27.1 - '@istanbuljs/load-nyc-config': 1.1.0 - '@istanbuljs/schema': 0.1.3 - istanbul-lib-instrument: 6.0.3 - test-exclude: 6.0.0 + base64-js@0.0.8: {} + + baseline-browser-mapping@2.9.9: + optional: true + + better-auth@1.7.7(drizzle-kit@0.31.11)(drizzle-orm@0.45.4(@cloudflare/workers-types@5.20261011.1)(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)(kysely@0.29.6))(next@15.5.27(@playwright/test@1.64.0)(@types/node@22.19.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(vitest@4.1.11(@types/node@22.19.3)(jsdom@30.1.2(@noble/hashes@2.4.0))(vite@8.3.4(@types/node@22.19.3)(esbuild@0.28.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))): + dependencies: + '@better-auth/core': 1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@3.25.76))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) + '@better-auth/drizzle-adapter': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@3.25.76))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.4(@cloudflare/workers-types@5.20261011.1)(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)(kysely@0.29.6)) + '@better-auth/kysely-adapter': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@3.25.76))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2)(kysely@0.29.6) + '@better-auth/memory-adapter': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@3.25.76))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2) + '@better-auth/mongo-adapter': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@3.25.76))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2) + '@better-auth/prisma-adapter': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@3.25.76))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2) + '@better-auth/telemetry': 1.7.7(@better-auth/core@1.7.7(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@3.25.76))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2) + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.2 + '@noble/ciphers': 2.4.0 + '@noble/hashes': 2.4.0 + better-call: 1.4.0(zod@4.6.5) + defu: 6.1.7 + jose: 6.2.12 + kysely: 0.29.6 + nanostores: 1.5.5 + zod: 4.6.5 + optionalDependencies: + drizzle-kit: 0.31.11 + drizzle-orm: 0.45.4(@cloudflare/workers-types@5.20261011.1)(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)(kysely@0.29.6) + next: 15.5.27(@playwright/test@1.64.0)(@types/node@22.19.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + vitest: 4.1.11(@types/node@22.19.3)(jsdom@30.1.2(@noble/hashes@2.4.0))(vite@8.3.4(@types/node@22.19.3)(esbuild@0.28.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) transitivePeerDependencies: - - supports-color + - '@opentelemetry/api' - babel-plugin-jest-hoist@30.3.0: + better-call@1.4.0(zod@4.6.5): dependencies: - '@types/babel__core': 7.20.5 + '@better-auth/utils': 0.5.0 + '@better-fetch/fetch': 1.3.2 + rou3: 0.9.2 + set-cookie-parser: 3.1.3 + optionalDependencies: + zod: 4.6.5 - babel-preset-current-node-syntax@1.2.0(@babel/core@7.28.3): + better-sqlite3@13.0.3: dependencies: - '@babel/core': 7.28.3 - '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.28.3) - '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.28.3) - '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.28.3) - '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.28.3) - '@babel/plugin-syntax-import-attributes': 7.27.1(@babel/core@7.28.3) - '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.28.3) - '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.28.3) - '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.28.3) - '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.28.3) - '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.28.3) - '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.28.3) - '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.28.3) - '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.28.3) - '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.28.3) - '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.28.3) + node-addon-api: 8.9.2 + optional: true - babel-preset-jest@30.3.0(@babel/core@7.28.3): + bidi-js@1.1.0: dependencies: - '@babel/core': 7.28.3 - babel-plugin-jest-hoist: 30.3.0 - babel-preset-current-node-syntax: 1.2.0(@babel/core@7.28.3) - - balanced-match@1.0.2: {} + require-from-string: 2.0.2 - baseline-browser-mapping@2.9.9: {} + blake3-wasm@2.1.5: {} bn.js@5.2.3: {} @@ -6603,65 +6665,24 @@ snapshots: electron-to-chromium: 1.5.267 node-releases: 2.0.27 update-browserslist-db: 1.2.3(browserslist@4.28.1) - - bser@2.1.1: - dependencies: - node-int64: 0.4.0 + optional: true buffer-equal-constant-time@1.0.1: {} buffer-from@1.1.2: {} - c12@3.1.0: - dependencies: - chokidar: 4.0.3 - confbox: 0.2.2 - defu: 6.1.4 - dotenv: 16.6.1 - exsolve: 1.0.8 - giget: 2.0.0 - jiti: 2.6.1 - ohash: 2.0.11 - pathe: 2.0.3 - perfect-debounce: 1.0.0 - pkg-types: 2.3.0 - rc9: 2.1.2 - call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 function-bind: 1.1.2 - callsites@3.1.0: {} - - camelcase@5.3.1: {} - - camelcase@6.3.0: {} + camelize@1.0.1: {} caniuse-lite@1.0.30001760: {} - chalk@4.1.2: - dependencies: - ansi-styles: 4.3.0 - supports-color: 7.2.0 - - chalk@5.6.2: {} - - char-regex@1.0.2: {} - - chokidar@4.0.3: - dependencies: - readdirp: 4.1.2 - - ci-info@3.9.0: {} - - ci-info@4.3.0: {} - - citty@0.1.6: - dependencies: - consola: 3.4.2 + chai@6.3.0: {} - cjs-module-lexer@2.1.0: {} + cjs-module-lexer@1.2.3: {} class-variance-authority@0.7.1: dependencies: @@ -6678,12 +6699,6 @@ snapshots: client-only@0.0.1: {} - cliui@8.0.1: - dependencies: - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrap-ansi: 7.0.0 - clsx@2.1.1: {} cmdk@1.1.1(@types/react-dom@19.2.1(@types/react@19.2.1))(@types/react@19.2.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1): @@ -6698,14 +6713,6 @@ snapshots: - '@types/react' - '@types/react-dom' - co@4.6.0: {} - - collect-v8-coverage@1.0.2: {} - - color-convert@2.0.1: - dependencies: - color-name: 1.1.4 - color-name@1.1.4: {} colorette@2.0.20: {} @@ -6718,13 +6725,9 @@ snapshots: common-tags@1.8.2: {} - confbox@0.2.2: {} - - consola@3.4.2: {} - convert-source-map@2.0.0: {} - cookie@0.7.2: {} + cookie@1.1.1: {} copy-anything@4.0.5: dependencies: @@ -6746,23 +6749,33 @@ snapshots: transitivePeerDependencies: - encoding - cross-spawn@7.0.6: + css-background-parser@0.1.0: {} + + css-box-shadow@1.0.0-3: {} + + css-color-keywords@1.0.0: {} + + css-gradient-parser@0.0.17: {} + + css-to-react-native@3.2.0: dependencies: - path-key: 3.1.1 - shebang-command: 2.0.0 - which: 2.0.2 + camelize: 1.0.1 + css-color-keywords: 1.0.0 + postcss-value-parser: 4.2.0 - cssstyle@4.6.0: + css-tree@3.2.1: dependencies: - '@asamuzakjp/css-color': 3.2.0 - rrweb-cssom: 0.8.0 + mdn-data: 2.27.1 + source-map-js: 1.2.2 csstype@3.1.3: {} - data-urls@5.0.0: + data-urls@8.0.0(@noble/hashes@2.4.0): dependencies: - whatwg-mimetype: 4.0.0 - whatwg-url: 14.2.0 + whatwg-mimetype: 5.0.0 + whatwg-url: 17.2.0(@noble/hashes@2.4.0) + transitivePeerDependencies: + - '@noble/hashes' date-fns-jalali@4.1.0-0: {} @@ -6774,38 +6787,33 @@ snapshots: dependencies: ms: 2.1.3 - decimal.js@10.5.0: {} - - dedent@1.6.0: {} + decimal.js@10.6.0: {} - deepmerge-ts@7.1.5: {} - - deepmerge@4.3.1: {} - - defu@6.1.4: {} + defu@6.1.7: {} delayed-stream@1.0.0: {} - destr@2.0.5: {} - detect-libc@2.1.2: {} - detect-newline@3.1.0: {} - detect-node-es@1.1.0: {} - dezalgo@1.0.4: - dependencies: - asap: 2.0.6 - wrappy: 1.0.2 - - diff-sequences@29.6.3: {} - diff@4.0.4: {} dotenv@10.0.0: {} - dotenv@16.6.1: {} + drizzle-kit@0.31.11: + dependencies: + '@drizzle-team/brocli': 0.10.2 + '@esbuild-kit/esm-loader': 2.6.5 + esbuild: 0.25.12 + tsx: 4.21.0 + + drizzle-orm@0.45.4(@cloudflare/workers-types@5.20261011.1)(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)(kysely@0.29.6): + optionalDependencies: + '@cloudflare/workers-types': 5.20261011.1 + '@types/better-sqlite3': 9.6.0 + better-sqlite3: 13.0.3 + kysely: 0.29.6 dunder-proto@1.0.1: dependencies: @@ -6813,46 +6821,36 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 - eastasianwidth@0.2.0: {} - ecdsa-sig-formatter@1.0.11: dependencies: safe-buffer: 5.2.1 - effect@3.21.0: - dependencies: - '@standard-schema/spec': 1.1.0 - fast-check: 3.23.2 - - electron-to-chromium@1.5.267: {} + electron-to-chromium@1.5.267: + optional: true - emittery@0.13.1: {} + emoji-regex-xs@2.0.1: {} emoji-regex@10.6.0: {} - emoji-regex@8.0.0: {} - - emoji-regex@9.2.2: {} - - empathic@2.0.0: {} - enhanced-resolve@5.18.4: dependencies: graceful-fs: 4.2.11 tapable: 2.3.0 - entities@6.0.0: {} + entities@8.1.0: {} environment@1.1.0: {} - error-ex@1.3.2: - dependencies: - is-arrayish: 0.2.1 + error-stack-parser-es@1.0.5: {} es-define-property@1.0.1: {} es-errors@1.3.0: {} + es-module-lexer@1.7.0: {} + + es-module-lexer@2.3.2: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -6864,6 +6862,60 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.2 + esbuild@0.18.20: + optionalDependencies: + '@esbuild/android-arm': 0.18.20 + '@esbuild/android-arm64': 0.18.20 + '@esbuild/android-x64': 0.18.20 + '@esbuild/darwin-arm64': 0.18.20 + '@esbuild/darwin-x64': 0.18.20 + '@esbuild/freebsd-arm64': 0.18.20 + '@esbuild/freebsd-x64': 0.18.20 + '@esbuild/linux-arm': 0.18.20 + '@esbuild/linux-arm64': 0.18.20 + '@esbuild/linux-ia32': 0.18.20 + '@esbuild/linux-loong64': 0.18.20 + '@esbuild/linux-mips64el': 0.18.20 + '@esbuild/linux-ppc64': 0.18.20 + '@esbuild/linux-riscv64': 0.18.20 + '@esbuild/linux-s390x': 0.18.20 + '@esbuild/linux-x64': 0.18.20 + '@esbuild/netbsd-x64': 0.18.20 + '@esbuild/openbsd-x64': 0.18.20 + '@esbuild/sunos-x64': 0.18.20 + '@esbuild/win32-arm64': 0.18.20 + '@esbuild/win32-ia32': 0.18.20 + '@esbuild/win32-x64': 0.18.20 + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + esbuild@0.27.2: optionalDependencies: '@esbuild/aix-ppc64': 0.27.2 @@ -6893,73 +6945,72 @@ snapshots: '@esbuild/win32-ia32': 0.27.2 '@esbuild/win32-x64': 0.27.2 - escalade@3.2.0: {} - - escape-string-regexp@2.0.0: {} + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 - esprima@4.0.1: {} + escalade@3.2.0: + optional: true - eventemitter3@5.0.1: {} + escape-html@1.0.3: {} - execa@5.1.1: + estree-walker@3.0.3: dependencies: - cross-spawn: 7.0.6 - get-stream: 6.0.1 - human-signals: 2.1.0 - is-stream: 2.0.1 - merge-stream: 2.0.0 - npm-run-path: 4.0.1 - onetime: 5.1.2 - signal-exit: 3.0.7 - strip-final-newline: 2.0.0 + '@types/estree': 1.0.9 - exit-x@0.2.2: {} + eventemitter3@5.0.1: {} - expect@29.7.0: - dependencies: - '@jest/expect-utils': 29.7.0 - jest-get-type: 29.6.3 - jest-matcher-utils: 29.7.0 - jest-message-util: 29.7.0 - jest-util: 29.7.0 + expect-type@1.4.0: {} - expect@30.3.0: + fast-glob@3.3.3: dependencies: - '@jest/expect-utils': 30.3.0 - '@jest/get-type': 30.1.0 - jest-matcher-utils: 30.3.0 - jest-message-util: 30.3.0 - jest-mock: 30.3.0 - jest-util: 30.3.0 - - exsolve@1.0.8: {} + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 - fast-check@3.23.2: + fastq@1.20.3: dependencies: - pure-rand: 6.1.0 + reusify: 1.1.0 - fast-json-stable-stringify@2.1.0: {} + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 - fb-watchman@2.0.2: - dependencies: - bser: 2.1.1 + fflate@0.7.3: {} fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 - find-up@4.1.0: - dependencies: - locate-path: 5.0.0 - path-exists: 4.0.0 - follow-redirects@1.16.0: {} - foreground-child@3.3.1: - dependencies: - cross-spawn: 7.0.6 - signal-exit: 4.1.0 - form-data@4.0.5: dependencies: asynckit: 0.4.0 @@ -6968,23 +7019,11 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 - formidable@3.5.4: - dependencies: - '@paralleldrive/cuid2': 2.3.1 - dezalgo: 1.0.4 - once: 1.4.0 - - fs.realpath@1.0.0: {} - fsevents@2.3.3: optional: true function-bind@1.1.2: {} - gensync@1.0.0-beta.2: {} - - get-caller-file@2.0.5: {} - get-east-asian-width@1.4.0: {} get-intrinsic@1.3.0: @@ -7002,51 +7041,30 @@ snapshots: get-nonce@1.0.1: {} - get-package-type@0.1.0: {} - get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 es-object-atoms: 1.1.1 - get-stream@6.0.1: {} - get-tsconfig@4.10.1: dependencies: resolve-pkg-maps: 1.0.0 - giget@2.0.0: + glob-parent@5.1.2: dependencies: - citty: 0.1.6 - consola: 3.4.2 - defu: 6.1.4 - node-fetch-native: 1.6.7 - nypm: 0.6.2 - pathe: 2.0.3 - - glob@10.5.0: - dependencies: - foreground-child: 3.3.1 - jackspeak: 3.4.3 - minimatch: 9.0.9 - minipass: 7.1.2 - package-json-from-dist: 1.0.1 - path-scurry: 1.11.1 + is-glob: 4.0.3 - glob@7.2.3: + glob@13.0.6: dependencies: - fs.realpath: 1.0.0 - inflight: 1.0.6 - inherits: 2.0.4 minimatch: 9.0.9 - once: 1.4.0 - path-is-absolute: 1.0.1 + minipass: 7.1.3 + path-scurry: 2.0.2 gopd@1.2.0: {} graceful-fs@4.2.11: {} - has-flag@4.0.0: {} + harfbuzzjs@0.10.0: {} has-symbols@1.1.0: {} @@ -7058,27 +7076,22 @@ snapshots: dependencies: function-bind: 1.1.2 + hex-rgb@4.3.0: {} + hoist-non-react-statics@3.3.2: dependencies: react-is: 16.13.1 - html-encoding-sniffer@4.0.0: + html-encoding-sniffer@7.0.0(@noble/hashes@2.4.0): dependencies: - whatwg-encoding: 3.1.1 - - html-escaper@2.0.2: {} + '@exodus/bytes': 1.16.0(@noble/hashes@2.4.0) + transitivePeerDependencies: + - '@noble/hashes' html-parse-stringify@3.0.1: dependencies: void-elements: 3.1.0 - http-proxy-agent@7.0.2: - dependencies: - agent-base: 7.1.3 - debug: 4.4.1 - transitivePeerDependencies: - - supports-color - http_ece@1.2.0: {} https-proxy-agent@7.0.6: @@ -7088,8 +7101,6 @@ snapshots: transitivePeerDependencies: - supports-color - human-signals@2.1.0: {} - husky@9.1.7: {} i18next-browser-languagedetector@8.2.1: @@ -7110,24 +7121,8 @@ snapshots: optionalDependencies: typescript: 5.7.3 - iconv-lite@0.6.3: - dependencies: - safer-buffer: 2.1.2 - idb@8.0.3: {} - import-local@3.2.0: - dependencies: - pkg-dir: 4.2.0 - resolve-cwd: 3.0.0 - - imurmurhash@0.1.4: {} - - inflight@1.0.6: - dependencies: - once: 1.4.0 - wrappy: 1.0.2 - inherits@2.0.4: {} input-otp@1.4.2(react-dom@19.2.1(react@19.2.1))(react@19.2.1): @@ -7135,464 +7130,54 @@ snapshots: react: 19.2.1 react-dom: 19.2.1(react@19.2.1) - is-arrayish@0.2.1: {} + ipaddr.js@2.5.0: {} - is-fullwidth-code-point@3.0.0: {} + is-extglob@2.1.1: {} is-fullwidth-code-point@5.1.0: dependencies: get-east-asian-width: 1.4.0 - is-generator-fn@2.1.0: {} - - is-number@7.0.0: {} - - is-potential-custom-element-name@1.0.1: {} - - is-stream@2.0.1: {} - - is-what@5.5.0: {} - - isexe@2.0.0: {} - - istanbul-lib-coverage@3.2.2: {} - - istanbul-lib-instrument@6.0.3: - dependencies: - '@babel/core': 7.28.3 - '@babel/parser': 7.28.3 - '@istanbuljs/schema': 0.1.3 - istanbul-lib-coverage: 3.2.2 - semver: 7.7.3 - transitivePeerDependencies: - - supports-color - - istanbul-lib-report@3.0.1: - dependencies: - istanbul-lib-coverage: 3.2.2 - make-dir: 4.0.0 - supports-color: 7.2.0 - - istanbul-lib-source-maps@5.0.6: - dependencies: - '@jridgewell/trace-mapping': 0.3.31 - debug: 4.4.1 - istanbul-lib-coverage: 3.2.2 - transitivePeerDependencies: - - supports-color - - istanbul-reports@3.1.7: - dependencies: - html-escaper: 2.0.2 - istanbul-lib-report: 3.0.1 - - jackspeak@3.4.3: - dependencies: - '@isaacs/cliui': 8.0.2 - optionalDependencies: - '@pkgjs/parseargs': 0.11.0 - - jest-changed-files@30.3.0: - dependencies: - execa: 5.1.1 - jest-util: 30.3.0 - p-limit: 3.1.0 - - jest-circus@30.3.0: - dependencies: - '@jest/environment': 30.3.0 - '@jest/expect': 30.3.0 - '@jest/test-result': 30.3.0 - '@jest/types': 30.3.0 - '@types/node': 22.19.3 - chalk: 4.1.2 - co: 4.6.0 - dedent: 1.6.0 - is-generator-fn: 2.1.0 - jest-each: 30.3.0 - jest-matcher-utils: 30.3.0 - jest-message-util: 30.3.0 - jest-runtime: 30.3.0 - jest-snapshot: 30.3.0 - jest-util: 30.3.0 - p-limit: 3.1.0 - pretty-format: 30.3.0 - pure-rand: 7.0.1 - slash: 3.0.0 - stack-utils: 2.0.6 - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - - jest-cli@30.3.0(@types/node@22.19.3)(ts-node@10.9.2(@types/node@22.19.3)(typescript@5.7.3)): - dependencies: - '@jest/core': 30.3.0(ts-node@10.9.2(@types/node@22.19.3)(typescript@5.7.3)) - '@jest/test-result': 30.3.0 - '@jest/types': 30.3.0 - chalk: 4.1.2 - exit-x: 0.2.2 - import-local: 3.2.0 - jest-config: 30.3.0(@types/node@22.19.3)(ts-node@10.9.2(@types/node@22.19.3)(typescript@5.7.3)) - jest-util: 30.3.0 - jest-validate: 30.3.0 - yargs: 17.7.2 - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - esbuild-register - - supports-color - - ts-node - - jest-config@30.3.0(@types/node@22.19.3)(ts-node@10.9.2(@types/node@22.19.3)(typescript@5.7.3)): - dependencies: - '@babel/core': 7.28.3 - '@jest/get-type': 30.1.0 - '@jest/pattern': 30.0.1 - '@jest/test-sequencer': 30.3.0 - '@jest/types': 30.3.0 - babel-jest: 30.3.0(@babel/core@7.28.3) - chalk: 4.1.2 - ci-info: 4.3.0 - deepmerge: 4.3.1 - glob: 10.5.0 - graceful-fs: 4.2.11 - jest-circus: 30.3.0 - jest-docblock: 30.2.0 - jest-environment-node: 30.3.0 - jest-regex-util: 30.0.1 - jest-resolve: 30.3.0 - jest-runner: 30.3.0 - jest-util: 30.3.0 - jest-validate: 30.3.0 - parse-json: 5.2.0 - pretty-format: 30.3.0 - slash: 3.0.0 - strip-json-comments: 3.1.1 - optionalDependencies: - '@types/node': 22.19.3 - ts-node: 10.9.2(@types/node@22.19.3)(typescript@5.7.3) - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - - jest-diff@29.7.0: - dependencies: - chalk: 4.1.2 - diff-sequences: 29.6.3 - jest-get-type: 29.6.3 - pretty-format: 29.7.0 - - jest-diff@30.3.0: - dependencies: - '@jest/diff-sequences': 30.3.0 - '@jest/get-type': 30.1.0 - chalk: 4.1.2 - pretty-format: 30.3.0 - - jest-docblock@30.2.0: - dependencies: - detect-newline: 3.1.0 - - jest-each@30.3.0: - dependencies: - '@jest/get-type': 30.1.0 - '@jest/types': 30.3.0 - chalk: 4.1.2 - jest-util: 30.3.0 - pretty-format: 30.3.0 - - jest-environment-jsdom@30.3.0: - dependencies: - '@jest/environment': 30.3.0 - '@jest/environment-jsdom-abstract': 30.3.0(jsdom@26.1.0) - jsdom: 26.1.0 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - - jest-environment-node@30.3.0: - dependencies: - '@jest/environment': 30.3.0 - '@jest/fake-timers': 30.3.0 - '@jest/types': 30.3.0 - '@types/node': 22.19.3 - jest-mock: 30.3.0 - jest-util: 30.3.0 - jest-validate: 30.3.0 - - jest-get-type@29.6.3: {} - - jest-haste-map@30.3.0: - dependencies: - '@jest/types': 30.3.0 - '@types/node': 22.19.3 - anymatch: 3.1.3 - fb-watchman: 2.0.2 - graceful-fs: 4.2.11 - jest-regex-util: 30.0.1 - jest-util: 30.3.0 - jest-worker: 30.3.0 - picomatch: 4.0.4 - walker: 1.0.8 - optionalDependencies: - fsevents: 2.3.3 - - jest-leak-detector@30.3.0: - dependencies: - '@jest/get-type': 30.1.0 - pretty-format: 30.3.0 - - jest-matcher-utils@29.7.0: - dependencies: - chalk: 4.1.2 - jest-diff: 29.7.0 - jest-get-type: 29.6.3 - pretty-format: 29.7.0 - - jest-matcher-utils@30.3.0: - dependencies: - '@jest/get-type': 30.1.0 - chalk: 4.1.2 - jest-diff: 30.3.0 - pretty-format: 30.3.0 - - jest-message-util@29.7.0: - dependencies: - '@babel/code-frame': 7.27.1 - '@jest/types': 29.6.3 - '@types/stack-utils': 2.0.3 - chalk: 4.1.2 - graceful-fs: 4.2.11 - micromatch: 4.0.8 - pretty-format: 29.7.0 - slash: 3.0.0 - stack-utils: 2.0.6 - - jest-message-util@30.3.0: - dependencies: - '@babel/code-frame': 7.27.1 - '@jest/types': 30.3.0 - '@types/stack-utils': 2.0.3 - chalk: 4.1.2 - graceful-fs: 4.2.11 - picomatch: 4.0.4 - pretty-format: 30.3.0 - slash: 3.0.0 - stack-utils: 2.0.6 - - jest-mock@30.3.0: - dependencies: - '@jest/types': 30.3.0 - '@types/node': 22.19.3 - jest-util: 30.3.0 - - jest-pnp-resolver@1.2.3(jest-resolve@30.3.0): - optionalDependencies: - jest-resolve: 30.3.0 - - jest-regex-util@30.0.1: {} - - jest-resolve-dependencies@30.3.0: - dependencies: - jest-regex-util: 30.0.1 - jest-snapshot: 30.3.0 - transitivePeerDependencies: - - supports-color - - jest-resolve@30.3.0: - dependencies: - chalk: 4.1.2 - graceful-fs: 4.2.11 - jest-haste-map: 30.3.0 - jest-pnp-resolver: 1.2.3(jest-resolve@30.3.0) - jest-util: 30.3.0 - jest-validate: 30.3.0 - slash: 3.0.0 - unrs-resolver: 1.11.1 - - jest-runner@30.3.0: - dependencies: - '@jest/console': 30.3.0 - '@jest/environment': 30.3.0 - '@jest/test-result': 30.3.0 - '@jest/transform': 30.3.0 - '@jest/types': 30.3.0 - '@types/node': 22.19.3 - chalk: 4.1.2 - emittery: 0.13.1 - exit-x: 0.2.2 - graceful-fs: 4.2.11 - jest-docblock: 30.2.0 - jest-environment-node: 30.3.0 - jest-haste-map: 30.3.0 - jest-leak-detector: 30.3.0 - jest-message-util: 30.3.0 - jest-resolve: 30.3.0 - jest-runtime: 30.3.0 - jest-util: 30.3.0 - jest-watcher: 30.3.0 - jest-worker: 30.3.0 - p-limit: 3.1.0 - source-map-support: 0.5.13 - transitivePeerDependencies: - - supports-color - - jest-runtime@30.3.0: - dependencies: - '@jest/environment': 30.3.0 - '@jest/fake-timers': 30.3.0 - '@jest/globals': 30.3.0 - '@jest/source-map': 30.0.1 - '@jest/test-result': 30.3.0 - '@jest/transform': 30.3.0 - '@jest/types': 30.3.0 - '@types/node': 22.19.3 - chalk: 4.1.2 - cjs-module-lexer: 2.1.0 - collect-v8-coverage: 1.0.2 - glob: 10.5.0 - graceful-fs: 4.2.11 - jest-haste-map: 30.3.0 - jest-message-util: 30.3.0 - jest-mock: 30.3.0 - jest-regex-util: 30.0.1 - jest-resolve: 30.3.0 - jest-snapshot: 30.3.0 - jest-util: 30.3.0 - slash: 3.0.0 - strip-bom: 4.0.0 - transitivePeerDependencies: - - supports-color - - jest-snapshot@30.3.0: - dependencies: - '@babel/core': 7.28.3 - '@babel/generator': 7.28.3 - '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.3) - '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.28.3) - '@babel/types': 7.28.2 - '@jest/expect-utils': 30.3.0 - '@jest/get-type': 30.1.0 - '@jest/snapshot-utils': 30.3.0 - '@jest/transform': 30.3.0 - '@jest/types': 30.3.0 - babel-preset-current-node-syntax: 1.2.0(@babel/core@7.28.3) - chalk: 4.1.2 - expect: 30.3.0 - graceful-fs: 4.2.11 - jest-diff: 30.3.0 - jest-matcher-utils: 30.3.0 - jest-message-util: 30.3.0 - jest-util: 30.3.0 - pretty-format: 30.3.0 - semver: 7.7.3 - synckit: 0.11.11 - transitivePeerDependencies: - - supports-color - - jest-util@29.7.0: - dependencies: - '@jest/types': 29.6.3 - '@types/node': 22.19.3 - chalk: 4.1.2 - ci-info: 3.9.0 - graceful-fs: 4.2.11 - picomatch: 4.0.4 - - jest-util@30.3.0: + is-glob@4.0.3: dependencies: - '@jest/types': 30.3.0 - '@types/node': 22.19.3 - chalk: 4.1.2 - ci-info: 4.3.0 - graceful-fs: 4.2.11 - picomatch: 4.0.4 - - jest-validate@30.3.0: - dependencies: - '@jest/get-type': 30.1.0 - '@jest/types': 30.3.0 - camelcase: 6.3.0 - chalk: 4.1.2 - leven: 3.1.0 - pretty-format: 30.3.0 - - jest-watcher@30.3.0: - dependencies: - '@jest/test-result': 30.3.0 - '@jest/types': 30.3.0 - '@types/node': 22.19.3 - ansi-escapes: 4.3.2 - chalk: 4.1.2 - emittery: 0.13.1 - jest-util: 30.3.0 - string-length: 4.0.2 + is-extglob: 2.1.1 - jest-worker@30.3.0: - dependencies: - '@types/node': 22.19.3 - '@ungap/structured-clone': 1.3.0 - jest-util: 30.3.0 - merge-stream: 2.0.0 - supports-color: 8.1.1 + is-number@7.0.0: {} - jest@30.3.0(@types/node@22.19.3)(ts-node@10.9.2(@types/node@22.19.3)(typescript@5.7.3)): - dependencies: - '@jest/core': 30.3.0(ts-node@10.9.2(@types/node@22.19.3)(typescript@5.7.3)) - '@jest/types': 30.3.0 - import-local: 3.2.0 - jest-cli: 30.3.0(@types/node@22.19.3)(ts-node@10.9.2(@types/node@22.19.3)(typescript@5.7.3)) - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - esbuild-register - - supports-color - - ts-node + is-potential-custom-element-name@1.0.1: {} + + is-what@5.5.0: {} jiti@2.6.1: {} - jose@4.15.9: {} + jose@6.2.12: {} js-tokens@4.0.0: {} - js-yaml@3.14.2: - dependencies: - argparse: 1.0.10 - esprima: 4.0.1 - - jsdom@26.1.0: - dependencies: - cssstyle: 4.6.0 - data-urls: 5.0.0 - decimal.js: 10.5.0 - html-encoding-sniffer: 4.0.0 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 + jsdom@30.1.2(@noble/hashes@2.4.0): + dependencies: + '@asamuzakjp/css-color': 7.1.5 + '@asamuzakjp/dom-selector': 9.2.4 + '@bramus/specificity': 2.4.3 + '@csstools/css-syntax-patches-for-csstree': 1.1.15(css-tree@3.2.1) + '@exodus/bytes': 1.16.0(@noble/hashes@2.4.0) + css-tree: 3.2.1 + data-urls: 8.0.0(@noble/hashes@2.4.0) + decimal.js: 10.6.0 + html-encoding-sniffer: 7.0.0(@noble/hashes@2.4.0) is-potential-custom-element-name: 1.0.1 - nwsapi: 2.2.20 - parse5: 7.3.0 - rrweb-cssom: 0.8.0 + lru-cache: 11.5.3 + parse5: 8.0.1 saxes: 6.0.0 - symbol-tree: 3.2.4 - tough-cookie: 5.1.2 - w3c-xmlserializer: 5.0.0 - webidl-conversions: 7.0.0 - whatwg-encoding: 3.1.1 - whatwg-mimetype: 4.0.0 - whatwg-url: 14.2.0 - ws: 8.18.2 + tough-cookie: 6.0.2 + undici: 8.11.2 + w3c-xmlserializer: 6.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 17.2.0(@noble/hashes@2.4.0) xml-name-validator: 5.0.0 transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - - jsesc@3.1.0: {} - - json-parse-even-better-errors@2.3.1: {} - - json5@2.2.3: {} + - '@noble/hashes' jwa@2.0.1: dependencies: @@ -7605,41 +7190,78 @@ snapshots: jwa: 2.0.1 safe-buffer: 5.2.1 - leven@3.1.0: {} + kleur@4.1.5: {} + + kolorist@1.8.0: {} + + kysely@0.29.6: {} lightningcss-android-arm64@1.30.2: optional: true + lightningcss-android-arm64@1.33.0: + optional: true + lightningcss-darwin-arm64@1.30.2: optional: true + lightningcss-darwin-arm64@1.33.0: + optional: true + lightningcss-darwin-x64@1.30.2: optional: true + lightningcss-darwin-x64@1.33.0: + optional: true + lightningcss-freebsd-x64@1.30.2: optional: true + lightningcss-freebsd-x64@1.33.0: + optional: true + lightningcss-linux-arm-gnueabihf@1.30.2: optional: true + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + lightningcss-linux-arm64-gnu@1.30.2: optional: true + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + lightningcss-linux-arm64-musl@1.30.2: optional: true + lightningcss-linux-arm64-musl@1.33.0: + optional: true + lightningcss-linux-x64-gnu@1.30.2: optional: true + lightningcss-linux-x64-gnu@1.33.0: + optional: true + lightningcss-linux-x64-musl@1.30.2: optional: true + lightningcss-linux-x64-musl@1.33.0: + optional: true + lightningcss-win32-arm64-msvc@1.30.2: optional: true + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + lightningcss-win32-x64-msvc@1.30.2: optional: true + lightningcss-win32-x64-msvc@1.33.0: + optional: true + lightningcss@1.30.2: dependencies: detect-libc: 2.1.2 @@ -7656,7 +7278,26 @@ snapshots: lightningcss-win32-arm64-msvc: 1.30.2 lightningcss-win32-x64-msvc: 1.30.2 - lines-and-columns@1.2.4: {} + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + linebreak@1.1.0: + dependencies: + base64-js: 0.0.8 + unicode-trie: 2.0.0 lint-staged@16.4.0: dependencies: @@ -7676,12 +7317,6 @@ snapshots: rfdc: 1.4.1 wrap-ansi: 9.0.2 - locate-path@5.0.0: - dependencies: - p-locate: 4.1.0 - - lodash.sortby@4.7.0: {} - log-update@6.1.0: dependencies: ansi-escapes: 7.2.0 @@ -7694,15 +7329,7 @@ snapshots: dependencies: js-tokens: 4.0.0 - lru-cache@10.4.3: {} - - lru-cache@5.1.1: - dependencies: - yallist: 3.1.1 - - lru-cache@6.0.0: - dependencies: - yallist: 4.0.0 + lru-cache@11.5.3: {} lucide-react@0.544.0(react@19.2.1): dependencies: @@ -7714,19 +7341,13 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 - make-dir@4.0.0: - dependencies: - semver: 7.7.3 - make-error@1.3.6: {} - makeerror@1.0.12: - dependencies: - tmpl: 1.0.5 - math-intrinsics@1.1.0: {} - merge-stream@2.0.0: {} + mdn-data@2.27.1: {} + + merge2@1.4.1: {} micromatch@4.0.8: dependencies: @@ -7739,10 +7360,21 @@ snapshots: dependencies: mime-db: 1.52.0 - mimic-fn@2.1.0: {} - mimic-function@5.0.1: {} + miniflare@5.20261006.1-alpha(@types/node@22.19.3): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + sharp: 0.35.5(@types/node@22.19.3) + undici: 7.29.1 + workerd: 1.20261006.1 + ws: 8.21.0 + youch: 4.1.0-beta.10 + transitivePeerDependencies: + - '@types/node' + - bufferutil + - utf-8-validate + minimalistic-assert@1.0.1: {} minimatch@9.0.9: @@ -7751,36 +7383,19 @@ snapshots: minimist@1.2.8: {} - minipass@7.1.2: {} + minipass@7.1.3: {} ms@2.1.3: {} nanoid@3.3.11: {} - nanoid@5.1.6: {} - - napi-postinstall@0.3.3: {} + nanoid@3.3.20: {} - natural-compare@1.4.0: {} + nanoid@5.1.6: {} - next-auth@4.24.14(next@15.5.18(@babel/core@7.28.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(nodemailer@9.0.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1): - dependencies: - '@babel/runtime': 7.29.2 - '@panva/hkdf': 1.2.1 - cookie: 0.7.2 - jose: 4.15.9 - next: 15.5.18(@babel/core@7.28.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - oauth: 0.9.15 - openid-client: 5.7.1 - preact: 10.28.3 - preact-render-to-string: 5.2.6(preact@10.28.3) - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) - uuid: 8.3.2 - optionalDependencies: - nodemailer: 9.0.1 + nanostores@1.5.5: {} - next-i18next@15.4.3(i18next@25.10.10(typescript@5.7.3))(next@15.5.18(@babel/core@7.28.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-i18next@15.7.4(i18next@25.10.10(typescript@5.7.3))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.7.3))(react@19.2.1): + next-i18next@15.4.3(i18next@25.10.10(typescript@5.7.3))(next@15.5.27(@playwright/test@1.64.0)(@types/node@22.19.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-i18next@15.7.4(i18next@25.10.10(typescript@5.7.3))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.7.3))(react@19.2.1): dependencies: '@babel/runtime': 7.28.4 '@types/hoist-non-react-statics': 3.3.6 @@ -7788,7 +7403,7 @@ snapshots: hoist-non-react-statics: 3.3.2 i18next: 25.10.10(typescript@5.7.3) i18next-fs-backend: 2.6.0 - next: 15.5.18(@babel/core@7.28.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + next: 15.5.27(@playwright/test@1.64.0)(@types/node@22.19.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) react: 19.2.1 react-i18next: 15.7.4(i18next@25.10.10(typescript@5.7.3))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.7.3) @@ -7797,40 +7412,40 @@ snapshots: react: 19.2.1 react-dom: 19.2.1(react@19.2.1) - next@15.5.18(@babel/core@7.28.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1): + next@15.5.27(@playwright/test@1.64.0)(@types/node@22.19.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1): dependencies: - '@next/env': 15.5.18 + '@next/env': 15.5.27 '@swc/helpers': 0.5.15 caniuse-lite: 1.0.30001760 postcss: 8.4.31 react: 19.2.1 react-dom: 19.2.1(react@19.2.1) - styled-jsx: 5.1.6(@babel/core@7.28.3)(react@19.2.1) + styled-jsx: 5.1.6(react@19.2.1) optionalDependencies: - '@next/swc-darwin-arm64': 15.5.18 - '@next/swc-darwin-x64': 15.5.18 - '@next/swc-linux-arm64-gnu': 15.5.18 - '@next/swc-linux-arm64-musl': 15.5.18 - '@next/swc-linux-x64-gnu': 15.5.18 - '@next/swc-linux-x64-musl': 15.5.18 - '@next/swc-win32-arm64-msvc': 15.5.18 - '@next/swc-win32-x64-msvc': 15.5.18 - sharp: 0.34.5 + '@next/swc-darwin-arm64': 15.5.27 + '@next/swc-darwin-x64': 15.5.27 + '@next/swc-linux-arm64-gnu': 15.5.27 + '@next/swc-linux-arm64-musl': 15.5.27 + '@next/swc-linux-x64-gnu': 15.5.27 + '@next/swc-linux-x64-musl': 15.5.27 + '@next/swc-win32-arm64-msvc': 15.5.27 + '@next/swc-win32-x64-msvc': 15.5.27 + '@playwright/test': 1.64.0 + sharp: 0.35.5(@types/node@22.19.3) transitivePeerDependencies: - '@babel/core' + - '@types/node' - babel-plugin-macros - node-fetch-native@1.6.7: {} + node-addon-api@8.9.2: + optional: true node-fetch@2.7.0: dependencies: whatwg-url: 5.0.0 - node-int64@0.4.0: {} - - node-releases@2.0.27: {} - - nodemailer@9.0.1: {} + node-releases@2.0.27: + optional: true nordigen-node@1.4.1: dependencies: @@ -7839,53 +7454,16 @@ snapshots: transitivePeerDependencies: - debug - normalize-path@3.0.0: {} - normalize-wheel@1.0.1: {} - npm-run-path@4.0.1: - dependencies: - path-key: 3.1.1 - - nwsapi@2.2.20: {} - - nypm@0.6.2: - dependencies: - citty: 0.1.6 - consola: 3.4.2 - pathe: 2.0.3 - pkg-types: 2.3.0 - tinyexec: 1.0.4 - - oauth@0.9.15: {} - object-assign@4.1.1: {} - object-hash@2.2.0: {} - - ohash@2.0.11: {} - - oidc-token-hash@5.1.0: {} - - once@1.4.0: - dependencies: - wrappy: 1.0.2 - - onetime@5.1.2: - dependencies: - mimic-fn: 2.1.0 + obug@2.2.1: {} onetime@7.0.0: dependencies: mimic-function: 5.0.1 - openid-client@5.7.1: - dependencies: - jose: 4.15.9 - lru-cache: 6.0.0 - object-hash: 2.2.0 - oidc-token-hash: 5.1.0 - oxlint-tsgolint@0.18.1: optionalDependencies: '@oxlint-tsgolint/darwin-arm64': 0.18.1 @@ -7918,63 +7496,31 @@ snapshots: '@oxlint/binding-win32-x64-msvc': 1.57.0 oxlint-tsgolint: 0.18.1 - p-limit@2.3.0: - dependencies: - p-try: 2.2.0 - - p-limit@3.1.0: - dependencies: - yocto-queue: 0.1.0 + pako@0.2.9: {} - p-locate@4.1.0: + parse-css-color@0.2.1: dependencies: - p-limit: 2.3.0 - - p-try@2.2.0: {} - - package-json-from-dist@1.0.1: {} + color-name: 1.1.4 + hex-rgb: 4.3.0 - parse-json@5.2.0: + parse5@8.0.1: dependencies: - '@babel/code-frame': 7.27.1 - error-ex: 1.3.2 - json-parse-even-better-errors: 2.3.1 - lines-and-columns: 1.2.4 + entities: 8.1.0 - parse5@7.3.0: + path-scurry@2.0.2: dependencies: - entities: 6.0.0 - - path-exists@4.0.0: {} - - path-is-absolute@1.0.1: {} - - path-key@3.1.1: {} + lru-cache: 11.5.3 + minipass: 7.1.3 - path-scurry@1.11.1: - dependencies: - lru-cache: 10.4.3 - minipass: 7.1.2 + path-to-regexp@6.3.0: {} pathe@2.0.3: {} - perfect-debounce@1.0.0: {} - picocolors@1.1.1: {} picomatch@4.0.4: {} - pirates@4.0.7: {} - - pkg-dir@4.2.0: - dependencies: - find-up: 4.1.0 - - pkg-types@2.3.0: - dependencies: - confbox: 0.2.2 - exsolve: 1.0.8 - pathe: 2.0.3 + picomatch@4.0.7: {} plaid@38.1.0: dependencies: @@ -7982,11 +7528,19 @@ snapshots: transitivePeerDependencies: - debug + playwright-core@1.64.0: {} + + playwright@1.64.0: + dependencies: + playwright-core: 1.64.0 + + postcss-value-parser@4.2.0: {} + postcss@8.4.31: dependencies: - nanoid: 3.3.11 + nanoid: 3.3.20 picocolors: 1.1.1 - source-map-js: 1.2.1 + source-map-js: 1.2.2 postcss@8.5.14: dependencies: @@ -7994,12 +7548,11 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - preact-render-to-string@5.2.6(preact@10.28.3): + postcss@8.5.29: dependencies: - preact: 10.28.3 - pretty-format: 3.8.0 - - preact@10.28.3: {} + nanoid: 3.3.20 + picocolors: 1.1.1 + source-map-js: 1.2.2 prettier-plugin-tailwindcss@0.6.14(prettier@3.7.4): dependencies: @@ -8009,29 +7562,6 @@ snapshots: pretty-bytes@6.1.1: {} - pretty-format@29.7.0: - dependencies: - '@jest/schemas': 29.6.3 - ansi-styles: 5.2.0 - react-is: 18.3.1 - - pretty-format@3.8.0: {} - - pretty-format@30.3.0: - dependencies: - '@jest/schemas': 30.0.5 - ansi-styles: 5.2.0 - react-is: 18.3.1 - - prisma@6.19.3(typescript@5.7.3): - dependencies: - '@prisma/config': 6.19.3 - '@prisma/engines': 6.19.3 - optionalDependencies: - typescript: 5.7.3 - transitivePeerDependencies: - - magicast - prop-types@15.8.1: dependencies: loose-envify: 1.4.0 @@ -8042,9 +7572,7 @@ snapshots: punycode@2.3.1: {} - pure-rand@6.1.0: {} - - pure-rand@7.0.1: {} + queue-microtask@1.2.3: {} radix-ui@1.4.3(@types/react-dom@19.2.1(@types/react@19.2.1))(@types/react@19.2.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1): dependencies: @@ -8109,11 +7637,6 @@ snapshots: '@types/react': 19.2.1 '@types/react-dom': 19.2.1(@types/react@19.2.1) - rc9@2.1.2: - dependencies: - defu: 6.1.4 - destr: 2.0.5 - react-day-picker@9.12.0(react@19.2.1): dependencies: '@date-fns/tz': 1.4.1 @@ -8149,8 +7672,6 @@ snapshots: react-is@16.13.1: {} - react-is@18.3.1: {} - react-plaid-link@4.1.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1): dependencies: prop-types: 15.8.1 @@ -8197,15 +7718,7 @@ snapshots: react@19.2.1: {} - readdirp@4.1.2: {} - - require-directory@2.1.1: {} - - resolve-cwd@3.0.0: - dependencies: - resolve-from: 5.0.0 - - resolve-from@5.0.0: {} + require-from-string@2.0.2: {} resolve-pkg-maps@1.0.0: {} @@ -8214,23 +7727,64 @@ snapshots: onetime: 7.0.0 signal-exit: 4.1.0 + reusify@1.1.0: {} + rfdc@1.4.1: {} - rrweb-cssom@0.8.0: {} + rolldown@1.2.13: + dependencies: + '@oxc-project/types': 0.153.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.13 + '@rolldown/binding-android-arm64': 1.2.13 + '@rolldown/binding-darwin-arm64': 1.2.13 + '@rolldown/binding-darwin-x64': 1.2.13 + '@rolldown/binding-freebsd-x64': 1.2.13 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.13 + '@rolldown/binding-linux-arm64-gnu': 1.2.13 + '@rolldown/binding-linux-arm64-musl': 1.2.13 + '@rolldown/binding-linux-ppc64-gnu': 1.2.13 + '@rolldown/binding-linux-s390x-gnu': 1.2.13 + '@rolldown/binding-linux-x64-gnu': 1.2.13 + '@rolldown/binding-linux-x64-musl': 1.2.13 + '@rolldown/binding-openharmony-arm64': 1.2.13 + '@rolldown/binding-win32-arm64-msvc': 1.2.13 + '@rolldown/binding-win32-x64-msvc': 1.2.13 + + rou3@0.9.2: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 safe-buffer@5.2.1: {} safer-buffer@2.1.2: {} + satori@0.33.5: + dependencies: + '@shuding/opentype.js': 1.4.0-beta.0 + css-background-parser: 0.1.0 + css-box-shadow: 1.0.0-3 + css-gradient-parser: 0.0.17 + css-to-react-native: 3.2.0 + emoji-regex-xs: 2.0.1 + escape-html: 1.0.3 + fflate: 0.7.3 + harfbuzzjs: 0.10.0 + linebreak: 1.1.0 + parse-css-color: 0.2.1 + postcss-value-parser: 4.2.0 + yoga-layout: 3.2.1 + saxes@6.0.0: dependencies: xmlchars: 2.2.0 scheduler@0.27.0: {} - semver@6.3.1: {} - - semver@7.7.3: {} + semver@7.8.5: {} serwist@9.2.3(typescript@5.7.3): dependencies: @@ -8238,48 +7792,53 @@ snapshots: optionalDependencies: typescript: 5.7.3 - sharp@0.34.5: + serwist@9.5.13(browserslist@4.28.1)(typescript@5.7.3): + dependencies: + '@serwist/utils': 9.5.13(browserslist@4.28.1) + idb: 8.0.3 + optionalDependencies: + typescript: 5.7.3 + transitivePeerDependencies: + - browserslist + + set-cookie-parser@3.1.3: {} + + sharp@0.35.5(@types/node@22.19.3): dependencies: - '@img/colour': 1.0.0 + '@img/colour': 1.1.0 detect-libc: 2.1.2 - semver: 7.7.3 + semver: 7.8.5 optionalDependencies: - '@img/sharp-darwin-arm64': 0.34.5 - '@img/sharp-darwin-x64': 0.34.5 - '@img/sharp-libvips-darwin-arm64': 1.2.4 - '@img/sharp-libvips-darwin-x64': 1.2.4 - '@img/sharp-libvips-linux-arm': 1.2.4 - '@img/sharp-libvips-linux-arm64': 1.2.4 - '@img/sharp-libvips-linux-ppc64': 1.2.4 - '@img/sharp-libvips-linux-riscv64': 1.2.4 - '@img/sharp-libvips-linux-s390x': 1.2.4 - '@img/sharp-libvips-linux-x64': 1.2.4 - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 - '@img/sharp-linux-arm': 0.34.5 - '@img/sharp-linux-arm64': 0.34.5 - '@img/sharp-linux-ppc64': 0.34.5 - '@img/sharp-linux-riscv64': 0.34.5 - '@img/sharp-linux-s390x': 0.34.5 - '@img/sharp-linux-x64': 0.34.5 - '@img/sharp-linuxmusl-arm64': 0.34.5 - '@img/sharp-linuxmusl-x64': 0.34.5 - '@img/sharp-wasm32': 0.34.5 - '@img/sharp-win32-arm64': 0.34.5 - '@img/sharp-win32-ia32': 0.34.5 - '@img/sharp-win32-x64': 0.34.5 - - shebang-command@2.0.0: - dependencies: - shebang-regex: 3.0.0 - - shebang-regex@3.0.0: {} - - signal-exit@3.0.7: {} + '@img/sharp-darwin-arm64': 0.35.5 + '@img/sharp-darwin-x64': 0.35.5 + '@img/sharp-freebsd-wasm32': 0.35.5 + '@img/sharp-libvips-darwin-arm64': 1.3.4 + '@img/sharp-libvips-darwin-x64': 1.3.4 + '@img/sharp-libvips-linux-arm': 1.3.4 + '@img/sharp-libvips-linux-arm64': 1.3.4 + '@img/sharp-libvips-linux-ppc64': 1.3.4 + '@img/sharp-libvips-linux-riscv64': 1.3.4 + '@img/sharp-libvips-linux-s390x': 1.3.4 + '@img/sharp-libvips-linux-x64': 1.3.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 + '@img/sharp-libvips-linuxmusl-x64': 1.3.4 + '@img/sharp-linux-arm': 0.35.5 + '@img/sharp-linux-arm64': 0.35.5 + '@img/sharp-linux-ppc64': 0.35.5 + '@img/sharp-linux-riscv64': 0.35.5 + '@img/sharp-linux-s390x': 0.35.5 + '@img/sharp-linux-x64': 0.35.5 + '@img/sharp-linuxmusl-arm64': 0.35.5 + '@img/sharp-linuxmusl-x64': 0.35.5 + '@img/sharp-webcontainers-wasm32': 0.35.5 + '@img/sharp-win32-arm64': 0.35.5 + '@img/sharp-win32-ia32': 0.35.5 + '@img/sharp-win32-x64': 0.35.5 + '@types/node': 22.19.3 - signal-exit@4.1.0: {} + siginfo@2.0.0: {} - slash@3.0.0: {} + signal-exit@4.1.0: {} slice-ansi@7.1.2: dependencies: @@ -8293,42 +7852,23 @@ snapshots: source-map-js@1.2.1: {} - source-map-support@0.5.13: + source-map-js@1.2.2: {} + + source-map-support@0.5.21: dependencies: buffer-from: 1.1.2 source-map: 0.6.1 source-map@0.6.1: {} - source-map@0.8.0-beta.0: - dependencies: - whatwg-url: 7.1.0 + source-map@0.8.0: {} - sprintf-js@1.0.3: {} + stackback@0.0.2: {} - stack-utils@2.0.6: - dependencies: - escape-string-regexp: 2.0.0 + std-env@4.3.0: {} string-argv@0.3.2: {} - string-length@4.0.2: - dependencies: - char-regex: 1.0.2 - strip-ansi: 6.0.1 - - string-width@4.2.3: - dependencies: - emoji-regex: 8.0.0 - is-fullwidth-code-point: 3.0.0 - strip-ansi: 6.0.1 - - string-width@5.1.2: - dependencies: - eastasianwidth: 0.2.0 - emoji-regex: 9.2.2 - strip-ansi: 7.1.2 - string-width@7.2.0: dependencies: emoji-regex: 10.6.0 @@ -8340,44 +7880,24 @@ snapshots: get-east-asian-width: 1.4.0 strip-ansi: 7.1.2 - strip-ansi@6.0.1: - dependencies: - ansi-regex: 5.0.1 + string.prototype.codepointat@0.2.1: {} strip-ansi@7.1.2: dependencies: ansi-regex: 6.2.2 - strip-bom@4.0.0: {} - - strip-final-newline@2.0.0: {} - - strip-json-comments@3.1.1: {} - - styled-jsx@5.1.6(@babel/core@7.28.3)(react@19.2.1): + styled-jsx@5.1.6(react@19.2.1): dependencies: client-only: 0.0.1 react: 19.2.1 - optionalDependencies: - '@babel/core': 7.28.3 superjson@2.2.6: dependencies: copy-anything: 4.0.5 - supports-color@7.2.0: - dependencies: - has-flag: 4.0.0 - - supports-color@8.1.1: - dependencies: - has-flag: 4.0.0 - - symbol-tree@3.2.4: {} + supports-color@10.2.2: {} - synckit@0.11.11: - dependencies: - '@pkgr/core': 0.2.9 + tagged-tag@1.0.0: {} tailwind-merge@2.6.0: {} @@ -8385,37 +7905,36 @@ snapshots: tapable@2.3.0: {} - test-exclude@6.0.0: - dependencies: - '@istanbuljs/schema': 0.1.3 - glob: 7.2.3 - minimatch: 9.0.9 + tiny-inflate@1.0.3: {} - tinyexec@1.0.4: {} + tinybench@2.9.0: {} - tldts-core@6.1.86: {} + tinyexec@1.0.4: {} - tldts@6.1.86: + tinyglobby@0.2.17: dependencies: - tldts-core: 6.1.86 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 - tmpl@1.0.5: {} + tinyrainbow@3.2.0: {} + + tldts-core@7.4.18: {} + + tldts@7.4.18: + dependencies: + tldts-core: 7.4.18 to-regex-range@5.0.1: dependencies: is-number: 7.0.0 - tough-cookie@5.1.2: + tough-cookie@6.0.2: dependencies: - tldts: 6.1.86 + tldts: 7.4.18 tr46@0.0.3: {} - tr46@1.0.1: - dependencies: - punycode: 2.3.1 - - tr46@5.1.1: + tr46@7.0.0: dependencies: punycode: 2.3.1 @@ -8446,43 +7965,35 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - type-detect@4.0.8: {} - - type-fest@0.21.3: {} + type-fest@5.10.0: + dependencies: + tagged-tag: 1.0.0 typescript@5.7.3: {} undici-types@6.21.0: {} - unrs-resolver@1.11.1: + undici@7.29.1: {} + + undici@8.11.2: {} + + unenv@2.0.0-rc.24: dependencies: - napi-postinstall: 0.3.3 - optionalDependencies: - '@unrs/resolver-binding-android-arm-eabi': 1.11.1 - '@unrs/resolver-binding-android-arm64': 1.11.1 - '@unrs/resolver-binding-darwin-arm64': 1.11.1 - '@unrs/resolver-binding-darwin-x64': 1.11.1 - '@unrs/resolver-binding-freebsd-x64': 1.11.1 - '@unrs/resolver-binding-linux-arm-gnueabihf': 1.11.1 - '@unrs/resolver-binding-linux-arm-musleabihf': 1.11.1 - '@unrs/resolver-binding-linux-arm64-gnu': 1.11.1 - '@unrs/resolver-binding-linux-arm64-musl': 1.11.1 - '@unrs/resolver-binding-linux-ppc64-gnu': 1.11.1 - '@unrs/resolver-binding-linux-riscv64-gnu': 1.11.1 - '@unrs/resolver-binding-linux-riscv64-musl': 1.11.1 - '@unrs/resolver-binding-linux-s390x-gnu': 1.11.1 - '@unrs/resolver-binding-linux-x64-gnu': 1.11.1 - '@unrs/resolver-binding-linux-x64-musl': 1.11.1 - '@unrs/resolver-binding-wasm32-wasi': 1.11.1 - '@unrs/resolver-binding-win32-arm64-msvc': 1.11.1 - '@unrs/resolver-binding-win32-ia32-msvc': 1.11.1 - '@unrs/resolver-binding-win32-x64-msvc': 1.11.1 + pathe: 2.0.3 + + unicode-trie@2.0.0: + dependencies: + pako: 0.2.9 + tiny-inflate: 1.0.3 + + unpic@4.2.2: {} update-browserslist-db@1.2.3(browserslist@4.28.1): dependencies: browserslist: 4.28.1 escalade: 3.2.0 picocolors: 1.1.1 + optional: true use-callback-ref@1.3.3(@types/react@19.2.1)(react@19.2.1): dependencies: @@ -8503,18 +8014,10 @@ snapshots: dependencies: react: 19.2.1 - uuid@8.3.2: {} - uzip@0.20201231.0: {} v8-compile-cache-lib@3.0.1: {} - v8-to-istanbul@9.3.0: - dependencies: - '@jridgewell/trace-mapping': 0.3.31 - '@types/istanbul-lib-coverage': 2.0.6 - convert-source-map: 2.0.0 - vaul@1.1.2(@types/react-dom@19.2.1(@types/react@19.2.1))(@types/react@19.2.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1): dependencies: '@radix-ui/react-dialog': 1.1.14(@types/react-dom@19.2.1(@types/react@19.2.1))(@types/react@19.2.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) @@ -8524,15 +8027,84 @@ snapshots: - '@types/react' - '@types/react-dom' - void-elements@3.1.0: {} + vinext@1.1.0(@types/node@22.19.3)(@vitejs/plugin-react@6.1.2(vite@8.3.4(@types/node@22.19.3)(esbuild@0.28.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)))(next@15.5.27(@playwright/test@1.64.0)(@types/node@22.19.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(vite@8.3.4(@types/node@22.19.3)(esbuild@0.28.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)): + dependencies: + '@unpic/react': 1.0.2(next@15.5.27(@playwright/test@1.64.0)(@types/node@22.19.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + '@vercel/og': 1.0.3(@types/node@22.19.3) + '@vinext/types': 1.0.1 + '@vitejs/plugin-react': 6.1.2(vite@8.3.4(@types/node@22.19.3)(esbuild@0.28.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + ipaddr.js: 2.5.0 + magic-string: 0.30.21 + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + vite: 8.3.4(@types/node@22.19.3)(esbuild@0.28.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3) + vite-plugin-commonjs: 0.10.4 + web-vitals: 4.2.4 + transitivePeerDependencies: + - '@types/node' + - next - w3c-xmlserializer@5.0.0: + vite-plugin-commonjs@0.10.4: dependencies: - xml-name-validator: 5.0.0 + acorn: 8.14.1 + magic-string: 0.30.21 + vite-plugin-dynamic-import: 1.6.0 + + vite-plugin-dynamic-import@1.6.0: + dependencies: + acorn: 8.14.1 + es-module-lexer: 1.7.0 + fast-glob: 3.3.3 + magic-string: 0.30.21 + + vite@8.3.4(@types/node@22.19.3)(esbuild@0.28.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.29 + rolldown: 1.2.13 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 22.19.3 + esbuild: 0.28.2 + fsevents: 2.3.3 + jiti: 2.6.1 + tsx: 4.21.0 + yaml: 2.8.3 + + vitest@4.1.11(@types/node@22.19.3)(jsdom@30.1.2(@noble/hashes@2.4.0))(vite@8.3.4(@types/node@22.19.3)(esbuild@0.28.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)): + dependencies: + '@vitest/expect': 4.1.11 + '@vitest/mocker': 4.1.11(vite@8.3.4(@types/node@22.19.3)(esbuild@0.28.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + '@vitest/pretty-format': 4.1.11 + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.2.1 + pathe: 2.0.3 + picomatch: 4.0.7 + std-env: 4.3.0 + tinybench: 2.9.0 + tinyexec: 1.0.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.2.0 + vite: 8.3.4(@types/node@22.19.3)(esbuild@0.28.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.19.3 + jsdom: 30.1.2(@noble/hashes@2.4.0) + transitivePeerDependencies: + - msw + + void-elements@3.1.0: {} - walker@1.0.8: + w3c-xmlserializer@6.0.0: dependencies: - makeerror: 1.0.12 + xml-name-validator: 5.0.0 web-push@3.6.7: dependencies: @@ -8544,49 +8116,57 @@ snapshots: transitivePeerDependencies: - supports-color - webidl-conversions@3.0.1: {} - - webidl-conversions@4.0.2: {} + web-vitals@4.2.4: {} - webidl-conversions@7.0.0: {} + webidl-conversions@3.0.1: {} - whatwg-encoding@3.1.1: - dependencies: - iconv-lite: 0.6.3 + webidl-conversions@8.0.1: {} - whatwg-mimetype@4.0.0: {} + whatwg-mimetype@5.0.0: {} - whatwg-url@14.2.0: + whatwg-url@17.2.0(@noble/hashes@2.4.0): dependencies: - tr46: 5.1.1 - webidl-conversions: 7.0.0 + '@exodus/bytes': 1.16.0(@noble/hashes@2.4.0) + tr46: 7.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' whatwg-url@5.0.0: dependencies: tr46: 0.0.3 webidl-conversions: 3.0.1 - whatwg-url@7.1.0: - dependencies: - lodash.sortby: 4.7.0 - tr46: 1.0.1 - webidl-conversions: 4.0.2 - - which@2.0.2: - dependencies: - isexe: 2.0.0 - - wrap-ansi@7.0.0: + why-is-node-running@2.3.0: dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 + siginfo: 2.0.0 + stackback: 0.0.2 - wrap-ansi@8.1.0: - dependencies: - ansi-styles: 6.2.3 - string-width: 5.1.2 - strip-ansi: 7.1.2 + workerd@1.20261006.1: + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20261006.1 + '@cloudflare/workerd-darwin-arm64': 1.20261006.1 + '@cloudflare/workerd-linux-64': 1.20261006.1 + '@cloudflare/workerd-linux-arm64': 1.20261006.1 + '@cloudflare/workerd-windows-64': 1.20261006.1 + + wrangler@4.149.0(@cloudflare/workers-types@5.20261011.1)(@types/node@22.19.3): + dependencies: + '@cloudflare/kv-asset-handler': 0.5.0 + '@cloudflare/unenv-preset': 2.16.2(unenv@2.0.0-rc.24)(workerd@1.20261006.1) + blake3-wasm: 2.1.5 + esbuild: 0.28.2 + miniflare: 5.20261006.1-alpha(@types/node@22.19.3) + path-to-regexp: 6.3.0 + unenv: 2.0.0-rc.24 + workerd: 1.20261006.1 + optionalDependencies: + '@cloudflare/workers-types': 5.20261011.1 + fsevents: 2.3.3 + transitivePeerDependencies: + - '@types/node' + - bufferutil + - utf-8-validate wrap-ansi@9.0.2: dependencies: @@ -8594,46 +8174,34 @@ snapshots: string-width: 7.2.0 strip-ansi: 7.1.2 - wrappy@1.0.2: {} - - write-file-atomic@5.0.1: - dependencies: - imurmurhash: 0.1.4 - signal-exit: 4.1.0 - - ws@8.18.2: {} + ws@8.21.0: {} xml-name-validator@5.0.0: {} xmlchars@2.2.0: {} - y18n@5.0.8: {} - - yallist@3.1.1: {} - - yallist@4.0.0: {} - yaml@2.8.3: {} - yargs-parser@21.1.1: {} + yn@3.1.1: {} - yargs@17.7.2: - dependencies: - cliui: 8.0.1 - escalade: 3.2.0 - get-caller-file: 2.0.5 - require-directory: 2.1.1 - string-width: 4.2.3 - y18n: 5.0.8 - yargs-parser: 21.1.1 + yoga-layout@3.2.1: {} - yn@3.1.1: {} + youch-core@0.3.3: + dependencies: + '@poppinss/exception': 1.2.3 + error-stack-parser-es: 1.0.5 - yocto-queue@0.1.0: {} + youch@4.1.0-beta.10: + dependencies: + '@poppinss/colors': 4.1.6 + '@poppinss/dumper': 0.6.5 + '@speed-highlight/core': 1.2.24 + cookie: 1.1.1 + youch-core: 0.3.3 zod@3.25.76: {} - zod@4.1.12: {} + zod@4.6.5: {} zustand@4.5.7(@types/react@19.2.1)(react@19.2.1): dependencies: diff --git a/prisma.config.ts b/prisma.config.ts deleted file mode 100644 index 7f89f3085..000000000 --- a/prisma.config.ts +++ /dev/null @@ -1,17 +0,0 @@ -import 'dotenv/config'; -import path from 'node:path'; -import type { PrismaConfig } from 'prisma'; - -export default { - schema: path.join('prisma', 'schema.prisma'), - migrations: { - path: path.join('prisma', 'migrations'), - seed: 'tsx prisma/seed.ts', - }, - experimental: { - externalTables: true, - }, - tables: { - external: ['cron.job', 'cron.job_run_details'], - }, -} satisfies PrismaConfig; diff --git a/prisma/migrations/20240224010302_init/migration.sql b/prisma/migrations/20240224010302_init/migration.sql deleted file mode 100644 index 81331268a..000000000 --- a/prisma/migrations/20240224010302_init/migration.sql +++ /dev/null @@ -1,208 +0,0 @@ --- CreateEnum -CREATE TYPE "SplitType" AS ENUM ('EQUAL', 'PERCENTAGE', 'EXACT', 'SHARE', 'ADJUSTMENT', 'SETTLEMENT'); - --- CreateTable -CREATE TABLE "Account" ( - "id" TEXT NOT NULL, - "userId" INTEGER NOT NULL, - "type" TEXT NOT NULL, - "provider" TEXT NOT NULL, - "providerAccountId" TEXT NOT NULL, - "refresh_token" TEXT, - "access_token" TEXT, - "expires_at" INTEGER, - "token_type" TEXT, - "scope" TEXT, - "id_token" TEXT, - "session_state" TEXT, - - CONSTRAINT "Account_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "Session" ( - "id" TEXT NOT NULL, - "sessionToken" TEXT NOT NULL, - "userId" INTEGER NOT NULL, - "expires" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "Session_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "User" ( - "id" SERIAL NOT NULL, - "name" TEXT, - "email" TEXT, - "emailVerified" TIMESTAMP(3), - "image" TEXT, - "currency" TEXT NOT NULL DEFAULT 'USD', - - CONSTRAINT "User_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "VerificationToken" ( - "identifier" TEXT NOT NULL, - "token" TEXT NOT NULL, - "expires" TIMESTAMP(3) NOT NULL -); - --- CreateTable -CREATE TABLE "Balance" ( - "userId" INTEGER NOT NULL, - "currency" TEXT NOT NULL, - "friendId" INTEGER NOT NULL, - "amount" INTEGER NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "Balance_pkey" PRIMARY KEY ("userId","currency","friendId") -); - --- CreateTable -CREATE TABLE "Group" ( - "id" SERIAL NOT NULL, - "publicId" TEXT NOT NULL, - "name" TEXT NOT NULL, - "userId" INTEGER NOT NULL, - "defaultCurrency" TEXT NOT NULL DEFAULT 'USD', - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "Group_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "GroupUser" ( - "groupId" INTEGER NOT NULL, - "userId" INTEGER NOT NULL, - - CONSTRAINT "GroupUser_pkey" PRIMARY KEY ("groupId","userId") -); - --- CreateTable -CREATE TABLE "GroupBalance" ( - "groupId" INTEGER NOT NULL, - "currency" TEXT NOT NULL, - "userId" INTEGER NOT NULL, - "firendId" INTEGER NOT NULL, - "amount" INTEGER NOT NULL, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "GroupBalance_pkey" PRIMARY KEY ("groupId","currency","firendId","userId") -); - --- CreateTable -CREATE TABLE "Expense" ( - "id" TEXT NOT NULL, - "paidBy" INTEGER NOT NULL, - "addedBy" INTEGER NOT NULL, - "name" TEXT NOT NULL, - "category" TEXT NOT NULL, - "amount" INTEGER NOT NULL, - "splitType" "SplitType" NOT NULL DEFAULT 'EQUAL', - "expenseDate" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - "currency" TEXT NOT NULL, - "fileKey" TEXT, - "groupId" INTEGER, - - CONSTRAINT "Expense_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "ExpenseParticipant" ( - "expenseId" TEXT NOT NULL, - "userId" INTEGER NOT NULL, - "amount" INTEGER NOT NULL, - - CONSTRAINT "ExpenseParticipant_pkey" PRIMARY KEY ("expenseId","userId") -); - --- CreateTable -CREATE TABLE "ExpenseNote" ( - "id" TEXT NOT NULL, - "expenseId" TEXT NOT NULL, - "note" TEXT NOT NULL, - "createdById" INTEGER NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "ExpenseNote_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId"); - --- CreateIndex -CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken"); - --- CreateIndex -CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); - --- CreateIndex -CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"("token"); - --- CreateIndex -CREATE UNIQUE INDEX "VerificationToken_identifier_token_key" ON "VerificationToken"("identifier", "token"); - --- CreateIndex -CREATE UNIQUE INDEX "Group_publicId_key" ON "Group"("publicId"); - --- CreateIndex -CREATE INDEX "Expense_groupId_idx" ON "Expense"("groupId"); - --- CreateIndex -CREATE INDEX "Expense_paidBy_idx" ON "Expense"("paidBy"); - --- AddForeignKey -ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Balance" ADD CONSTRAINT "Balance_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Balance" ADD CONSTRAINT "Balance_friendId_fkey" FOREIGN KEY ("friendId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Group" ADD CONSTRAINT "Group_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "GroupUser" ADD CONSTRAINT "GroupUser_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "GroupUser" ADD CONSTRAINT "GroupUser_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "Group"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "GroupBalance" ADD CONSTRAINT "GroupBalance_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "Group"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "GroupBalance" ADD CONSTRAINT "GroupBalance_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "GroupBalance" ADD CONSTRAINT "GroupBalance_firendId_fkey" FOREIGN KEY ("firendId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Expense" ADD CONSTRAINT "Expense_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "Group"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Expense" ADD CONSTRAINT "Expense_paidBy_fkey" FOREIGN KEY ("paidBy") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Expense" ADD CONSTRAINT "Expense_addedBy_fkey" FOREIGN KEY ("addedBy") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "ExpenseParticipant" ADD CONSTRAINT "ExpenseParticipant_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "ExpenseParticipant" ADD CONSTRAINT "ExpenseParticipant_expenseId_fkey" FOREIGN KEY ("expenseId") REFERENCES "Expense"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "ExpenseNote" ADD CONSTRAINT "ExpenseNote_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "ExpenseNote" ADD CONSTRAINT "ExpenseNote_expenseId_fkey" FOREIGN KEY ("expenseId") REFERENCES "Expense"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20240228204411_add_deleted_at_for_expense/migration.sql b/prisma/migrations/20240228204411_add_deleted_at_for_expense/migration.sql deleted file mode 100644 index 086f4d86f..000000000 --- a/prisma/migrations/20240228204411_add_deleted_at_for_expense/migration.sql +++ /dev/null @@ -1,6 +0,0 @@ --- AlterTable -ALTER TABLE "Expense" ADD COLUMN "deletedAt" TIMESTAMP(3), -ADD COLUMN "deletedBy" INTEGER; - --- AddForeignKey -ALTER TABLE "Expense" ADD CONSTRAINT "Expense_deletedBy_fkey" FOREIGN KEY ("deletedBy") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20240311110627_add_push_notification/migration.sql b/prisma/migrations/20240311110627_add_push_notification/migration.sql deleted file mode 100644 index f0aa49d41..000000000 --- a/prisma/migrations/20240311110627_add_push_notification/migration.sql +++ /dev/null @@ -1,7 +0,0 @@ --- CreateTable -CREATE TABLE "PushNotification" ( - "userId" INTEGER NOT NULL, - "subscription" TEXT NOT NULL, - - CONSTRAINT "PushNotification_pkey" PRIMARY KEY ("userId") -); diff --git a/prisma/migrations/20240330050427_add_imported_from_splitiwise_user/migration.sql b/prisma/migrations/20240330050427_add_imported_from_splitiwise_user/migration.sql deleted file mode 100644 index 506d96b9d..000000000 --- a/prisma/migrations/20240330050427_add_imported_from_splitiwise_user/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- AlterTable -ALTER TABLE "Balance" ADD COLUMN "importedFromSplitwise" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/migrations/20240330061939_add_splitwise_group_id/migration.sql b/prisma/migrations/20240330061939_add_splitwise_group_id/migration.sql deleted file mode 100644 index f9b2d8462..000000000 --- a/prisma/migrations/20240330061939_add_splitwise_group_id/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- AlterTable -ALTER TABLE "Group" ADD COLUMN "splitwiseGroupId" TEXT; diff --git a/prisma/migrations/20240330064009_add_splitwise_group_id_unique/migration.sql b/prisma/migrations/20240330064009_add_splitwise_group_id_unique/migration.sql deleted file mode 100644 index 54f464939..000000000 --- a/prisma/migrations/20240330064009_add_splitwise_group_id_unique/migration.sql +++ /dev/null @@ -1,8 +0,0 @@ -/* - Warnings: - - - A unique constraint covering the columns `[splitwiseGroupId]` on the table `Group` will be added. If there are existing duplicate values, this will fail. - -*/ --- CreateIndex -CREATE UNIQUE INDEX "Group_splitwiseGroupId_key" ON "Group"("splitwiseGroupId"); diff --git a/prisma/migrations/20241116203000_add_updated_by_for_expense/migration.sql b/prisma/migrations/20241116203000_add_updated_by_for_expense/migration.sql deleted file mode 100644 index c671e9063..000000000 --- a/prisma/migrations/20241116203000_add_updated_by_for_expense/migration.sql +++ /dev/null @@ -1,5 +0,0 @@ --- AlterTable -ALTER TABLE "Expense" ADD COLUMN "updatedBy" INTEGER; - --- AddForeignKey -ALTER TABLE "Expense" ADD CONSTRAINT "Expense_updatedBy_fkey" FOREIGN KEY ("updatedBy") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/migrations/20250206103642_add_simplify_debts/migration.sql b/prisma/migrations/20250206103642_add_simplify_debts/migration.sql deleted file mode 100644 index 5f6c3ff3b..000000000 --- a/prisma/migrations/20250206103642_add_simplify_debts/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- AlterTable -ALTER TABLE "Group" ADD COLUMN "simplifyDebts" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/migrations/20250526193958_migrate_amounts_to_bigint/migration.sql b/prisma/migrations/20250526193958_migrate_amounts_to_bigint/migration.sql deleted file mode 100644 index 7f3ea85f8..000000000 --- a/prisma/migrations/20250526193958_migrate_amounts_to_bigint/migration.sql +++ /dev/null @@ -1,11 +0,0 @@ --- AlterTable -ALTER TABLE "Balance" ALTER COLUMN "amount" SET DATA TYPE BIGINT; - --- AlterTable -ALTER TABLE "Expense" ALTER COLUMN "amount" SET DATA TYPE BIGINT; - --- AlterTable -ALTER TABLE "ExpenseParticipant" ALTER COLUMN "amount" SET DATA TYPE BIGINT; - --- AlterTable -ALTER TABLE "GroupBalance" ALTER COLUMN "amount" SET DATA TYPE BIGINT; diff --git a/prisma/migrations/20250628134635_add_user_language/migration.sql b/prisma/migrations/20250628134635_add_user_language/migration.sql deleted file mode 100644 index a2dd79089..000000000 --- a/prisma/migrations/20250628134635_add_user_language/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- AlterTable -ALTER TABLE "User" ADD COLUMN "preferredLanguage" TEXT NOT NULL DEFAULT ''; diff --git a/prisma/migrations/20250824205700_add_archived_at_to_group/migration.sql b/prisma/migrations/20250824205700_add_archived_at_to_group/migration.sql deleted file mode 100644 index 102a086ae..000000000 --- a/prisma/migrations/20250824205700_add_archived_at_to_group/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- AlterTable -ALTER TABLE "Group" ADD COLUMN "archivedAt" TIMESTAMP(3); diff --git a/prisma/migrations/20250905152203_currency_conversion/migration.sql b/prisma/migrations/20250905152203_currency_conversion/migration.sql deleted file mode 100644 index 9cb2f1756..000000000 --- a/prisma/migrations/20250905152203_currency_conversion/migration.sql +++ /dev/null @@ -1,30 +0,0 @@ -/* - Warnings: - - - A unique constraint covering the columns `[conversionToId]` on the table `Expense` will be added. If there are existing duplicate values, this will fail. - -*/ --- AlterEnum -ALTER TYPE "SplitType" ADD VALUE 'CURRENCY_CONVERSION'; - --- AlterTable -ALTER TABLE "Expense" ADD COLUMN "conversionToId" TEXT; - --- CreateTable -CREATE UNLOGGED TABLE "public"."CachedCurrencyRate" ( - "from" TEXT NOT NULL, - "to" TEXT NOT NULL, - "date" TIMESTAMP(3) NOT NULL, - "rate" DOUBLE PRECISION NOT NULL, - "lastFetched" TIMESTAMP(3) NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "CachedCurrencyRate_pkey" PRIMARY KEY ("from","to","date") -); - --- CreateIndex -CREATE UNIQUE INDEX "Expense_conversionToId_key" ON "Expense"("conversionToId"); - --- AddForeignKey -ALTER TABLE "Expense" ADD CONSTRAINT "Expense_conversionToId_fkey" FOREIGN KEY ("conversionToId") REFERENCES "Expense"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20250907160149_add_gocardless_bank_transaction_integration/migration.sql b/prisma/migrations/20250907160149_add_gocardless_bank_transaction_integration/migration.sql deleted file mode 100644 index 29c58d7f8..000000000 --- a/prisma/migrations/20250907160149_add_gocardless_bank_transaction_integration/migration.sql +++ /dev/null @@ -1,25 +0,0 @@ --- AlterTable -ALTER TABLE "Expense" ADD COLUMN "transactionId" TEXT; - --- AlterTable -ALTER TABLE "User" ADD COLUMN "bankingId" TEXT, -ADD COLUMN "obapiProviderId" TEXT; - --- CreateTable -CREATE UNLOGGED TABLE "CachedBankData" ( - "id" SERIAL NOT NULL, - "obapiProviderId" TEXT NOT NULL, - "data" TEXT NOT NULL, - "userId" INTEGER NOT NULL, - "lastFetched" TIMESTAMP(3) NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "CachedBankData_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE UNIQUE INDEX "CachedBankData_obapiProviderId_key" ON "CachedBankData"("obapiProviderId"); - --- AddForeignKey -ALTER TABLE "public"."CachedBankData" ADD CONSTRAINT "CachedBankData_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20250920192654_recurrence/migration.sql b/prisma/migrations/20250920192654_recurrence/migration.sql deleted file mode 100644 index b412b30e2..000000000 --- a/prisma/migrations/20250920192654_recurrence/migration.sql +++ /dev/null @@ -1,73 +0,0 @@ --- AlterTable -ALTER TABLE "public"."Expense" ADD COLUMN "recurrenceId" INTEGER; - --- CreateTable -CREATE TABLE "public"."ExpenseRecurrence" ( - "id" SERIAL NOT NULL, - "jobId" BIGINT NOT NULL, - "notified" BOOLEAN NOT NULL DEFAULT true, - - CONSTRAINT "ExpenseRecurrence_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE UNIQUE INDEX "ExpenseRecurrence_jobId_key" ON "public"."ExpenseRecurrence"("jobId"); - --- AddForeignKey -ALTER TABLE "public"."Expense" ADD CONSTRAINT "Expense_recurrenceId_fkey" FOREIGN KEY ("recurrenceId") REFERENCES "public"."ExpenseRecurrence"("id") ON DELETE CASCADE ON UPDATE CASCADE; - -CREATE OR REPLACE FUNCTION duplicate_expense_with_participants(original_expense_id UUID) -RETURNS UUID AS $$ -DECLARE - new_expense_id UUID; -BEGIN - -- STEP 1: Insert the new expense and get its new ID - INSERT INTO "Expense" ( - "paidBy", "addedBy", name, category, amount, "splitType", "expenseDate", "updatedAt", - currency, "groupId", "updatedBy", "recurrenceId" - ) - SELECT - "paidBy", "addedBy", name, category, amount, "splitType", now(), now(), - currency, "groupId", "updatedBy", "recurrenceId" - FROM "Expense" - WHERE id = original_expense_id - RETURNING id INTO new_expense_id; - -- STEP 2: Insert the new expense participants - INSERT INTO "ExpenseParticipant" ( - "expenseId", "userId", amount - ) - SELECT - new_expense_id, "userId", amount - FROM "ExpenseParticipant" - WHERE "expenseId" = original_expense_id; - -- STEP 3: Set notified to false in the ExpenseRecurrence table - UPDATE "ExpenseRecurrence" - SET notified = false - WHERE id = (SELECT "recurrenceId" FROM "Expense" WHERE id = original_expense_id); - -- STEP 4: Return the new expense ID - RETURN new_expense_id; -END; -$$ LANGUAGE plpgsql; - -DO $$ -BEGIN -IF current_database() NOT LIKE 'prisma_migrate_shadow_db%' THEN - CREATE EXTENSION IF NOT EXISTS pg_cron; -ELSE - CREATE SCHEMA IF NOT EXISTS cron; - CREATE TABLE IF NOT EXISTS cron.job ( - jobid BIGINT PRIMARY KEY, - schedule TEXT, - command TEXT, - nodename TEXT, - nodeport INTEGER, - database TEXT, - username TEXT, - active BOOLEAN, - jobname TEXT - ); -END IF; -END $$; - --- AddForeignKey -ALTER TABLE "public"."ExpenseRecurrence" ADD CONSTRAINT "ExpenseRecurrence_jobId_fkey" FOREIGN KEY ("jobId") REFERENCES "cron"."job"("jobid") ON DELETE CASCADE ON UPDATE CASCADE; \ No newline at end of file diff --git a/prisma/migrations/20250921172223_add_expense_uuid/migration.sql b/prisma/migrations/20250921172223_add_expense_uuid/migration.sql deleted file mode 100644 index 7e86f889d..000000000 --- a/prisma/migrations/20250921172223_add_expense_uuid/migration.sql +++ /dev/null @@ -1,48 +0,0 @@ -/* - Warnings: - - - The `conversionToId` column on the `Expense` table would be dropped and recreated. This will lead to data loss if there is data in the column. - - A unique constraint covering the columns `[uuidId]` on the table `Expense` will be added. If there are existing duplicate values, this will fail. - -*/ --- DropForeignKey -ALTER TABLE "public"."Expense" DROP CONSTRAINT "Expense_conversionToId_fkey"; - --- DropForeignKey -ALTER TABLE "public"."ExpenseNote" DROP CONSTRAINT "ExpenseNote_expenseId_fkey"; - --- DropForeignKey -ALTER TABLE "public"."ExpenseParticipant" DROP CONSTRAINT "ExpenseParticipant_expenseId_fkey"; - --- AlterTable -ALTER TABLE "public"."Expense" ADD COLUMN "uuidId" UUID DEFAULT gen_random_uuid(), -DROP COLUMN "conversionToId", -ADD COLUMN "conversionToId" UUID; - --- AlterTable -ALTER TABLE "public"."ExpenseNote" ADD COLUMN "expenseUuid" UUID; - --- AlterTable -ALTER TABLE "public"."ExpenseParticipant" ADD COLUMN "expenseUuid" UUID; - --- CreateIndex -CREATE UNIQUE INDEX "Expense_uuidId_key" ON "public"."Expense"("uuidId"); - --- CreateIndex -CREATE UNIQUE INDEX "Expense_conversionToId_key" ON "public"."Expense"("conversionToId"); - --- AddForeignKey -ALTER TABLE "public"."Expense" ADD CONSTRAINT "Expense_conversionToId_fkey" FOREIGN KEY ("conversionToId") REFERENCES "public"."Expense"("uuidId") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "public"."ExpenseParticipant" ADD CONSTRAINT "ExpenseParticipant_expenseUuid_fkey" FOREIGN KEY ("expenseUuid") REFERENCES "public"."Expense"("uuidId") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "public"."ExpenseNote" ADD CONSTRAINT "ExpenseNote_expenseUuid_fkey" FOREIGN KEY ("expenseUuid") REFERENCES "public"."Expense"("uuidId") ON DELETE CASCADE ON UPDATE CASCADE; - --- Migrate data -UPDATE "public"."Expense" SET "uuidId" = gen_random_uuid() WHERE "uuidId" IS NULL; - -UPDATE "public"."ExpenseParticipant" SET "expenseUuid" = (SELECT "uuidId" FROM "public"."Expense" WHERE "id" = "expenseId") WHERE "expenseUuid" IS NULL; - -UPDATE "public"."ExpenseNote" SET "expenseUuid" = (SELECT "uuidId" FROM "public"."Expense" WHERE "id" = "expenseId") WHERE "expenseUuid" IS NULL; diff --git a/prisma/migrations/20250921174004_replace_expense_cuid_with_uuid/migration.sql b/prisma/migrations/20250921174004_replace_expense_cuid_with_uuid/migration.sql deleted file mode 100644 index a5bb956db..000000000 --- a/prisma/migrations/20250921174004_replace_expense_cuid_with_uuid/migration.sql +++ /dev/null @@ -1,66 +0,0 @@ -/* - Warnings: - - - The primary key for the `Expense` table will be changed. If it partially fails, the table could be left without primary key constraint. - - You are about to drop the column `uuidId` on the `Expense` table. All the data in the column will be lost. - - The `id` column on the `Expense` table would be dropped and recreated. This will lead to data loss if there is data in the column. - - You are about to drop the column `expenseUuid` on the `ExpenseNote` table. All the data in the column will be lost. - - The primary key for the `ExpenseParticipant` table will be changed. If it partially fails, the table could be left without primary key constraint. - - You are about to drop the column `expenseUuid` on the `ExpenseParticipant` table. All the data in the column will be lost. - - Changed the type of `expenseId` on the `ExpenseNote` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required. - - Changed the type of `expenseId` on the `ExpenseParticipant` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required. - -*/ --- DropForeignKey -ALTER TABLE "public"."Expense" DROP CONSTRAINT "Expense_conversionToId_fkey"; - --- DropForeignKey -ALTER TABLE "public"."ExpenseNote" DROP CONSTRAINT "ExpenseNote_expenseUuid_fkey"; - --- DropForeignKey -ALTER TABLE "public"."ExpenseParticipant" DROP CONSTRAINT "ExpenseParticipant_expenseUuid_fkey"; - --- DropIndex -DROP INDEX "public"."Expense_uuidId_key"; - --- AlterTable: Expense - Drop primary key constraint first -ALTER TABLE "public"."Expense" DROP CONSTRAINT "Expense_pkey"; - --- AlterTable: Expense - Drop the old id column -ALTER TABLE "public"."Expense" DROP COLUMN "id"; - --- AlterTable: Expense - Rename uuidId to id -ALTER TABLE "public"."Expense" RENAME COLUMN "uuidId" TO "id"; - --- AlterTable: Expense - Add primary key constraint -ALTER TABLE "public"."Expense" ADD CONSTRAINT "Expense_pkey" PRIMARY KEY ("id"); - --- AlterTable: ExpenseNote - Drop old expenseId column -ALTER TABLE "public"."ExpenseNote" DROP COLUMN "expenseId"; - --- AlterTable: ExpenseNote - Rename expenseUuid to expenseId -ALTER TABLE "public"."ExpenseNote" RENAME COLUMN "expenseUuid" TO "expenseId"; - --- AlterTable: ExpenseParticipant - Drop primary key constraint first -ALTER TABLE "public"."ExpenseParticipant" DROP CONSTRAINT "ExpenseParticipant_pkey"; - --- AlterTable: ExpenseParticipant - Drop old expenseId column -ALTER TABLE "public"."ExpenseParticipant" DROP COLUMN "expenseId"; - --- AlterTable: ExpenseParticipant - Rename expenseUuid to expenseId -ALTER TABLE "public"."ExpenseParticipant" RENAME COLUMN "expenseUuid" TO "expenseId"; - --- AlterTable: ExpenseParticipant - Add primary key constraint -ALTER TABLE "public"."ExpenseParticipant" ADD CONSTRAINT "ExpenseParticipant_pkey" PRIMARY KEY ("expenseId", "userId"); - --- AlterTable -ALTER TABLE "public"."ExpenseNote" ALTER COLUMN "expenseId" SET NOT NULL; - --- AddForeignKey -ALTER TABLE "public"."Expense" ADD CONSTRAINT "Expense_conversionToId_fkey" FOREIGN KEY ("conversionToId") REFERENCES "public"."Expense"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "public"."ExpenseParticipant" ADD CONSTRAINT "ExpenseParticipant_expenseId_fkey" FOREIGN KEY ("expenseId") REFERENCES "public"."Expense"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "public"."ExpenseNote" ADD CONSTRAINT "ExpenseNote_expenseId_fkey" FOREIGN KEY ("expenseId") REFERENCES "public"."Expense"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20251108122842_add_balance_view/migration.sql b/prisma/migrations/20251108122842_add_balance_view/migration.sql deleted file mode 100644 index 7d0f444c6..000000000 --- a/prisma/migrations/20251108122842_add_balance_view/migration.sql +++ /dev/null @@ -1,74 +0,0 @@ -CREATE VIEW - "public"."BalanceView" AS - -- Use a Common Table Expression (CTE) to calculate the "one-way" balances. - -- This query is based on YOUR schema where `ep.amount < 0` is a debt. -WITH - "BaseBalance" AS ( - SELECT - CASE - WHEN ep."userId" < e."paidBy" THEN ep."userId" - ELSE e."paidBy" - END AS "user_id_A", -- The person who owes (debtor) - CASE - WHEN ep."userId" < e."paidBy" THEN e."paidBy" - ELSE ep."userId" - END AS "user_id_B", -- The person who is owed (creditor) - e."groupId", - e.currency, - SUM( - ep."amount" * ( - CASE - WHEN ep."userId" < e."paidBy" - -- A=participant, B=payer. A owes B. ep.amount is < 0. We need a NEGATIVE result. - THEN 1 - -- B=participant, A=payer. B owes A. ep.amount is < 0. We need a POSITIVE result. - ELSE -1 - END - ) - ) AS "net_amount", - MIN(e."createdAt") AS "createdAt", - MAX(e."updatedAt") AS "updatedAt" - FROM - "public"."ExpenseParticipant" AS ep - JOIN "public"."Expense" AS e ON ep."expenseId" = e."id" - WHERE - -- A user can't owe themselves - ep."userId" != e."paidBy" - AND e."deletedAt" IS NULL - GROUP BY - -- Note: We group by the original columns - "user_id_A", - "user_id_B", - e."groupId", - e.currency - ) - -- Query 1: The debtor's perspective (e.g., User 5, Friend 10, Amount -100) - -- This shows what "userId" owes to "friendId" -SELECT - "user_id_A" AS "userId", - "user_id_B" AS "friendId", - "groupId", - currency, - "net_amount" AS amount, - "createdAt", - "updatedAt" -FROM - "BaseBalance" -UNION ALL --- Query 2: The creditor's perspective (e.g., User 10, Friend 5, Amount +100) --- This shows what "userId" (the creditor) is owed by "friendId" (the debtor) -SELECT - "user_id_B" AS "userId", -- Swapped - "user_id_A" AS "friendId", -- Swapped - "groupId", - currency, - -- Invert the sign to show a positive balance (is owed) - - ("net_amount") AS amount, - "createdAt", - "updatedAt" -FROM - "BaseBalance"; - --- We may want to migrate this to a materialized view in the future for performance reasons. --- CREATE UNIQUE INDEX ON "public"."BalanceView" ("userId", "friendId", "groupId", currency); --- CREATE INDEX ON "public"."BalanceView" ("userId"); \ No newline at end of file diff --git a/prisma/migrations/20251119183616_switch_to_balance_view/migration.sql b/prisma/migrations/20251119183616_switch_to_balance_view/migration.sql deleted file mode 100644 index dad3fd679..000000000 --- a/prisma/migrations/20251119183616_switch_to_balance_view/migration.sql +++ /dev/null @@ -1,132 +0,0 @@ --- DropForeignKey -ALTER TABLE "public"."Balance" DROP CONSTRAINT "Balance_friendId_fkey"; - --- DropForeignKey -ALTER TABLE "public"."Balance" DROP CONSTRAINT "Balance_userId_fkey"; - --- DropForeignKey -ALTER TABLE "public"."GroupBalance" DROP CONSTRAINT "GroupBalance_firendId_fkey"; - --- DropForeignKey -ALTER TABLE "public"."GroupBalance" DROP CONSTRAINT "GroupBalance_groupId_fkey"; - --- DropForeignKey -ALTER TABLE "public"."GroupBalance" DROP CONSTRAINT "GroupBalance_userId_fkey"; - --- Adjust Splitwise imported balances to account for BalanceView only counting expenses -WITH "Differences" AS ( - SELECT - B."userId", - B."friendId", - B."currency", - -- Calculate the difference - (B."amount" - COALESCE(BV."amount", 0)) AS "diff_amount", - B."createdAt", - -- Pre-generate the Expense ID - gen_random_uuid() AS "new_expense_id" - FROM - "Balance" B - LEFT JOIN ( - SELECT "userId", "friendId", "currency", SUM("amount") as "amount" - FROM "BalanceView" - GROUP BY "userId", "friendId", "currency" - ) BV ON B."userId" = BV."userId" - AND B."friendId" = BV."friendId" - AND B."currency" = BV."currency" - WHERE - B."importedFromSplitwise" = true - AND B."userId" < B."friendId" - AND (B."amount" - COALESCE(BV."amount", 0)) != 0 -), -"InsertExpenses" AS ( - INSERT INTO "Expense" ( - "id", - "paidBy", - "addedBy", - "name", - "category", - "amount", - "currency", - "splitType", - "expenseDate", - "createdAt", - "updatedAt", - "groupId" - ) - SELECT - "new_expense_id", - -- Determine Payer: If diff < 0, Friend is creditor. If diff > 0, User is creditor. - CASE WHEN "diff_amount" < 0 THEN "friendId" ELSE "userId" END, - CASE WHEN "diff_amount" < 0 THEN "friendId" ELSE "userId" END, - 'Splitwise Balance Import', - 'general', - ABS("diff_amount"), -- Expense amount is always positive - "currency", - 'EXACT', - "createdAt", - "createdAt", - "createdAt", - NULL - FROM "Differences" - -- RETURNING needed info for the next step - RETURNING "id", "amount", "paidBy" -), -"InsertParticipants" AS ( - INSERT INTO "ExpenseParticipant" ( - "expenseId", - "userId", - "amount" - ) - -- Row 1: The Payer (Creditor) -> Positive Amount - SELECT - ie."id", - ie."paidBy", - ie."amount" -- Positive flow (they paid/are owed) - FROM "InsertExpenses" ie - - UNION ALL - -- Row 2: The Debtor -> Negative Amount - SELECT - ie."id", - -- The Debtor is whoever is NOT the payer. - CASE - WHEN ie."paidBy" = d."friendId" THEN d."userId" - ELSE d."friendId" - END, - -ie."amount" -- Negative flow (they owe) - FROM "InsertExpenses" ie - JOIN "Differences" d ON ie."id" = d."new_expense_id" -) -SELECT count(*) as "AdjustmentsCreated" FROM "InsertExpenses"; - --- AlterTable -ALTER TABLE "public"."User" ADD COLUMN "hiddenFriendIds" INTEGER[] DEFAULT ARRAY[]::INTEGER[]; - --- Function to remove a user ID from the hiddenFriendIds array -CREATE OR REPLACE FUNCTION public.auto_unhide_friend() -RETURNS TRIGGER AS $$ -DECLARE - payer_id INT; -BEGIN - SELECT "paidBy" INTO payer_id FROM "Expense" WHERE id = NEW."expenseId"; - -- ONLY update if the array actually contains the ID. - -- This prevents locking the row if the friend is already visible (which is 99% of cases). - UPDATE "User" - SET "hiddenFriendIds" = array_remove("hiddenFriendIds", payer_id) - WHERE id = NEW."userId" - AND "hiddenFriendIds" @> ARRAY[payer_id]; -- Only if array contains payer_id - UPDATE "User" - SET "hiddenFriendIds" = array_remove("hiddenFriendIds", NEW."userId") - WHERE id = payer_id - AND "hiddenFriendIds" @> ARRAY[NEW."userId"]; -- Only if array contains userId - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - --- Trigger definition -DROP TRIGGER IF EXISTS trigger_auto_unhide_friend ON "ExpenseParticipant"; - -CREATE TRIGGER trigger_auto_unhide_friend - AFTER INSERT ON "ExpenseParticipant" - FOR EACH ROW - EXECUTE FUNCTION public.auto_unhide_friend(); \ No newline at end of file diff --git a/prisma/migrations/20251231194929_fix_recurrence_cascade_to_setnull/migration.sql b/prisma/migrations/20251231194929_fix_recurrence_cascade_to_setnull/migration.sql deleted file mode 100644 index 33250cb62..000000000 --- a/prisma/migrations/20251231194929_fix_recurrence_cascade_to_setnull/migration.sql +++ /dev/null @@ -1,5 +0,0 @@ --- DropForeignKey -ALTER TABLE "Expense" DROP CONSTRAINT "Expense_recurrenceId_fkey"; - --- AddForeignKey -ALTER TABLE "Expense" ADD CONSTRAINT "Expense_recurrenceId_fkey" FOREIGN KEY ("recurrenceId") REFERENCES "ExpenseRecurrence"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/migrations/20260201130853_add_app_metadata/migration.sql b/prisma/migrations/20260201130853_add_app_metadata/migration.sql deleted file mode 100644 index 7e23a6224..000000000 --- a/prisma/migrations/20260201130853_add_app_metadata/migration.sql +++ /dev/null @@ -1,58 +0,0 @@ --- CreateTable -CREATE TABLE "AppMetadata" ( - "key" TEXT NOT NULL, - "value" TEXT NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "AppMetadata_pkey" PRIMARY KEY ("key") -); - --- CreateFunction: Historical balance calculation with date filter --- Same logic as BalanceView but accepts a date parameter to calculate balances as of that date -CREATE OR REPLACE FUNCTION public.get_balance_at_date(before_date TIMESTAMP WITH TIME ZONE) -RETURNS TABLE ( - "userId" INT, - "friendId" INT, - "groupId" INT, - currency TEXT, - amount BIGINT, - "createdAt" TIMESTAMP, - "updatedAt" TIMESTAMP -) AS $$ -BEGIN - RETURN QUERY - WITH "BaseBalance" AS ( - SELECT - CASE WHEN ep."userId" < e."paidBy" THEN ep."userId" ELSE e."paidBy" END AS "user_id_A", - CASE WHEN ep."userId" < e."paidBy" THEN e."paidBy" ELSE ep."userId" END AS "user_id_B", - e."groupId" AS grp_id, - e.currency AS curr, - SUM( - ep."amount" * ( - CASE WHEN ep."userId" < e."paidBy" THEN 1 ELSE -1 END - ) - )::BIGINT AS "net_amount", - MIN(e."createdAt") AS created, - MAX(e."updatedAt") AS updated - FROM "public"."ExpenseParticipant" AS ep - JOIN "public"."Expense" AS e ON ep."expenseId" = e."id" - WHERE - ep."userId" != e."paidBy" - AND e."deletedAt" IS NULL - AND e."createdAt" < before_date - GROUP BY "user_id_A", "user_id_B", e."groupId", e.currency - ) - -- Debtor's perspective - SELECT "user_id_A", "user_id_B", grp_id, curr, "net_amount", created, updated - FROM "BaseBalance" - UNION ALL - -- Creditor's perspective (mirrored) - SELECT "user_id_B", "user_id_A", grp_id, curr, -"net_amount", created, updated - FROM "BaseBalance"; -END; -$$ LANGUAGE plpgsql STABLE; - -COMMENT ON FUNCTION public.get_balance_at_date IS -'Returns balances as of a given date. Same logic as BalanceView but with date filtering. -Used for settlement migration and historical balance queries.'; diff --git a/prisma/migrations/20260209204650_add_cached_currency_rate_precision/migration.sql b/prisma/migrations/20260209204650_add_cached_currency_rate_precision/migration.sql deleted file mode 100644 index ab8bf090c..000000000 --- a/prisma/migrations/20260209204650_add_cached_currency_rate_precision/migration.sql +++ /dev/null @@ -1,6 +0,0 @@ --- Clear cached currency rates to repopulate with precision -TRUNCATE TABLE "CachedCurrencyRate"; - --- Add precision to cached currency rates -ALTER TABLE "CachedCurrencyRate" -ADD COLUMN "precision" INTEGER NOT NULL; diff --git a/prisma/migrations/20260210212119_revert_currency_rate_precision_column/migration.sql b/prisma/migrations/20260210212119_revert_currency_rate_precision_column/migration.sql deleted file mode 100644 index a79e37a9f..000000000 --- a/prisma/migrations/20260210212119_revert_currency_rate_precision_column/migration.sql +++ /dev/null @@ -1,8 +0,0 @@ -/* - Warnings: - - - You are about to drop the column `precision` on the `CachedCurrencyRate` table. All the data in the column will be lost. - -*/ --- AlterTable -ALTER TABLE "CachedCurrencyRate" DROP COLUMN "precision"; diff --git a/prisma/migrations/20260411120000_add_group_image/migration.sql b/prisma/migrations/20260411120000_add_group_image/migration.sql deleted file mode 100644 index 6aa87dd31..000000000 --- a/prisma/migrations/20260411120000_add_group_image/migration.sql +++ /dev/null @@ -1,3 +0,0 @@ --- AlterTable -ALTER TABLE "public"."Group" -ADD COLUMN "image" TEXT; diff --git a/prisma/migrations/20260412120000_default_splits/migration.sql b/prisma/migrations/20260412120000_default_splits/migration.sql deleted file mode 100644 index 6f2f64c84..000000000 --- a/prisma/migrations/20260412120000_default_splits/migration.sql +++ /dev/null @@ -1,31 +0,0 @@ --- CreateTable -CREATE TABLE "public"."GroupDefaultSplit" ( - "groupId" INTEGER NOT NULL, - "splitType" "public"."SplitType" NOT NULL, - "shares" JSONB NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "GroupDefaultSplit_pkey" PRIMARY KEY ("groupId") -); - --- CreateTable -CREATE TABLE "public"."FriendDefaultSplit" ( - "userAId" INTEGER NOT NULL, - "userBId" INTEGER NOT NULL, - "splitType" "public"."SplitType" NOT NULL, - "shares" JSONB NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "FriendDefaultSplit_pkey" PRIMARY KEY ("userAId","userBId") -); - --- AddForeignKey -ALTER TABLE "public"."GroupDefaultSplit" ADD CONSTRAINT "GroupDefaultSplit_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "public"."Group"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "public"."FriendDefaultSplit" ADD CONSTRAINT "FriendDefaultSplit_userAId_fkey" FOREIGN KEY ("userAId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "public"."FriendDefaultSplit" ADD CONSTRAINT "FriendDefaultSplit_userBId_fkey" FOREIGN KEY ("userBId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260412120000_push_notification_multi_device/migration.sql b/prisma/migrations/20260412120000_push_notification_multi_device/migration.sql deleted file mode 100644 index 7f3d3df76..000000000 --- a/prisma/migrations/20260412120000_push_notification_multi_device/migration.sql +++ /dev/null @@ -1,21 +0,0 @@ --- Add endpoint column first so we can backfill values from subscription JSON. -ALTER TABLE "public"."PushNotification" -ADD COLUMN "endpoint" TEXT; - --- Backfill endpoint from the serialized PushSubscription payload. -UPDATE "public"."PushNotification" -SET "endpoint" = "subscription"::jsonb ->> 'endpoint'; - --- Drop rows with malformed legacy payloads that don't include endpoint. -DELETE FROM "public"."PushNotification" -WHERE "endpoint" IS NULL OR '' = "endpoint"; - --- Replace single-device PK with multi-device composite PK. -ALTER TABLE "public"."PushNotification" -DROP CONSTRAINT "PushNotification_pkey"; - -ALTER TABLE "public"."PushNotification" -ALTER COLUMN "endpoint" SET NOT NULL; - -ALTER TABLE "public"."PushNotification" -ADD CONSTRAINT "PushNotification_pkey" PRIMARY KEY ("userId", "endpoint"); diff --git a/prisma/migrations/20260425135751_default_currency_fields/migration.sql b/prisma/migrations/20260425135751_default_currency_fields/migration.sql deleted file mode 100644 index 9603cc6c8..000000000 --- a/prisma/migrations/20260425135751_default_currency_fields/migration.sql +++ /dev/null @@ -1,6 +0,0 @@ --- AlterTable -ALTER TABLE "Group" ALTER COLUMN "defaultCurrency" DROP NOT NULL, -ALTER COLUMN "defaultCurrency" DROP DEFAULT; - --- AlterTable -ALTER TABLE "User" ADD COLUMN "defaultCurrency" TEXT; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml deleted file mode 100644 index 044d57cdb..000000000 --- a/prisma/migrations/migration_lock.toml +++ /dev/null @@ -1,3 +0,0 @@ -# Please do not edit this file manually -# It should be added in your version-control system (e.g., Git) -provider = "postgresql" diff --git a/prisma/schema.prisma b/prisma/schema.prisma deleted file mode 100644 index 576ece269..000000000 --- a/prisma/schema.prisma +++ /dev/null @@ -1,346 +0,0 @@ -generator client { - provider = "prisma-client-js" - previewFeatures = ["relationJoins", "views"] -} - -datasource db { - provider = "postgresql" - url = env("DATABASE_URL") - schemas = ["cron", "public"] -} - -model Account { - id String @id @default(cuid()) - userId Int - type String - provider String - providerAccountId String - refresh_token String? - access_token String? - expires_at Int? - token_type String? - scope String? - id_token String? - session_state String? - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - @@unique([provider, providerAccountId]) - @@schema("public") -} - -model Session { - id String @id @default(cuid()) - sessionToken String @unique - userId Int - expires DateTime - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - @@schema("public") -} - -model User { - id Int @id @default(autoincrement()) - name String? - email String? @unique - emailVerified DateTime? - image String? - currency String @default("USD") - defaultCurrency String? - preferredLanguage String @default("") - bankingId String? - obapiProviderId String? - accounts Account[] - friendBalances BalanceView[] @relation("FriendBalanceView") - userBalances BalanceView[] @relation("UserBalanceView") - cachedBankData CachedBankData[] @relation("UserCachedBankData") - addedExpenses Expense[] @relation("AddedByUser") - deletedExpenses Expense[] @relation("DeletedByUser") - paidExpenses Expense[] @relation("PaidByUser") - updatedExpenses Expense[] @relation("UpdatedByUser") - expenseNotes ExpenseNote[] - expenseParticipants ExpenseParticipant[] - groups Group[] - associatedGroups GroupUser[] - friendDefaultSplitsA FriendDefaultSplit[] @relation("FriendDefaultSplitUserA") - friendDefaultSplitsB FriendDefaultSplit[] @relation("FriendDefaultSplitUserB") - sessions Session[] - hiddenFriendIds Int[] @default([]) - - @@schema("public") -} - -model VerificationToken { - identifier String - token String @unique - expires DateTime - - @@unique([identifier, token]) - @@schema("public") -} - -// Deprecated in favor of BalanceView. Kept to preserve existing data for now. -model Balance { - userId Int - currency String - friendId Int - amount BigInt - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - importedFromSplitwise Boolean @default(false) - // friend User @relation("FriendBalance", fields: [friendId], references: [id], onDelete: Cascade) - // user User @relation("UserBalance", fields: [userId], references: [id], onDelete: Cascade) - - @@id([userId, currency, friendId]) - @@schema("public") -} - -model Group { - id Int @id @default(autoincrement()) - publicId String @unique - name String - image String? - userId Int - defaultCurrency String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - splitwiseGroupId String? @unique - simplifyDebts Boolean @default(false) - archivedAt DateTime? - expenses Expense[] - createdBy User @relation(fields: [userId], references: [id], onDelete: Cascade) - groupUsers GroupUser[] - groupBalances BalanceView[] - groupDefaultSplit GroupDefaultSplit? - - @@schema("public") -} - -model GroupDefaultSplit { - groupId Int @id - splitType SplitType - shares Json - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - group Group @relation(fields: [groupId], references: [id], onDelete: Cascade) - - @@schema("public") -} - -model FriendDefaultSplit { - userAId Int - userBId Int - splitType SplitType - shares Json - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - userA User @relation("FriendDefaultSplitUserA", fields: [userAId], references: [id], onDelete: Cascade) - userB User @relation("FriendDefaultSplitUserB", fields: [userBId], references: [id], onDelete: Cascade) - - @@id([userAId, userBId]) - @@schema("public") -} - -model GroupUser { - groupId Int - userId Int - group Group @relation(fields: [groupId], references: [id], onDelete: Cascade) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - @@id([groupId, userId]) - @@schema("public") -} - -// Deprecated in favor of BalanceView. Kept to preserve existing data for now. -model GroupBalance { - groupId Int - currency String - userId Int - firendId Int - amount BigInt - updatedAt DateTime @updatedAt - // friend User @relation("GroupFriendBalance", fields: [firendId], references: [id], onDelete: Cascade) - // group Group @relation(fields: [groupId], references: [id], onDelete: Cascade) - // user User @relation("GroupUserBalance", fields: [userId], references: [id], onDelete: Cascade) - - @@id([groupId, currency, firendId, userId]) - @@schema("public") -} - -model Expense { - paidBy Int - addedBy Int - name String - category String - amount BigInt - splitType SplitType @default(EQUAL) - expenseDate DateTime @default(now()) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - currency String - fileKey String? - groupId Int? - deletedAt DateTime? - deletedBy Int? - updatedBy Int? - transactionId String? - recurrenceId Int? - id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid - conversionToId String? @unique @db.Uuid - addedByUser User @relation("AddedByUser", fields: [addedBy], references: [id], onDelete: Cascade) - deletedByUser User? @relation("DeletedByUser", fields: [deletedBy], references: [id], onDelete: Cascade) - group Group? @relation(fields: [groupId], references: [id], onDelete: Cascade) - conversionTo Expense? @relation("CurrencyConversion", fields: [conversionToId], references: [id], onDelete: Cascade) - conversionFrom Expense? @relation("CurrencyConversion") - paidByUser User @relation("PaidByUser", fields: [paidBy], references: [id], onDelete: Cascade) - recurrence ExpenseRecurrence? @relation(fields: [recurrenceId], references: [id], onDelete: SetNull) - updatedByUser User? @relation("UpdatedByUser", fields: [updatedBy], references: [id]) - expenseNotes ExpenseNote[] - expenseParticipants ExpenseParticipant[] - - @@index([groupId]) - @@index([paidBy]) - @@schema("public") -} - -model ExpenseParticipant { - userId Int - amount BigInt - expenseId String @db.Uuid - expense Expense @relation(fields: [expenseId], references: [id], onDelete: Cascade) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - @@id([expenseId, userId]) - @@schema("public") -} - -model ExpenseNote { - id String @id @default(cuid()) - note String - createdById Int - createdAt DateTime @default(now()) - expenseId String @db.Uuid - createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade) - expense Expense @relation(fields: [expenseId], references: [id], onDelete: Cascade) - - @@schema("public") -} - -model ExpenseRecurrence { - id Int @id @default(autoincrement()) - jobId BigInt @unique - notified Boolean @default(true) - expense Expense[] - job job @relation(fields: [jobId], references: [jobid], onDelete: Cascade) - - @@schema("public") -} - -model CachedCurrencyRate { - from String - to String - date DateTime - rate Float - lastFetched DateTime - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@id([from, to, date]) - @@schema("public") -} - -model CachedBankData { - id Int @id @default(autoincrement()) - obapiProviderId String @unique - data String - userId Int - lastFetched DateTime - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - user User @relation("UserCachedBankData", fields: [userId], references: [id], onDelete: Cascade) - - @@schema("public") -} - -model PushNotification { - userId Int - endpoint String - subscription String - - @@id([userId, endpoint]) - @@schema("public") -} - -model AppMetadata { - key String @id - value String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@schema("public") -} - -/// This model contains row level security and requires additional setup for migrations. Visit https://pris.ly/d/row-level-security for more info. -model job { - jobid BigInt @id @default(autoincrement()) - schedule String - command String - nodename String @default("localhost") - nodeport Int @default(dbgenerated("inet_server_port()")) - database String @default(dbgenerated("current_database()")) - username String @default(dbgenerated("CURRENT_USER")) - active Boolean @default(true) - jobname String? - recurrence ExpenseRecurrence? @relation() - runDetails job_run_details[] @relation("job_run_details") - - @@unique([jobname, username], map: "jobname_username_uniq") - @@schema("cron") -} - -/// This model contains row level security and requires additional setup for migrations. Visit https://pris.ly/d/row-level-security for more info. -model job_run_details { - jobid BigInt? - runid BigInt @id @default(autoincrement()) - job_pid Int? - database String? - username String? - command String? - status String? - return_message String? - start_time DateTime? @db.Timestamptz(6) - end_time DateTime? @db.Timestamptz(6) - - job job? @relation("job_run_details", fields: [jobid], references: [jobid], onDelete: Cascade) - - @@schema("cron") -} - -/// Unified balance view that calculates balances from expenses -/// Replaces both Balance and GroupBalance tables -view BalanceView { - userId Int - friendId Int - groupId Int? - currency String - amount BigInt - createdAt DateTime - updatedAt DateTime - - user User @relation("UserBalanceView", fields: [userId], references: [id], onDelete: Cascade) - friend User @relation("FriendBalanceView", fields: [friendId], references: [id], onDelete: Cascade) - group Group? @relation(fields: [groupId], references: [id], onDelete: Cascade) - - @@schema("public") -} - -enum SplitType { - EQUAL - PERCENTAGE - EXACT - SHARE - ADJUSTMENT - SETTLEMENT - CURRENCY_CONVERSION - - @@schema("public") -} diff --git a/prisma/seed-checksum.txt b/prisma/seed-checksum.txt deleted file mode 100644 index 119c95043..000000000 --- a/prisma/seed-checksum.txt +++ /dev/null @@ -1 +0,0 @@ -157cf91ec892d975f64aaf8ef567b06bdc5cfbd238a1ff3c59f92cdc4081d77f diff --git a/prisma/seed.ts b/prisma/seed.ts deleted file mode 100644 index 0102dff31..000000000 --- a/prisma/seed.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { PrismaClient, SplitType } from '@prisma/client'; - -import { createExpense, deleteExpense, editExpense } from '~/server/api/services/splitService'; -import { dummyData } from '~/dummies'; -import { calculateParticipantSplit } from '~/store/addStore'; -import assert from 'node:assert'; -import { settleBalances } from './seedSettlement'; - -const prisma = new PrismaClient(); - -async function createUsers() { - await prisma.user.createMany({ - data: dummyData.users, - }); - - console.log('Finished creating users'); - - return prisma.user.findMany(); -} - -async function createGroups() { - await Promise.all( - dummyData.groups.map(({ members, type, ...group }) => - prisma.group.create({ - data: { - ...group, - groupUsers: { - create: members.map((member) => ({ - userId: member.id, - })), - }, - }, - }), - ), - ); - - console.log('Finished creating groups'); - - return prisma.group.findMany(); -} - -const idLookup: Map = new Map(); - -async function createExpenses() { - await Promise.all( - dummyData.expenses.map(async (expense, idx) => { - const res = await createExpense( - { - ...expense, - paidBy: expense.paidBy.id, - participants: calculateParticipantSplit(expense as any).participants.map((p) => ({ - userId: p.id, - amount: p.amount ?? 0n, - })), - }, - expense.addedBy, - ); - - await prisma.expense.update({ - where: { - id: res!.id, - }, - data: { - createdAt: expense.createdAt, - }, - }); - - idLookup.set(idx, res!.id); - }), - ); - - console.log('Finished creating expenses'); - - return prisma.expense.findMany({ include: { expenseParticipants: true } }); -} - -async function editExpenses() { - await Promise.all( - dummyData.expenseEdits.map(async ({ idx, ...expense }) => { - assert(idLookup.get(idx), `No expense ID found for index ${idx}`); - await editExpense( - { - ...expense, - expenseId: idLookup.get(idx), - paidBy: expense.paidBy.id, - participants: calculateParticipantSplit(expense as any).participants.map((p) => ({ - userId: p.id, - amount: p.amount ?? 0n, - })), - }, - expense.updatedBy.id, - ); - }), - ); - - console.log('Finished editing expenses'); - - return prisma.expense.findMany({ include: { expenseParticipants: true } }); -} - -async function deleteExpenses() { - await Promise.all( - dummyData.expensesToDelete.map(async ({ idx, deletedBy }) => { - assert(idLookup.get(idx), `No expense ID found for index ${idx}`); - await deleteExpense(idLookup.get(idx)!, deletedBy.id); - }), - ); - - console.log('Finished deleting expenses'); - - return prisma.expense.findMany({ include: { expenseParticipants: true } }); -} - -async function main() { - // await prisma.user.deleteMany(); - // await prisma.expense.deleteMany(); - // await prisma.expenseParticipant.deleteMany(); - // await prisma.group.deleteMany(); - // await prisma.groupUser.deleteMany(); - // await prisma.groupBalance.deleteMany(); - await createUsers(); - await createGroups(); - await createExpenses(); - await editExpenses(); - await deleteExpenses(); - await settleBalances(prisma, dummyData.balancesToSettle); -} - -main() - .catch((e) => { - console.error(e); - process.exit(1); - }) - .finally(() => { - prisma.$disconnect().catch(console.log); - }); diff --git a/prisma/seedSettlement.ts b/prisma/seedSettlement.ts deleted file mode 100644 index d9194220f..000000000 --- a/prisma/seedSettlement.ts +++ /dev/null @@ -1,329 +0,0 @@ -import type { PrismaClient } from '@prisma/client'; -import { SplitType } from '@prisma/client'; - -import { type SettleupCandidate } from '~/dummies/expenseGenerator'; -import { createExpense } from '~/server/api/services/splitService'; -import { DEFAULT_CATEGORY } from '~/lib/category'; -import { BigMath } from '~/utils/numbers'; - -interface BalanceAtDate { - userId: number; - friendId: number; - groupId: number | null; - currency: string; - amount: bigint; -} - -interface ExpenseForSettlement { - groupId: number | null; - currency: string; - paidBy: number; - createdAt: Date; - expenseParticipants: { - userId: number; - }[]; -} - -interface TimeWindow { - from: Date; - to: Date; - count: number; -} - -interface SettlementEvent { - at: Date; - kind: 'group' | 'direct'; - groupId: number | null; - directKey: string | null; -} - -const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max); - -const normalizePair = (userA: number, userB: number) => - userA < userB ? ([userA, userB] as const) : ([userB, userA] as const); - -const toCandidateKey = ( - userId: number, - friendId: number, - groupId: number | null, - currency: string, - kind: 'group' | 'direct', -) => { - const [left, right] = normalizePair(userId, friendId); - return [kind, left, right, groupId ?? 'null', currency].join('-'); -}; - -const toDirectWindowKey = (userId: number, friendId: number, currency: string) => { - const [left, right] = normalizePair(userId, friendId); - return `${left}-${right}-${currency}`; -}; - -const buildDatesFromWindow = (window: TimeWindow, targetCount: number) => { - const fromMs = window.from.getTime(); - const toMs = window.to.getTime(); - - if (fromMs >= toMs) { - return [new Date(fromMs)]; - } - - const totalMs = toMs - fromMs; - const uniqueDates: Date[] = []; - const seen = new Set(); - - for (let i = 1; i <= targetCount; i++) { - const ratio = i / (targetCount + 1); - const lateBiasedRatio = ratio ** 0.75; - const timestamp = Math.floor(fromMs + totalMs * lateBiasedRatio) + i; - - if (seen.has(timestamp)) { - // oxlint-disable-next-line no-continue - continue; - } - - seen.add(timestamp); - uniqueDates.push(new Date(timestamp)); - } - - if (0 === uniqueDates.length) { - return [new Date(fromMs + 1)]; - } - - return uniqueDates.sort((a, b) => a.getTime() - b.getTime()); -}; - -const buildWindows = (expenses: ExpenseForSettlement[]) => { - const groupWindows = new Map(); - const directWindows = new Map(); - - expenses.forEach((expense) => { - if (null !== expense.groupId) { - const existing = groupWindows.get(expense.groupId); - if (!existing) { - groupWindows.set(expense.groupId, { - from: expense.createdAt, - to: expense.createdAt, - count: 1, - }); - return; - } - - groupWindows.set(expense.groupId, { - from: - existing.from.getTime() < expense.createdAt.getTime() ? existing.from : expense.createdAt, - to: existing.to.getTime() > expense.createdAt.getTime() ? existing.to : expense.createdAt, - count: existing.count + 1, - }); - return; - } - - const participants = expense.expenseParticipants - .map((participant) => participant.userId) - .filter((participantId) => participantId !== expense.paidBy) - .map((participantId) => toDirectWindowKey(expense.paidBy, participantId, expense.currency)); - - participants.forEach((windowKey) => { - const existing = directWindows.get(windowKey); - if (!existing) { - directWindows.set(windowKey, { - from: expense.createdAt, - to: expense.createdAt, - count: 1, - }); - return; - } - - directWindows.set(windowKey, { - from: - existing.from.getTime() < expense.createdAt.getTime() ? existing.from : expense.createdAt, - to: existing.to.getTime() > expense.createdAt.getTime() ? existing.to : expense.createdAt, - count: existing.count + 1, - }); - }); - }); - - return { groupWindows, directWindows }; -}; - -const buildSettlementEvents = ( - candidates: SettleupCandidate[], - windows: ReturnType, -) => { - const events: SettlementEvent[] = []; - const groupIds = new Set( - candidates - .filter((candidate) => 'group' === candidate.kind) - .map((candidate) => candidate.groupId), - ); - - groupIds.forEach((groupId) => { - if (null === groupId) { - return; - } - - const window = windows.groupWindows.get(groupId); - if (!window) { - return; - } - - const eventCount = clamp(Math.floor(window.count / 20), 1, 6); - const dates = buildDatesFromWindow(window, eventCount); - - dates.forEach((date) => { - events.push({ - at: date, - kind: 'group', - groupId, - directKey: null, - }); - }); - }); - - candidates - .filter((candidate) => 'direct' === candidate.kind) - .forEach((candidate) => { - const directKey = toDirectWindowKey(candidate.userId, candidate.friendId, candidate.currency); - const window = windows.directWindows.get(directKey); - if (!window) { - return; - } - - const eventCount = clamp(Math.floor(window.count / 15), 1, 4); - const dates = buildDatesFromWindow(window, eventCount); - - dates.forEach((date) => { - events.push({ - at: date, - kind: 'direct', - groupId: null, - directKey, - }); - }); - }); - - return events.sort((left, right) => left.at.getTime() - right.at.getTime()); -}; - -export const settleBalances = async ( - prisma: PrismaClient, - settleCandidates: SettleupCandidate[], -) => { - const candidateMap = new Map( - settleCandidates.map((candidate) => [ - toCandidateKey( - candidate.userId, - candidate.friendId, - candidate.groupId, - candidate.currency, - candidate.kind, - ), - candidate, - ]), - ); - - const expenses = await prisma.expense.findMany({ - where: { - deletedAt: null, - splitType: { - not: SplitType.SETTLEMENT, - }, - }, - select: { - groupId: true, - currency: true, - paidBy: true, - createdAt: true, - expenseParticipants: { - select: { - userId: true, - }, - }, - }, - }); - - const windows = buildWindows(expenses); - const events = buildSettlementEvents(settleCandidates, windows); - - await events.reduce>(async (eventChain, event) => { - await eventChain; - - const balancesAtDate = await prisma.$queryRaw` - SELECT "userId", "friendId", "groupId", currency, amount - FROM get_balance_at_date(${event.at}) - WHERE amount != 0 - AND "userId" < "friendId" - `; - - const relevantBalances = balancesAtDate.filter((balance) => { - if ('group' === event.kind && event.groupId !== balance.groupId) { - return false; - } - - if ('direct' === event.kind) { - if (null !== balance.groupId) { - return false; - } - - const directKey = toDirectWindowKey(balance.userId, balance.friendId, balance.currency); - return event.directKey === directKey; - } - - return true; - }); - - await Promise.all( - relevantBalances.map(async (balance) => { - const candidateKind = null === balance.groupId ? 'direct' : 'group'; - const key = toCandidateKey( - balance.userId, - balance.friendId, - balance.groupId, - balance.currency, - candidateKind, - ); - - const candidate = candidateMap.get(key); - if (!candidate || 0n === balance.amount) { - return; - } - - const sender = 0n > balance.amount ? candidate.friendId : candidate.userId; - const receiver = 0n > balance.amount ? candidate.userId : candidate.friendId; - - const settlementExpense = await createExpense( - { - name: 'Settle up', - amount: BigMath.abs(balance.amount), - currency: balance.currency, - splitType: SplitType.SETTLEMENT, - groupId: balance.groupId, - participants: [ - { - userId: sender, - amount: balance.amount, - }, - { - userId: receiver, - amount: -balance.amount, - }, - ], - paidBy: sender, - category: DEFAULT_CATEGORY, - expenseDate: event.at, - }, - sender, - ); - - await prisma.expense.update({ - where: { - id: settlementExpense.id, - }, - data: { - createdAt: event.at, - }, - }); - }), - ); - }, Promise.resolve()); - - console.log('Finished settling balances'); -}; diff --git a/prisma/views/public/BalanceView.sql b/prisma/views/public/BalanceView.sql deleted file mode 100644 index 079cd5695..000000000 --- a/prisma/views/public/BalanceView.sql +++ /dev/null @@ -1,66 +0,0 @@ -WITH "BaseBalance" AS ( - SELECT - CASE - WHEN (ep."userId" < e."paidBy") THEN ep."userId" - ELSE e."paidBy" - END AS "user_id_A", - CASE - WHEN (ep."userId" < e."paidBy") THEN e."paidBy" - ELSE ep."userId" - END AS "user_id_B", - e."groupId", - e.currency, - sum( - ( - ep.amount * CASE - WHEN (ep."userId" < e."paidBy") THEN 1 - ELSE '-1' :: integer - END - ) - ) AS net_amount, - min(e."createdAt") AS "createdAt", - max(e."updatedAt") AS "updatedAt" - FROM - ( - "ExpenseParticipant" ep - JOIN "Expense" e ON ((ep."expenseId" = e.id)) - ) - WHERE - ( - (ep."userId" <> e."paidBy") - AND (e."deletedAt" IS NULL) - ) - GROUP BY - CASE - WHEN (ep."userId" < e."paidBy") THEN ep."userId" - ELSE e."paidBy" - END, - CASE - WHEN (ep."userId" < e."paidBy") THEN e."paidBy" - ELSE ep."userId" - END, - e."groupId", - e.currency -) -SELECT - "BaseBalance"."user_id_A" AS "userId", - "BaseBalance"."user_id_B" AS "friendId", - "BaseBalance"."groupId", - "BaseBalance".currency, - "BaseBalance".net_amount AS amount, - "BaseBalance"."createdAt", - "BaseBalance"."updatedAt" -FROM - "BaseBalance" -UNION -ALL -SELECT - "BaseBalance"."user_id_B" AS "userId", - "BaseBalance"."user_id_A" AS "friendId", - "BaseBalance"."groupId", - "BaseBalance".currency, - (- "BaseBalance".net_amount) AS amount, - "BaseBalance"."createdAt", - "BaseBalance"."updatedAt" -FROM - "BaseBalance"; \ No newline at end of file diff --git a/src/components/Account/DebugInfo.tsx b/src/components/Account/DebugInfo.tsx index 6f097d27a..b26f23925 100644 --- a/src/components/Account/DebugInfo.tsx +++ b/src/components/Account/DebugInfo.tsx @@ -29,7 +29,7 @@ export const DebugInfo: React.FC = ({ children }) => { const response = await fetch( 'https://api.github.com/repos/oss-apps/split-pro/releases/latest', ); - const data = await response.json(); + const data = (await response.json()) as { tag_name?: string }; if (data.tag_name) { setNewVersion(data.tag_name); } diff --git a/src/components/AddExpense/AddExpensePage.tsx b/src/components/AddExpense/AddExpensePage.tsx index 86eec7494..77ce43b43 100644 --- a/src/components/AddExpense/AddExpensePage.tsx +++ b/src/components/AddExpense/AddExpensePage.tsx @@ -27,7 +27,7 @@ import { CurrencyInput } from '../ui/currency-input'; import { CurrencyConversion } from '../Friend/CurrencyConversion'; import { currencyConversion } from '~/utils/numbers'; import { CurrencyConversionIcon } from '../ui/categoryIcons'; -import { useSession } from 'next-auth/react'; +import { useSession } from '~/lib/auth-client'; export const AddOrEditExpensePage: React.FC<{ enableSendingInvites: boolean; diff --git a/src/components/AddExpense/BankTransactions/BankingTransactionList.tsx b/src/components/AddExpense/BankTransactions/BankingTransactionList.tsx index afe022fbe..7ef71998e 100644 --- a/src/components/AddExpense/BankTransactions/BankingTransactionList.tsx +++ b/src/components/AddExpense/BankTransactions/BankingTransactionList.tsx @@ -41,7 +41,7 @@ export const BankingTransactionList: React.FC<{ const userQuery = api.user.me.useQuery(); const transactions = api.bankTransactions.getTransactions.useQuery( - userQuery.data?.obapiProviderId, + userQuery.data?.obapiProviderId ?? undefined, { enabled: bankConnectionEnabled && !!userQuery.data?.obapiProviderId, }, @@ -113,7 +113,7 @@ export const BankingTransactionList: React.FC<{ setMultipleTransactions( isInMultipleTransactions ? multipleTransactions.filter((cItem) => cItem.transactionId !== item.transactionId) - : [...multipleTransactions, transactionData as TransactionAddInputModel], + : [...multipleTransactions, transactionData], ); } else { if (alreadyAdded(item.transactionId)) { diff --git a/src/components/AddExpense/SelectUserOrGroup.tsx b/src/components/AddExpense/SelectUserOrGroup.tsx index 85ef2265b..0a9dac7e6 100644 --- a/src/components/AddExpense/SelectUserOrGroup.tsx +++ b/src/components/AddExpense/SelectUserOrGroup.tsx @@ -1,6 +1,6 @@ import { CheckIcon } from '@heroicons/react/24/outline'; import { UserPlusIcon } from '@heroicons/react/24/solid'; -import type { Group, GroupUser, SplitType, User } from '@prisma/client'; +import type { Group, GroupUser, SplitType, User } from '~/server/db/types'; import { SendIcon } from 'lucide-react'; import { useTranslation } from 'next-i18next'; import Image from 'next/image'; @@ -62,7 +62,7 @@ export const SelectUserOrGroup: React.FC<{ id: -1, name: nameOrEmail, email: nameOrEmail, - emailVerified: new Date(), + emailVerified: true, image: null, currency: 'USD', defaultCurrency: null, diff --git a/src/components/AddExpense/SplitTypeSection.tsx b/src/components/AddExpense/SplitTypeSection.tsx index 768915577..1d1c8f273 100644 --- a/src/components/AddExpense/SplitTypeSection.tsx +++ b/src/components/AddExpense/SplitTypeSection.tsx @@ -1,4 +1,4 @@ -import { SplitType } from '@prisma/client'; +import { SplitType } from '~/server/db/types'; import { clsx } from 'clsx'; import { BarChart2, diff --git a/src/components/AddExpense/UserInput.tsx b/src/components/AddExpense/UserInput.tsx index e200c7c3b..782a394d1 100644 --- a/src/components/AddExpense/UserInput.tsx +++ b/src/components/AddExpense/UserInput.tsx @@ -59,7 +59,7 @@ export const UserInput: React.FC<{ id: -1, name: nameOrEmail, email: nameOrEmail, - emailVerified: new Date(), + emailVerified: true, image: null, currency: 'USD', defaultCurrency: null, diff --git a/src/components/DefaultSplit/DefaultSplitSettings.tsx b/src/components/DefaultSplit/DefaultSplitSettings.tsx index 9fc55c798..816604b17 100644 --- a/src/components/DefaultSplit/DefaultSplitSettings.tsx +++ b/src/components/DefaultSplit/DefaultSplitSettings.tsx @@ -1,4 +1,4 @@ -import { SplitType } from '@prisma/client'; +import { SplitType } from '~/server/db/types'; import React, { useCallback, useMemo } from 'react'; import { diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx new file mode 100644 index 000000000..faaf86691 --- /dev/null +++ b/src/components/ErrorBoundary.tsx @@ -0,0 +1,51 @@ +import { Component, type ErrorInfo, type ReactNode } from 'react'; + +import { reportClientError } from '~/lib/report-client-error'; + +interface Props { + children: ReactNode; +} + +interface State { + failed: boolean; +} + +/** Catches render errors, reports them to Workers Logs, and shows a reload prompt. */ +export class ErrorBoundary extends Component { + state: State = { failed: false }; + + static getDerivedStateFromError(): State { + return { failed: true }; + } + + componentDidCatch(error: Error, info: ErrorInfo) { + reportClientError({ + kind: 'react-boundary', + message: error.message, + name: error.name, + stack: error.stack, + componentStack: info.componentStack ?? undefined, + }); + } + + render() { + if (this.state.failed) { + return ( +
+

Something went wrong.

+

+ The error was reported. Try reloading the page. +

+ +
+ ); + } + return this.props.children; + } +} diff --git a/src/components/Expense/BalanceList.tsx b/src/components/Expense/BalanceList.tsx index 311ab2992..e9c277d0e 100644 --- a/src/components/Expense/BalanceList.tsx +++ b/src/components/Expense/BalanceList.tsx @@ -1,4 +1,4 @@ -import type { BalanceView, User } from '@prisma/client'; +import type { BalanceView, User } from '~/server/db/types'; import { clsx } from 'clsx'; import { type ComponentProps, Fragment, useCallback, useMemo } from 'react'; import { EntityAvatar } from '~/components/ui/avatar'; diff --git a/src/components/Expense/ExpenseDetails.tsx b/src/components/Expense/ExpenseDetails.tsx index 59d313b99..47766863f 100644 --- a/src/components/Expense/ExpenseDetails.tsx +++ b/src/components/Expense/ExpenseDetails.tsx @@ -1,6 +1,6 @@ -import { SplitType } from '@prisma/client'; +import { SplitType } from '~/server/db/types'; import { isSameDay } from 'date-fns'; -import { type User as NextUser } from 'next-auth'; +import type { SessionUser as NextUser } from '~/server/auth'; import type { inferRouterOutputs } from '@trpc/server'; import { ArrowRightIcon, Landmark, Merge, PencilIcon, Users } from 'lucide-react'; diff --git a/src/components/Expense/ExpenseList.tsx b/src/components/Expense/ExpenseList.tsx index f54b6e0d3..a172ecbdc 100644 --- a/src/components/Expense/ExpenseList.tsx +++ b/src/components/Expense/ExpenseList.tsx @@ -1,4 +1,4 @@ -import { SplitType } from '@prisma/client'; +import { SplitType } from '~/server/db/types'; import { type inferRouterOutputs } from '@trpc/server'; import Image from 'next/image'; import Link from 'next/link'; diff --git a/src/components/Friend/Export.tsx b/src/components/Friend/Export.tsx index 4b93450ca..917e10a92 100644 --- a/src/components/Friend/Export.tsx +++ b/src/components/Friend/Export.tsx @@ -1,4 +1,4 @@ -import { type Expense, type ExpenseParticipant, SplitType } from '@prisma/client'; +import { type Expense, type ExpenseParticipant, SplitType } from '~/server/db/types'; import { format } from 'date-fns'; import { Download } from 'lucide-react'; import React from 'react'; diff --git a/src/components/Friend/FriendBalance.tsx b/src/components/Friend/FriendBalance.tsx index aa462ab86..34df8902e 100644 --- a/src/components/Friend/FriendBalance.tsx +++ b/src/components/Friend/FriendBalance.tsx @@ -1,4 +1,4 @@ -import { type User } from '@prisma/client'; +import { type User } from '~/server/db/types'; import { clsx } from 'clsx'; import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils'; diff --git a/src/components/Friend/GroupSettleup.tsx b/src/components/Friend/GroupSettleup.tsx index 3e8a34d63..186547a4f 100644 --- a/src/components/Friend/GroupSettleup.tsx +++ b/src/components/Friend/GroupSettleup.tsx @@ -1,4 +1,4 @@ -import { SplitType, type User } from '@prisma/client'; +import { SplitType, type User } from '~/server/db/types'; import { ArrowRightIcon } from 'lucide-react'; import React, { type ReactNode, useState } from 'react'; import { toast } from 'sonner'; @@ -10,7 +10,7 @@ import { BigMath } from '~/utils/numbers'; import { EntityAvatar } from '../ui/avatar'; import { CurrencyInput } from '../ui/currency-input'; import { AppDrawer } from '../ui/drawer'; -import { useSession } from 'next-auth/react'; +import { useSession } from '~/lib/auth-client'; export const GroupSettleUp: React.FC<{ amount: bigint; diff --git a/src/components/Friend/Settleup.tsx b/src/components/Friend/Settleup.tsx index 6d9a53df5..e7704b26c 100644 --- a/src/components/Friend/Settleup.tsx +++ b/src/components/Friend/Settleup.tsx @@ -1,4 +1,4 @@ -import { SplitType, type User } from '@prisma/client'; +import { SplitType, type User } from '~/server/db/types'; import { ArrowRightIcon } from 'lucide-react'; import React, { useState } from 'react'; import { toast } from 'sonner'; @@ -7,7 +7,7 @@ import { DEFAULT_CATEGORY } from '~/lib/category'; import { api } from '~/utils/api'; import { BigMath } from '~/utils/numbers'; -import { useSession } from 'next-auth/react'; +import { useSession } from '~/lib/auth-client'; import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils'; import type { MinimalBalance } from '~/types/balance.types'; import { EntityAvatar } from '../ui/avatar'; diff --git a/src/components/group/AddMembers.tsx b/src/components/group/AddMembers.tsx index 0a101537d..7c0224835 100644 --- a/src/components/group/AddMembers.tsx +++ b/src/components/group/AddMembers.tsx @@ -1,5 +1,5 @@ import { UserPlusIcon } from '@heroicons/react/24/solid'; -import { type Group, type GroupUser } from '@prisma/client'; +import { type Group, type GroupUser } from '~/server/db/types'; import { clsx } from 'clsx'; import { CheckIcon, SendIcon } from 'lucide-react'; import React, { useCallback, useState } from 'react'; diff --git a/src/components/group/CreateGroup.tsx b/src/components/group/CreateGroup.tsx index bf286717f..e6afcff87 100644 --- a/src/components/group/CreateGroup.tsx +++ b/src/components/group/CreateGroup.tsx @@ -1,5 +1,5 @@ import { zodResolver } from '@hookform/resolvers/zod'; -import Avatar from 'boring-avatars'; +import Avatar from '~/lib/boring-avatar'; import { useRouter } from 'next/router'; import React, { useCallback, useState } from 'react'; import { useForm } from 'react-hook-form'; diff --git a/src/components/group/GroupMyBalance.tsx b/src/components/group/GroupMyBalance.tsx index 7c1d29511..cf06ae5cf 100644 --- a/src/components/group/GroupMyBalance.tsx +++ b/src/components/group/GroupMyBalance.tsx @@ -1,4 +1,4 @@ -import { type BalanceView, type User } from '@prisma/client'; +import { type BalanceView, type User } from '~/server/db/types'; import React, { useMemo } from 'react'; import { CumulatedBalances } from '~/components/Expense/CumulatedBalances'; @@ -22,19 +22,19 @@ const GroupMyBalance: React.FC = ({ const userMap = useMemo( () => - users.reduce( + users.reduce< Record>( (acc, user) => { acc[user.id] = user; return acc; }, - {} as Record, + {}, ), [users], ); const friendBalances = useMemo( () => - groupBalances.reduce( + groupBalances.reduce< Record>>( (acc, balance) => { if (balance.userId === userId && 0 < BigMath.abs(balance.amount)) { acc[balance.friendId] ??= {}; @@ -44,7 +44,7 @@ const GroupMyBalance: React.FC = ({ } return acc; }, - {} as Record>, + {}, ), [groupBalances, userId], ); @@ -52,7 +52,7 @@ const GroupMyBalance: React.FC = ({ const cumulatedBalances = useMemo( () => Object.entries( - Object.values(friendBalances).reduce( + Object.values(friendBalances).reduce< Record>( (acc, balances) => { if (balances) { Object.entries(balances).forEach(([currency, amount]) => { @@ -61,7 +61,7 @@ const GroupMyBalance: React.FC = ({ } return acc; }, - {} as Record, + {}, ), ).map(([currency, amount]) => ({ currency, amount })), [friendBalances], diff --git a/src/components/group/NoMembers.tsx b/src/components/group/NoMembers.tsx index 7d31685d0..7cacc63de 100644 --- a/src/components/group/NoMembers.tsx +++ b/src/components/group/NoMembers.tsx @@ -1,4 +1,4 @@ -import { type Group, type GroupUser } from '@prisma/client'; +import { type Group, type GroupUser } from '~/server/db/types'; import { Share, UserPlus } from 'lucide-react'; import React, { useState } from 'react'; import { useTranslation } from 'next-i18next'; diff --git a/src/components/ui/avatar.tsx b/src/components/ui/avatar.tsx index 08d94be9c..8cb5a92f8 100644 --- a/src/components/ui/avatar.tsx +++ b/src/components/ui/avatar.tsx @@ -1,4 +1,4 @@ -import BoringAvatar from 'boring-avatars'; +import BoringAvatar from '~/lib/boring-avatar'; import { Avatar as AvatarPrimitive } from 'radix-ui'; import * as React from 'react'; diff --git a/src/components/ui/categoryIcons.tsx b/src/components/ui/categoryIcons.tsx index 5aa2116d3..35bd39ad5 100644 --- a/src/components/ui/categoryIcons.tsx +++ b/src/components/ui/categoryIcons.tsx @@ -1,4 +1,4 @@ -import { SplitType } from '@prisma/client'; +import { SplitType } from '~/server/db/types'; import { Baby, Backpack, diff --git a/src/dummies/expenseGenerator.ts b/src/dummies/expenseGenerator.ts index dca445ab0..c57e9c007 100644 --- a/src/dummies/expenseGenerator.ts +++ b/src/dummies/expenseGenerator.ts @@ -1,5 +1,5 @@ import { faker } from '@faker-js/faker'; -import type { SplitType } from '@prisma/client'; +import type { SplitType } from '~/server/db/types'; import { addDays } from 'date-fns'; import type { CategoryItem } from '~/lib/category'; import { generateExpenseName } from './expenseNameGenerator'; diff --git a/src/dummies/splitGenerator.ts b/src/dummies/splitGenerator.ts index 3a26b5950..b50e01edd 100644 --- a/src/dummies/splitGenerator.ts +++ b/src/dummies/splitGenerator.ts @@ -1,5 +1,5 @@ import { faker } from '@faker-js/faker'; -import { SplitType } from '@prisma/client'; +import { SplitType } from '~/server/db/types'; import { type SplitShares, initSplitShares } from '~/store/addStore'; /** @@ -43,7 +43,7 @@ const generatePercentageShares = (participants: ParticipantId[]): SplitShares => if (participants.length === 1) { return { - [participants[0]!.id]: { + [participants[0].id]: { ...initSplitShares(), [SplitType.PERCENTAGE]: 10000n, }, @@ -59,12 +59,12 @@ const generatePercentageShares = (participants: ParticipantId[]): SplitShares => // Convert percentages to the format used (0-10000) const shares: SplitShares = {}; percentages.forEach((percentage, index) => { - shares[participants[index]!.id] = { + shares[participants[index].id] = { ...initSplitShares(), [SplitType.PERCENTAGE]: BigInt(percentage * 100), }; }); - shares[participants[participants.length - 1]!.id] = { + shares[participants[participants.length - 1].id] = { ...initSplitShares(), [SplitType.PERCENTAGE]: BigInt(lastParticipantPercentage * 100), }; diff --git a/src/env.ts b/src/env.ts index 11c46a0e0..017f2b93b 100644 --- a/src/env.ts +++ b/src/env.ts @@ -9,13 +9,8 @@ export const env = createEnv({ * isn't built with invalid env vars. */ server: { - DATABASE_URL: z - .string() - .url() - .refine( - (str) => !str.includes('YOUR_MYSQL_URL_HERE'), - 'You forgot to change the default URL', - ), + // Unused on Cloudflare Workers: the database is the D1 binding `DB`. + DATABASE_URL: z.string().optional(), NODE_ENV: z.enum(['development', 'test', 'production']).default('development'), DOCKER_OUTPUT: z.boolean().default(false), NEXTAUTH_SECRET: 'production' === process.env.NODE_ENV ? z.string() : z.string().optional(), @@ -35,6 +30,7 @@ export const env = createEnv({ ENABLE_SENDING_INVITES: z.boolean(), DISABLE_EMAIL_SIGNUP: z.boolean(), INVITE_ONLY: z.boolean(), + PREVIEW_ACCESS_AUTH: z.boolean().default(false), FROM_EMAIL: z.string().optional(), EMAIL_SERVER_HOST: z.string().optional(), EMAIL_SERVER_PORT: z.string().optional(), @@ -74,6 +70,7 @@ export const env = createEnv({ OIDC_WELL_KNOWN_URL: z.string().optional(), OIDC_ALLOW_DANGEROUS_EMAIL_LINKING: z.boolean().default(false), OAUTH_AUTO_REDIRECT: z.boolean().default(false), + CRON_SECRET: z.string().optional(), UPLOAD_MAX_FILE_SIZE_MB: z.coerce.number().int().positive().default(10), }, @@ -92,9 +89,7 @@ export const env = createEnv({ * middlewares) or client-side so we need to destruct manually. */ runtimeEnv: { - DATABASE_URL: - process.env.DATABASE_URL ?? - `postgresql://${process.env.POSTGRES_USER}:${process.env.POSTGRES_PASSWORD}@${process.env.POSTGRES_HOST}:${process.env.POSTGRES_PORT}`, + DATABASE_URL: process.env.DATABASE_URL, NODE_ENV: process.env.NODE_ENV, DOCKER_OUTPUT: parseEnvBoolean(process.env.DOCKER_OUTPUT), NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET, @@ -105,6 +100,7 @@ export const env = createEnv({ ENABLE_SENDING_INVITES: parseEnvBoolean(process.env.ENABLE_SENDING_INVITES), DISABLE_EMAIL_SIGNUP: parseEnvBoolean(process.env.DISABLE_EMAIL_SIGNUP), INVITE_ONLY: parseEnvBoolean(process.env.INVITE_ONLY), + PREVIEW_ACCESS_AUTH: parseEnvBoolean(process.env.PREVIEW_ACCESS_AUTH, false), FROM_EMAIL: process.env.FROM_EMAIL, EMAIL_SERVER_HOST: process.env.EMAIL_SERVER_HOST, EMAIL_SERVER_PORT: process.env.EMAIL_SERVER_PORT, @@ -144,6 +140,7 @@ export const env = createEnv({ process.env.OIDC_ALLOW_DANGEROUS_EMAIL_LINKING, ), OAUTH_AUTO_REDIRECT: parseEnvBoolean(process.env.OAUTH_AUTO_REDIRECT), + CRON_SECRET: process.env.CRON_SECRET, UPLOAD_MAX_FILE_SIZE_MB: process.env.UPLOAD_MAX_FILE_SIZE_MB ? Number(process.env.UPLOAD_MAX_FILE_SIZE_MB) : 10, diff --git a/src/instrumentation.ts b/src/instrumentation.ts deleted file mode 100644 index 17672f6a9..000000000 --- a/src/instrumentation.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { env } from './env'; - -/** - * Add things here to be executed during server startup. - * - * more details here: https://nextjs.org/docs/app/building-your-application/optimizing/instrumentation - */ -export async function register() { - if (process.env.NEXT_RUNTIME === 'nodejs') { - console.log('Registering instrumentation'); - - // Run data migrations - const { runMigrations } = await import('./migrations'); - await runMigrations(); - - const { validateAuthEnv } = await import('./server/auth'); - validateAuthEnv(); - - const { checkRecurrenceNotifications } = - await import('./server/api/services/notificationService'); - console.log('Starting recurrent expense notification checking...'); - setTimeout(checkRecurrenceNotifications, 1000 * 10); // Start after 10 seconds - } - - if (process.env.NEXT_RUNTIME !== 'nodejs') { - console.log('Skipping instrumentation on edge runtime'); - return; - } - - if (env.CLEAR_CACHE_CRON_RULE && env.CACHE_RETENTION_INTERVAL) { - // Create cron jobs - console.log('Setting up cron jobs...'); - - const { createRecurringDeleteBankCacheJob } = - await import('./server/api/services/scheduleService'); - - console.log( - `Creating cron job for cleaning up bank cache ${env.CLEAR_CACHE_CRON_RULE} with interval ${env.CACHE_RETENTION_INTERVAL}`, - ); - setTimeout( - () => - createRecurringDeleteBankCacheJob( - env.CLEAR_CACHE_CRON_RULE!, - env.CACHE_RETENTION_INTERVAL!, - ).catch((err) => { - console.error('Error creating recurring delete bank cache job:', err); - }), - 1000 * 10, - ); - } -} diff --git a/src/lib/auth-client.tsx b/src/lib/auth-client.tsx new file mode 100644 index 000000000..21efc3b7f --- /dev/null +++ b/src/lib/auth-client.tsx @@ -0,0 +1,115 @@ +/** + * Client auth helpers built on better-auth. Keeps the call shapes the app used with + * next-auth (`useSession`, `signIn`, `signOut`, `SessionProvider`) so pages change little. + */ +import { createAuthClient } from 'better-auth/react'; +import { useRouter } from 'next/router'; +import { type ReactNode, createContext, useCallback, useContext, useEffect, useState } from 'react'; + +import type { Session } from '~/server/auth'; + +export const authClient = createAuthClient(); + +type Status = 'loading' | 'authenticated' | 'unauthenticated'; + +interface SessionContextValue { + data: Session | null; + status: Status; + update: ( + data?: Partial | ((current: Session | null) => Partial), + ) => Promise; +} + +const SessionContext = createContext(null); + +const fetchSession = async (): Promise => { + const res = await fetch('/api/session', { credentials: 'include' }); + if (!res.ok) { + return null; + } + return (await res.json()) as Session | null; +}; + +export function SessionProvider({ + session, + children, +}: { + session?: Session | null; + children: ReactNode; +}) { + const [data, setData] = useState(session ?? null); + const [status, setStatus] = useState(session ? 'authenticated' : 'loading'); + + const refresh = useCallback(async () => { + const next = await fetchSession().catch(() => null); + setData(next); + setStatus(next ? 'authenticated' : 'unauthenticated'); + return next; + }, []); + + useEffect(() => { + if (!session) { + void refresh(); + } + }, [session, refresh]); + + const update = useCallback( + async (input?: Partial | ((current: Session | null) => Partial)) => { + const patch = 'function' === typeof input ? input(data) : input; + if (patch?.user && data) { + const merged = { ...data, user: { ...data.user, ...patch.user } }; + setData(merged); + return merged; + } + return refresh(); + }, + [data, refresh], + ); + + return ( + {children} + ); +} + +type RequiredSession = + | { + data: Session; + status: 'authenticated'; + update: SessionContextValue['update']; + } + | { data: null; status: 'loading'; update: SessionContextValue['update'] }; + +export function useSession(options: { required: true }): RequiredSession; +export function useSession(options?: { required?: false }): SessionContextValue; +export function useSession(options?: { required?: boolean }) { + const ctx = useContext(SessionContext); + if (!ctx) { + throw new Error('useSession must be used inside SessionProvider'); + } + const router = useRouter(); + const mustRedirect = Boolean(options?.required) && 'unauthenticated' === ctx.status; + + useEffect(() => { + if (mustRedirect) { + const callbackUrl = encodeURIComponent(window.location.href); + void router.push(`/auth/signin?error=SessionRequired&callbackUrl=${callbackUrl}`); + } + }, [mustRedirect, router]); + + if (options?.required && 'authenticated' !== ctx.status) { + return { data: null, status: 'loading', update: ctx.update }; + } + return ctx; +} + +export const signIn = async (provider: string, options?: { callbackUrl?: string }) => { + await authClient.signIn.social({ + provider: provider as 'google', + callbackURL: options?.callbackUrl || '/balances', + errorCallbackURL: '/auth/signin', + }); +}; + +export const signOut = async (_options?: { redirect?: boolean }) => { + await authClient.signOut(); +}; diff --git a/src/lib/boring-avatar.ts b/src/lib/boring-avatar.ts new file mode 100644 index 000000000..9e3c29ef9 --- /dev/null +++ b/src/lib/boring-avatar.ts @@ -0,0 +1,29 @@ +import * as BoringAvatarModule from 'boring-avatars'; +import type { ComponentType } from 'react'; + +interface AvatarProps { + name?: string; + size?: number | string; + variant?: 'marble' | 'beam' | 'pixel' | 'sunset' | 'ring' | 'bauhaus' | 'geometric' | 'abstract'; + colors?: string[]; + square?: boolean; + title?: boolean; + [key: string]: unknown; +} + +/** + * Boring-avatars ships only a UMD build with `exports.default`. Depending on the bundler's + * CJS interop, the default import is either the component or `{ default: component }`. + * Unwrap until we reach the component (fixes React error #130 under Vite). + */ +const unwrap = (mod: unknown): ComponentType => { + let value = mod as { default?: unknown }; + while (value && 'function' !== typeof value && 'default' in value) { + value = value.default as { default?: unknown }; + } + return value as unknown as ComponentType; +}; + +const BoringAvatar = unwrap(BoringAvatarModule); + +export default BoringAvatar; diff --git a/src/lib/cron.test.ts b/src/lib/cron.test.ts deleted file mode 100644 index 0caf2a85b..000000000 --- a/src/lib/cron.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { cronFromBackend, cronToBackend } from '~/lib/cron'; - -const mockTimezoneOffset = (offset: number) => - jest.spyOn(Date.prototype, 'getTimezoneOffset').mockReturnValue(offset); - -describe('cron timezone conversions', () => { - afterEach(() => { - jest.restoreAllMocks(); - }); - - it('shifts hours and minutes for negative timezone offsets', () => { - mockTimezoneOffset(-120); - - expect(cronToBackend('0 9 * * *')).toBe('0 7 * * *'); - }); - - it('shifts hours and minutes for positive offsets', () => { - mockTimezoneOffset(120); - - expect(cronToBackend('0 9 * * *')).toBe('0 11 * * *'); - }); - - it('handles minute underflow and day-of-week shift', () => { - mockTimezoneOffset(-90); - - expect(cronToBackend('15 0 * * 0')).toBe('45 22 * * 6'); - }); - - it('rolls month forward when crossing from the 31st', () => { - mockTimezoneOffset(120); - - expect(cronToBackend('0 23 31 1 *')).toBe('0 1 1 2 *'); - }); - - it('rolls month backward and converts to last-day marker', () => { - mockTimezoneOffset(-60); - - expect(cronToBackend('0 0 1 3 *')).toBe('0 23 $ 2 *'); - }); - - it('recovers from invalid day-of-month after timezone shift', () => { - mockTimezoneOffset(120); - - expect(cronToBackend('0 23 30 4 *')).toBe('0 1 1 5 *'); - }); - - it('restores last-day marker from backend notation', () => { - mockTimezoneOffset(0); - - expect(cronFromBackend('0 0 $ * *')).toBe('0 0 L * *'); - }); - - it('restores last-day marker from frontend notation', () => { - mockTimezoneOffset(0); - - expect(cronToBackend('0 0 L * *')).toBe('0 0 $ * *'); - }); - - it('round-trips frontend to backend and back', () => { - mockTimezoneOffset(60); - const expression = '15 5 * * 1'; - - expect(cronFromBackend(cronToBackend(expression))).toBe(expression); - }); -}); diff --git a/src/lib/defaultSplit.ts b/src/lib/defaultSplit.ts index 9714992c5..812b8e836 100644 --- a/src/lib/defaultSplit.ts +++ b/src/lib/defaultSplit.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { SplitType } from '@prisma/client'; +import { SplitType } from '~/server/db/types'; export const DEFAULT_SPLIT_TYPES = [ SplitType.EQUAL, diff --git a/src/lib/report-client-error.ts b/src/lib/report-client-error.ts new file mode 100644 index 000000000..9a1ae55a8 --- /dev/null +++ b/src/lib/report-client-error.ts @@ -0,0 +1,56 @@ +interface ClientErrorReport { + kind: 'error' | 'unhandledrejection' | 'react-boundary'; + message?: string; + name?: string; + stack?: string; + componentStack?: string; +} + +let sent = 0; +const MAX_REPORTS_PER_PAGE = 20; + +/** Sends a browser error to /api/client-errors (Workers Logs). Never throws. */ +export const reportClientError = (report: ClientErrorReport) => { + if ('undefined' === typeof window || sent >= MAX_REPORTS_PER_PAGE) { + return; + } + sent += 1; + try { + const body = JSON.stringify({ ...report, url: window.location.href }); + if ( + !navigator.sendBeacon?.('/api/client-errors', new Blob([body], { type: 'application/json' })) + ) { + void fetch('/api/client-errors', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body, + keepalive: true, + }).catch(() => undefined); + } + } catch { + // Reporting must never break the app. + } +}; + +export const installGlobalErrorReporting = () => { + window.addEventListener('error', (event) => { + reportClientError({ + kind: 'error', + message: event.message, + name: event.error instanceof Error ? event.error.name : undefined, + stack: + event.error instanceof Error + ? event.error.stack + : `${event.filename}:${event.lineno}:${event.colno}`, + }); + }); + window.addEventListener('unhandledrejection', (event) => { + const reason: unknown = event.reason; + reportClientError({ + kind: 'unhandledrejection', + message: reason instanceof Error ? reason.message : String(reason), + name: reason instanceof Error ? reason.name : undefined, + stack: reason instanceof Error ? reason.stack : undefined, + }); + }); +}; diff --git a/src/lib/simplify.ts b/src/lib/simplify.ts index d56cf6b20..a11884827 100644 --- a/src/lib/simplify.ts +++ b/src/lib/simplify.ts @@ -1,4 +1,4 @@ -import type { BalanceView as GroupBalance } from '@prisma/client'; +import type { BalanceView as GroupBalance } from '~/server/db/types'; export function simplifyDebts(groupBalances: GroupBalance[]): GroupBalance[] { const currencies = new Set(groupBalances.map((balance) => balance.currency)); @@ -78,7 +78,7 @@ function simplifyDebtsForSingleCurrency( return result; } -// based on https://www.geeksforgeeks.org/minimize-cash-flow-among-given-set-friends-borrowed-money/ +// Based on https://www.geeksforgeeks.org/minimize-cash-flow-among-given-set-friends-borrowed-money/ const minCashFlow = (graph: bigint[][]): bigint[][] => { const n = graph.length; diff --git a/src/migrations/convert-files-to-webp.ts b/src/migrations/convert-files-to-webp.ts deleted file mode 100644 index 5806e27b7..000000000 --- a/src/migrations/convert-files-to-webp.ts +++ /dev/null @@ -1,182 +0,0 @@ -import fs from 'node:fs/promises'; -import path from 'node:path'; -import sharp from 'sharp'; -import { fileExists } from '~/utils/file'; - -import { db } from '~/server/db'; - -const UPLOAD_DIR = path.join(process.cwd(), 'uploads'); - -const IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.webp']; - -async function convertImageToWebP(inputPath: string, outputPath: string, isThumbnail: boolean) { - const pipeline = sharp(inputPath); - - if (isThumbnail) { - pipeline.resize(200); - } else { - pipeline.resize({ width: 1200, withoutEnlargement: true }); - } - - await pipeline.webp({ quality: isThumbnail ? 60 : 80 }).toFile(outputPath); -} - -async function processFile( - filePath: string, - userDir: string, - fileName: string, -): Promise<{ oldKey: string; newKey: string } | null> { - const ext = path.extname(fileName).toLowerCase(); - const baseName = path.basename(fileName, ext); - - if (!IMAGE_EXTENSIONS.includes(ext)) { - return null; - } - - const userId = path.basename(userDir); - const oldKey = `${userId}/${fileName}`; - - if (ext === '.webp') { - const thumbName = `${baseName}-thumb.webp`; - const thumbPath = path.join(userDir, thumbName); - - if (baseName.endsWith('-thumb')) { - return null; - } else if (await fileExists(thumbPath)) { - return null; - } - - console.log(` Creating thumbnail for existing WebP: ${fileName}`); - await convertImageToWebP(filePath, thumbPath, true); - console.log(` Created thumbnail: ${thumbName}`); - - return { oldKey, newKey: oldKey }; - } - - const newFileName = `${baseName}.webp`; - const newFilePath = path.join(userDir, newFileName); - const thumbName = `${baseName}-thumb.webp`; - const thumbPath = path.join(userDir, thumbName); - - console.log(` Converting to WebP: ${fileName} -> ${newFileName}`); - await convertImageToWebP(filePath, newFilePath, false); - console.log(` Creating thumbnail: ${thumbName}`); - await convertImageToWebP(filePath, thumbPath, true); - - console.log(` Deleting original: ${fileName}`); - await fs.unlink(filePath); - - const newKey = `${userId}/${newFileName}`; - return { oldKey, newKey }; -} - -async function processUserDirectory( - userDir: string, -): Promise<{ oldKey: string; newKey: string }[]> { - const userId = path.basename(userDir); - console.log(`\nProcessing user directory: ${userId}`); - - const files = await fs.readdir(userDir); - const convertedFiles: { oldKey: string; newKey: string }[] = []; - - for (const file of files) { - const filePath = path.join(userDir, file); - const stat = await fs.stat(filePath); - - if (stat.isDirectory()) { - console.log(` Skipping subdirectory: ${file}`); - continue; - } - - try { - const result = await processFile(filePath, userDir, file); - if (result) { - convertedFiles.push(result); - } - } catch (error) { - console.error(` ERROR processing file ${file}:`, error); - throw new Error(`Failed to convert file: ${filePath}. Migration aborted.`, { cause: error }); - } - } - - return convertedFiles; -} - -async function updateDatabaseFileKeys(convertedFiles: { oldKey: string; newKey: string }[]) { - console.log('\nUpdating database file keys...'); - - for (const { oldKey, newKey } of convertedFiles) { - if (oldKey === newKey) { - continue; - } - - try { - const expenses = await db.expense.findMany({ - where: { fileKey: oldKey }, - select: { id: true }, - }); - - if (0 === expenses.length) { - console.log(` No expenses found with fileKey: ${oldKey}`); - continue; - } - - for (const expense of expenses) { - await db.expense.update({ - where: { id: expense.id }, - data: { fileKey: newKey }, - }); - } - - console.log(` Updated ${expenses.length} expense(s): ${oldKey} -> ${newKey}`); - } catch (error) { - console.error(` ERROR updating database for ${oldKey}:`, error); - throw new Error(`Failed to update database for fileKey: ${oldKey}. Migration aborted.`, { - cause: error, - }); - } - } -} - -export async function convertExistingFilesToWebP(): Promise { - console.log('\n=== Converting existing files to WebP format ===\n'); - - if (!(await fileExists(UPLOAD_DIR))) { - console.log('Uploads directory does not exist. Skipping file conversion.'); - return; - } - - const entries = await fs.readdir(UPLOAD_DIR); - const userDirs: string[] = []; - for (const entry of entries) { - const fullPath = path.join(UPLOAD_DIR, entry); - const stat = await fs.stat(fullPath); - if (stat.isDirectory()) { - userDirs.push(entry); - } - } - - if (0 === userDirs.length) { - console.log('No user directories found. Skipping file conversion.'); - return; - } - - console.log( - `Found ${userDirs.length} user director${1 === userDirs.length ? 'y' : 'ies'} to process`, - ); - - const allConvertedFiles: { oldKey: string; newKey: string }[] = []; - - for (const userDirName of userDirs) { - const userDir = path.join(UPLOAD_DIR, userDirName); - const convertedFiles = await processUserDirectory(userDir); - allConvertedFiles.push(...convertedFiles); - } - - const filesToUpdate = allConvertedFiles.filter((f) => f.oldKey !== f.newKey); - if (0 < filesToUpdate.length) { - await updateDatabaseFileKeys(filesToUpdate); - } - - console.log('\n=== File conversion completed successfully ===\n'); -} diff --git a/src/migrations/index.ts b/src/migrations/index.ts deleted file mode 100644 index 4dae8f51f..000000000 --- a/src/migrations/index.ts +++ /dev/null @@ -1,67 +0,0 @@ -/** - * Migration Runner - * - * This module manages data migrations for SplitPro. - * It checks the current schema version and runs necessary migrations. - * - * Version format: semver-like string (e.g., "2.0.0") - * Versions are compared lexicographically, which works for semver. - */ - -import { db } from '~/server/db'; - -// Import migrations -import { convertExistingFilesToWebP } from './convert-files-to-webp'; -import { runDbMigrations } from './programmatic-prisma'; -import { env } from '~/env'; - -/** - * Get the current schema version from the database. - * Returns null if no version is set (pre-2.0.0 database). - */ -async function getCurrentVersion(): Promise { - try { - const record = await db.appMetadata.findUnique({ - where: { key: 'schema_version' }, - }); - return record?.value ?? null; - } catch { - // Table might not exist yet - return null; - } -} - -/** - * Set the schema version in the database. - */ -async function setVersion(version: string): Promise { - await db.appMetadata.upsert({ - where: { key: 'schema_version' }, - update: { value: version }, - create: { key: 'schema_version', value: version }, - }); -} - -/** - * Run all pending migrations. - * Migrations are run in order and each updates the schema version on success. - */ -export async function runMigrations(): Promise { - if (env.DOCKER_OUTPUT) { - console.log('=== Prisma DB Migrations ===\n'); - await runDbMigrations(); - } - - console.log('=== SplitPro Data Migrations ===\n'); - - const currentVersion = await getCurrentVersion(); - console.log(`Current schema version: ${currentVersion ?? ''}`); - - switch (currentVersion) { - case null: - await convertExistingFilesToWebP(); - await setVersion('2.0.0'); - } - - console.log('=== All migrations completed ===\n'); -} diff --git a/src/migrations/programmatic-prisma.ts b/src/migrations/programmatic-prisma.ts deleted file mode 100644 index e4112226e..000000000 --- a/src/migrations/programmatic-prisma.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { readFileSync, readdirSync, statSync } from 'node:fs'; -import { pipeline } from 'stream/promises'; -import { createHash, randomUUID } from 'node:crypto'; -import { join } from 'node:path'; -import { db } from '~/server/db'; -import { env } from '~/env'; - -const migrationsPath = `${process.cwd()}/prisma/migrations`; - -export async function runDbMigrations() { - console.info(`Running DB migrations for "${env.NODE_ENV}"`); - - // check if any migrations have been applied - const migrationsTable: ({ exists: boolean } | undefined)[] = await db.$queryRaw` - SELECT EXISTS( - SELECT * - FROM information_schema.tables - WHERE table_schema = 'public' AND table_name = '_prisma_migrations' - ); - `; - - const migrationTableExists = migrationsTable[0]?.exists === true; - - if (!migrationTableExists) { - await db.$queryRaw` - CREATE TABLE _prisma_migrations ( - id VARCHAR(36) PRIMARY KEY NOT NULL, - checksum VARCHAR(64) NOT NULL, - finished_at TIMESTAMPTZ, - migration_name VARCHAR(255) NOT NULL, - logs TEXT, - rolled_back_at TIMESTAMPTZ, - started_at TIMESTAMPTZ NOT NULL DEFAULT now(), - applied_steps_count INTEGER NOT NULL DEFAULT 0 - ); - `; - } - - const dbMigrations: { - id: string; - checksum: string; - finished_at: string; - migration_name: string; - logs: string | null; - rolled_back_at: string | null; - started_at: string; - applied_steps_count: number; - }[] = await db.$queryRaw` - SELECT * - FROM _prisma_migrations - `; - - const localMigrations = walkMigrationDirectory(); - - console.info( - `DB migration found (${dbMigrations.length}): ${dbMigrations.map(({ migration_name }) => migration_name).join(', ')}`, - ); - console.info(`Local migrations found (${localMigrations.length}): ${localMigrations.join(', ')}`); - - let totalMigrationsApplied = 0; - for (const localMigrationName of localMigrations) { - // find local migration in all DB migrations - const existingMigration = dbMigrations.find( - (migration) => migration.migration_name === localMigrationName, - ); - if (existingMigration?.rolled_back_at) { - throw new Error( - 'Unsupported Prisma migrate feature: Script does not support rolled back migrations', - ); - } - - if (!existingMigration) { - console.info(`Migration ${localMigrationName} will be executed`); - - const migrationContents = readFileSync( - `${migrationsPath}/${localMigrationName}/migration.sql`, - 'utf8', - ); - - // executeRawUnsafe cannot insert multiple commands into a prepared statement - const migrationStatements = migrationContents - .split(';\n\n') - .map((stmt) => stmt.trim() + ';') - .filter((stmt) => stmt.length > 0); - - const ops = [ - ...migrationStatements.map((stmt) => db.$executeRawUnsafe(stmt)), - db.$executeRawUnsafe(` - INSERT INTO _prisma_migrations ( - id, - checksum, - migration_name, - finished_at, - started_at, - applied_steps_count, - logs, - rolled_back_at - ) VALUES ( - '${randomUUID()}', - '${await computeHash(migrationContents)}', - '${localMigrationName}', - now(), - now(), - 1, - NULL, - NULL - ); - `), - ]; - - await db.$transaction(ops); - - console.info(`Migration ${localMigrationName} applied successfully`); - - totalMigrationsApplied++; - } - } - console.info(`A total of ${totalMigrationsApplied} migration(s) were applied`); -} - -function walkMigrationDirectory() { - return readdirSync(migrationsPath).reduce((migrations, file) => { - const dirPath = join(migrationsPath, file); - if (statSync(dirPath).isDirectory()) { - migrations.push(file); - } - return migrations; - }, []); -} - -async function computeHash(content: string) { - const hash = createHash('sha256'); - await pipeline(content, hash); - return hash.digest('hex'); -} diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 2702850f8..dc3c75ca4 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -1,7 +1,7 @@ import i18nConfig from '@/next-i18next.config.js'; import { clsx } from 'clsx'; -import { type Session } from 'next-auth'; -import { SessionProvider, useSession } from 'next-auth/react'; +import type { Session } from '~/server/auth'; +import { SessionProvider, useSession } from '~/lib/auth-client'; import { appWithTranslation, useTranslation } from 'next-i18next'; import { type AppType } from 'next/app'; import { Poppins } from 'next/font/google'; @@ -9,6 +9,8 @@ import Head from 'next/head'; import { useRouter } from 'next/router'; import { useEffect, useState } from 'react'; import { Toaster } from 'sonner'; +import { ErrorBoundary } from '~/components/ErrorBoundary'; +import { installGlobalErrorReporting } from '~/lib/report-client-error'; import { LoadingSpinner } from '~/components/ui/spinner'; import { ThemeProvider } from 'next-themes'; import { CurrencyHelpersProvider } from '~/contexts/CurrencyHelpersContext'; @@ -29,6 +31,16 @@ const MyApp: AppType<{ session: Session | null }> = ({ }) => { const { t, ready } = useTranslation(); + useEffect(() => { + installGlobalErrorReporting(); + }, []); + + useEffect(() => { + if ('serviceWorker' in navigator && 'production' === process.env.NODE_ENV) { + navigator.serviceWorker.register('/sw.js').catch(console.error); + } + }, []); + if (!ready) { return (
@@ -82,18 +94,20 @@ const MyApp: AppType<{ session: Session | null }> = ({ - - - - - {(Component as NextPageWithUser).auth ? ( - - ) : ( - - )}{' '} - - - + + + + + + {(Component as NextPageWithUser).auth ? ( + + ) : ( + + )}{' '} + + + + ); }; diff --git a/src/pages/account.tsx b/src/pages/account.tsx index 3f37ad261..f63c204da 100644 --- a/src/pages/account.tsx +++ b/src/pages/account.tsx @@ -10,7 +10,7 @@ import { Star, } from 'lucide-react'; import type { GetServerSideProps } from 'next'; -import { signOut } from 'next-auth/react'; +import { signOut } from '~/lib/auth-client'; import { useTranslation } from 'next-i18next'; import Head from 'next/head'; import { useRouter } from 'next/router'; diff --git a/src/pages/activity.tsx b/src/pages/activity.tsx index 62e66c648..56ddaa99b 100644 --- a/src/pages/activity.tsx +++ b/src/pages/activity.tsx @@ -1,5 +1,5 @@ -import { SplitType } from '@prisma/client'; -import { type User } from 'next-auth'; +import { SplitType } from '~/server/db/types'; +import type { SessionUser as User } from '~/server/auth'; import Head from 'next/head'; import Link from 'next/link'; import MainLayout from '~/components/Layout/MainLayout'; diff --git a/src/pages/add.tsx b/src/pages/add.tsx index d26d049e7..96319d40f 100644 --- a/src/pages/add.tsx +++ b/src/pages/add.tsx @@ -65,7 +65,7 @@ const AddPage: NextPageWithUser<{ setCurrentUser({ ...user, defaultCurrency: user.defaultCurrency ?? null, - emailVerified: null, + emailVerified: true, name: user.name ?? null, email: user.email ?? null, image: user.image ?? null, diff --git a/src/pages/api/auth/[...all].ts b/src/pages/api/auth/[...all].ts new file mode 100644 index 000000000..7a5fab6ef --- /dev/null +++ b/src/pages/api/auth/[...all].ts @@ -0,0 +1,7 @@ +import { auth } from '~/server/auth'; + +export const config = { runtime: 'edge' }; + +export default function handler(request: Request) { + return auth.handler(request); +} diff --git a/src/pages/api/auth/[...nextauth].ts b/src/pages/api/auth/[...nextauth].ts deleted file mode 100644 index 11410e3ab..000000000 --- a/src/pages/api/auth/[...nextauth].ts +++ /dev/null @@ -1,5 +0,0 @@ -import NextAuth from 'next-auth'; - -import { authOptions } from '~/server/auth'; - -export default NextAuth(authOptions); diff --git a/src/pages/api/client-errors.ts b/src/pages/api/client-errors.ts new file mode 100644 index 000000000..3edd4654a --- /dev/null +++ b/src/pages/api/client-errors.ts @@ -0,0 +1,55 @@ +import { getSessionFromHeaders } from '~/server/auth'; +import { logError } from '~/server/observability/errorLog'; + +export const config = { runtime: 'edge' }; + +const MAX_BODY_BYTES = 16 * 1024; +const MAX_FIELD = 4000; + +const clip = (value: unknown, max = MAX_FIELD) => + 'string' === typeof value ? value.slice(0, max) : undefined; + +/** + * Receives browser errors (window.onerror, unhandled rejections, React error boundary) and + * writes them to Workers Logs. Same-origin only, size-capped, no auth required so crashes on + * the sign-in page are captured too. + */ +export default async function handler(request: Request) { + if ('POST' !== request.method) { + return new Response(null, { status: 405 }); + } + + const origin = request.headers.get('origin'); + if (origin && new URL(origin).host !== new URL(request.url).host) { + return new Response(null, { status: 403 }); + } + + const raw = await request.text(); + if (raw.length > MAX_BODY_BYTES) { + return new Response(null, { status: 413 }); + } + + let body: Record; + try { + body = JSON.parse(raw) as Record; + } catch { + return new Response(null, { status: 400 }); + } + + const session = await getSessionFromHeaders(request.headers).catch(() => null); + + const error = new Error(clip(body.message) ?? 'Unknown browser error'); + error.name = clip(body.name, 200) ?? 'BrowserError'; + error.stack = clip(body.stack); + + logError('browser', error, { + kind: clip(body.kind, 50), + url: clip(body.url, 1000), + componentStack: clip(body.componentStack), + userAgent: clip(request.headers.get('user-agent'), 300), + userId: session?.user.id, + rayId: request.headers.get('cf-ray') ?? undefined, + }); + + return new Response(null, { status: 204 }); +} diff --git a/src/pages/api/files/[...path].ts b/src/pages/api/files/[...path].ts index 7269ea3f2..dcd045924 100644 --- a/src/pages/api/files/[...path].ts +++ b/src/pages/api/files/[...path].ts @@ -1,45 +1,36 @@ -import fs from 'node:fs/promises'; -import path from 'node:path'; -import type { NextApiRequest, NextApiResponse } from 'next'; -import { getServerSession } from 'next-auth/next'; +import { getSessionFromHeaders } from '~/server/auth'; +import { bindings } from '~/server/db'; -import { authOptions } from '~/server/auth'; -import { fileExists } from '~/utils/file'; +export const config = { runtime: 'edge' }; -const UPLOAD_DIR = path.join(process.cwd(), 'uploads'); +const PREFIX = '/api/files/'; -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - if ('GET' !== req.method) { - return res.status(405).end(); +/** Serves receipts from R2. Any signed-in user can read, as before. */ +export default async function handler(request: Request) { + if ('GET' !== request.method) { + return new Response(null, { status: 405 }); } - const session = await getServerSession(req, res, authOptions); + const session = await getSessionFromHeaders(request.headers); if (!session) { - return res.status(403).send('Unauthorized'); + return new Response('Unauthorized', { status: 403 }); } - const { path: pathParts } = req.query; - - if (!pathParts || !Array.isArray(pathParts)) { - return res.status(400).send('Invalid path'); + const { pathname } = new URL(request.url); + const key = decodeURIComponent(pathname.slice(pathname.indexOf(PREFIX) + PREFIX.length)); + // Keys look like "/" (legacy: "/.webp"). + if (!/^\d+\/[0-9a-f-]{36}(-thumb)?(\.webp)?$/i.test(key)) { + return new Response('Invalid path', { status: 400 }); } - const filePath = path.join(UPLOAD_DIR, ...pathParts); - const resolvedPath = path.resolve(filePath); - - if (!resolvedPath.startsWith(path.resolve(UPLOAD_DIR))) { - return res.status(403).send('Forbidden'); + const object = await bindings.UPLOADS.get(key); + if (!object) { + return new Response('File not found', { status: 404 }); } - if (!(await fileExists(filePath))) { - return res.status(404).send('File not found'); - } - - res.setHeader('Content-Type', 'image/webp'); - res.setHeader('Cache-Control', 'private, max-age=31536000, immutable'); - - const fileStream = await fs - .open(filePath, 'r') - .then((fileHandle) => fileHandle.createReadStream()); - fileStream.pipe(res); + const headers = new Headers(); + object.writeHttpMetadata(headers); + headers.set('etag', object.httpEtag); + headers.set('Cache-Control', 'private, max-age=31536000, immutable'); + return new Response(object.body, { headers }); } diff --git a/src/pages/api/readyz.ts b/src/pages/api/readyz.ts index 37b6d0a53..9ea0270c2 100644 --- a/src/pages/api/readyz.ts +++ b/src/pages/api/readyz.ts @@ -1,39 +1,28 @@ -import { Prisma } from '@prisma/client'; -import type { NextApiRequest, NextApiResponse } from 'next'; +import { bindings } from '~/server/db'; -import { db } from '~/server/db'; +export const config = { runtime: 'edge' }; const DB_CHECK_TIMEOUT_MS = 3000; -/** - * Readiness check. Verifies the database is reachable, bounded by a short timeout so a - * hung connection fails the check quickly instead of leaving the request pending. The - * timeout is enforced by Postgres itself (`statement_timeout`, scoped to this transaction - * via `SET LOCAL`) rather than raced client-side, so a slow query is actually cancelled - * instead of left running against the connection pool. Meant for load balancers/ - * orchestrators to stop routing traffic here without restarting the process - see - * `/api/healthz` for the dependency-free liveness check. - * - * This only bounds queries that reach Postgres - it can't help if the socket itself is - * dead (see the `connect_timeout`/`socket_timeout` note in docs/CONFIGURATION.md), so it's - * a complement to those connection-string options rather than a replacement for them. - */ -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - if ('GET' !== req.method) { - res.setHeader('Allow', 'GET'); - return res.status(405).json({ status: 'error', message: 'Method not allowed' }); +/** Readiness check: verifies D1 answers `SELECT 1` within a short timeout. */ +export default async function handler(request: Request) { + if ('GET' !== request.method) { + return Response.json( + { status: 'error', message: 'Method not allowed' }, + { status: 405, headers: { Allow: 'GET' } }, + ); } try { - // DB_CHECK_TIMEOUT_MS is a hardcoded constant, not user input, so inlining it via - // Prisma.raw is safe here - `SET` does not accept bound query parameters. - await db.$transaction([ - db.$executeRaw`SET LOCAL statement_timeout = ${Prisma.raw(String(DB_CHECK_TIMEOUT_MS))}`, - db.$queryRaw`SELECT 1`, + await Promise.race([ + bindings.DB.prepare('SELECT 1').first(), + new Promise((_, reject) => + setTimeout(() => reject(new Error('timeout')), DB_CHECK_TIMEOUT_MS), + ), ]); - return res.status(200).json({ status: 'ok' }); + return Response.json({ status: 'ok' }); } catch (error) { console.error('Readiness check failed:', error); - return res.status(503).json({ status: 'error', message: 'Database not reachable' }); + return Response.json({ status: 'error', message: 'Database not reachable' }, { status: 503 }); } } diff --git a/src/pages/api/session.ts b/src/pages/api/session.ts new file mode 100644 index 000000000..7ccaec64c --- /dev/null +++ b/src/pages/api/session.ts @@ -0,0 +1,9 @@ +import { getSessionFromHeaders } from '~/server/auth'; + +export const config = { runtime: 'edge' }; + +/** Returns the app session (with SplitPro user fields) for the client SessionProvider. */ +export default async function handler(request: Request) { + const session = await getSessionFromHeaders(request.headers); + return Response.json(session, { headers: { 'Cache-Control': 'private, no-store' } }); +} diff --git a/src/pages/api/trpc/[trpc].ts b/src/pages/api/trpc/[trpc].ts index 62bca418b..5af221491 100644 --- a/src/pages/api/trpc/[trpc].ts +++ b/src/pages/api/trpc/[trpc].ts @@ -1,17 +1,54 @@ -import { createNextApiHandler } from '@trpc/server/adapters/next'; +import { fetchRequestHandler } from '@trpc/server/adapters/fetch'; -import { env } from '~/env'; import { appRouter } from '~/server/api/root'; import { createTRPCContext } from '~/server/api/trpc'; +import { logError } from '~/server/observability/errorLog'; -// export API handler -export default createNextApiHandler({ - router: appRouter, - createContext: createTRPCContext, - onError: - 'development' === env.NODE_ENV - ? ({ path, error }) => { - console.error(`❌ tRPC failed on ${path ?? ''}: ${error.message}`); - } - : undefined, -}); +export const config = { runtime: 'edge' }; + +/** Client mistakes (bad input, auth) are expected; log them as warnings, not errors. */ +const EXPECTED_CODES = new Set([ + 'BAD_REQUEST', + 'UNAUTHORIZED', + 'FORBIDDEN', + 'NOT_FOUND', + 'PARSE_ERROR', + 'METHOD_NOT_SUPPORTED', + 'TOO_MANY_REQUESTS', +]); + +export default function handler(request: Request) { + let userId: number | undefined; + + return fetchRequestHandler({ + endpoint: '/api/trpc', + req: request, + router: appRouter, + createContext: async () => { + const ctx = await createTRPCContext({ req: { headers: request.headers } }); + userId = ctx.session?.user.id; + return ctx; + }, + onError: ({ path, type, error, input }) => { + const context = { + path, + type, + code: error.code, + userId, + input, + rayId: request.headers.get('cf-ray') ?? undefined, + }; + if (EXPECTED_CODES.has(error.code)) { + console.warn( + JSON.stringify( + { level: 'warn', source: 'trpc', message: error.message, ...context }, + (_k, v: unknown) => ('bigint' === typeof v ? v.toString() : v), + ), + ); + return; + } + // Log the original error (with its stack), not the tRPC wrapper. + logError('trpc', error.cause ?? error, context); + }, + }); +} diff --git a/src/pages/api/upload.ts b/src/pages/api/upload.ts index ff4d6b0b3..11cc098ba 100644 --- a/src/pages/api/upload.ts +++ b/src/pages/api/upload.ts @@ -1,88 +1,54 @@ -import { randomUUID } from 'node:crypto'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import type { NextApiRequest, NextApiResponse } from 'next'; -import { getServerSession } from 'next-auth/next'; -import sharp from 'sharp'; - -import { authOptions } from '~/server/auth'; import { env } from '~/env'; +import { getSessionFromHeaders } from '~/server/auth'; +import { bindings } from '~/server/db'; -import { type File, formidable } from 'formidable'; -import { fileExists } from '~/utils/file'; - -export const config = { - api: { - bodyParser: false, - }, -}; +export const config = { runtime: 'edge' }; -const UPLOAD_DIR = path.join(process.cwd(), 'uploads'); +const ALLOWED_TYPES = new Set(['image/webp', 'image/jpeg', 'image/png', 'image/gif', 'image/avif']); -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - if ('POST' !== req.method) { - return res.status(405).json({ error: 'Method not allowed' }); +/** + * Stores a receipt in R2 under `/`. The browser already compresses images + * (browser-image-compression), so the server stores them as-is (no sharp on Workers). + */ +export default async function handler(request: Request) { + if ('POST' !== request.method) { + return Response.json({ error: 'Method not allowed' }, { status: 405 }); } - const session = await getServerSession(req, res, authOptions); + const session = await getSessionFromHeaders(request.headers); if (!session?.user) { - return res.status(401).json({ error: 'Unauthorized' }); + return Response.json({ error: 'Unauthorized' }, { status: 401 }); } - if (!(await fileExists(UPLOAD_DIR))) { - await fs.mkdir(UPLOAD_DIR, { recursive: true }); + const maxBytes = env.UPLOAD_MAX_FILE_SIZE_MB * 1024 * 1024; + const declared = Number(request.headers.get('content-length') ?? 0); + if (declared > maxBytes + 64 * 1024) { + return Response.json({ error: 'File too large' }, { status: 413 }); } - const form = formidable({ - keepExtensions: true, - maxFileSize: env.UPLOAD_MAX_FILE_SIZE_MB * 1024 * 1024, - }); - - let uploadedFile: File | undefined; - try { - const [, files] = await form.parse(req); - uploadedFile = files.file?.[0]; - - if (!uploadedFile) { - return res.status(400).json({ error: 'No file provided' }); + const form = await request.formData(); + const file = form.get('file'); + if (!(file instanceof File)) { + return Response.json({ error: 'No file provided' }, { status: 400 }); } - - const userId = String(session.user.id); - const userDir = path.join(UPLOAD_DIR, userId); - - if (!(await fileExists(userDir))) { - await fs.mkdir(userDir, { recursive: true }); + if (file.size > maxBytes) { + return Response.json({ error: 'File too large' }, { status: 413 }); + } + const contentType = file.type || 'application/octet-stream'; + if (!ALLOWED_TYPES.has(contentType)) { + return Response.json({ error: 'Unsupported file type' }, { status: 415 }); } - const fileUUID = randomUUID(); - const fileName = `${fileUUID}.webp`; - const thumbName = `${fileUUID}-thumb.webp`; - const finalPath = path.join(userDir, fileName); - const thumbPath = path.join(userDir, thumbName); - - const fileBuffer = await fs.readFile(uploadedFile.filepath); - - await sharp(fileBuffer) - .resize({ width: 1200, withoutEnlargement: true }) - .webp({ quality: 80 }) - .toFile(finalPath); - - await sharp(fileBuffer).resize(200).webp({ quality: 60 }).toFile(thumbPath); - - return res.status(200).json({ - key: `${userId}/${fileName}`, + const key = `${session.user.id}/${crypto.randomUUID()}`; + await bindings.UPLOADS.put(key, file.stream(), { + httpMetadata: { contentType }, + customMetadata: { uploadedBy: String(session.user.id) }, }); + + return Response.json({ key }); } catch (error) { console.error('Upload error:', error); - return res.status(500).json({ error: 'Internal Server Error' }); - } finally { - try { - if (uploadedFile) { - await fs.unlink(uploadedFile.filepath); - } - } catch { - // Ignore errors - } + return Response.json({ error: 'Internal Server Error' }, { status: 500 }); } } diff --git a/src/pages/auth/signin.tsx b/src/pages/auth/signin.tsx index 2e2cfe3d7..367d1fabc 100644 --- a/src/pages/auth/signin.tsx +++ b/src/pages/auth/signin.tsx @@ -9,7 +9,7 @@ import { SiKeycloak, } from '@icons-pack/react-simple-icons'; import { type GetServerSideProps, type NextPage } from 'next'; -import { type ClientSafeProvider, getProviders, signIn } from 'next-auth/react'; +import { signIn } from '~/lib/auth-client'; import { type TFunction, useTranslation } from 'next-i18next'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { useForm } from 'react-hook-form'; @@ -28,7 +28,11 @@ import { import { Input } from '~/components/ui/input'; import { LoadingSpinner } from '~/components/ui/spinner'; import { env } from '~/env'; -import { getServerAuthSession } from '~/server/auth'; +import { + type AuthProvider as ClientSafeProvider, + getAuthProviders, + getServerAuthSession, +} from '~/server/auth'; import { customServerSideTranslations } from '~/utils/i18n/server'; import VerificationStep from './VerificationStep'; @@ -74,29 +78,14 @@ const Home: NextPage<{ }, }); - // Client-side fallback for getProviders when server-side call fails + // Providers come from getServerSideProps; better-auth has no client provider listing. useEffect(() => { - if (serverProviders.length > 0) { - return; + if (0 === serverProviders.length) { + toast.error(t('errors.no_providers'), { duration: 8000 }); } - - void (async () => { - setIsLoadingProviders(true); - try { - const clientProviders = await getProviders(); - if (clientProviders && Object.keys(clientProviders).length > 0) { - setProviders(Object.values(clientProviders)); - } else { - throw new Error('No providers returned from getProviders()'); - } - } catch (error) { - console.error('Error fetching providers client-side:', error); - toast.error(t('errors.no_providers'), { duration: 8000 }); - } finally { - setIsLoadingProviders(false); - } - })(); - }, [serverProviders.length, t]); + setProviders(serverProviders); + setIsLoadingProviders(false); + }, [serverProviders, t]); useEffect(() => { if (error) { @@ -138,9 +127,10 @@ const Home: NextPage<{ setEmailStatus('sending'); const email = emailForm.getValues().email.toLowerCase(); setShowVerificationStep(true); - await signIn('email', { email, callbackUrl, redirect: false }); + // Email sign-in is disabled on this deployment (Google only). + void email; setEmailStatus('success'); - }, [emailForm, setShowVerificationStep, callbackUrl]); + }, [emailForm, setShowVerificationStep]); useEffect(() => { const onPop = (e: PopStateEvent) => { @@ -290,12 +280,7 @@ export default Home; export const getServerSideProps: GetServerSideProps = async (context) => { const session = await getServerAuthSession(context); - let providers: Record | null = null; - try { - providers = await getProviders(); - } catch (error) { - console.error(error); - } + const providers = getAuthProviders(); const { callbackUrl, error } = context.query; if (session) { @@ -315,7 +300,7 @@ export const getServerSideProps: GetServerSideProps = async (context) => { ...(await customServerSideTranslations(context.locale, ['common'])), error: typeof error === 'string' ? error : '', feedbackEmail: env.FEEDBACK_EMAIL ?? '', - providers: Object.values(providers ?? {}), + providers, callbackUrl: callbackUrl && !Array.isArray(callbackUrl) ? callbackUrl : '', oauthAutoRedirect: env.OAUTH_AUTO_REDIRECT, }, diff --git a/src/pages/balances/[friendId].tsx b/src/pages/balances/[friendId].tsx index a5f87a0d0..0e9136e5a 100644 --- a/src/pages/balances/[friendId].tsx +++ b/src/pages/balances/[friendId].tsx @@ -96,7 +96,7 @@ const FriendPage: NextPageWithUser = ({ user }) => { participants={[ { ...user, - emailVerified: null, + emailVerified: true, name: user.name ?? null, email: user.email ?? null, image: user.image ?? null, diff --git a/src/pages/balances/[friendId]/expenses/[expenseId].tsx b/src/pages/balances/[friendId]/expenses/[expenseId].tsx index a1b7beaff..4e6f841bb 100644 --- a/src/pages/balances/[friendId]/expenses/[expenseId].tsx +++ b/src/pages/balances/[friendId]/expenses/[expenseId].tsx @@ -1,4 +1,4 @@ -import { SplitType } from '@prisma/client'; +import { SplitType } from '~/server/db/types'; import { ChevronLeftIcon, PencilIcon } from 'lucide-react'; import { type GetServerSideProps } from 'next'; import { useTranslation } from 'next-i18next'; diff --git a/src/pages/expenses/[expenseId].tsx b/src/pages/expenses/[expenseId].tsx index 4239c47a6..d4ea38559 100644 --- a/src/pages/expenses/[expenseId].tsx +++ b/src/pages/expenses/[expenseId].tsx @@ -1,4 +1,4 @@ -import { SplitType } from '@prisma/client'; +import { SplitType } from '~/server/db/types'; import { ChevronLeftIcon, PencilIcon } from 'lucide-react'; import { type GetServerSideProps } from 'next'; import { useTranslation } from 'next-i18next'; diff --git a/src/pages/groups/[groupId].tsx b/src/pages/groups/[groupId].tsx index e4b824711..3f489fd4e 100644 --- a/src/pages/groups/[groupId].tsx +++ b/src/pages/groups/[groupId].tsx @@ -39,7 +39,9 @@ import { env } from '~/env'; import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils'; import { deserializeDefaultSplit } from '~/lib/defaultSplit'; import { isCurrencyCode, parseCurrencyCode } from '~/lib/currency'; +import { eq } from 'drizzle-orm'; import { db } from '~/server/db'; +import { groups } from '~/server/db/schema'; import { type NextPageWithUser } from '~/types'; import { api } from '~/utils/api'; import { customServerSideTranslations } from '~/utils/i18n/server'; @@ -579,10 +581,9 @@ BalancePage.auth = true; export default BalancePage; export const getServerSideProps: GetServerSideProps = async (context) => { - const group = await db.group.findFirst({ - where: { - id: Number(context.query.groupId), - }, + const group = await db.query.groups.findFirst({ + where: eq(groups.id, Number(context.query.groupId)), + columns: { id: true }, }); if (!group) { diff --git a/src/pages/groups/[groupId]/expenses/[expenseId].tsx b/src/pages/groups/[groupId]/expenses/[expenseId].tsx index 2465290b3..a929ac199 100644 --- a/src/pages/groups/[groupId]/expenses/[expenseId].tsx +++ b/src/pages/groups/[groupId]/expenses/[expenseId].tsx @@ -1,4 +1,4 @@ -import { SplitType } from '@prisma/client'; +import { SplitType } from '~/server/db/types'; import { ChevronLeftIcon, PencilIcon } from 'lucide-react'; import { type GetServerSideProps } from 'next'; import { useTranslation } from 'next-i18next'; diff --git a/src/server/api/routers/bankTransactions.ts b/src/server/api/routers/bankTransactions.ts index 62915ba77..cf9ca2d37 100644 --- a/src/server/api/routers/bankTransactions.ts +++ b/src/server/api/routers/bankTransactions.ts @@ -1,3 +1,6 @@ +import { eq } from 'drizzle-orm'; + +import { users } from '~/server/db/schema'; import { createTRPCRouter, protectedProcedure } from '../trpc'; import { z } from 'zod'; import { whichBankConnectionConfigured } from '~/server/bankTransactionHelper'; @@ -35,14 +38,10 @@ export const bankTransactionsRouter = createTRPCRouter({ throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Failed to link to bank' }); } - await ctx.db.user.update({ - where: { - id: ctx.session.user.id, - }, - data: { - obapiProviderId: res.institutionId, - }, - }); + await ctx.db + .update(users) + .set({ obapiProviderId: res.institutionId }) + .where(eq(users.id, ctx.session.user.id)); return res; } @@ -77,14 +76,10 @@ export const bankTransactionsRouter = createTRPCRouter({ }); } - await ctx.db.user.update({ - where: { - id: ctx.session.user.id, - }, - data: { - obapiProviderId: res.accessToken, - }, - }); + await ctx.db + .update(users) + .set({ obapiProviderId: res.accessToken }) + .where(eq(users.id, ctx.session.user.id)); return res; } diff --git a/src/server/api/routers/expense.ts b/src/server/api/routers/expense.ts index afc6f354a..6af5eab77 100644 --- a/src/server/api/routers/expense.ts +++ b/src/server/api/routers/expense.ts @@ -3,7 +3,28 @@ import { z } from 'zod'; import { simplifyDebts } from '~/lib/simplify'; import { createTRPCRouter, groupProcedure, protectedProcedure } from '~/server/api/trpc'; +import { + and, + desc, + eq, + exists, + inArray, + isNotNull, + isNull, + ne, + notInArray, + or, + sql, +} from 'drizzle-orm'; + import { db } from '~/server/db'; +import { + balanceView, + expenseParticipants, + expenses as expensesTable, + groupUsers, + groups, +} from '~/server/db/schema'; import { BigMath, currencyConversion } from '~/utils/numbers'; import { @@ -16,46 +37,73 @@ import { import { createExpense, deleteExpense, editExpense } from '../services/splitService'; import { currencyRateProvider } from '../services/currencyRateService'; import { type CurrencyCode, isCurrencyCode } from '~/lib/currency'; -import { SplitType } from '@prisma/client'; +import { SplitType } from '~/server/db/types'; import { DEFAULT_CATEGORY } from '~/lib/category'; import { getUserMap } from './user'; -import { FriendBalance } from '~/components/Friend/FriendBalance'; +import { deleteJob, updateJobSchedule } from '../services/scheduleService'; + +/** Correlated EXISTS: the expense identified by `expenseIdCol` has participant `userId`. */ +const hasParticipant = (expenseIdCol: typeof expensesTable.id, userId: number, nonZero = false) => + exists( + db + .select({ one: sql`1` }) + .from(expenseParticipants) + .where( + and( + eq(expenseParticipants.expenseId, expenseIdCol), + eq(expenseParticipants.userId, userId), + nonZero ? ne(expenseParticipants.amount, 0n) : undefined, + ), + ), + ); + +/** Hide the "from" half of a currency conversion; the "to" half carries conversionToId. */ +const notConversionFromHalf = (e: typeof expensesTable._.columns) => + or(ne(e.splitType, SplitType.CURRENCY_CONVERSION), isNotNull(e.conversionToId)); + +const userSummaryColumns = { name: true, email: true, image: true, id: true } as const; export const expenseRouter = createTRPCRouter({ getCumulatedBalances: protectedProcedure.query(async ({ ctx }) => { - const cumulatedBalances = await db.balanceView.groupBy({ - by: ['currency'], - _sum: { amount: true }, - where: { userId: ctx.session.user.id, amount: { not: 0 } }, - orderBy: { _sum: { amount: 'desc' } }, - }); + const total = sql`SUM(${balanceView.amount})`.mapWith((v) => BigInt(v ?? 0)); + const cumulatedBalances = ( + await db + .select({ currency: balanceView.currency, amount: total }) + .from(balanceView) + .where(and(eq(balanceView.userId, ctx.session.user.id), ne(balanceView.amount, 0n))) + .groupBy(balanceView.currency) + .orderBy(desc(total)) + ).map((b) => ({ currency: b.currency, _sum: { amount: b.amount } })); const youOwe = cumulatedBalances .filter((b) => b._sum.amount && 0 > b._sum.amount) - .map((b) => ({ currency: b.currency, amount: b._sum.amount! })) + .map((b) => ({ currency: b.currency, amount: b._sum.amount })) .reverse(); const youGet = cumulatedBalances .filter((b) => b._sum.amount && 0 < b._sum.amount) - .map((b) => ({ currency: b.currency, amount: b._sum.amount! })); + .map((b) => ({ currency: b.currency, amount: b._sum.amount })); return { youOwe, youGet }; }), getBalances: protectedProcedure.query(async ({ ctx }) => { - const rawBalances = await db.balanceView.findMany({ - where: { - userId: ctx.session.user.id, - friendId: { notIn: ctx.session.user.hiddenFriendIds }, - }, - include: { - group: { - select: { - simplifyDebts: true, - }, - }, - }, - }); + const rawBalances = await db + .select({ + friendId: balanceView.friendId, + currency: balanceView.currency, + amount: balanceView.amount, + groupId: balanceView.groupId, + group: { simplifyDebts: groups.simplifyDebts }, + }) + .from(balanceView) + .leftJoin(groups, eq(groups.id, balanceView.groupId)) + .where( + and( + eq(balanceView.userId, ctx.session.user.id), + notInArray(balanceView.friendId, ctx.session.user.hiddenFriendIds), + ), + ); const processedBalances = await Promise.all( rawBalances.map(async ({ friendId, currency, amount, groupId, group }) => { @@ -63,9 +111,10 @@ export const expenseRouter = createTRPCRouter({ return { friendId, currency, amount }; } - const allGroupBalances = await db.balanceView.findMany({ - where: { groupId, currency }, - }); + const allGroupBalances = await db + .select() + .from(balanceView) + .where(and(eq(balanceView.groupId, groupId), eq(balanceView.currency, currency))); const simplified = simplifyDebts(allGroupBalances); const simplifiedBalance = simplified.find( @@ -142,16 +191,7 @@ export const expenseRouter = createTRPCRouter({ } if (input.groupId !== null) { - const group = await db.group.findUnique({ - where: { id: input.groupId }, - select: { archivedAt: true }, - }); - if (!group) { - throw new TRPCError({ code: 'BAD_REQUEST', message: 'Group not found' }); - } - if (group.archivedAt) { - throw new TRPCError({ code: 'BAD_REQUEST', message: 'Group is archived' }); - } + await assertCanWriteToGroup(input.groupId, ctx.session.user.id); } try { @@ -177,6 +217,13 @@ export const expenseRouter = createTRPCRouter({ .mutation(async ({ input, ctx }) => { const { amount, rate, from, to, senderId, receiverId, groupId, expenseId } = input; + if (expenseId) { + await validateEditExpensePermission(expenseId, ctx.session.user.id); + } + if (groupId !== null && groupId !== undefined) { + await assertCanWriteToGroup(groupId, ctx.session.user.id); + } + if (!isCurrencyCode(from) || !isCurrencyCode(to)) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Invalid currency code' }); } @@ -231,83 +278,24 @@ export const expenseRouter = createTRPCRouter({ getExpensesWithFriend: protectedProcedure .input(z.object({ friendId: z.number() })) .query(async ({ input, ctx }) => { - const expenses = await db.expense.findMany({ - where: { - AND: [ - { - expenseParticipants: { - some: { - userId: input.friendId, - amount: { - not: 0n, - }, - }, - }, - }, - { - expenseParticipants: { - some: { - userId: ctx.session.user.id, - amount: { - not: 0n, - }, - }, - }, - }, - { - OR: [ - { - paidBy: ctx.session.user.id, - }, - { - paidBy: input.friendId, - }, - ], - }, - { - deletedBy: null, - }, - { - OR: [ - { - NOT: { - splitType: SplitType.CURRENCY_CONVERSION, - }, - }, - { - NOT: { - conversionToId: null, - }, - }, - ], - }, - ], - }, - orderBy: { - expenseDate: 'desc', - }, - include: { + const me = ctx.session.user.id; + const expenses = await db.query.expenses.findMany({ + where: (e) => + and( + hasParticipant(e.id, input.friendId, true), + hasParticipant(e.id, me, true), + or(eq(e.paidBy, me), eq(e.paidBy, input.friendId)), + isNull(e.deletedBy), + notConversionFromHalf(e), + ), + orderBy: (e) => [desc(e.expenseDate)], + with: { expenseParticipants: { - where: { - OR: [ - { - userId: ctx.session.user.id, - }, - { - userId: input.friendId, - }, - ], - }, + where: (ep) => inArray(ep.userId, [me, input.friendId]), }, paidByUser: true, conversionTo: true, - group: { - select: { - id: true, - name: true, - simplifyDebts: true, - }, - }, + group: { columns: { id: true, name: true, simplifyDebts: true } }, }, }); @@ -317,27 +305,11 @@ export const expenseRouter = createTRPCRouter({ getGroupExpenses: groupProcedure .input(z.object({ groupId: z.number() })) .query(async ({ input, ctx }) => { - const expenses = await ctx.db.expense.findMany({ - where: { - groupId: input.groupId, - deletedBy: null, - OR: [ - { - NOT: { - splitType: SplitType.CURRENCY_CONVERSION, - }, - }, - { - NOT: { - conversionToId: null, - }, - }, - ], - }, - orderBy: { - expenseDate: 'desc', - }, - include: { + const expenses = await ctx.db.query.expenses.findMany({ + where: (e) => + and(eq(e.groupId, input.groupId), isNull(e.deletedBy), notConversionFromHalf(e)), + orderBy: (e) => [desc(e.expenseDate)], + with: { expenseParticipants: true, paidByUser: true, deletedByUser: true, @@ -350,60 +322,34 @@ export const expenseRouter = createTRPCRouter({ getExpenseDetails: protectedProcedure .input(z.object({ expenseId: z.string() })) - .query(async ({ input }) => { - const expense = await db.expense.findUnique({ - where: { - id: input.expenseId, - }, - include: { - expenseParticipants: { - include: { - user: true, - }, - }, + .query(async ({ input, ctx }) => { + const expense = await db.query.expenses.findFirst({ + where: eq(expensesTable.id, input.expenseId), + with: { + expenseParticipants: { with: { user: true } }, expenseNotes: true, addedByUser: true, paidByUser: true, deletedByUser: true, updatedByUser: true, group: true, - recurrence: { - include: { - job: { - select: { - schedule: true, - command: true, - }, - }, - }, - }, - conversionTo: { - include: { - expenseParticipants: { - include: { - user: true, - }, - }, - }, - }, + recurrence: { with: { job: { columns: { schedule: true, command: true } } } }, + conversionTo: { with: { expenseParticipants: { with: { user: true } } } }, }, }); + if (expense && !(await canAccessExpense(expense.id, ctx.session.user.id))) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Expense not found' }); + } + if (expense && expense.groupId !== null) { - const missingGroupMembers = await db.group.findUnique({ - where: { - id: expense.groupId, - }, - include: { + const participantIds = expense.expenseParticipants.map((ep) => ep.userId); + const missingGroupMembers = await db.query.groups.findFirst({ + where: eq(groups.id, expense.groupId), + with: { groupUsers: { - include: { - user: true, - }, - where: { - userId: { - notIn: expense.expenseParticipants.map((ep) => ep.userId), - }, - }, + where: (gu) => notInArray(gu.userId, participantIds), + with: { user: true }, }, }, }); @@ -425,68 +371,34 @@ export const expenseRouter = createTRPCRouter({ }), getAllExpenses: protectedProcedure.query(async ({ ctx }) => { - const expenses = await db.expenseParticipant.findMany({ - where: { - userId: ctx.session.user.id, - }, - orderBy: { - expense: { - createdAt: 'desc', - }, - }, - include: { - expense: { - include: { - paidByUser: { - select: { - name: true, - email: true, - image: true, - id: true, - }, - }, - deletedByUser: { - select: { - name: true, - email: true, - image: true, - id: true, - }, + const expenses = ( + await db.query.expenseParticipants.findMany({ + where: eq(expenseParticipants.userId, ctx.session.user.id), + with: { + expense: { + with: { + paidByUser: { columns: userSummaryColumns }, + deletedByUser: { columns: userSummaryColumns }, }, }, }, - }, - }); + }) + ).sort((a, b) => b.expense.createdAt.getTime() - a.expense.createdAt.getTime()); return expenses; }), getRecurringExpenses: protectedProcedure.query(async ({ ctx }) => { - const recurrences = await db.expenseRecurrence.findMany({ - include: { + const me = ctx.session.user.id; + const recurrences = await db.query.expenseRecurrences.findMany({ + with: { job: true, expense: { - take: 1, - orderBy: { createdAt: 'desc' }, - where: { - deletedBy: null, - expenseParticipants: { - some: { - userId: ctx.session.user.id, - }, - }, - recurrenceId: { not: null }, - }, - include: { - addedByUser: { - select: { - name: true, - email: true, - image: true, - id: true, - }, - }, - }, + limit: 1, + orderBy: (e) => [desc(e.createdAt)], + where: (e) => + and(isNull(e.deletedBy), hasParticipant(e.id, me), isNotNull(e.recurrenceId)), + with: { addedByUser: { columns: userSummaryColumns } }, }, }, }); @@ -499,56 +411,26 @@ export const expenseRouter = createTRPCRouter({ deleteRecurrence: protectedProcedure .input(z.object({ recurrenceId: z.number() })) .mutation(async ({ input, ctx }) => { - const recurrence = await db.expenseRecurrence.findUnique({ - where: { id: input.recurrenceId }, - include: { - job: true, - expense: { - where: { - expenseParticipants: { - some: { userId: ctx.session.user.id }, - }, - }, - take: 1, - }, - }, - }); + const recurrence = await findUserRecurrence(input.recurrenceId, ctx.session.user.id); if (!recurrence || 0 === recurrence.expense.length) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Recurrence not found' }); } - // Unschedule the cron job - await db.$executeRaw`SELECT cron.unschedule(${recurrence.job.jobname})`; - - // Delete the recurrence (cascade nulls recurrenceId on expenses) - await db.expenseRecurrence.delete({ where: { id: input.recurrenceId } }); + // Deleting the job cascades to the recurrence, which nulls recurrenceId on expenses. + await deleteJob(recurrence.job.jobid); }), updateRecurrence: protectedProcedure .input(z.object({ recurrenceId: z.number(), cronExpression: z.string() })) .mutation(async ({ input, ctx }) => { - const recurrence = await db.expenseRecurrence.findUnique({ - where: { id: input.recurrenceId }, - include: { - job: true, - expense: { - where: { - expenseParticipants: { - some: { userId: ctx.session.user.id }, - }, - }, - take: 1, - }, - }, - }); + const recurrence = await findUserRecurrence(input.recurrenceId, ctx.session.user.id); if (!recurrence || 0 === recurrence.expense.length) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Recurrence not found' }); } - // Use cron.alter_job to update the schedule - await db.$executeRaw`SELECT cron.alter_job(${recurrence.job.jobid}, schedule := ${input.cronExpression})`; + await updateJobSchedule(recurrence.job.jobid, input.cronExpression); }), deleteExpense: protectedProcedure @@ -556,9 +438,9 @@ export const expenseRouter = createTRPCRouter({ .mutation(async ({ input, ctx }) => { await validateEditExpensePermission(input.expenseId, ctx.session.user.id); - const expense = await db.expense.findUnique({ - where: { id: input.expenseId }, - select: { groupId: true }, + const expense = await db.query.expenses.findFirst({ + where: eq(expensesTable.id, input.expenseId), + columns: { groupId: true }, }); if (!expense) { @@ -566,16 +448,7 @@ export const expenseRouter = createTRPCRouter({ } if (expense.groupId !== null) { - const group = await db.group.findUnique({ - where: { id: expense.groupId }, - select: { archivedAt: true }, - }); - if (!group) { - throw new TRPCError({ code: 'BAD_REQUEST', message: 'Group not found' }); - } - if (group.archivedAt) { - throw new TRPCError({ code: 'BAD_REQUEST', message: 'Group is archived' }); - } + await assertCanWriteToGroup(expense.groupId, ctx.session.user.id); } await deleteExpense(input.expenseId, ctx.session.user.id); @@ -626,20 +499,40 @@ export const expenseRouter = createTRPCRouter({ }), }); -const validateEditExpensePermission = async (expenseId: string, userId: number): Promise => { - const [expenseParticipant, addedBy] = await Promise.all([ - db.expenseParticipant.findUnique({ - where: { - expenseId_userId: { - expenseId: expenseId, - userId: userId, - }, - }, +/** + * Who may see or change an expense: its payer, the user who added it, a participant, or (for + * group expenses) any member of the group. + */ +const canAccessExpense = async (expenseId: string, userId: number): Promise => { + const expense = await db.query.expenses.findFirst({ + where: eq(expensesTable.id, expenseId), + columns: { paidBy: true, addedBy: true, groupId: true }, + }); + if (!expense) { + return false; + } + if (expense.paidBy === userId || expense.addedBy === userId) { + return true; + } + + const [participant, member] = await Promise.all([ + db.query.expenseParticipants.findFirst({ + where: and( + eq(expenseParticipants.expenseId, expenseId), + eq(expenseParticipants.userId, userId), + ), }), - db.expense.findUnique({ where: { id: expenseId }, select: { addedBy: true } }), + expense.groupId === null + ? undefined + : db.query.groupUsers.findFirst({ + where: and(eq(groupUsers.groupId, expense.groupId), eq(groupUsers.userId, userId)), + }), ]); + return Boolean(participant ?? member); +}; - if (!expenseParticipant && !addedBy?.addedBy) { +const validateEditExpensePermission = async (expenseId: string, userId: number): Promise => { + if (!(await canAccessExpense(expenseId, userId))) { throw new TRPCError({ code: 'UNAUTHORIZED', message: 'You are not the participant of the expense', @@ -647,4 +540,33 @@ const validateEditExpensePermission = async (expenseId: string, userId: number): } }; +/** The group must exist, be active, and the user must be a member. */ +const assertCanWriteToGroup = async (groupId: number, userId: number): Promise => { + const group = await db.query.groups.findFirst({ + where: eq(groups.id, groupId), + columns: { archivedAt: true }, + }); + if (!group) { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'Group not found' }); + } + const membership = await db.query.groupUsers.findFirst({ + where: and(eq(groupUsers.groupId, groupId), eq(groupUsers.userId, userId)), + }); + if (!membership) { + throw new TRPCError({ code: 'FORBIDDEN', message: 'Not a group member' }); + } + if (group.archivedAt) { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'Group is archived' }); + } +}; + +const findUserRecurrence = (recurrenceId: number, userId: number) => + db.query.expenseRecurrences.findFirst({ + where: (r) => eq(r.id, recurrenceId), + with: { + job: true, + expense: { where: (e) => hasParticipant(e.id, userId), limit: 1 }, + }, + }); + export type ExpenseRouter = typeof expenseRouter; diff --git a/src/server/api/routers/group.ts b/src/server/api/routers/group.ts index 99f01455b..0806c7ad5 100644 --- a/src/server/api/routers/group.ts +++ b/src/server/api/routers/group.ts @@ -1,11 +1,16 @@ import { TRPCError } from '@trpc/server'; +import { and, desc, eq, inArray, isNull, ne, sql } from 'drizzle-orm'; import { nanoid } from 'nanoid'; import { z } from 'zod'; import { simplifyDebts } from '~/lib/simplify'; import { createTRPCRouter, groupProcedure, protectedProcedure } from '~/server/api/trpc'; import { sendGroupSimplifyDebtsToggleNotification } from '~/server/api/services/notificationService'; -import { SplitType } from '@prisma/client'; +import { SplitType } from '~/server/db/types'; +import { runBatch } from '~/server/db/batch'; +import { balanceView, expenses, groupDefaultSplits, groupUsers, groups } from '~/server/db/schema'; +import type { DB } from '~/server/db'; +import { runInBackground } from '~/server/observability/background'; import { defaultSplitInputSchema, deserializeDefaultSplit, @@ -17,42 +22,32 @@ export const groupRouter = createTRPCRouter({ create: protectedProcedure .input(z.object({ name: z.string().min(1) })) .mutation(async ({ ctx, input }) => { - const group = await ctx.db.group.create({ - data: { - name: input.name, - publicId: nanoid(), + const publicId = nanoid(); + const [[group]] = (await runBatch([ + ctx.db + .insert(groups) + .values({ name: input.name, publicId, userId: ctx.session.user.id }) + .returning(), + ctx.db.insert(groupUsers).values({ + groupId: sql`(SELECT ${groups.id} FROM ${groups} WHERE ${groups.publicId} = ${publicId})`, userId: ctx.session.user.id, - groupUsers: { - create: { - userId: ctx.session.user.id, - }, - }, - }, - }); + }), + ])) as [(typeof groups.$inferSelect)[]]; - return group; + return group!; }), getAllGroups: protectedProcedure.query(async ({ ctx }) => { - const groups = await ctx.db.groupUser.findMany({ - where: { - userId: ctx.session.user.id, - }, - include: { + const userGroups = await ctx.db.query.groupUsers.findMany({ + where: eq(groupUsers.userId, ctx.session.user.id), + with: { group: { - include: { - groupUsers: { - include: { - user: true, - }, - }, - groupDefaultSplit: true, - }, + with: { groupUsers: { with: { user: true } }, groupDefaultSplit: true }, }, }, }); - return groups.map((groupUser) => ({ + return userGroups.map((groupUser) => ({ ...groupUser, group: { ...groupUser.group, @@ -69,30 +64,37 @@ export const groupRouter = createTRPCRouter({ getAllGroupsWithBalances: protectedProcedure .input(z.object({ getArchived: z.boolean() }).optional()) .query(async ({ ctx, input }) => { - const groups = await ctx.db.groupUser.findMany({ - where: { - userId: ctx.session.user.id, - group: { - archivedAt: input?.getArchived ? { not: null } : null, - }, - }, - include: { + const me = ctx.session.user.id; + const memberships = await ctx.db.query.groupUsers.findMany({ + where: eq(groupUsers.userId, me), + with: { group: { - include: { - groupBalances: { - where: { userId: ctx.session.user.id }, - }, - // We can sort by group balance view instead - expenses: { - orderBy: { - createdAt: 'desc', - }, - take: 1, - }, - }, + with: { expenses: { orderBy: (e) => [desc(e.createdAt)], limit: 1 } }, }, }, }); + const filtered = memberships.filter((m) => + input?.getArchived ? null !== m.group.archivedAt : null === m.group.archivedAt, + ); + const myBalances = + 0 === filtered.length + ? [] + : await ctx.db + .select() + .from(balanceView) + .where( + and( + eq(balanceView.userId, me), + inArray( + balanceView.groupId, + filtered.map((m) => m.groupId), + ), + ), + ); + const groups = filtered.map((m) => ({ + ...m, + group: { ...m.group, groupBalances: myBalances.filter((b) => b.groupId === m.groupId) }, + })); const sortedGroupsByLatestExpense = groups.sort((a, b) => { const aDate = a.group.expenses[0]?.createdAt ?? new Date(0); @@ -119,43 +121,28 @@ export const groupRouter = createTRPCRouter({ joinGroup: protectedProcedure .input(z.object({ groupId: z.string() })) .mutation(async ({ ctx, input }) => { - const group = await ctx.db.group.findFirst({ - where: { - publicId: input.groupId, - }, + const group = await ctx.db.query.groups.findFirst({ + where: eq(groups.publicId, input.groupId), }); if (!group) { throw new Error('Group not found'); } - await ctx.db.groupUser.create({ - data: { - groupId: group.id, - userId: ctx.session.user.id, - }, - }); - - await ctx.db.groupDefaultSplit.deleteMany({ where: { groupId: group.id } }); + await runBatch([ + ctx.db.insert(groupUsers).values({ groupId: group.id, userId: ctx.session.user.id }), + ctx.db.delete(groupDefaultSplits).where(eq(groupDefaultSplits.groupId, group.id)), + ]); return group; }), getGroupDetails: groupProcedure.query(async ({ input, ctx }) => { - const group = await ctx.db.group.findUnique({ - where: { - id: input.groupId, - }, - include: { - groupUsers: { - include: { - user: true, - }, - }, - groupBalances: true, - groupDefaultSplit: true, - }, + const found = await ctx.db.query.groups.findFirst({ + where: eq(groups.id, input.groupId), + with: { groupUsers: { with: { user: true } }, groupDefaultSplit: true }, }); + const group = found && { ...found, groupBalances: await groupBalancesOf(ctx.db, found.id) }; if (!group) { return group; @@ -179,19 +166,22 @@ export const groupRouter = createTRPCRouter({ }), getGroupTotals: groupProcedure.query(async ({ input, ctx }) => { - const totals = await ctx.db.expense.groupBy({ - by: 'currency', - _sum: { - amount: true, - }, - where: { - groupId: input.groupId, - deletedAt: null, - splitType: { - not: SplitType.SETTLEMENT, - }, - }, - }); + const totals = ( + await ctx.db + .select({ + currency: expenses.currency, + amount: sql`SUM(${expenses.amount})`.mapWith((v) => BigInt(v ?? 0)), + }) + .from(expenses) + .where( + and( + eq(expenses.groupId, input.groupId), + isNull(expenses.deletedAt), + ne(expenses.splitType, SplitType.SETTLEMENT), + ), + ) + .groupBy(expenses.currency) + ).map((t) => ({ currency: t.currency, _sum: { amount: t.amount } })); return totals; }), @@ -199,26 +189,25 @@ export const groupRouter = createTRPCRouter({ addMembers: groupProcedure .input(z.object({ userIds: z.array(z.number()) })) .mutation(async ({ input, ctx }) => { - const groupUsers = await ctx.db.groupUser.createMany({ - data: input.userIds.map((userId) => ({ - groupId: input.groupId, - userId, - })), - }); - - await ctx.db.groupDefaultSplit.deleteMany({ where: { groupId: input.groupId } }); - - return groupUsers; + await runBatch([ + ...(input.userIds.length + ? [ + ctx.db + .insert(groupUsers) + .values(input.userIds.map((userId) => ({ groupId: input.groupId, userId }))), + ] + : []), + ctx.db.delete(groupDefaultSplits).where(eq(groupDefaultSplits.groupId, input.groupId)), + ]); + const created = { count: input.userIds.length }; + + return created; }), toggleSimplifyDebts: groupProcedure .input(z.object({ groupId: z.number() })) .mutation(async ({ input, ctx }) => { - const group = await ctx.db.group.findUnique({ - where: { - id: input.groupId, - }, - }); + const group = await ctx.db.query.groups.findFirst({ where: eq(groups.id, input.groupId) }); if (!group) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Group not found' }); @@ -231,11 +220,11 @@ export const groupRouter = createTRPCRouter({ }); } - const isInGroup = await ctx.db.groupUser.findFirst({ - where: { - groupId: input.groupId, - userId: ctx.session.user.id, - }, + const isInGroup = await ctx.db.query.groupUsers.findFirst({ + where: and( + eq(groupUsers.groupId, input.groupId), + eq(groupUsers.userId, ctx.session.user.id), + ), }); if (!isInGroup) { @@ -247,20 +236,19 @@ export const groupRouter = createTRPCRouter({ const simplifyDebtsInv = !group.simplifyDebts; - await ctx.db.group.update({ - where: { - id: input.groupId, - }, - data: { - simplifyDebts: simplifyDebtsInv, - }, - }); + await ctx.db + .update(groups) + .set({ simplifyDebts: simplifyDebtsInv }) + .where(eq(groups.id, input.groupId)); // Send notifications asynchronously - void sendGroupSimplifyDebtsToggleNotification( - input.groupId, - ctx.session.user.id, - simplifyDebtsInv, + runInBackground( + 'push:simplify-debts', + sendGroupSimplifyDebtsToggleNotification( + input.groupId, + ctx.session.user.id, + simplifyDebtsInv, + ), ); return simplifyDebts; @@ -271,11 +259,7 @@ export const groupRouter = createTRPCRouter({ .mutation(async ({ input, ctx }) => { const userId = input.userId ?? ctx.session.user.id; - const group = await ctx.db.group.findUnique({ - where: { - id: input.groupId, - }, - }); + const group = await ctx.db.query.groups.findFirst({ where: eq(groups.id, input.groupId) }); if (!group) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Group not found' }); } @@ -287,9 +271,7 @@ export const groupRouter = createTRPCRouter({ }); } - const groupBalances = await ctx.db.balanceView.findMany({ - where: { groupId: input.groupId }, - }); + const groupBalances = await groupBalancesOf(ctx.db, input.groupId); const finalGroupBalances = group.simplifyDebts ? simplifyDebts(groupBalances) : groupBalances; @@ -300,16 +282,13 @@ export const groupRouter = createTRPCRouter({ }); } - const groupUser = await ctx.db.groupUser.delete({ - where: { - groupId_userId: { - groupId: input.groupId, - userId, - }, - }, - }); - - await ctx.db.groupDefaultSplit.deleteMany({ where: { groupId: input.groupId } }); + const [[groupUser]] = (await runBatch([ + ctx.db + .delete(groupUsers) + .where(and(eq(groupUsers.groupId, input.groupId), eq(groupUsers.userId, userId))) + .returning(), + ctx.db.delete(groupDefaultSplits).where(eq(groupDefaultSplits.groupId, input.groupId)), + ])) as [(typeof groupUsers.$inferSelect)[]]; return groupUser; }), @@ -329,21 +308,25 @@ export const groupRouter = createTRPCRouter({ const serialized = serializeDefaultSplit(parsed); - const groupDefaultSplit = await ctx.db.groupDefaultSplit.upsert({ - where: { groupId: input.groupId }, - create: { + const [groupDefaultSplit] = await ctx.db + .insert(groupDefaultSplits) + .values({ groupId: input.groupId, splitType: serialized.splitType, shares: serialized.shares, - }, - update: { - splitType: serialized.splitType, - shares: serialized.shares, - }, - }); + }) + .onConflictDoUpdate({ + target: groupDefaultSplits.groupId, + set: { + splitType: serialized.splitType, + shares: serialized.shares, + updatedAt: new Date(), + }, + }) + .returning(); return { - splitType: groupDefaultSplit.splitType, + splitType: groupDefaultSplit!.splitType, shares: serialized.shares, }; }), @@ -351,7 +334,7 @@ export const groupRouter = createTRPCRouter({ clearDefaultSplit: groupProcedure .input(z.object({ groupId: z.number() })) .mutation(async ({ input, ctx }) => { - await ctx.db.groupDefaultSplit.deleteMany({ where: { groupId: input.groupId } }); + await ctx.db.delete(groupDefaultSplits).where(eq(groupDefaultSplits.groupId, input.groupId)); return true; }), @@ -365,26 +348,17 @@ export const groupRouter = createTRPCRouter({ }), ) .mutation(async ({ input, ctx }) => { - const group = await ctx.db.group.findUnique({ - where: { - id: input.groupId, - }, - }); + const group = await ctx.db.query.groups.findFirst({ where: eq(groups.id, input.groupId) }); if (!group) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Group not found' }); } - const updatedGroup = await ctx.db.group.update({ - where: { - id: input.groupId, - }, - data: { - name: input.name, - image: input.image, - defaultCurrency: input.defaultCurrency, - }, - }); + const [updatedGroup] = await ctx.db + .update(groups) + .set({ name: input.name, image: input.image, defaultCurrency: input.defaultCurrency }) + .where(eq(groups.id, input.groupId)) + .returning(); return updatedGroup; }), @@ -392,25 +366,19 @@ export const groupRouter = createTRPCRouter({ toggleArchive: groupProcedure .input(z.object({ groupId: z.number() })) .mutation(async ({ input, ctx }) => { - const group = await ctx.db.group.findUnique({ - where: { - id: input.groupId, - }, - include: { - groupBalances: true, - }, - }); + const found = await ctx.db.query.groups.findFirst({ where: eq(groups.id, input.groupId) }); + const group = found && { ...found, groupBalances: await groupBalancesOf(ctx.db, found.id) }; if (!group) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Group not found' }); } // Check if user is a member of the group - const isInGroup = await ctx.db.groupUser.findFirst({ - where: { - groupId: input.groupId, - userId: ctx.session.user.id, - }, + const isInGroup = await ctx.db.query.groupUsers.findFirst({ + where: and( + eq(groupUsers.groupId, input.groupId), + eq(groupUsers.userId, ctx.session.user.id), + ), }); if (!isInGroup) { @@ -439,14 +407,11 @@ export const groupRouter = createTRPCRouter({ } } - const updatedGroup = await ctx.db.group.update({ - where: { - id: input.groupId, - }, - data: { - archivedAt: isArchiving ? new Date() : null, - }, - }); + const [updatedGroup] = await ctx.db + .update(groups) + .set({ archivedAt: isArchiving ? new Date() : null }) + .where(eq(groups.id, input.groupId)) + .returning(); return updatedGroup; }), @@ -454,14 +419,8 @@ export const groupRouter = createTRPCRouter({ delete: groupProcedure .input(z.object({ groupId: z.number() })) .mutation(async ({ input, ctx }) => { - const group = await ctx.db.group.findUnique({ - where: { - id: input.groupId, - }, - include: { - groupBalances: true, - }, - }); + const found = await ctx.db.query.groups.findFirst({ where: eq(groups.id, input.groupId) }); + const group = found && { ...found, groupBalances: await groupBalancesOf(ctx.db, found.id) }; if (group?.userId !== ctx.session.user.id) { throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Only creator can delete the group' }); @@ -480,14 +439,13 @@ export const groupRouter = createTRPCRouter({ }); } - await ctx.db.group.delete({ - where: { - id: input.groupId, - }, - }); + await ctx.db.delete(groups).where(eq(groups.id, input.groupId)); return group; }), }); +const groupBalancesOf = (database: DB, groupId: number) => + database.select().from(balanceView).where(eq(balanceView.groupId, groupId)); + export type GroupRouter = typeof groupRouter; diff --git a/src/server/api/routers/user.ts b/src/server/api/routers/user.ts index 1b7e9a87f..eff17feec 100644 --- a/src/server/api/routers/user.ts +++ b/src/server/api/routers/user.ts @@ -1,5 +1,5 @@ import { TRPCError } from '@trpc/server'; -import { type User } from 'next-auth'; +import type { SessionUser as User } from '~/server/auth'; import { z } from 'zod'; import { env } from '~/env'; @@ -10,7 +10,18 @@ import { } from '~/lib/defaultSplit'; import { simplifyDebts } from '~/lib/simplify'; import { createTRPCRouter, protectedProcedure } from '~/server/api/trpc'; +import { and, desc, eq, inArray, isNull, ne, notInArray, sql } from 'drizzle-orm'; + import { db } from '~/server/db'; +import { runInBackground } from '~/server/observability/background'; +import { + balanceView, + expenses, + friendDefaultSplits, + groups, + pushNotifications, + users, +} from '~/server/db/schema'; import { sendFeedbackEmail, sendInviteEmail } from '~/server/mailer'; import { SplitwiseGroupSchema, SplitwiseUserSchema } from '~/types'; @@ -29,56 +40,53 @@ export const userRouter = createTRPCRouter({ me: protectedProcedure.query(({ ctx }) => ctx.session.user), getFriends: protectedProcedure.query(async ({ ctx }) => { - const friends = await db.balanceView.findMany({ - where: { userId: ctx.session.user.id, friendId: { notIn: ctx.session.user.hiddenFriendIds } }, - include: { friend: true }, - distinct: ['friendId'], - }); + const friends = await db + .selectDistinct({ friend: users }) + .from(balanceView) + .innerJoin(users, eq(users.id, balanceView.friendId)) + .where( + and( + eq(balanceView.userId, ctx.session.user.id), + notInArray(balanceView.friendId, ctx.session.user.hiddenFriendIds), + ), + ); return friends.map((f) => f.friend); }), getOwnExpenses: protectedProcedure.query(async ({ ctx }) => { - const expenses = await db.expense.findMany({ - where: { - paidBy: ctx.session.user.id, - deletedBy: null, - }, - orderBy: { - expenseDate: 'desc', - }, - include: { - group: true, - }, + const ownExpenses = await db.query.expenses.findMany({ + where: and(eq(expenses.paidBy, ctx.session.user.id), isNull(expenses.deletedBy)), + orderBy: [desc(expenses.expenseDate)], + with: { group: true }, }); - return expenses; + return ownExpenses; }), inviteFriend: protectedProcedure .input(z.object({ email: z.string(), sendInviteEmail: z.boolean().optional() })) .mutation(async ({ input, ctx: { session } }) => { - const friend = await db.user.findUnique({ - where: { - email: input.email, - }, - }); + const email = input.email.toLowerCase(); + const friend = await db.query.users.findFirst({ where: eq(users.email, email) }); if (friend) { return friend; } - const user = await db.user.create({ - data: { - email: input.email, - name: input.email.split('@')[0], - }, - }); + const [user] = await db + .insert(users) + .values({ email, name: email.split('@')[0] }) + .returning(); + if (!user) { + throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Failed to create user' }); + } if (input.sendInviteEmail) { - sendInviteEmail(input.email, session.user.name ?? session.user.email ?? '').catch((err) => { - console.error('Error sending invite email', err); - }); + runInBackground( + 'invite-email', + sendInviteEmail(input.email, session.user.name ?? session.user.email ?? ''), + ); } return user; @@ -87,21 +95,22 @@ export const userRouter = createTRPCRouter({ getBalancesWithFriend: protectedProcedure .input(z.object({ friendId: z.number() })) .query(async ({ input, ctx }) => { - const rawBalances = await db.balanceView.findMany({ - where: { - userId: ctx.session.user.id, - friendId: input.friendId, - amount: { not: 0 }, - }, - include: { - group: { - select: { - name: true, - simplifyDebts: true, - }, - }, - }, - }); + const rawBalances = await db + .select({ + groupId: balanceView.groupId, + currency: balanceView.currency, + amount: balanceView.amount, + group: { name: groups.name, simplifyDebts: groups.simplifyDebts }, + }) + .from(balanceView) + .leftJoin(groups, eq(groups.id, balanceView.groupId)) + .where( + and( + eq(balanceView.userId, ctx.session.user.id), + eq(balanceView.friendId, input.friendId), + ne(balanceView.amount, 0n), + ), + ); const processedBalances = await Promise.all( rawBalances.map(async ({ groupId, currency, amount, group }) => { @@ -117,9 +126,10 @@ export const userRouter = createTRPCRouter({ } // For simplifyDebts groups, fetch all group balances and simplify - const allGroupBalances = await db.balanceView.findMany({ - where: { groupId, currency }, - }); + const allGroupBalances = await db + .select() + .from(balanceView) + .where(and(eq(balanceView.groupId, groupId), eq(balanceView.currency, currency))); const simplified = simplifyDebts(allGroupBalances); @@ -156,14 +166,11 @@ export const userRouter = createTRPCRouter({ }), ) .mutation(async ({ input, ctx }) => { - const user = await db.user.update({ - where: { - id: ctx.session.user.id, - }, - data: { - ...input, - }, - }); + const [user] = await db + .update(users) + .set(input) + .where(eq(users.id, ctx.session.user.id)) + .returning(); return user; }), @@ -171,11 +178,7 @@ export const userRouter = createTRPCRouter({ getUserDetails: protectedProcedure .input(z.object({ userId: z.number() })) .query(async ({ input }) => { - const user = await db.user.findUnique({ - where: { - id: input.userId, - }, - }); + const user = await db.query.users.findFirst({ where: eq(users.id, input.userId) }); return user; }), @@ -183,21 +186,25 @@ export const userRouter = createTRPCRouter({ submitFeedback: protectedProcedure .input(z.object({ feedback: z.string().min(10) })) .mutation(async ({ input, ctx }) => { - await sendFeedbackEmail(input.feedback, ctx.session.user as User); + await sendFeedbackEmail(input.feedback, ctx.session.user); }), getFriend: protectedProcedure .input(z.object({ friendId: z.number() })) .query(async ({ input, ctx }) => { - const friend = await db.user.findUnique({ - where: { - id: input.friendId, - userBalances: { - some: { - friendId: ctx.session.user.id, - }, - }, - }, + const friend = await db.query.users.findFirst({ + // Uncorrelated IN subquery: relational queries alias the outer table, so a correlated + // Reference to `users.id` would render as "User"."id" and fail on D1. + where: and( + eq(users.id, input.friendId), + inArray( + users.id, + db + .select({ userId: balanceView.userId }) + .from(balanceView) + .where(eq(balanceView.friendId, ctx.session.user.id)), + ), + ), }); if (!friend) { @@ -206,13 +213,11 @@ export const userRouter = createTRPCRouter({ const [userAId, userBId] = toSortedFriendPair(ctx.session.user.id, input.friendId); - const friendDefaultSplit = await db.friendDefaultSplit.findUnique({ - where: { - userAId_userBId: { - userAId, - userBId, - }, - }, + const friendDefaultSplit = await db.query.friendDefaultSplits.findFirst({ + where: and( + eq(friendDefaultSplits.userAId, userAId), + eq(friendDefaultSplits.userBId, userBId), + ), }); const defaultSplit = @@ -248,15 +253,19 @@ export const userRouter = createTRPCRouter({ }), ) .mutation(async ({ input, ctx }) => { - const friend = await db.user.findUnique({ - where: { - id: input.friendId, - userBalances: { - some: { - friendId: ctx.session.user.id, - }, - }, - }, + const friend = await db.query.users.findFirst({ + // Uncorrelated IN subquery: relational queries alias the outer table, so a correlated + // Reference to `users.id` would render as "User"."id" and fail on D1. + where: and( + eq(users.id, input.friendId), + inArray( + users.id, + db + .select({ userId: balanceView.userId }) + .from(balanceView) + .where(eq(balanceView.friendId, ctx.session.user.id)), + ), + ), }); if (!friend) { @@ -271,19 +280,17 @@ export const userRouter = createTRPCRouter({ const [userAId, userBId] = toSortedFriendPair(ctx.session.user.id, input.friendId); const serialized = serializeDefaultSplit(parsed); - await db.friendDefaultSplit.upsert({ - where: { userAId_userBId: { userAId, userBId } }, - create: { - userAId, - userBId, - splitType: serialized.splitType, - shares: serialized.shares, - }, - update: { - splitType: serialized.splitType, - shares: serialized.shares, - }, - }); + await db + .insert(friendDefaultSplits) + .values({ userAId, userBId, splitType: serialized.splitType, shares: serialized.shares }) + .onConflictDoUpdate({ + target: [friendDefaultSplits.userAId, friendDefaultSplits.userBId], + set: { + splitType: serialized.splitType, + shares: serialized.shares, + updatedAt: new Date(), + }, + }); return serialized; }), @@ -292,7 +299,11 @@ export const userRouter = createTRPCRouter({ .input(z.object({ friendId: z.number() })) .mutation(async ({ input, ctx }) => { const [userAId, userBId] = toSortedFriendPair(ctx.session.user.id, input.friendId); - await db.friendDefaultSplit.deleteMany({ where: { userAId, userBId } }); + await db + .delete(friendDefaultSplits) + .where( + and(eq(friendDefaultSplits.userAId, userAId), eq(friendDefaultSplits.userBId, userBId)), + ); return true; }), @@ -308,22 +319,13 @@ export const userRouter = createTRPCRouter({ }); } - await db.pushNotification.upsert({ - where: { - userId_endpoint: { - userId: ctx.session.user.id, - endpoint, - }, - }, - create: { - userId: ctx.session.user.id, - endpoint, - subscription: input.subscription, - }, - update: { - subscription: input.subscription, - }, - }); + await db + .insert(pushNotifications) + .values({ userId: ctx.session.user.id, endpoint, subscription: input.subscription }) + .onConflictDoUpdate({ + target: [pushNotifications.userId, pushNotifications.endpoint], + set: { subscription: input.subscription }, + }); }), deletePushNotification: protectedProcedure @@ -334,15 +336,14 @@ export const userRouter = createTRPCRouter({ return; } - await db.pushNotification - .delete({ - where: { - userId_endpoint: { - userId: ctx.session.user.id, - endpoint, - }, - }, - }) + await db + .delete(pushNotifications) + .where( + and( + eq(pushNotifications.userId, ctx.session.user.id), + eq(pushNotifications.endpoint, endpoint), + ), + ) .catch(() => null); }), @@ -361,22 +362,19 @@ export const userRouter = createTRPCRouter({ deleteFriend: protectedProcedure .input(z.object({ friendId: z.number() })) .mutation(async ({ input, ctx }) => { - const friendBalances = await db.balanceView.groupBy({ - by: ['currency'], - _sum: { amount: true }, - where: { - userId: ctx.session.user.id, - friendId: input.friendId, - amount: { not: 0 }, - }, - having: { - amount: { - _sum: { - not: 0, - }, - }, - }, - }); + const total = sql`SUM(${balanceView.amount})`; + const friendBalances = await db + .select({ currency: balanceView.currency, amount: total }) + .from(balanceView) + .where( + and( + eq(balanceView.userId, ctx.session.user.id), + eq(balanceView.friendId, input.friendId), + ne(balanceView.amount, 0n), + ), + ) + .groupBy(balanceView.currency) + .having(sql`${total} != 0`); if (0 < friendBalances.length) { throw new TRPCError({ @@ -385,16 +383,18 @@ export const userRouter = createTRPCRouter({ }); } - await db.user.update({ - where: { - id: ctx.session.user.id, - }, - data: { - hiddenFriendIds: { - push: input.friendId, - }, - }, - }); + // Atomic JSON array append (no read-modify-write race). + await db + .update(users) + .set({ + hiddenFriendIds: sql`( + SELECT json_group_array(value) FROM ( + SELECT value FROM json_each(${users.hiddenFriendIds}) + UNION SELECT ${input.friendId} + ) + )`, + }) + .where(eq(users.id, ctx.session.user.id)); }), downloadData: protectedProcedure.mutation(async ({ ctx }) => { @@ -422,13 +422,10 @@ export const userRouter = createTRPCRouter({ }); export const getUserMap = async (userIds: number[]) => { - const users = await db.user.findMany({ - where: { - id: { in: userIds }, - }, - }); + const found = + 0 === userIds.length ? [] : await db.select().from(users).where(inArray(users.id, userIds)); - return Object.fromEntries(users.map((u) => [u.id, u])); + return Object.fromEntries(found.map((u) => [u.id, u])); }; export type UserRouter = typeof userRouter; diff --git a/src/server/api/services/bankTransactions/gocardless.ts b/src/server/api/services/bankTransactions/gocardless.ts index e4be40eaa..6fa0a7bdf 100644 --- a/src/server/api/services/bankTransactions/gocardless.ts +++ b/src/server/api/services/bankTransactions/gocardless.ts @@ -4,7 +4,10 @@ import { TRPCError } from '@trpc/server'; import { format, subDays } from 'date-fns'; import NordigenClient, { type Transaction } from 'nordigen-node'; import { env } from '~/env'; +import { and, eq } from 'drizzle-orm'; + import { db } from '~/server/db'; +import { cachedBankData } from '~/server/db/schema'; import type { TransactionOutput, TransactionOutputItem } from '~/types/bank.types'; abstract class AbstractBankProvider { @@ -64,8 +67,11 @@ export class GoCardlessService extends AbstractBankProvider { const requisitionData = await this.client.requisition.getRequisitionById(requisitionId); - const cachedData = await db.cachedBankData.findUnique({ - where: { obapiProviderId: requisitionId, userId }, + const cachedData = await db.query.cachedBankData.findFirst({ + where: and( + eq(cachedBankData.obapiProviderId, requisitionId), + eq(cachedBankData.userId, userId), + ), }); if (cachedData) { @@ -112,24 +118,16 @@ export class GoCardlessService extends AbstractBankProvider { const formattedTransactions: TransactionOutput = this.mergeTransactions(allTransactions); - const data = { - obapiProviderId: requisitionId ?? '', - data: JSON.stringify(formattedTransactions), - lastFetched: new Date(), - user: { - connect: { - id: userId, - }, - }, - }; - - await db.cachedBankData.upsert({ - where: { obapiProviderId: requisitionId ?? '', userId }, - create: data, - update: { - lastFetched: new Date(), - }, - }); + const serialized = JSON.stringify(formattedTransactions); + const now = new Date(); + await db + .insert(cachedBankData) + .values({ obapiProviderId: requisitionId ?? '', data: serialized, lastFetched: now, userId }) + .onConflictDoUpdate({ + target: cachedBankData.obapiProviderId, + set: { data: serialized, lastFetched: now, updatedAt: now }, + where: eq(cachedBankData.userId, userId), + }); return formattedTransactions; } diff --git a/src/server/api/services/bankTransactions/plaid.ts b/src/server/api/services/bankTransactions/plaid.ts index c6df75dc1..36db08cb9 100644 --- a/src/server/api/services/bankTransactions/plaid.ts +++ b/src/server/api/services/bankTransactions/plaid.ts @@ -9,7 +9,10 @@ import { type Transaction, } from 'plaid'; import { env } from '~/env'; +import { and, eq } from 'drizzle-orm'; + import { db } from '~/server/db'; +import { cachedBankData } from '~/server/db/schema'; import type { TransactionOutput, TransactionOutputItem } from '~/types/bank.types'; abstract class AbstractBankProvider { @@ -77,8 +80,11 @@ export class PlaidService extends AbstractBankProvider { return; } - const cachedData = await db.cachedBankData.findUnique({ - where: { obapiProviderId: accessToken, userId }, + const cachedData = await db.query.cachedBankData.findFirst({ + where: and( + eq(cachedBankData.obapiProviderId, accessToken), + eq(cachedBankData.userId, userId), + ), }); if (cachedData) { @@ -111,24 +117,16 @@ export class PlaidService extends AbstractBankProvider { response.data.transactions, ); - const data = { - obapiProviderId: accessToken, - data: JSON.stringify(formattedTransactions), - lastFetched: new Date(), - user: { - connect: { - id: userId, - }, - }, - }; - - await db.cachedBankData.upsert({ - where: { obapiProviderId: accessToken, userId }, - create: data, - update: { - lastFetched: new Date(), - }, - }); + const serialized = JSON.stringify(formattedTransactions); + const now = new Date(); + await db + .insert(cachedBankData) + .values({ obapiProviderId: accessToken, data: serialized, lastFetched: now, userId }) + .onConflictDoUpdate({ + target: cachedBankData.obapiProviderId, + set: { data: serialized, lastFetched: now, updatedAt: now }, + where: eq(cachedBankData.userId, userId), + }); return formattedTransactions; } @@ -213,8 +211,8 @@ export class PlaidService extends AbstractBankProvider { } private formatTransactions(transactions: Transaction[]): TransactionOutput { - const bookedTransactions = transactions.filter((t) => t.pending === false); - const pendingTransactions = transactions.filter((t) => t.pending === true); + const bookedTransactions = transactions.filter((t) => !t.pending); + const pendingTransactions = transactions.filter((t) => t.pending); return { transactions: { diff --git a/src/server/api/services/currencyRateService.ts b/src/server/api/services/currencyRateService.ts index 0b119742b..e08f48ce4 100644 --- a/src/server/api/services/currencyRateService.ts +++ b/src/server/api/services/currencyRateService.ts @@ -1,7 +1,10 @@ import { format, getISODay, isToday, subDays } from 'date-fns'; import { env } from '~/env'; import type { CurrencyCode } from '~/lib/currency'; +import { and, eq } from 'drizzle-orm'; + import { db } from '~/server/db'; +import { cachedCurrencyRates } from '~/server/db/schema'; export interface RateResponse { base: string; @@ -80,39 +83,25 @@ abstract class CurrencyRateProvider { } private upsertCache(from: CurrencyCode, to: CurrencyCode, date: Date, rate: number) { - return db.cachedCurrencyRate.upsert({ - where: { - from_to_date: { from, to, date }, - }, - create: { - from, - to, - date, - rate, - lastFetched: new Date(), - }, - update: { - rate, - lastFetched: new Date(), - }, - }); + const now = new Date(); + return db + .insert(cachedCurrencyRates) + .values({ from, to, date, rate, lastFetched: now }) + .onConflictDoUpdate({ + target: [cachedCurrencyRates.from, cachedCurrencyRates.to, cachedCurrencyRates.date], + set: { rate, lastFetched: now, updatedAt: now }, + }); } private async getCache(from: CurrencyCode, to: CurrencyCode, date: Date) { - const result = await db.cachedCurrencyRate.findUnique({ - where: { - from_to_date: { from, to, date }, - }, - }); + const key = and( + eq(cachedCurrencyRates.from, from), + eq(cachedCurrencyRates.to, to), + eq(cachedCurrencyRates.date, date), + ); + const result = await db.query.cachedCurrencyRates.findFirst({ where: key }); if (result) { - void db.cachedCurrencyRate.update({ - where: { - from_to_date: { from, to, date }, - }, - data: { - lastFetched: new Date(), - }, - }); + void db.update(cachedCurrencyRates).set({ lastFetched: new Date() }).where(key); } return result; } diff --git a/src/server/api/services/notificationService.ts b/src/server/api/services/notificationService.ts index b7a2f2d4a..f01ece806 100644 --- a/src/server/api/services/notificationService.ts +++ b/src/server/api/services/notificationService.ts @@ -1,8 +1,11 @@ -import { SplitType } from '@prisma/client'; +import { SplitType } from '~/server/db/types'; import { isCurrencyCode } from '~/lib/currency'; import { type PushMessage } from '~/types'; +import { and, desc, eq, inArray, or } from 'drizzle-orm'; + import { db } from '~/server/db'; +import { expenseRecurrences, expenses, groups, pushNotifications, users } from '~/server/db/schema'; import { pushNotification } from '~/server/notification'; import { getCurrencyHelpers } from '~/utils/numbers'; @@ -26,14 +29,15 @@ const removeStalePushSubscriptions = async ( return; } - await db.pushNotification.deleteMany({ - where: { - OR: subscriptions.map((subscription) => ({ - userId: subscription.userId, - endpoint: subscription.endpoint, - })), - }, - }); + await db + .delete(pushNotifications) + .where( + or( + ...subscriptions.map((s) => + and(eq(pushNotifications.userId, s.userId), eq(pushNotifications.endpoint, s.endpoint)), + ), + ), + ); }; const isPermanentPushFailure = (statusCode: number | undefined) => @@ -44,13 +48,10 @@ export const sendPushNotificationToUsers = async (userIds: number[], pushData: P return { sentCount: 0 }; } - const subscriptions = await db.pushNotification.findMany({ - where: { - userId: { - in: userIds, - }, - }, - }); + const subscriptions = await db + .select() + .from(pushNotifications) + .where(inArray(pushNotifications.userId, userIds)); const pushResults = await Promise.all( subscriptions.map(async (s) => { @@ -69,11 +70,10 @@ export const sendPushNotificationToUsers = async (userIds: number[], pushData: P }; export async function sendExpensePushNotification(expenseId: string) { - const expense = await db.expense.findUnique({ - where: { - id: expenseId, - }, - select: { + const nameEmail = { columns: { name: true, email: true } } as const; + const expense = await db.query.expenses.findFirst({ + where: eq(expenses.id, expenseId), + columns: { paidBy: true, amount: true, currency: true, @@ -81,42 +81,14 @@ export async function sendExpensePushNotification(expenseId: string) { name: true, deletedBy: true, splitType: true, - deletedByUser: { - select: { - name: true, - email: true, - }, - }, - expenseParticipants: { - select: { - userId: true, - amount: true, - }, - }, - paidByUser: { - select: { - name: true, - email: true, - }, - }, - addedByUser: { - select: { - name: true, - email: true, - }, - }, - updatedByUser: { - select: { - name: true, - email: true, - }, - }, - conversionTo: { - select: { - currency: true, - amount: true, - }, - }, + }, + with: { + deletedByUser: nameEmail, + expenseParticipants: { columns: { userId: true, amount: true } }, + paidByUser: nameEmail, + addedByUser: nameEmail, + updatedByUser: nameEmail, + conversionTo: { columns: { currency: true, amount: true } }, }, }); @@ -207,22 +179,13 @@ export async function sendGroupSimplifyDebtsToggleNotification( newState: boolean, ) { try { - const group = await db.group.findUnique({ - where: { - id: groupId, - }, - select: { - name: true, + const group = await db.query.groups.findFirst({ + where: eq(groups.id, groupId), + columns: { name: true }, + with: { groupUsers: { - select: { - userId: true, - user: { - select: { - name: true, - email: true, - }, - }, - }, + columns: { userId: true }, + with: { user: { columns: { name: true, email: true } } }, }, }, }); @@ -231,14 +194,9 @@ export async function sendGroupSimplifyDebtsToggleNotification( return; } - const togglerUser = await db.user.findUnique({ - where: { - id: togglerUserId, - }, - select: { - name: true, - email: true, - }, + const togglerUser = await db.query.users.findFirst({ + where: eq(users.id, togglerUserId), + columns: { name: true, email: true }, }); if (!togglerUser) { @@ -277,17 +235,13 @@ export async function sendGroupSimplifyDebtsToggleNotification( export async function checkRecurrenceNotifications() { try { - const recurrences = await db.expenseRecurrence.findMany({ - where: { - NOT: { - notified: true, - }, - }, - include: { + const recurrences = await db.query.expenseRecurrences.findMany({ + where: eq(expenseRecurrences.notified, false), + with: { expense: { - select: { id: true }, - orderBy: { createdAt: 'desc' }, - take: 1, + columns: { id: true }, + orderBy: (e) => [desc(e.createdAt)], + limit: 1, }, }, }); @@ -297,19 +251,14 @@ export async function checkRecurrenceNotifications() { .filter((r) => r.expense[0]) .map(async (r) => { await sendExpensePushNotification(r.expense[0]!.id); - await db.expenseRecurrence.update({ - where: { - id: r.id, - }, - data: { - notified: true, - }, - }); + await db + .update(expenseRecurrences) + .set({ notified: true }) + .where(eq(expenseRecurrences.id, r.id)); }), ); } catch (e) { console.error('Error sending recurrence notifications', e); - } finally { - setTimeout(checkRecurrenceNotifications, 1000 * 60); // Check every minute + throw e; } } diff --git a/src/server/api/services/scheduleService.ts b/src/server/api/services/scheduleService.ts index b6a3f1f9f..2f4645d4c 100644 --- a/src/server/api/services/scheduleService.ts +++ b/src/server/api/services/scheduleService.ts @@ -1,24 +1,76 @@ +/** + * Recurring-expense scheduling. Replaces pg_cron. + * + * Each recurring expense owns one row in the `job` table. The scheduler Workflow + * (see worker-entry.ts) runs every minute and executes jobs whose `nextRunAt` is due. + * The `command` column keeps the old pg_cron format so `extractTemplateExpenseId` + * and the UI keep working. + */ +import cronParser from 'cron-parser'; +import { and, eq, lt, lte, sql } from 'drizzle-orm'; + import { db } from '~/server/db'; +import { runBatch } from '~/server/db/batch'; +import { cachedBankData, cachedCurrencyRates, jobs } from '~/server/db/schema'; + +export const recurringJobName = (expenseId: string) => `expense_recurring_${expenseId}`; + +export const recurringJobCommand = (expenseId: string) => + `SELECT duplicate_expense_with_participants('${expenseId}'::UUID);`; + +/** Pg_cron used `$` for "last day of month"; cron-parser uses `L`. Schedules are UTC. */ +export const nextRunAt = (schedule: string, from: Date = new Date()): Date => + cronParser + .parseExpression(schedule.replaceAll('$', 'L'), { currentDate: from, tz: 'UTC' }) + .next() + .toDate(); + +/** True when `schedule` has an occurrence inside the minute that starts at `minute`. */ +export const firesInMinute = (schedule: string, minute: Date): boolean => { + const start = new Date(Math.floor(minute.getTime() / 60_000) * 60_000); + return nextRunAt(schedule, new Date(start.getTime() - 1)).getTime() === start.getTime(); +}; -export const createRecurringDeleteBankCacheJob = async ( - cronExpression: string, - interval: string, -) => { - // Implementation for creating a recurring delete bank cache using pg_cron - - await db.$executeRawUnsafe(` - SELECT cron.schedule('cleanup_cached_bank_data', '${cronExpression}', $$ - DELETE FROM "CachedBankData" - WHERE "lastFetched" < NOW() - INTERVAL '${interval}'; - DELETE FROM "CachedCurrencyRate" - WHERE "lastFetched" < NOW() - INTERVAL '${interval}'; - $$); - `); +/** Parses a Postgres-style interval such as "2 days" or "12 hours" into milliseconds. */ +export const parseInterval = (interval: string): number => { + const match = /^\s*(\d+)\s*(minute|hour|day|week|month)s?\s*$/i.exec(interval); + if (!match) { + throw new Error(`Unsupported interval: ${interval}`); + } + const units: Record = { + minute: 60_000, + hour: 3_600_000, + day: 86_400_000, + week: 7 * 86_400_000, + month: 30 * 86_400_000, + }; + return Number(match[1]) * units[match[2]!.toLowerCase()]!; }; -export const createRecurringExpenseJob = (expenseId: string, cronExpression: string) => - db.$queryRawUnsafe<[{ schedule: bigint }]>( - `SELECT cron.schedule($1, $2, $$ SELECT duplicate_expense_with_participants('${expenseId}'::UUID); $$);`, - expenseId, - cronExpression, - ); +export const getDueJobs = (now: Date = new Date()) => + db + .select({ jobid: jobs.jobid }) + .from(jobs) + .where(and(eq(jobs.active, true), lte(jobs.nextRunAt, now))) + .limit(100); + +/** Deletes cached bank data and currency rates that were not fetched within `interval`. */ +export const deleteStaleCache = async (interval: string, now: Date = new Date()) => { + const cutoff = new Date(now.getTime() - parseInterval(interval)); + await runBatch([ + db.delete(cachedBankData).where(lt(cachedBankData.lastFetched, cutoff)), + db.delete(cachedCurrencyRates).where(lt(cachedCurrencyRates.lastFetched, cutoff)), + ]); +}; + +export const updateJobSchedule = (jobid: number, schedule: string) => + db + .update(jobs) + .set({ schedule, nextRunAt: nextRunAt(schedule) }) + .where(eq(jobs.jobid, jobid)); + +/** Deleting the job cascades to ExpenseRecurrence, which sets Expense.recurrenceId to NULL. */ +export const deleteJob = (jobid: number) => db.delete(jobs).where(eq(jobs.jobid, jobid)); + +export const jobIdSubquery = (jobname: string) => + sql`(SELECT ${jobs.jobid} FROM ${jobs} WHERE ${jobs.jobname} = ${jobname})`; diff --git a/src/server/api/services/splitService.ts b/src/server/api/services/splitService.ts index 8c81a47ea..525f23a0b 100644 --- a/src/server/api/services/splitService.ts +++ b/src/server/api/services/splitService.ts @@ -1,355 +1,338 @@ -import { SplitType, type User } from '@prisma/client'; +import { and, eq, inArray, sql } from 'drizzle-orm'; +import type { BatchItem } from 'drizzle-orm/batch'; import { nanoid } from 'nanoid'; -import { db } from '~/server/db'; +import { bindings, db } from '~/server/db'; +import { chunk, runBatch } from '~/server/db/batch'; +import { + SplitType, + type User, + balanceView, + expenseParticipants, + expenseRecurrences, + expenses, + groupUsers, + groups, + jobs, + users, +} from '~/server/db/schema'; import { type SplitwiseGroup, type SplitwiseUser } from '~/types'; import type { CreateExpense } from '~/types/expense.types'; import { sendExpensePushNotification } from './notificationService'; -import { createRecurringExpenseJob } from './scheduleService'; +import { + deleteJob, + jobIdSubquery, + nextRunAt, + recurringJobCommand, + recurringJobName, + updateJobSchedule, +} from './scheduleService'; +import { runInBackground } from '~/server/observability/background'; import { getCurrencyHelpers } from '~/utils/numbers'; import { isCurrencyCode } from '~/lib/currency'; import { DEFAULT_CATEGORY } from '~/lib/category'; import { extractTemplateExpenseId } from '~/lib/cron'; +interface Participant { + userId: number; + amount: bigint; +} + +/** 3 bound parameters per participant row; D1 allows 100 per statement. */ +const PARTICIPANT_CHUNK = 33; + +const insertParticipants = ( + expenseId: string, + participants: Participant[], +): BatchItem<'sqlite'>[] => + chunk(participants, PARTICIPANT_CHUNK).map((rows) => + db + .insert(expenseParticipants) + .values(rows.map((p) => ({ expenseId, userId: p.userId, amount: p.amount }))), + ); + +/** Columns of an expense that a create/edit request may set. */ +const expenseColumns = (e: CreateExpense) => ({ + groupId: e.groupId, + paidBy: e.paidBy, + name: e.name, + category: e.category, + amount: e.amount, + splitType: e.splitType, + currency: e.currency, +}); + export async function joinGroup(userId: number, publicGroupId: string) { - const group = await db.group.findUnique({ - where: { - publicId: publicGroupId, - }, - }); + const group = await db.query.groups.findFirst({ where: eq(groups.publicId, publicGroupId) }); if (!group) { throw new Error('Group not found'); } - await db.groupUser.create({ - data: { - groupId: group.id, - userId, - }, - }); + await db.insert(groupUsers).values({ groupId: group.id, userId }); return group; } export async function createExpense( - { - groupId, - paidBy, - name, - category, - amount, - splitType, - currency, - participants, - expenseDate, - fileKey, - transactionId, - cronExpression, - }: CreateExpense & { cronExpression?: string }, + input: CreateExpense & { cronExpression?: string }, currentUserId: number, conversionFromParams?: CreateExpense, ) { - const nonZeroParticipants = getNonZeroParticipants(participants); - - const conversionFrom = conversionFromParams - ? { - create: { - ...conversionFromParams, - addedBy: currentUserId, - expenseParticipants: { - create: getNonZeroParticipants(conversionFromParams.participants), - }, - }, - } - : undefined; - if (conversionFrom) { - // @ts-ignore - delete conversionFrom.create.participants; - } - - // Pre-generate UUID and create cron job if recurring (before transaction) - let expenseId: string | undefined = undefined; - let jobId: bigint | undefined = undefined; + const { participants, expenseDate, fileKey, transactionId, cronExpression } = input; + const expenseId = crypto.randomUUID(); + const now = new Date(); + const items: BatchItem<'sqlite'>[] = []; + // Recurrence: job -> ExpenseRecurrence -> Expense.recurrenceId, all resolved inside the batch. + let recurrenceIdSql: ReturnType | null = null; if (cronExpression) { - const [{ gen_random_uuid }] = await db.$queryRaw<[{ gen_random_uuid: string }]>` - SELECT gen_random_uuid()::text as gen_random_uuid - `; - expenseId = gen_random_uuid; - - const [{ schedule }] = await createRecurringExpenseJob(expenseId, cronExpression); - jobId = schedule; + const jobname = recurringJobName(expenseId); + items.push( + db.insert(jobs).values({ + schedule: cronExpression, + command: recurringJobCommand(expenseId), + jobname, + nextRunAt: nextRunAt(cronExpression, now), + }), + db.insert(expenseRecurrences).values({ jobId: jobIdSubquery(jobname) }), + ); + recurrenceIdSql = sql`(SELECT ${expenseRecurrences.id} FROM ${expenseRecurrences} WHERE ${expenseRecurrences.jobId} = ${jobIdSubquery(jobname)})`; } - const operations = []; - - // Create expense operation - operations.push( - db.expense.create({ - data: { - ...(expenseId && { id: expenseId }), - groupId, - paidBy, - name, - category, - amount, - splitType, - currency, - expenseParticipants: { - create: nonZeroParticipants, - }, + const expenseIndex = items.length; + items.push( + db + .insert(expenses) + .values({ + id: expenseId, + ...expenseColumns(input), fileKey, addedBy: currentUserId, - expenseDate, + expenseDate: expenseDate ?? now, transactionId, - conversionFrom, - }, - }), + recurrenceId: recurrenceIdSql, + }) + .returning(), ); - - // Link recurrence to expense if we created a cron job - if (expenseId && jobId) { - operations.push( - db.expenseRecurrence.create({ - data: { - expense: { - connect: { id: expenseId }, - }, - job: { - connect: { jobid: jobId }, - }, - }, + items.push(...insertParticipants(expenseId, getNonZeroParticipants(participants))); + + // Currency conversion: the "from" expense points at this one via conversionToId. + if (conversionFromParams) { + const fromId = crypto.randomUUID(); + items.push( + db.insert(expenses).values({ + id: fromId, + ...expenseColumns(conversionFromParams), + addedBy: currentUserId, + expenseDate: conversionFromParams.expenseDate ?? now, + conversionToId: expenseId, }), + ...insertParticipants(fromId, getNonZeroParticipants(conversionFromParams.participants)), ); } - try { - const results = await db.$transaction(operations); - const createdExpense = results[0] as Awaited>; - if (!createdExpense) { - throw new Error('Expense creation failed'); - } - - sendExpensePushNotification(createdExpense.id).catch(console.error); - return createdExpense; - } catch (error) { - // If we created a cron job but transaction failed, clean up the cron job - if (expenseId) { - await db.$executeRaw`SELECT cron.unschedule(${`expense_recurring_${expenseId}`})`; - } - throw error; + const results = await runBatch(items); + const [createdExpense] = results[expenseIndex] as (typeof expenses.$inferSelect)[]; + if (!createdExpense) { + throw new Error('Expense creation failed'); } + + runInBackground('push:create', sendExpensePushNotification(createdExpense.id)); + return createdExpense; } -export async function deleteExpense(expenseId: string, deletedBy: number) { - const expense = await db.expense.findUnique({ - where: { - id: expenseId, - }, - include: { - recurrence: { - include: { - job: true, - }, - }, - }, +const getExpenseWithJob = (expenseId: string) => + db.query.expenses.findFirst({ + where: eq(expenses.id, expenseId), + with: { recurrence: { with: { job: true } } }, }); - const operations = []; - - if (!expense) { - throw new Error('Expense not found'); - } - - if (expense.conversionToId) { - await deleteExpense(expense.conversionToId, deletedBy); - } - - operations.push( - db.expense.update({ - where: { id: expenseId }, - data: { - deletedBy, - deletedAt: new Date(), - }, - }), - ); +export async function deleteExpense(expenseId: string, deletedBy: number) { + const items: BatchItem<'sqlite'>[] = []; + const touched: string[] = []; + + // Collect the expense and its conversion chain, then soft-delete all of them atomically. + let currentId: string | null = expenseId; + while (currentId) { + const expense = await getExpenseWithJob(currentId); + if (!expense) { + throw new Error('Expense not found'); + } + touched.push(expense.id); - if (expense.recurrence?.job) { - const templateId = extractTemplateExpenseId(expense.recurrence.job.command); - const isTemplate = templateId === expense.id; + items.push( + db + .update(expenses) + .set({ deletedBy, deletedAt: new Date() }) + .where(eq(expenses.id, expense.id)), + ); - if (isTemplate) { - // Template deletion stops the recurrence - operations.push(db.$executeRaw`SELECT cron.unschedule(${expense.recurrence.job.jobname})`); - operations.push( - db.expenseRecurrence.delete({ - where: { id: expense.recurrence.id }, - }), - ); + const job = expense.recurrence?.job; + // Template deletion stops the recurrence; deleting a derived expense keeps it running. + if (job && extractTemplateExpenseId(job.command) === expense.id) { + items.push(deleteJob(job.jobid)); } - // Derived expense deletion: just soft-delete, recurrence continues + + currentId = expense.conversionToId; } - await db.$transaction(operations); - sendExpensePushNotification(expenseId).catch(console.error); + await runBatch(items); + runInBackground('push:expense', sendExpensePushNotification(expenseId)); } export async function editExpense( - { - expenseId, - paidBy, - name, - category, - amount, - splitType, - currency, - participants, - expenseDate, - fileKey, - transactionId, - cronExpression, - }: CreateExpense & { cronExpression?: string }, + input: CreateExpense & { cronExpression?: string }, currentUserId: number, conversionToParams?: CreateExpense, ) { + const { expenseId, participants, expenseDate, fileKey, transactionId, cronExpression } = input; if (!expenseId) { throw new Error('Expense ID is required for editing'); } - const expense = await db.expense.findUnique({ - where: { id: expenseId }, - include: { - expenseParticipants: true, - recurrence: { - include: { - job: true, - }, - }, - }, - }); - + const expense = await getExpenseWithJob(expenseId); if (!expense) { throw new Error('Expense not found'); } - // Determine if this is a template or derived expense - const templateId = expense.recurrence?.job?.command - ? extractTemplateExpenseId(expense.recurrence.job.command) - : null; - const isTemplate = templateId === expenseId; + const job = expense.recurrence?.job; + const isTemplate = job ? extractTemplateExpenseId(job.command) === expenseId : false; - const operations = []; + if (conversionToParams && !expense.conversionToId) { + throw new Error('Conversion to expense not found for editing'); + } - // Delete existing participants - operations.push( - db.expenseParticipant.deleteMany({ - where: { - expenseId: expense.conversionToId ? { in: [expenseId, expense.conversionToId] } : expenseId, - }, - }), - ); + const affectedIds = expense.conversionToId ? [expenseId, expense.conversionToId] : [expenseId]; - // Update expense with new details and create new participants - operations.push( - db.expense.update({ - where: { id: expenseId }, - data: { - paidBy, - name, - category, - amount, - splitType, - currency, - expenseParticipants: { - create: participants, - }, + const items: BatchItem<'sqlite'>[] = [ + db.delete(expenseParticipants).where(inArray(expenseParticipants.expenseId, affectedIds)), + db + .update(expenses) + .set({ + ...expenseColumns(input), fileKey, transactionId, - expenseDate, + ...(expenseDate && { expenseDate }), updatedBy: currentUserId, - }, - }), - ); - if (conversionToParams) { - if (!expense.conversionToId) { - throw new Error('Conversion to expense not found for editing'); - } - const { participants: toParticipants, ...toExpenseData } = conversionToParams; - - operations.push( - db.expense.update({ - where: { id: expense.conversionToId }, - data: { - ...toExpenseData, - expenseParticipants: { - create: toParticipants, - }, + }) + .where(eq(expenses.id, expenseId)), + ...insertParticipants(expenseId, participants), + ]; + + if (conversionToParams && expense.conversionToId) { + items.push( + db + .update(expenses) + .set({ + ...expenseColumns(conversionToParams), + ...(conversionToParams.expenseDate && { expenseDate: conversionToParams.expenseDate }), updatedBy: currentUserId, - }, - }), + }) + .where(eq(expenses.id, expense.conversionToId)), + ...insertParticipants(expense.conversionToId, conversionToParams.participants), ); } - // Handle recurrence changes only for template expenses - if (isTemplate && expense.recurrence?.job) { - const currentSchedule = expense.recurrence.job.schedule; - - if (cronExpression && cronExpression !== currentSchedule) { - // Schedule changed - update the cron job - operations.push( - db.$executeRaw`SELECT cron.alter_job(${expense.recurrence.job.jobid}, schedule := ${cronExpression})`, - ); + // Recurrence changes apply only to the template expense. + if (isTemplate && job) { + if (cronExpression && cronExpression !== job.schedule) { + items.push(updateJobSchedule(job.jobid, cronExpression)); } else if (!cronExpression) { - // Recurrence removed - unschedule and delete recurrence record - operations.push(db.$executeRaw`SELECT cron.unschedule(${expense.recurrence.job.jobname})`); - operations.push( - db.expenseRecurrence.delete({ - where: { id: expense.recurrence.id }, - }), - ); + items.push(deleteJob(job.jobid)); } - // If cronExpression === currentSchedule, no action needed } - // For derived expenses, cronExpression is ignored entirely - await db.$transaction(operations); - sendExpensePushNotification(expenseId).catch(console.error); - return { id: expenseId }; // Return the updated expense + await runBatch(items); + runInBackground('push:expense', sendExpensePushNotification(expenseId)); + return { id: expenseId }; } -export async function getCompleteFriendsDetails(userId: number) { - const viewBalances = await db.balanceView.findMany({ - where: { - userId, - }, - include: { - friend: true, - }, - }); +/** + * Executes one recurring-expense job: copies the template expense and its participants. + * + * Idempotent per run slot: the copy's id is derived from (jobid, scheduled time), and the + * inserts use INSERT OR IGNORE, so a retried Workflow step cannot create duplicates. + */ +export async function runRecurringJob(jobid: number, now: Date = new Date()) { + const job = await db.query.jobs.findFirst({ where: eq(jobs.jobid, jobid) }); + if (!job?.active || !job.nextRunAt || job.nextRunAt > now) { + return { skipped: true }; + } - const friends = viewBalances.reduce( - (acc, balance) => { - const { friendId } = balance; - acc[friendId] ??= { - balances: [], - id: friendId, - email: balance.friend.email, - name: balance.friend.name, - }; - - if (0n !== balance.amount) { - acc[friendId]?.balances.push({ - currency: balance.currency, - amount: balance.amount, - }); - } + const templateId = extractTemplateExpenseId(job.command); + const slot = job.nextRunAt; + const newId = await deterministicUuid(`${jobid}:${slot.getTime()}`); + const ts = now.getTime(); + + // Drizzle's `db.run()` cannot be part of a D1 batch, so the whole batch is built from prepared + // D1 statements. Drizzle builders contribute their SQL via `toSQL()`. + const prepare = (statement: { sql: string; params: unknown[] }) => + bindings.DB.prepare(statement.sql).bind(...statement.params); + + await bindings.DB.batch([ + bindings.DB.prepare( + `INSERT OR IGNORE INTO "Expense" ( + "id", "paidBy", "addedBy", "name", "category", "amount", "splitType", "expenseDate", + "createdAt", "updatedAt", "currency", "groupId", "updatedBy", "recurrenceId" + ) + SELECT ?, "paidBy", "addedBy", "name", "category", "amount", "splitType", ?, + ?, ?, "currency", "groupId", "updatedBy", "recurrenceId" + FROM "Expense" WHERE "id" = ?`, + ).bind(newId, ts, ts, ts, templateId), + bindings.DB.prepare( + `INSERT OR IGNORE INTO "ExpenseParticipant" ("expenseId", "userId", "amount") + SELECT ?, "userId", "amount" FROM "ExpenseParticipant" + WHERE "expenseId" = ? + AND EXISTS (SELECT 1 FROM "Expense" WHERE "id" = ?)`, + ).bind(newId, templateId, newId), + prepare( + db + .update(expenseRecurrences) + .set({ notified: false }) + .where(eq(expenseRecurrences.jobId, jobid)) + .toSQL(), + ), + prepare( + db + .update(jobs) + .set({ lastRunAt: now, nextRunAt: nextRunAt(job.schedule, now) }) + .where(eq(jobs.jobid, jobid)) + .toSQL(), + ), + ]); + + return { skipped: false, expenseId: newId }; +} + +const deterministicUuid = async (seed: string) => { + const hash = new Uint8Array( + await crypto.subtle.digest('SHA-256', new TextEncoder().encode(seed)), + ); + const b = hash.slice(0, 16); + b[6] = (b[6]! & 0x0f) | 0x50; // Version 5-style + b[8] = (b[8]! & 0x3f) | 0x80; // RFC 4122 variant + const hex = [...b].map((x) => x.toString(16).padStart(2, '0')).join(''); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +}; - return acc; - }, - {} as Record< +export async function getCompleteFriendsDetails(userId: number) { + const viewBalances = await db + .select({ + balance: { + friendId: balanceView.friendId, + currency: balanceView.currency, + amount: balanceView.amount, + }, + friend: { email: users.email, name: users.name }, + }) + .from(balanceView) + .innerJoin(users, eq(users.id, balanceView.friendId)) + .where(eq(balanceView.userId, userId)); + + const friends = viewBalances.reduce< + Record< number, { id: number; @@ -357,48 +340,76 @@ export async function getCompleteFriendsDetails(userId: number) { name?: string | null; balances: { currency: string; amount: bigint }[]; } - >, - ); + > + >((acc, { balance, friend }) => { + const { friendId } = balance; + acc[friendId] ??= { + balances: [], + id: friendId, + email: friend.email, + name: friend.name, + }; + + if (0n !== balance.amount) { + acc[friendId]?.balances.push({ + currency: balance.currency, + amount: balance.amount, + }); + } + + return acc; + }, {}); return friends; } export async function getCompleteGroupDetails(userId: number) { - const groups = await db.group.findMany({ - where: { - groupUsers: { - some: { - userId, - }, - }, - }, - include: { - groupUsers: true, - groupBalances: true, - }, + const userGroups = await db.query.groups.findMany({ + where: inArray( + groups.id, + db + .select({ groupId: groupUsers.groupId }) + .from(groupUsers) + .where(eq(groupUsers.userId, userId)), + ), + with: { groupUsers: true }, }); - return groups; + if (0 === userGroups.length) { + return []; + } + + const balances = await db + .select() + .from(balanceView) + .where( + inArray( + balanceView.groupId, + userGroups.map((g) => g.id), + ), + ); + + return userGroups.map((g) => ({ + ...g, + groupBalances: balances.filter((b) => b.groupId === g.id), + })); } export async function importUserBalanceFromSplitWise( currentUserId: number, splitWiseUsers: SplitwiseUser[], ) { - const operations = []; + const items: BatchItem<'sqlite'>[] = []; - const users = await createUsersFromSplitwise(splitWiseUsers); + const importedUsers = await createUsersFromSplitwise(splitWiseUsers); - const userMap = users.reduce( - (acc, user) => { - if (user.email) { - acc[user.email] = user; - } + const userMap = importedUsers.reduce>((acc, user) => { + if (user.email) { + acc[user.email] = user; + } - return acc; - }, - {} as Record, - ); + return acc; + }, {}); const currencyHelperCache: Record['toSafeBigInt']> = {}; @@ -413,80 +424,69 @@ export async function importUserBalanceFromSplitWise( for (const balance of user.balance) { const currency = balance.currency_code; - if (!currencyHelperCache[currency]) { - currencyHelperCache[currency] = getCurrencyHelpers({ - currency: isCurrencyCode(currency) ? currency : 'USD', - }).toSafeBigInt; - } + currencyHelperCache[currency] ??= getCurrencyHelpers({ + currency: isCurrencyCode(currency) ? currency : 'USD', + }).toSafeBigInt; const amount = currencyHelperCache[currency](balance.amount); - - operations.push( - db.expense.create({ - data: { - name: 'Splitwise Balance Import', - amount, - currency, - paidBy: currentUserId, - splitType: SplitType.EQUAL, - expenseParticipants: { - create: [ - { - userId: currentUserId, - amount: amount, - }, - { - userId: dbUser.id, - amount: -amount, - }, - ], - }, - addedBy: currentUserId, - category: DEFAULT_CATEGORY, - }, + const id = crypto.randomUUID(); + + items.push( + db.insert(expenses).values({ + id, + name: 'Splitwise Balance Import', + amount, + currency, + paidBy: currentUserId, + splitType: SplitType.EQUAL, + addedBy: currentUserId, + category: DEFAULT_CATEGORY, }), + ...insertParticipants(id, [ + { userId: currentUserId, amount }, + { userId: dbUser.id, amount: -amount }, + ]), ); } } - await db.$transaction(operations); + await runBatch(items); } -async function createUsersFromSplitwise(users: SplitwiseUser[]) { - const userEmails = users.map((u) => u.email); - - const existingUsers = await db.user.findMany({ - where: { - email: { - in: userEmails, - }, - }, - }); - - const existingUserMap: Record = {}; - - for (const user of existingUsers) { - if (user.email) { - existingUserMap[user.email] = true; - } +async function createUsersFromSplitwise(splitwiseUsers: SplitwiseUser[]) { + const userEmails = splitwiseUsers.map((u) => u.email); + if (0 === userEmails.length) { + return []; } - const newUsers = users.filter((u) => !existingUserMap[u.email]); - - await db.user.createMany({ - data: newUsers.map((u) => ({ - email: u.email, - name: `${u.first_name}${u.last_name ? ` ${u.last_name}` : ''}`, - })), - }); + const findExisting = async () => + ( + await Promise.all( + chunk(userEmails, 90).map((emails) => + db.select().from(users).where(inArray(users.email, emails)), + ), + ) + ).flat(); + + const existingEmails = new Set((await findExisting()).map((u) => u.email)); + const newUsers = splitwiseUsers.filter((u) => !existingEmails.has(u.email)); + + // 2 bound parameters per row. + await runBatch( + chunk(newUsers, 45).map((rows) => + db + .insert(users) + .values( + rows.map((u) => ({ + email: u.email, + name: `${u.first_name}${u.last_name ? ` ${u.last_name}` : ''}`, + })), + ) + .onConflictDoNothing(), + ), + ); - return db.user.findMany({ - where: { - email: { - in: userEmails, - }, - }, - }); + return findExisting(); } export async function importGroupFromSplitwise( @@ -501,76 +501,56 @@ export async function importGroupFromSplitwise( } } - const users = await createUsersFromSplitwise(Object.values(splitwiseUserMap)); + const importedUsers = await createUsersFromSplitwise(Object.values(splitwiseUserMap)); - const userMap = users.reduce( - (acc, user) => { - if (user.email) { - acc[user.email] = user; - } + const userMap = importedUsers.reduce>((acc, user) => { + if (user.email) { + acc[user.email] = user; + } - return acc; - }, - {} as Record, - ); + return acc; + }, {}); const missingGroups = await Promise.all( splitWiseGroups.map(async (group) => { - const dbGroup = await db.group.findUnique({ - where: { - splitwiseGroupId: group.id.toString(), - }, + const dbGroup = await db.query.groups.findFirst({ + where: eq(groups.splitwiseGroupId, group.id.toString()), }); return dbGroup ? null : group; }), ); - const operations = missingGroups - .filter((g) => null !== g) - .map((group) => { - const groupmembers = group.members.map((member) => ({ - userId: userMap[member.email.toString()]!.id, - })); - - return db.group.create({ - data: { - name: group.name, - splitwiseGroupId: group.id.toString(), - publicId: nanoid(), - userId: currentUserId, - groupUsers: { - create: groupmembers, - }, - }, - }); - }); + const items: BatchItem<'sqlite'>[] = []; + for (const group of missingGroups) { + if (!group) { + // oxlint-disable-next-line no-continue + continue; + } + const splitwiseGroupId = group.id.toString(); + const groupIdSql = sql`(SELECT ${groups.id} FROM ${groups} WHERE ${groups.splitwiseGroupId} = ${splitwiseGroupId})`; + + items.push( + db.insert(groups).values({ + name: group.name, + splitwiseGroupId, + publicId: nanoid(), + userId: currentUserId, + }), + // 2 bound parameters per row, plus the subquery parameter. + ...chunk(group.members, 45).map((members) => + db.insert(groupUsers).values( + members.map((member) => ({ + groupId: groupIdSql, + userId: userMap[member.email.toString()]!.id, + })), + ), + ), + ); + } - await db.$transaction(operations); + await runBatch(items); } -const getNonZeroParticipants = (participants: { userId: number; amount: bigint }[]) => +const getNonZeroParticipants = (participants: Participant[]) => participants.length > 1 ? participants.filter((p) => 0n !== p.amount) : participants; - -interface HistoricalBalance { - userId: number; - friendId: number; - groupId: number | null; - currency: string; - amount: bigint; -} - -export const getHistoricalBalances = async ( - userId: number, - friendId: number, - currency: string, - beforeDate: Date, -) => - db.$queryRaw` - SELECT "userId", "friendId", "groupId", currency, amount - FROM get_balance_at_date(${beforeDate}) - WHERE "userId" = ${userId} - AND "friendId" = ${friendId} - AND currency = ${currency} - AND amount != 0 - `; diff --git a/src/server/api/trpc.ts b/src/server/api/trpc.ts index fa6fdbc1a..0346ceb7f 100644 --- a/src/server/api/trpc.ts +++ b/src/server/api/trpc.ts @@ -8,13 +8,15 @@ */ import { TRPCError, initTRPC } from '@trpc/server'; -import { type CreateNextContextOptions } from '@trpc/server/adapters/next'; -import { type Session } from 'next-auth'; +import type { Session } from '~/server/auth'; import superjson from 'superjson'; import { ZodError, z } from 'zod'; import { getServerAuthSession } from '~/server/auth'; +import { and, eq } from 'drizzle-orm'; + import { db } from '~/server/db'; +import { groupUsers } from '~/server/db/schema'; /** * 1. CONTEXT @@ -49,11 +51,9 @@ const createInnerTRPCContext = (opts: CreateContextOptions) => ({ * * @see https://trpc.io/docs/context */ -export const createTRPCContext = async (opts: CreateNextContextOptions) => { - const { req, res } = opts; - - // Get the session from the server using the getServerSession wrapper function - const session = await getServerAuthSession({ req, res }); +export const createTRPCContext = async (opts: { req: { headers: Headers } }) => { + const { req } = opts; + const session = await getServerAuthSession({ req }); return createInnerTRPCContext({ session, @@ -119,7 +119,7 @@ export const protectedProcedure = t.procedure.use(({ ctx, next }) => { return next({ ctx: { - // infers the `session` as non-nullable + // Infers the `session` as non-nullable session: { ...ctx.session, user: ctx.session.user }, }, }); @@ -128,11 +128,8 @@ export const protectedProcedure = t.procedure.use(({ ctx, next }) => { export const groupProcedure = protectedProcedure .input(z.object({ groupId: z.number() })) .use(async ({ ctx, input: { groupId }, next }) => { - const groupUser = await ctx.db.groupUser.findFirst({ - where: { - groupId, - userId: ctx.session.user.id, - }, + const groupUser = await ctx.db.query.groupUsers.findFirst({ + where: and(eq(groupUsers.groupId, groupId), eq(groupUsers.userId, ctx.session.user.id)), }); if (!groupUser) { diff --git a/src/server/auth.ts b/src/server/auth.ts index d301b4a67..cc6a9fc1e 100644 --- a/src/server/auth.ts +++ b/src/server/auth.ts @@ -1,301 +1,172 @@ -import { PrismaAdapter } from '@next-auth/prisma-adapter'; -import { Prisma } from '@prisma/client'; +import { betterAuth } from 'better-auth'; +import { drizzleAdapter } from 'better-auth/adapters/drizzle'; +import { eq } from 'drizzle-orm'; import { type GetServerSidePropsContext } from 'next'; -import { type DefaultSession, type NextAuthOptions, type User, getServerSession } from 'next-auth'; -import { type Adapter, type AdapterAccount, type AdapterUser } from 'next-auth/adapters'; -import AuthentikProvider from 'next-auth/providers/authentik'; -import EmailProvider from 'next-auth/providers/email'; -import GoogleProvider from 'next-auth/providers/google'; -import KeycloakProvider from 'next-auth/providers/keycloak'; import { env } from '~/env'; import { db } from '~/server/db'; -import { parseEnvBoolean } from '~/utils/env'; - -import { mailServerConfig, sendSignUpEmail } from './mailer'; -import { getBaseUrl } from '~/utils/api'; -import type { OAuthConfig } from 'next-auth/providers/oauth'; - -/** - * Module augmentation for `next-auth` types. Allows us to add custom properties to the `session` - * object and keep type safety. - * - * @see https://next-auth.js.org/getting-started/typescript#module-augmentation - */ -declare module 'next-auth' { - interface Session extends DefaultSession { - user: DefaultSession['user'] & { - id: number; - currency: string; - defaultCurrency?: string | null; - obapiProviderId?: string; - bankingId?: string; - preferredLanguage: string; - hiddenFriendIds: number[]; - // ...other properties - // Role: UserRole; - }; - } - - interface User { - id: number; - name: string; - email: string; - image: string; - currency: string; - defaultCurrency?: string | null; - obapiProviderId?: string; - bankingId?: string; - preferredLanguage: string; - hiddenFriendIds: number[]; - } +import { accounts, sessions, users, verifications } from '~/server/db/schema'; +import { PREVIEW_ACCESS_EMAIL_HEADER, PREVIEW_ACCESS_NAME_HEADER } from '~/server/preview-access'; + +/** Session user shape the app expects (kept from the former next-auth session). */ +export interface SessionUser { + id: number; + name: string | null; + email: string | null; + image: string | null; + currency: string; + defaultCurrency?: string | null; + obapiProviderId?: string | null; + bankingId?: string | null; + preferredLanguage: string; + hiddenFriendIds: number[]; } -const SplitProPrismaAdapter = (...args: Parameters): Adapter => { - const prismaAdapter = PrismaAdapter(...args); - - return { - ...prismaAdapter, - createUser: (user: Omit): Promise => { - // oxlint-disable-next-line typescript/no-unsafe-assignment - const prismaCreateUser = prismaAdapter.createUser; - - if (env.INVITE_ONLY) { - throw new Error('This instance is Invite Only'); - } - - if (!prismaCreateUser) { - // This should never happen but typing says it's possible. - throw new Error('Prisma Adapter lacks User Creation'); - } - - // oxlint-disable-next-line typescript/no-unsafe-return, typescript/no-unsafe-call - return prismaCreateUser(user); - }, - linkAccount: async (account: AdapterAccount) => { - // oxlint-disable-next-line typescript/no-unsafe-assignment - const originalLinkAccount = prismaAdapter.linkAccount; - - if (!originalLinkAccount) { - throw new Error('Adapter is missing the linkAccount method.'); - } - - // OIDC providers can provide non-standard fields that do not exist in the prisma schema. - // We strip them out before passing them on to the original adapter. - const knownAccountFields = new Set(Object.values(Prisma.AccountScalarFieldEnum)); - - const sanitised = Object.fromEntries( - Object.entries(account as Record).filter(([k]) => - knownAccountFields.has(k), - ), - ) as AdapterAccount; - - return originalLinkAccount(sanitised); - }, - } as Adapter; -}; +export interface Session { + user: SessionUser; + expires: string; +} -/** - * Options for NextAuth.js used to configure adapters, providers, callbacks, etc. - * - * @see https://next-auth.js.org/configuration/options - */ -export const authOptions: NextAuthOptions = { - pages: { - signIn: '/auth/signin', +export const auth = betterAuth({ + appName: 'SplitPro', + baseURL: env.NEXTAUTH_URL, + secret: env.NEXTAUTH_SECRET, + database: drizzleAdapter(db, { + provider: 'sqlite', + schema: { user: users, session: sessions, account: accounts, verification: verifications }, + }), + advanced: { + // Keep integer user ids: the whole app (balances, groups, expenses) uses them. + database: { generateId: 'serial' }, }, - callbacks: { - session: ({ session, user }) => ({ - ...session, - user: { - ...session.user, - id: user.id, - currency: user.currency, - defaultCurrency: user.defaultCurrency, - obapiProviderId: user.obapiProviderId, - bankingId: user.bankingId, - preferredLanguage: user.preferredLanguage, - hiddenFriendIds: user.hiddenFriendIds, - }, - }), - async signIn({ user, email }) { - if (email?.verificationRequest && env.DISABLE_EMAIL_SIGNUP) { - const existingUser = await db.user.findUnique({ - where: { email: user.email }, - }); - - if (!existingUser) { - return `${getBaseUrl()}/auth/signin?error=SignupDisabled`; + session: { + expiresIn: 60 * 60 * 24 * 30, + updateAge: 60 * 60 * 24, + }, + account: { + accountLinking: { enabled: true, trustedProviders: ['google'] }, + }, + socialProviders: + env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET + ? { + google: { + clientId: env.GOOGLE_CLIENT_ID, + clientSecret: env.GOOGLE_CLIENT_SECRET, + prompt: 'select_account', + }, } - } - - return true; + : {}, + databaseHooks: { + user: { + create: { + before: async (user) => { + if (!user.email) { + return { data: user }; + } + // Users created by invites or Splitwise imports already exist without an account. + // Better-auth links by email, so only brand-new emails reach this hook. + if (env.INVITE_ONLY) { + throw new Error('This instance is Invite Only'); + } + const name = user.name?.trim() ? user.name : user.email.split('@')[0]; + return { data: { ...user, email: user.email.toLowerCase(), name } }; + }, + }, }, }, - adapter: SplitProPrismaAdapter(db), - providers: getProviders(), - events: { - createUser: async ({ user }) => { - // Check if the user's name is empty - if ((!user.name || '' === user.name.trim()) && user.email) { - // Define the logic to update the user's name here - const [updatedName] = user.email.split('@'); +}); - // Use your database client to update the user's name - await db.user.update({ - where: { id: user.id }, - data: { name: updatedName }, - }); - } - }, - }, -}; +/** Enabled sign-in providers, for the sign-in page. */ +export const getAuthProviders = () => + env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET + ? [{ id: 'google', name: 'Google', type: 'oauth' as const }] + : []; -/** - * Wrapper for `getServerSession` so that you don't need to import the `authOptions` in every file. - * - * @see https://next-auth.js.org/configuration/nextjs - */ -export const getServerAuthSession = (ctx: { - req: GetServerSidePropsContext['req']; - res: GetServerSidePropsContext['res']; -}) => getServerSession(ctx.req, ctx.res, authOptions); +export type AuthProvider = ReturnType[number]; -export const getServerAuthSessionForSSG = async (context: GetServerSidePropsContext) => { - console.log('Before getting session'); - const session = await getServerAuthSession(context); - console.log('After getting session'); - - if (!session?.user?.email) { - return { - redirect: { - destination: '/', - permanent: false, - }, - }; +const toHeaders = (raw: Headers | Record): Headers => { + if (raw instanceof Headers) { + return raw; } + const headers = new Headers(); + for (const [key, value] of Object.entries(raw)) { + if (Array.isArray(value)) { + value.forEach((v) => headers.append(key, v)); + } else if (undefined !== value) { + headers.set(key, value); + } + } + return headers; +}; +const loadSessionUser = async (userId: number): Promise => { + const user = await db.query.users.findFirst({ where: eq(users.id, userId) }); + if (!user) { + return null; + } return { - props: { - user: session.user, - }, + id: user.id, + name: user.name, + email: user.email, + image: user.image, + currency: user.currency, + defaultCurrency: user.defaultCurrency, + obapiProviderId: user.obapiProviderId, + bankingId: user.bankingId, + preferredLanguage: user.preferredLanguage, + hiddenFriendIds: user.hiddenFriendIds ?? [], }; }; -/** - * Get providers to enable - */ -function getProviders() { - const providersList = []; - - if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) { - providersList.push( - GoogleProvider({ - clientId: env.GOOGLE_CLIENT_ID, - clientSecret: env.GOOGLE_CLIENT_SECRET, - allowDangerousEmailAccountLinking: true, - }), - ); - } - - if (env.EMAIL_SERVER_HOST) { - providersList.push( - EmailProvider({ - from: env.FROM_EMAIL, - server: mailServerConfig, - async sendVerificationRequest({ identifier: email, url, token }) { - const result = await sendSignUpEmail(email, url, token); - if (!result) { - throw new Error('Failed to send email'); - } - }, - generateVerificationToken() { - return Math.random().toString(36).substring(2, 7).toLowerCase(); - }, - }), - ); +/** Session from request headers (cookie). Returns the app's full session user. */ +export const getSessionFromHeaders = async ( + raw: Headers | Record, +): Promise => { + const headers = toHeaders(raw); + if (env.PREVIEW_ACCESS_AUTH) { + const email = headers.get(PREVIEW_ACCESS_EMAIL_HEADER)?.trim().toLowerCase(); + if (!email) return null; + + await db + .insert(users) + .values({ + email, + name: headers.get(PREVIEW_ACCESS_NAME_HEADER)?.trim() || email.split('@')[0], + emailVerified: true, + }) + .onConflictDoNothing(); + + const accessUser = await db.query.users.findFirst({ where: eq(users.email, email) }); + if (!accessUser) return null; + const user = await loadSessionUser(Number(accessUser.id)); + if (!user) return null; + return { user, expires: new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString() }; } - if (env.AUTHENTIK_ID && env.AUTHENTIK_SECRET && env.AUTHENTIK_ISSUER) { - providersList.push( - AuthentikProvider({ - clientId: env.AUTHENTIK_ID, - clientSecret: env.AUTHENTIK_SECRET, - issuer: env.AUTHENTIK_ISSUER, - allowDangerousEmailAccountLinking: true, - }), - ); + const result = await auth.api.getSession({ headers }); + if (!result) { + return null; } - - if (env.KEYCLOAK_ID && env.KEYCLOAK_SECRET && env.KEYCLOAK_ISSUER) { - providersList.push( - KeycloakProvider({ - clientId: env.KEYCLOAK_ID, - clientSecret: env.KEYCLOAK_SECRET, - issuer: env.KEYCLOAK_ISSUER, - allowDangerousEmailAccountLinking: true, - }), - ); + const user = await loadSessionUser(Number(result.user.id)); + if (!user) { + return null; } + return { user, expires: new Date(result.session.expiresAt).toISOString() }; +}; - if (env.OIDC_CLIENT_ID && env.OIDC_CLIENT_SECRET && env.OIDC_WELL_KNOWN_URL) { - providersList.push({ - id: env.OIDC_NAME?.toLowerCase() ?? 'oidc', - name: env.OIDC_NAME ?? 'OIDC', - clientId: env.OIDC_CLIENT_ID, - clientSecret: env.OIDC_CLIENT_SECRET, - type: 'oauth', - wellKnown: env.OIDC_WELL_KNOWN_URL, - authorization: { params: { scope: 'openid email profile' } }, - allowDangerousEmailAccountLinking: env.OIDC_ALLOW_DANGEROUS_EMAIL_LINKING, - idToken: true, - profile(profile) { - // This function expects a "standard" next-auth user but we override - // What a next-auth user is above. The expected next-auth user must be - // A record that has an id, a name, an email, and an image. - // - // To work around this, we case to unknown and then `User`. - return { - id: profile.sub, - name: profile.name, - email: profile.email, - image: profile.picture, - } as unknown as User; - }, - } satisfies OAuthConfig<{ - sub: string; - name: string; - email: string; - picture: string; - preferred_username: string; - }>); - } - - return providersList; -} - -/** - * Validates the environment variables that are related to authentication. - * this will check if atleat one provider is set properly. - * - * this function should be updated if new providers are added. - */ -export function validateAuthEnv() { - console.log('Validating auth env'); - if (!parseEnvBoolean(process.env.SKIP_ENV_VALIDATION)) { - const providers = getProviders(); - const oauthProviders = providers.filter((provider) => 'oauth' === provider.type); +/** Drop-in for the former next-auth wrapper used by getServerSideProps and tRPC. */ +export const getServerAuthSession = (ctx: { + req: + | GetServerSidePropsContext['req'] + | { headers: Headers | Record }; +}) => getSessionFromHeaders(ctx.req.headers as Record); - if (env.OAUTH_AUTO_REDIRECT && (1 !== oauthProviders.length || 1 !== providers.length)) { - console.warn( - 'OAUTH_AUTO_REDIRECT is enabled, but automatic redirection will not happen until exactly one OAuth provider and no other authentication providers are configured.', - ); - } +export const getServerAuthSessionForSSG = async (context: GetServerSidePropsContext) => { + const session = await getServerAuthSession(context); - if (0 === providers.length) { - throw new Error( - 'No authentication providers are configured, at least one is required. Learn more here: https://github.com/oss-apps/split-pro?tab=readme-ov-file#setting-up-the-environment', - ); - } + if (!session?.user?.email) { + return { redirect: { destination: '/', permanent: false } }; } -} + + return { props: { user: session.user } }; +}; diff --git a/src/server/db.ts b/src/server/db.ts deleted file mode 100644 index 86818a8f8..000000000 --- a/src/server/db.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { PrismaClient } from '@prisma/client'; - -import { env } from '~/env'; - -// oxlint-disable-next-line no-shadow-restricted-names -declare namespace globalThis { - // oxlint-disable-next-line no-unused-vars - let prisma: PrismaClient | undefined; -} - -export const db = - globalThis.prisma ?? - (await (async () => { - const prisma = new PrismaClient({ - log: 'development' === env.NODE_ENV ? ['error', 'warn'] : ['error'], - }); - return prisma; - })()); - -if ('production' !== env.NODE_ENV) { - globalThis.prisma = db; -} diff --git a/src/server/db/batch.ts b/src/server/db/batch.ts new file mode 100644 index 000000000..fb5858d3e --- /dev/null +++ b/src/server/db/batch.ts @@ -0,0 +1,26 @@ +import type { BatchItem } from 'drizzle-orm/batch'; + +import { getDb } from './index'; + +/** + * Runs statements in one D1 batch. D1 executes a batch as a single SQL transaction: + * if any statement fails, every statement in the batch is rolled back. + */ +export const runBatch = async (items: BatchItem<'sqlite'>[]): Promise => { + if (0 === items.length) { + return []; + } + return getDb().batch(items as [BatchItem<'sqlite'>, ...BatchItem<'sqlite'>[]]); +}; + +/** + * D1 allows at most 100 bound parameters per statement. Use this to split multi-row + * inserts: `chunk(rows, Math.floor(100 / columnsPerRow))`. + */ +export const chunk = (items: T[], size: number): T[][] => { + const out: T[][] = []; + for (let i = 0; i < items.length; i += size) { + out.push(items.slice(i, i + size)); + } + return out; +}; diff --git a/src/server/db/enums.ts b/src/server/db/enums.ts new file mode 100644 index 000000000..325e3fdb3 --- /dev/null +++ b/src/server/db/enums.ts @@ -0,0 +1,11 @@ +// Dependency-free so client components can import it without pulling in drizzle-orm. +export const SplitType = { + EQUAL: 'EQUAL', + PERCENTAGE: 'PERCENTAGE', + EXACT: 'EXACT', + SHARE: 'SHARE', + ADJUSTMENT: 'ADJUSTMENT', + SETTLEMENT: 'SETTLEMENT', + CURRENCY_CONVERSION: 'CURRENCY_CONVERSION', +} as const; +export type SplitType = (typeof SplitType)[keyof typeof SplitType]; diff --git a/src/server/db/index.ts b/src/server/db/index.ts new file mode 100644 index 000000000..5454b7f1f --- /dev/null +++ b/src/server/db/index.ts @@ -0,0 +1,22 @@ +import { env } from 'cloudflare:workers'; +import { type DrizzleD1Database, drizzle } from 'drizzle-orm/d1'; + +import * as schema from './schema'; + +export type DB = DrizzleD1Database; + +/** Cloudflare bindings for this Worker. Keep in sync with wrangler.jsonc. */ +export interface AppBindings { + DB: D1Database; + UPLOADS: R2Bucket; + EMAIL?: SendEmail; + SCHEDULER?: Workflow; + [key: string]: unknown; +} + +export const bindings = env as unknown as AppBindings; + +/** Drizzle over the D1 binding. `cloudflare:workers` env is available at module scope. */ +export const db: DB = drizzle(bindings.DB, { schema }); + +export const getDb = (): DB => db; diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts new file mode 100644 index 000000000..74401a9cc --- /dev/null +++ b/src/server/db/schema.ts @@ -0,0 +1,456 @@ +/** + * Drizzle schema for Cloudflare D1 (SQLite). Port of the former Prisma/Postgres schema. + * + * Conventions: + * - Table and column names match the old Prisma names, so SQL stays familiar. + * - Money amounts are stored as INTEGER (minor units) and surfaced as `bigint` in JS. + * - Timestamps are stored as INTEGER milliseconds and surfaced as `Date`. + */ +import { relations, sql } from 'drizzle-orm'; +import { + customType, + index, + integer, + primaryKey, + real, + sqliteTable, + sqliteView, + text, + uniqueIndex, +} from 'drizzle-orm/sqlite-core'; + +import { SplitType } from './enums'; + +export { SplitType }; +const splitTypeValues = Object.values(SplitType) as [SplitType, ...SplitType[]]; + +/** INTEGER column surfaced as bigint. D1 returns numbers; amounts stay well below 2^53. */ +export const bigintInt = customType<{ data: bigint; driverData: number | bigint }>({ + dataType: () => 'integer', + toDriver: (value) => Number(value), + fromDriver: (value) => BigInt(value), +}); + +const timestamp = (name: string) => integer(name, { mode: 'timestamp_ms' }); +const createdAt = () => + timestamp('createdAt') + .notNull() + .$defaultFn(() => new Date()); +const updatedAt = () => + timestamp('updatedAt') + .notNull() + .$defaultFn(() => new Date()) + .$onUpdateFn(() => new Date()); +const randomId = () => crypto.randomUUID(); + +export const users = sqliteTable('User', { + id: integer('id').primaryKey({ autoIncrement: true }), + name: text('name'), + email: text('email').unique(), + emailVerified: integer('emailVerified', { mode: 'boolean' }).notNull().default(false), + image: text('image'), + currency: text('currency').notNull().default('USD'), + defaultCurrency: text('defaultCurrency'), + preferredLanguage: text('preferredLanguage').notNull().default(''), + bankingId: text('bankingId'), + obapiProviderId: text('obapiProviderId'), + hiddenFriendIds: text('hiddenFriendIds', { mode: 'json' }) + .$type() + .notNull() + .default(sql`'[]'`), + createdAt: createdAt(), + updatedAt: updatedAt(), +}); + +// Better-auth tables (serial ids; see src/server/auth.ts). +export const accounts = sqliteTable( + 'Account', + { + id: integer('id').primaryKey({ autoIncrement: true }), + userId: integer('userId') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + accountId: text('accountId').notNull(), + providerId: text('providerId').notNull(), + accessToken: text('accessToken'), + refreshToken: text('refreshToken'), + idToken: text('idToken'), + accessTokenExpiresAt: timestamp('accessTokenExpiresAt'), + refreshTokenExpiresAt: timestamp('refreshTokenExpiresAt'), + scope: text('scope'), + password: text('password'), + createdAt: createdAt(), + updatedAt: updatedAt(), + }, + (t) => [ + uniqueIndex('Account_providerId_accountId_key').on(t.providerId, t.accountId), + index('Account_userId_idx').on(t.userId), + ], +); + +export const sessions = sqliteTable( + 'Session', + { + id: integer('id').primaryKey({ autoIncrement: true }), + token: text('token').notNull().unique(), + userId: integer('userId') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + expiresAt: timestamp('expiresAt').notNull(), + ipAddress: text('ipAddress'), + userAgent: text('userAgent'), + createdAt: createdAt(), + updatedAt: updatedAt(), + }, + (t) => [index('Session_userId_idx').on(t.userId)], +); + +export const verifications = sqliteTable( + 'Verification', + { + id: integer('id').primaryKey({ autoIncrement: true }), + identifier: text('identifier').notNull(), + value: text('value').notNull(), + expiresAt: timestamp('expiresAt').notNull(), + createdAt: createdAt(), + updatedAt: updatedAt(), + }, + (t) => [index('Verification_identifier_idx').on(t.identifier)], +); + +export const groups = sqliteTable('Group', { + id: integer('id').primaryKey({ autoIncrement: true }), + publicId: text('publicId').notNull().unique(), + name: text('name').notNull(), + image: text('image'), + userId: integer('userId') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + defaultCurrency: text('defaultCurrency'), + createdAt: createdAt(), + updatedAt: updatedAt(), + splitwiseGroupId: text('splitwiseGroupId').unique(), + simplifyDebts: integer('simplifyDebts', { mode: 'boolean' }).notNull().default(false), + archivedAt: timestamp('archivedAt'), +}); + +export const groupDefaultSplits = sqliteTable('GroupDefaultSplit', { + groupId: integer('groupId') + .primaryKey() + .references(() => groups.id, { onDelete: 'cascade' }), + splitType: text('splitType', { enum: splitTypeValues }).notNull(), + shares: text('shares', { mode: 'json' }).notNull(), + createdAt: createdAt(), + updatedAt: updatedAt(), +}); + +export const friendDefaultSplits = sqliteTable( + 'FriendDefaultSplit', + { + userAId: integer('userAId') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + userBId: integer('userBId') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + splitType: text('splitType', { enum: splitTypeValues }).notNull(), + shares: text('shares', { mode: 'json' }).notNull(), + createdAt: createdAt(), + updatedAt: updatedAt(), + }, + (t) => [primaryKey({ columns: [t.userAId, t.userBId] })], +); + +export const groupUsers = sqliteTable( + 'GroupUser', + { + groupId: integer('groupId') + .notNull() + .references(() => groups.id, { onDelete: 'cascade' }), + userId: integer('userId') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + }, + (t) => [ + primaryKey({ columns: [t.groupId, t.userId] }), + index('GroupUser_userId_idx').on(t.userId), + ], +); + +/** Replaces pg_cron's `cron.job`. Executed by the scheduler Workflow. */ +export const jobs = sqliteTable( + 'job', + { + jobid: integer('jobid').primaryKey({ autoIncrement: true }), + schedule: text('schedule').notNull(), + command: text('command').notNull(), + jobname: text('jobname').notNull().unique(), + active: integer('active', { mode: 'boolean' }).notNull().default(true), + nextRunAt: timestamp('nextRunAt'), + lastRunAt: timestamp('lastRunAt'), + }, + (t) => [index('job_active_nextRunAt_idx').on(t.active, t.nextRunAt)], +); + +export const expenseRecurrences = sqliteTable('ExpenseRecurrence', { + id: integer('id').primaryKey({ autoIncrement: true }), + jobId: integer('jobId') + .notNull() + .unique() + .references(() => jobs.jobid, { onDelete: 'cascade' }), + notified: integer('notified', { mode: 'boolean' }).notNull().default(true), +}); + +export const expenses = sqliteTable( + 'Expense', + { + id: text('id').primaryKey().$defaultFn(randomId), + paidBy: integer('paidBy') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + addedBy: integer('addedBy') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + name: text('name').notNull(), + category: text('category').notNull(), + amount: bigintInt('amount').notNull(), + splitType: text('splitType', { enum: splitTypeValues }).notNull().default('EQUAL'), + expenseDate: timestamp('expenseDate') + .notNull() + .$defaultFn(() => new Date()), + createdAt: createdAt(), + updatedAt: updatedAt(), + currency: text('currency').notNull(), + fileKey: text('fileKey'), + groupId: integer('groupId').references(() => groups.id, { onDelete: 'cascade' }), + deletedAt: timestamp('deletedAt'), + deletedBy: integer('deletedBy').references(() => users.id, { onDelete: 'cascade' }), + updatedBy: integer('updatedBy').references(() => users.id, { onDelete: 'set null' }), + transactionId: text('transactionId'), + recurrenceId: integer('recurrenceId').references(() => expenseRecurrences.id, { + onDelete: 'set null', + }), + // oxlint-disable-next-line typescript/no-explicit-any + conversionToId: text('conversionToId') + .unique() + .references((): any => expenses.id, { onDelete: 'cascade' }), + }, + (t) => [ + index('Expense_groupId_idx').on(t.groupId), + index('Expense_paidBy_idx').on(t.paidBy), + index('Expense_recurrenceId_idx').on(t.recurrenceId), + ], +); + +export const expenseParticipants = sqliteTable( + 'ExpenseParticipant', + { + expenseId: text('expenseId') + .notNull() + .references(() => expenses.id, { onDelete: 'cascade' }), + userId: integer('userId') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + amount: bigintInt('amount').notNull(), + }, + (t) => [ + primaryKey({ columns: [t.expenseId, t.userId] }), + index('ExpenseParticipant_userId_idx').on(t.userId), + ], +); + +export const expenseNotes = sqliteTable('ExpenseNote', { + id: text('id').primaryKey().$defaultFn(randomId), + note: text('note').notNull(), + createdById: integer('createdById') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + createdAt: createdAt(), + expenseId: text('expenseId') + .notNull() + .references(() => expenses.id, { onDelete: 'cascade' }), +}); + +export const cachedCurrencyRates = sqliteTable( + 'CachedCurrencyRate', + { + from: text('from').notNull(), + to: text('to').notNull(), + date: timestamp('date').notNull(), + rate: real('rate').notNull(), + lastFetched: timestamp('lastFetched').notNull(), + createdAt: createdAt(), + updatedAt: updatedAt(), + }, + (t) => [primaryKey({ columns: [t.from, t.to, t.date] })], +); + +export const cachedBankData = sqliteTable('CachedBankData', { + id: integer('id').primaryKey({ autoIncrement: true }), + obapiProviderId: text('obapiProviderId').notNull().unique(), + data: text('data').notNull(), + userId: integer('userId') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + lastFetched: timestamp('lastFetched').notNull(), + createdAt: createdAt(), + updatedAt: updatedAt(), +}); + +export const pushNotifications = sqliteTable( + 'PushNotification', + { + userId: integer('userId').notNull(), + endpoint: text('endpoint').notNull(), + subscription: text('subscription').notNull(), + }, + (t) => [primaryKey({ columns: [t.userId, t.endpoint] })], +); + +export const appMetadata = sqliteTable('AppMetadata', { + key: text('key').primaryKey(), + value: text('value').notNull(), + createdAt: createdAt(), + updatedAt: updatedAt(), +}); + +/** + * Balances derived from non-deleted expenses. Defined in SQL by the migration + * `0001_balance_view_and_triggers.sql`; `.existing()` stops drizzle-kit from managing it. + */ +export const balanceView = sqliteView('BalanceView', { + userId: integer('userId').notNull(), + friendId: integer('friendId').notNull(), + groupId: integer('groupId'), + currency: text('currency').notNull(), + amount: bigintInt('amount').notNull(), + createdAt: timestamp('createdAt').notNull(), + updatedAt: timestamp('updatedAt').notNull(), +}).existing(); + +// --------------------------------------------------------------------------- +// Relations (used by db.query.*.findMany({ with })) +// --------------------------------------------------------------------------- + +export const usersRelations = relations(users, ({ many }) => ({ + accounts: many(accounts), + sessions: many(sessions), + groups: many(groups), + associatedGroups: many(groupUsers), + expenseParticipants: many(expenseParticipants), + expenseNotes: many(expenseNotes), + paidExpenses: many(expenses, { relationName: 'PaidByUser' }), + addedExpenses: many(expenses, { relationName: 'AddedByUser' }), + deletedExpenses: many(expenses, { relationName: 'DeletedByUser' }), + updatedExpenses: many(expenses, { relationName: 'UpdatedByUser' }), + cachedBankData: many(cachedBankData), +})); + +export const accountsRelations = relations(accounts, ({ one }) => ({ + user: one(users, { fields: [accounts.userId], references: [users.id] }), +})); + +export const sessionsRelations = relations(sessions, ({ one }) => ({ + user: one(users, { fields: [sessions.userId], references: [users.id] }), +})); + +export const groupsRelations = relations(groups, ({ one, many }) => ({ + createdBy: one(users, { fields: [groups.userId], references: [users.id] }), + groupUsers: many(groupUsers), + expenses: many(expenses), + groupDefaultSplit: one(groupDefaultSplits, { + fields: [groups.id], + references: [groupDefaultSplits.groupId], + }), +})); + +export const groupDefaultSplitsRelations = relations(groupDefaultSplits, ({ one }) => ({ + group: one(groups, { fields: [groupDefaultSplits.groupId], references: [groups.id] }), +})); + +export const friendDefaultSplitsRelations = relations(friendDefaultSplits, ({ one }) => ({ + userA: one(users, { fields: [friendDefaultSplits.userAId], references: [users.id] }), + userB: one(users, { fields: [friendDefaultSplits.userBId], references: [users.id] }), +})); + +export const groupUsersRelations = relations(groupUsers, ({ one }) => ({ + group: one(groups, { fields: [groupUsers.groupId], references: [groups.id] }), + user: one(users, { fields: [groupUsers.userId], references: [users.id] }), +})); + +export const jobsRelations = relations(jobs, ({ one }) => ({ + recurrence: one(expenseRecurrences, { + fields: [jobs.jobid], + references: [expenseRecurrences.jobId], + }), +})); + +export const expenseRecurrencesRelations = relations(expenseRecurrences, ({ one, many }) => ({ + job: one(jobs, { fields: [expenseRecurrences.jobId], references: [jobs.jobid] }), + expense: many(expenses), +})); + +export const expensesRelations = relations(expenses, ({ one, many }) => ({ + paidByUser: one(users, { + fields: [expenses.paidBy], + references: [users.id], + relationName: 'PaidByUser', + }), + addedByUser: one(users, { + fields: [expenses.addedBy], + references: [users.id], + relationName: 'AddedByUser', + }), + deletedByUser: one(users, { + fields: [expenses.deletedBy], + references: [users.id], + relationName: 'DeletedByUser', + }), + updatedByUser: one(users, { + fields: [expenses.updatedBy], + references: [users.id], + relationName: 'UpdatedByUser', + }), + group: one(groups, { fields: [expenses.groupId], references: [groups.id] }), + recurrence: one(expenseRecurrences, { + fields: [expenses.recurrenceId], + references: [expenseRecurrences.id], + }), + conversionTo: one(expenses, { + fields: [expenses.conversionToId], + references: [expenses.id], + relationName: 'CurrencyConversion', + }), + conversionFrom: many(expenses, { relationName: 'CurrencyConversion' }), + expenseParticipants: many(expenseParticipants), + expenseNotes: many(expenseNotes), +})); + +export const expenseParticipantsRelations = relations(expenseParticipants, ({ one }) => ({ + expense: one(expenses, { fields: [expenseParticipants.expenseId], references: [expenses.id] }), + user: one(users, { fields: [expenseParticipants.userId], references: [users.id] }), +})); + +export const expenseNotesRelations = relations(expenseNotes, ({ one }) => ({ + expense: one(expenses, { fields: [expenseNotes.expenseId], references: [expenses.id] }), + createdBy: one(users, { fields: [expenseNotes.createdById], references: [users.id] }), +})); + +export const cachedBankDataRelations = relations(cachedBankData, ({ one }) => ({ + user: one(users, { fields: [cachedBankData.userId], references: [users.id] }), +})); + +// --------------------------------------------------------------------------- +// Row types (replacements for the former @prisma/client model types) +// --------------------------------------------------------------------------- + +export type User = Omit; +export type Group = typeof groups.$inferSelect; +export type GroupUser = typeof groupUsers.$inferSelect; +export type Expense = typeof expenses.$inferSelect; +export type ExpenseParticipant = typeof expenseParticipants.$inferSelect; +export type ExpenseNote = typeof expenseNotes.$inferSelect; +export type ExpenseRecurrence = typeof expenseRecurrences.$inferSelect; +export type Job = typeof jobs.$inferSelect; +export type BalanceView = typeof balanceView.$inferSelect; +export type Account = typeof accounts.$inferSelect; diff --git a/src/server/db/types.ts b/src/server/db/types.ts new file mode 100644 index 000000000..7d5f5c826 --- /dev/null +++ b/src/server/db/types.ts @@ -0,0 +1,14 @@ +// Client-safe entry point: the enum value plus type-only re-exports of row types. +export { SplitType } from './enums'; +export type { + Account, + BalanceView, + Expense, + ExpenseNote, + ExpenseParticipant, + ExpenseRecurrence, + Group, + GroupUser, + Job, + User, +} from './schema'; diff --git a/src/server/mailer.ts b/src/server/mailer.ts index 15c601cfc..bf60b487d 100644 --- a/src/server/mailer.ts +++ b/src/server/mailer.ts @@ -1,72 +1,27 @@ -import { type User } from 'next-auth'; -import nodemailer, { type Transporter } from 'nodemailer'; - import { env } from '~/env'; +import type { SessionUser as User } from '~/server/auth'; +import { bindings } from '~/server/db'; import { sendToDiscord } from './service-notification'; -// oxlint-disable-next-line init-declarations -let transporter: Transporter; - -export const mailServerConfig = { - host: env.EMAIL_SERVER_HOST, - port: parseInt(env.EMAIL_SERVER_PORT ?? ''), - auth: { user: env.EMAIL_SERVER_USER, pass: env.EMAIL_SERVER_PASSWORD }, - tls: { - rejectUnauthorized: env.NODE_ENV !== 'development' ? env.EMAIL_TLS_REJECT_UNAUTHORIZED : false, - }, -}; - -const getTransporter = () => { - if (transporter) { - return transporter; - } - - if (!mailServerConfig.host) { - return; - } - - const transport = { - ...mailServerConfig, - secure: 465 === mailServerConfig.port, - }; - - transporter = nodemailer.createTransport(transport); - return transporter; -}; - -export async function sendSignUpEmail(email: string, url: string, token: string) { - const { host } = new URL(url); - - console.log(env.NODE_ENV); - - if ('development' === env.NODE_ENV) { - console.log('Sign in link : ', email, url, token); - return true; - } - - const subject = 'Sign in to SplitPro'; - const text = `Hey,\n\nYou can sign in to SplitPro by clicking the below URL:\n${url}\n\nYou can also use this OTP: ${token}\n\nThanks,\nSplitPro Team`; - const html = `

Hey,

You can sign in to SplitPro by clicking the below URL:

Sign in to ${host}

You can also use this OTP: ${token}



Thanks,


SplitPro Team

`; - - return await sendMail(email, subject, text, html); -} +const escapeHtml = (value: string) => + value.replace( + /[&<>"']/g, + (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!, + ); export async function sendInviteEmail(email: string, name: string) { if (!env.ENABLE_SENDING_INVITES) { throw new Error('Sending invites is not enabled'); } - const { host } = new URL(env.NEXTAUTH_URL); - - if ('development' === env.NODE_ENV) { - console.log('Sending invite email', email, name); - return; - } + const url = env.NEXTAUTH_URL; + const { host } = new URL(url); + const safeName = escapeHtml(name); const subject = 'Invitation to SplitPro'; - const text = `Hey,\n\nYou have been invited to SplitPro by ${name}. It's a completely open source free alternative to splitwise. You can sign in to SplitPro by clicking the below URL:\n${env.NEXTAUTH_URL}\n\nThanks,\nSplitPro Team`; - const html = `

Hey,

You have been invited to SplitPro by ${name}. It's a completely open source free alternative to splitwise. You can sign in to SplitPro by clicking the below URL:

Sign in to ${host}


Thanks,
SplitPro Team

`; + const text = `Hey,\n\nYou have been invited to SplitPro by ${name}. It's a completely open source free alternative to splitwise. You can sign in to SplitPro by clicking the below URL:\n${url}\n\nThanks,\nSplitPro Team`; + const html = `

Hey,

You have been invited to SplitPro by ${safeName}. It's a completely open source free alternative to splitwise. You can sign in to SplitPro by clicking the below URL:

Sign in to ${host}


Thanks,
SplitPro Team

`; await sendMail(email, subject, text, html); } @@ -81,33 +36,28 @@ export async function sendFeedbackEmail(feedback: string, user: User) { const subject = `Feedback received on SplitPro from ${user.name}`; const text = `Feedback created by ${user.name} :\n\nFeedback: ${feedback}\n\nemail: ${user.email}`; - await sendMail(env.FEEDBACK_EMAIL, subject, text, text, user.email ?? undefined); + await sendMail(env.FEEDBACK_EMAIL, subject, text, escapeHtml(text), user.email ?? undefined); } -async function sendMail( - email: string, - subject: string, - text: string, - html: string, - replyTo?: string, -) { - const transporter = getTransporter(); - try { - if (transporter) { - await transporter.sendMail({ - to: email, - from: env.FROM_EMAIL, - subject, - text, - html, - replyTo, - }); +/** Sends through the Cloudflare Email Sending binding (`send_email` → EMAIL). */ +async function sendMail(to: string, subject: string, text: string, html: string, replyTo?: string) { + const mailer = bindings.EMAIL; + if (!mailer || !env.FROM_EMAIL) { + console.log('Email binding or FROM_EMAIL not configured, so skipping'); + return false; + } - console.log('Email sent'); - return true; - } else { - console.log('SMTP server not configured, so skipping'); - } + try { + await mailer.send({ + to, + from: { email: env.FROM_EMAIL, name: 'SplitPro' }, + subject, + text, + html, + ...(replyTo && { replyTo }), + }); + console.log('Email sent'); + return true; } catch (error) { console.log('Error sending email', error); await sendToDiscord( diff --git a/src/server/observability/background.ts b/src/server/observability/background.ts new file mode 100644 index 000000000..c5a90e9bd --- /dev/null +++ b/src/server/observability/background.ts @@ -0,0 +1,18 @@ +import { getRequestExecutionContext } from 'vinext/shims/request-context'; + +import { logError } from './errorLog'; + +/** + * Runs fire-and-forget work so it survives the response (via `ctx.waitUntil`) and any + * failure is logged to Workers Observability instead of being lost. + */ +export const runInBackground = (name: string, work: Promise) => { + const guarded = work.catch((error: unknown) => { + logError('worker', error, { phase: 'background', task: name }); + }); + try { + getRequestExecutionContext()?.waitUntil(guarded); + } catch { + // No request context (e.g. inside a Workflow step): the promise still runs. + } +}; diff --git a/src/server/observability/errorLog.ts b/src/server/observability/errorLog.ts new file mode 100644 index 000000000..6231fbc8d --- /dev/null +++ b/src/server/observability/errorLog.ts @@ -0,0 +1,45 @@ +/** + * Structured error logging for Workers Observability. + * + * Every entry is one `console.error` call with a JSON object, so Workers Logs can filter on + * fields such as `source`, `path`, or `errorName`. Nothing is sampled + * (see `observability` in wrangler.jsonc). + */ +export type ErrorSource = 'worker' | 'unhandledrejection' | 'trpc' | 'ssr' | 'workflow' | 'browser'; + +export interface ErrorContext { + [key: string]: unknown; +} + +const MAX_STRING = 4000; + +const truncate = (value: string) => + value.length > MAX_STRING ? `${value.slice(0, MAX_STRING)}…[truncated]` : value; + +export const serializeError = (error: unknown) => { + if (error instanceof Error) { + const cause = (error as Error & { cause?: unknown }).cause; + return { + errorName: error.name, + errorMessage: truncate(error.message), + stack: error.stack ? truncate(error.stack) : undefined, + cause: cause instanceof Error ? `${cause.name}: ${truncate(cause.message)}` : undefined, + }; + } + return { errorName: 'NonError', errorMessage: truncate(String(error)) }; +}; + +export const logError = (source: ErrorSource, error: unknown, context: ErrorContext = {}) => { + console.error( + JSON.stringify( + { + level: 'error', + source, + ...serializeError(error), + ...context, + timestamp: new Date().toISOString(), + }, + (_key, value: unknown) => ('bigint' === typeof value ? value.toString() : value), + ), + ); +}; diff --git a/src/server/preview-access.ts b/src/server/preview-access.ts new file mode 100644 index 000000000..36168205c --- /dev/null +++ b/src/server/preview-access.ts @@ -0,0 +1,2 @@ +export const PREVIEW_ACCESS_EMAIL_HEADER = 'x-splitpro-preview-access-email'; +export const PREVIEW_ACCESS_NAME_HEADER = 'x-splitpro-preview-access-name'; diff --git a/src/server/workflows/scheduler.ts b/src/server/workflows/scheduler.ts new file mode 100644 index 000000000..58caa3919 --- /dev/null +++ b/src/server/workflows/scheduler.ts @@ -0,0 +1,69 @@ +/** + * Scheduler Workflow. Replaces pg_cron and the old in-process setTimeout loops. + * Triggered every minute by the `schedules` on the SCHEDULER Workflow binding (wrangler.jsonc). + */ +import { WorkflowEntrypoint, type WorkflowEvent, type WorkflowStep } from 'cloudflare:workers'; + +import { env as appEnv } from '~/env'; +import { checkRecurrenceNotifications } from '~/server/api/services/notificationService'; +import { deleteStaleCache, firesInMinute, getDueJobs } from '~/server/api/services/scheduleService'; +import { runRecurringJob } from '~/server/api/services/splitService'; +import { logError } from '~/server/observability/errorLog'; + +const retry = { + retries: { limit: 3, delay: '10 seconds' as const, backoff: 'exponential' as const }, +}; + +export class SchedulerWorkflow extends WorkflowEntrypoint { + async run(event: WorkflowEvent, step: WorkflowStep) { + const now = new Date(event.schedule?.scheduledTime ?? event.timestamp.getTime()); + + const due = await step.do('load due jobs', async () => + (await getDueJobs(now)).map((j) => j.jobid), + ); + + // One step per job: retried independently; runRecurringJob is idempotent per run slot. + for (const jobid of due) { + await step.do(`run job ${jobid}`, retry, async () => { + try { + const result = await runRecurringJob(jobid, now); + return { jobid, skipped: result.skipped }; + } catch (error) { + logError('workflow', error, { step: 'run job', jobid, instanceId: event.instanceId }); + throw error; + } + }); + } + + if (due.length > 0) { + await step.do('recurrence notifications', retry, async () => { + try { + await checkRecurrenceNotifications(); + return true; + } catch (error) { + logError('workflow', error, { + step: 'recurrence notifications', + instanceId: event.instanceId, + }); + throw error; + } + }); + } + + const cleanupRule = appEnv.CLEAR_CACHE_CRON_RULE; + const retention = appEnv.CACHE_RETENTION_INTERVAL; + if (cleanupRule && retention && firesInMinute(cleanupRule, now)) { + await step.do('cache cleanup', retry, async () => { + try { + await deleteStaleCache(retention, now); + return true; + } catch (error) { + logError('workflow', error, { step: 'cache cleanup', instanceId: event.instanceId }); + throw error; + } + }); + } + + return { ranJobs: due.length }; + } +} diff --git a/src/store/addStore.ts b/src/store/addStore.ts index 0dbf1d40b..4da93d999 100644 --- a/src/store/addStore.ts +++ b/src/store/addStore.ts @@ -1,4 +1,4 @@ -import { type Group, SplitType, type User } from '@prisma/client'; +import { type Group, SplitType, type User } from '~/server/db/types'; import Router from 'next/router'; import { create } from 'zustand'; diff --git a/src/sw.ts b/src/sw.ts new file mode 100644 index 000000000..7955258a8 --- /dev/null +++ b/src/sw.ts @@ -0,0 +1,58 @@ +import { type PushMessage } from '~/types'; +import { defaultCache } from '@serwist/vite/worker'; +import type { PrecacheEntry, SerwistGlobalConfig } from 'serwist'; +import { Serwist } from 'serwist'; + +// This declares the value of `injectionPoint` to TypeScript. +// `injectionPoint` is the string that will be replaced by the +// Actual precache manifest. By default, this string is set to +// `"self.__SW_MANIFEST"`. +declare global { + interface WorkerGlobalScope extends SerwistGlobalConfig { + __SW_MANIFEST: (PrecacheEntry | string)[] | undefined; + } +} + +declare const self: ServiceWorkerGlobalScope; + +const serwist = new Serwist({ + precacheEntries: self.__SW_MANIFEST, + skipWaiting: true, + clientsClaim: true, + navigationPreload: true, + runtimeCaching: defaultCache, +}); + +self.addEventListener('push', function (event) { + const { title, message, data } = JSON.parse(event?.data?.text() ?? '{}') as PushMessage; + event.waitUntil( + self.registration.showNotification(title, { + body: message, + icon: '/icons/android-chrome-192x192.png', + data, + }), + ); +}); + +self.addEventListener('notificationclick', function (event) { + event.notification.close(); + event.waitUntil( + (async () => { + const clientList = await self.clients.matchAll({ type: 'window' }); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const url = (event.notification.data?.url as string) ?? '/'; + + const matchingClient = clientList.find((client) => client.focused) ?? clientList[0]; + + if (matchingClient) { + const client = await matchingClient.focus(); + await client.navigate(url); + } else { + await self.clients.openWindow(url); + } + })(), + ); +}); + +serwist.addEventListeners(); diff --git a/src/tests/addStore.test.ts b/src/tests/addStore.test.ts deleted file mode 100644 index 026f654d0..000000000 --- a/src/tests/addStore.test.ts +++ /dev/null @@ -1,1190 +0,0 @@ -import { SplitType, type User } from '@prisma/client'; - -import { - type AddExpenseState, - type Participant, - calculateParticipantSplit, - calculateSplitShareBasedOnAmount, - initSplitShares, - useAddExpenseStore, -} from '~/store/addStore'; -import { getCurrencyHelpers } from '~/utils/numbers'; - -// Mock dependencies -jest.mock('~/utils/array', () => ({ - shuffleArray: jest.fn((arr: T[]): T[] => arr), // No shuffling for predictable tests -})); - -// Create mock users for testing -const createMockUser = (id: number, name: string, email: string): User => ({ - id, - name, - email, - currency: 'USD', - defaultCurrency: null, - emailVerified: null, - image: null, - preferredLanguage: 'en', - obapiProviderId: null, - bankingId: null, - hiddenFriendIds: [], -}); - -const user1: User = createMockUser(1, 'Alice', 'alice@example.com'); -const user2: User = createMockUser(2, 'Bob', 'bob@example.com'); -const user3: User = createMockUser(3, 'Charlie', 'charlie@example.com'); - -// Create participants with initial amounts -const createParticipants = (users: User[], amounts: bigint[] = []): Participant[] => - users.map((user, index) => ({ - ...user, - amount: amounts[index] ?? 0n, - })); - -// Helper to create split shares structure -const createSplitShares = (participants: Participant[], splitType: SplitType, shares: bigint[]) => { - const splitShares: Record> = {}; - - participants.forEach((participant, index) => { - splitShares[participant.id] = initSplitShares(); - splitShares[participant.id]![splitType] = shares[index] ?? 0n; - }); - - return splitShares; -}; - -describe('calculateParticipantSplit', () => { - describe('Edge cases', () => { - it('should return participants unchanged when amount is 0', () => { - const participants = createParticipants([user1, user2, user3]); - const splitShares = createSplitShares(participants, SplitType.EQUAL, [1n, 1n, 1n]); - - const state: Partial = { - amount: 0n, - participants, - splitType: SplitType.EQUAL, - splitShares, - paidBy: user1, - expenseDate: new Date('2024-01-01'), - }; - - const result = calculateParticipantSplit(state as AddExpenseState); - - expect(result.participants).toEqual(participants); - expect(result.canSplitScreenClosed).toBe(true); - }); - it('should handle empty participants array', () => { - const state: Partial = { - amount: 10000n, - participants: [], - splitType: SplitType.EQUAL, - splitShares: {}, - paidBy: undefined, - expenseDate: new Date('2024-01-01'), - }; - - const result = calculateParticipantSplit(state as AddExpenseState); - - expect(result.participants).toEqual([]); - expect(result.canSplitScreenClosed).toBe(false); - }); - }); - - describe('SplitType.EQUAL', () => { - it('should split amount equally among all participants', () => { - const participants = createParticipants([user1, user2, user3]); - const splitShares = createSplitShares(participants, SplitType.EQUAL, [1n, 1n, 1n]); - - const state: Partial = { - amount: 30000n, - participants, - splitType: SplitType.EQUAL, - splitShares, - paidBy: user1, - expenseDate: new Date('2024-01-01'), - }; - - const result = calculateParticipantSplit(state as AddExpenseState); - - // Each person should owe $100 (10000n), but payer gets the difference - expect(result.participants[0]?.amount).toBe(20000n); // Payer: -10000n + 30000n = 20000n - expect(result.participants[1]?.amount).toBe(-10000n); // Owes $100 - expect(result.participants[2]?.amount).toBe(-10000n); // Owes $100 - expect(result.canSplitScreenClosed).toBe(true); - }); - - it('should exclude participants with 0 share from equal split', () => { - const participants = createParticipants([user1, user2, user3]); - const splitShares = createSplitShares(participants, SplitType.EQUAL, [1n, 1n, 0n]); - - const state: Partial = { - amount: 20000n, - participants, - splitType: SplitType.EQUAL, - splitShares, - paidBy: user1, - expenseDate: new Date('2024-01-01'), - }; - - const result = calculateParticipantSplit(state as AddExpenseState); - - // Only first two participants split the cost - expect(result.participants[0]?.amount).toBe(10000n); // Payer: -10000n + 20000n = 10000n - expect(result.participants[1]?.amount).toBe(-10000n); // Owes $100 - expect(result.participants[2]?.amount).toBe(0n); // Excluded from split - }); - - it('should allow one non-payer to owe the full amount', () => { - const participants = createParticipants([user1, user2]); - const splitShares = createSplitShares(participants, SplitType.EQUAL, [0n, 1n]); - - const state: Partial = { - amount: 10000n, - participants, - splitType: SplitType.EQUAL, - splitShares, - paidBy: user1, - expenseDate: new Date('2024-01-01'), - }; - - const result = calculateParticipantSplit(state as AddExpenseState); - - expect(result.participants[0]?.amount).toBe(10000n); - expect(result.participants[1]?.amount).toBe(-10000n); - expect(result.canSplitScreenClosed).toBe(true); - }); - - it('should mark a multi-person self-only split as incomplete', () => { - const participants = createParticipants([user1, user2]); - const splitShares = createSplitShares(participants, SplitType.EQUAL, [1n, 0n]); - - const state: Partial = { - amount: 10000n, - participants, - splitType: SplitType.EQUAL, - splitShares, - paidBy: user1, - expenseDate: new Date('2024-01-01'), - }; - - const result = calculateParticipantSplit(state as AddExpenseState); - - expect(result.participants[0]?.amount).toBe(0n); - expect(result.participants[1]?.amount).toBe(0n); - expect(result.canSplitScreenClosed).toBe(false); - }); - - it('should mark equal split incomplete when every share is disabled', () => { - const participants = createParticipants([user1, user2]); - const splitShares = createSplitShares(participants, SplitType.EQUAL, [0n, 0n]); - - const state: Partial = { - amount: 10000n, - participants, - splitType: SplitType.EQUAL, - splitShares, - paidBy: user1, - expenseDate: new Date('2024-01-01'), - }; - - const result = calculateParticipantSplit(state as AddExpenseState); - - expect(result.canSplitScreenClosed).toBe(false); - }); - - it('should handle uneven division with penny adjustment', () => { - const participants = createParticipants([user1, user2, user3]); - const splitShares = createSplitShares(participants, SplitType.EQUAL, [1n, 1n, 1n]); - - const state: Partial = { - amount: 10001n, - participants, - splitType: SplitType.EQUAL, - splitShares, - paidBy: user1, - expenseDate: new Date('2024-01-01'), - }; - - const result = calculateParticipantSplit(state as AddExpenseState); - - // Should distribute the extra penny - const totalOwed = result.participants.reduce((sum, p) => sum + (p.amount ?? 0n), 0n); - expect(totalOwed).toBe(0n); // Total should always balance // Payer should get the remainder - expect(result.participants[0]?.amount).toBeGreaterThanOrEqual(6667n); - }); - - it('should handle single participant as payer', () => { - const participants = createParticipants([user1]); - const splitShares = createSplitShares(participants, SplitType.EQUAL, [1n]); - - const state: Partial = { - amount: 10000n, - participants, - splitType: SplitType.EQUAL, - splitShares, - paidBy: user1, - expenseDate: new Date('2024-01-01'), - }; - - const result = calculateParticipantSplit(state as AddExpenseState); - - expect(result.participants[0]?.amount).toBe(0n); // Payer pays and owes nothing - expect(result.canSplitScreenClosed).toBe(true); - }); - }); - - describe('SplitType.PERCENTAGE', () => { - it('should split amount by percentage', () => { - const participants = createParticipants([user1, user2, user3]); - const splitShares = createSplitShares(participants, SplitType.PERCENTAGE, [ - 5000n /* Lines 153-154 omitted */, - 3000n /* Lines 154-155 omitted */, - 2000n /* Lines 155-156 omitted */, - ]); - - const state: Partial = { - amount: 10000n, - participants, - splitType: SplitType.PERCENTAGE, - splitShares, - paidBy: user1, - expenseDate: new Date('2024-01-01'), - }; - - const result = calculateParticipantSplit(state as AddExpenseState); - - expect(result.participants[0]?.amount).toBe(5000n); // Payer: -5000n + 10000n = 5000n - expect(result.participants[1]?.amount).toBe(-3000n); // Owes 30% - expect(result.participants[2]?.amount).toBe(-2000n); // Owes 20% - expect(result.canSplitScreenClosed).toBe(true); - }); - - it('should mark incomplete when percentages do not sum to 100%', () => { - const participants = createParticipants([user1, user2, user3]); - const splitShares = createSplitShares(participants, SplitType.PERCENTAGE, [ - 4000n /* Lines 175-176 omitted */, - 3000n /* Lines 176-177 omitted */, - 2000n /* Lines 177-178 omitted */, - ]); - - const state: Partial = { - amount: 10000n, - participants, - splitType: SplitType.PERCENTAGE, - splitShares, - paidBy: user1, - expenseDate: new Date('2024-01-01'), - }; - - const result = calculateParticipantSplit(state as AddExpenseState); - - expect(result.canSplitScreenClosed).toBe(false); - }); - - it('should handle 0% share participants', () => { - const participants = createParticipants([user1, user2, user3]); - const splitShares = createSplitShares(participants, SplitType.PERCENTAGE, [ - 7000n /* Lines 194-195 omitted */, - 3000n /* Lines 195-196 omitted */, - 0n /* Lines 196-197 omitted */, - ]); - - const state: Partial = { - amount: 10000n, - participants, - splitType: SplitType.PERCENTAGE, - splitShares, - paidBy: user1, - expenseDate: new Date('2024-01-01'), - }; - - const result = calculateParticipantSplit(state as AddExpenseState); - - expect(result.participants[0]?.amount).toBe(3000n); // Payer: -7000n + 10000n - expect(result.participants[1]?.amount).toBe(-3000n); - expect(result.participants[2]?.amount).toBe(0n); // No share - }); - }); - - describe('SplitType.SHARE', () => { - it('should split amount by shares', () => { - const participants = createParticipants([user1, user2, user3]); - const splitShares = createSplitShares(participants, SplitType.SHARE, [ - 200n /* Lines 217-218 omitted */, - 100n /* Lines 218-219 omitted */, - 100n /* Lines 219-220 omitted */, - ]); - - const state: Partial = { - amount: 12000n, - participants, - splitType: SplitType.SHARE, - splitShares, - paidBy: user1, - expenseDate: new Date('2024-01-01'), - }; - - const result = calculateParticipantSplit(state as AddExpenseState); - - expect(result.participants[0]?.amount).toBe(6000n); // Payer: -6000n + 12000n = 6000n (50%) - expect(result.participants[1]?.amount).toBe(-3000n); // 25% - expect(result.participants[2]?.amount).toBe(-3000n); // 25% - expect(result.canSplitScreenClosed).toBe(true); - }); - - it('should handle participants with 0 shares', () => { - const participants = createParticipants([user1, user2, user3]); - const splitShares = createSplitShares(participants, SplitType.SHARE, [ - 300n /* Lines 239-240 omitted */, - 100n /* Lines 240-241 omitted */, - 0n /* Lines 241-242 omitted */, - ]); - - const state: Partial = { - amount: 8000n, - participants, - splitType: SplitType.SHARE, - splitShares, - paidBy: user1, - expenseDate: new Date('2024-01-01'), - }; - - const result = calculateParticipantSplit(state as AddExpenseState); - - expect(result.participants[0]?.amount).toBe(2000n); // Payer: -6000n + 8000n (75%) - expect(result.participants[1]?.amount).toBe(-2000n); // 25% - expect(result.participants[2]?.amount).toBe(0n); // No shares - }); - - it('should mark incomplete when no shares are assigned', () => { - const participants = createParticipants([user1, user2, user3]); - const splitShares = createSplitShares(participants, SplitType.SHARE, [0n, 0n, 0n]); - - const state: Partial = { - amount: 10000n, - participants, - splitType: SplitType.SHARE, - splitShares, - paidBy: user1, - expenseDate: new Date('2024-01-01'), - }; - - const result = calculateParticipantSplit(state as AddExpenseState); - - expect(result.canSplitScreenClosed).toBe(false); - }); - }); - - describe('SplitType.EXACT', () => { - it('should assign exact amounts to participants', () => { - const participants = createParticipants([user1, user2, user3]); - const splitShares = createSplitShares(participants, SplitType.EXACT, [ - 4000n /* Lines 277-278 omitted */, - 3000n /* Lines 278-279 omitted */, - 3000n /* Lines 279-280 omitted */, - ]); - - const state: Partial = { - amount: 10000n, - participants, - splitType: SplitType.EXACT, - splitShares, - paidBy: user1, - expenseDate: new Date('2024-01-01'), - }; - - const result = calculateParticipantSplit(state as AddExpenseState); - - expect(result.participants[0]?.amount).toBe(6000n); // Payer: -4000n + 10000n - expect(result.participants[1]?.amount).toBe(-3000n); - expect(result.participants[2]?.amount).toBe(-3000n); - expect(result.canSplitScreenClosed).toBe(true); - }); - - it('should mark incomplete when exact amounts do not sum to total', () => { - const participants = createParticipants([user1, user2, user3]); - const splitShares = createSplitShares(participants, SplitType.EXACT, [ - 4000n /* Lines 299-300 omitted */, - 3000n /* Lines 300-301 omitted */, - 2000n /* Lines 301-302 omitted */, - ]); - - const state: Partial = { - amount: 10000n, - participants, - splitType: SplitType.EXACT, - splitShares, - paidBy: user1, - expenseDate: new Date('2024-01-01'), - }; - - const result = calculateParticipantSplit(state as AddExpenseState); - - expect(result.canSplitScreenClosed).toBe(false); - }); - - it('should handle undefined exact amounts as 0', () => { - const participants = createParticipants([user1, user2, user3]); - const splitShares = createSplitShares(participants, SplitType.EXACT, [ - 10000n /* Lines 318-319 omitted */, - 0n /* Lines 319-320 omitted */, - 0n /* Lines 320-321 omitted */, - ]); - - const state: Partial = { - amount: 10000n, - participants, - splitType: SplitType.EXACT, - splitShares, - paidBy: user1, - expenseDate: new Date('2024-01-01'), - }; - - const result = calculateParticipantSplit(state as AddExpenseState); - - expect(result.participants[0]?.amount).toBe(0n); // Payer: -10000n + 10000n - expect(result.participants[1]?.amount).toBe(0n); - expect(result.participants[2]?.amount).toBe(0n); - }); - }); - - describe('SplitType.ADJUSTMENT', () => { - it('should distribute remaining amount equally after adjustments', () => { - const participants = createParticipants([user1, user2, user3]); - const splitShares = createSplitShares(participants, SplitType.ADJUSTMENT, [ - 1000n /* Lines 341-342 omitted */, - -500n /* Lines 342-343 omitted */, - 0n /* Lines 343-344 omitted */, - ]); - - const state: Partial = { - amount: 15000n, - participants, - splitType: SplitType.ADJUSTMENT, - splitShares, - paidBy: user1, - expenseDate: new Date('2024-01-01'), - }; - - const result = calculateParticipantSplit(state as AddExpenseState); - - // Remaining after adjustments: 15000n - 1000n - (-500n) - 0n = 14500n - // Split equally: 14500n / 3 = ~4833n each - const baseShare = (15000n - 1000n - -500n - 0n) / 3n; // 4833n - - expect(result.participants[0]?.amount).toBe(9166n); // Payer adjustment (adjusted for rounding) - expect(result.participants[1]?.amount).toBe(-(baseShare - 500n)); - expect(result.participants[2]?.amount).toBe(-baseShare); - }); - - it('should mark incomplete when adjustments exceed total amount', () => { - const participants = createParticipants([user1, user2, user3]); - const splitShares = createSplitShares(participants, SplitType.ADJUSTMENT, [ - 8000n /* Lines 366-367 omitted */, - 5000n /* Lines 367-368 omitted */, - 0n, - ]); - - const state: Partial = { - amount: 10000n, - participants, - splitType: SplitType.ADJUSTMENT, - splitShares, - paidBy: user1, - expenseDate: new Date('2024-01-01'), - }; - - const result = calculateParticipantSplit(state as AddExpenseState); - - expect(result.canSplitScreenClosed).toBe(false); - }); - }); - - describe('Payer scenarios', () => { - it('should handle different payers correctly', () => { - const participants = createParticipants([user1, user2, user3]); - const splitShares = createSplitShares(participants, SplitType.EQUAL, [1n, 1n, 1n]); - - const state: Partial = { - amount: 30000n, - participants, - splitType: SplitType.EQUAL, - splitShares, - paidBy: user2, - expenseDate: new Date('2024-01-01'), - }; - - // Test with user2 as payer - const result = calculateParticipantSplit(state as AddExpenseState); - - expect(result.participants[0]?.amount).toBe(-10000n); // Owes - expect(result.participants[1]?.amount).toBe(20000n); // Payer: -10000n + 30000n - expect(result.participants[2]?.amount).toBe(-10000n); // Owes - }); - - it('should handle when payer is not in participants list', () => { - const participants = createParticipants([user1, user2, user3]); - const splitShares = createSplitShares(participants, SplitType.EQUAL, [1n, 1n, 1n]); - - const externalPayer = createMockUser(4, 'David', 'david@example.com'); - - const state: Partial = { - amount: 30000n, - participants, - splitType: SplitType.EQUAL, - splitShares, - paidBy: externalPayer, - expenseDate: new Date('2024-01-01'), - }; - - const result = calculateParticipantSplit(state as AddExpenseState); - - // All participants should owe their share but total balances to 0 due to penny adjustment - expect(result.participants[0]?.amount).toBe(0n); - expect(result.participants[1]?.amount).toBe(0n); - expect(result.participants[2]?.amount).toBe(0n); - }); - }); - - describe('Balance verification', () => { - it('should always maintain balance (total amounts sum to 0)', () => { - const participants = createParticipants([user1, user2, user3]); - const splitShares = createSplitShares(participants, SplitType.PERCENTAGE, [ - 4000n, // 40% - 3500n, // 35% - 2500n, // 25% - ]); - - const state: Partial = { - amount: 12345n, - participants, - splitType: SplitType.PERCENTAGE, - splitShares, - paidBy: user1, - expenseDate: new Date('2024-01-01'), - }; - - const result = calculateParticipantSplit(state as AddExpenseState); - - const totalAmount = result.participants.reduce((sum, p) => sum + (p.amount ?? 0n), 0n); - - expect(totalAmount).toBe(0n); - }); - - it('should handle penny adjustments correctly', () => { - const participants = createParticipants([user1, user2]); - const splitShares = createSplitShares(participants, SplitType.EQUAL, [1n, 1n]); - - const state: Partial = { - amount: 1n, - participants, - splitType: SplitType.EQUAL, - splitShares, - paidBy: user1, - expenseDate: new Date('2024-01-01'), - }; - - const result = calculateParticipantSplit(state as AddExpenseState); - - // One participant should get the penny - const totalAmount = result.participants.reduce((sum, p) => sum + (p.amount ?? 0n), 0n); - - expect(totalAmount).toBe(0n); - expect(result.participants.some((p) => -1n === p.amount)).toBe(true); - }); - }); -}); - -describe('calculateSplitShareBasedOnAmount', () => { - describe('SplitType.EQUAL', () => { - it('should set equal shares for participants with non-zero amounts', () => { - const participants = createParticipants([user1, user2, user3], [5000n, 5000n, 0n]); - const splitShares: Record> = {}; - participants.forEach((p) => { - splitShares[p.id] = initSplitShares(); - }); - - calculateSplitShareBasedOnAmount(10000n, participants, SplitType.EQUAL, splitShares, user1); - - expect(splitShares[user1.id]![SplitType.EQUAL]).toBe(1n); - expect(splitShares[user2.id]![SplitType.EQUAL]).toBe(1n); - expect(splitShares[user3.id]![SplitType.EQUAL]).toBe(0n); // Zero amount - }); - }); - - describe('SplitType.PERCENTAGE', () => { - it('should calculate percentage shares for regular participants', () => { - const participants = createParticipants([user1, user2, user3], [-3000n, -2000n, -5000n]); - const splitShares: Record> = {}; - participants.forEach((p) => { - splitShares[p.id] = initSplitShares(); - }); - - calculateSplitShareBasedOnAmount(10000n, participants, SplitType.PERCENTAGE, splitShares); - - expect(splitShares[user1.id]![SplitType.PERCENTAGE]).toBe(3000n); // 30% - expect(splitShares[user2.id]![SplitType.PERCENTAGE]).toBe(2000n); // 20% - expect(splitShares[user3.id]![SplitType.PERCENTAGE]).toBe(5000n); // 50% - }); - - it('should calculate percentage shares when payer is specified', () => { - const participants = createParticipants([user1, user2, user3], [8000n, -3000n, -5000n]); - const splitShares: Record> = {}; - participants.forEach((p) => { - splitShares[p.id] = initSplitShares(); - }); - - calculateSplitShareBasedOnAmount( - 10000n, - participants, - SplitType.PERCENTAGE, - splitShares, - user1, - ); - - // User1 is payer: paid 10000n but their amount is 8000n, so they owe 2000n (20%) - expect(splitShares[user1.id]![SplitType.PERCENTAGE]).toBe(2000n); // 20% - expect(splitShares[user2.id]![SplitType.PERCENTAGE]).toBe(3000n); // 30% - expect(splitShares[user3.id]![SplitType.PERCENTAGE]).toBe(5000n); // 50% - }); - - it('should handle zero amount', () => { - const participants = createParticipants([user1, user2, user3], [-3000n, -2000n, -5000n]); - const splitShares: Record> = {}; - participants.forEach((p) => { - splitShares[p.id] = initSplitShares(); - }); - - calculateSplitShareBasedOnAmount(0n, participants, SplitType.PERCENTAGE, splitShares); - - expect(splitShares[user1.id]![SplitType.PERCENTAGE]).toBe(0n); - expect(splitShares[user2.id]![SplitType.PERCENTAGE]).toBe(0n); - expect(splitShares[user3.id]![SplitType.PERCENTAGE]).toBe(0n); - }); - }); - - describe('SplitType.SHARE', () => { - it('should calculate share values based on participant amounts', () => { - const participants = createParticipants([user1, user2, user3], [-6000n, -3000n, -3000n]); - const splitShares: Record> = {}; - participants.forEach((p) => { - splitShares[p.id] = initSplitShares(); - }); - - calculateSplitShareBasedOnAmount(12000n, participants, SplitType.SHARE, splitShares); - - // Shares should be proportional: 6000:3000:3000 = 2:1:1 - // Multiplied by 100 and normalized - expect(splitShares[user1.id]![SplitType.SHARE]).toBe(200n); // 2 shares * 100 - expect(splitShares[user2.id]![SplitType.SHARE]).toBe(100n); // 1 share * 100 - expect(splitShares[user3.id]![SplitType.SHARE]).toBe(100n); // 1 share * 100 - }); - - it('should handle payer in share calculation', () => { - const participants = createParticipants([user1, user2, user3], [6000n, -3000n, -3000n]); - const splitShares: Record> = {}; - participants.forEach((p) => { - splitShares[p.id] = initSplitShares(); - }); - - calculateSplitShareBasedOnAmount(12000n, participants, SplitType.SHARE, splitShares, user1); - - // User1 is payer: paid 12000n but their amount is 6000n, so they owe 6000n - // Shares: 6000:3000:3000 = 2:1:1 - expect(splitShares[user1.id]![SplitType.SHARE]).toBe(200n); - expect(splitShares[user2.id]![SplitType.SHARE]).toBe(100n); - expect(splitShares[user3.id]![SplitType.SHARE]).toBe(100n); - }); - - it('should handle zero amount', () => { - const participants = createParticipants([user1, user2, user3], [-6000n, -3000n, -3000n]); - const splitShares: Record> = {}; - participants.forEach((p) => { - splitShares[p.id] = initSplitShares(); - }); - - calculateSplitShareBasedOnAmount(0n, participants, SplitType.SHARE, splitShares); - - expect(splitShares[user1.id]![SplitType.SHARE]).toBe(0n); - expect(splitShares[user2.id]![SplitType.SHARE]).toBe(0n); - expect(splitShares[user3.id]![SplitType.SHARE]).toBe(0n); - }); - }); - - describe('SplitType.EXACT', () => { - it('should set exact amounts for regular participants', () => { - const participants = createParticipants([user1, user2, user3], [-4000n, -3000n, -3000n]); - const splitShares: Record> = {}; - participants.forEach((p) => { - splitShares[p.id] = initSplitShares(); - }); - - calculateSplitShareBasedOnAmount(10000n, participants, SplitType.EXACT, splitShares); - - expect(splitShares[user1.id]![SplitType.EXACT]).toBe(4000n); - expect(splitShares[user2.id]![SplitType.EXACT]).toBe(3000n); - expect(splitShares[user3.id]![SplitType.EXACT]).toBe(3000n); - }); - - it('should handle payer in exact calculation', () => { - const participants = createParticipants([user1, user2, user3], [6000n, -3000n, -3000n]); - const splitShares: Record> = {}; - participants.forEach((p) => { - splitShares[p.id] = initSplitShares(); - }); - - calculateSplitShareBasedOnAmount(10000n, participants, SplitType.EXACT, splitShares, user1); - - // User1 is payer: paid 10000n but their amount is 6000n, so they owe 4000n - expect(splitShares[user1.id]![SplitType.EXACT]).toBe(4000n); - expect(splitShares[user2.id]![SplitType.EXACT]).toBe(3000n); - expect(splitShares[user3.id]![SplitType.EXACT]).toBe(3000n); - }); - - it('should handle zero amounts', () => { - const participants = createParticipants([user1, user2, user3], [0n, 0n, 0n]); - const splitShares: Record> = {}; - participants.forEach((p) => { - splitShares[p.id] = initSplitShares(); - }); - - calculateSplitShareBasedOnAmount(10000n, participants, SplitType.EXACT, splitShares); - - expect(splitShares[user1.id]![SplitType.EXACT]).toBe(0n); - expect(splitShares[user2.id]![SplitType.EXACT]).toBe(0n); - expect(splitShares[user3.id]![SplitType.EXACT]).toBe(0n); - }); - }); - - describe('SplitType.ADJUSTMENT', () => { - it('should handle payer in adjustment calculation', () => { - const participants = createParticipants([user1, user2, user3], [-9500n, -4500n, -5000n]); - const splitShares: Record> = {}; - participants.forEach((p) => { - splitShares[p.id] = initSplitShares(); - }); - - calculateSplitShareBasedOnAmount( - 15000n, - participants, - SplitType.ADJUSTMENT, - splitShares, - user1, - ); - - expect(splitShares[user1.id]![SplitType.ADJUSTMENT]).toBe(1000n); - expect(splitShares[user2.id]![SplitType.ADJUSTMENT]).toBe(0n); - expect(splitShares[user3.id]![SplitType.ADJUSTMENT]).toBe(500n); - }); - - it('should handle participants with zero amounts', () => { - const participants = createParticipants([user1, user2, user3], [0n, -5000n, 0n]); - const splitShares: Record> = {}; - participants.forEach((p) => { - splitShares[p.id] = initSplitShares(); - }); - - calculateSplitShareBasedOnAmount(5000n, participants, SplitType.ADJUSTMENT, splitShares); - - // Only user2 has non-zero amount, so equal share = 5000n / 1 = 5000n - // User2 owes 5000n vs 5000n = 0n adjustment - expect(splitShares[user1.id]![SplitType.ADJUSTMENT]).toBe(-5000n); // 0 - 5000n - expect(splitShares[user2.id]![SplitType.ADJUSTMENT]).toBe(0n); // 5000n - 5000n - expect(splitShares[user3.id]![SplitType.ADJUSTMENT]).toBe(-5000n); // 0 - 5000n - }); - }); - - describe('Edge cases', () => { - it('should handle empty participants array', () => { - const participants: Participant[] = []; - const splitShares: Record> = {}; - - calculateSplitShareBasedOnAmount(10000n, participants, SplitType.EQUAL, splitShares); - - // Should not throw an error and splitShares should remain empty - expect(Object.keys(splitShares)).toHaveLength(0); - }); - - it('should handle when one participant owes entire amount', () => { - const participants = createParticipants([user1, user2], [10000n, -10000n]); - const splitShares: Record> = {}; - participants.forEach((p) => { - splitShares[p.id] = initSplitShares(); - }); - - calculateSplitShareBasedOnAmount(10000n, participants, SplitType.EQUAL, splitShares, user1); - - expect(splitShares[user1.id]![SplitType.EQUAL]).toBe(0n); - expect(splitShares[user2.id]![SplitType.EQUAL]).toBe(1n); - }); - - it('should handle self-payment (no money flow) scenario', () => { - const participants = createParticipants([user1, user2, user3], [0n, 0n, 0n]); - const splitShares: Record> = {}; - participants.forEach((p) => { - splitShares[p.id] = initSplitShares(); - }); - - calculateSplitShareBasedOnAmount(10000n, participants, SplitType.EQUAL, splitShares, user1); - - expect(splitShares[user1.id]![SplitType.EQUAL]).toBe(1n); - expect(splitShares[user2.id]![SplitType.EQUAL]).toBe(0n); - expect(splitShares[user3.id]![SplitType.EQUAL]).toBe(0n); - }); - - it('should handle undefined paidBy', () => { - const participants = createParticipants([user1, user2], [-5000n, -5000n]); - const splitShares: Record> = {}; - participants.forEach((p) => { - splitShares[p.id] = initSplitShares(); - }); - - calculateSplitShareBasedOnAmount( - 10000n, - participants, - SplitType.PERCENTAGE, - splitShares, - undefined, - ); - - expect(splitShares[user1.id]![SplitType.PERCENTAGE]).toBe(5000n); - expect(splitShares[user2.id]![SplitType.PERCENTAGE]).toBe(5000n); - }); - - it('should handle all split types with same data', () => { - const participants = createParticipants([user1, user2], [-6000n, -4000n]); - - Object.values(SplitType).forEach((splitType) => { - if (splitType === SplitType.SETTLEMENT) { - return; - } // Skip settlement as it's not implemented - - const splitShares: Record> = {}; - participants.forEach((p) => { - splitShares[p.id] = initSplitShares(); - }); - - // Should not throw for any split type - expect(() => { - calculateSplitShareBasedOnAmount(10000n, participants, splitType, splitShares); - }).not.toThrow(); - }); - }); - }); -}); - -// Integration tests for function reversibility -describe('Function Reversibility Tests', () => { - describe('calculateParticipantSplit -> calculateSplitShareBasedOnAmount', () => { - it('should properly reverse EQUAL split', () => { - const participants = createParticipants([user1, user2, user3]); - const originalShares = [1n, 1n, 1n]; - const splitShares = createSplitShares(participants, SplitType.EQUAL, originalShares); - - const state: Partial = { - amount: 15000n, - participants, - splitType: SplitType.EQUAL, - splitShares, - paidBy: user1, - expenseDate: new Date('2024-01-01'), - }; - - // Apply split calculation - const splitResult = calculateParticipantSplit(state as AddExpenseState); - - // Reverse the calculation - const reversedShares: Record> = {}; - participants.forEach((p) => { - reversedShares[p.id] = initSplitShares(); - }); - - calculateSplitShareBasedOnAmount( - 15000n, - splitResult.participants, - SplitType.EQUAL, - reversedShares, - user1, - ); - - // Check if we got back the original shares - expect(reversedShares[user1.id]![SplitType.EQUAL]).toBe(originalShares[0]); - expect(reversedShares[user2.id]![SplitType.EQUAL]).toBe(originalShares[1]); - expect(reversedShares[user3.id]![SplitType.EQUAL]).toBe(originalShares[2]); - }); - - it('should properly reverse PERCENTAGE split', () => { - const participants = createParticipants([user1, user2, user3]); - const originalShares = [5000n, 3000n, 2000n]; // 50%, 30%, 20% - const splitShares = createSplitShares(participants, SplitType.PERCENTAGE, originalShares); - - const state: Partial = { - amount: 20000n, - participants, - splitType: SplitType.PERCENTAGE, - splitShares, - paidBy: user1, - expenseDate: new Date('2024-01-01'), - }; - - // Apply split calculation - const splitResult = calculateParticipantSplit(state as AddExpenseState); - - // Reverse the calculation - const reversedShares: Record> = {}; - participants.forEach((p) => { - reversedShares[p.id] = initSplitShares(); - }); - - calculateSplitShareBasedOnAmount( - 20000n, - splitResult.participants, - SplitType.PERCENTAGE, - reversedShares, - user1, - ); - - // Check if we got back the original percentage shares - expect(reversedShares[user1.id]![SplitType.PERCENTAGE]).toBe(originalShares[0]); - expect(reversedShares[user2.id]![SplitType.PERCENTAGE]).toBe(originalShares[1]); - expect(reversedShares[user3.id]![SplitType.PERCENTAGE]).toBe(originalShares[2]); - }); - - it('should properly reverse SHARE split', () => { - const participants = createParticipants([user1, user2, user3]); - const originalShares = [400n, 200n, 200n]; // 2:1:1 ratio - const splitShares = createSplitShares(participants, SplitType.SHARE, originalShares); - - const state: Partial = { - amount: 16000n, - participants, - splitType: SplitType.SHARE, - splitShares, - paidBy: user1, - expenseDate: new Date('2024-01-01'), - }; - - // Apply split calculation - const splitResult = calculateParticipantSplit(state as AddExpenseState); - - // Reverse the calculation - const reversedShares: Record> = {}; - participants.forEach((p) => { - reversedShares[p.id] = initSplitShares(); - }); - - calculateSplitShareBasedOnAmount( - 16000n, - splitResult.participants, - SplitType.SHARE, - reversedShares, - user1, - ); - - // Check if we got back proportional shares (should maintain the 2:1:1 ratio) - const ratio1 = Number(reversedShares[user1.id]![SplitType.SHARE]) / Number(originalShares[0]); - const ratio2 = Number(reversedShares[user2.id]![SplitType.SHARE]) / Number(originalShares[1]); - const ratio3 = Number(reversedShares[user3.id]![SplitType.SHARE]) / Number(originalShares[2]); - - // All ratios should be approximately equal (accounting for rounding) - expect(Math.abs(ratio1 - ratio2)).toBeLessThan(0.1); - expect(Math.abs(ratio2 - ratio3)).toBeLessThan(0.1); - }); - - it('should properly reverse EXACT split', () => { - const participants = createParticipants([user1, user2, user3]); - const originalShares = [6000n, 4000n, 2000n]; - const splitShares = createSplitShares(participants, SplitType.EXACT, originalShares); - - const state: Partial = { - amount: 12000n, - participants, - splitType: SplitType.EXACT, - splitShares, - paidBy: user1, - expenseDate: new Date('2024-01-01'), - }; - - // Apply split calculation - const splitResult = calculateParticipantSplit(state as AddExpenseState); - - // Reverse the calculation - const reversedShares: Record> = {}; - participants.forEach((p) => { - reversedShares[p.id] = initSplitShares(); - }); - - calculateSplitShareBasedOnAmount( - 12000n, - splitResult.participants, - SplitType.EXACT, - reversedShares, - user1, - ); - - // Check if we got back the original exact amounts - expect(reversedShares[user1.id]![SplitType.EXACT]).toBe(originalShares[0]); - expect(reversedShares[user2.id]![SplitType.EXACT]).toBe(originalShares[1]); - }); - - it('should handle ADJUSTMENT split reversal (known to have bugs)', () => { - const participants = createParticipants([user1, user2, user3]); - const originalShares = [1000n, 0n, 500n]; // Various adjustments - const splitShares = createSplitShares(participants, SplitType.ADJUSTMENT, originalShares); - - const state: Partial = { - amount: 12300n, - participants, - splitType: SplitType.ADJUSTMENT, - splitShares, - paidBy: user1, - expenseDate: new Date('2024-01-01'), - }; - - // Apply split calculation - const splitResult = calculateParticipantSplit(state as AddExpenseState); - - // Reverse the calculation - const reversedShares: Record> = {}; - participants.forEach((p) => { - reversedShares[p.id] = initSplitShares(); - }); - - calculateSplitShareBasedOnAmount( - 12300n, - splitResult.participants, - SplitType.ADJUSTMENT, - reversedShares, - user1, - ); - - expect(reversedShares[user1.id]![SplitType.ADJUSTMENT]).toBe(originalShares[0]); - expect(reversedShares[user2.id]![SplitType.ADJUSTMENT]).toBe(originalShares[1]); - expect(reversedShares[user3.id]![SplitType.ADJUSTMENT]).toBe(originalShares[2]); - }); - - it('should handle edge case with zero amounts', () => { - const participants = createParticipants([user1, user2, user3]); - const originalShares = [1n, 1n, 0n]; // One participant excluded - const splitShares = createSplitShares(participants, SplitType.EQUAL, originalShares); - - const state: Partial = { - amount: 10000n, - participants, - splitType: SplitType.EQUAL, - splitShares, - paidBy: user1, - expenseDate: new Date('2024-01-01'), - }; - - // Apply split calculation - const splitResult = calculateParticipantSplit(state as AddExpenseState); - - // Reverse the calculation - const reversedShares: Record> = {}; - participants.forEach((p) => { - reversedShares[p.id] = initSplitShares(); - }); - - calculateSplitShareBasedOnAmount( - 10000n, - splitResult.participants, - SplitType.EQUAL, - reversedShares, - user1, - ); - - // Check if we preserved the zero share - expect(reversedShares[user1.id]![SplitType.EQUAL]).toBe(originalShares[0]); - expect(reversedShares[user2.id]![SplitType.EQUAL]).toBe(originalShares[1]); - expect(reversedShares[user3.id]![SplitType.EQUAL]).toBe(originalShares[2]); - }); - - it('should handle external payer scenario', () => { - const participants = createParticipants([user1, user2, user3]); - const originalShares = [1n, 1n, 1n]; - const splitShares = createSplitShares(participants, SplitType.EQUAL, originalShares); - const externalPayer = createMockUser(4, 'External', 'external@example.com'); - - const state: Partial = { - amount: 15000n, - participants, - splitType: SplitType.EQUAL, - splitShares, - paidBy: externalPayer, - expenseDate: new Date('2024-01-01'), - }; - - // Apply split calculation with external payer - const splitResult = calculateParticipantSplit(state as AddExpenseState); - - // Reverse the calculation - const reversedShares: Record> = {}; - participants.forEach((p) => { - reversedShares[p.id] = initSplitShares(); - }); - - calculateSplitShareBasedOnAmount( - 15000n, - splitResult.participants, - SplitType.EQUAL, - reversedShares, - externalPayer, - ); - - // With external payer, all participants have 0 amounts due to balance adjustment - // So reversed shares should all be 0 - expect(reversedShares[user1.id]![SplitType.EQUAL]).toBe(0n); - expect(reversedShares[user2.id]![SplitType.EQUAL]).toBe(0n); - expect(reversedShares[user3.id]![SplitType.EQUAL]).toBe(0n); - }); - }); -}); - -// Regression test for #658: editing a negative expense must preserve the sign -describe('useAddExpenseStore sign preservation on edit (#658)', () => { - const { toUIString } = getCurrencyHelpers({ locale: 'en-US', currency: 'USD' }); - const { actions } = useAddExpenseStore.getState(); - - beforeEach(() => { - actions.resetState(); - }); - - it('loads a negative expense with the minus sign visible in amountStr', () => { - actions.setAmount(-20000n); - actions.setAmountStr(toUIString(-20000n, true, true)); - - const state = useAddExpenseStore.getState(); - expect(state.amountStr).toBe('-200'); - expect(state.isNegative).toBe(true); - expect(state.amount).toBe(20000n); - }); - - it('preserves the sign when the user edits digits without removing the minus', () => { - actions.setAmount(-20000n); - actions.setAmountStr(toUIString(-20000n, true, true)); - - // Simulate the user changing -200 to -200.01 via the input - actions.setAmountStr('-200.01'); - actions.setAmount(-20001n); - - const state = useAddExpenseStore.getState(); - expect(state.amountStr).toBe('-200.01'); - expect(state.isNegative).toBe(true); - expect(state.amount).toBe(20001n); - }); - - it('flips the sign when the user deletes the minus and edits the value', () => { - actions.setAmount(-20000n); - actions.setAmountStr(toUIString(-20000n, true, true)); - - // Simulate the user deleting the minus and changing the value to 200.01 - actions.setAmountStr('200.01'); - actions.setAmount(20001n); - - const state = useAddExpenseStore.getState(); - expect(state.amountStr).toBe('200.01'); - expect(state.isNegative).toBe(false); - expect(state.amount).toBe(20001n); - }); -}); diff --git a/src/tests/currencyPreferenceStore.test.ts b/src/tests/currencyPreferenceStore.test.ts deleted file mode 100644 index 7213a13ae..000000000 --- a/src/tests/currencyPreferenceStore.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { SHOW_ALL_VALUE, useCurrencyPreferenceStore } from '~/store/currencyPreferenceStore'; - -const resetStore = () => - useCurrencyPreferenceStore.setState({ - recentCurrencies: [], - userDefaultCurrency: null, - groupDefaultCurrencies: {}, - preferences: {}, - }); - -beforeEach(() => { - sessionStorage.clear(); - resetStore(); -}); - -describe('getPreference – entity type isolation', () => { - it('returns userDefaultCurrency for a friend entity even when a group with the same id has a different default currency', () => { - const collidingId = 5; - - // Simulate visiting the Groups page: group id=5 has USD as its default - useCurrencyPreferenceStore.getState().setGroupDefaultCurrency(collidingId, 'USD'); - // User's own currency is EUR - useCurrencyPreferenceStore.getState().setUserDefaultCurrency('EUR'); - - // When rendering a friend balance entry (no entityType), USD must not leak in - const preference = useCurrencyPreferenceStore.getState().getPreference(collidingId); - - expect(preference).toBe('EUR'); - expect(preference).not.toBe('USD'); - }); - - it('returns the group default currency when entityType is "group"', () => { - const groupId = 5; - useCurrencyPreferenceStore.getState().setGroupDefaultCurrency(groupId, 'USD'); - useCurrencyPreferenceStore.getState().setUserDefaultCurrency('EUR'); - - const preference = useCurrencyPreferenceStore.getState().getPreference(groupId, 'group'); - - expect(preference).toBe('USD'); - }); - - it('respects an explicit preference over all defaults for both entity types', () => { - const id = 7; - useCurrencyPreferenceStore.getState().setGroupDefaultCurrency(id, 'USD'); - useCurrencyPreferenceStore.getState().setUserDefaultCurrency('EUR'); - useCurrencyPreferenceStore.getState().setPreference(id, 'GBP'); - - expect(useCurrencyPreferenceStore.getState().getPreference(id)).toBe('GBP'); - expect(useCurrencyPreferenceStore.getState().getPreference(id, 'group')).toBe('GBP'); - }); - - it('falls back to SHOW_ALL when no preference, no group default (for group entity), and no user default', () => { - const preference = useCurrencyPreferenceStore.getState().getPreference(99, 'group'); - expect(preference).toBe(SHOW_ALL_VALUE); - }); - - it('falls back to SHOW_ALL for a friend entity with no preference and no user default', () => { - useCurrencyPreferenceStore.getState().setGroupDefaultCurrency(3, 'USD'); - const preference = useCurrencyPreferenceStore.getState().getPreference(3); - expect(preference).toBe(SHOW_ALL_VALUE); - }); - - it('uses the global (string) key when no entityId is provided', () => { - useCurrencyPreferenceStore.getState().setUserDefaultCurrency('JPY'); - expect(useCurrencyPreferenceStore.getState().getPreference()).toBe('JPY'); - }); -}); diff --git a/src/tests/env.test.ts b/src/tests/env.test.ts deleted file mode 100644 index cc3a6c915..000000000 --- a/src/tests/env.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { parseEnvBoolean } from '../utils/env'; - -describe('parseEnvBoolean', () => { - it.each([ - ['1', true], - ['true', true], - ['TRUE', true], - [' TrUe ', true], - ['0', false], - ['false', false], - ['FALSE', false], - [' FaLsE ', false], - ])('parses %p as %p', (value, expected) => { - expect(parseEnvBoolean(value)).toBe(expected); - }); - - it.each([undefined, '', 'on', 'off', 'yes', '2'])('uses false for invalid value %p', (value) => { - expect(parseEnvBoolean(value)).toBe(false); - }); - - it.each([undefined, '', 'on', 'off', 'yes', '2'])( - 'uses the supplied fallback for invalid value %p', - (value) => { - expect(parseEnvBoolean(value, true)).toBe(true); - }, - ); -}); diff --git a/src/tests/healthz.test.ts b/src/tests/healthz.test.ts deleted file mode 100644 index 3812bed72..000000000 --- a/src/tests/healthz.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { NextApiRequest, NextApiResponse } from 'next'; - -import handler from '~/pages/api/healthz'; - -const createMockRes = () => { - const res = {} as NextApiResponse; - res.status = jest.fn().mockReturnValue(res); - res.json = jest.fn().mockReturnValue(res); - res.setHeader = jest.fn().mockReturnValue(res); - return res; -}; - -describe('/api/healthz', () => { - describe('GET requests', () => { - it('responds with 200 ok for GET requests', () => { - const req = { method: 'GET' } as NextApiRequest; - const res = createMockRes(); - - handler(req, res); - - expect(res.status).toHaveBeenCalledWith(200); - expect(res.json).toHaveBeenCalledWith({ status: 'ok' }); - }); - }); - - describe('method validation', () => { - it('rejects non-GET methods', () => { - const req = { method: 'POST' } as NextApiRequest; - const res = createMockRes(); - - handler(req, res); - - expect(res.status).toHaveBeenCalledWith(405); - expect((res.setHeader as jest.Mock).mock.calls).toContainEqual(['Allow', 'GET']); - }); - }); -}); diff --git a/src/tests/integration/authorization.integration.test.ts b/src/tests/integration/authorization.integration.test.ts new file mode 100644 index 000000000..013f0bc3f --- /dev/null +++ b/src/tests/integration/authorization.integration.test.ts @@ -0,0 +1,251 @@ +import { TRPCError } from '@trpc/server'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { db } from '~/server/db'; +import { expenseParticipants, expenses, groupUsers } from '~/server/db/schema'; +import { SplitType } from '~/server/db/types'; +import { eq } from 'drizzle-orm'; + +import { + addExpense, + balanceBetween, + callerFor, + expenseRow, + makeGroup, + makeUser, + participantsOf, + resetDb, +} from './helpers'; + +const rejectsWith = async (promise: Promise, code?: TRPCError['code']) => { + const error = await promise.then( + () => null, + (e: unknown) => e, + ); + expect(error, 'expected the call to be rejected').toBeInstanceOf(TRPCError); + if (code) { + expect((error as TRPCError).code).toBe(code); + } +}; + +describe('authorization', () => { + beforeEach(resetDb); + + it('rejects unauthenticated queries and mutations', async () => { + const anon = callerFor(null); + await rejectsWith(anon.expense.getBalances(), 'UNAUTHORIZED'); + await rejectsWith(anon.group.create({ name: 'x' }), 'UNAUTHORIZED'); + await rejectsWith(anon.user.getFriends(), 'UNAUTHORIZED'); + await rejectsWith( + anon.expense.addOrEditExpense({ + paidBy: 1, + name: 'x', + category: 'general', + amount: 100n, + groupId: null, + splitType: SplitType.EQUAL, + currency: 'EUR', + participants: [], + }), + 'UNAUTHORIZED', + ); + }); + + it('rejects outsider group reads and membership mutations', async () => { + const owner = await makeUser(); + const member = await makeUser(); + const outsider = await makeUser(); + const group = await makeGroup(owner, [member]); + const out = callerFor(outsider); + + await rejectsWith(out.group.getGroupDetails({ groupId: group.id }), 'FORBIDDEN'); + await rejectsWith(out.group.getGroupTotals({ groupId: group.id }), 'FORBIDDEN'); + await rejectsWith( + out.group.addMembers({ groupId: group.id, userIds: [outsider.id] }), + 'FORBIDDEN', + ); + await rejectsWith(out.group.leaveGroup({ groupId: group.id, userId: member.id }), 'FORBIDDEN'); + await rejectsWith(out.group.toggleArchive({ groupId: group.id }), 'FORBIDDEN'); + await rejectsWith(out.group.delete({ groupId: group.id }), 'FORBIDDEN'); + + const members = await db.select().from(groupUsers).where(eq(groupUsers.groupId, group.id)); + expect(members.map((m) => m.userId).sort((x, y) => x - y)).toEqual( + [owner.id, member.id].sort((x, y) => x - y), + ); + }); + + it('only the creator can remove another member, and only the creator can delete a group', async () => { + const owner = await makeUser(); + const member = await makeUser(); + const other = await makeUser(); + const group = await makeGroup(owner, [member, other]); + + await rejectsWith( + callerFor(member).group.leaveGroup({ groupId: group.id, userId: other.id }), + 'UNAUTHORIZED', + ); + await rejectsWith(callerFor(member).group.delete({ groupId: group.id }), 'UNAUTHORIZED'); + }); + + it('rejects outsider expense reads, edits and deletes without changing accounting', async () => { + const alice = await makeUser(); + const bob = await makeUser(); + const mallory = await makeUser(); + const group = await makeGroup(alice, [bob]); + + const created = await addExpense(callerFor(alice), { + paidBy: alice, + amount: 1000n, + groupId: group.id, + shares: { [alice.id]: 500n, [bob.id]: -500n }, + name: 'Dinner', + }); + const mal = callerFor(mallory); + + await rejectsWith(mal.expense.getExpenseDetails({ expenseId: created.id })); + await rejectsWith( + addExpense(mal, { + paidBy: mallory, + amount: 1n, + groupId: group.id, + shares: { [mallory.id]: 1n, [bob.id]: -1n }, + }), + ); + await rejectsWith( + mal.expense.addOrEditExpense({ + expenseId: created.id, + paidBy: alice.id, + name: 'Hijacked', + category: 'general', + amount: 999_999n, + groupId: group.id, + splitType: SplitType.EQUAL, + currency: 'EUR', + participants: [ + { userId: alice.id, amount: 999_999n }, + { userId: bob.id, amount: -999_999n }, + ], + }), + ); + await rejectsWith(mal.expense.deleteExpense({ expenseId: created.id })); + + const row = await expenseRow(created.id); + expect(row?.name).toBe('Dinner'); + expect(row?.deletedAt).toBeNull(); + expect(await participantsOf(created.id)).toEqual( + [ + { userId: alice.id, amount: 500n }, + { userId: bob.id, amount: -500n }, + ].sort((a, b) => a.userId - b.userId), + ); + expect(await balanceBetween(alice.id, bob.id)).toBe(500n); + const stray = await db + .select() + .from(expenseParticipants) + .where(eq(expenseParticipants.userId, mallory.id)); + expect(stray).toHaveLength(0); + }); + + it('lets a group member who is not a participant read and delete a group expense', async () => { + const alice = await makeUser(); + const bob = await makeUser(); + const carol = await makeUser(); + const group = await makeGroup(alice, [bob, carol]); + const created = await addExpense(callerFor(alice), { + paidBy: alice, + amount: 1000n, + groupId: group.id, + shares: { [alice.id]: 500n, [bob.id]: -500n }, + }); + + const details = await callerFor(carol).expense.getExpenseDetails({ expenseId: created.id }); + expect(details?.id).toBe(created.id); + }); + + it('rejects writes to archived groups', async () => { + const alice = await makeUser(); + const bob = await makeUser(); + const group = await makeGroup(alice, [bob]); + const a = callerFor(alice); + + const created = await addExpense(a, { + paidBy: alice, + amount: 1000n, + groupId: group.id, + shares: { [alice.id]: 500n, [bob.id]: -500n }, + }); + // Settle fully so the group may be archived. + await addExpense(callerFor(bob), { + paidBy: bob, + amount: 500n, + groupId: group.id, + splitType: SplitType.SETTLEMENT, + shares: { [bob.id]: 500n, [alice.id]: -500n }, + }); + await a.group.toggleArchive({ groupId: group.id }); + + await rejectsWith( + addExpense(a, { + paidBy: alice, + amount: 100n, + groupId: group.id, + shares: { [alice.id]: 50n, [bob.id]: -50n }, + }), + 'BAD_REQUEST', + ); + await rejectsWith(a.expense.deleteExpense({ expenseId: created.id }), 'BAD_REQUEST'); + await rejectsWith( + a.expense.addOrEditExpense({ + expenseId: created.id, + paidBy: alice.id, + name: 'Edited', + category: 'general', + amount: 1000n, + groupId: group.id, + splitType: SplitType.EQUAL, + currency: 'EUR', + participants: [ + { userId: alice.id, amount: 500n }, + { userId: bob.id, amount: -500n }, + ], + }), + 'BAD_REQUEST', + ); + expect((await expenseRow(created.id))?.name).not.toBe('Edited'); + }); + + it('applies the same membership, edit and archive guards to linked currency conversions', async () => { + const alice = await makeUser(); + const bob = await makeUser(); + const mallory = await makeUser(); + const group = await makeGroup(alice, [bob]); + const conversion = { + amount: 1000n, + from: 'EUR', + to: 'USD', + rate: 1.1, + senderId: alice.id, + receiverId: bob.id, + groupId: group.id, + }; + + // An outsider cannot create a conversion in someone else's group. + await rejectsWith(callerFor(mallory).expense.addOrEditCurrencyConversion(conversion)); + expect(await db.select().from(expenses)).toHaveLength(0); + + // A member can. + await callerFor(alice).expense.addOrEditCurrencyConversion(conversion); + const [from] = await db.select().from(expenses).where(eq(expenses.currency, 'EUR')); + expect(from).toBeDefined(); + + // An outsider cannot edit it. + await rejectsWith( + callerFor(mallory).expense.addOrEditCurrencyConversion({ + ...conversion, + expenseId: from!.id, + amount: 5n, + }), + ); + expect((await expenseRow(from!.id))?.amount).toBe(1000n); + }); +}); diff --git a/src/tests/integration/balances.integration.test.ts b/src/tests/integration/balances.integration.test.ts new file mode 100644 index 000000000..e502dddb3 --- /dev/null +++ b/src/tests/integration/balances.integration.test.ts @@ -0,0 +1,102 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { SplitType } from '~/server/db/types'; + +import { addExpense, balanceBetween, callerFor, makeGroup, makeUser, resetDb } from './helpers'; + +describe('balance isolation and simplification', () => { + beforeEach(resetDb); + + it('isolates groups and currencies and hides deleted expenses', async () => { + const alice = await makeUser(); + const bob = await makeUser(); + const g1 = await makeGroup(alice, [bob]); + const g2 = await makeGroup(alice, [bob]); + const a = callerFor(alice); + const split = (n: bigint) => ({ [alice.id]: n, [bob.id]: -n }); + + await addExpense(a, { paidBy: alice, amount: 2000n, groupId: g1.id, shares: split(1000n) }); + await addExpense(a, { paidBy: alice, amount: 600n, groupId: g2.id, shares: split(300n) }); + await addExpense(a, { paidBy: alice, amount: 400n, currency: 'USD', shares: split(200n) }); + const doomed = await addExpense(a, { + paidBy: alice, + amount: 10_000n, + groupId: g1.id, + shares: split(5000n), + }); + + expect(await balanceBetween(alice.id, bob.id, { groupId: g1.id, currency: 'EUR' })).toBe(6000n); + + await a.expense.deleteExpense({ expenseId: doomed.id }); + + expect(await balanceBetween(alice.id, bob.id, { groupId: g1.id, currency: 'EUR' })).toBe(1000n); + expect(await balanceBetween(alice.id, bob.id, { groupId: g2.id, currency: 'EUR' })).toBe(300n); + expect(await balanceBetween(alice.id, bob.id, { groupId: null, currency: 'USD' })).toBe(200n); + expect(await balanceBetween(bob.id, alice.id, { groupId: g1.id, currency: 'EUR' })).toBe( + -1000n, + ); + + // GetBalances aggregates per friend and currency across groups. + const { balances } = await a.expense.getBalances(); + const withBob = balances.find((b) => b.friendId === bob.id)!; + const amountOf = (currency: string) => + withBob.currencies.find((c) => c.currency === currency)?.amount; + expect(amountOf('EUR')).toBe(1300n); + expect(amountOf('USD')).toBe(200n); + }); + + it('simplifies a three-member chain while preserving every member net balance', async () => { + const alice = await makeUser(); + const bob = await makeUser(); + const carol = await makeUser(); + const group = await makeGroup(alice, [bob, carol]); + + // Alice paid for Bob (100); Bob paid for Carol (100). + await addExpense(callerFor(alice), { + paidBy: alice, + amount: 100n, + groupId: group.id, + shares: { [alice.id]: 100n, [bob.id]: -100n }, + }); + await addExpense(callerFor(bob), { + paidBy: bob, + amount: 100n, + groupId: group.id, + shares: { [bob.id]: 100n, [carol.id]: -100n }, + }); + await callerFor(alice).group.toggleSimplifyDebts({ groupId: group.id }); + + const details = await callerFor(alice).group.getGroupDetails({ groupId: group.id }); + const balances = details?.groupBalances ?? []; + + // The chain collapses to one edge: Carol owes Alice 100, Bob is out of it. + expect(balances.filter((b) => 0n !== b.amount)).toHaveLength(2); + expect(balances).toEqual( + expect.arrayContaining([ + expect.objectContaining({ userId: alice.id, friendId: carol.id, amount: 100n }), + expect.objectContaining({ userId: carol.id, friendId: alice.id, amount: -100n }), + ]), + ); + + // Every member's net position is preserved (and the whole group nets to zero). + const netOf = (userId: number) => + balances.filter((b) => b.userId === userId).reduce((sum, b) => sum + b.amount, 0n); + expect(netOf(alice.id)).toBe(100n); + expect(netOf(bob.id)).toBe(0n); + expect(netOf(carol.id)).toBe(-100n); + expect(balances.reduce((sum, b) => sum + b.amount, 0n)).toBe(0n); + }); + + it('keeps a plain two-person balance symmetric', async () => { + const alice = await makeUser(); + const bob = await makeUser(); + await addExpense(callerFor(alice), { + paidBy: alice, + amount: 1234n, + splitType: SplitType.EXACT, + shares: { [alice.id]: 700n, [bob.id]: -700n }, + }); + expect(await balanceBetween(alice.id, bob.id)).toBe(700n); + expect(await balanceBetween(bob.id, alice.id)).toBe(-700n); + }); +}); diff --git a/src/tests/integration/expense.integration.test.ts b/src/tests/integration/expense.integration.test.ts new file mode 100644 index 000000000..b0e504504 --- /dev/null +++ b/src/tests/integration/expense.integration.test.ts @@ -0,0 +1,174 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { SplitType } from '~/server/db/types'; + +import { + addExpense, + balanceBetween, + callerFor, + expenseRow, + makeGroup, + makeUser, + participantsOf, + resetDb, +} from './helpers'; + +describe('expense lifecycle', () => { + beforeEach(resetDb); + + it.each([false, true])( + 'creates, edits and soft-deletes an expense with exact bidirectional balances (group: %s)', + async (inGroup) => { + const alice = await makeUser(); + const bob = await makeUser(); + const groupId = inGroup ? (await makeGroup(alice, [bob])).id : null; + const a = callerFor(alice); + + const created = await addExpense(a, { + paidBy: alice, + amount: 1000n, + groupId, + name: 'Dinner', + shares: { [alice.id]: 500n, [bob.id]: -500n }, + }); + expect(await balanceBetween(alice.id, bob.id)).toBe(500n); + expect(await balanceBetween(bob.id, alice.id)).toBe(-500n); + + await a.expense.addOrEditExpense({ + expenseId: created.id, + paidBy: alice.id, + name: 'Dinner + drinks', + category: 'food', + amount: 1200n, + groupId, + splitType: SplitType.EQUAL, + currency: 'EUR', + participants: [ + { userId: alice.id, amount: 600n }, + { userId: bob.id, amount: -600n }, + ], + }); + expect((await expenseRow(created.id))?.name).toBe('Dinner + drinks'); + expect((await expenseRow(created.id))?.updatedBy).toBe(alice.id); + expect(await balanceBetween(alice.id, bob.id)).toBe(600n); + expect(await balanceBetween(bob.id, alice.id)).toBe(-600n); + + await callerFor(bob).expense.deleteExpense({ expenseId: created.id }); + const deleted = await expenseRow(created.id); + expect(deleted?.deletedAt).toBeInstanceOf(Date); + expect(deleted?.deletedBy).toBe(bob.id); + expect(await balanceBetween(alice.id, bob.id)).toBe(0n); + expect(await balanceBetween(bob.id, alice.id)).toBe(0n); + }, + ); + + it('preserves the original expense if an edit transaction fails', async () => { + const alice = await makeUser(); + const bob = await makeUser(); + const a = callerFor(alice); + const created = await addExpense(a, { + paidBy: alice, + amount: 1000n, + name: 'Original', + shares: { [alice.id]: 500n, [bob.id]: -500n }, + }); + + // The last statement of the edit batch references a user that does not exist, so the + // Foreign key fails. Atomicity means none of the earlier statements may stay applied. + const missingUserId = 999_999; + await expect( + a.expense.addOrEditExpense({ + expenseId: created.id, + paidBy: alice.id, + name: 'Should not persist', + category: 'food', + amount: 5000n, + groupId: null, + splitType: SplitType.EQUAL, + currency: 'EUR', + participants: [ + { userId: alice.id, amount: 2500n }, + { userId: missingUserId, amount: -2500n }, + ], + }), + ).rejects.toThrow(); + + const row = await expenseRow(created.id); + expect(row?.name).toBe('Original'); + expect(row?.amount).toBe(1000n); + expect(await participantsOf(created.id)).toEqual( + [ + { userId: alice.id, amount: 500n }, + { userId: bob.id, amount: -500n }, + ].sort((x, y) => x.userId - y.userId), + ); + expect(await balanceBetween(alice.id, bob.id)).toBe(500n); + }); + + it('does not create a half-written expense when creation fails', async () => { + const alice = await makeUser(); + await expect( + callerFor(alice).expense.addOrEditExpense({ + paidBy: alice.id, + name: 'Broken', + category: 'food', + amount: 100n, + groupId: null, + splitType: SplitType.EQUAL, + currency: 'EUR', + participants: [ + { userId: alice.id, amount: 50n }, + { userId: 999_999, amount: -50n }, + ], + }), + ).rejects.toThrow(); + + const { expenses } = await import('~/server/db/schema'); + const { db } = await import('~/server/db'); + expect(await db.select().from(expenses)).toHaveLength(0); + }); +}); + +describe('settlement accounting', () => { + beforeEach(resetDb); + + it.each([false, true])( + 'partially then fully settles a debt, editing and deleting the settlement (group: %s)', + async (inGroup) => { + const alice = await makeUser(); + const bob = await makeUser(); + const groupId = inGroup ? (await makeGroup(alice, [bob])).id : null; + + // Bob owes Alice 500. + await addExpense(callerFor(alice), { + paidBy: alice, + amount: 1000n, + groupId, + shares: { [alice.id]: 500n, [bob.id]: -500n }, + }); + expect(await balanceBetween(bob.id, alice.id)).toBe(-500n); + + const settle = (amount: bigint) => + addExpense(callerFor(bob), { + paidBy: bob, + amount, + groupId, + splitType: SplitType.SETTLEMENT, + name: 'Settle up', + shares: { [bob.id]: amount, [alice.id]: -amount }, + }); + + const partial = await settle(200n); + expect(await balanceBetween(bob.id, alice.id)).toBe(-300n); + expect(await balanceBetween(alice.id, bob.id)).toBe(300n); + + await settle(300n); + expect(await balanceBetween(bob.id, alice.id)).toBe(0n); + expect(await balanceBetween(alice.id, bob.id)).toBe(0n); + + // Undo the partial settlement: the debt comes back by exactly that amount. + await callerFor(alice).expense.deleteExpense({ expenseId: partial.id }); + expect(await balanceBetween(alice.id, bob.id)).toBe(200n); + }, + ); +}); diff --git a/src/tests/integration/helpers.ts b/src/tests/integration/helpers.ts new file mode 100644 index 000000000..e1a4513be --- /dev/null +++ b/src/tests/integration/helpers.ts @@ -0,0 +1,148 @@ +import { env } from 'cloudflare:workers'; +import { and, eq } from 'drizzle-orm'; + +import { appRouter } from '~/server/api/root'; +import type { Session } from '~/server/auth'; +import { db } from '~/server/db'; +import { + balanceView, + expenseParticipants, + expenses, + groupUsers, + groups, + users, +} from '~/server/db/schema'; +import { SplitType } from '~/server/db/types'; + +export type TestUser = typeof users.$inferSelect; + +/** Delete every row from every app table (not the migration bookkeeping tables). */ +export const resetDb = async () => { + const { results } = await (env as unknown as { DB: D1Database }).DB.prepare( + `SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '_cf_%' AND name != 'd1_migrations'`, + ).all<{ name: string }>(); + await (env as unknown as { DB: D1Database }).DB.batch([ + (env as unknown as { DB: D1Database }).DB.prepare('PRAGMA defer_foreign_keys = on'), + ...results.map(({ name }) => + (env as unknown as { DB: D1Database }).DB.prepare(`DELETE FROM "${name}"`), + ), + ]); +}; + +let counter = 0; + +export const makeUser = async (overrides: Partial = {}) => { + counter += 1; + const [user] = await db + .insert(users) + .values({ name: `User ${counter}`, email: `user${counter}@example.test`, ...overrides }) + .returning(); + return user!; +}; + +export const makeGroup = async (owner: TestUser, members: TestUser[] = [], name = 'Trip') => { + counter += 1; + const [group] = await db + .insert(groups) + .values({ + name: `${name} ${counter}`, + publicId: `pub-${counter}-${crypto.randomUUID()}`, + userId: owner.id, + }) + .returning(); + const everyone = [owner, ...members.filter((m) => m.id !== owner.id)]; + await db.insert(groupUsers).values(everyone.map((u) => ({ groupId: group!.id, userId: u.id }))); + return group!; +}; + +export const sessionFor = (user: TestUser): Session => ({ + user: { + id: user.id, + name: user.name, + email: user.email, + image: user.image, + currency: user.currency, + defaultCurrency: user.defaultCurrency, + obapiProviderId: user.obapiProviderId, + bankingId: user.bankingId, + preferredLanguage: user.preferredLanguage, + hiddenFriendIds: user.hiddenFriendIds, + }, + expires: new Date(Date.now() + 86_400_000).toISOString(), +}); + +/** TRPC caller bound to a user's session, or anonymous when `user` is null. */ +// oxlint-disable-next-line typescript/no-redundant-type-constituents +export const callerFor = (user: TestUser | null) => + appRouter.createCaller({ session: user ? sessionFor(user) : null, db }); + +// --------------------------------------------------------------------------------------------- +// Expense helpers +// --------------------------------------------------------------------------------------------- +type Caller = ReturnType; +type ExpenseInput = Parameters[0]; +type SingleExpenseInput = Extract; + +/** + * Create an expense through the real tRPC procedure. + * `shares` maps userId -> signed amount. The payer is positive, everyone else negative, and the + * amounts sum to zero (the convention the app and BalanceView use). + */ +export const addExpense = async ( + caller: Caller, + opts: { + paidBy: TestUser; + shares: Record; + amount: bigint; + groupId?: number | null; + currency?: string; + splitType?: SingleExpenseInput['splitType']; + name?: string; + cronExpression?: string; + }, +) => { + const [created] = await caller.expense.addOrEditExpense({ + paidBy: opts.paidBy.id, + name: opts.name ?? 'Expense', + category: 'general', + amount: opts.amount, + groupId: opts.groupId ?? null, + splitType: opts.splitType ?? SplitType.EQUAL, + currency: opts.currency ?? 'EUR', + participants: Object.entries(opts.shares).map(([userId, amount]) => ({ + userId: Number(userId), + amount, + })), + cronExpression: opts.cronExpression, + }); + return created!; +}; + +/** Net amount `userId` is owed by `friendId` (positive = friend owes user), summed over rows. */ +export const balanceBetween = async ( + userId: number, + friendId: number, + where: { groupId?: number | null; currency?: string } = {}, +) => { + const rows = await db + .select() + .from(balanceView) + .where( + and( + eq(balanceView.userId, userId), + eq(balanceView.friendId, friendId), + where.currency ? eq(balanceView.currency, where.currency) : undefined, + ), + ); + return rows + .filter((r) => (where.groupId === undefined ? true : r.groupId === where.groupId)) + .reduce((sum, r) => sum + r.amount, 0n); +}; + +export const participantsOf = async (expenseId: string) => + (await db.select().from(expenseParticipants).where(eq(expenseParticipants.expenseId, expenseId))) + .map((p) => ({ userId: p.userId, amount: p.amount })) + .sort((a, b) => a.userId - b.userId); + +export const expenseRow = async (expenseId: string) => + (await db.select().from(expenses).where(eq(expenses.id, expenseId)))[0]; diff --git a/src/tests/integration/queries.integration.test.ts b/src/tests/integration/queries.integration.test.ts new file mode 100644 index 000000000..179abf182 --- /dev/null +++ b/src/tests/integration/queries.integration.test.ts @@ -0,0 +1,138 @@ +import { eq } from 'drizzle-orm'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { getCompleteGroupDetails } from '~/server/api/services/splitService'; +import { db } from '~/server/db'; +import { users } from '~/server/db/schema'; +import { SplitType } from '~/server/db/types'; + +import { addExpense, callerFor, makeGroup, makeUser, resetDb } from './helpers'; + +/** + * Regression tests for the Drizzle/D1 query layer: every one of these ran real SQL against D1. + * `getFriend` and `getCompleteGroupDetails` once failed in production with + * "no such column: User.id" (correlated subquery against an aliased table). + */ +describe('query layer on D1', () => { + beforeEach(resetDb); + + it('getFriend returns a friend only when a balance relationship exists', async () => { + const alice = await makeUser(); + const bob = await makeUser(); + const stranger = await makeUser(); + await addExpense(callerFor(alice), { + paidBy: alice, + amount: 1000n, + shares: { [alice.id]: 500n, [bob.id]: -500n }, + }); + + const friend = await callerFor(alice).user.getFriend({ friendId: bob.id }); + expect(friend?.id).toBe(bob.id); + expect((await callerFor(bob).user.getFriend({ friendId: alice.id }))?.id).toBe(alice.id); + expect(await callerFor(alice).user.getFriend({ friendId: stranger.id })).toBeFalsy(); + }); + + it('getCompleteGroupDetails only returns the groups the user belongs to', async () => { + const alice = await makeUser(); + const bob = await makeUser(); + const carol = await makeUser(); + const shared = await makeGroup(alice, [bob], 'Shared'); + await makeGroup(carol, [], 'Private'); + await addExpense(callerFor(alice), { + paidBy: alice, + amount: 1000n, + groupId: shared.id, + shares: { [alice.id]: 500n, [bob.id]: -500n }, + }); + + const aliceGroups = await getCompleteGroupDetails(alice.id); + expect(aliceGroups.map((g) => g.id)).toEqual([shared.id]); + expect(aliceGroups[0]!.groupBalances.some((b) => 0n !== b.amount)).toBe(true); + expect((await getCompleteGroupDetails(carol.id)).map((g) => g.name)[0]).toContain('Private'); + expect(await getCompleteGroupDetails((await makeUser()).id)).toEqual([]); + }); + + it('deleteFriend refuses while a balance is outstanding, then hides the friend once', async () => { + const alice = await makeUser(); + const bob = await makeUser(); + await addExpense(callerFor(alice), { + paidBy: alice, + amount: 1000n, + shares: { [alice.id]: 500n, [bob.id]: -500n }, + }); + await expect(callerFor(alice).user.deleteFriend({ friendId: bob.id })).rejects.toThrow( + /outstanding balances/, + ); + + await addExpense(callerFor(bob), { + paidBy: bob, + amount: 500n, + splitType: SplitType.SETTLEMENT, + shares: { [bob.id]: 500n, [alice.id]: -500n }, + }); + await callerFor(alice).user.deleteFriend({ friendId: bob.id }); + await callerFor(alice).user.deleteFriend({ friendId: bob.id }); + + const [row] = await db.select().from(users).where(eq(users.id, alice.id)); + expect(row!.hiddenFriendIds).toEqual([bob.id]); + }); + + it('a new shared expense un-hides the friend on both sides (trigger)', async () => { + const alice = await makeUser(); + const bob = await makeUser(); + const carol = await makeUser(); + await db + .update(users) + .set({ hiddenFriendIds: [bob.id, carol.id] }) + .where(eq(users.id, alice.id)); + await db + .update(users) + .set({ hiddenFriendIds: [alice.id] }) + .where(eq(users.id, bob.id)); + + await addExpense(callerFor(bob), { + paidBy: bob, + amount: 1000n, + shares: { [bob.id]: 500n, [alice.id]: -500n }, + }); + + const [a] = await db.select().from(users).where(eq(users.id, alice.id)); + const [b] = await db.select().from(users).where(eq(users.id, bob.id)); + expect(a!.hiddenFriendIds).toEqual([carol.id]); + expect(b!.hiddenFriendIds).toEqual([]); + }); + + it('every read procedure runs valid SQL against a populated database', async () => { + const alice = await makeUser(); + const bob = await makeUser(); + const group = await makeGroup(alice, [bob]); + const a = callerFor(alice); + const expense = await addExpense(a, { + paidBy: alice, + amount: 1000n, + groupId: group.id, + shares: { [alice.id]: 500n, [bob.id]: -500n }, + cronExpression: '0 9 * * *', + }); + + await expect(a.user.me()).resolves.toBeDefined(); + await expect(a.user.getFriends()).resolves.toBeDefined(); + await expect(a.user.getOwnExpenses()).resolves.toHaveLength(1); + await expect(a.user.getBalancesWithFriend({ friendId: bob.id })).resolves.toBeDefined(); + await expect(a.user.getUserDetails({ userId: bob.id })).resolves.toBeDefined(); + await expect(a.user.downloadData()).resolves.toBeDefined(); + await expect(a.expense.getBalances()).resolves.toBeDefined(); + await expect(a.expense.getCumulatedBalances()).resolves.toBeDefined(); + await expect(a.expense.getExpensesWithFriend({ friendId: bob.id })).resolves.toBeDefined(); + await expect(a.expense.getGroupExpenses({ groupId: group.id })).resolves.toHaveLength(1); + await expect(a.expense.getExpenseDetails({ expenseId: expense.id })).resolves.toMatchObject({ + id: expense.id, + }); + await expect(a.expense.getAllExpenses()).resolves.toBeDefined(); + await expect(a.expense.getRecurringExpenses()).resolves.toHaveLength(1); + await expect(a.group.getAllGroups()).resolves.toHaveLength(1); + await expect(a.group.getAllGroupsWithBalances()).resolves.toBeDefined(); + await expect(a.group.getGroupDetails({ groupId: group.id })).resolves.toBeDefined(); + await expect(a.group.getGroupTotals({ groupId: group.id })).resolves.toBeDefined(); + }); +}); diff --git a/src/tests/integration/recurrence.integration.test.ts b/src/tests/integration/recurrence.integration.test.ts new file mode 100644 index 000000000..28777df15 --- /dev/null +++ b/src/tests/integration/recurrence.integration.test.ts @@ -0,0 +1,135 @@ +import { eq } from 'drizzle-orm'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { getDueJobs } from '~/server/api/services/scheduleService'; +import { runRecurringJob } from '~/server/api/services/splitService'; +import { db } from '~/server/db'; +import { expenseRecurrences, expenses, jobs } from '~/server/db/schema'; + +import { + addExpense, + balanceBetween, + callerFor, + expenseRow, + makeUser, + participantsOf, + resetDb, +} from './helpers'; + +const copiesOf = async (recurrenceId: number, templateId: string) => + (await db.select().from(expenses).where(eq(expenses.recurrenceId, recurrenceId))).filter( + (e) => e.id !== templateId, + ); + +describe('recurrence lifecycle', () => { + beforeEach(resetDb); + + const setup = async () => { + const alice = await makeUser(); + const bob = await makeUser(); + const a = callerFor(alice); + const template = await addExpense(a, { + paidBy: alice, + amount: 1000n, + name: 'Rent', + shares: { [alice.id]: 500n, [bob.id]: -500n }, + cronExpression: '0 9 * * *', + }); + const recurrenceId = (await expenseRow(template.id))!.recurrenceId!; + const [job] = await db.select().from(jobs); + return { alice, bob, a, template, recurrenceId, job: job! }; + }; + + it('creates a job linked to the template expense', async () => { + const { template, job, recurrenceId } = await setup(); + expect(recurrenceId).toBeGreaterThan(0); + expect(job.jobname).toBe(`expense_recurring_${template.id}`); + expect(job.command).toContain(template.id); + expect(job.active).toBe(true); + expect(job.nextRunAt!.getTime()).toBeGreaterThan(Date.now()); + const [recurrence] = await db.select().from(expenseRecurrences); + expect(recurrence?.jobId).toBe(job.jobid); + }); + + it('duplicates participants and balances once per slot, even if the step is retried', async () => { + const { alice, bob, template, recurrenceId, job } = await setup(); + const slot1 = job.nextRunAt!; + const now1 = new Date(slot1.getTime() + 60_000); + + expect((await getDueJobs(new Date(slot1.getTime() - 1))).map((j) => j.jobid)).not.toContain( + job.jobid, + ); + expect((await getDueJobs(now1)).map((j) => j.jobid)).toContain(job.jobid); + + const first = await runRecurringJob(job.jobid, now1); + expect(first.skipped).toBe(false); + let copies = await copiesOf(recurrenceId, template.id); + expect(copies).toHaveLength(1); + expect(await participantsOf(copies[0]!.id)).toEqual(await participantsOf(template.id)); + expect(await balanceBetween(alice.id, bob.id)).toBe(1000n); // Template + one copy + + const [afterFirst] = await db.select().from(jobs); + expect(afterFirst!.nextRunAt!.getTime()).toBeGreaterThan(slot1.getTime()); + expect(afterFirst!.lastRunAt).toBeInstanceOf(Date); + expect((await db.select().from(expenseRecurrences))[0]?.notified).toBe(false); + + // Same slot again: nothing is due, nothing is duplicated. + expect((await runRecurringJob(job.jobid, now1)).skipped).toBe(true); + + // A retry after the job row was rolled back to the old slot must not create a second copy. + await db.update(jobs).set({ nextRunAt: slot1 }).where(eq(jobs.jobid, job.jobid)); + await runRecurringJob(job.jobid, now1); + copies = await copiesOf(recurrenceId, template.id); + expect(copies).toHaveLength(1); + expect(await balanceBetween(alice.id, bob.id)).toBe(1000n); + + // The next slot produces a second, distinct copy. + const [afterRetry] = await db.select().from(jobs); + await runRecurringJob(job.jobid, new Date(afterRetry!.nextRunAt!.getTime() + 60_000)); + copies = await copiesOf(recurrenceId, template.id); + expect(copies).toHaveLength(2); + expect(new Set(copies.map((c) => c.id)).size).toBe(2); + expect(await balanceBetween(alice.id, bob.id)).toBe(1500n); + }); + + it('edits the schedule and stops only when the template is deleted', async () => { + const { a, alice, template, recurrenceId, job } = await setup(); + + await a.expense.updateRecurrence({ recurrenceId, cronExpression: '30 18 * * *' }); + const [edited] = await db.select().from(jobs); + expect(edited!.schedule).toBe('30 18 * * *'); + expect(edited!.nextRunAt!.getUTCHours()).toBe(18); + expect(edited!.nextRunAt!.getUTCMinutes()).toBe(30); + + // Deleting a generated copy keeps the recurrence running. + await runRecurringJob(job.jobid, new Date(edited!.nextRunAt!.getTime() + 60_000)); + const [copy] = await copiesOf(recurrenceId, template.id); + await a.expense.deleteExpense({ expenseId: copy!.id }); + expect(await db.select().from(jobs)).toHaveLength(1); + + // Deleting the template stops it. + await a.expense.deleteExpense({ expenseId: template.id }); + expect(await db.select().from(jobs)).toHaveLength(0); + expect(await db.select().from(expenseRecurrences)).toHaveLength(0); + expect((await expenseRow(template.id))?.recurrenceId).toBeNull(); + void alice; + }); + + it('deleteRecurrence removes the job but keeps the expenses', async () => { + const { a, template, recurrenceId } = await setup(); + await a.expense.deleteRecurrence({ recurrenceId }); + expect(await db.select().from(jobs)).toHaveLength(0); + expect((await expenseRow(template.id))?.deletedAt).toBeNull(); + expect((await expenseRow(template.id))?.recurrenceId).toBeNull(); + }); + + it("does not let a stranger change someone else's recurrence", async () => { + const { recurrenceId } = await setup(); + const stranger = await makeUser(); + await expect( + callerFor(stranger).expense.updateRecurrence({ recurrenceId, cronExpression: '0 0 * * *' }), + ).rejects.toThrow(); + await expect(callerFor(stranger).expense.deleteRecurrence({ recurrenceId })).rejects.toThrow(); + expect(await db.select().from(jobs)).toHaveLength(1); + }); +}); diff --git a/src/tests/mock/example_group_balances.json b/src/tests/mock/example_group_balances.json deleted file mode 100644 index f754551b9..000000000 --- a/src/tests/mock/example_group_balances.json +++ /dev/null @@ -1,1490 +0,0 @@ -[ - { - "groupId": 2, - "currency": "NZD", - "userId": 14, - "friendId": 3, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "NZD", - "userId": 3, - "friendId": 14, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "NZD", - "userId": 14, - "friendId": 1, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "NZD", - "userId": 1, - "friendId": 14, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "NZD", - "userId": 14, - "friendId": 4, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "NZD", - "userId": 4, - "friendId": 14, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "NZD", - "userId": 14, - "friendId": 7, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "NZD", - "userId": 7, - "friendId": 14, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "NZD", - "userId": 14, - "friendId": 9, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "NZD", - "userId": 9, - "friendId": 14, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "NZD", - "userId": 14, - "friendId": 12, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "NZD", - "userId": 12, - "friendId": 14, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "NZD", - "userId": 14, - "friendId": 11, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "NZD", - "userId": 11, - "friendId": 14, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "NZD", - "userId": 14, - "friendId": 13, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "NZD", - "userId": 13, - "friendId": 14, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "NZD", - "userId": 14, - "friendId": 10, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "NZD", - "userId": 10, - "friendId": 14, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "NZD", - "userId": 14, - "friendId": 8, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "NZD", - "userId": 8, - "friendId": 14, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "USD", - "userId": 14, - "friendId": 4, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "USD", - "userId": 4, - "friendId": 14, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "USD", - "userId": 6, - "friendId": 3, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "USD", - "userId": 3, - "friendId": 6, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "USD", - "userId": 6, - "friendId": 1, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "USD", - "userId": 14, - "friendId": 7, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "USD", - "userId": 7, - "friendId": 14, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "USD", - "userId": 14, - "friendId": 9, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "USD", - "userId": 9, - "friendId": 14, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "USD", - "userId": 14, - "friendId": 12, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "USD", - "userId": 12, - "friendId": 14, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "USD", - "userId": 14, - "friendId": 3, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "USD", - "userId": 3, - "friendId": 14, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "USD", - "userId": 14, - "friendId": 1, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "USD", - "userId": 1, - "friendId": 14, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "USD", - "userId": 14, - "friendId": 8, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "USD", - "userId": 8, - "friendId": 14, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "USD", - "userId": 14, - "friendId": 11, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "USD", - "userId": 11, - "friendId": 14, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "USD", - "userId": 14, - "friendId": 13, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "USD", - "userId": 13, - "friendId": 14, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "USD", - "userId": 14, - "friendId": 10, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "USD", - "userId": 10, - "friendId": 14, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "USD", - "userId": 1, - "friendId": 6, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "USD", - "userId": 6, - "friendId": 14, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "USD", - "userId": 14, - "friendId": 6, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "USD", - "userId": 6, - "friendId": 8, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "USD", - "userId": 8, - "friendId": 6, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 7, - "friendId": 9, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 9, - "friendId": 7, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 4, - "friendId": 6, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 6, - "friendId": 4, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 4, - "friendId": 7, - "amount": "2700", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 7, - "friendId": 4, - "amount": "-2700", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 4, - "friendId": 9, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 9, - "friendId": 4, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 3, - "friendId": 4, - "amount": "5495", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 4, - "friendId": 3, - "amount": "-5495", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 3, - "friendId": 6, - "amount": "81537", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 6, - "friendId": 3, - "amount": "-81537", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 3, - "friendId": 7, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 7, - "friendId": 3, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 3, - "friendId": 9, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 9, - "friendId": 3, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 10, - "friendId": 3, - "amount": "-12227", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 3, - "friendId": 10, - "amount": "12227", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 10, - "friendId": 6, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 6, - "friendId": 10, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 10, - "friendId": 7, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 7, - "friendId": 10, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 10, - "friendId": 9, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 9, - "friendId": 10, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 14, - "friendId": 3, - "amount": "-78708", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 3, - "friendId": 14, - "amount": "78708", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 14, - "friendId": 4, - "amount": "2600", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 4, - "friendId": 14, - "amount": "-2600", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 14, - "friendId": 6, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 6, - "friendId": 14, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 14, - "friendId": 7, - "amount": "427", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 7, - "friendId": 14, - "amount": "-427", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 14, - "friendId": 9, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 9, - "friendId": 14, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 14, - "friendId": 10, - "amount": "227", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 10, - "friendId": 14, - "amount": "-227", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 1, - "friendId": 4, - "amount": "-2700", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 4, - "friendId": 1, - "amount": "2700", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 1, - "friendId": 6, - "amount": "32517", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 6, - "friendId": 1, - "amount": "-32517", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 1, - "friendId": 7, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 7, - "friendId": 1, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 1, - "friendId": 9, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 9, - "friendId": 1, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 1, - "friendId": 14, - "amount": "30417", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 14, - "friendId": 1, - "amount": "-30417", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 13, - "friendId": 1, - "amount": "1000", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 1, - "friendId": 13, - "amount": "-1000", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 13, - "friendId": 3, - "amount": "20738", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 3, - "friendId": 13, - "amount": "-20738", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 13, - "friendId": 4, - "amount": "2400", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 4, - "friendId": 13, - "amount": "-2400", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 13, - "friendId": 6, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 6, - "friendId": 13, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 13, - "friendId": 7, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 7, - "friendId": 13, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 13, - "friendId": 9, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 9, - "friendId": 13, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 13, - "friendId": 14, - "amount": "-13999", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 14, - "friendId": 13, - "amount": "13999", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 10, - "friendId": 1, - "amount": "9400", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 1, - "friendId": 10, - "amount": "-9400", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 10, - "friendId": 4, - "amount": "400", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 4, - "friendId": 10, - "amount": "-400", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 13, - "friendId": 10, - "amount": "-6400", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 10, - "friendId": 13, - "amount": "6400", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 8, - "friendId": 1, - "amount": "-29917", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 1, - "friendId": 8, - "amount": "29917", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 8, - "friendId": 3, - "amount": "46133", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 3, - "friendId": 8, - "amount": "-46133", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 8, - "friendId": 4, - "amount": "-1800", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 4, - "friendId": 8, - "amount": "1800", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 8, - "friendId": 6, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 6, - "friendId": 8, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 8, - "friendId": 7, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 7, - "friendId": 8, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 8, - "friendId": 9, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 9, - "friendId": 8, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 8, - "friendId": 10, - "amount": "-8500", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 10, - "friendId": 8, - "amount": "8500", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 8, - "friendId": 13, - "amount": "15500", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 13, - "friendId": 8, - "amount": "-15500", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 8, - "friendId": 14, - "amount": "-9672", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 14, - "friendId": 8, - "amount": "9672", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 11, - "friendId": 1, - "amount": "-960", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 1, - "friendId": 11, - "amount": "960", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 11, - "friendId": 3, - "amount": "44672", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 3, - "friendId": 11, - "amount": "-44672", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 11, - "friendId": 4, - "amount": "24985", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 4, - "friendId": 11, - "amount": "-24985", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 11, - "friendId": 6, - "amount": "6300", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 6, - "friendId": 11, - "amount": "-6300", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 11, - "friendId": 7, - "amount": "1100", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 7, - "friendId": 11, - "amount": "-1100", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 11, - "friendId": 8, - "amount": "18124", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 8, - "friendId": 11, - "amount": "-18124", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 11, - "friendId": 9, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 9, - "friendId": 11, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 11, - "friendId": 10, - "amount": "1140", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 10, - "friendId": 11, - "amount": "-1140", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 11, - "friendId": 13, - "amount": "-2176", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 13, - "friendId": 11, - "amount": "2176", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 11, - "friendId": 14, - "amount": "24385", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 14, - "friendId": 11, - "amount": "-24385", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 11, - "friendId": 17, - "amount": "9043", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 17, - "friendId": 11, - "amount": "-9043", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "EUR", - "userId": 3, - "friendId": 12, - "amount": "-4637", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "EUR", - "userId": 12, - "friendId": 3, - "amount": "4637", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "EUR", - "userId": 1, - "friendId": 3, - "amount": "-9251", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "EUR", - "userId": 3, - "friendId": 1, - "amount": "9251", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "EUR", - "userId": 1, - "friendId": 12, - "amount": "-12760", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "EUR", - "userId": 12, - "friendId": 1, - "amount": "12760", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 1, - "friendId": 3, - "amount": "5808", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 3, - "friendId": 1, - "amount": "-5808", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 12, - "friendId": 1, - "amount": "-26585", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 1, - "friendId": 12, - "amount": "26585", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 12, - "friendId": 4, - "amount": "2000", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 4, - "friendId": 12, - "amount": "-2000", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 12, - "friendId": 6, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 6, - "friendId": 12, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 12, - "friendId": 7, - "amount": "2500", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 7, - "friendId": 12, - "amount": "-2500", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 12, - "friendId": 8, - "amount": "-7888", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 8, - "friendId": 12, - "amount": "7888", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 12, - "friendId": 9, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 9, - "friendId": 12, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 12, - "friendId": 10, - "amount": "-4067", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 10, - "friendId": 12, - "amount": "4067", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 12, - "friendId": 13, - "amount": "1500", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 13, - "friendId": 12, - "amount": "-1500", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 12, - "friendId": 14, - "amount": "4800", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 14, - "friendId": 12, - "amount": "-4800", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 12, - "friendId": 17, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 17, - "friendId": 12, - "amount": "0", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 12, - "friendId": 3, - "amount": "-9922", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 3, - "friendId": 12, - "amount": "9922", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 12, - "friendId": 11, - "amount": "20277", - "updatedAt": "2025-10-19T06:44:46.676Z" - }, - { - "groupId": 2, - "currency": "PLN", - "userId": 11, - "friendId": 12, - "amount": "-20277", - "updatedAt": "2025-10-19T06:44:46.676Z" - } -] diff --git a/src/tests/number.test.ts b/src/tests/number.test.ts deleted file mode 100644 index 15c14456a..000000000 --- a/src/tests/number.test.ts +++ /dev/null @@ -1,254 +0,0 @@ -// Filepath: /home/wiktor/kod/split-pro/src/utils/number.test.ts -import { currencyConversion, getCurrencyHelpers } from '../utils/numbers'; - -describe('getCurrencyHelpers', () => { - describe('toUIString', () => { - describe('en-US locale', () => { - const locale = 'en-US'; - - describe('USD', () => { - const currency = 'USD'; - const { toUIString } = getCurrencyHelpers({ locale, currency }); - - it.each([ - [12345n, '$123.45'], - [-12345n, '$123.45'], - [-50n, '$0.5'], - [-0n, '$0'], - [99999999999999999999999999999n, '$999,999,999,999,999,999,999,999,999.99'], - [-99999999999999999999999999999n, '$999,999,999,999,999,999,999,999,999.99'], - ])('should format %p as %p ', (value, expected) => { - expect(toUIString(value)).toBe(expected); - }); - - it('should format with thousands separators for large numbers', () => { - const value = 12345678900n; - expect(toUIString(value)).toBe('$123,456,789'); - }); - - it('should insert thousands separators for single ~1k values', () => { - const value = 123400n; - expect(toUIString(value)).toBe('$1,234'); - }); - - it('Should correctly format single digit cent amounts', () => { - const value = 7n; - expect(toUIString(value)).toBe('$0.07'); - }); - - it.each([ - [12345n, '$123.45'], - [-12345n, '-$123.45'], - [-50n, '-$0.5'], - [-0n, '$0'], - [99999999999999999999999999999n, '$999,999,999,999,999,999,999,999,999.99'], - [-99999999999999999999999999999n, '-$999,999,999,999,999,999,999,999,999.99'], - ])('should format %p as %p with signed flag', (value, expected) => { - expect(toUIString(value, true)).toBe(expected); - }); - - it.each([ - [12345n, '123.45'], - [-12345n, '-123.45'], - [-50n, '-0.5'], - [-0n, '0'], - ])('should format %p as %p with signed flag and hideSymbol', (value, expected) => { - expect(toUIString(value, true, true)).toBe(expected); - }); - }); - describe('JPY (no decimals)', () => { - const currency = 'JPY'; - - const { toUIString } = getCurrencyHelpers({ locale, currency }); - - it.each([ - [12345n, '¥12,345'], - [-12345n, '¥12,345'], - [-50n, '¥50'], - [-0n, '¥0'], - ])('should format %p as %p ', (value, expected) => { - expect(toUIString(value)).toBe(expected); - }); - }); - - describe('NZD', () => { - const { toSafeBigInt, toUIString } = getCurrencyHelpers({ - locale: 'en-NZ', - currency: 'NZD', - }); - - it('preserves one cent when parsing and displaying an amount', () => { - expect(toSafeBigInt('0.01')).toBe(1n); - expect(toUIString(1n, false, true)).toBe('0.01'); - }); - }); - }); - - describe('bg-BG locale', () => { - const locale = 'bg-BG'; - - describe('EUR', () => { - const currency = 'EUR'; - - const { toUIString } = getCurrencyHelpers({ locale, currency }); - - it.each([ - [12345n, '123,45 €'], - [-12345n, '123,45 €'], - [-50n, '0,5 €'], - [-0n, '0 €'], - ])('should format %p as %p ', (value, expected) => { - expect(toUIString(value)).toBe(expected); - }); - - it('should format with thousands separators for large numbers', () => { - expect(toUIString(12345678900n)).toBe('123 456 789 €'); - expect(toUIString(5678900n)).toBe('56 789 €'); - }); - - it('should not insert thousands separators for single ~1k values', () => { - const value = 123400n; - expect(toUIString(value)).toBe('1234 €'); - }); - }); - }); - }); - - describe('toSafeBigInt', () => { - const { toSafeBigInt } = getCurrencyHelpers({ - locale: 'en-US', - currency: 'USD', - }); - - it.each([ - ['123.45', 12345n], - ['0.99', 99n], - ['1000', 100000n], - ['1000.5', 100050n], - ['0', 0n], - ['', 0n], - ['invalid', 0n], - [123.45, 12345n], - [0.99, 99n], - [1000, 100000n], - [1000.5, 100050n], - [NaN, 0n], - [0, 0n], - [12345n, 12345n], - [0n, 0n], - ])('should parse %p to %p ', (input, expected) => { - expect(toSafeBigInt(input)).toBe(expected); - }); - - it.each([ - ['-123.45', -12345n], - ['-0.99', -99n], - ['-100.01', -10001n], - ['-1000', -100000n], - ['-1000.5', -100050n], - - [-123.45, -12345n], - [-0.99, -99n], - [-1000, -100000n], - [-1000.5, -100050n], - ])('should parse %p to %p with signed flag', (input, expected) => { - expect(toSafeBigInt(input, true)).toBe(expected); - }); - - it.each([ - ['123456789012345678901234567890.12', 12345678901234567890123456789012n], - ['0.000000000000000000000000000001', 0n], - ['-123456789012345678901234567890.12', -12345678901234567890123456789012n], - ])('should parse big numbers %p to %p ', (input, expected) => { - expect(toSafeBigInt(input, true)).toBe(expected); - }); - }); - - describe('sanitizeInput', () => { - const { sanitizeInput } = getCurrencyHelpers({ - locale: 'en-US', - currency: 'USD', - }); - - it.each([ - ['123.45', '123.45'], - ['1,234.56', '1234.56'], - ['1,234.56789', '1234.56'], - ['$1,234.56', '1234.56'], - ['$1,234.56789', '1234.56'], - ['-123.45', '123.45'], - ['abc123.45xyz', '123.45'], - ['..123..45..', '.12'], - ['', ''], - ])('should sanitize %p to %p', (input, expected) => { - expect(sanitizeInput(input, false)).toBe(expected); - }); - - it.each([ - ['-123.45', '-123.45'], - ['(-123.45)', '-123.45'], - ['-$1,234.56', '-1234.56'], - ['abc-123.45xyz', '-123.45'], - ['--123.45', '-123.45'], - ])('should sanitize %p to %p with signed flag', (input, expected) => { - expect(sanitizeInput(input, true)).toBe(expected); - }); - }); - - describe('parseToCleanString', () => { - const { parseToCleanString } = getCurrencyHelpers({ - locale: 'en-US', - currency: 'USD', - }); - - it.each([ - ['-200', '-200'], - ['-200.01', '-200.01'], - ['--200', '-200'], - ['-$200.00', '-200.00'], - ])('should keep the minus sign for %p with signed flag', (input, expected) => { - expect(parseToCleanString(input, true)).toBe(expected); - }); - - it.each([ - [-20000n, '-200.00'], - [-12345n, '-123.45'], - [-50n, '-0.50'], - [-0n, '0.00'], - ])('should keep the minus sign for bigint %p with signed flag', (value, expected) => { - expect(parseToCleanString(value, true)).toBe(expected); - }); - - it.each([ - ['-200', '200'], - ['-123.45', '123.45'], - ])('should drop the minus sign for %p without signed flag', (input, expected) => { - expect(parseToCleanString(input)).toBe(expected); - }); - }); -}); - -describe('currencyConversion', () => { - it('handles increasing decimal digit conversions', () => { - const from = 'JPY'; // 0 decimal digits - const to = 'USD'; - const amount = 12345n; // 12345 JPY - const rate = 0.0073; // 1 JPY = 0.0073 USD - - const res = currencyConversion({ from, to, amount, rate }); - - // 12345 JPY * 0.0073 = 90.1185 USD -> rounded to 90.12 USD -> 9012 in bigint - expect(res).toBe(9012n); - }); - - it('handles decreasing decimal digit conversions', () => { - const from = 'USD'; // 2 decimal digits - const to = 'JPY'; - const amount = 9012n; - const rate = 1 / 0.0073; - - const res = currencyConversion({ from, to, amount, rate }); - - expect(res).toBe(12345n); - }); -}); diff --git a/src/tests/readyz.test.ts b/src/tests/readyz.test.ts deleted file mode 100644 index e6ae32234..000000000 --- a/src/tests/readyz.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -import type { Prisma } from '@prisma/client'; -import type { NextApiRequest, NextApiResponse } from 'next'; - -jest.mock('~/server/db', () => ({ - db: { - $executeRaw: jest.fn(), - $queryRaw: jest.fn(), - $transaction: jest.fn(), - }, -})); - -import { db } from '~/server/db'; -import handler from '~/pages/api/readyz'; - -const createMockRes = () => { - const res = {} as NextApiResponse; - res.status = jest.fn().mockReturnValue(res); - res.json = jest.fn().mockReturnValue(res); - res.setHeader = jest.fn().mockReturnValue(res); - return res; -}; - -describe('/api/readyz', () => { - afterEach(() => { - jest.clearAllMocks(); - }); - - describe('successful checks', () => { - it('responds with 200 ok when the database is reachable', async () => { - (db.$transaction as jest.Mock).mockResolvedValue([undefined, [{ '?column?': 1 }]]); - const req = { method: 'GET' } as NextApiRequest; - const res = createMockRes(); - - await handler(req, res); - - expect(res.status).toHaveBeenCalledWith(200); - expect(res.json).toHaveBeenCalledWith({ status: 'ok' }); - }); - - it('scopes a 3s statement_timeout to the check via SET LOCAL', async () => { - (db.$executeRaw as jest.Mock).mockReturnValue('SET_STATEMENT_TIMEOUT'); - (db.$queryRaw as jest.Mock).mockReturnValue('SELECT_1'); - (db.$transaction as jest.Mock).mockResolvedValue([undefined, [{ '?column?': 1 }]]); - const req = { method: 'GET' } as NextApiRequest; - const res = createMockRes(); - - await handler(req, res); - - const executeRawCall = (db.$executeRaw as jest.Mock).mock.calls[0] as - | [TemplateStringsArray, Prisma.Sql] - | undefined; - if (!executeRawCall) { - throw new Error('Expected db.$executeRaw to have been called'); - } - const [setTimeoutStrings, rawTimeoutValue] = executeRawCall; - expect(setTimeoutStrings.join('')).toContain('SET LOCAL statement_timeout = '); - expect(rawTimeoutValue.strings.join('')).toBe('3000'); - - const queryRawCall = (db.$queryRaw as jest.Mock).mock.calls[0] as - | [TemplateStringsArray] - | undefined; - if (!queryRawCall) { - throw new Error('Expected db.$queryRaw to have been called'); - } - const [selectStrings] = queryRawCall; - expect(selectStrings.join('')).toBe('SELECT 1'); - - // Order matters: the timeout must be set before SELECT 1 runs in the same transaction. - expect((db.$transaction as jest.Mock).mock.calls[0]?.[0]).toEqual([ - 'SET_STATEMENT_TIMEOUT', - 'SELECT_1', - ]); - }); - }); - - describe('database failures', () => { - it('responds with 503 when the database is unreachable', async () => { - (db.$transaction as jest.Mock).mockRejectedValue(new Error('connection refused')); - const req = { method: 'GET' } as NextApiRequest; - const res = createMockRes(); - - await handler(req, res); - - expect(res.status).toHaveBeenCalledWith(503); - expect(res.json).toHaveBeenCalledWith({ - status: 'error', - message: 'Database not reachable', - }); - }); - - it('responds with 503 when the database check times out', async () => { - (db.$transaction as jest.Mock).mockRejectedValue( - new Error('canceling statement due to statement timeout'), - ); - const req = { method: 'GET' } as NextApiRequest; - const res = createMockRes(); - - await handler(req, res); - - expect(res.status).toHaveBeenCalledWith(503); - }); - }); - - describe('method validation', () => { - it('rejects non-GET methods', async () => { - const req = { method: 'POST' } as NextApiRequest; - const res = createMockRes(); - - await handler(req, res); - - expect(res.status).toHaveBeenCalledWith(405); - expect((res.setHeader as jest.Mock).mock.calls).toContainEqual(['Allow', 'GET']); - expect((db.$executeRaw as jest.Mock).mock.calls).toHaveLength(0); - expect((db.$queryRaw as jest.Mock).mock.calls).toHaveLength(0); - expect((db.$transaction as jest.Mock).mock.calls).toHaveLength(0); - }); - }); -}); diff --git a/src/tests/simplify.test.ts b/src/tests/simplify.test.ts deleted file mode 100644 index f3768a2bc..000000000 --- a/src/tests/simplify.test.ts +++ /dev/null @@ -1,210 +0,0 @@ -import { type BalanceView as GroupBalance } from '@prisma/client'; -import { addHours } from 'date-fns'; -import rawRealGraph from './mock/example_group_balances.json'; -import { simplifyDebts } from '~/lib/simplify'; - -const realGraph = rawRealGraph.map(({ amount, updatedAt, ...rest }) => ({ - ...rest, - amount: BigInt(amount), - updatedAt: new Date(updatedAt), - createdAt: new Date(updatedAt), -})); - -interface MinimalEdge { - userOne: number; - userTwo: number; - amount: bigint; -} - -const sortByIds = (a: GroupBalance, b: GroupBalance) => { - if (a.userId === b.userId) { - return a.friendId - b.friendId; - } - return a.userId - b.userId; -}; - -let dateCounter = 0; - -const edgeToGroupBalance = (edge: MinimalEdge): [GroupBalance, GroupBalance] => { - const createdAt = addHours(new Date(), dateCounter++); - const base = { - groupId: 0, - currency: 'USD', - updatedAt: createdAt, - createdAt, - }; - return [ - { - userId: edge.userOne, - friendId: edge.userTwo, - amount: edge.amount, - ...base, - }, - { - userId: edge.userTwo, - friendId: edge.userOne, - amount: 0n === edge.amount ? 0n : -edge.amount, - ...base, - }, - ]; -}; - -const padWithZeroBalances: (balances: GroupBalance[], userCount: number) => GroupBalance[] = ( - balances, - userCount, -) => { - const result = [...balances]; - for (let userId = 0; userId < userCount; userId++) { - for (let friendId = userId + 1; friendId < userCount; friendId++) { - const found = balances.find( - (balance) => balance.userId === userId && balance.friendId === friendId, - ); - - if (!found) { - result.push(...edgeToGroupBalance({ userOne: userId, userTwo: friendId, amount: 0n })); - } - } - } - return result; -}; - -const getFullBalanceGraph = (edges: MinimalEdge[], userCount: number): GroupBalance[] => { - const arr = padWithZeroBalances(edges.flatMap(edgeToGroupBalance), userCount); - arr.sort(sortByIds); - return arr; -}; - -// taken from https://www.geeksforgeeks.org/minimize-cash-flow-among-given-set-friends-borrowed-money/ -const smallGraph: GroupBalance[] = getFullBalanceGraph( - [ - { userOne: 0, userTwo: 1, amount: 1000n }, - { userOne: 1, userTwo: 2, amount: 5000n }, - { userOne: 2, userTwo: 0, amount: -2000n }, - ], - 3, -); - -const smallGraphResult: GroupBalance[] = getFullBalanceGraph( - [ - { userOne: 1, userTwo: 2, amount: 4000n }, - { userOne: 2, userTwo: 0, amount: -3000n }, - ], - 3, -).map((resultBalance, idx) => ({ - ...resultBalance, - updatedAt: smallGraph[idx]!.updatedAt, - createdAt: smallGraph[idx]!.createdAt, -})); - -// taken from https://medium.com/@mithunmk93/algorithm-behind-splitwises-debt-simplification-feature-8ac485e97688 -const largeGraph: GroupBalance[] = getFullBalanceGraph( - [ - { userOne: 1, userTwo: 2, amount: 4000n }, - { userOne: 1, userTwo: 5, amount: -1000n }, - { userOne: 1, userTwo: 6, amount: -3000n }, - - { userOne: 2, userTwo: 3, amount: 2000n }, - { userOne: 2, userTwo: 5, amount: -3000n }, - - { userOne: 3, userTwo: 4, amount: 5000n }, - { userOne: 3, userTwo: 5, amount: -1000n }, - { userOne: 3, userTwo: 6, amount: -1000n }, - - { userOne: 4, userTwo: 5, amount: -1000n }, - ], - 7, -); - -const denseGraph: GroupBalance[] = getFullBalanceGraph( - [ - { userOne: 0, userTwo: 1, amount: 895795n }, - { userOne: 0, userTwo: 2, amount: 328043n }, - { userOne: 0, userTwo: 3, amount: -174624n }, - { userOne: 0, userTwo: 4, amount: 5035n }, - { userOne: 0, userTwo: 5, amount: -81416n }, - - { userOne: 1, userTwo: 2, amount: -324560n }, - { userOne: 1, userTwo: 3, amount: -11502n }, - { userOne: 1, userTwo: 4, amount: 126115n }, - { userOne: 1, userTwo: 5, amount: 83333n }, - - { userOne: 2, userTwo: 3, amount: -519167n }, - { userOne: 2, userTwo: 4, amount: -400050n }, - { userOne: 2, userTwo: 5, amount: -23334n }, - - { userOne: 3, userTwo: 4, amount: 157284n }, - { userOne: 3, userTwo: 5, amount: 15833n }, - - { userOne: 4, userTwo: 5, amount: 12916n }, - ], - 6, -); - -describe('simplifyDebts', () => { - it('simplifies small graph', () => { - const result = simplifyDebts(smallGraph); - result.sort(sortByIds); - expect(result).toEqual(smallGraphResult); - }); - - it.each([ - { graph: smallGraph, expected: 2 }, - { graph: largeGraph, expected: 3 }, - { graph: denseGraph, expected: 5 }, - ])('gets the optimal operation count', ({ graph, expected }) => { - expect(simplifyDebts(graph).filter((balance) => 0 < balance.amount).length).toBe(expected); - }); - - it.each([ - ['small', smallGraph], - ['large', largeGraph], - ['dense', denseGraph], - ['real', realGraph], - ])('preserves metadata for %s graph', (name, graph) => { - const result = simplifyDebts(graph); - for (const balance of result) { - expect(balance.groupId).toBeDefined(); - expect(balance.currency).toBeDefined(); - expect(balance.userId).toBeDefined(); - expect(balance.friendId).toBeDefined(); - expect(balance.updatedAt).toBeDefined(); - } - }); - - it.each([ - ['small', smallGraph], - ['large', largeGraph], - ['dense', denseGraph], - ['real', realGraph], - ])('preserves the total balance per user for %s graph', (name, graph) => { - const startingBalances = graph.reduce( - (acc, balance) => { - acc[balance.userId] = (acc[balance.userId] ?? 0n) + balance.amount; - return acc; - }, - {} as Record, - ); - - const userBalances = simplifyDebts(graph).reduce( - (acc, balance) => { - acc[balance.userId] = (acc[balance.userId] ?? 0n) + balance.amount; - return acc; - }, - {} as Record, - ); - - expect(startingBalances).toEqual(userBalances); - }); - - it.each([ - ['small', smallGraph], - ['large', largeGraph], - ['dense', denseGraph], - ['real', realGraph], - ])('produces no self-loops for %s graph', (name, graph) => { - const result = simplifyDebts(graph); - for (const balance of result) { - expect(balance.userId).not.toBe(balance.friendId); - } - }); -}); diff --git a/src/types.ts b/src/types.ts index 88c2ac39c..7d28d5cbe 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,5 @@ import { type NextPage } from 'next'; -import { type User } from 'next-auth'; +import type { SessionUser as User } from '~/server/auth'; import { z } from 'zod'; import { type CurrencyCode } from './lib/currency'; diff --git a/src/types/expense.types.ts b/src/types/expense.types.ts index 8ead7d236..7ddbbc6b4 100644 --- a/src/types/expense.types.ts +++ b/src/types/expense.types.ts @@ -1,4 +1,4 @@ -import { type Expense, type ExpenseParticipant, SplitType } from '@prisma/client'; +import { type Expense, type ExpenseParticipant, SplitType } from '~/server/db/types'; import { type ZodTypeAny, z } from 'zod'; /** diff --git a/src/utils/api.ts b/src/utils/api.ts index af26dffe2..1ea1f90eb 100644 --- a/src/utils/api.ts +++ b/src/utils/api.ts @@ -14,11 +14,11 @@ import { type AppRouter } from '~/server/api/root'; export const getBaseUrl = () => { if ('undefined' !== typeof window) { return ''; - } // browser should use relative url + } // Browser should use relative url if (process.env.VERCEL_URL) { return `https://${process.env.VERCEL_URL}`; } // SSR should use vercel url - return `http://localhost:${process.env.PORT ?? 3000}`; // dev SSR should use localhost + return process.env.NEXTAUTH_URL ?? 'http://localhost:5173'; }; /** A set of type-safe react-query hooks for your tRPC API. */ diff --git a/src/utils/file.ts b/src/utils/file.ts deleted file mode 100644 index 829ac7e82..000000000 --- a/src/utils/file.ts +++ /dev/null @@ -1,8 +0,0 @@ -import fs from 'node:fs/promises'; - -export function fileExists(filePath: string): Promise { - return fs - .stat(filePath) - .then(() => true) - .catch(() => false); -} diff --git a/src/utils/i18n/server.ts b/src/utils/i18n/server.ts index d34b7b74c..bc8d3b654 100644 --- a/src/utils/i18n/server.ts +++ b/src/utils/i18n/server.ts @@ -1,13 +1,54 @@ import { type SSRConfig } from 'next-i18next'; -import { serverSideTranslations } from 'next-i18next/serverSideTranslations'; -import i18nConfig from '@/next-i18next.config.js'; + +/** + * Server-side translations without `fs`. next-i18next's `serverSideTranslations` reads + * `public/locales` from disk, which does not exist on Workers. The locale JSON files are + * bundled instead (one lazy chunk per file) and returned in the same `_nextI18Next` shape + * that `appWithTranslation` expects. + */ +const localeFiles = import.meta.glob<{ default: Record }>( + '../../../public/locales/*/*.json', +); + +const FALLBACK_LOCALE = 'en'; + +const loadNamespace = async (locale: string, ns: string) => { + const loader = localeFiles[`../../../public/locales/${locale}/${ns}.json`]; + if (!loader) { + return undefined; + } + return (await loader()).default; +}; export const customServerSideTranslations = async ( locale: string | undefined, namespaces: string[], ): Promise => { - locale ??= 'en'; - return await serverSideTranslations(locale, namespaces, i18nConfig); + const initialLocale = !locale || 'default' === locale ? FALLBACK_LOCALE : locale; + const locales = [...new Set([initialLocale, FALLBACK_LOCALE])]; + + const initialI18nStore: Record> = {}; + await Promise.all( + locales.flatMap((lng) => { + initialI18nStore[lng] = {}; + return namespaces.map(async (ns) => { + const data = await loadNamespace(lng, ns); + if (data) { + initialI18nStore[lng]![ns] = data; + } + }); + }), + ); + + return { + _nextI18Next: { + initialI18nStore, + initialLocale, + ns: namespaces, + // `appWithTranslation(MyApp, i18nConfig)` in _app.tsx supplies the config. + userConfig: null, + }, + }; }; export const withI18nStaticProps = diff --git a/src/utils/strings.ts b/src/utils/strings.ts index bd6adcc51..93978e888 100644 --- a/src/utils/strings.ts +++ b/src/utils/strings.ts @@ -1,4 +1,4 @@ -import { SplitType, type User } from '@prisma/client'; +import { SplitType, type User } from '~/server/db/types'; import { isToday } from 'date-fns'; import { type TFunction } from 'next-i18next'; import { type CurrencyCode } from '~/lib/currency'; diff --git a/test/setup.integration.ts b/test/setup.integration.ts new file mode 100644 index 000000000..4f24de72a --- /dev/null +++ b/test/setup.integration.ts @@ -0,0 +1,17 @@ +import { applyD1Migrations } from 'cloudflare:test'; +import { env } from 'cloudflare:workers'; +import { beforeAll } from 'vitest'; + +// BigInt values appear in tRPC results and balance rows. +// @ts-expect-error extending the BigInt prototype for tests only +BigInt.prototype.toJSON = function toJSON() { + return this.toString(); +}; + +beforeAll(async () => { + const { DB, TEST_MIGRATIONS } = env as unknown as { + DB: D1Database; + TEST_MIGRATIONS: Parameters[1]; + }; + await applyD1Migrations(DB, TEST_MIGRATIONS); +}); diff --git a/test/wrangler.test.jsonc b/test/wrangler.test.jsonc new file mode 100644 index 000000000..4cc58e1a3 --- /dev/null +++ b/test/wrangler.test.jsonc @@ -0,0 +1,20 @@ +{ + // Used only by the Vitest integration project (@cloudflare/vitest-pool-workers). + "name": "splitpro-test", + "compatibility_date": "2026-10-01", + "compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"], + "d1_databases": [ + { + "binding": "DB", + "database_name": "splitpro-test", + "database_id": "00000000-0000-0000-0000-000000000000", + "migrations_dir": "../migrations", + }, + ], + "r2_buckets": [{ "binding": "UPLOADS", "bucket_name": "splitpro-test-uploads" }], + "vars": { + "SKIP_ENV_VALIDATION": "1", + "NEXTAUTH_URL": "http://localhost:8787", + "NEXTAUTH_SECRET": "test-secret-test-secret-test-secret-123456", + }, +} diff --git a/tests/e2e/app.spec.ts b/tests/e2e/app.spec.ts new file mode 100644 index 000000000..acb73de7b --- /dev/null +++ b/tests/e2e/app.spec.ts @@ -0,0 +1,220 @@ +import { expect, login, test, trpc } from './fixtures'; +import { USERS } from './users.mjs'; + +/** Superjson input with BigInt fields: `bigints` lists the dotted paths that hold bigints. */ +const sj = (json: unknown, bigints: string[] = []) => ({ + json, + meta: bigints.length + ? { values: Object.fromEntries(bigints.map((p) => [p, ['bigint']])) } + : undefined, +}); + +const expenseInput = (opts: { + paidBy: number; + other: number; + amount: number; + groupId?: number | null; + name?: string; + splitType?: string; + cronExpression?: string; +}) => + sj( + { + paidBy: opts.paidBy, + name: opts.name ?? 'E2E dinner', + category: 'food', + amount: String(opts.amount), + groupId: opts.groupId ?? null, + splitType: opts.splitType ?? 'EQUAL', + currency: 'EUR', + participants: [ + { userId: opts.paidBy, amount: String(opts.amount / 2) }, + { userId: opts.other, amount: String(-opts.amount / 2) }, + ], + cronExpression: opts.cronExpression, + }, + ['amount', 'participants.0.amount', 'participants.1.amount'], + ); + +// oxlint-disable-next-line typescript/no-explicit-any +const data = (result: { body: unknown }): any => + // oxlint-disable-next-line typescript/no-explicit-any + (result.body as { result: { data: { json: any } } }[])[0].result.data.json; + +// Tests share one database but never share rows: each one works on its own group or expense, +// So they can run in parallel. + +test.describe('auth', () => { + test('redirects visitors to sign in, which offers Google only', async ({ page }) => { + await page.goto('/balances'); + await expect(page).toHaveURL(/\/auth\/signin/); + await expect(page.getByText('Continue with Google')).toBeVisible(); + }); + + test('a signed-in user sees the app instead of the sign-in page', async ({ + page, + context, + baseURL, + }) => { + await login(context, 'alice', baseURL!); + await page.goto('/balances'); + await expect(page).toHaveURL(/\/balances/); + await expect(page.getByText('Continue with Google')).toHaveCount(0); + }); + + test('tRPC rejects anonymous calls and serves signed-in ones', async ({ context, baseURL }) => { + expect((await trpc(context, baseURL!, 'query', 'user.me')).status).toBe(401); + await login(context, 'alice', baseURL!); + const me = await trpc(context, baseURL!, 'query', 'user.me'); + expect(me.status).toBe(200); + expect(data(me)).toMatchObject({ id: USERS.alice.id, email: USERS.alice.email }); + }); +}); + +test.describe('groups, expenses and balances', () => { + test('creates a group and an expense, shows both in the UI, and settles up', async ({ + page, + context, + baseURL, + }) => { + await login(context, 'alice', baseURL!); + const groupName = `Trip ${Date.now()}`; + + const created = await trpc( + context, + baseURL!, + 'mutation', + 'group.create', + sj({ name: groupName }), + ); + expect(created.status).toBe(200); + const group = data(created); + await trpc( + context, + baseURL!, + 'mutation', + 'group.addMembers', + sj({ groupId: group.id, userIds: [USERS.bob.id] }), + ); + + // BigInt + Date round trip through the real tRPC endpoint. + const add = await trpc( + context, + baseURL!, + 'mutation', + 'expense.addOrEditExpense', + expenseInput({ + paidBy: USERS.alice.id, + other: USERS.bob.id, + amount: 1000, + groupId: group.id, + }), + ); + expect(add.status).toBe(200); + + await page.goto(`/groups/${group.id}`); + await expect(page.getByText(groupName).first()).toBeVisible(); + await expect(page.getByText('E2E dinner').first()).toBeVisible(); + + const groupBalances = async (): Promise< + { userId: number; friendId: number; amount: string }[] + > => + data( + await trpc(context, baseURL!, 'query', 'group.getGroupDetails', sj({ groupId: group.id })), + ).groupBalances; + const before = (await groupBalances()).find( + (b) => b.userId === USERS.alice.id && b.friendId === USERS.bob.id, + ); + expect(before?.amount).toBe('500'); + + await page.goto('/balances'); + await expect(page.getByText(USERS.bob.name).first()).toBeVisible(); + + // Bob settles up; the group balance goes back to zero. + await context.clearCookies(); + await login(context, 'bob', baseURL!); + const settle = await trpc( + context, + baseURL!, + 'mutation', + 'expense.addOrEditExpense', + expenseInput({ + paidBy: USERS.bob.id, + other: USERS.alice.id, + amount: 1000, + groupId: group.id, + splitType: 'SETTLEMENT', + name: 'Settle up', + }), + ); + expect(settle.status).toBe(200); + expect((await groupBalances()).every((b) => '0' === b.amount)).toBe(true); + }); + + test('a stranger cannot read or delete someone else’s expense', async ({ context, baseURL }) => { + await login(context, 'alice', baseURL!); + const add = await trpc( + context, + baseURL!, + 'mutation', + 'expense.addOrEditExpense', + expenseInput({ paidBy: USERS.alice.id, other: USERS.bob.id, amount: 400, name: 'Private' }), + ); + const expenseId = data(add)[0].id as string; + + await context.clearCookies(); + await login(context, 'mallory', baseURL!); + const read = await trpc( + context, + baseURL!, + 'query', + 'expense.getExpenseDetails', + sj({ expenseId }), + ); + expect(read.status).toBe(404); + const del = await trpc( + context, + baseURL!, + 'mutation', + 'expense.deleteExpense', + sj({ expenseId }), + ); + expect(del.status).toBeGreaterThanOrEqual(400); + + await context.clearCookies(); + await login(context, 'alice', baseURL!); + const still = await trpc( + context, + baseURL!, + 'query', + 'expense.getExpenseDetails', + sj({ expenseId }), + ); + expect(still.status).toBe(200); + expect(data(still).deletedAt).toBeNull(); + }); + + test('creates a recurring expense and lists it (cron parsing works in the production bundle)', async ({ + context, + baseURL, + }) => { + await login(context, 'alice', baseURL!); + const name = `Monthly rent ${Date.now()}`; + const add = await trpc( + context, + baseURL!, + 'mutation', + 'expense.addOrEditExpense', + expenseInput({ + paidBy: USERS.alice.id, + other: USERS.bob.id, + amount: 800, + name, + cronExpression: '0 9 1 * *', + }), + ); + expect(add.status).toBe(200); + const recurring = data(await trpc(context, baseURL!, 'query', 'expense.getRecurringExpenses')); + expect(JSON.stringify(recurring)).toContain(name); + }); +}); diff --git a/tests/e2e/fixtures.ts b/tests/e2e/fixtures.ts new file mode 100644 index 000000000..202dd3c4f --- /dev/null +++ b/tests/e2e/fixtures.ts @@ -0,0 +1,40 @@ +import { type BrowserContext, test as base, expect } from '@playwright/test'; + +import { USERS, sessionCookie } from './users.mjs'; + +type Who = keyof typeof USERS; + +export const login = async (context: BrowserContext, who: Who, baseURL: string) => { + await context.addCookies([ + { + name: 'better-auth.session_token', + value: sessionCookie(USERS[who].token), + url: baseURL, + }, + ]); +}; + +/** Calls a tRPC procedure over HTTP the way the browser does (superjson, single call). */ +export const trpc = async ( + context: BrowserContext, + baseURL: string, + kind: 'query' | 'mutation', + path: string, + input?: unknown, +) => { + const body = input === undefined ? { json: null, meta: { values: ['undefined'], v: 1 } } : input; + const response = + kind === 'query' + ? await context.request.get( + `${baseURL}/api/trpc/${path}?batch=1&input=${encodeURIComponent(JSON.stringify({ 0: body }))}`, + ) + : await context.request.post(`${baseURL}/api/trpc/${path}?batch=1`, { data: { 0: body } }); + const parsed = (await response.json()) as unknown; + if (response.status() >= 400) { + console.log(`tRPC ${path} -> ${response.status()}`, JSON.stringify(parsed).slice(0, 600)); + } + return { status: response.status(), body: parsed }; +}; + +export { expect }; +export const test = base; diff --git a/tests/e2e/prepare.mjs b/tests/e2e/prepare.mjs new file mode 100644 index 000000000..6892dd244 --- /dev/null +++ b/tests/e2e/prepare.mjs @@ -0,0 +1,36 @@ +// Creates a throwaway local D1 under .e2e-state (migrations + three users with live sessions). +// Runs as part of the Playwright webServer command, before the Worker starts. +import { execFileSync } from 'node:child_process'; +import { rmSync, writeFileSync } from 'node:fs'; + +import { USERS } from './users.mjs'; + +const wrangler = (...args) => + execFileSync( + 'npx', + [ + 'wrangler', + ...args, + '-c', + 'dist/splitpro/wrangler.json', + '--local', + '--persist-to', + '.e2e-state', + ], + { stdio: 'pipe', env: { ...process.env, CI: '1' } }, + ); + +rmSync('.e2e-state', { recursive: true, force: true }); +wrangler('d1', 'migrations', 'apply', 'splitpro'); + +const now = Date.now(); +const expires = now + 7 * 86_400_000; +const sql = Object.values(USERS) + .flatMap((u) => [ + `INSERT INTO "User" (id, name, email, emailVerified, currency, createdAt, updatedAt) VALUES (${u.id}, '${u.name}', '${u.email}', 1, 'EUR', ${now}, ${now});`, + `INSERT INTO "Session" (token, userId, expiresAt, createdAt, updatedAt) VALUES ('${u.token}', ${u.id}, ${expires}, ${now}, ${now});`, + ]) + .join('\n'); +writeFileSync('.e2e-seed.sql', sql); +wrangler('d1', 'execute', 'splitpro', '--file', '.e2e-seed.sql'); +rmSync('.e2e-seed.sql'); diff --git a/tests/e2e/users.mjs b/tests/e2e/users.mjs new file mode 100644 index 000000000..f277c2fdc --- /dev/null +++ b/tests/e2e/users.mjs @@ -0,0 +1,12 @@ +import { createHmac } from 'node:crypto'; + +export const SECRET = 'e2e-secret-e2e-secret-e2e-secret-123456'; +export const USERS = { + alice: { id: 1, name: 'Alice E2E', email: 'alice@e2e.test', token: 'e2e-token-alice' }, + bob: { id: 2, name: 'Bob E2E', email: 'bob@e2e.test', token: 'e2e-token-bob' }, + mallory: { id: 3, name: 'Mallory E2E', email: 'mallory@e2e.test', token: 'e2e-token-mallory' }, +}; + +/** better-auth cookie value: `.`, URL-encoded. */ +export const sessionCookie = (token) => + encodeURIComponent(`${token}.${createHmac('sha256', SECRET).update(token).digest('base64')}`); diff --git a/tsconfig.json b/tsconfig.json index f11f7096c..1930d0536 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -20,7 +20,7 @@ "module": "ESNext", "moduleResolution": "Bundler", "jsx": "preserve", - "plugins": [{ "name": "next" }], + "types": ["@cloudflare/workers-types", "vite/client", "vinext/types"], "incremental": true, /* Path Aliases */ @@ -31,13 +31,25 @@ }, "include": [ "eslint.config.mjs", - "next-env.d.ts", "**/*.ts", "**/*.tsx", "**/*.jsx", "**/*.cjs", "**/*.js", - ".next/types/**/*.ts" + "worker/**/*.ts" ], - "exclude": ["node_modules", "public"] + "exclude": [ + "node_modules", + "public", + "dist", + ".wrangler", + "src/tests", + "src/**/*.test.ts", + "src/dummies", + "test", + "tests", + "playwright.config.ts", + "vitest.config.ts", + ".e2e-state" + ] } diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 000000000..e9bb53225 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,14 @@ +import { cloudflare } from '@cloudflare/vite-plugin'; +import vinext from 'vinext'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [vinext(), cloudflare()], + build: { sourcemap: true }, + resolve: { + alias: { + '~': new URL('./src', import.meta.url).pathname, + '@': new URL('.', import.meta.url).pathname, + }, + }, +}); diff --git a/vite.sw.config.ts b/vite.sw.config.ts new file mode 100644 index 000000000..0098bc595 --- /dev/null +++ b/vite.sw.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vite'; + +// Builds the service worker (push notifications + runtime caching) to public/sw.js. +// Runs before the app build so the file is served as a static asset. +export default defineConfig({ + publicDir: false, + resolve: { alias: { '~': new URL('./src', import.meta.url).pathname } }, + define: { 'self.__SW_MANIFEST': 'undefined', 'process.env.NODE_ENV': '"production"' }, + build: { + outDir: 'public', + emptyOutDir: false, + lib: { entry: 'src/sw.ts', formats: ['es'], fileName: () => 'sw.js' }, + rolldownOptions: { output: { inlineDynamicImports: true } }, + }, +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 000000000..7571f652e --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,34 @@ +import { resolve } from 'node:path'; + +import { cloudflareTest, readD1Migrations } from '@cloudflare/vitest-plugin'; +import { defineConfig } from 'vitest/config'; + +const alias = { '~': resolve(import.meta.dirname, 'src') }; + +export default defineConfig({ + test: { + projects: [ + { + // Real D1 (local, via workerd) with the production migrations applied. + resolve: { alias }, + plugins: [ + cloudflareTest(async () => ({ + wrangler: { configPath: './test/wrangler.test.jsonc' }, + miniflare: { + bindings: { + TEST_MIGRATIONS: await readD1Migrations(resolve(import.meta.dirname, 'migrations')), + }, + }, + })), + ], + test: { + name: 'integration', + include: ['src/tests/integration/**/*.integration.test.ts'], + setupFiles: ['test/setup.integration.ts'], + // CommonJS-only dependencies must be pre-bundled to run inside workerd. + deps: { optimizer: { ssr: { enabled: true, include: ['cron-parser'] } } }, + }, + }, + ], + }, +}); diff --git a/worker/index.ts b/worker/index.ts index 1dbec632f..656d4ab14 100644 --- a/worker/index.ts +++ b/worker/index.ts @@ -1,58 +1,97 @@ -import { type PushMessage } from '~/types'; -import { defaultCache } from '@serwist/next/worker'; -import type { PrecacheEntry, SerwistGlobalConfig } from 'serwist'; -import { Serwist } from 'serwist'; - -// This declares the value of `injectionPoint` to TypeScript. -// `injectionPoint` is the string that will be replaced by the -// actual precache manifest. By default, this string is set to -// `"self.__SW_MANIFEST"`. -declare global { - interface WorkerGlobalScope extends SerwistGlobalConfig { - __SW_MANIFEST: (PrecacheEntry | string)[] | undefined; - } -} - -declare const self: ServiceWorkerGlobalScope; - -const serwist = new Serwist({ - precacheEntries: self.__SW_MANIFEST, - skipWaiting: true, - clientsClaim: true, - navigationPreload: true, - runtimeCaching: defaultCache, -}); +/** + * Worker entry: vinext Pages Router handler plus the scheduler Workflow class. + * vinext uses ./worker/index.ts as `main` when it exists. + * + * The wrapper logs every uncaught exception and every 5xx response to Workers Observability. + * Uncaught exceptions are re-thrown so they also appear in Workers Issues. + */ +import handler from 'vinext/server/pages-router-entry'; +import { createRemoteJWKSet, jwtVerify } from 'jose'; + +import { logError } from '../src/server/observability/errorLog'; +import { PREVIEW_ACCESS_EMAIL_HEADER, PREVIEW_ACCESS_NAME_HEADER } from '../src/server/preview-access'; + +export { SchedulerWorkflow } from '../src/server/workflows/scheduler'; -self.addEventListener('push', function (event) { - const { title, message, data } = JSON.parse(event?.data?.text() ?? '{}') as PushMessage; - event.waitUntil( - self.registration.showNotification(title, { - body: message, - icon: '/icons/android-chrome-192x192.png', - data, - }), - ); +addEventListener('unhandledrejection', (event: Event) => { + logError('unhandledrejection', (event as PromiseRejectionEvent).reason); }); -self.addEventListener('notificationclick', function (event) { - event.notification.close(); - event.waitUntil( - (async () => { - const clientList = await self.clients.matchAll({ type: 'window' }); +type FetchHandler = (request: Request, env: unknown, ctx: ExecutionContext) => Promise; +const vinextFetch = (handler as unknown as { fetch: FetchHandler }).fetch; +const accessIssuer = 'https://andrepascoa.cloudflareaccess.com'; +const accessJwks = createRemoteJWKSet(new URL(`${accessIssuer}/cdn-cgi/access/certs`)); - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - const url = (event.notification.data?.url as string) ?? '/'; +const requestContext = (request: Request) => { + const url = new URL(request.url); + return { + method: request.method, + path: url.pathname, + query: url.search, + rayId: request.headers.get('cf-ray') ?? undefined, + }; +}; - const matchingClient = clientList.find((client) => client.focused) ?? clientList[0]; +export default { + async fetch(request: Request, env: unknown, ctx: ExecutionContext): Promise { + const previewAccessEnabled = + 'object' === typeof env && null !== env && 'true' === Reflect.get(env, 'PREVIEW_ACCESS_AUTH'); - if (matchingClient) { - const client = await matchingClient.focus(); - await client.navigate(url); - } else { - await self.clients.openWindow(url); + if (previewAccessEnabled) { + let identity = await ctx.access?.getIdentity(); + if (!identity?.email) { + const assertion = request.headers.get('cf-access-jwt-assertion'); + const audience = 'object' === typeof env && null !== env && Reflect.get(env, 'PREVIEW_ACCESS_AUD'); + if (assertion && 'string' === typeof audience) { + try { + const verified = await jwtVerify(assertion, accessJwks, { + issuer: accessIssuer, + audience, + }); + if ('string' === typeof verified.payload.email) { + identity = { + email: verified.payload.email, + name: 'string' === typeof verified.payload.name ? verified.payload.name : undefined, + }; + } + } catch (error) { + logError('worker', error, { ...requestContext(request), phase: 'access-identity-fallback' }); + } + } } - })(), - ); -}); -serwist.addEventListeners(); + const email = identity?.email?.trim().toLowerCase(); + if (!email) { + return new Response('Cloudflare Access authentication was not verified.', { status: 403 }); + } + + // Replace, never trust, identity headers supplied by the incoming client. + const headers = new Headers(request.headers); + headers.delete(PREVIEW_ACCESS_EMAIL_HEADER); + headers.delete(PREVIEW_ACCESS_NAME_HEADER); + headers.set(PREVIEW_ACCESS_EMAIL_HEADER, email); + if (identity?.name) headers.set(PREVIEW_ACCESS_NAME_HEADER, identity.name); + request = new Request(request, { headers }); + } + + let response: Response; + try { + response = await vinextFetch(request, env, ctx); + } catch (error) { + logError('worker', error, { ...requestContext(request), phase: 'uncaught' }); + throw error; + } + + // Vinext turns errors in getServerSideProps / API routes into 5xx responses without + // Throwing, so log those too (the handler's own console.error output, if any, is + // Attached to the same invocation in Workers Logs). + if (500 <= response.status) { + logError('ssr', new Error(`HTTP ${response.status} response`), { + ...requestContext(request), + status: response.status, + }); + } + + return response; + }, +} satisfies ExportedHandler; diff --git a/wrangler.example.jsonc b/wrangler.example.jsonc new file mode 100644 index 000000000..6f6952bd6 --- /dev/null +++ b/wrangler.example.jsonc @@ -0,0 +1,87 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "splitpro", + "main": "./worker/index.ts", + "compatibility_date": "2026-10-01", + "compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"], + "assets": { + "not_found_handling": "none", + "binding": "ASSETS", + }, + "routes": [{ "pattern": "split.example.com", "custom_domain": true }], + "workers_dev": false, + "upload_source_maps": true, + "observability": { + "enabled": true, + "head_sampling_rate": 1, + "redact_query_string": false, + "issues": { "enabled": true }, + "logs": { "enabled": true, "invocation_logs": true, "persist": true, "head_sampling_rate": 1 }, + "traces": { "enabled": true, "persist": true, "head_sampling_rate": 1 }, + }, + "d1_databases": [ + { + "binding": "DB", + "database_name": "splitpro", + "database_id": "00000000-0000-0000-0000-000000000000", + "migrations_dir": "migrations", + }, + ], + "r2_buckets": [{ "binding": "UPLOADS", "bucket_name": "splitpro-uploads" }], + "send_email": [{ "name": "EMAIL" }], + "workflows": [ + { + "name": "splitpro-scheduler", + "binding": "SCHEDULER", + "class_name": "SchedulerWorkflow", + // Every minute: run due recurring expenses, send recurrence notifications, + // and (on CLEAR_CACHE_CRON_RULE) clean up cached bank data and currency rates. + "schedules": ["* * * * *"], + }, + ], + "vars": { + "NEXTAUTH_URL": "https://split.example.com", + "DEFAULT_HOMEPAGE": "/balances", + "ENABLE_SENDING_INVITES": "true", + "DISABLE_EMAIL_SIGNUP": "true", + "INVITE_ONLY": "false", + "PREVIEW_ACCESS_AUTH": "false", + "FROM_EMAIL": "splitpro@example.com", + "CLEAR_CACHE_CRON_RULE": "0 2 * * 0", + "CACHE_RETENTION_INTERVAL": "2 days", + "CURRENCY_RATE_PROVIDER": "frankfurter", + "UPLOAD_MAX_FILE_SIZE_MB": "10", + }, + "previews": { + "vars": { + "NEXTAUTH_URL": "https://replace-with-this-preview-url", + "NEXTAUTH_SECRET": "preview-only-secret-change-at-runtime", + "NODE_ENV": "production", + "DEFAULT_HOMEPAGE": "/balances", + "ENABLE_SENDING_INVITES": "false", + "DISABLE_EMAIL_SIGNUP": "true", + "INVITE_ONLY": "true", + "PREVIEW_ACCESS_AUTH": "true", + "PREVIEW_ACCESS_AUD": "replace-with-your-access-audience-tag", + "CLEAR_CACHE_CRON_RULE": "0 2 * * 0", + "CACHE_RETENTION_INTERVAL": "2 days", + "CURRENCY_RATE_PROVIDER": "frankfurter", + "UPLOAD_MAX_FILE_SIZE_MB": "10", + }, + "d1_databases": [ + { + "binding": "DB", + "database_name": "splitpro-preview", + "database_id": "00000000-0000-0000-0000-000000000000", + "migrations_dir": "migrations", + }, + ], + "r2_buckets": [{ "binding": "UPLOADS", "bucket_name": "splitpro-preview-uploads" }], + "observability": { + "enabled": true, + "head_sampling_rate": 1, + "logs": { "enabled": true, "invocation_logs": true, "persist": true, "head_sampling_rate": 1 }, + "traces": { "enabled": true, "persist": true, "head_sampling_rate": 1 }, + }, + }, +}