diff --git a/.gitignore b/.gitignore index 8d8a4fc..9d718ff 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ yarn-error.log* # local env files .env*.local *.env +!scripts/build.env # vercel .vercel diff --git a/Dockerfile b/Dockerfile index d31173b..3fefd3c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,22 +1,47 @@ -FROM node:21-slim as base +FROM node:21-alpine as base + +WORKDIR /usr/app +COPY ./package.json \ + ./package-lock.json \ + ./next.config.js \ + ./tsconfig.json \ + ./reset.d.ts \ + ./tailwind.config.js \ + ./postcss.config.js ./ +COPY ./scripts ./scripts +COPY ./prisma ./prisma +COPY ./src ./src + +RUN apk add --no-cache openssl && \ + npm ci --ignore-scripts && \ + npx prisma generate + +ENV NEXT_TELEMETRY_DISABLED=1 + +COPY scripts/build.env .env +RUN npm run build + +RUN rm -r .next/cache + +FROM node:21-alpine as runtime-deps + +WORKDIR /usr/app +COPY --from=base /usr/app/package.json /usr/app/package-lock.json ./ +COPY --from=base /usr/app/prisma ./prisma + +RUN npm ci --omit=dev --omit=optional --ignore-scripts && \ + npx prisma generate + +FROM node:21-alpine as runner EXPOSE 3000/tcp WORKDIR /usr/app -COPY ./ ./ -RUN apt update && \ - apt install openssl -y && \ - apt clean && \ - apt autoclean && \ - apt autoremove && \ - npm ci --ignore-scripts && \ - npm install -g prisma && \ - prisma generate +COPY --from=base /usr/app/package.json /usr/app/package-lock.json ./ +COPY --from=runtime-deps /usr/app/node_modules ./node_modules +COPY ./public ./public +COPY ./scripts ./scripts +COPY --from=base /usr/app/prisma ./prisma +COPY --from=base /usr/app/.next ./.next -# env vars needed for build not to fail -ARG POSTGRES_PRISMA_URL -ARG POSTGRES_URL_NON_POOLING - -RUN npm run build - -ENTRYPOINT ["/usr/app/scripts/container-entrypoint.sh"] +ENTRYPOINT ["/bin/sh", "/usr/app/scripts/container-entrypoint.sh"] diff --git a/package-lock.json b/package-lock.json index 9a55903..52b0e54 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,6 +39,7 @@ "next13-progressbar": "^1.1.1", "openai": "^4.25.0", "pg": "^8.11.3", + "prisma": "^5.7.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-hook-form": "^7.47.0", @@ -65,7 +66,6 @@ "postcss": "^8", "prettier": "^3.0.3", "prettier-plugin-organize-imports": "^3.2.3", - "prisma": "^5.7.0", "tailwindcss": "^3", "tsconfig-paths": "^4.2.0", "typescript": "^5.3.3" @@ -1429,13 +1429,10 @@ } }, "node_modules/@prisma/client": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.6.0.tgz", - "integrity": "sha512-mUDefQFa1wWqk4+JhKPYq8BdVoFk9NFMBXUI8jAkBfQTtgx8WPx02U2HB/XbAz3GSUJpeJOKJQtNvaAIDs6sug==", + "version": "5.9.1", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.9.1.tgz", + "integrity": "sha512-caSOnG4kxcSkhqC/2ShV7rEoWwd3XrftokxJqOCMVvia4NYV/TPtJlS9C2os3Igxw/Qyxumj9GBQzcStzECvtQ==", "hasInstallScript": true, - "dependencies": { - "@prisma/engines-version": "5.6.0-32.e95e739751f42d8ca026f6b910f5a2dc5adeaeee" - }, "engines": { "node": ">=16.13" }, @@ -1449,59 +1446,48 @@ } }, "node_modules/@prisma/debug": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.7.0.tgz", - "integrity": "sha512-tZ+MOjWlVvz1kOEhNYMa4QUGURY+kgOUBqLHYIV8jmCsMuvA1tWcn7qtIMLzYWCbDcQT4ZS8xDgK0R2gl6/0wA==", - "devOptional": true + "version": "5.9.1", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.9.1.tgz", + "integrity": "sha512-yAHFSFCg8KVoL0oRUno3m60GAjsUKYUDkQ+9BA2X2JfVR3kRVSJFc/GpQ2fSORi4pSHZR9orfM4UC9OVXIFFTA==" }, "node_modules/@prisma/engines": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.7.0.tgz", - "integrity": "sha512-TkOMgMm60n5YgEKPn9erIvFX2/QuWnl3GBo6yTRyZKk5O5KQertXiNnrYgSLy0SpsKmhovEPQb+D4l0SzyE7XA==", - "devOptional": true, + "version": "5.9.1", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.9.1.tgz", + "integrity": "sha512-gkdXmjxQ5jktxWNdDA5aZZ6R8rH74JkoKq6LD5mACSvxd2vbqWeWIOV0Py5wFC8vofOYShbt6XUeCIUmrOzOnQ==", "hasInstallScript": true, "dependencies": { - "@prisma/debug": "5.7.0", - "@prisma/engines-version": "5.7.0-41.79fb5193cf0a8fdbef536e4b4a159cad677ab1b9", - "@prisma/fetch-engine": "5.7.0", - "@prisma/get-platform": "5.7.0" + "@prisma/debug": "5.9.1", + "@prisma/engines-version": "5.9.0-32.23fdc5965b1e05fc54e5f26ed3de66776b93de64", + "@prisma/fetch-engine": "5.9.1", + "@prisma/get-platform": "5.9.1" } }, - "node_modules/@prisma/engines-version": { - "version": "5.6.0-32.e95e739751f42d8ca026f6b910f5a2dc5adeaeee", - "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.6.0-32.e95e739751f42d8ca026f6b910f5a2dc5adeaeee.tgz", - "integrity": "sha512-UoFgbV1awGL/3wXuUK3GDaX2SolqczeeJ5b4FVec9tzeGbSWJboPSbT0psSrmgYAKiKnkOPFSLlH6+b+IyOwAw==" - }, "node_modules/@prisma/engines/node_modules/@prisma/engines-version": { - "version": "5.7.0-41.79fb5193cf0a8fdbef536e4b4a159cad677ab1b9", - "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.7.0-41.79fb5193cf0a8fdbef536e4b4a159cad677ab1b9.tgz", - "integrity": "sha512-V6tgRVi62jRwTm0Hglky3Scwjr/AKFBFtS+MdbsBr7UOuiu1TKLPc6xfPiyEN1+bYqjEtjxwGsHgahcJsd1rNg==", - "devOptional": true + "version": "5.9.0-32.23fdc5965b1e05fc54e5f26ed3de66776b93de64", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.9.0-32.23fdc5965b1e05fc54e5f26ed3de66776b93de64.tgz", + "integrity": "sha512-HFl7275yF0FWbdcNvcSRbbu9JCBSLMcurYwvWc8WGDnpu7APxQo2ONtZrUggU3WxLxUJ2uBX+0GOFIcJeVeOOQ==" }, "node_modules/@prisma/fetch-engine": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.7.0.tgz", - "integrity": "sha512-zIn/qmO+N/3FYe7/L9o+yZseIU8ivh4NdPKSkQRIHfg2QVTVMnbhGoTcecbxfVubeTp+DjcbjS0H9fCuM4W04w==", - "devOptional": true, + "version": "5.9.1", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.9.1.tgz", + "integrity": "sha512-l0goQOMcNVOJs1kAcwqpKq3ylvkD9F04Ioe1oJoCqmz05mw22bNAKKGWuDd3zTUoUZr97va0c/UfLNru+PDmNA==", "dependencies": { - "@prisma/debug": "5.7.0", - "@prisma/engines-version": "5.7.0-41.79fb5193cf0a8fdbef536e4b4a159cad677ab1b9", - "@prisma/get-platform": "5.7.0" + "@prisma/debug": "5.9.1", + "@prisma/engines-version": "5.9.0-32.23fdc5965b1e05fc54e5f26ed3de66776b93de64", + "@prisma/get-platform": "5.9.1" } }, "node_modules/@prisma/fetch-engine/node_modules/@prisma/engines-version": { - "version": "5.7.0-41.79fb5193cf0a8fdbef536e4b4a159cad677ab1b9", - "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.7.0-41.79fb5193cf0a8fdbef536e4b4a159cad677ab1b9.tgz", - "integrity": "sha512-V6tgRVi62jRwTm0Hglky3Scwjr/AKFBFtS+MdbsBr7UOuiu1TKLPc6xfPiyEN1+bYqjEtjxwGsHgahcJsd1rNg==", - "devOptional": true + "version": "5.9.0-32.23fdc5965b1e05fc54e5f26ed3de66776b93de64", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.9.0-32.23fdc5965b1e05fc54e5f26ed3de66776b93de64.tgz", + "integrity": "sha512-HFl7275yF0FWbdcNvcSRbbu9JCBSLMcurYwvWc8WGDnpu7APxQo2ONtZrUggU3WxLxUJ2uBX+0GOFIcJeVeOOQ==" }, "node_modules/@prisma/get-platform": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.7.0.tgz", - "integrity": "sha512-ZeV/Op4bZsWXuw5Tg05WwRI8BlKiRFhsixPcAM+5BKYSiUZiMKIi713tfT3drBq8+T0E1arNZgYSA9QYcglWNA==", - "devOptional": true, + "version": "5.9.1", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.9.1.tgz", + "integrity": "sha512-6OQsNxTyhvG+T2Ksr8FPFpuPeL4r9u0JF0OZHUBI/Uy9SS43sPyAIutt4ZEAyqWQt104ERh70EZedkHZKsnNbg==", "dependencies": { - "@prisma/debug": "5.7.0" + "@prisma/debug": "5.9.1" } }, "node_modules/@radix-ui/number": { @@ -7430,13 +7416,12 @@ } }, "node_modules/prisma": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.7.0.tgz", - "integrity": "sha512-0rcfXO2ErmGAtxnuTNHQT9ztL0zZheQjOI/VNJzdq87C3TlGPQtMqtM+KCwU6XtmkoEr7vbCQqA7HF9IY0ST+Q==", - "devOptional": true, + "version": "5.9.1", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.9.1.tgz", + "integrity": "sha512-Hy/8KJZz0ELtkw4FnG9MS9rNWlXcJhf98Z2QMqi0QiVMoS8PzsBkpla0/Y5hTlob8F3HeECYphBjqmBxrluUrQ==", "hasInstallScript": true, "dependencies": { - "@prisma/engines": "5.7.0" + "@prisma/engines": "5.9.1" }, "bin": { "prisma": "build/index.js" diff --git a/package.json b/package.json index 1451f5a..5706b1e 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,8 @@ "ts-pattern": "^5.0.6", "uuid": "^9.0.1", "vaul": "^0.8.0", - "zod": "^3.22.4" + "zod": "^3.22.4", + "prisma": "^5.7.0" }, "devDependencies": { "@total-typescript/ts-reset": "^0.5.1", @@ -70,7 +71,6 @@ "postcss": "^8", "prettier": "^3.0.3", "prettier-plugin-organize-imports": "^3.2.3", - "prisma": "^5.7.0", "tailwindcss": "^3", "tsconfig-paths": "^4.2.0", "typescript": "^5.3.3" diff --git a/scripts/build-image.sh b/scripts/build-image.sh index 95b8acb..c900089 100755 --- a/scripts/build-image.sh +++ b/scripts/build-image.sh @@ -5,9 +5,6 @@ SPLIIT_VERSION=$(node -p -e "require('./package.json').version") # we need to set dummy data for POSTGRES env vars in order for build not to fail docker buildx build \ - --no-cache \ - --build-arg POSTGRES_PRISMA_URL=postgresql://build:@db \ - --build-arg POSTGRES_URL_NON_POOLING=postgresql://build:@db \ -t ${SPLIIT_APP_NAME}:${SPLIIT_VERSION} \ -t ${SPLIIT_APP_NAME}:latest \ . diff --git a/scripts/build.env b/scripts/build.env new file mode 100644 index 0000000..98b3fac --- /dev/null +++ b/scripts/build.env @@ -0,0 +1,22 @@ +# build file that contains all possible env vars with mocked values +# as most of them are used at build time in order to have the production build to work properly + +# db +POSTGRES_PASSWORD=1234 + +# app +POSTGRES_PRISMA_URL=postgresql://postgres:${POSTGRES_PASSWORD}@db +POSTGRES_URL_NON_POOLING=postgresql://postgres:${POSTGRES_PASSWORD}@db + +# app-minio +NEXT_PUBLIC_ENABLE_EXPENSE_DOCUMENTS=false +S3_UPLOAD_KEY=AAAAAAAAAAAAAAAAAAAA +S3_UPLOAD_SECRET=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +S3_UPLOAD_BUCKET=spliit +S3_UPLOAD_REGION=eu-north-1 +S3_UPLOAD_ENDPOINT=s3://minio.example.com + +# app-openai +NEXT_PUBLIC_ENABLE_RECEIPT_EXTRACT=false +OPENAI_API_KEY=XXXXXXXXXXXXXXXXXXXXXXXXXXXX +NEXT_PUBLIC_ENABLE_CATEGORY_EXTRACT=false diff --git a/scripts/container-entrypoint.sh b/scripts/container-entrypoint.sh index 364787e..5871314 100755 --- a/scripts/container-entrypoint.sh +++ b/scripts/container-entrypoint.sh @@ -1,3 +1,6 @@ #!/bin/bash -prisma migrate deploy + +set -euxo pipefail + +npx prisma migrate deploy npm run start diff --git a/src/app/groups/[groupId]/expenses/[expenseId]/edit/page.tsx b/src/app/groups/[groupId]/expenses/[expenseId]/edit/page.tsx index 65b2b50..2514a77 100644 --- a/src/app/groups/[groupId]/expenses/[expenseId]/edit/page.tsx +++ b/src/app/groups/[groupId]/expenses/[expenseId]/edit/page.tsx @@ -6,6 +6,7 @@ import { getExpense, updateExpense, } from '@/lib/api' +import { getRuntimeFeatureFlags } from '@/lib/featureFlags' import { expenseFormSchema } from '@/lib/schemas' import { Metadata } from 'next' import { notFound, redirect } from 'next/navigation' @@ -47,6 +48,7 @@ export default async function EditExpensePage({ categories={categories} onSubmit={updateExpenseAction} onDelete={deleteExpenseAction} + runtimeFeatureFlags={await getRuntimeFeatureFlags()} /> ) diff --git a/src/app/groups/[groupId]/expenses/create/page.tsx b/src/app/groups/[groupId]/expenses/create/page.tsx index 2d580d3..6398fd1 100644 --- a/src/app/groups/[groupId]/expenses/create/page.tsx +++ b/src/app/groups/[groupId]/expenses/create/page.tsx @@ -1,6 +1,7 @@ import { cached } from '@/app/cached-functions' import { ExpenseForm } from '@/components/expense-form' import { createExpense, getCategories } from '@/lib/api' +import { getRuntimeFeatureFlags } from '@/lib/featureFlags' import { expenseFormSchema } from '@/lib/schemas' import { Metadata } from 'next' import { notFound, redirect } from 'next/navigation' @@ -32,6 +33,7 @@ export default async function ExpensePage({ group={group} categories={categories} onSubmit={createExpenseAction} + runtimeFeatureFlags={await getRuntimeFeatureFlags()} /> ) diff --git a/src/components/expense-form.tsx b/src/components/expense-form.tsx index fd3f6e2..a7e549c 100644 --- a/src/components/expense-form.tsx +++ b/src/components/expense-form.tsx @@ -35,6 +35,7 @@ import { SelectValue, } from '@/components/ui/select' import { getCategories, getExpense, getGroup, randomId } from '@/lib/api' +import { RuntimeFeatureFlags } from '@/lib/featureFlags' import { ExpenseFormValues, expenseFormSchema } from '@/lib/schemas' import { cn } from '@/lib/utils' import { zodResolver } from '@hookform/resolvers/zod' @@ -52,6 +53,7 @@ export type Props = { categories: NonNullable>> onSubmit: (values: ExpenseFormValues) => Promise onDelete?: () => Promise + runtimeFeatureFlags: RuntimeFeatureFlags } export function ExpenseForm({ @@ -60,6 +62,7 @@ export function ExpenseForm({ categories, onSubmit, onDelete, + runtimeFeatureFlags, }: Props) { const isCreate = expense === undefined const searchParams = useSearchParams() @@ -161,7 +164,7 @@ export function ExpenseForm({ {...field} onBlur={async () => { field.onBlur() // avoid skipping other blur event listeners since we overwrite `field` - if (process.env.NEXT_PUBLIC_ENABLE_CATEGORY_EXTRACT) { + if (runtimeFeatureFlags.enableCategoryExtract) { setCategoryLoading(true) const { categoryId } = await extractCategoryFromTitle( field.value, @@ -541,7 +544,7 @@ export function ExpenseForm({ - {process.env.NEXT_PUBLIC_ENABLE_EXPENSE_DOCUMENTS && ( + {runtimeFeatureFlags.enableExpenseDocuments && ( diff --git a/src/lib/env.ts b/src/lib/env.ts index 927e756..920df14 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -1,5 +1,10 @@ import { ZodIssueCode, z } from 'zod' +const interpretEnvVarAsBool = (val: unknown): boolean => { + if (typeof val !== 'string') return false + return ['true', 'yes', '1', 'on'].includes(val.toLowerCase()) +} + const envSchema = z .object({ POSTGRES_URL_NON_POOLING: z.string().url(), @@ -12,14 +17,23 @@ const envSchema = z ? `https://${process.env.VERCEL_URL}` : 'http://localhost:3000', ), - NEXT_PUBLIC_ENABLE_EXPENSE_DOCUMENTS: z.coerce.boolean().default(false), + NEXT_PUBLIC_ENABLE_EXPENSE_DOCUMENTS: z.preprocess( + interpretEnvVarAsBool, + z.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(), S3_UPLOAD_ENDPOINT: z.string().optional(), - NEXT_PUBLIC_ENABLE_RECEIPT_EXTRACT: z.coerce.boolean().default(false), - NEXT_PUBLIC_ENABLE_CATEGORY_EXTRACT: z.coerce.boolean().default(false), + NEXT_PUBLIC_ENABLE_RECEIPT_EXTRACT: z.preprocess( + interpretEnvVarAsBool, + z.boolean().default(false), + ), + NEXT_PUBLIC_ENABLE_CATEGORY_EXTRACT: z.preprocess( + interpretEnvVarAsBool, + z.boolean().default(false), + ), OPENAI_API_KEY: z.string().optional(), }) .superRefine((env, ctx) => { diff --git a/src/lib/featureFlags.ts b/src/lib/featureFlags.ts new file mode 100644 index 0000000..e57ca9c --- /dev/null +++ b/src/lib/featureFlags.ts @@ -0,0 +1,15 @@ +'use server' + +import { env } from './env' + +export async function getRuntimeFeatureFlags() { + return { + enableExpenseDocuments: env.NEXT_PUBLIC_ENABLE_EXPENSE_DOCUMENTS, + enableReceiptExtract: env.NEXT_PUBLIC_ENABLE_RECEIPT_EXTRACT, + enableCategoryExtract: env.NEXT_PUBLIC_ENABLE_CATEGORY_EXTRACT, + } +} + +export type RuntimeFeatureFlags = Awaited< + ReturnType +>