Archived
Compare commits
4
Commits
3f3b1362a9
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e7174fa60 | ||
|
|
5f6987b84b | ||
|
|
66a53f25f2 | ||
|
|
29d7b498ae |
@@ -82,12 +82,15 @@ bun run db:push # fast iteration during development
|
|||||||
# bun run db:migrate # same migrations the Docker image runs in production
|
# bun run db:migrate # same migrations the Docker image runs in production
|
||||||
```
|
```
|
||||||
|
|
||||||
**Demo account.** For App Store review and local testing, `bun run db:migrate` applies `0014_seed_demo_account.sql`, which creates a pre-populated user (`db:push` does not). Sign in at `/auth/login`:
|
**Demo account.** For App Store review and local testing, `bun run db:migrate` creates a pre-populated `demo@example.com` account (`db:push` does not) with the public password `demo123`.
|
||||||
|
|
||||||
- Email: `demo@example.com`
|
To rotate the credential temporarily, provision a private password:
|
||||||
- Password: `demo123`
|
|
||||||
|
|
||||||
The account includes a sample business, clients, and invoices (draft, sent, and paid).
|
```bash
|
||||||
|
DEMO_ACCOUNT_PASSWORD='<private 12+ character password>' bun run demo:provision
|
||||||
|
```
|
||||||
|
|
||||||
|
Provisioning rotates the credential and invalidates prior sessions. Do not commit or publish a private replacement password. The account includes a sample business, clients, and invoices (draft, sent, and paid).
|
||||||
|
|
||||||
### 4. Run
|
### 4. Run
|
||||||
|
|
||||||
|
|||||||
@@ -53,19 +53,21 @@
|
|||||||
"framer-motion": "^12.40.0",
|
"framer-motion": "^12.40.0",
|
||||||
"fuse.js": "^7.4.2",
|
"fuse.js": "^7.4.2",
|
||||||
"lucide-react": "^0.525.0",
|
"lucide-react": "^0.525.0",
|
||||||
"next": "^16.2.9",
|
"next": "^16.2.12",
|
||||||
"pg": "8.21.0",
|
"pg": "8.21.0",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.8",
|
||||||
"react-colorful": "^5.7.0",
|
"react-colorful": "^5.7.0",
|
||||||
"react-day-picker": "^9.12.0",
|
"react-day-picker": "^9.12.0",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.8",
|
||||||
"react-dropzone": "^14.3.8",
|
"react-dropzone": "^14.3.8",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
"resend": "^4.8.0",
|
"resend": "^4.8.0",
|
||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
|
"sharp": "^0.35.3",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"superjson": "^2.2.6",
|
"superjson": "^2.2.6",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
|
"trpc": "^0.11.3",
|
||||||
"zod": "^3.25.76",
|
"zod": "^3.25.76",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -96,8 +98,8 @@
|
|||||||
},
|
},
|
||||||
"trustedDependencies": [
|
"trustedDependencies": [
|
||||||
"@tailwindcss/oxide",
|
"@tailwindcss/oxide",
|
||||||
"esbuild",
|
|
||||||
"sharp",
|
"sharp",
|
||||||
|
"esbuild",
|
||||||
"unrs-resolver",
|
"unrs-resolver",
|
||||||
],
|
],
|
||||||
"packages": {
|
"packages": {
|
||||||
@@ -327,53 +329,57 @@
|
|||||||
|
|
||||||
"@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
|
"@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
|
||||||
|
|
||||||
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
|
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.2" }, "os": "darwin", "cpu": "arm64" }, "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg=="],
|
||||||
|
|
||||||
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
|
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.2" }, "os": "darwin", "cpu": "x64" }, "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w=="],
|
||||||
|
|
||||||
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
|
"@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.3", "", { "dependencies": { "@img/sharp-wasm32": "0.35.3" }, "os": "freebsd" }, "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg=="],
|
||||||
|
|
||||||
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
|
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg=="],
|
||||||
|
|
||||||
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
|
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw=="],
|
||||||
|
|
||||||
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
|
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.2", "", { "os": "linux", "cpu": "arm" }, "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ=="],
|
||||||
|
|
||||||
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="],
|
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA=="],
|
||||||
|
|
||||||
"@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="],
|
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw=="],
|
||||||
|
|
||||||
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
|
"@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.2", "", { "os": "linux", "cpu": "none" }, "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w=="],
|
||||||
|
|
||||||
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
|
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ=="],
|
||||||
|
|
||||||
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
|
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w=="],
|
||||||
|
|
||||||
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
|
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw=="],
|
||||||
|
|
||||||
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
|
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ=="],
|
||||||
|
|
||||||
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
|
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.2" }, "os": "linux", "cpu": "arm" }, "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA=="],
|
||||||
|
|
||||||
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="],
|
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.2" }, "os": "linux", "cpu": "arm64" }, "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ=="],
|
||||||
|
|
||||||
"@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="],
|
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.2" }, "os": "linux", "cpu": "ppc64" }, "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA=="],
|
||||||
|
|
||||||
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="],
|
"@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.2" }, "os": "linux", "cpu": "none" }, "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ=="],
|
||||||
|
|
||||||
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
|
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.2" }, "os": "linux", "cpu": "s390x" }, "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw=="],
|
||||||
|
|
||||||
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
|
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.2" }, "os": "linux", "cpu": "x64" }, "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA=="],
|
||||||
|
|
||||||
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
|
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" }, "os": "linux", "cpu": "arm64" }, "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w=="],
|
||||||
|
|
||||||
|
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.2" }, "os": "linux", "cpu": "x64" }, "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg=="],
|
||||||
|
|
||||||
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
|
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
|
||||||
|
|
||||||
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
|
"@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.3", "", { "dependencies": { "@img/sharp-wasm32": "0.35.3" }, "cpu": "none" }, "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q=="],
|
||||||
|
|
||||||
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
|
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w=="],
|
||||||
|
|
||||||
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
|
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw=="],
|
||||||
|
|
||||||
|
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.3", "", { "os": "win32", "cpu": "x64" }, "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA=="],
|
||||||
|
|
||||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||||
|
|
||||||
@@ -387,25 +393,25 @@
|
|||||||
|
|
||||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
|
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
|
||||||
|
|
||||||
"@next/env": ["@next/env@16.2.9", "", {}, "sha512-ki5VxxXfzD/9TDe13wyeTKIjQTAwBVpnr8KhRDUr8ltMUq1/NBpWNT5tiPoxiGl+PHM4X2ahSOiPk6iAimIzPg=="],
|
"@next/env": ["@next/env@16.2.12", "", {}, "sha512-d0Z5Bc13Fa4nR8pFAKx2jay2yhJM16vlfHbTzYnUQAxlNb6B6lmn4hjt69lYNt4kRtyYP6gEM49lPRHNbIyneg=="],
|
||||||
|
|
||||||
"@next/eslint-plugin-next": ["@next/eslint-plugin-next@16.2.7", "", { "dependencies": { "fast-glob": "3.3.1" } }, "sha512-VbS+QgMHqvIDMTIqD2xMBKK1otIpdAUKA8VLHFwR9h6OfU/mOm7w/69nQcvdmI8hCk99Wr2AsGLn/PJ/tMHw1w=="],
|
"@next/eslint-plugin-next": ["@next/eslint-plugin-next@16.2.7", "", { "dependencies": { "fast-glob": "3.3.1" } }, "sha512-VbS+QgMHqvIDMTIqD2xMBKK1otIpdAUKA8VLHFwR9h6OfU/mOm7w/69nQcvdmI8hCk99Wr2AsGLn/PJ/tMHw1w=="],
|
||||||
|
|
||||||
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-HkfxNYUCmcct0Xsqib5KxqMSHV4AHJq857BNRchyBDs4YS19aHzVfn1kDuBYKqLLQBjXgnkIsjV2Kd4d2wzYhw=="],
|
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0W1R0teHWJrqKX0FH20IzzIWAOuGtBxPGuObrxy1lE8hQvCFj49KE8a3WUg0D7sq6rn6zkM4c7YGUnhudBS6oA=="],
|
||||||
|
|
||||||
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-7IAtK4MeybpqRV9GRABWEhJ62mOS+rzWOzOTFie4cSEtm12xsoOMJRcECoZx3FHPzFAqN/IJtHqWAFOLfl152w=="],
|
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-Hy5Ls099+aFUmOLmIgPfLqNi6iCwhL3uQCssz5rWk+5Nkc6TUKCE83DY5BbNylfm3+mfwcSFnLRfrZDJhVxdtw=="],
|
||||||
|
|
||||||
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-hBD75iWpUtkL9SmQmcRhmLomn9jgkPzCEkbOcLgHymPEKzv+6ONy13RRiIEz/iEObjkS2Jlb5gYS2XGoS3X4rw=="],
|
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-+YqU2h1cQkHsGfvjAsrSmst8UIFBibBGm5x3Xgel8NLMiDQtNOM4sM2GOEMvG5YiOBNeN/Ykk8cQC2S0Xrqljg=="],
|
||||||
|
|
||||||
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-qZTI3pf9SGc/obr8NkQAekBxmp1QK+kVm+VAf3BALLfFAj+1kUhkTxmrWpVos9R/UYIA8AWX2p6cGI5WdwzVUA=="],
|
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-0qjhiYBaKAqF63LA1ZWAAnKTzFUguAaZiRa5etMLGGPj/B6uEVjtIZldIzFEp3wHlB0koK6aTzqPtSdplTCjoA=="],
|
||||||
|
|
||||||
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.9", "", { "os": "linux", "cpu": "x64" }, "sha512-xm0HfRNX+UkH4R3c18ynswjj5o5uEj/7iI9p9omdtTSIsRCzQqkGMA+10nzJ4EHnYC3as65IMhbbl5fWRUWHYg=="],
|
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.12", "", { "os": "linux", "cpu": "x64" }, "sha512-7A3q26W+h7gnA15uqBToNuDqBEFZZcqh0mW2mn4AJh/G5pdg2RVE3n4slzLEliASZFG3NmsbEzng/x2Sh09mBg=="],
|
||||||
|
|
||||||
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.9", "", { "os": "linux", "cpu": "x64" }, "sha512-QumimHkGEG6vM3PfEDWKyKen03NcqLOkeKB1EfcPe7VxzmEiCa4jNnMyBn/US5zcd/VE1CI+O8Ovb3lfjVHfGw=="],
|
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.12", "", { "os": "linux", "cpu": "x64" }, "sha512-qSjL/uppm+cbh21s72Ss8gkiOhQ4dExWHNGOWy6eZV7STj5WsKehgxT61beSsOj+YYQuTplL376lOCdMQU5T8w=="],
|
||||||
|
|
||||||
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-hzQpKZvw8rAwI6A2uQh6SacCSvNAXaIkPNsWwzqqfRiIMiXMfH936skDhz1OO6KpvdKkJrgHHtqQOq5PIXOvdQ=="],
|
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-X6hzsOUJac/e7AWSbn9gQ9nzHld1xWP5iyjHpYWvud8pufB679O1xg4JDyKr8Xd69Jvd+kM2Der6uftiZCmjYA=="],
|
||||||
|
|
||||||
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.9", "", { "os": "win32", "cpu": "x64" }, "sha512-qr2VL3Ce5QrwgO2yh1ujSBawrimjVKX8FGF/cOynmdYKJY0BdHpGVNIRK1tqONB10Vkm25Ub1BD2bkjWs4+96w=="],
|
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.12", "", { "os": "win32", "cpu": "x64" }, "sha512-F6fakeHuFTLOPt0bslQJdf+xtT+WIP9DVn/m4y1w1mRnVPyh3D/cNvzlRkxM444xfm+IvvYNSOrKiA2CDJ0Uxw=="],
|
||||||
|
|
||||||
"@noble/ciphers": ["@noble/ciphers@2.2.0", "", {}, "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA=="],
|
"@noble/ciphers": ["@noble/ciphers@2.2.0", "", {}, "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA=="],
|
||||||
|
|
||||||
@@ -821,6 +827,8 @@
|
|||||||
|
|
||||||
"axe-core": ["axe-core@4.11.3", "", {}, "sha512-zBQouZixDTbo3jMGqHKyePxYxr1e5W8UdTmBQ7sNtaA9M2bE32daxxPLS/jojhKOHxQ7LWwPjfiwf/fhaJWzlg=="],
|
"axe-core": ["axe-core@4.11.3", "", {}, "sha512-zBQouZixDTbo3jMGqHKyePxYxr1e5W8UdTmBQ7sNtaA9M2bE32daxxPLS/jojhKOHxQ7LWwPjfiwf/fhaJWzlg=="],
|
||||||
|
|
||||||
|
"axios": ["axios@0.19.2", "", { "dependencies": { "follow-redirects": "1.5.10" } }, "sha512-fjgm5MvRHLhx+osE2xoekY70AhARk3a6hkN+3Io1jc00jtquGvxYlKlsFUhmUET0V5te6CcZI7lcv2Ym61mjHA=="],
|
||||||
|
|
||||||
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
|
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
|
||||||
|
|
||||||
"babel-plugin-react-compiler": ["babel-plugin-react-compiler@1.0.0", "", { "dependencies": { "@babel/types": "^7.26.0" } }, "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw=="],
|
"babel-plugin-react-compiler": ["babel-plugin-react-compiler@1.0.0", "", { "dependencies": { "@babel/types": "^7.26.0" } }, "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw=="],
|
||||||
@@ -1065,6 +1073,8 @@
|
|||||||
|
|
||||||
"flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="],
|
"flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="],
|
||||||
|
|
||||||
|
"follow-redirects": ["follow-redirects@1.5.10", "", { "dependencies": { "debug": "=3.1.0" } }, "sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ=="],
|
||||||
|
|
||||||
"fontkit": ["fontkit@2.0.4", "", { "dependencies": { "@swc/helpers": "^0.5.12", "brotli": "^1.3.2", "clone": "^2.1.2", "dfa": "^1.2.0", "fast-deep-equal": "^3.1.3", "restructure": "^3.0.0", "tiny-inflate": "^1.0.3", "unicode-properties": "^1.4.0", "unicode-trie": "^2.0.0" } }, "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g=="],
|
"fontkit": ["fontkit@2.0.4", "", { "dependencies": { "@swc/helpers": "^0.5.12", "brotli": "^1.3.2", "clone": "^2.1.2", "dfa": "^1.2.0", "fast-deep-equal": "^3.1.3", "restructure": "^3.0.0", "tiny-inflate": "^1.0.3", "unicode-properties": "^1.4.0", "unicode-trie": "^2.0.0" } }, "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g=="],
|
||||||
|
|
||||||
"for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="],
|
"for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="],
|
||||||
@@ -1311,7 +1321,7 @@
|
|||||||
|
|
||||||
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
|
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
|
||||||
|
|
||||||
"next": ["next@16.2.9", "", { "dependencies": { "@next/env": "16.2.9", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.9", "@next/swc-darwin-x64": "16.2.9", "@next/swc-linux-arm64-gnu": "16.2.9", "@next/swc-linux-arm64-musl": "16.2.9", "@next/swc-linux-x64-gnu": "16.2.9", "@next/swc-linux-x64-musl": "16.2.9", "@next/swc-win32-arm64-msvc": "16.2.9", "@next/swc-win32-x64-msvc": "16.2.9", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-MEOJiq/UvuezAdqVSceHbqDgZt1kDw2tpGVOlsdIoJsQdbN2JY2hpVG4xnXGkbdJUOEWhnRfiu/O4Hpc9Juwww=="],
|
"next": ["next@16.2.12", "", { "dependencies": { "@next/env": "16.2.12", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.12", "@next/swc-darwin-x64": "16.2.12", "@next/swc-linux-arm64-gnu": "16.2.12", "@next/swc-linux-arm64-musl": "16.2.12", "@next/swc-linux-x64-gnu": "16.2.12", "@next/swc-linux-x64-musl": "16.2.12", "@next/swc-win32-arm64-msvc": "16.2.12", "@next/swc-win32-x64-msvc": "16.2.12", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-iD59eYQWmbFcEbX7v/acG5DRym9iw1DdaPoD0WTA920naWsE25wShzJW4+UvAs8MK9EC2kBfIH6vtto1H1PHGw=="],
|
||||||
|
|
||||||
"node-exports-info": ["node-exports-info@1.6.0", "", { "dependencies": { "array.prototype.flatmap": "^1.3.3", "es-errors": "^1.3.0", "object.entries": "^1.1.9", "semver": "^6.3.1" } }, "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw=="],
|
"node-exports-info": ["node-exports-info@1.6.0", "", { "dependencies": { "array.prototype.flatmap": "^1.3.3", "es-errors": "^1.3.0", "object.entries": "^1.1.9", "semver": "^6.3.1" } }, "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw=="],
|
||||||
|
|
||||||
@@ -1435,13 +1445,13 @@
|
|||||||
|
|
||||||
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
||||||
|
|
||||||
"react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="],
|
"react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="],
|
||||||
|
|
||||||
"react-colorful": ["react-colorful@5.7.0", "", { "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-fuesYIemttah97XmsIHmz4OORDHiSFzyc9HMAIrCHJou2jaRQmL8cFJ76K4zQhhj8jzwOBlOi4BaGTjjOZCfTg=="],
|
"react-colorful": ["react-colorful@5.7.0", "", { "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-fuesYIemttah97XmsIHmz4OORDHiSFzyc9HMAIrCHJou2jaRQmL8cFJ76K4zQhhj8jzwOBlOi4BaGTjjOZCfTg=="],
|
||||||
|
|
||||||
"react-day-picker": ["react-day-picker@9.14.0", "", { "dependencies": { "@date-fns/tz": "^1.4.1", "@tabby_ai/hijri-converter": "1.0.5", "date-fns": "^4.1.0", "date-fns-jalali": "4.1.0-0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-tBaoDWjPwe0M5pGrum4H0SR6Lyk+BO9oHnp9JbKpGKW2mlraNPgP9BMfsg5pWpwrssARmeqk7YBl2oXutZTaHA=="],
|
"react-day-picker": ["react-day-picker@9.14.0", "", { "dependencies": { "@date-fns/tz": "^1.4.1", "@tabby_ai/hijri-converter": "1.0.5", "date-fns": "^4.1.0", "date-fns-jalali": "4.1.0-0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-tBaoDWjPwe0M5pGrum4H0SR6Lyk+BO9oHnp9JbKpGKW2mlraNPgP9BMfsg5pWpwrssARmeqk7YBl2oXutZTaHA=="],
|
||||||
|
|
||||||
"react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="],
|
"react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="],
|
||||||
|
|
||||||
"react-dropzone": ["react-dropzone@14.4.1", "", { "dependencies": { "attr-accept": "^2.2.4", "file-selector": "^2.1.0", "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">= 16.8 || 18.0.0" } }, "sha512-QDuV76v3uKbHiH34SpwifZ+gOLi1+RdsCO1kl5vxMT4wW8R82+sthjvBw4th3NHF/XX6FBsqDYZVNN+pnhaw0g=="],
|
"react-dropzone": ["react-dropzone@14.4.1", "", { "dependencies": { "attr-accept": "^2.2.4", "file-selector": "^2.1.0", "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">= 16.8 || 18.0.0" } }, "sha512-QDuV76v3uKbHiH34SpwifZ+gOLi1+RdsCO1kl5vxMT4wW8R82+sthjvBw4th3NHF/XX6FBsqDYZVNN+pnhaw0g=="],
|
||||||
|
|
||||||
@@ -1501,7 +1511,7 @@
|
|||||||
|
|
||||||
"selderee": ["selderee@0.11.0", "", { "dependencies": { "parseley": "^0.12.0" } }, "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA=="],
|
"selderee": ["selderee@0.11.0", "", { "dependencies": { "parseley": "^0.12.0" } }, "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA=="],
|
||||||
|
|
||||||
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
"semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
|
||||||
|
|
||||||
"server-only": ["server-only@0.0.1", "", {}, "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA=="],
|
"server-only": ["server-only@0.0.1", "", {}, "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA=="],
|
||||||
|
|
||||||
@@ -1513,7 +1523,7 @@
|
|||||||
|
|
||||||
"set-proto": ["set-proto@1.0.0", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="],
|
"set-proto": ["set-proto@1.0.0", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="],
|
||||||
|
|
||||||
"sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "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" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
|
"sharp": ["sharp@0.35.3", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.3", "@img/sharp-darwin-x64": "0.35.3", "@img/sharp-freebsd-wasm32": "0.35.3", "@img/sharp-libvips-darwin-arm64": "1.3.2", "@img/sharp-libvips-darwin-x64": "1.3.2", "@img/sharp-libvips-linux-arm": "1.3.2", "@img/sharp-libvips-linux-arm64": "1.3.2", "@img/sharp-libvips-linux-ppc64": "1.3.2", "@img/sharp-libvips-linux-riscv64": "1.3.2", "@img/sharp-libvips-linux-s390x": "1.3.2", "@img/sharp-libvips-linux-x64": "1.3.2", "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", "@img/sharp-libvips-linuxmusl-x64": "1.3.2", "@img/sharp-linux-arm": "0.35.3", "@img/sharp-linux-arm64": "0.35.3", "@img/sharp-linux-ppc64": "0.35.3", "@img/sharp-linux-riscv64": "0.35.3", "@img/sharp-linux-s390x": "0.35.3", "@img/sharp-linux-x64": "0.35.3", "@img/sharp-linuxmusl-arm64": "0.35.3", "@img/sharp-linuxmusl-x64": "0.35.3", "@img/sharp-webcontainers-wasm32": "0.35.3", "@img/sharp-win32-arm64": "0.35.3", "@img/sharp-win32-ia32": "0.35.3", "@img/sharp-win32-x64": "0.35.3" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q=="],
|
||||||
|
|
||||||
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||||
|
|
||||||
@@ -1585,6 +1595,8 @@
|
|||||||
|
|
||||||
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
||||||
|
|
||||||
|
"trpc": ["trpc@0.11.3", "", { "dependencies": { "axios": "^0.19.2" } }, "sha512-vfj6WrxYk8XDZzCsFNLwo5WhlKi4IYmVRzRgRQAlcK8zH4sY0yIAlJ7Nd1lZcGFe985GfP2LZLoEsCrMsIl/tA=="],
|
||||||
|
|
||||||
"ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="],
|
"ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="],
|
||||||
|
|
||||||
"tsconfig-paths": ["tsconfig-paths@3.15.0", "", { "dependencies": { "@types/json5": "^0.0.29", "json5": "^1.0.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg=="],
|
"tsconfig-paths": ["tsconfig-paths@3.15.0", "", { "dependencies": { "@types/json5": "^0.0.29", "json5": "^1.0.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg=="],
|
||||||
@@ -1663,6 +1675,10 @@
|
|||||||
|
|
||||||
"@babel/core/json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
"@babel/core/json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||||
|
|
||||||
|
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||||
|
|
||||||
|
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||||
|
|
||||||
"@better-auth/core/@better-fetch/fetch": ["@better-fetch/fetch@1.2.2", "", {}, "sha512-xlgQcYROGFgKg5FY7ZLppFmG7rR5Hkmz7tgDuQeR79i5KhKRjr2QC9xsBG2qEGPJJjf9bxzg/NMW2hEUWs5OnA=="],
|
"@better-auth/core/@better-fetch/fetch": ["@better-fetch/fetch@1.2.2", "", {}, "sha512-xlgQcYROGFgKg5FY7ZLppFmG7rR5Hkmz7tgDuQeR79i5KhKRjr2QC9xsBG2qEGPJJjf9bxzg/NMW2hEUWs5OnA=="],
|
||||||
|
|
||||||
"@better-auth/core/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
"@better-auth/core/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||||
@@ -1677,6 +1693,10 @@
|
|||||||
|
|
||||||
"@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
|
"@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
|
||||||
|
|
||||||
|
"@img/sharp-freebsd-wasm32/@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.3", "", { "dependencies": { "@emnapi/runtime": "^1.11.1" } }, "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w=="],
|
||||||
|
|
||||||
|
"@img/sharp-webcontainers-wasm32/@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.3", "", { "dependencies": { "@emnapi/runtime": "^1.11.1" } }, "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w=="],
|
||||||
|
|
||||||
"@react-email/render/prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="],
|
"@react-email/render/prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="],
|
||||||
|
|
||||||
"@react-pdf/pdfkit/@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="],
|
"@react-pdf/pdfkit/@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="],
|
||||||
@@ -1725,10 +1745,16 @@
|
|||||||
|
|
||||||
"eslint-plugin-import/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
|
"eslint-plugin-import/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
|
||||||
|
|
||||||
|
"eslint-plugin-import/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||||
|
|
||||||
|
"eslint-plugin-react/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||||
|
|
||||||
"eslint-plugin-react-hooks/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
"eslint-plugin-react-hooks/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||||
|
|
||||||
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||||
|
|
||||||
|
"follow-redirects/debug": ["debug@3.1.0", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g=="],
|
||||||
|
|
||||||
"fontkit/@swc/helpers": ["@swc/helpers@0.5.21", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg=="],
|
"fontkit/@swc/helpers": ["@swc/helpers@0.5.21", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg=="],
|
||||||
|
|
||||||
"is-bun-module/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
"is-bun-module/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||||
@@ -1737,14 +1763,16 @@
|
|||||||
|
|
||||||
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
|
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
|
||||||
|
|
||||||
|
"next/sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "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" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
|
||||||
|
|
||||||
|
"node-exports-info/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||||
|
|
||||||
"pg/pg-protocol": ["pg-protocol@1.14.0", "", {}, "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA=="],
|
"pg/pg-protocol": ["pg-protocol@1.14.0", "", {}, "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA=="],
|
||||||
|
|
||||||
"react-day-picker/date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="],
|
"react-day-picker/date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="],
|
||||||
|
|
||||||
"react-promise-suspense/fast-deep-equal": ["fast-deep-equal@2.0.1", "", {}, "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w=="],
|
"react-promise-suspense/fast-deep-equal": ["fast-deep-equal@2.0.1", "", {}, "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w=="],
|
||||||
|
|
||||||
"sharp/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
|
||||||
|
|
||||||
"tsx/esbuild": ["esbuild@0.28.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.0", "@esbuild/android-arm": "0.28.0", "@esbuild/android-arm64": "0.28.0", "@esbuild/android-x64": "0.28.0", "@esbuild/darwin-arm64": "0.28.0", "@esbuild/darwin-x64": "0.28.0", "@esbuild/freebsd-arm64": "0.28.0", "@esbuild/freebsd-x64": "0.28.0", "@esbuild/linux-arm": "0.28.0", "@esbuild/linux-arm64": "0.28.0", "@esbuild/linux-ia32": "0.28.0", "@esbuild/linux-loong64": "0.28.0", "@esbuild/linux-mips64el": "0.28.0", "@esbuild/linux-ppc64": "0.28.0", "@esbuild/linux-riscv64": "0.28.0", "@esbuild/linux-s390x": "0.28.0", "@esbuild/linux-x64": "0.28.0", "@esbuild/netbsd-arm64": "0.28.0", "@esbuild/netbsd-x64": "0.28.0", "@esbuild/openbsd-arm64": "0.28.0", "@esbuild/openbsd-x64": "0.28.0", "@esbuild/openharmony-arm64": "0.28.0", "@esbuild/sunos-x64": "0.28.0", "@esbuild/win32-arm64": "0.28.0", "@esbuild/win32-ia32": "0.28.0", "@esbuild/win32-x64": "0.28.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw=="],
|
"tsx/esbuild": ["esbuild@0.28.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.0", "@esbuild/android-arm": "0.28.0", "@esbuild/android-arm64": "0.28.0", "@esbuild/android-x64": "0.28.0", "@esbuild/darwin-arm64": "0.28.0", "@esbuild/darwin-x64": "0.28.0", "@esbuild/freebsd-arm64": "0.28.0", "@esbuild/freebsd-x64": "0.28.0", "@esbuild/linux-arm": "0.28.0", "@esbuild/linux-arm64": "0.28.0", "@esbuild/linux-ia32": "0.28.0", "@esbuild/linux-loong64": "0.28.0", "@esbuild/linux-mips64el": "0.28.0", "@esbuild/linux-ppc64": "0.28.0", "@esbuild/linux-riscv64": "0.28.0", "@esbuild/linux-s390x": "0.28.0", "@esbuild/linux-x64": "0.28.0", "@esbuild/netbsd-arm64": "0.28.0", "@esbuild/netbsd-x64": "0.28.0", "@esbuild/openbsd-arm64": "0.28.0", "@esbuild/openbsd-x64": "0.28.0", "@esbuild/openharmony-arm64": "0.28.0", "@esbuild/sunos-x64": "0.28.0", "@esbuild/win32-arm64": "0.28.0", "@esbuild/win32-ia32": "0.28.0", "@esbuild/win32-x64": "0.28.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw=="],
|
||||||
|
|
||||||
"unicode-properties/base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
"unicode-properties/base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
||||||
@@ -1795,10 +1823,64 @@
|
|||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
|
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
|
||||||
|
|
||||||
|
"@img/sharp-freebsd-wasm32/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
|
||||||
|
|
||||||
|
"@img/sharp-webcontainers-wasm32/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
|
||||||
|
|
||||||
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="],
|
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="],
|
||||||
|
|
||||||
|
"follow-redirects/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||||
|
|
||||||
"next/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
"next/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||||
|
|
||||||
|
"next/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
|
||||||
|
|
||||||
|
"next/sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
|
||||||
|
|
||||||
|
"next/sharp/@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
|
||||||
|
|
||||||
|
"next/sharp/@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
|
||||||
|
|
||||||
|
"next/sharp/@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
|
||||||
|
|
||||||
|
"next/sharp/@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
|
||||||
|
|
||||||
|
"next/sharp/@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="],
|
||||||
|
|
||||||
|
"next/sharp/@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="],
|
||||||
|
|
||||||
|
"next/sharp/@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
|
||||||
|
|
||||||
|
"next/sharp/@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
|
||||||
|
|
||||||
|
"next/sharp/@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
|
||||||
|
|
||||||
|
"next/sharp/@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
|
||||||
|
|
||||||
|
"next/sharp/@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
|
||||||
|
|
||||||
|
"next/sharp/@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
|
||||||
|
|
||||||
|
"next/sharp/@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="],
|
||||||
|
|
||||||
|
"next/sharp/@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="],
|
||||||
|
|
||||||
|
"next/sharp/@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="],
|
||||||
|
|
||||||
|
"next/sharp/@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
|
||||||
|
|
||||||
|
"next/sharp/@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
|
||||||
|
|
||||||
|
"next/sharp/@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
|
||||||
|
|
||||||
|
"next/sharp/@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
|
||||||
|
|
||||||
|
"next/sharp/@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
|
||||||
|
|
||||||
|
"next/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
|
||||||
|
|
||||||
|
"next/sharp/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||||
|
|
||||||
"tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA=="],
|
"tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA=="],
|
||||||
|
|
||||||
"tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.0", "", { "os": "android", "cpu": "arm" }, "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ=="],
|
"tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.0", "", { "os": "android", "cpu": "arm" }, "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ=="],
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE "beenvoice_business" ADD COLUMN IF NOT EXISTS "logoStorageKey" varchar(500);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "beenvoice_business" ADD COLUMN IF NOT EXISTS "logoMimeType" varchar(100);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "beenvoice_business" ADD COLUMN IF NOT EXISTS "hideNameWithLogo" boolean DEFAULT false NOT NULL;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- 0025 was silently skipped on databases that already had a later migration
|
||||||
|
-- timestamp recorded (from a since-removed feature's migrations). Re-apply
|
||||||
|
-- idempotently so both that DB and any fresh install end up with the column.
|
||||||
|
ALTER TABLE "beenvoice_business" ADD COLUMN IF NOT EXISTS "hideNameWithLogo" boolean DEFAULT false NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
-- Drop the orphaned table from the business-document-vault feature that was
|
||||||
|
-- removed earlier this session (its migrations ran on this DB before removal).
|
||||||
|
DROP TABLE IF EXISTS "beenvoice_business_document" CASCADE;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- The original App Review credential was committed publicly in migration 0014.
|
||||||
|
-- Rotate it to an unknown value and invalidate its sessions. Use
|
||||||
|
-- `bun run demo:provision` with a private DEMO_ACCOUNT_PASSWORD when review
|
||||||
|
-- access is needed.
|
||||||
|
UPDATE "beenvoice_user"
|
||||||
|
SET "password" = '$2b$12$GyM6.bLv2.sZMsWytNz1L.j7pLwc79a55Nww6bSLQJ9OJarqY9oZW',
|
||||||
|
"updatedAt" = NOW()
|
||||||
|
WHERE "id" = 'a0000000-0000-4000-8000-000000000001';
|
||||||
|
|
||||||
|
UPDATE "beenvoice_account"
|
||||||
|
SET "password" = '$2b$12$GyM6.bLv2.sZMsWytNz1L.j7pLwc79a55Nww6bSLQJ9OJarqY9oZW',
|
||||||
|
"updatedAt" = NOW()
|
||||||
|
WHERE "userId" = 'a0000000-0000-4000-8000-000000000001'
|
||||||
|
AND "providerId" = 'credential';
|
||||||
|
|
||||||
|
DELETE FROM "beenvoice_session"
|
||||||
|
WHERE "userId" = 'a0000000-0000-4000-8000-000000000001';
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- Restore the public App Store review credential for the seeded demo account.
|
||||||
|
-- Password: demo123
|
||||||
|
UPDATE "beenvoice_user"
|
||||||
|
SET "password" = '$2b$12$90U31okgkhOwSQD5RDqHwO0QpcC.pkKsqKb1IPnHfKUZm/2A9hzs6',
|
||||||
|
"updatedAt" = NOW()
|
||||||
|
WHERE "id" = 'a0000000-0000-4000-8000-000000000001'
|
||||||
|
AND "email" = 'demo@example.com';
|
||||||
|
|
||||||
|
UPDATE "beenvoice_account"
|
||||||
|
SET "password" = '$2b$12$90U31okgkhOwSQD5RDqHwO0QpcC.pkKsqKb1IPnHfKUZm/2A9hzs6',
|
||||||
|
"updatedAt" = NOW()
|
||||||
|
WHERE "userId" = 'a0000000-0000-4000-8000-000000000001'
|
||||||
|
AND "providerId" = 'credential';
|
||||||
|
|
||||||
|
DELETE FROM "beenvoice_session"
|
||||||
|
WHERE "userId" = 'a0000000-0000-4000-8000-000000000001';
|
||||||
@@ -156,12 +156,54 @@
|
|||||||
"tag": "0021_audit_log",
|
"tag": "0021_audit_log",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"idx": 22,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1782100000000,
|
||||||
|
"tag": "0022_expense_business_receipts",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"idx": 23,
|
"idx": 23,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1782200000000,
|
"when": 1782200000000,
|
||||||
"tag": "0023_invoice_item_time_entry",
|
"tag": "0023_invoice_item_time_entry",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 24,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1783700000000,
|
||||||
|
"tag": "0024_business_logo_upload",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 25,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1783800000000,
|
||||||
|
"tag": "0025_business_hide_name_with_logo",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 26,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1784000000000,
|
||||||
|
"tag": "0026_business_hide_name_with_logo_fix",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 27,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1786740000000,
|
||||||
|
"tag": "0027_disable_public_demo_password",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 28,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1786766968000,
|
||||||
|
"tag": "0028_enable_public_demo_password",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-3
@@ -12,6 +12,7 @@
|
|||||||
"db:push": "drizzle-kit push",
|
"db:push": "drizzle-kit push",
|
||||||
"db:studio": "drizzle-kit studio",
|
"db:studio": "drizzle-kit studio",
|
||||||
"db:clone": "./scripts/clone-local.sh",
|
"db:clone": "./scripts/clone-local.sh",
|
||||||
|
"demo:provision": "bun scripts/provision-demo-account.ts",
|
||||||
"docker:up": "colima start && docker compose -f docker-compose.dev.yml up -d",
|
"docker:up": "colima start && docker compose -f docker-compose.dev.yml up -d",
|
||||||
"docker:down": "docker compose -f docker-compose.dev.yml down && colima stop",
|
"docker:down": "docker compose -f docker-compose.dev.yml down && colima stop",
|
||||||
"docker:dev:down": "docker compose -f docker-compose.dev.yml down && colima stop",
|
"docker:dev:down": "docker compose -f docker-compose.dev.yml down && colima stop",
|
||||||
@@ -75,19 +76,21 @@
|
|||||||
"framer-motion": "^12.40.0",
|
"framer-motion": "^12.40.0",
|
||||||
"fuse.js": "^7.4.2",
|
"fuse.js": "^7.4.2",
|
||||||
"lucide-react": "^0.525.0",
|
"lucide-react": "^0.525.0",
|
||||||
"next": "^16.2.9",
|
"next": "^16.2.12",
|
||||||
"pg": "8.21.0",
|
"pg": "8.21.0",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.8",
|
||||||
"react-colorful": "^5.7.0",
|
"react-colorful": "^5.7.0",
|
||||||
"react-day-picker": "^9.12.0",
|
"react-day-picker": "^9.12.0",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.8",
|
||||||
"react-dropzone": "^14.3.8",
|
"react-dropzone": "^14.3.8",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
"resend": "^4.8.0",
|
"resend": "^4.8.0",
|
||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
|
"sharp": "^0.35.3",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"superjson": "^2.2.6",
|
"superjson": "^2.2.6",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
|
"trpc": "^0.11.3",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import "dotenv/config";
|
||||||
|
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import { Pool } from "pg";
|
||||||
|
|
||||||
|
const DEMO_USER_ID = "a0000000-0000-4000-8000-000000000001";
|
||||||
|
const password = process.env.DEMO_ACCOUNT_PASSWORD?.trim();
|
||||||
|
const databaseUrl = process.env.DATABASE_URL?.trim();
|
||||||
|
|
||||||
|
if (!databaseUrl) {
|
||||||
|
throw new Error("DATABASE_URL is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!password || password.length < 12) {
|
||||||
|
throw new Error("DEMO_ACCOUNT_PASSWORD must be at least 12 characters");
|
||||||
|
}
|
||||||
|
|
||||||
|
const pool = new Pool({ connectionString: databaseUrl, ssl: false });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const passwordHash = await bcrypt.hash(password, 12);
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
const userResult = await client.query(
|
||||||
|
`UPDATE "beenvoice_user"
|
||||||
|
SET "password" = $1, "updatedAt" = NOW()
|
||||||
|
WHERE "id" = $2`,
|
||||||
|
[passwordHash, DEMO_USER_ID],
|
||||||
|
);
|
||||||
|
const accountResult = await client.query(
|
||||||
|
`UPDATE "beenvoice_account"
|
||||||
|
SET "password" = $1, "updatedAt" = NOW()
|
||||||
|
WHERE "userId" = $2 AND "providerId" = 'credential'`,
|
||||||
|
[passwordHash, DEMO_USER_ID],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (userResult.rowCount !== 1 || accountResult.rowCount !== 1) {
|
||||||
|
throw new Error(
|
||||||
|
"Demo account is missing. Apply database migrations before provisioning it.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query(`DELETE FROM "beenvoice_session" WHERE "userId" = $1`, [
|
||||||
|
DEMO_USER_ID,
|
||||||
|
]);
|
||||||
|
await client.query("COMMIT");
|
||||||
|
} catch (error) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
"Demo review account provisioned; previous sessions were invalidated.",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ const drizzleDir = path.resolve(import.meta.dir, "../drizzle");
|
|||||||
const journalPath = path.join(drizzleDir, "meta/_journal.json");
|
const journalPath = path.join(drizzleDir, "meta/_journal.json");
|
||||||
|
|
||||||
const journal = JSON.parse(readFileSync(journalPath, "utf8")) as {
|
const journal = JSON.parse(readFileSync(journalPath, "utf8")) as {
|
||||||
entries: Array<{ idx: number; tag: string }>;
|
entries: Array<{ idx: number; tag: string; when: number }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const sqlTags = readdirSync(drizzleDir)
|
const sqlTags = readdirSync(drizzleDir)
|
||||||
@@ -26,6 +26,27 @@ const idxSequence = journal.entries.map((entry) => entry.idx);
|
|||||||
const expectedIdx = journal.entries.map((_, i) => i);
|
const expectedIdx = journal.entries.map((_, i) => i);
|
||||||
const badIdx = idxSequence.some((idx, i) => idx !== expectedIdx[i]);
|
const badIdx = idxSequence.some((idx, i) => idx !== expectedIdx[i]);
|
||||||
|
|
||||||
|
// drizzle's migrator gates on the single highest `when` already recorded in
|
||||||
|
// the target DB — it doesn't check hashes per-migration. If `when` values
|
||||||
|
// ever go non-increasing (e.g. a migration got deleted/renumbered after
|
||||||
|
// being applied to some environment), later entries can silently be skipped
|
||||||
|
// forever even though drizzle reports success. Keep this strictly increasing.
|
||||||
|
//
|
||||||
|
// 0008 and 0011 are known pre-existing exceptions from before this check
|
||||||
|
// existed (see git log on those files) — both already guard every statement
|
||||||
|
// with IF NOT EXISTS specifically because of this, so a skip is harmless.
|
||||||
|
// Don't add new exceptions here; fix the timestamp instead.
|
||||||
|
const KNOWN_NON_MONOTONIC_TAGS = new Set([
|
||||||
|
"0008_payments_recurring_public_links",
|
||||||
|
"0011_time_entry_invoice_id",
|
||||||
|
]);
|
||||||
|
const nonMonotonic = journal.entries.some(
|
||||||
|
(entry, i) =>
|
||||||
|
i > 0 &&
|
||||||
|
entry.when <= journal.entries[i - 1]!.when &&
|
||||||
|
!KNOWN_NON_MONOTONIC_TAGS.has(entry.tag),
|
||||||
|
);
|
||||||
|
|
||||||
let failed = false;
|
let failed = false;
|
||||||
|
|
||||||
if (missingFromJournal.length > 0) {
|
if (missingFromJournal.length > 0) {
|
||||||
@@ -45,6 +66,16 @@ if (badIdx) {
|
|||||||
failed = true;
|
failed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (nonMonotonic) {
|
||||||
|
console.error(
|
||||||
|
"[verify-drizzle-journal] Journal `when` timestamps are not strictly increasing. " +
|
||||||
|
"drizzle-orm's migrator only compares against the single highest `when` already " +
|
||||||
|
"applied in the target DB, so a lower or equal value here can cause migrations to " +
|
||||||
|
"be silently skipped on databases that already ran a later timestamp.",
|
||||||
|
);
|
||||||
|
failed = true;
|
||||||
|
}
|
||||||
|
|
||||||
if (failed) {
|
if (failed) {
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { getObject } from "~/lib/object-storage";
|
||||||
|
import { db } from "~/server/db";
|
||||||
|
import { businesses } from "~/server/db/schema";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
const RASTERIZABLE_MIME_TYPES = new Set(["image/svg+xml", "image/webp"]);
|
||||||
|
|
||||||
|
// Intentionally unauthenticated: a business logo must be viewable on public,
|
||||||
|
// token-based invoice pages without a session. Business IDs are random
|
||||||
|
// UUIDs, so this only serves images to callers who already know the ID.
|
||||||
|
export async function GET(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ businessId: string }> },
|
||||||
|
) {
|
||||||
|
const { businessId } = await params;
|
||||||
|
const business = await db.query.businesses.findFirst({
|
||||||
|
where: eq(businesses.id, businessId),
|
||||||
|
columns: { logoStorageKey: true, logoMimeType: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!business?.logoStorageKey || !business.logoMimeType) {
|
||||||
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// @react-pdf/renderer's Image component only decodes PNG/JPEG, so PDF
|
||||||
|
// generation requests a rasterized copy of SVG/WebP logos via this param.
|
||||||
|
const wantsPng =
|
||||||
|
new URL(req.url).searchParams.get("format") === "png" &&
|
||||||
|
RASTERIZABLE_MIME_TYPES.has(business.logoMimeType);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await getObject(business.logoStorageKey);
|
||||||
|
|
||||||
|
if (wantsPng) {
|
||||||
|
const { default: sharp } = await import("sharp");
|
||||||
|
const isSvg = business.logoMimeType === "image/svg+xml";
|
||||||
|
// SVG is vector: rasterize at a high density so the PNG stays crisp at
|
||||||
|
// the size it's actually displayed (PDF header, up to ~2.2in wide).
|
||||||
|
// withoutEnlargement only makes sense for the WebP (already-raster)
|
||||||
|
// case — for SVG it would cap us at whatever tiny canvas the source's
|
||||||
|
// intrinsic viewBox implies, even though the vector has no such limit.
|
||||||
|
const png = await sharp(body, isSvg ? { density: 600 } : undefined)
|
||||||
|
.resize({
|
||||||
|
width: 1024,
|
||||||
|
height: 1024,
|
||||||
|
fit: "inside",
|
||||||
|
withoutEnlargement: !isSvg,
|
||||||
|
})
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
return new NextResponse(new Uint8Array(png), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "image/png",
|
||||||
|
"Cache-Control": "public, max-age=300, must-revalidate",
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new NextResponse(new Uint8Array(body), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": business.logoMimeType,
|
||||||
|
"Cache-Control": "public, max-age=300, must-revalidate",
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[business-logo] Failed to serve logo", {
|
||||||
|
backendError: error,
|
||||||
|
businessId,
|
||||||
|
wantsPng,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ error: "Logo not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import { generateInvoicePDFBlob } from "~/lib/pdf-export";
|
|||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_request: Request,
|
request: Request,
|
||||||
{ params }: { params: Promise<{ token: string }> },
|
{ params }: { params: Promise<{ token: string }> },
|
||||||
) {
|
) {
|
||||||
const { token } = await params;
|
const { token } = await params;
|
||||||
@@ -16,7 +16,28 @@ export async function GET(
|
|||||||
where: eq(invoices.publicToken, token),
|
where: eq(invoices.publicToken, token),
|
||||||
with: {
|
with: {
|
||||||
client: true,
|
client: true,
|
||||||
business: true,
|
// Explicit allowlist: token-based public route — never fetch
|
||||||
|
// secret fields (resendApiKey, resendDomain) for an unauthenticated request.
|
||||||
|
business: {
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
nickname: true,
|
||||||
|
email: true,
|
||||||
|
phone: true,
|
||||||
|
addressLine1: true,
|
||||||
|
addressLine2: true,
|
||||||
|
city: true,
|
||||||
|
state: true,
|
||||||
|
postalCode: true,
|
||||||
|
country: true,
|
||||||
|
website: true,
|
||||||
|
taxId: true,
|
||||||
|
logoStorageKey: true,
|
||||||
|
logoMimeType: true,
|
||||||
|
hideNameWithLogo: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
items: {
|
items: {
|
||||||
orderBy: (i, { asc }) => [
|
orderBy: (i, { asc }) => [
|
||||||
asc(i.date),
|
asc(i.date),
|
||||||
@@ -39,19 +60,27 @@ export async function GET(
|
|||||||
where: eq(platformSettings.id, "global"),
|
where: eq(platformSettings.id, "global"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const pdfBlob = await generateInvoicePDFBlob(invoice, {
|
const pdfBlob = await generateInvoicePDFBlob(
|
||||||
pdfTemplate: settings?.pdfTemplate as "classic" | "minimal" | undefined,
|
invoice,
|
||||||
pdfAccentColor: settings?.pdfAccentColor,
|
{
|
||||||
pdfFontFamily: settings?.pdfFontFamily as "sans" | "serif" | "mono" | undefined,
|
pdfTemplate: settings?.pdfTemplate as "classic" | "minimal" | undefined,
|
||||||
pdfNumericFontFamily: settings?.pdfNumericFontFamily as
|
pdfAccentColor: settings?.pdfAccentColor,
|
||||||
| "sans"
|
pdfFontFamily: settings?.pdfFontFamily as
|
||||||
| "serif"
|
| "sans"
|
||||||
| "mono"
|
| "serif"
|
||||||
| undefined,
|
| "mono"
|
||||||
pdfFooterText: settings?.pdfFooterText,
|
| undefined,
|
||||||
pdfShowLogo: settings?.pdfShowLogo,
|
pdfNumericFontFamily: settings?.pdfNumericFontFamily as
|
||||||
pdfShowPageNumbers: settings?.pdfShowPageNumbers,
|
| "sans"
|
||||||
});
|
| "serif"
|
||||||
|
| "mono"
|
||||||
|
| undefined,
|
||||||
|
pdfFooterText: settings?.pdfFooterText,
|
||||||
|
pdfShowLogo: settings?.pdfShowLogo,
|
||||||
|
pdfShowPageNumbers: settings?.pdfShowPageNumbers,
|
||||||
|
},
|
||||||
|
{ logoBaseUrl: new URL(request.url).origin },
|
||||||
|
);
|
||||||
|
|
||||||
const buffer = await pdfBlob.arrayBuffer();
|
const buffer = await pdfBlob.arrayBuffer();
|
||||||
const filename = `invoice-${invoice.invoiceNumber}.pdf`;
|
const filename = `invoice-${invoice.invoiceNumber}.pdf`;
|
||||||
|
|||||||
@@ -74,9 +74,20 @@ export default async function BusinessDetailPage({
|
|||||||
<Card className="bg-card border-border border">
|
<Card className="bg-card border-border border">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<div className="bg-primary/10 p-2">
|
{business.logoStorageKey ? (
|
||||||
<Building className="text-primary h-5 w-5" />
|
<div className="bg-muted border-border/40 flex h-9 max-w-32 shrink-0 items-center justify-center overflow-hidden border px-1.5 py-1">
|
||||||
</div>
|
{/* eslint-disable-next-line @next/next/no-img-element -- object-storage-backed image, not a static asset */}
|
||||||
|
<img
|
||||||
|
src={`/api/business-logo/${business.id}`}
|
||||||
|
alt={`${business.name} logo`}
|
||||||
|
className="h-full w-auto max-w-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-primary/10 p-2">
|
||||||
|
<Building className="text-primary h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<span>Business Information</span>
|
<span>Business Information</span>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ interface Business {
|
|||||||
website: string | null;
|
website: string | null;
|
||||||
taxId: string | null;
|
taxId: string | null;
|
||||||
logoUrl: string | null;
|
logoUrl: string | null;
|
||||||
|
logoStorageKey: string | null;
|
||||||
createdById: string;
|
createdById: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date | null;
|
updatedAt: Date | null;
|
||||||
@@ -86,8 +87,17 @@ export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
|
|||||||
const business = row.original;
|
const business = row.original;
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="bg-primary/10 hidden p-2 sm:flex">
|
<div className="bg-primary/10 hidden h-8 w-8 shrink-0 items-center justify-center overflow-hidden p-2 sm:flex">
|
||||||
<Building className="text-primary h-4 w-4" />
|
{business.logoStorageKey ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element -- object-storage-backed image, not a static asset
|
||||||
|
<img
|
||||||
|
src={`/api/business-logo/${business.id}`}
|
||||||
|
alt=""
|
||||||
|
className="h-full w-full object-contain"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Building className="text-primary h-4 w-4" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate font-medium">{business.name}</p>
|
<p className="truncate font-medium">{business.name}</p>
|
||||||
|
|||||||
@@ -377,6 +377,16 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
|
{invoice.business.logoStorageKey && (
|
||||||
|
<div className="bg-muted border-border/40 flex h-12 max-w-40 w-fit items-center justify-center overflow-hidden border px-2 py-1.5">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element -- object-storage-backed image, not a static asset */}
|
||||||
|
<img
|
||||||
|
src={`/api/business-logo/${invoice.business.id}`}
|
||||||
|
alt={`${invoice.business.name} logo`}
|
||||||
|
className="h-full w-auto max-w-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<h3 className="text-foreground text-xl font-semibold">
|
<h3 className="text-foreground text-xl font-semibold">
|
||||||
{invoice.business.name}
|
{invoice.business.name}
|
||||||
</h3>
|
</h3>
|
||||||
|
|||||||
@@ -204,9 +204,12 @@ export default function SendEmailPage() {
|
|||||||
: undefined,
|
: undefined,
|
||||||
business: invoiceData.business
|
business: invoiceData.business
|
||||||
? {
|
? {
|
||||||
|
id: invoiceData.business.id,
|
||||||
name: invoiceData.business.name,
|
name: invoiceData.business.name,
|
||||||
nickname: invoiceData.business.nickname,
|
nickname: invoiceData.business.nickname,
|
||||||
email: invoiceData.business.email,
|
email: invoiceData.business.email,
|
||||||
|
logoStorageKey: invoiceData.business.logoStorageKey,
|
||||||
|
logoMimeType: invoiceData.business.logoMimeType,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
items: invoiceData.items?.map((item) => ({
|
items: invoiceData.items?.map((item) => ({
|
||||||
|
|||||||
@@ -162,8 +162,11 @@ export function SettingsContent({
|
|||||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||||
const [isLinking, setIsLinking] = useState(false);
|
const [isLinking, setIsLinking] = useState(false);
|
||||||
const authentikEnabled = env.NEXT_PUBLIC_AUTHENTIK_ENABLED === true;
|
const authentikEnabled = env.NEXT_PUBLIC_AUTHENTIK_ENABLED === true;
|
||||||
const { colorMode, updateAppearance, isUpdating: appearanceUpdating } =
|
const {
|
||||||
useAppearance();
|
colorMode,
|
||||||
|
updateAppearance,
|
||||||
|
isUpdating: appearanceUpdating,
|
||||||
|
} = useAppearance();
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const { data: pdfSettings } = api.settings.getPdfSettings.useQuery();
|
const { data: pdfSettings } = api.settings.getPdfSettings.useQuery();
|
||||||
const updatePdfSettingsMutation = api.settings.updatePdfSettings.useMutation({
|
const updatePdfSettingsMutation = api.settings.updatePdfSettings.useMutation({
|
||||||
@@ -221,8 +224,11 @@ export function SettingsContent({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Queries
|
// Queries
|
||||||
const { data: profile, refetch: refetchProfile, isFetched: profileFetched } =
|
const {
|
||||||
api.settings.getProfile.useQuery();
|
data: profile,
|
||||||
|
refetch: refetchProfile,
|
||||||
|
isFetched: profileFetched,
|
||||||
|
} = api.settings.getProfile.useQuery();
|
||||||
const isAdmin = profile?.role === "admin";
|
const isAdmin = profile?.role === "admin";
|
||||||
const { data: dataStats } = api.settings.getDataStats.useQuery();
|
const { data: dataStats } = api.settings.getDataStats.useQuery();
|
||||||
|
|
||||||
@@ -285,10 +291,13 @@ export function SettingsContent({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const deleteDataMutation = api.settings.deleteAllData.useMutation({
|
const deleteAccountMutation = api.settings.deleteAccount.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: async () => {
|
||||||
toast.success("All data has been permanently deleted");
|
toast.success("Your account and data have been permanently deleted");
|
||||||
setDeleteConfirmText("");
|
setDeleteConfirmText("");
|
||||||
|
await authClient.signOut().catch(() => undefined);
|
||||||
|
router.replace("/login");
|
||||||
|
router.refresh();
|
||||||
},
|
},
|
||||||
onError: (error: { message: string }) => {
|
onError: (error: { message: string }) => {
|
||||||
toast.error(`Delete failed: ${error.message}`);
|
toast.error(`Delete failed: ${error.message}`);
|
||||||
@@ -401,12 +410,12 @@ export function SettingsContent({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteAllData = () => {
|
const handleDeleteAccount = () => {
|
||||||
if (deleteConfirmText !== "delete all my data") {
|
if (deleteConfirmText !== "DELETE MY ACCOUNT") {
|
||||||
toast.error("Please type 'delete all my data' to confirm");
|
toast.error("Please type 'DELETE MY ACCOUNT' to confirm");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
deleteDataMutation.mutate({ confirmText: deleteConfirmText });
|
deleteAccountMutation.mutate({ confirmText: deleteConfirmText });
|
||||||
};
|
};
|
||||||
|
|
||||||
// Set initial name value once when profile loads
|
// Set initial name value once when profile loads
|
||||||
@@ -706,10 +715,7 @@ export function SettingsContent({
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{colorModes.map((modeOption) => (
|
{colorModes.map((modeOption) => (
|
||||||
<SelectItem
|
<SelectItem key={modeOption.value} value={modeOption.value}>
|
||||||
key={modeOption.value}
|
|
||||||
value={modeOption.value}
|
|
||||||
>
|
|
||||||
{modeOption.label}
|
{modeOption.label}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
@@ -1283,37 +1289,43 @@ export function SettingsContent({
|
|||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button variant="destructive" className="w-full sm:w-auto">
|
<Button variant="destructive" className="w-full sm:w-auto">
|
||||||
Delete All Data
|
Delete Account
|
||||||
</Button>
|
</Button>
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
This action cannot be undone. This will permanently delete
|
This action cannot be undone. It permanently deletes your
|
||||||
your account and remove your data from our servers.
|
account, invoices, clients, businesses, expenses, time
|
||||||
|
entries, uploaded files, and sign-in data from our servers.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<div className="my-4 space-y-2">
|
<div className="my-4 space-y-2">
|
||||||
<Label htmlFor="confirm-delete">
|
<Label htmlFor="confirm-delete">
|
||||||
Type <span className="font-bold">delete all my data</span>{" "}
|
Type <span className="font-bold">DELETE MY ACCOUNT</span> to
|
||||||
to confirm
|
confirm
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="confirm-delete"
|
id="confirm-delete"
|
||||||
value={deleteConfirmText}
|
value={deleteConfirmText}
|
||||||
onChange={(e) => setDeleteConfirmText(e.target.value)}
|
onChange={(e) => setDeleteConfirmText(e.target.value)}
|
||||||
placeholder="delete all my data"
|
placeholder="DELETE MY ACCOUNT"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
onClick={handleDeleteAllData}
|
onClick={handleDeleteAccount}
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
disabled={deleteConfirmText !== "delete all my data"}
|
disabled={
|
||||||
|
deleteConfirmText !== "DELETE MY ACCOUNT" ||
|
||||||
|
deleteAccountMutation.isPending
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Delete Account
|
{deleteAccountMutation.isPending
|
||||||
|
? "Deleting…"
|
||||||
|
: "Delete Account"}
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
|
|||||||
@@ -92,6 +92,8 @@ function PublicInvoiceView({ token }: { token: string }) {
|
|||||||
? `${invoice.business.name} (${invoice.business.nickname})`
|
? `${invoice.business.name} (${invoice.business.nickname})`
|
||||||
: invoice.business.name
|
: invoice.business.name
|
||||||
: null;
|
: null;
|
||||||
|
const hasLogo = Boolean(invoice.business?.logoStorageKey);
|
||||||
|
const hideName = hasLogo && Boolean(invoice.business?.hideNameWithLogo);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 py-10 px-4">
|
<div className="min-h-screen bg-gray-50 py-10 px-4">
|
||||||
@@ -99,11 +101,25 @@ function PublicInvoiceView({ token }: { token: string }) {
|
|||||||
{/* Card */}
|
{/* Card */}
|
||||||
<div className="overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm">
|
<div className="overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="bg-gray-900 px-8 py-6">
|
<div className="flex items-center gap-3 bg-gray-900 px-8 py-6">
|
||||||
<p className="text-lg font-bold text-white">{senderName ?? "Invoice"}</p>
|
{hasLogo && (
|
||||||
{invoice.business?.email && (
|
// Uploaded SVGs are sanitized and served by our route. next/image's
|
||||||
<p className="mt-0.5 text-sm text-gray-400">{invoice.business.email}</p>
|
// optimizer intentionally rejects SVG, so a native img is required.
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={`/api/business-logo/${invoice.business!.id}`}
|
||||||
|
alt=""
|
||||||
|
className="h-16 w-auto max-w-[220px] shrink-0 rounded bg-white object-contain px-2 py-1.5"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
<div className="min-w-0">
|
||||||
|
{!hideName && (
|
||||||
|
<p className="truncate text-lg font-bold text-white">{senderName ?? "Invoice"}</p>
|
||||||
|
)}
|
||||||
|
{invoice.business?.email && (
|
||||||
|
<p className="mt-0.5 truncate text-sm text-gray-400">{invoice.business.email}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
|
|||||||
+2
-14
@@ -8,6 +8,7 @@ import { getAppUrl } from "~/lib/app-url";
|
|||||||
import { brand } from "~/lib/branding";
|
import { brand } from "~/lib/branding";
|
||||||
|
|
||||||
import { UmamiScript } from "~/components/analytics/umami-script";
|
import { UmamiScript } from "~/components/analytics/umami-script";
|
||||||
|
import { AppearanceInitScript } from "~/components/layout/appearance-init-script";
|
||||||
import { BrandBackground } from "~/components/layout/brand-background";
|
import { BrandBackground } from "~/components/layout/brand-background";
|
||||||
|
|
||||||
const siteTitle = `${brand.name} - Invoicing Made Simple`;
|
const siteTitle = `${brand.name} - Invoicing Made Simple`;
|
||||||
@@ -63,20 +64,7 @@ export default function RootLayout({
|
|||||||
className={`${geistSans.variable} ${playfair.variable} ${geistMono.variable}`}
|
className={`${geistSans.variable} ${playfair.variable} ${geistMono.variable}`}
|
||||||
>
|
>
|
||||||
<head>
|
<head>
|
||||||
<script
|
<AppearanceInitScript />
|
||||||
id="appearance-init"
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: `
|
|
||||||
try {
|
|
||||||
var stored = JSON.parse(localStorage.getItem("bv.appearance") || "{}");
|
|
||||||
var colorMode = stored.colorMode || "system";
|
|
||||||
var root = document.documentElement;
|
|
||||||
root.dataset.colorMode = colorMode;
|
|
||||||
if (colorMode === "dark") root.classList.add("dark");
|
|
||||||
} catch {}
|
|
||||||
`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</head>
|
</head>
|
||||||
<body className="bg-background text-foreground relative min-h-screen overflow-x-hidden font-sans antialiased">
|
<body className="bg-background text-foreground relative min-h-screen overflow-x-hidden font-sans antialiased">
|
||||||
<BrandBackground />
|
<BrandBackground />
|
||||||
|
|||||||
@@ -7,12 +7,15 @@ import {
|
|||||||
EyeOff,
|
EyeOff,
|
||||||
FileText,
|
FileText,
|
||||||
Globe,
|
Globe,
|
||||||
|
ImageIcon,
|
||||||
Info,
|
Info,
|
||||||
Key,
|
Key,
|
||||||
Loader2,
|
Loader2,
|
||||||
Mail,
|
Mail,
|
||||||
Save,
|
Save,
|
||||||
Star,
|
Star,
|
||||||
|
Trash2,
|
||||||
|
Upload,
|
||||||
User,
|
User,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
@@ -59,6 +62,7 @@ interface FormData {
|
|||||||
country: string;
|
country: string;
|
||||||
website: string;
|
website: string;
|
||||||
taxId: string;
|
taxId: string;
|
||||||
|
hideNameWithLogo: boolean;
|
||||||
isDefault: boolean;
|
isDefault: boolean;
|
||||||
resendApiKey: string;
|
resendApiKey: string;
|
||||||
resendDomain: string;
|
resendDomain: string;
|
||||||
@@ -95,20 +99,31 @@ const initialFormData: FormData = {
|
|||||||
country: "United States",
|
country: "United States",
|
||||||
website: "",
|
website: "",
|
||||||
taxId: "",
|
taxId: "",
|
||||||
|
hideNameWithLogo: false,
|
||||||
isDefault: false,
|
isDefault: false,
|
||||||
resendApiKey: "",
|
resendApiKey: "",
|
||||||
resendDomain: "",
|
resendDomain: "",
|
||||||
emailFromName: "",
|
emailFromName: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const MAX_LOGO_BYTES = 5 * 1024 * 1024;
|
||||||
|
const ACCEPTED_LOGO_TYPES = new Set([
|
||||||
|
"image/png",
|
||||||
|
"image/jpeg",
|
||||||
|
"image/webp",
|
||||||
|
"image/svg+xml",
|
||||||
|
]);
|
||||||
|
|
||||||
export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const utils = api.useUtils();
|
||||||
const [formData, setFormData] = useState<FormData>(initialFormData);
|
const [formData, setFormData] = useState<FormData>(initialFormData);
|
||||||
const [errors, setErrors] = useState<FormErrors>({});
|
const [errors, setErrors] = useState<FormErrors>({});
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [showApiKey, setShowApiKey] = useState(false);
|
const [showApiKey, setShowApiKey] = useState(false);
|
||||||
const [isDirty, setIsDirty] = useState(false);
|
const [isDirty, setIsDirty] = useState(false);
|
||||||
const [initialized, setInitialized] = useState(false);
|
const [initialized, setInitialized] = useState(false);
|
||||||
|
const [isUploadingLogo, setIsUploadingLogo] = useState(false);
|
||||||
|
|
||||||
// Fetch business data if editing
|
// Fetch business data if editing
|
||||||
const { data: business, isLoading: isLoadingBusiness } =
|
const { data: business, isLoading: isLoadingBusiness } =
|
||||||
@@ -149,6 +164,62 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const uploadLogo = api.businesses.uploadLogo.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
await utils.businesses.getById.invalidate({ id: businessId });
|
||||||
|
toast.success("Logo updated");
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error.message || "Failed to upload logo");
|
||||||
|
},
|
||||||
|
onSettled: () => setIsUploadingLogo(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeLogo = api.businesses.removeLogo.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
await utils.businesses.getById.invalidate({ id: businessId });
|
||||||
|
toast.success("Logo removed");
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error.message || "Failed to remove logo");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleLogoFileSelected = async (
|
||||||
|
e: React.ChangeEvent<HTMLInputElement>,
|
||||||
|
) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
e.target.value = "";
|
||||||
|
if (!file || !businessId) return;
|
||||||
|
|
||||||
|
if (!ACCEPTED_LOGO_TYPES.has(file.type)) {
|
||||||
|
toast.error("Logo must be a PNG, JPEG, WebP, or SVG image");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.size > MAX_LOGO_BYTES) {
|
||||||
|
toast.error("Logo must be 5MB or less");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsUploadingLogo(true);
|
||||||
|
const data = await new Promise<string>((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
const result = reader.result;
|
||||||
|
resolve(typeof result === "string" ? (result.split(",")[1] ?? "") : "");
|
||||||
|
};
|
||||||
|
reader.onerror = reject;
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
uploadLogo.mutate({
|
||||||
|
id: businessId,
|
||||||
|
filename: file.name,
|
||||||
|
mimeType: file.type,
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- Reset form when navigating to a different business.
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- Reset form when navigating to a different business.
|
||||||
setInitialized(false);
|
setInitialized(false);
|
||||||
@@ -178,6 +249,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
|||||||
country: business.country ?? "United States",
|
country: business.country ?? "United States",
|
||||||
website: business.website ?? "",
|
website: business.website ?? "",
|
||||||
taxId: business.taxId ?? "",
|
taxId: business.taxId ?? "",
|
||||||
|
hideNameWithLogo: business.hideNameWithLogo ?? false,
|
||||||
isDefault: business.isDefault ?? false,
|
isDefault: business.isDefault ?? false,
|
||||||
resendApiKey: "", // Never pre-fill API key for security
|
resendApiKey: "", // Never pre-fill API key for security
|
||||||
resendDomain: emailConfig?.resendDomain ?? "",
|
resendDomain: emailConfig?.resendDomain ?? "",
|
||||||
@@ -338,6 +410,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
|||||||
country: dataToSubmit.country,
|
country: dataToSubmit.country,
|
||||||
website: dataToSubmit.website,
|
website: dataToSubmit.website,
|
||||||
taxId: dataToSubmit.taxId,
|
taxId: dataToSubmit.taxId,
|
||||||
|
hideNameWithLogo: dataToSubmit.hideNameWithLogo,
|
||||||
isDefault: dataToSubmit.isDefault,
|
isDefault: dataToSubmit.isDefault,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -376,6 +449,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
|||||||
country: dataToSubmit.country,
|
country: dataToSubmit.country,
|
||||||
website: dataToSubmit.website,
|
website: dataToSubmit.website,
|
||||||
taxId: dataToSubmit.taxId,
|
taxId: dataToSubmit.taxId,
|
||||||
|
hideNameWithLogo: dataToSubmit.hideNameWithLogo,
|
||||||
isDefault: dataToSubmit.isDefault,
|
isDefault: dataToSubmit.isDefault,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -649,6 +723,110 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Logo */}
|
||||||
|
{mode === "edit" && businessId && (
|
||||||
|
<Card className="bg-card border-border border">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="bg-muted flex h-10 w-10 items-center justify-center">
|
||||||
|
<ImageIcon className="text-muted-foreground h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle>Logo</CardTitle>
|
||||||
|
<p className="text-muted-foreground mt-1 text-sm">
|
||||||
|
Shown on invoices sent to your clients. PNG, JPEG,
|
||||||
|
WebP, or SVG, up to 5MB.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="bg-muted border-border/40 flex h-20 min-w-20 max-w-[240px] shrink-0 items-center justify-center overflow-hidden border px-2">
|
||||||
|
{business?.logoStorageKey ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element -- external/object-storage-backed image, not a static asset
|
||||||
|
<img
|
||||||
|
src={`/api/business-logo/${businessId}?v=${business.updatedAt ? new Date(business.updatedAt).getTime() : 0}`}
|
||||||
|
alt={`${business.name} logo`}
|
||||||
|
className="h-full w-auto max-w-full object-contain"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ImageIcon className="text-muted-foreground/50 h-8 w-8" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={isUploadingLogo}
|
||||||
|
onClick={() =>
|
||||||
|
document.getElementById("logo-upload-input")?.click()
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isUploadingLogo ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin sm:mr-2" />
|
||||||
|
) : (
|
||||||
|
<Upload className="h-4 w-4 sm:mr-2" />
|
||||||
|
)}
|
||||||
|
<span className="hidden sm:inline">
|
||||||
|
{business?.logoStorageKey
|
||||||
|
? "Replace logo"
|
||||||
|
: "Upload logo"}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
{business?.logoStorageKey && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={removeLogo.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
businessId && removeLogo.mutate({ id: businessId })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 sm:mr-2" />
|
||||||
|
<span className="hidden sm:inline">Remove</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
id="logo-upload-input"
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/webp,image/svg+xml"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleLogoFileSelected}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{business?.logoStorageKey && (
|
||||||
|
<div className="bg-muted border-border/40 mt-4 flex items-center justify-between border p-4">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label
|
||||||
|
htmlFor="hideNameWithLogo"
|
||||||
|
className="text-base font-medium"
|
||||||
|
>
|
||||||
|
Hide business name on invoices
|
||||||
|
</Label>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Show only the logo in the invoice header — useful
|
||||||
|
if your logo already includes your business name.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="hideNameWithLogo"
|
||||||
|
checked={formData.hideNameWithLogo}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
handleInputChange("hideNameWithLogo", checked)
|
||||||
|
}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Address */}
|
{/* Address */}
|
||||||
<Card className="bg-card border-border border">
|
<Card className="bg-card border-border border">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
@@ -25,8 +25,11 @@ interface EmailPreviewProps {
|
|||||||
email: string | null;
|
email: string | null;
|
||||||
};
|
};
|
||||||
business?: {
|
business?: {
|
||||||
|
id?: string;
|
||||||
name: string;
|
name: string;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
|
logoStorageKey?: string | null;
|
||||||
|
logoMimeType?: string | null;
|
||||||
};
|
};
|
||||||
items?: Array<{
|
items?: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// Sets data-color-mode / .dark on <html> from localStorage before paint, to
|
||||||
|
// avoid a flash of the wrong theme. Rendered only during SSR (typeof window
|
||||||
|
// check) and returns null on the client, so the <script> element never
|
||||||
|
// enters the tree React reconciles during hydration — React 19 otherwise
|
||||||
|
// warns "Encountered a script tag while rendering React component" for any
|
||||||
|
// <script> it walks while hydrating, even one from next/script. Same fix
|
||||||
|
// next-themes ships for its inline ThemeScript (shadcn-ui/ui#10238).
|
||||||
|
const APPEARANCE_INIT_SOURCE = `
|
||||||
|
try {
|
||||||
|
var stored = JSON.parse(localStorage.getItem("bv.appearance") || "{}");
|
||||||
|
var colorMode = stored.colorMode || "system";
|
||||||
|
var root = document.documentElement;
|
||||||
|
root.dataset.colorMode = colorMode;
|
||||||
|
if (colorMode === "dark") root.classList.add("dark");
|
||||||
|
} catch {}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export function AppearanceInitScript() {
|
||||||
|
if (typeof window !== "undefined") return null;
|
||||||
|
return (
|
||||||
|
<script
|
||||||
|
id="appearance-init"
|
||||||
|
suppressHydrationWarning
|
||||||
|
dangerouslySetInnerHTML={{ __html: APPEARANCE_INIT_SOURCE }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,4 @@
|
|||||||
import {
|
import { LEGAL_PRIVACY_EMAIL, LEGAL_WEBSITE } from "~/lib/legal";
|
||||||
LEGAL_PRIVACY_EMAIL,
|
|
||||||
LEGAL_WEBSITE,
|
|
||||||
} from "~/lib/legal";
|
|
||||||
import { brand } from "~/lib/branding";
|
import { brand } from "~/lib/branding";
|
||||||
import {
|
import {
|
||||||
LegalDocument,
|
LegalDocument,
|
||||||
@@ -16,9 +13,9 @@ const sections: LegalSection[] = [
|
|||||||
children: (
|
children: (
|
||||||
<>
|
<>
|
||||||
<LegalParagraph>
|
<LegalParagraph>
|
||||||
This Privacy Policy explains how {brand.name} collects, uses, and protects
|
This Privacy Policy explains how {brand.name} collects, uses, and
|
||||||
information when you use our invoicing platform, including the web app and mobile
|
protects information when you use our invoicing platform, including
|
||||||
app (the “Service”).
|
the web app and mobile app (the “Service”).
|
||||||
</LegalParagraph>
|
</LegalParagraph>
|
||||||
<LegalParagraph>
|
<LegalParagraph>
|
||||||
If you have questions about this policy, email us at{" "}
|
If you have questions about this policy, email us at{" "}
|
||||||
@@ -33,19 +30,21 @@ const sections: LegalSection[] = [
|
|||||||
children: (
|
children: (
|
||||||
<>
|
<>
|
||||||
<LegalParagraph>
|
<LegalParagraph>
|
||||||
When you create an account and use the Service, you provide information such as
|
When you create an account and use the Service, you provide
|
||||||
your name, email address, business details, client records, invoice content, and
|
information such as your name, email address, business details, client
|
||||||
time entries. This is the data you enter to run your invoicing workflow.
|
records, invoice content, and time entries. This is the data you enter
|
||||||
|
to run your invoicing workflow.
|
||||||
</LegalParagraph>
|
</LegalParagraph>
|
||||||
<LegalParagraph>
|
<LegalParagraph>
|
||||||
You may also add payment instructions that appear on invoices, such as bank
|
You may also add payment instructions that appear on invoices, such as
|
||||||
transfer details. We do not process card payments on your behalf.
|
bank transfer details. We do not process card payments on your behalf.
|
||||||
</LegalParagraph>
|
</LegalParagraph>
|
||||||
<LegalParagraph>
|
<LegalParagraph>
|
||||||
We also collect some technical information automatically so the Service stays
|
We also collect some technical information automatically so the
|
||||||
secure and reliable. This can include your IP address, device and browser or app
|
Service stays secure and reliable. This can include your IP address,
|
||||||
details, log and diagnostic data, and session cookies that keep you signed in.
|
device and browser or app details, log and diagnostic data, and
|
||||||
Some deployments may use optional, privacy-focused analytics.
|
session cookies that keep you signed in. Some deployments may use
|
||||||
|
optional, privacy-focused analytics.
|
||||||
</LegalParagraph>
|
</LegalParagraph>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
@@ -56,9 +55,10 @@ const sections: LegalSection[] = [
|
|||||||
children: (
|
children: (
|
||||||
<>
|
<>
|
||||||
<LegalParagraph>
|
<LegalParagraph>
|
||||||
We use your information to provide and operate the Service, authenticate your
|
We use your information to provide and operate the Service,
|
||||||
account, send transactional messages such as password resets, respond to support
|
authenticate your account, send transactional messages such as
|
||||||
requests, monitor security and performance, and meet legal obligations.
|
password resets, respond to support requests, monitor security and
|
||||||
|
performance, and meet legal obligations.
|
||||||
</LegalParagraph>
|
</LegalParagraph>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
@@ -69,24 +69,24 @@ const sections: LegalSection[] = [
|
|||||||
children: (
|
children: (
|
||||||
<>
|
<>
|
||||||
<LegalParagraph>
|
<LegalParagraph>
|
||||||
We do not sell your personal information. We share it only when needed to run the
|
We do not sell your personal information. We share it only when needed
|
||||||
Service or when the law requires it.
|
to run the Service or when the law requires it.
|
||||||
</LegalParagraph>
|
</LegalParagraph>
|
||||||
<LegalParagraph>
|
<LegalParagraph>
|
||||||
We work with service providers that host our infrastructure, deliver transactional
|
We work with service providers that host our infrastructure, deliver
|
||||||
email, support single sign-on when enabled on your instance, and optionally provide
|
transactional email, support single sign-on when enabled on your
|
||||||
privacy-focused analytics. These vendors may process your information only to
|
instance, and optionally provide privacy-focused analytics. These
|
||||||
perform services for us.
|
vendors may process your information only to perform services for us.
|
||||||
</LegalParagraph>
|
</LegalParagraph>
|
||||||
<LegalParagraph>
|
<LegalParagraph>
|
||||||
We may disclose information if we believe it is reasonably necessary to comply with
|
We may disclose information if we believe it is reasonably necessary
|
||||||
law, respond to a valid legal request, or protect the security and integrity of the
|
to comply with law, respond to a valid legal request, or protect the
|
||||||
Service.
|
security and integrity of the Service.
|
||||||
</LegalParagraph>
|
</LegalParagraph>
|
||||||
<LegalParagraph>
|
<LegalParagraph>
|
||||||
If we are involved in a merger, acquisition, or sale of assets, your information
|
If we are involved in a merger, acquisition, or sale of assets, your
|
||||||
may be transferred as part of that transaction, subject to continued protection
|
information may be transferred as part of that transaction, subject to
|
||||||
consistent with this policy.
|
continued protection consistent with this policy.
|
||||||
</LegalParagraph>
|
</LegalParagraph>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
@@ -97,14 +97,25 @@ const sections: LegalSection[] = [
|
|||||||
children: (
|
children: (
|
||||||
<>
|
<>
|
||||||
<LegalParagraph>
|
<LegalParagraph>
|
||||||
We use reasonable safeguards to protect information, including encryption in
|
We use reasonable safeguards to protect information, including
|
||||||
transit, access controls, and secure authentication. No method of transmission or
|
encryption in transit, access controls, and secure authentication. No
|
||||||
storage is completely secure.
|
method of transmission or storage is completely secure.
|
||||||
</LegalParagraph>
|
</LegalParagraph>
|
||||||
<LegalParagraph>
|
<LegalParagraph>
|
||||||
We retain information for as long as you have an account or as needed to provide
|
We retain information for as long as you have an account or as needed
|
||||||
the Service. We may keep certain records longer when required by law or for
|
to provide the Service. We may keep certain records longer when
|
||||||
legitimate purposes such as fraud prevention or dispute resolution.
|
required by law or for legitimate purposes such as fraud prevention or
|
||||||
|
dispute resolution.
|
||||||
|
</LegalParagraph>
|
||||||
|
<LegalParagraph>
|
||||||
|
You can permanently delete your account from Settings in the mobile
|
||||||
|
app or web app. Account deletion removes your account record and
|
||||||
|
associated Service data, including invoices, clients, businesses,
|
||||||
|
expenses, time entries, uploaded files, access keys, and active
|
||||||
|
sessions. The action cannot be undone. Limited information may be
|
||||||
|
retained only when required by law, and residual copies may remain in
|
||||||
|
secure backups until those backups are overwritten through our normal
|
||||||
|
retention cycle.
|
||||||
</LegalParagraph>
|
</LegalParagraph>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
@@ -115,13 +126,16 @@ const sections: LegalSection[] = [
|
|||||||
children: (
|
children: (
|
||||||
<>
|
<>
|
||||||
<LegalParagraph>
|
<LegalParagraph>
|
||||||
Depending on where you live, you may have the right to access, correct, delete, or
|
Depending on where you live, you may have the right to access,
|
||||||
export your personal information, or to object to or restrict certain processing.
|
correct, delete, or export your personal information, or to object to
|
||||||
|
or restrict certain processing.
|
||||||
</LegalParagraph>
|
</LegalParagraph>
|
||||||
<LegalParagraph>
|
<LegalParagraph>
|
||||||
To exercise these rights, contact us at{" "}
|
You can delete your account directly in Settings. To exercise another
|
||||||
<a href={`mailto:${LEGAL_PRIVACY_EMAIL}`}>{LEGAL_PRIVACY_EMAIL}</a>. We will
|
privacy right, or if you cannot access your account, contact us at{" "}
|
||||||
respond within a reasonable timeframe and as required by applicable law.
|
<a href={`mailto:${LEGAL_PRIVACY_EMAIL}`}>{LEGAL_PRIVACY_EMAIL}</a>.
|
||||||
|
We will respond within a reasonable timeframe and as required by
|
||||||
|
applicable law.
|
||||||
</LegalParagraph>
|
</LegalParagraph>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
@@ -132,13 +146,13 @@ const sections: LegalSection[] = [
|
|||||||
children: (
|
children: (
|
||||||
<>
|
<>
|
||||||
<LegalParagraph>
|
<LegalParagraph>
|
||||||
We use cookies and similar technologies to keep you signed in, remember
|
We use cookies and similar technologies to keep you signed in,
|
||||||
preferences such as theme, and, when enabled on a deployment, measure usage with
|
remember preferences such as theme, and, when enabled on a deployment,
|
||||||
privacy-focused analytics.
|
measure usage with privacy-focused analytics.
|
||||||
</LegalParagraph>
|
</LegalParagraph>
|
||||||
<LegalParagraph>
|
<LegalParagraph>
|
||||||
You can control cookies through your browser settings. If you disable essential
|
You can control cookies through your browser settings. If you disable
|
||||||
cookies, some parts of the Service may not work correctly.
|
essential cookies, some parts of the Service may not work correctly.
|
||||||
</LegalParagraph>
|
</LegalParagraph>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
@@ -149,22 +163,26 @@ const sections: LegalSection[] = [
|
|||||||
children: (
|
children: (
|
||||||
<>
|
<>
|
||||||
<LegalParagraph>
|
<LegalParagraph>
|
||||||
The Service may link to third-party websites or integrate with services you
|
The Service may link to third-party websites or integrate with
|
||||||
configure, such as single sign-on. Those services have their own privacy policies,
|
services you configure, such as single sign-on. Those services have
|
||||||
and we are not responsible for their practices.
|
their own privacy policies, and we are not responsible for their
|
||||||
|
practices.
|
||||||
</LegalParagraph>
|
</LegalParagraph>
|
||||||
<LegalParagraph>
|
<LegalParagraph>
|
||||||
The Service is not intended for children under 13. If you believe a child has
|
The Service is not intended for children under 13. If you believe a
|
||||||
provided us personal information, contact us and we will delete it.
|
child has provided us personal information, contact us and we will
|
||||||
|
delete it.
|
||||||
</LegalParagraph>
|
</LegalParagraph>
|
||||||
<LegalParagraph>
|
<LegalParagraph>
|
||||||
Your information may be processed in countries other than your own. Where required,
|
Your information may be processed in countries other than your own.
|
||||||
we use appropriate safeguards for international transfers.
|
Where required, we use appropriate safeguards for international
|
||||||
|
transfers.
|
||||||
</LegalParagraph>
|
</LegalParagraph>
|
||||||
<LegalParagraph>
|
<LegalParagraph>
|
||||||
We may update this policy from time to time. If we make material changes, we will
|
We may update this policy from time to time. If we make material
|
||||||
post the updated policy on the Service and may notify you by email. Continued use
|
changes, we will post the updated policy on the Service and may notify
|
||||||
after changes take effect means you accept the updated policy.
|
you by email. Continued use after changes take effect means you accept
|
||||||
|
the updated policy.
|
||||||
</LegalParagraph>
|
</LegalParagraph>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
@@ -176,7 +194,8 @@ const sections: LegalSection[] = [
|
|||||||
<>
|
<>
|
||||||
<LegalParagraph>
|
<LegalParagraph>
|
||||||
For privacy questions or requests, email{" "}
|
For privacy questions or requests, email{" "}
|
||||||
<a href={`mailto:${LEGAL_PRIVACY_EMAIL}`}>{LEGAL_PRIVACY_EMAIL}</a> or visit{" "}
|
<a href={`mailto:${LEGAL_PRIVACY_EMAIL}`}>{LEGAL_PRIVACY_EMAIL}</a> or
|
||||||
|
visit{" "}
|
||||||
<a href={LEGAL_WEBSITE} target="_blank" rel="noopener noreferrer">
|
<a href={LEGAL_WEBSITE} target="_blank" rel="noopener noreferrer">
|
||||||
{LEGAL_WEBSITE.replace(/^https?:\/\//, "")}
|
{LEGAL_WEBSITE.replace(/^https?:\/\//, "")}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -19,7 +19,13 @@ export function AppearanceProviderSynced({
|
|||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const [colorMode, setColorMode] = useState<ColorMode>(defaultColorMode);
|
// Lazy initializer so the first render already matches what the inline
|
||||||
|
// appearance-init script set on <html> — a separate mount effect here
|
||||||
|
// would run one render behind it, transiently flashing (and persisting)
|
||||||
|
// colorMode back to the default before the effect's own state update lands.
|
||||||
|
const [colorMode, setColorMode] = useState<ColorMode>(
|
||||||
|
() => readStoredColorMode() ?? defaultColorMode,
|
||||||
|
);
|
||||||
const serverHydratedRef = useRef(false);
|
const serverHydratedRef = useRef(false);
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const updateMutation = api.settings.updateColorMode.useMutation({
|
const updateMutation = api.settings.updateColorMode.useMutation({
|
||||||
@@ -41,14 +47,6 @@ export function AppearanceProviderSynced({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const stored = readStoredColorMode();
|
|
||||||
if (stored) {
|
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
||||||
setColorMode(stored);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!serverColorMode?.colorMode) return;
|
if (!serverColorMode?.colorMode) return;
|
||||||
if (serverHydratedRef.current) return;
|
if (serverHydratedRef.current) return;
|
||||||
|
|||||||
@@ -63,15 +63,13 @@ export function AppearanceProvider({
|
|||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const [colorMode, setColorMode] = useState<ColorMode>(defaultColorMode);
|
// Lazy initializer so the first render already matches what the inline
|
||||||
|
// appearance-init script set on <html> — a separate mount effect here
|
||||||
useEffect(() => {
|
// would run one render behind it, transiently flashing (and persisting)
|
||||||
const stored = readStoredColorMode();
|
// colorMode back to the default before the effect's own state update lands.
|
||||||
if (stored) {
|
const [colorMode, setColorMode] = useState<ColorMode>(
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
() => readStoredColorMode() ?? defaultColorMode,
|
||||||
setColorMode(stored);
|
);
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
applyColorMode(colorMode);
|
applyColorMode(colorMode);
|
||||||
|
|||||||
@@ -10,6 +10,25 @@ export function getAppUrl(): string {
|
|||||||
return `http://localhost:${process.env.PORT ?? 3000}`;
|
return `http://localhost:${process.env.PORT ?? 3000}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Origin derived from an incoming request's own headers — robust against
|
||||||
|
* NEXT_PUBLIC_APP_URL/BETTER_AUTH_URL drifting from the port the server is
|
||||||
|
* actually reachable on (e.g. local dev when the configured port is taken).
|
||||||
|
* Falls back to getAppUrl() if the request has no usable host header.
|
||||||
|
*/
|
||||||
|
export function getRequestOrigin(headers: Headers): string {
|
||||||
|
const host = headers.get("x-forwarded-host") ?? headers.get("host");
|
||||||
|
if (!host) return getAppUrl();
|
||||||
|
|
||||||
|
const forwardedProto = headers.get("x-forwarded-proto");
|
||||||
|
const protocol =
|
||||||
|
forwardedProto ?? (host.startsWith("localhost:") || host.startsWith("127.0.0.1:")
|
||||||
|
? "http"
|
||||||
|
: "https");
|
||||||
|
|
||||||
|
return `${protocol}://${host}`;
|
||||||
|
}
|
||||||
|
|
||||||
/** Hostname for display (e.g. marketing browser chrome). */
|
/** Hostname for display (e.g. marketing browser chrome). */
|
||||||
export function getAppHost(): string {
|
export function getAppHost(): string {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -39,6 +39,11 @@ export const auth = betterAuth({
|
|||||||
secret: process.env.AUTH_SECRET,
|
secret: process.env.AUTH_SECRET,
|
||||||
advanced: {
|
advanced: {
|
||||||
trustedProxyHeaders: true,
|
trustedProxyHeaders: true,
|
||||||
|
// Login from a LAN IP, ngrok tunnel, or the Expo dev client hits the API
|
||||||
|
// from an Origin that's rarely worth adding to trustedOrigins ahead of
|
||||||
|
// time. Skip the Origin/CSRF check in dev only; production still
|
||||||
|
// enforces it via trustedOrigins above.
|
||||||
|
...(env.NODE_ENV === "development" ? { disableCSRFCheck: true } : {}),
|
||||||
},
|
},
|
||||||
rateLimit: {
|
rateLimit: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|||||||
@@ -1,5 +1,21 @@
|
|||||||
import { getAppUrl } from "~/lib/app-url";
|
import { getAppUrl } from "~/lib/app-url";
|
||||||
|
|
||||||
|
// Most email clients render <img src> fine for PNG/JPEG but are inconsistent
|
||||||
|
// with SVG (Outlook and several webmail clients strip or refuse it), so
|
||||||
|
// non-raster logos are requested through the same on-the-fly PNG
|
||||||
|
// rasterization the PDF export uses.
|
||||||
|
function resolveEmailLogoUrl(
|
||||||
|
business: { id?: string; logoStorageKey?: string | null; logoMimeType?: string | null } | null | undefined,
|
||||||
|
baseUrl: string,
|
||||||
|
): string | null {
|
||||||
|
if (!business?.id || !business.logoStorageKey) return null;
|
||||||
|
const needsRaster =
|
||||||
|
business.logoMimeType != null &&
|
||||||
|
!["image/png", "image/jpeg"].includes(business.logoMimeType);
|
||||||
|
const path = `/api/business-logo/${business.id}${needsRaster ? "?format=png" : ""}`;
|
||||||
|
return `${baseUrl.replace(/\/$/, "")}${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
interface InvoiceEmailTemplateProps {
|
interface InvoiceEmailTemplateProps {
|
||||||
invoice: {
|
invoice: {
|
||||||
invoiceNumber: string;
|
invoiceNumber: string;
|
||||||
@@ -14,6 +30,7 @@ interface InvoiceEmailTemplateProps {
|
|||||||
email: string | null;
|
email: string | null;
|
||||||
};
|
};
|
||||||
business?: {
|
business?: {
|
||||||
|
id?: string;
|
||||||
name: string;
|
name: string;
|
||||||
nickname?: string | null;
|
nickname?: string | null;
|
||||||
email?: string | null;
|
email?: string | null;
|
||||||
@@ -24,6 +41,8 @@ interface InvoiceEmailTemplateProps {
|
|||||||
state?: string | null;
|
state?: string | null;
|
||||||
postalCode?: string | null;
|
postalCode?: string | null;
|
||||||
country?: string | null;
|
country?: string | null;
|
||||||
|
logoStorageKey?: string | null;
|
||||||
|
logoMimeType?: string | null;
|
||||||
} | null;
|
} | null;
|
||||||
items: Array<{
|
items: Array<{
|
||||||
date: Date;
|
date: Date;
|
||||||
@@ -73,6 +92,7 @@ export function generateInvoiceEmailTemplate({
|
|||||||
const subtotal = invoice.items.reduce((sum, item) => sum + item.amount, 0);
|
const subtotal = invoice.items.reduce((sum, item) => sum + item.amount, 0);
|
||||||
const taxAmount = subtotal * (invoice.taxRate / 100);
|
const taxAmount = subtotal * (invoice.taxRate / 100);
|
||||||
const total = subtotal + taxAmount;
|
const total = subtotal + taxAmount;
|
||||||
|
const logoUrl = resolveEmailLogoUrl(invoice.business, baseUrl);
|
||||||
|
|
||||||
const businessAddress = invoice.business
|
const businessAddress = invoice.business
|
||||||
? [
|
? [
|
||||||
@@ -409,6 +429,7 @@ export function generateInvoiceEmailTemplate({
|
|||||||
<body>
|
<body>
|
||||||
<div class="email-container">
|
<div class="email-container">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
|
${logoUrl ? `<img src="${logoUrl}" alt="${invoice.business?.name ?? ""}" style="max-height: 40px; max-width: 200px; margin-bottom: 12px;">` : ""}
|
||||||
<div class="header-content">Invoice ${invoice.invoiceNumber}</div>
|
<div class="header-content">Invoice ${invoice.invoiceNumber}</div>
|
||||||
<div class="header-subtitle">From ${invoice.business?.name ?? "Your Business"}</div>
|
<div class="header-subtitle">From ${invoice.business?.name ?? "Your Business"}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -56,7 +56,11 @@ export const navigationConfig: NavSection[] = [
|
|||||||
{ name: "Time clock", href: "/dashboard/time-clock", icon: Clock },
|
{ name: "Time clock", href: "/dashboard/time-clock", icon: Clock },
|
||||||
{ name: "Entities", href: "/dashboard/entities", icon: Users },
|
{ name: "Entities", href: "/dashboard/entities", icon: Users },
|
||||||
{ name: "Invoices", href: "/dashboard/invoices", icon: FileText },
|
{ name: "Invoices", href: "/dashboard/invoices", icon: FileText },
|
||||||
{ name: "Recurring", href: "/dashboard/invoices/recurring", icon: RefreshCw },
|
{
|
||||||
|
name: "Recurring",
|
||||||
|
href: "/dashboard/invoices/recurring",
|
||||||
|
icon: RefreshCw,
|
||||||
|
},
|
||||||
{ name: "Expenses", href: "/dashboard/expenses", icon: Receipt },
|
{ name: "Expenses", href: "/dashboard/expenses", icon: Receipt },
|
||||||
{ name: "Reports", href: "/dashboard/reports", icon: BarChart2 },
|
{ name: "Reports", href: "/dashboard/reports", icon: BarChart2 },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
import { mkdir, readFile, unlink, writeFile } from "fs/promises";
|
import { mkdir, readFile, unlink, writeFile } from "fs/promises";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
import type * as S3ClientModule from "@aws-sdk/client-s3";
|
||||||
|
|
||||||
// Local dev fallback when S3_* env vars are unset. Files land in .data/receipts/.
|
// Local dev fallback when S3_* env vars are unset. Files land in .data/receipts/.
|
||||||
const LOCAL_RECEIPTS_DIR = path.join(process.cwd(), ".data", "receipts");
|
const LOCAL_RECEIPTS_DIR = path.join(process.cwd(), ".data", "receipts");
|
||||||
@@ -17,7 +18,7 @@ export function getStorageBackend(): "s3" | "local" {
|
|||||||
return isS3Configured() ? "s3" : "local";
|
return isS3Configured() ? "s3" : "local";
|
||||||
}
|
}
|
||||||
|
|
||||||
type S3Module = typeof import("@aws-sdk/client-s3");
|
type S3Module = typeof S3ClientModule;
|
||||||
|
|
||||||
let s3ModulePromise: Promise<S3Module> | null = null;
|
let s3ModulePromise: Promise<S3Module> | null = null;
|
||||||
let s3Client: InstanceType<S3Module["S3Client"]> | null = null;
|
let s3Client: InstanceType<S3Module["S3Client"]> | null = null;
|
||||||
|
|||||||
+72
-13
@@ -75,6 +75,7 @@ export interface InvoiceData {
|
|||||||
currency?: string | null;
|
currency?: string | null;
|
||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
business?: {
|
business?: {
|
||||||
|
id?: string;
|
||||||
name: string;
|
name: string;
|
||||||
nickname?: string | null;
|
nickname?: string | null;
|
||||||
email?: string | null;
|
email?: string | null;
|
||||||
@@ -87,6 +88,9 @@ export interface InvoiceData {
|
|||||||
country?: string | null;
|
country?: string | null;
|
||||||
website?: string | null;
|
website?: string | null;
|
||||||
taxId?: string | null;
|
taxId?: string | null;
|
||||||
|
logoStorageKey?: string | null;
|
||||||
|
logoMimeType?: string | null;
|
||||||
|
hideNameWithLogo?: boolean | null;
|
||||||
} | null;
|
} | null;
|
||||||
client?: {
|
client?: {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -843,14 +847,45 @@ function getColumnWidths(showRate: boolean) {
|
|||||||
: { date: "15%", description: "48%", hours: "14%", amount: "23%" };
|
: { date: "15%", description: "48%", hours: "14%", amount: "23%" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// @react-pdf/renderer's Image component only reliably decodes PNG/JPEG.
|
||||||
|
// SVG and WebP logos are rasterized to PNG on the fly by the serving route
|
||||||
|
// (via ?format=png, using sharp) so every logo format still shows in the PDF.
|
||||||
|
function resolveBusinessLogoSrc(
|
||||||
|
business: InvoiceData["business"],
|
||||||
|
baseUrlOverride?: string,
|
||||||
|
): string | null {
|
||||||
|
if (!business?.id || !business.logoStorageKey) return null;
|
||||||
|
|
||||||
|
const needsRaster =
|
||||||
|
business.logoMimeType != null &&
|
||||||
|
!["image/png", "image/jpeg"].includes(business.logoMimeType);
|
||||||
|
const path = `/api/business-logo/${business.id}${needsRaster ? "?format=png" : ""}`;
|
||||||
|
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
return `${window.location.origin}${path}`;
|
||||||
|
}
|
||||||
|
// Server-side rendering has no window.location — callers that know their
|
||||||
|
// own request origin (e.g. a Route Handler) should pass baseUrlOverride,
|
||||||
|
// since NEXT_PUBLIC_APP_URL/BETTER_AUTH_URL can drift from the port the
|
||||||
|
// server actually ends up running on (e.g. in local dev).
|
||||||
|
const base =
|
||||||
|
baseUrlOverride ??
|
||||||
|
process.env.NEXT_PUBLIC_APP_URL ??
|
||||||
|
process.env.BETTER_AUTH_URL;
|
||||||
|
return base ? `${base.replace(/\/$/, "")}${path}` : null;
|
||||||
|
}
|
||||||
|
|
||||||
// Dense header component (first page)
|
// Dense header component (first page)
|
||||||
const DenseHeader: React.FC<{
|
const DenseHeader: React.FC<{
|
||||||
invoice: InvoiceData;
|
invoice: InvoiceData;
|
||||||
settings: Required<PDFGenerationSettings>;
|
settings: Required<PDFGenerationSettings>;
|
||||||
pdfStyles: PdfStyleBundle;
|
pdfStyles: PdfStyleBundle;
|
||||||
}> = ({ invoice, settings, pdfStyles }) => {
|
logoBaseUrl?: string;
|
||||||
|
}> = ({ invoice, settings, pdfStyles, logoBaseUrl }) => {
|
||||||
const { styles, minimalStyles, getStatusStyle } = pdfStyles;
|
const { styles, minimalStyles, getStatusStyle } = pdfStyles;
|
||||||
const isMinimal = settings.pdfTemplate === "minimal";
|
const isMinimal = settings.pdfTemplate === "minimal";
|
||||||
|
const logoSrc = resolveBusinessLogoSrc(invoice.business, logoBaseUrl);
|
||||||
|
const hideName = Boolean(logoSrc && invoice.business?.hideNameWithLogo);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
@@ -860,15 +895,30 @@ const DenseHeader: React.FC<{
|
|||||||
style={[styles.headerTop, isMinimal ? minimalStyles.headerTop : {}]}
|
style={[styles.headerTop, isMinimal ? minimalStyles.headerTop : {}]}
|
||||||
>
|
>
|
||||||
<View style={styles.businessSection}>
|
<View style={styles.businessSection}>
|
||||||
<Text
|
{logoSrc && (
|
||||||
style={[
|
// eslint-disable-next-line jsx-a11y/alt-text -- @react-pdf/renderer Image does not support alt.
|
||||||
styles.businessName,
|
<Image
|
||||||
isMinimal ? minimalStyles.businessName : {},
|
src={logoSrc}
|
||||||
{ color: settings.pdfAccentColor },
|
style={{
|
||||||
]}
|
width: 160,
|
||||||
>
|
height: 64,
|
||||||
{invoice.business?.name ?? "Your Business Name"}
|
marginBottom: 6,
|
||||||
</Text>
|
objectFit: "contain",
|
||||||
|
objectPosition: "left",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!hideName && (
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.businessName,
|
||||||
|
isMinimal ? minimalStyles.businessName : {},
|
||||||
|
{ color: settings.pdfAccentColor },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{invoice.business?.name ?? "Your Business Name"}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
{invoice.business?.email && (
|
{invoice.business?.email && (
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
@@ -1390,7 +1440,8 @@ const TotalsSection: React.FC<{
|
|||||||
export const InvoicePDF: React.FC<{
|
export const InvoicePDF: React.FC<{
|
||||||
invoice: InvoiceData;
|
invoice: InvoiceData;
|
||||||
settings?: PDFGenerationSettings;
|
settings?: PDFGenerationSettings;
|
||||||
}> = ({ invoice, settings: inputSettings }) => {
|
logoBaseUrl?: string;
|
||||||
|
}> = ({ invoice, settings: inputSettings, logoBaseUrl }) => {
|
||||||
const settings = resolvePDFSettings(inputSettings);
|
const settings = resolvePDFSettings(inputSettings);
|
||||||
const pdfStyles = getPdfStyleBundle(
|
const pdfStyles = getPdfStyleBundle(
|
||||||
settings.pdfFontFamily,
|
settings.pdfFontFamily,
|
||||||
@@ -1402,6 +1453,7 @@ export const InvoicePDF: React.FC<{
|
|||||||
invoice={invoice}
|
invoice={invoice}
|
||||||
settings={settings}
|
settings={settings}
|
||||||
pdfStyles={pdfStyles}
|
pdfStyles={pdfStyles}
|
||||||
|
logoBaseUrl={logoBaseUrl}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1410,7 +1462,8 @@ const InvoicePDFDocument: React.FC<{
|
|||||||
invoice: InvoiceData;
|
invoice: InvoiceData;
|
||||||
settings: Required<PDFGenerationSettings>;
|
settings: Required<PDFGenerationSettings>;
|
||||||
pdfStyles: PdfStyleBundle;
|
pdfStyles: PdfStyleBundle;
|
||||||
}> = ({ invoice, settings, pdfStyles }) => {
|
logoBaseUrl?: string;
|
||||||
|
}> = ({ invoice, settings, pdfStyles, logoBaseUrl }) => {
|
||||||
const { styles, minimalStyles } = pdfStyles;
|
const { styles, minimalStyles } = pdfStyles;
|
||||||
const items = invoice.items?.filter(Boolean) ?? [];
|
const items = invoice.items?.filter(Boolean) ?? [];
|
||||||
const currency = invoice.currency ?? "USD";
|
const currency = invoice.currency ?? "USD";
|
||||||
@@ -1426,6 +1479,7 @@ const InvoicePDFDocument: React.FC<{
|
|||||||
>
|
>
|
||||||
<DenseHeader
|
<DenseHeader
|
||||||
invoice={invoice}
|
invoice={invoice}
|
||||||
|
logoBaseUrl={logoBaseUrl}
|
||||||
settings={settings}
|
settings={settings}
|
||||||
pdfStyles={pdfStyles}
|
pdfStyles={pdfStyles}
|
||||||
/>
|
/>
|
||||||
@@ -1592,6 +1646,7 @@ export async function generateInvoicePDF(
|
|||||||
export async function generateInvoicePDFBlob(
|
export async function generateInvoicePDFBlob(
|
||||||
invoice: InvoiceData,
|
invoice: InvoiceData,
|
||||||
settings?: PDFGenerationSettings,
|
settings?: PDFGenerationSettings,
|
||||||
|
options?: { logoBaseUrl?: string },
|
||||||
): Promise<Blob> {
|
): Promise<Blob> {
|
||||||
try {
|
try {
|
||||||
// Validate invoice data
|
// Validate invoice data
|
||||||
@@ -1609,7 +1664,11 @@ export async function generateInvoicePDFBlob(
|
|||||||
|
|
||||||
// Generate PDF blob
|
// Generate PDF blob
|
||||||
const originalBlob = await pdf(
|
const originalBlob = await pdf(
|
||||||
<InvoicePDF invoice={invoice} settings={settings} />,
|
<InvoicePDF
|
||||||
|
invoice={invoice}
|
||||||
|
settings={settings}
|
||||||
|
logoBaseUrl={options?.logoBaseUrl}
|
||||||
|
/>,
|
||||||
).toBlob();
|
).toBlob();
|
||||||
|
|
||||||
// Validate blob
|
// Validate blob
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight defense-in-depth pass over uploaded SVG markup before it is
|
||||||
|
* stored. Strips executable content (scripts, event handlers, external
|
||||||
|
* references) so a malicious SVG can't run script if it's ever rendered
|
||||||
|
* inline (dangerouslySetInnerHTML) rather than via <img src>. Not a full
|
||||||
|
* parser — good enough for a self-uploaded logo, not a substitute for
|
||||||
|
* treating SVG as active content from an untrusted source.
|
||||||
|
*/
|
||||||
|
export function sanitizeSvg(input: string): string {
|
||||||
|
let svg = input;
|
||||||
|
|
||||||
|
// Strip <script>...</script> blocks and self-closing <script/> tags.
|
||||||
|
svg = svg.replace(/<script[\s\S]*?<\/script\s*>/gi, "");
|
||||||
|
svg = svg.replace(/<script\b[^>]*\/>/gi, "");
|
||||||
|
|
||||||
|
// Strip on* event handler attributes (onload, onclick, onerror, ...).
|
||||||
|
svg = svg.replace(/\son\w+\s*=\s*"[^"]*"/gi, "");
|
||||||
|
svg = svg.replace(/\son\w+\s*=\s*'[^']*'/gi, "");
|
||||||
|
svg = svg.replace(/\son\w+\s*=\s*[^\s>]+/gi, "");
|
||||||
|
|
||||||
|
// Strip javascript: URIs in href/xlink:href/src attributes.
|
||||||
|
svg = svg.replace(
|
||||||
|
/((?:xlink:href|href|src)\s*=\s*)"javascript:[^"]*"/gi,
|
||||||
|
'$1""',
|
||||||
|
);
|
||||||
|
svg = svg.replace(
|
||||||
|
/((?:xlink:href|href|src)\s*=\s*)'javascript:[^']*'/gi,
|
||||||
|
"$1''",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Strip <foreignObject> (can embed arbitrary HTML) and <iframe>.
|
||||||
|
svg = svg.replace(/<foreignObject[\s\S]*?<\/foreignObject\s*>/gi, "");
|
||||||
|
svg = svg.replace(/<iframe[\s\S]*?<\/iframe\s*>/gi, "");
|
||||||
|
|
||||||
|
return svg;
|
||||||
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
/** Stored on entries clocked in before empty descriptions were allowed. */
|
/** Stored on entries clocked in before empty descriptions were allowed. */
|
||||||
export const LEGACY_DEFAULT_CLOCK_DESCRIPTION = "Professional services";
|
export const LEGACY_DEFAULT_CLOCK_DESCRIPTION = "Professional services";
|
||||||
|
|
||||||
|
export function normalizeOptionalId(value?: string | null): string | null {
|
||||||
|
const trimmed = value?.trim();
|
||||||
|
return trimmed == null || trimmed === "" ? null : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveEffectiveHourlyRate(
|
export function resolveEffectiveHourlyRate(
|
||||||
enteredRate: number,
|
enteredRate: number,
|
||||||
client?: { defaultHourlyRate?: number | null } | null,
|
client?: { defaultHourlyRate?: number | null } | null,
|
||||||
|
|||||||
+7
-1
@@ -17,7 +17,13 @@ export function proxy(request: NextRequest) {
|
|||||||
const { pathname } = request.nextUrl;
|
const { pathname } = request.nextUrl;
|
||||||
|
|
||||||
// Define API routes that should be handled separately
|
// Define API routes that should be handled separately
|
||||||
const apiRoutes = ["/api/auth", "/api/trpc", "/api/mcp", "/api/i"];
|
const apiRoutes = [
|
||||||
|
"/api/auth",
|
||||||
|
"/api/trpc",
|
||||||
|
"/api/mcp",
|
||||||
|
"/api/i",
|
||||||
|
"/api/business-logo",
|
||||||
|
];
|
||||||
|
|
||||||
// Allow API routes to pass through
|
// Allow API routes to pass through
|
||||||
if (apiRoutes.some((route) => pathname.startsWith(route))) {
|
if (apiRoutes.some((route) => pathname.startsWith(route))) {
|
||||||
|
|||||||
@@ -1,9 +1,20 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||||
import { businesses } from "~/server/db/schema";
|
import { businesses } from "~/server/db/schema";
|
||||||
import { eq, and, desc } from "drizzle-orm";
|
import { eq, and, desc } from "drizzle-orm";
|
||||||
import { invoices } from "~/server/db/schema";
|
import { invoices } from "~/server/db/schema";
|
||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
|
import { deleteObject, putObject } from "~/lib/object-storage";
|
||||||
|
import { sanitizeSvg } from "~/lib/svg-sanitize";
|
||||||
|
|
||||||
|
const MAX_LOGO_BYTES = 5 * 1024 * 1024;
|
||||||
|
const allowedLogoMimeTypes = new Set([
|
||||||
|
"image/png",
|
||||||
|
"image/jpeg",
|
||||||
|
"image/webp",
|
||||||
|
"image/svg+xml",
|
||||||
|
]);
|
||||||
|
|
||||||
const businessSchema = z.object({
|
const businessSchema = z.object({
|
||||||
name: z
|
name: z
|
||||||
@@ -28,6 +39,7 @@ const businessSchema = z.object({
|
|||||||
website: z.string().url().optional().or(z.literal("")),
|
website: z.string().url().optional().or(z.literal("")),
|
||||||
taxId: z.string().optional().or(z.literal("")),
|
taxId: z.string().optional().or(z.literal("")),
|
||||||
logoUrl: z.string().optional().or(z.literal("")),
|
logoUrl: z.string().optional().or(z.literal("")),
|
||||||
|
hideNameWithLogo: z.boolean().default(false),
|
||||||
isDefault: z.boolean().default(false),
|
isDefault: z.boolean().default(false),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -153,6 +165,7 @@ export const businessesRouter = createTRPCRouter({
|
|||||||
input.logoUrl && input.logoUrl.trim() !== ""
|
input.logoUrl && input.logoUrl.trim() !== ""
|
||||||
? input.logoUrl.trim()
|
? input.logoUrl.trim()
|
||||||
: null,
|
: null,
|
||||||
|
hideNameWithLogo: input.hideNameWithLogo ?? false,
|
||||||
isDefault: input.isDefault ?? false,
|
isDefault: input.isDefault ?? false,
|
||||||
createdById: ctx.session.user.id,
|
createdById: ctx.session.user.id,
|
||||||
})
|
})
|
||||||
@@ -232,6 +245,7 @@ export const businessesRouter = createTRPCRouter({
|
|||||||
updateData.logoUrl && updateData.logoUrl.trim() !== ""
|
updateData.logoUrl && updateData.logoUrl.trim() !== ""
|
||||||
? updateData.logoUrl.trim()
|
? updateData.logoUrl.trim()
|
||||||
: null,
|
: null,
|
||||||
|
hideNameWithLogo: updateData.hideNameWithLogo ?? false,
|
||||||
isDefault: updateData.isDefault ?? false,
|
isDefault: updateData.isDefault ?? false,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
@@ -417,4 +431,124 @@ export const businessesRouter = createTRPCRouter({
|
|||||||
hasApiKey: !!business[0].hasApiKey,
|
hasApiKey: !!business[0].hasApiKey,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// Upload (or replace) a business logo, shown on invoices
|
||||||
|
uploadLogo: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
id: z.string(),
|
||||||
|
filename: z.string().min(1).max(255),
|
||||||
|
mimeType: z.string().min(1).max(100),
|
||||||
|
data: z.string().min(1),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const [business] = await ctx.db
|
||||||
|
.select()
|
||||||
|
.from(businesses)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(businesses.id, input.id),
|
||||||
|
eq(businesses.createdById, ctx.session.user.id),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!business) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Business not found or you don't have permission to update it",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const mimeType = input.mimeType.toLowerCase().split(";")[0]!.trim();
|
||||||
|
if (!allowedLogoMimeTypes.has(mimeType)) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Logo must be a PNG, JPEG, WebP, or SVG image",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = Buffer.from(input.data, "base64");
|
||||||
|
if (!body.length || body.length > MAX_LOGO_BYTES) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Logo must be between 1 byte and 5MB",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mimeType === "image/svg+xml") {
|
||||||
|
body = Buffer.from(sanitizeSvg(body.toString("utf8")), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeName = input.filename.replace(/[^a-zA-Z0-9._-]/g, "_");
|
||||||
|
const storageKey = `logos/${ctx.session.user.id}/${business.id}/${crypto.randomUUID()}-${safeName}`;
|
||||||
|
const previousStorageKey = business.logoStorageKey;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await putObject(storageKey, body, mimeType);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[businesses.uploadLogo] Failed to store logo", {
|
||||||
|
backendError: error,
|
||||||
|
businessId: business.id,
|
||||||
|
});
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
message:
|
||||||
|
"Logo storage is unavailable. Check the object-storage service and try again.",
|
||||||
|
cause: error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updatedBusiness] = await ctx.db
|
||||||
|
.update(businesses)
|
||||||
|
.set({
|
||||||
|
logoStorageKey: storageKey,
|
||||||
|
logoMimeType: mimeType,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(businesses.id, business.id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (previousStorageKey) {
|
||||||
|
await deleteObject(previousStorageKey).catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedBusiness;
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Remove a business logo
|
||||||
|
removeLogo: protectedProcedure
|
||||||
|
.input(z.object({ id: z.string() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const [business] = await ctx.db
|
||||||
|
.select()
|
||||||
|
.from(businesses)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(businesses.id, input.id),
|
||||||
|
eq(businesses.createdById, ctx.session.user.id),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!business) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Business not found or you don't have permission to update it",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (business.logoStorageKey) {
|
||||||
|
await deleteObject(business.logoStorageKey).catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updatedBusiness] = await ctx.db
|
||||||
|
.update(businesses)
|
||||||
|
.set({ logoStorageKey: null, logoMimeType: null, updatedAt: new Date() })
|
||||||
|
.where(eq(businesses.id, business.id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return updatedBusiness;
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { invoices, platformSettings } from "~/server/db/schema";
|
|||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { env } from "~/env";
|
import { env } from "~/env";
|
||||||
import { NOREPLY_EMAIL } from "~/lib/app-email";
|
import { NOREPLY_EMAIL } from "~/lib/app-email";
|
||||||
import { getAppUrl } from "~/lib/app-url";
|
import { getRequestOrigin } from "~/lib/app-url";
|
||||||
import { generateInvoicePDFBlob } from "~/lib/pdf-export";
|
import { generateInvoicePDFBlob } from "~/lib/pdf-export";
|
||||||
import { generateInvoiceEmailTemplate } from "~/lib/email-templates";
|
import { generateInvoiceEmailTemplate } from "~/lib/email-templates";
|
||||||
|
|
||||||
@@ -88,26 +88,30 @@ export const emailRouter = createTRPCRouter({
|
|||||||
const settings = await ctx.db.query.platformSettings.findFirst({
|
const settings = await ctx.db.query.platformSettings.findFirst({
|
||||||
where: eq(platformSettings.id, "global"),
|
where: eq(platformSettings.id, "global"),
|
||||||
});
|
});
|
||||||
const pdfBlob = await generateInvoicePDFBlob(invoice, {
|
const pdfBlob = await generateInvoicePDFBlob(
|
||||||
pdfTemplate: settings?.pdfTemplate as
|
invoice,
|
||||||
| "classic"
|
{
|
||||||
| "minimal"
|
pdfTemplate: settings?.pdfTemplate as
|
||||||
| undefined,
|
| "classic"
|
||||||
pdfAccentColor: settings?.pdfAccentColor,
|
| "minimal"
|
||||||
pdfFontFamily: settings?.pdfFontFamily as
|
| undefined,
|
||||||
| "sans"
|
pdfAccentColor: settings?.pdfAccentColor,
|
||||||
| "serif"
|
pdfFontFamily: settings?.pdfFontFamily as
|
||||||
| "mono"
|
| "sans"
|
||||||
| undefined,
|
| "serif"
|
||||||
pdfNumericFontFamily: settings?.pdfNumericFontFamily as
|
| "mono"
|
||||||
| "sans"
|
| undefined,
|
||||||
| "serif"
|
pdfNumericFontFamily: settings?.pdfNumericFontFamily as
|
||||||
| "mono"
|
| "sans"
|
||||||
| undefined,
|
| "serif"
|
||||||
pdfFooterText: settings?.pdfFooterText,
|
| "mono"
|
||||||
pdfShowLogo: settings?.pdfShowLogo,
|
| undefined,
|
||||||
pdfShowPageNumbers: settings?.pdfShowPageNumbers,
|
pdfFooterText: settings?.pdfFooterText,
|
||||||
});
|
pdfShowLogo: settings?.pdfShowLogo,
|
||||||
|
pdfShowPageNumbers: settings?.pdfShowPageNumbers,
|
||||||
|
},
|
||||||
|
{ logoBaseUrl: getRequestOrigin(ctx.headers) },
|
||||||
|
);
|
||||||
pdfBuffer = Buffer.from(await pdfBlob.arrayBuffer());
|
pdfBuffer = Buffer.from(await pdfBlob.arrayBuffer());
|
||||||
|
|
||||||
// Validate PDF was generated successfully
|
// Validate PDF was generated successfully
|
||||||
@@ -165,7 +169,7 @@ export const emailRouter = createTRPCRouter({
|
|||||||
customMessage,
|
customMessage,
|
||||||
userName,
|
userName,
|
||||||
userEmail,
|
userEmail,
|
||||||
baseUrl: getAppUrl(),
|
baseUrl: getRequestOrigin(ctx.headers),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Determine Resend instance and email configuration to use
|
// Determine Resend instance and email configuration to use
|
||||||
|
|||||||
@@ -20,9 +20,15 @@ import {
|
|||||||
RECEIPT_MAX_BYTES,
|
RECEIPT_MAX_BYTES,
|
||||||
} from "~/lib/object-storage";
|
} from "~/lib/object-storage";
|
||||||
import { parseReceiptText } from "~/lib/receipt-parse";
|
import { parseReceiptText } from "~/lib/receipt-parse";
|
||||||
|
import type { db } from "~/server/db";
|
||||||
|
|
||||||
export { EXPENSE_CATEGORIES };
|
export { EXPENSE_CATEGORIES };
|
||||||
|
|
||||||
|
type ExpenseContext = {
|
||||||
|
db: typeof db;
|
||||||
|
session: { user: { id: string } };
|
||||||
|
};
|
||||||
|
|
||||||
const createExpenseSchema = z.object({
|
const createExpenseSchema = z.object({
|
||||||
date: z.date(),
|
date: z.date(),
|
||||||
description: z.string().min(1, "Description is required"),
|
description: z.string().min(1, "Description is required"),
|
||||||
@@ -43,7 +49,7 @@ const updateExpenseSchema = createExpenseSchema.partial().extend({
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function verifyClientAccess(
|
async function verifyClientAccess(
|
||||||
ctx: { db: typeof import("~/server/db").db; session: { user: { id: string } } },
|
ctx: ExpenseContext,
|
||||||
clientId: string,
|
clientId: string,
|
||||||
) {
|
) {
|
||||||
const client = await ctx.db.query.clients.findFirst({
|
const client = await ctx.db.query.clients.findFirst({
|
||||||
@@ -62,7 +68,7 @@ async function verifyClientAccess(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function verifyInvoiceAccess(
|
async function verifyInvoiceAccess(
|
||||||
ctx: { db: typeof import("~/server/db").db; session: { user: { id: string } } },
|
ctx: ExpenseContext,
|
||||||
invoiceId: string,
|
invoiceId: string,
|
||||||
) {
|
) {
|
||||||
const invoice = await ctx.db.query.invoices.findFirst({
|
const invoice = await ctx.db.query.invoices.findFirst({
|
||||||
@@ -81,7 +87,7 @@ async function verifyInvoiceAccess(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function resolveExpenseBusinessId(
|
async function resolveExpenseBusinessId(
|
||||||
ctx: { db: typeof import("~/server/db").db; session: { user: { id: string } } },
|
ctx: ExpenseContext,
|
||||||
businessId: string | null,
|
businessId: string | null,
|
||||||
invoice?: { businessId: string | null } | null,
|
invoice?: { businessId: string | null } | null,
|
||||||
) {
|
) {
|
||||||
@@ -98,7 +104,7 @@ async function resolveExpenseBusinessId(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getOwnedExpense(
|
async function getOwnedExpense(
|
||||||
ctx: { db: typeof import("~/server/db").db; session: { user: { id: string } } },
|
ctx: ExpenseContext,
|
||||||
expenseId: string,
|
expenseId: string,
|
||||||
) {
|
) {
|
||||||
const expense = await ctx.db.query.expenses.findFirst({
|
const expense = await ctx.db.query.expenses.findFirst({
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { calculateLineItemAmount } from "~/lib/invoice-line-item";
|
import { calculateLineItemAmount } from "~/lib/invoice-line-item";
|
||||||
import { generateInvoicePDFBlob } from "~/lib/pdf-export";
|
import { generateInvoicePDFBlob } from "~/lib/pdf-export";
|
||||||
|
import { getRequestOrigin } from "~/lib/app-url";
|
||||||
import { defaultDueDate, generateInvoiceNumber } from "~/lib/draft-invoice";
|
import { defaultDueDate, generateInvoiceNumber } from "~/lib/draft-invoice";
|
||||||
import { Resend } from "resend";
|
import { Resend } from "resend";
|
||||||
import { env } from "~/env";
|
import { env } from "~/env";
|
||||||
@@ -984,6 +985,7 @@ export const invoicesRouter = createTRPCRouter({
|
|||||||
pdfShowLogo: settings?.pdfShowLogo,
|
pdfShowLogo: settings?.pdfShowLogo,
|
||||||
pdfShowPageNumbers: settings?.pdfShowPageNumbers,
|
pdfShowPageNumbers: settings?.pdfShowPageNumbers,
|
||||||
},
|
},
|
||||||
|
{ logoBaseUrl: getRequestOrigin(ctx.headers) },
|
||||||
);
|
);
|
||||||
|
|
||||||
const buffer = Buffer.from(await pdfBlob.arrayBuffer());
|
const buffer = Buffer.from(await pdfBlob.arrayBuffer());
|
||||||
@@ -1046,7 +1048,29 @@ export const invoicesRouter = createTRPCRouter({
|
|||||||
where: eq(invoices.publicToken, input.token),
|
where: eq(invoices.publicToken, input.token),
|
||||||
with: {
|
with: {
|
||||||
client: true,
|
client: true,
|
||||||
business: true,
|
// Explicit allowlist: this is a publicProcedure — never let
|
||||||
|
// secret fields (resendApiKey, resendDomain) reach an
|
||||||
|
// unauthenticated caller via the business relation.
|
||||||
|
business: {
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
nickname: true,
|
||||||
|
email: true,
|
||||||
|
phone: true,
|
||||||
|
addressLine1: true,
|
||||||
|
addressLine2: true,
|
||||||
|
city: true,
|
||||||
|
state: true,
|
||||||
|
postalCode: true,
|
||||||
|
country: true,
|
||||||
|
website: true,
|
||||||
|
taxId: true,
|
||||||
|
logoStorageKey: true,
|
||||||
|
logoMimeType: true,
|
||||||
|
hideNameWithLogo: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
items: {
|
items: {
|
||||||
orderBy: (i, { asc }) => [
|
orderBy: (i, { asc }) => [
|
||||||
asc(i.date),
|
asc(i.date),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { and, count, eq, isNull } from "drizzle-orm";
|
|||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
import { logAuditEvent } from "~/lib/audit-log";
|
import { logAuditEvent } from "~/lib/audit-log";
|
||||||
|
import { deleteObject } from "~/lib/object-storage";
|
||||||
import {
|
import {
|
||||||
createTRPCRouter,
|
createTRPCRouter,
|
||||||
protectedProcedure,
|
protectedProcedure,
|
||||||
@@ -12,6 +13,8 @@ import {
|
|||||||
import { requireAdmin } from "~/server/api/require-admin";
|
import { requireAdmin } from "~/server/api/require-admin";
|
||||||
import {
|
import {
|
||||||
accounts,
|
accounts,
|
||||||
|
apiKeys,
|
||||||
|
auditLog,
|
||||||
users,
|
users,
|
||||||
clients,
|
clients,
|
||||||
businesses,
|
businesses,
|
||||||
@@ -20,10 +23,14 @@ import {
|
|||||||
invoicePayments,
|
invoicePayments,
|
||||||
invoiceTemplates,
|
invoiceTemplates,
|
||||||
expenses,
|
expenses,
|
||||||
|
expenseReceipts,
|
||||||
recurringInvoices,
|
recurringInvoices,
|
||||||
recurringInvoiceItems,
|
recurringInvoiceItems,
|
||||||
|
sessions,
|
||||||
|
ssoProviders,
|
||||||
timeEntries,
|
timeEntries,
|
||||||
platformSettings,
|
platformSettings,
|
||||||
|
verificationTokens,
|
||||||
} from "~/server/db/schema";
|
} from "~/server/db/schema";
|
||||||
import {
|
import {
|
||||||
colorModeSchema,
|
colorModeSchema,
|
||||||
@@ -703,25 +710,27 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const userRecurringInvoices = await ctx.db.query.recurringInvoices.findMany({
|
const userRecurringInvoices = await ctx.db.query.recurringInvoices.findMany(
|
||||||
where: eq(recurringInvoices.createdById, userId),
|
{
|
||||||
with: {
|
where: eq(recurringInvoices.createdById, userId),
|
||||||
client: { columns: { name: true } },
|
with: {
|
||||||
business: { columns: { name: true, nickname: true } },
|
client: { columns: { name: true } },
|
||||||
items: {
|
business: { columns: { name: true, nickname: true } },
|
||||||
columns: {
|
items: {
|
||||||
description: true,
|
columns: {
|
||||||
hours: true,
|
description: true,
|
||||||
rate: true,
|
hours: true,
|
||||||
position: true,
|
rate: true,
|
||||||
|
position: true,
|
||||||
|
},
|
||||||
|
orderBy: (items, { asc }) => [
|
||||||
|
asc(items.position),
|
||||||
|
asc(items.createdAt),
|
||||||
|
],
|
||||||
},
|
},
|
||||||
orderBy: (items, { asc }) => [
|
|
||||||
asc(items.position),
|
|
||||||
asc(items.createdAt),
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
);
|
||||||
|
|
||||||
const userExpenses = await ctx.db.query.expenses.findMany({
|
const userExpenses = await ctx.db.query.expenses.findMany({
|
||||||
where: eq(expenses.createdById, userId),
|
where: eq(expenses.createdById, userId),
|
||||||
@@ -1101,7 +1110,9 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
...(input.user.animationSpeedMultiplier !== undefined && {
|
...(input.user.animationSpeedMultiplier !== undefined && {
|
||||||
animationSpeedMultiplier: input.user.animationSpeedMultiplier,
|
animationSpeedMultiplier: input.user.animationSpeedMultiplier,
|
||||||
}),
|
}),
|
||||||
...(input.user.theme !== undefined && { theme: input.user.theme }),
|
...(input.user.theme !== undefined && {
|
||||||
|
theme: input.user.theme,
|
||||||
|
}),
|
||||||
...(input.user.onboardingCompletedAt !== undefined && {
|
...(input.user.onboardingCompletedAt !== undefined && {
|
||||||
onboardingCompletedAt: input.user.onboardingCompletedAt,
|
onboardingCompletedAt: input.user.onboardingCompletedAt,
|
||||||
}),
|
}),
|
||||||
@@ -1137,7 +1148,9 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "INTERNAL_SERVER_ERROR",
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
message:
|
message:
|
||||||
error instanceof Error ? error.message : "Import failed unexpectedly",
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Import failed unexpectedly",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
@@ -1171,44 +1184,67 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// Delete all user data (for account deletion)
|
// Permanently delete the signed-in account and all associated data.
|
||||||
deleteAllData: sessionProcedure
|
deleteAccount: sessionProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
confirmText: z.string().refine((val) => val === "DELETE ALL DATA", {
|
confirmText: z.string().refine((val) => val === "DELETE MY ACCOUNT", {
|
||||||
message: "You must type 'DELETE ALL DATA' to confirm",
|
message: "You must type 'DELETE MY ACCOUNT' to confirm",
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx }) => {
|
.mutation(async ({ ctx }) => {
|
||||||
const userId = ctx.session.user.id;
|
const userId = ctx.session.user.id;
|
||||||
|
|
||||||
return await ctx.db.transaction(async (tx) => {
|
const [receiptObjects, logoObjects] = await Promise.all([
|
||||||
// Delete in order due to foreign key constraints
|
ctx.db
|
||||||
// 1. Invoice items (cascade should handle this, but being explicit)
|
.select({ storageKey: expenseReceipts.storageKey })
|
||||||
const userInvoiceIds = await tx
|
.from(expenseReceipts)
|
||||||
.select({ id: invoices.id })
|
.innerJoin(expenses, eq(expenseReceipts.expenseId, expenses.id))
|
||||||
.from(invoices)
|
.where(eq(expenses.createdById, userId)),
|
||||||
.where(eq(invoices.createdById, userId));
|
ctx.db
|
||||||
|
.select({ storageKey: businesses.logoStorageKey })
|
||||||
|
.from(businesses)
|
||||||
|
.where(eq(businesses.createdById, userId)),
|
||||||
|
]);
|
||||||
|
|
||||||
if (userInvoiceIds.length > 0) {
|
// Delete uploaded personal data before removing its database pointers. If object
|
||||||
for (const invoice of userInvoiceIds) {
|
// storage is unavailable, the account remains intact so the user can retry.
|
||||||
await tx
|
await Promise.all(
|
||||||
.delete(invoiceItems)
|
[...receiptObjects, ...logoObjects].flatMap(({ storageKey }) =>
|
||||||
.where(eq(invoiceItems.invoiceId, invoice.id));
|
storageKey ? [deleteObject(storageKey)] : [],
|
||||||
}
|
),
|
||||||
}
|
);
|
||||||
|
|
||||||
// 2. Invoices
|
await ctx.db.transaction(async (tx) => {
|
||||||
|
// Dependents without cascading foreign keys must be removed first.
|
||||||
|
await tx.delete(auditLog).where(eq(auditLog.actorUserId, userId));
|
||||||
|
await tx
|
||||||
|
.delete(recurringInvoices)
|
||||||
|
.where(eq(recurringInvoices.createdById, userId));
|
||||||
|
await tx.delete(expenses).where(eq(expenses.createdById, userId));
|
||||||
|
await tx
|
||||||
|
.delete(invoicePayments)
|
||||||
|
.where(eq(invoicePayments.createdById, userId));
|
||||||
await tx.delete(invoices).where(eq(invoices.createdById, userId));
|
await tx.delete(invoices).where(eq(invoices.createdById, userId));
|
||||||
|
await tx.delete(timeEntries).where(eq(timeEntries.createdById, userId));
|
||||||
// 3. Clients
|
await tx
|
||||||
|
.delete(invoiceTemplates)
|
||||||
|
.where(eq(invoiceTemplates.createdById, userId));
|
||||||
await tx.delete(clients).where(eq(clients.createdById, userId));
|
await tx.delete(clients).where(eq(clients.createdById, userId));
|
||||||
|
|
||||||
// 4. Businesses
|
|
||||||
await tx.delete(businesses).where(eq(businesses.createdById, userId));
|
await tx.delete(businesses).where(eq(businesses.createdById, userId));
|
||||||
|
|
||||||
return { success: true };
|
// Authentication, access, and verification records.
|
||||||
|
await tx.delete(apiKeys).where(eq(apiKeys.userId, userId));
|
||||||
|
await tx.delete(ssoProviders).where(eq(ssoProviders.userId, userId));
|
||||||
|
await tx.delete(accounts).where(eq(accounts.userId, userId));
|
||||||
|
await tx.delete(sessions).where(eq(sessions.userId, userId));
|
||||||
|
await tx
|
||||||
|
.delete(verificationTokens)
|
||||||
|
.where(eq(verificationTokens.identifier, ctx.session.user.email));
|
||||||
|
await tx.delete(users).where(eq(users.id, userId));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { TRPCError } from "@trpc/server";
|
|||||||
import type { db } from "~/server/db";
|
import type { db } from "~/server/db";
|
||||||
import {
|
import {
|
||||||
computeTrackedHours,
|
computeTrackedHours,
|
||||||
|
normalizeOptionalId,
|
||||||
resolveBillingDescription,
|
resolveBillingDescription,
|
||||||
type ClockOutOutcome,
|
type ClockOutOutcome,
|
||||||
} from "~/lib/time-clock";
|
} from "~/lib/time-clock";
|
||||||
@@ -242,7 +243,7 @@ export const timeEntriesRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const clientId = input.clientId?.trim() || null;
|
const clientId = normalizeOptionalId(input.clientId);
|
||||||
let clientRecord: { defaultHourlyRate: number | null } | null = null;
|
let clientRecord: { defaultHourlyRate: number | null } | null = null;
|
||||||
if (clientId) {
|
if (clientId) {
|
||||||
const found = await ctx.db.query.clients.findFirst({
|
const found = await ctx.db.query.clients.findFirst({
|
||||||
@@ -514,7 +515,7 @@ export const timeEntriesRouter = createTRPCRouter({
|
|||||||
create: protectedProcedure
|
create: protectedProcedure
|
||||||
.input(createSchema)
|
.input(createSchema)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const clientId = input.clientId?.trim() || null;
|
const clientId = normalizeOptionalId(input.clientId);
|
||||||
if (clientId) {
|
if (clientId) {
|
||||||
const client = await ctx.db.query.clients.findFirst({
|
const client = await ctx.db.query.clients.findFirst({
|
||||||
where: and(eq(clients.id, clientId), eq(clients.createdById, ctx.session.user.id)),
|
where: and(eq(clients.id, clientId), eq(clients.createdById, ctx.session.user.id)),
|
||||||
|
|||||||
+16
-13
@@ -318,6 +318,9 @@ export const businesses = createTable(
|
|||||||
website: d.varchar({ length: 255 }),
|
website: d.varchar({ length: 255 }),
|
||||||
taxId: d.varchar({ length: 100 }),
|
taxId: d.varchar({ length: 100 }),
|
||||||
logoUrl: d.varchar({ length: 500 }),
|
logoUrl: d.varchar({ length: 500 }),
|
||||||
|
logoStorageKey: d.varchar({ length: 500 }),
|
||||||
|
logoMimeType: d.varchar({ length: 100 }),
|
||||||
|
hideNameWithLogo: d.boolean().default(false).notNull(),
|
||||||
isDefault: d.boolean().default(false),
|
isDefault: d.boolean().default(false),
|
||||||
// Email configuration for custom Resend setup
|
// Email configuration for custom Resend setup
|
||||||
resendApiKey: d.varchar({ length: 255 }),
|
resendApiKey: d.varchar({ length: 255 }),
|
||||||
@@ -609,10 +612,7 @@ export const invoicePayments = createTable(
|
|||||||
amount: d.real().notNull(),
|
amount: d.real().notNull(),
|
||||||
currency: d.varchar({ length: 3 }).default("USD").notNull(),
|
currency: d.varchar({ length: 3 }).default("USD").notNull(),
|
||||||
date: d.timestamp().notNull(),
|
date: d.timestamp().notNull(),
|
||||||
method: d
|
method: d.varchar({ length: 50 }).notNull().default("other"), // cash | check | bank_transfer | credit_card | paypal | other
|
||||||
.varchar({ length: 50 })
|
|
||||||
.notNull()
|
|
||||||
.default("other"), // cash | check | bank_transfer | credit_card | paypal | other
|
|
||||||
notes: d.varchar({ length: 500 }),
|
notes: d.varchar({ length: 500 }),
|
||||||
createdById: d
|
createdById: d
|
||||||
.varchar({ length: 255 })
|
.varchar({ length: 255 })
|
||||||
@@ -629,16 +629,19 @@ export const invoicePayments = createTable(
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
export const invoicePaymentsRelations = relations(invoicePayments, ({ one }) => ({
|
export const invoicePaymentsRelations = relations(
|
||||||
invoice: one(invoices, {
|
invoicePayments,
|
||||||
fields: [invoicePayments.invoiceId],
|
({ one }) => ({
|
||||||
references: [invoices.id],
|
invoice: one(invoices, {
|
||||||
|
fields: [invoicePayments.invoiceId],
|
||||||
|
references: [invoices.id],
|
||||||
|
}),
|
||||||
|
createdBy: one(users, {
|
||||||
|
fields: [invoicePayments.createdById],
|
||||||
|
references: [users.id],
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
createdBy: one(users, {
|
);
|
||||||
fields: [invoicePayments.createdById],
|
|
||||||
references: [users.id],
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// ─── Recurring Invoices ───────────────────────────────────────────────────────
|
// ─── Recurring Invoices ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user