mirror of
https://github.com/spliit-app/spliit.git
synced 2026-03-04 20:06:11 +01:00
Attach documents to expenses (#64)
* Upload documents to receipts * Improve documents * Make the feature opt-in * Fix file name issue
This commit is contained in:
committed by
GitHub
parent
11d2e298e8
commit
d43e731fe1
22
README.md
22
README.md
@@ -17,6 +17,7 @@ Spliit is a free and open source alternative to Splitwise. You can either use th
|
|||||||
- [x] Tell the application who you are when opening a group [(#7)](https://github.com/spliit-app/spliit/issues/7)
|
- [x] Tell the application who you are when opening a group [(#7)](https://github.com/spliit-app/spliit/issues/7)
|
||||||
- [x] Assign a category to expenses [(#35)](https://github.com/spliit-app/spliit/issues/35)
|
- [x] Assign a category to expenses [(#35)](https://github.com/spliit-app/spliit/issues/35)
|
||||||
- [x] Search for expenses in a group [(#51)](https://github.com/spliit-app/spliit/issues/51)
|
- [x] Search for expenses in a group [(#51)](https://github.com/spliit-app/spliit/issues/51)
|
||||||
|
- [x] Upload and attach images to expenses [(#63)](https://github.com/spliit-app/spliit/issues/63)
|
||||||
|
|
||||||
### Possible incoming features
|
### Possible incoming features
|
||||||
|
|
||||||
@@ -37,8 +38,8 @@ The project is open to contributions. Feel free to open an issue or even a pull-
|
|||||||
|
|
||||||
If you want to contribute financially and help us keep the application free and without ads, you can also:
|
If you want to contribute financially and help us keep the application free and without ads, you can also:
|
||||||
|
|
||||||
* 💜 [Sponsor me (Sebastien)](https://github.com/sponsors/scastiel), or
|
- 💜 [Sponsor me (Sebastien)](https://github.com/sponsors/scastiel), or
|
||||||
* 💙 [Make a small one-time donation](https://donate.stripe.com/28o3eh96G7hH8k89Ba).
|
- 💙 [Make a small one-time donation](https://donate.stripe.com/28o3eh96G7hH8k89Ba).
|
||||||
|
|
||||||
## Run locally
|
## Run locally
|
||||||
|
|
||||||
@@ -55,6 +56,23 @@ If you want to contribute financially and help us keep the application free and
|
|||||||
3. Run `npm run start-container` to start the postgres and the spliit2 containers
|
3. Run `npm run start-container` to start the postgres and the spliit2 containers
|
||||||
4. You can access the app by browsing to http://localhost:3000
|
4. You can access the app by browsing to http://localhost:3000
|
||||||
|
|
||||||
|
## Opt-in features
|
||||||
|
|
||||||
|
### Expense documents
|
||||||
|
|
||||||
|
Spliit offers users to upload images (to an AWS S3 bucket) and attach them to expenses. To enable this feature:
|
||||||
|
|
||||||
|
- Follow the instructions in the _S3 bucket_ and _IAM user_ sections of [next-s3-upload](https://next-s3-upload.codingvalue.com/setup#s3-bucket) to create and set up an S3 bucket where images will be stored.
|
||||||
|
- Update your environments variables with appropriate values:
|
||||||
|
|
||||||
|
```.env
|
||||||
|
NEXT_PUBLIC_ENABLE_EXPENSE_DOCUMENTS=true
|
||||||
|
S3_UPLOAD_KEY=AAAAAAAAAAAAAAAAAAAA
|
||||||
|
S3_UPLOAD_SECRET=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||||
|
S3_UPLOAD_BUCKET=name-of-s3-bucket
|
||||||
|
S3_UPLOAD_REGION=us-east-1
|
||||||
|
```
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT, see [LICENSE](./LICENSE).
|
MIT, see [LICENSE](./LICENSE).
|
||||||
|
|||||||
@@ -1,4 +1,15 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {}
|
const nextConfig = {
|
||||||
|
images: {
|
||||||
|
remotePatterns:
|
||||||
|
process.env.S3_UPLOAD_BUCKET && process.env.S3_UPLOAD_REGION
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
hostname: `${process.env.S3_UPLOAD_BUCKET}.s3.${process.env.S3_UPLOAD_REGION}.amazonaws.com`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = nextConfig
|
module.exports = nextConfig
|
||||||
|
|||||||
1596
package-lock.json
generated
1596
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -38,6 +38,7 @@
|
|||||||
"lucide-react": "^0.290.0",
|
"lucide-react": "^0.290.0",
|
||||||
"nanoid": "^5.0.4",
|
"nanoid": "^5.0.4",
|
||||||
"next": "^14.1.0",
|
"next": "^14.1.0",
|
||||||
|
"next-s3-upload": "^0.3.4",
|
||||||
"next-themes": "^0.2.1",
|
"next-themes": "^0.2.1",
|
||||||
"next13-progressbar": "^1.1.1",
|
"next13-progressbar": "^1.1.1",
|
||||||
"pg": "^8.11.3",
|
"pg": "^8.11.3",
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Expense" ADD COLUMN "documentUrls" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- The `documentUrls` column on the `Expense` table would be dropped and recreated. This will lead to data loss if there is data in the column.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Expense" DROP COLUMN "documentUrls",
|
||||||
|
ADD COLUMN "documentUrls" JSONB[] DEFAULT ARRAY[]::JSONB[];
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the column `documentUrls` on the `Expense` table. All the data in the column will be lost.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Expense" DROP COLUMN "documentUrls";
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ExpenseDocument" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"url" TEXT NOT NULL,
|
||||||
|
"expenseId" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "ExpenseDocument_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ExpenseDocument" ADD CONSTRAINT "ExpenseDocument_expenseId_fkey" FOREIGN KEY ("expenseId") REFERENCES "Expense"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
10
prisma/migrations/20240128202400_add_doc_info/migration.sql
Normal file
10
prisma/migrations/20240128202400_add_doc_info/migration.sql
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- Added the required column `height` to the `ExpenseDocument` table without a default value. This is not possible if the table is not empty.
|
||||||
|
- Added the required column `width` to the `ExpenseDocument` table without a default value. This is not possible if the table is not empty.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "ExpenseDocument" ADD COLUMN "height" INTEGER NOT NULL,
|
||||||
|
ADD COLUMN "width" INTEGER NOT NULL;
|
||||||
@@ -37,20 +37,30 @@ model Category {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Expense {
|
model Expense {
|
||||||
id String @id
|
id String @id
|
||||||
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||||
expenseDate DateTime @default(dbgenerated("CURRENT_DATE")) @db.Date
|
expenseDate DateTime @default(dbgenerated("CURRENT_DATE")) @db.Date
|
||||||
title String
|
title String
|
||||||
category Category? @relation(fields: [categoryId], references: [id])
|
category Category? @relation(fields: [categoryId], references: [id])
|
||||||
categoryId Int @default(0)
|
categoryId Int @default(0)
|
||||||
amount Int
|
amount Int
|
||||||
paidBy Participant @relation(fields: [paidById], references: [id], onDelete: Cascade)
|
paidBy Participant @relation(fields: [paidById], references: [id], onDelete: Cascade)
|
||||||
paidById String
|
paidById String
|
||||||
paidFor ExpensePaidFor[]
|
paidFor ExpensePaidFor[]
|
||||||
groupId String
|
groupId String
|
||||||
isReimbursement Boolean @default(false)
|
isReimbursement Boolean @default(false)
|
||||||
splitMode SplitMode @default(EVENLY)
|
splitMode SplitMode @default(EVENLY)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
documents ExpenseDocument[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model ExpenseDocument {
|
||||||
|
id String @id
|
||||||
|
url String
|
||||||
|
width Int
|
||||||
|
height Int
|
||||||
|
Expense Expense? @relation(fields: [expenseId], references: [id])
|
||||||
|
expenseId String?
|
||||||
}
|
}
|
||||||
|
|
||||||
enum SplitMode {
|
enum SplitMode {
|
||||||
|
|||||||
8
src/app/api/s3-upload/route.ts
Normal file
8
src/app/api/s3-upload/route.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { sanitizeKey } from 'next-s3-upload'
|
||||||
|
import { POST as route } from 'next-s3-upload/route'
|
||||||
|
|
||||||
|
export const POST = route.configure({
|
||||||
|
key(req, filename) {
|
||||||
|
return sanitizeKey(filename).toLowerCase()
|
||||||
|
},
|
||||||
|
})
|
||||||
145
src/components/expense-documents-input.tsx
Normal file
145
src/components/expense-documents-input.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { ToastAction } from '@/components/ui/toast'
|
||||||
|
import { useToast } from '@/components/ui/use-toast'
|
||||||
|
import { randomId } from '@/lib/api'
|
||||||
|
import { ExpenseFormValues } from '@/lib/schemas'
|
||||||
|
import { Loader2, Plus, Trash, X } from 'lucide-react'
|
||||||
|
import { getImageData, useS3Upload } from 'next-s3-upload'
|
||||||
|
import Image from 'next/image'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
documents: ExpenseFormValues['documents']
|
||||||
|
updateDocuments: (documents: ExpenseFormValues['documents']) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExpenseDocumentsInput({ documents, updateDocuments }: Props) {
|
||||||
|
const [pending, setPending] = useState(false)
|
||||||
|
const { FileInput, openFileDialog, uploadToS3 } = useS3Upload()
|
||||||
|
const { toast } = useToast()
|
||||||
|
|
||||||
|
const handleFileChange = async (file: File) => {
|
||||||
|
const upload = async () => {
|
||||||
|
try {
|
||||||
|
setPending(true)
|
||||||
|
const { width, height } = await getImageData(file)
|
||||||
|
if (!width || !height) throw new Error('Cannot get image dimensions')
|
||||||
|
const { url } = await uploadToS3(file)
|
||||||
|
updateDocuments([...documents, { id: randomId(), url, width, height }])
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
toast({
|
||||||
|
title: 'Error while uploading document',
|
||||||
|
description:
|
||||||
|
'Something wrong happened when uploading the document. Please retry later or select a different file.',
|
||||||
|
variant: 'destructive',
|
||||||
|
action: (
|
||||||
|
<ToastAction altText="Retry" onClick={() => upload()}>
|
||||||
|
Retry
|
||||||
|
</ToastAction>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setPending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
upload()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<FileInput onChange={handleFileChange} accept="image/jpeg,image/png" />
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4 [&_*]:aspect-square">
|
||||||
|
{documents.map((doc) => (
|
||||||
|
<DocumentThumbnail
|
||||||
|
key={doc.id}
|
||||||
|
document={doc}
|
||||||
|
deleteDocument={() => {
|
||||||
|
updateDocuments(documents.filter((d) => d.id !== doc.id))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
type="button"
|
||||||
|
onClick={openFileDialog}
|
||||||
|
className="w-full h-full"
|
||||||
|
disabled={pending}
|
||||||
|
>
|
||||||
|
{pending ? (
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Plus className="w-8 h-8" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DocumentThumbnail({
|
||||||
|
document,
|
||||||
|
deleteDocument,
|
||||||
|
}: {
|
||||||
|
document: ExpenseFormValues['documents'][number]
|
||||||
|
deleteDocument: () => void
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
className="w-full h-full border overflow-hidden rounded shadow-inner"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
width={300}
|
||||||
|
height={300}
|
||||||
|
className="object-contain"
|
||||||
|
src={document.url}
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="p-4 w-fit min-w-[300px] min-h-[300px] max-w-full [&>:last-child]:hidden">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="text-destructive"
|
||||||
|
onClick={() => {
|
||||||
|
deleteDocument()
|
||||||
|
setOpen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash className="w-4 h-4 mr-2" />
|
||||||
|
Delete document
|
||||||
|
</Button>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button variant="ghost">
|
||||||
|
<X className="w-4 h-4 mr-2" /> Close
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
</div>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<Image
|
||||||
|
className="object-contain w-[100vw] h-[100dvh] max-w-[calc(100vw-32px)] max-h-[calc(100dvh-32px-40px-16px)] sm:w-fit sm:h-fit sm:max-w-[calc(100vw-32px-32px)] sm:max-h-[calc(100dvh-32px-40px-32px)]"
|
||||||
|
src={document.url}
|
||||||
|
width={document.width}
|
||||||
|
height={document.height}
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import { AsyncButton } from '@/components/async-button'
|
import { AsyncButton } from '@/components/async-button'
|
||||||
import { CategorySelector } from '@/components/category-selector'
|
import { CategorySelector } from '@/components/category-selector'
|
||||||
|
import { ExpenseDocumentsInput } from '@/components/expense-documents-input'
|
||||||
import { SubmitButton } from '@/components/submit-button'
|
import { SubmitButton } from '@/components/submit-button'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import {
|
import {
|
||||||
@@ -83,6 +84,7 @@ export function ExpenseForm({
|
|||||||
})),
|
})),
|
||||||
splitMode: expense.splitMode,
|
splitMode: expense.splitMode,
|
||||||
isReimbursement: expense.isReimbursement,
|
isReimbursement: expense.isReimbursement,
|
||||||
|
documents: expense.documents,
|
||||||
}
|
}
|
||||||
: searchParams.get('reimbursement')
|
: searchParams.get('reimbursement')
|
||||||
? {
|
? {
|
||||||
@@ -100,6 +102,7 @@ export function ExpenseForm({
|
|||||||
],
|
],
|
||||||
isReimbursement: true,
|
isReimbursement: true,
|
||||||
splitMode: 'EVENLY',
|
splitMode: 'EVENLY',
|
||||||
|
documents: [],
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
title: '',
|
title: '',
|
||||||
@@ -114,6 +117,7 @@ export function ExpenseForm({
|
|||||||
paidBy: getSelectedPayer(),
|
paidBy: getSelectedPayer(),
|
||||||
isReimbursement: false,
|
isReimbursement: false,
|
||||||
splitMode: 'EVENLY',
|
splitMode: 'EVENLY',
|
||||||
|
documents: [],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -506,6 +510,31 @@ export function ExpenseForm({
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{process.env.NEXT_PUBLIC_ENABLE_EXPENSE_DOCUMENTS && (
|
||||||
|
<Card className="mt-4">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex justify-between">
|
||||||
|
<span>Attach documents</span>
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
See and attach receipts to the expense.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="documents"
|
||||||
|
render={({ field }) => (
|
||||||
|
<ExpenseDocumentsInput
|
||||||
|
documents={field.value}
|
||||||
|
updateDocuments={field.onChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex mt-4 gap-2">
|
<div className="flex mt-4 gap-2">
|
||||||
<SubmitButton
|
<SubmitButton
|
||||||
loadingContent={isCreate ? <>Creating…</> : <>Saving…</>}
|
loadingContent={isCreate ? <>Creating…</> : <>Saving…</>}
|
||||||
|
|||||||
@@ -62,6 +62,16 @@ export async function createExpense(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
isReimbursement: expenseFormValues.isReimbursement,
|
isReimbursement: expenseFormValues.isReimbursement,
|
||||||
|
documents: {
|
||||||
|
createMany: {
|
||||||
|
data: expenseFormValues.documents.map((doc) => ({
|
||||||
|
id: randomId(),
|
||||||
|
url: doc.url,
|
||||||
|
width: doc.width,
|
||||||
|
height: doc.height,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -159,6 +169,22 @@ export async function updateExpense(
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
isReimbursement: expenseFormValues.isReimbursement,
|
isReimbursement: expenseFormValues.isReimbursement,
|
||||||
|
documents: {
|
||||||
|
connectOrCreate: expenseFormValues.documents.map((doc) => ({
|
||||||
|
create: doc,
|
||||||
|
where: { id: doc.id },
|
||||||
|
})),
|
||||||
|
deleteMany: existingExpense.documents
|
||||||
|
.filter(
|
||||||
|
(existingDoc) =>
|
||||||
|
!expenseFormValues.documents.some(
|
||||||
|
(doc) => doc.id === existingDoc.id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.map((doc) => ({
|
||||||
|
id: doc.id,
|
||||||
|
})),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -231,6 +257,6 @@ export async function getExpense(groupId: string, expenseId: string) {
|
|||||||
const prisma = await getPrisma()
|
const prisma = await getPrisma()
|
||||||
return prisma.expense.findUnique({
|
return prisma.expense.findUnique({
|
||||||
where: { id: expenseId },
|
where: { id: expenseId },
|
||||||
include: { paidBy: true, paidFor: true, category: true },
|
include: { paidBy: true, paidFor: true, category: true, documents: true },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,37 @@
|
|||||||
import { z } from 'zod'
|
import { ZodIssueCode, z } from 'zod'
|
||||||
|
|
||||||
const envSchema = z.object({
|
const envSchema = z
|
||||||
POSTGRES_URL_NON_POOLING: z.string().url(),
|
.object({
|
||||||
POSTGRES_PRISMA_URL: z.string().url(),
|
POSTGRES_URL_NON_POOLING: z.string().url(),
|
||||||
NEXT_PUBLIC_BASE_URL: z
|
POSTGRES_PRISMA_URL: z.string().url(),
|
||||||
.string()
|
NEXT_PUBLIC_BASE_URL: z
|
||||||
.optional()
|
.string()
|
||||||
.default(
|
.optional()
|
||||||
process.env.VERCEL_URL
|
.default(
|
||||||
? `https://${process.env.VERCEL_URL}`
|
process.env.VERCEL_URL
|
||||||
: 'http://localhost:3000',
|
? `https://${process.env.VERCEL_URL}`
|
||||||
),
|
: 'http://localhost:3000',
|
||||||
})
|
),
|
||||||
|
NEXT_PUBLIC_ENABLE_EXPENSE_DOCUMENTS: z.coerce.boolean().default(false),
|
||||||
|
S3_UPLOAD_KEY: z.string().optional(),
|
||||||
|
S3_UPLOAD_SECRET: z.string().optional(),
|
||||||
|
S3_UPLOAD_BUCKET: z.string().optional(),
|
||||||
|
S3_UPLOAD_REGION: z.string().optional(),
|
||||||
|
})
|
||||||
|
.superRefine((env, ctx) => {
|
||||||
|
if (
|
||||||
|
env.NEXT_PUBLIC_ENABLE_EXPENSE_DOCUMENTS &&
|
||||||
|
(!env.S3_UPLOAD_BUCKET ||
|
||||||
|
!env.S3_UPLOAD_KEY ||
|
||||||
|
!env.S3_UPLOAD_REGION ||
|
||||||
|
!env.S3_UPLOAD_SECRET)
|
||||||
|
) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: ZodIssueCode.custom,
|
||||||
|
message:
|
||||||
|
'If NEXT_PUBLIC_ENABLE_EXPENSE_DOCUMENTS is specified, then S3_* must be specified too',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
export const env = envSchema.parse(process.env)
|
export const env = envSchema.parse(process.env)
|
||||||
|
|||||||
@@ -105,6 +105,16 @@ export const expenseFormSchema = z
|
|||||||
)
|
)
|
||||||
.default('EVENLY'),
|
.default('EVENLY'),
|
||||||
isReimbursement: z.boolean(),
|
isReimbursement: z.boolean(),
|
||||||
|
documents: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
id: z.string(),
|
||||||
|
url: z.string().url(),
|
||||||
|
width: z.number().int().min(1),
|
||||||
|
height: z.number().int().min(1),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.default([]),
|
||||||
})
|
})
|
||||||
.superRefine((expense, ctx) => {
|
.superRefine((expense, ctx) => {
|
||||||
let sum = 0
|
let sum = 0
|
||||||
|
|||||||
Reference in New Issue
Block a user