diff --git a/.github/workflows/postgres.yaml b/.github/workflows/postgres.yaml index 2995059c..de8ee1fd 100644 --- a/.github/workflows/postgres.yaml +++ b/.github/workflows/postgres.yaml @@ -156,11 +156,11 @@ jobs: --amend ghcr.io/oss-apps/postgres-amd64:$GIT_SHA \ --amend ghcr.io/oss-apps/postgres-arm64:$GIT_SHA \ - docker manifest push ghcr.io/oss-apps/postgres:$CHANNEL + docker manifest push ghcr.io/oss-apps/postgres:$DB_VERSION docker manifest push ghcr.io/oss-apps/postgres:$GIT_SHA - name: Create and push "latest" tag if applicable - if: ${{ github.event.inputs.is_latest == true }} + if: github.event.inputs.is_latest == 'true' run: | docker manifest create \ ossapps/postgres:latest \ diff --git a/.lintstagedrc.js b/.lintstagedrc.js index d916607d..6181ec62 100644 --- a/.lintstagedrc.js +++ b/.lintstagedrc.js @@ -5,6 +5,9 @@ const config = { // Run oxlint on JavaScript/TypeScript files '*.{js,jsx,ts,tsx}': ['oxlint --type-aware --fix'], + // Format prisma.schema files + '*.prisma': ['pnpm prisma format'], + // Type check TypeScript files (optional - can be slow) // '*.{ts,tsx}': () => 'tsc --noEmit', }; diff --git a/components.json b/components.json index 5e65b79c..034e70dc 100644 --- a/components.json +++ b/components.json @@ -1,10 +1,10 @@ { "$schema": "https://ui.shadcn.com/schema.json", - "style": "default", + "style": "new-york", "rsc": false, "tsx": true, "tailwind": { - "config": "tailwind.config.ts", + "config": "", "css": "src/styles/globals.css", "baseColor": "gray", "cssVariables": true, diff --git a/docker/dev/compose.yml b/docker/dev/compose.yml index dda1b9a2..19ba3326 100644 --- a/docker/dev/compose.yml +++ b/docker/dev/compose.yml @@ -12,13 +12,13 @@ services: - POSTGRES_PORT=${POSTGRES_PORT:-5432} volumes: - database:/var/lib/postgresql/data - ports: - - '${POSTGRES_PORT:-5432}:${POSTGRES_PORT:-5432}' command: > postgres -c shared_preload_libraries=pg_cron -c cron.database_name=${POSTGRES_DB:-splitpro} - -c cron.timezone=${TZ:-UTC} + -c cron.timezone=UTC + ports: + - '${POSTGRES_PORT:-5432}:${POSTGRES_PORT:-5432}' minio: image: minio/minio:RELEASE.2025-04-22T22-12-26Z diff --git a/docker/postgres/Dockerfile b/docker/postgres/Dockerfile index ef77aed1..019fbf17 100644 --- a/docker/postgres/Dockerfile +++ b/docker/postgres/Dockerfile @@ -1,9 +1,11 @@ ARG POSTGRES_MAJOR=16 -ARG POSTGRES_MINOR=16.10 +ARG POSTGRES_MINOR=10 ARG POSTGRES_BASE=trixie FROM postgres:${POSTGRES_MAJOR}.${POSTGRES_MINOR}-${POSTGRES_BASE} +ARG POSTGRES_MAJOR + # Install pg_cron (from PGDG packages) RUN apt-get update && apt-get install -y \ postgresql-${POSTGRES_MAJOR}-cron \ diff --git a/docker/prod/compose.yml b/docker/prod/compose.yml index c68c9f67..5323f3d6 100644 --- a/docker/prod/compose.yml +++ b/docker/prod/compose.yml @@ -15,16 +15,16 @@ services: interval: 10s timeout: 5s retries: 5 + command: > + postgres + -c shared_preload_libraries=pg_cron + -c cron.database_name=${POSTGRES_DB:-splitpro} + -c cron.timezone=UTC # ports: # - "5432:5432" env_file: .env volumes: - database:/var/lib/postgresql/data - command: > - postgres - -c shared_preload_libraries=pg_cron - -c cron.database_name=${POSTGRES_DB:-splitpro} - -c cron.timezone=${TZ:-UTC} splitpro: image: ossapps/splitpro:latest diff --git a/package.json b/package.json index 0cc069f3..3f67c883 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,8 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", "cmdk": "^1.1.1", + "cron-parser": "^4.9.0", + "cronstrue": "^3.3.0", "date-fns": "^3.3.1", "i18next": "^25.2.1", "i18next-browser-languagedetector": "^8.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 608124a6..db8b500e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,6 +66,12 @@ importers: cmdk: specifier: ^1.1.1 version: 1.1.1(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + cron-parser: + specifier: ^4.9.0 + version: 4.9.0 + cronstrue: + specifier: ^3.3.0 + version: 3.3.0 date-fns: specifier: ^3.3.1 version: 3.6.0 @@ -3511,6 +3517,14 @@ packages: create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + cron-parser@4.9.0: + resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==} + engines: {node: '>=12.0.0'} + + cronstrue@3.3.0: + resolution: {integrity: sha512-iwJytzJph1hosXC09zY8F5ACDJKerr0h3/2mOxg9+5uuFObYlgK0m35uUPk4GCvhHc2abK7NfnR9oMqY0qZFAg==} + hasBin: true + cross-fetch@4.0.0: resolution: {integrity: sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==} @@ -4571,6 +4585,10 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + luxon@3.7.2: + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} + engines: {node: '>=12'} + magic-string@0.25.9: resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==} @@ -9850,6 +9868,12 @@ snapshots: create-require@1.1.1: {} + cron-parser@4.9.0: + dependencies: + luxon: 3.7.2 + + cronstrue@3.3.0: {} + cross-fetch@4.0.0: dependencies: node-fetch: 2.7.0 @@ -11202,6 +11226,8 @@ snapshots: dependencies: react: 19.1.1 + luxon@3.7.2: {} + magic-string@0.25.9: dependencies: sourcemap-codec: 1.4.8 diff --git a/prisma.config.ts b/prisma.config.ts index 06d19cf2..7f89f308 100644 --- a/prisma.config.ts +++ b/prisma.config.ts @@ -8,4 +8,10 @@ export default { path: path.join('prisma', 'migrations'), seed: 'tsx prisma/seed.ts', }, + experimental: { + externalTables: true, + }, + tables: { + external: ['cron.job', 'cron.job_run_details'], + }, } satisfies PrismaConfig; diff --git a/prisma/migrations/20241026095834_add_gocardless_bank_transaction_integration/migration.sql b/prisma/migrations/20250907160149_add_gocardless_bank_transaction_integration/migration.sql similarity index 100% rename from prisma/migrations/20241026095834_add_gocardless_bank_transaction_integration/migration.sql rename to prisma/migrations/20250907160149_add_gocardless_bank_transaction_integration/migration.sql diff --git a/prisma/migrations/20250920192654_recurrence/migration.sql b/prisma/migrations/20250920192654_recurrence/migration.sql new file mode 100644 index 00000000..205161db --- /dev/null +++ b/prisma/migrations/20250920192654_recurrence/migration.sql @@ -0,0 +1,76 @@ +-- AlterTable +ALTER TABLE "public"."Expense" ADD COLUMN "recurrenceId" INTEGER; + +-- CreateTable +CREATE TABLE "public"."ExpenseRecurrence" ( + "id" SERIAL NOT NULL, + "jobId" BIGINT NOT NULL, + "notified" BOOLEAN NOT NULL DEFAULT true, + + CONSTRAINT "ExpenseRecurrence_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "ExpenseRecurrence_jobId_key" ON "public"."ExpenseRecurrence"("jobId"); + +-- AddForeignKey +ALTER TABLE "public"."Expense" ADD CONSTRAINT "Expense_recurrenceId_fkey" FOREIGN KEY ("recurrenceId") REFERENCES "public"."ExpenseRecurrence"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +CREATE OR REPLACE FUNCTION duplicate_expense_with_participants(original_expense_id UUID) +RETURNS UUID AS $$ +DECLARE + new_expense_id UUID; +BEGIN + -- STEP 1: Insert the new expense and get its new ID + INSERT INTO "Expense" ( + "paidBy", "addedBy", name, category, amount, "splitType", "expenseDate", "updatedAt", + currency, "groupId", "updatedBy", "recurrenceId" + ) + SELECT + "paidBy", "addedBy", name, category, amount, "splitType", now(), now(), + currency, "groupId", "updatedBy", "recurrenceId" + FROM "Expense" + WHERE id = original_expense_id + RETURNING id INTO new_expense_id; + + -- STEP 2: Insert the new expense participants + INSERT INTO "ExpenseParticipant" ( + "expenseId", "userId", amount + ) + SELECT + new_expense_id, "userId", amount + FROM "ExpenseParticipant" + WHERE "expenseId" = original_expense_id; + + -- STEP 3: Set notified to false in the ExpenseRecurrence table + UPDATE "ExpenseRecurrence" + SET notified = false + WHERE id = (SELECT "recurrenceId" FROM "Expense" WHERE id = original_expense_id); + + -- STEP 4: Return the new expense ID + RETURN new_expense_id; +END; +$$ LANGUAGE plpgsql; + +DO $$ +BEGIN +IF current_database() NOT LIKE 'prisma_migrate_shadow_db%' THEN + CREATE EXTENSION IF NOT EXISTS pg_cron; +ELSE + CREATE SCHEMA IF NOT EXISTS cron; + CREATE TABLE IF NOT EXISTS cron.job ( + jobid BIGINT PRIMARY KEY, + schedule TEXT, + command TEXT, + nodename TEXT, + nodeport INTEGER, + database TEXT, + username TEXT, + active BOOLEAN, + jobname TEXT + ); +END IF; +END $$; + +-- AddForeignKey +ALTER TABLE "public"."ExpenseRecurrence" ADD CONSTRAINT "ExpenseRecurrence_jobId_fkey" FOREIGN KEY ("jobId") REFERENCES "cron"."job"("jobid") ON DELETE CASCADE ON UPDATE CASCADE; \ No newline at end of file diff --git a/prisma/migrations/20250921172223_add_expense_uuid/migration.sql b/prisma/migrations/20250921172223_add_expense_uuid/migration.sql new file mode 100644 index 00000000..c64c2e3b --- /dev/null +++ b/prisma/migrations/20250921172223_add_expense_uuid/migration.sql @@ -0,0 +1,46 @@ +/* + Warnings: + + - The `otherConversion` column on the `Expense` table would be dropped and recreated. This will lead to data loss if there is data in the column. + - A unique constraint covering the columns `[uuidId]` on the table `Expense` will be added. If there are existing duplicate values, this will fail. + +*/ +-- DropForeignKey +ALTER TABLE "public"."Expense" DROP CONSTRAINT "Expense_otherConversion_fkey"; + +-- DropForeignKey +ALTER TABLE "public"."ExpenseNote" DROP CONSTRAINT "ExpenseNote_expenseId_fkey"; + +-- DropForeignKey +ALTER TABLE "public"."ExpenseParticipant" DROP CONSTRAINT "ExpenseParticipant_expenseId_fkey"; + +-- AlterTable +ALTER TABLE "public"."Expense" ADD COLUMN "uuidId" UUID DEFAULT gen_random_uuid(), +DROP COLUMN "otherConversion", +ADD COLUMN "otherConversion" UUID; + +-- AlterTable +ALTER TABLE "public"."ExpenseNote" ADD COLUMN "expenseUuid" UUID; + +-- AlterTable +ALTER TABLE "public"."ExpenseParticipant" ADD COLUMN "expenseUuid" UUID; + +-- CreateIndex +CREATE UNIQUE INDEX "Expense_uuidId_key" ON "public"."Expense"("uuidId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Expense_otherConversion_key" ON "public"."Expense"("otherConversion"); + +-- AddForeignKey +ALTER TABLE "public"."Expense" ADD CONSTRAINT "Expense_otherConversion_fkey" FOREIGN KEY ("otherConversion") REFERENCES "public"."Expense"("uuidId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."ExpenseParticipant" ADD CONSTRAINT "ExpenseParticipant_expenseUuid_fkey" FOREIGN KEY ("expenseUuid") REFERENCES "public"."Expense"("uuidId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."ExpenseNote" ADD CONSTRAINT "ExpenseNote_expenseUuid_fkey" FOREIGN KEY ("expenseUuid") REFERENCES "public"."Expense"("uuidId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Migrate data +UPDATE "public"."Expense" SET "uuidId" = gen_random_uuid() WHERE "uuidId" IS NULL; +UPDATE "public"."ExpenseParticipant" SET "expenseUuid" = (SELECT "uuidId" FROM "public"."Expense" WHERE "id" = "expenseId") WHERE "expenseUuid" IS NULL; +UPDATE "public"."ExpenseNote" SET "expenseUuid" = (SELECT "uuidId" FROM "public"."Expense" WHERE "id" = "expenseId") WHERE "expenseUuid" IS NULL; diff --git a/prisma/migrations/20250921174004_replace_expense_cuid_with_uuid/migration.sql b/prisma/migrations/20250921174004_replace_expense_cuid_with_uuid/migration.sql new file mode 100644 index 00000000..dc001d07 --- /dev/null +++ b/prisma/migrations/20250921174004_replace_expense_cuid_with_uuid/migration.sql @@ -0,0 +1,66 @@ +/* + Warnings: + + - The primary key for the `Expense` table will be changed. If it partially fails, the table could be left without primary key constraint. + - You are about to drop the column `uuidId` on the `Expense` table. All the data in the column will be lost. + - The `id` column on the `Expense` table would be dropped and recreated. This will lead to data loss if there is data in the column. + - You are about to drop the column `expenseUuid` on the `ExpenseNote` table. All the data in the column will be lost. + - The primary key for the `ExpenseParticipant` table will be changed. If it partially fails, the table could be left without primary key constraint. + - You are about to drop the column `expenseUuid` on the `ExpenseParticipant` table. All the data in the column will be lost. + - Changed the type of `expenseId` on the `ExpenseNote` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required. + - Changed the type of `expenseId` on the `ExpenseParticipant` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required. + +*/ +-- DropForeignKey +ALTER TABLE "public"."Expense" DROP CONSTRAINT "Expense_otherConversion_fkey"; + +-- DropForeignKey +ALTER TABLE "public"."ExpenseNote" DROP CONSTRAINT "ExpenseNote_expenseUuid_fkey"; + +-- DropForeignKey +ALTER TABLE "public"."ExpenseParticipant" DROP CONSTRAINT "ExpenseParticipant_expenseUuid_fkey"; + +-- DropIndex +DROP INDEX "public"."Expense_uuidId_key"; + +-- AlterTable: Expense - Drop primary key constraint first +ALTER TABLE "public"."Expense" DROP CONSTRAINT "Expense_pkey"; + +-- AlterTable: Expense - Drop the old id column +ALTER TABLE "public"."Expense" DROP COLUMN "id"; + +-- AlterTable: Expense - Rename uuidId to id +ALTER TABLE "public"."Expense" RENAME COLUMN "uuidId" TO "id"; + +-- AlterTable: Expense - Add primary key constraint +ALTER TABLE "public"."Expense" ADD CONSTRAINT "Expense_pkey" PRIMARY KEY ("id"); + +-- AlterTable: ExpenseNote - Drop old expenseId column +ALTER TABLE "public"."ExpenseNote" DROP COLUMN "expenseId"; + +-- AlterTable: ExpenseNote - Rename expenseUuid to expenseId +ALTER TABLE "public"."ExpenseNote" RENAME COLUMN "expenseUuid" TO "expenseId"; + +-- AlterTable: ExpenseParticipant - Drop primary key constraint first +ALTER TABLE "public"."ExpenseParticipant" DROP CONSTRAINT "ExpenseParticipant_pkey"; + +-- AlterTable: ExpenseParticipant - Drop old expenseId column +ALTER TABLE "public"."ExpenseParticipant" DROP COLUMN "expenseId"; + +-- AlterTable: ExpenseParticipant - Rename expenseUuid to expenseId +ALTER TABLE "public"."ExpenseParticipant" RENAME COLUMN "expenseUuid" TO "expenseId"; + +-- AlterTable: ExpenseParticipant - Add primary key constraint +ALTER TABLE "public"."ExpenseParticipant" ADD CONSTRAINT "ExpenseParticipant_pkey" PRIMARY KEY ("expenseId", "userId"); + +-- AlterTable +ALTER TABLE "public"."ExpenseNote" ALTER COLUMN "expenseId" SET NOT NULL; + +-- AddForeignKey +ALTER TABLE "public"."Expense" ADD CONSTRAINT "Expense_otherConversion_fkey" FOREIGN KEY ("otherConversion") REFERENCES "public"."Expense"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."ExpenseParticipant" ADD CONSTRAINT "ExpenseParticipant_expenseId_fkey" FOREIGN KEY ("expenseId") REFERENCES "public"."Expense"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."ExpenseNote" ADD CONSTRAINT "ExpenseNote_expenseId_fkey" FOREIGN KEY ("expenseId") REFERENCES "public"."Expense"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d3e94769..a5e68d40 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1,221 +1,289 @@ -// This is your Prisma schema file, -// learn more about it in the docs: https://pris.ly/d/prisma-schema - generator client { - provider = "prisma-client-js" - previewFeatures = ["relationJoins"] + provider = "prisma-client-js" + previewFeatures = ["relationJoins"] } datasource db { - provider = "postgresql" - // NOTE: When using mysql or sqlserver, uncomment the @db.Text annotations in model Account below - // Further reading: - // https://next-auth.js.org/adapters/prisma#create-the-prisma-schema - // https://www.prisma.io/docs/reference/api-reference/prisma-schema-reference#string - url = env("DATABASE_URL") + provider = "postgresql" + // NOTE: When using mysql or sqlserver, uncomment the @db.Text annotations in model Account below + // Further reading: + // https://next-auth.js.org/adapters/prisma#create-the-prisma-schema + // https://www.prisma.io/docs/reference/api-reference/prisma-schema-reference#string + url = env("DATABASE_URL") + schemas = ["cron", "public"] } -// Necessary for Next auth model Account { - id String @id @default(cuid()) - userId Int - type String - provider String - providerAccountId String - refresh_token String? // @db.Text - access_token String? // @db.Text - expires_at Int? - token_type String? - scope String? - id_token String? // @db.Text - session_state String? - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - @@unique([provider, providerAccountId]) + id String @id @default(cuid()) + userId Int + type String + provider String + providerAccountId String + refresh_token String? // @db.Text + access_token String? // @db.Text + expires_at Int? + token_type String? + scope String? + id_token String? // @db.Text + session_state String? + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([provider, providerAccountId]) + @@schema("public") } model Session { - id String @id @default(cuid()) - sessionToken String @unique - userId Int - expires DateTime - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + sessionToken String @unique + userId Int + expires DateTime + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@schema("public") } model User { - id Int @id @default(autoincrement()) - name String? - email String? @unique - obapiProviderId String? - bankingId String? - emailVerified DateTime? - image String? - currency String @default("USD") - preferredLanguage String @default("") - accounts Account[] - sessions Session[] - groups Group[] - associatedGroups GroupUser[] - expenseParticipants ExpenseParticipant[] - expenseNotes ExpenseNote[] - userBalances Balance[] @relation("UserBalance") - cachedBankData CachedBankData[] @relation("UserCachedBankData") - friendBalances Balance[] @relation("FriendBalance") - groupUserBalances GroupBalance[] @relation("GroupUserBalance") - groupFriendBalances GroupBalance[] @relation("GroupFriendBalance") - paidExpenses Expense[] @relation("PaidByUser") - addedExpenses Expense[] @relation("AddedByUser") - deletedExpenses Expense[] @relation("DeletedByUser") - updatedExpenses Expense[] @relation("UpdatedByUser") + id Int @id @default(autoincrement()) + name String? + email String? @unique + obapiProviderId String? + bankingId String? + emailVerified DateTime? + image String? + currency String @default("USD") + preferredLanguage String @default("") + accounts Account[] + sessions Session[] + groups Group[] + associatedGroups GroupUser[] + expenseParticipants ExpenseParticipant[] + expenseNotes ExpenseNote[] + userBalances Balance[] @relation(name: "UserBalance") + cachedBankData CachedBankData[] @relation(name: "UserCachedBankData") + friendBalances Balance[] @relation(name: "FriendBalance") + groupUserBalances GroupBalance[] @relation(name: "GroupUserBalance") + groupFriendBalances GroupBalance[] @relation(name: "GroupFriendBalance") + paidExpenses Expense[] @relation(name: "PaidByUser") + addedExpenses Expense[] @relation(name: "AddedByUser") + deletedExpenses Expense[] @relation(name: "DeletedByUser") + updatedExpenses Expense[] @relation(name: "UpdatedByUser") + + @@schema("public") } model CachedBankData { - id Int @id @default(autoincrement()) - obapiProviderId String @unique - data String - userId Int - user User @relation(name: "UserCachedBankData", fields: [userId], references: [id], onDelete: Cascade) - lastFetched DateTime - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id Int @id @default(autoincrement()) + obapiProviderId String @unique + data String + userId Int + user User @relation(name: "UserCachedBankData", fields: [userId], references: [id], onDelete: Cascade) + lastFetched DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@schema("public") } model VerificationToken { - identifier String - token String @unique - expires DateTime + identifier String + token String @unique + expires DateTime - @@unique([identifier, token]) + @@unique([identifier, token]) + @@schema("public") } model Balance { - userId Int - currency String - friendId Int - amount BigInt - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - importedFromSplitwise Boolean @default(false) - user User @relation(name: "UserBalance", fields: [userId], references: [id], onDelete: Cascade) - friend User @relation(name: "FriendBalance", fields: [friendId], references: [id], onDelete: Cascade) + userId Int + currency String + friendId Int + amount BigInt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + importedFromSplitwise Boolean @default(false) + user User @relation(name: "UserBalance", fields: [userId], references: [id], onDelete: Cascade) + friend User @relation(name: "FriendBalance", fields: [friendId], references: [id], onDelete: Cascade) - @@id([userId, currency, friendId]) + @@id([userId, currency, friendId]) + @@schema("public") } model Group { - id Int @id @default(autoincrement()) - publicId String @unique - name String - userId Int - defaultCurrency String @default("USD") - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - archivedAt DateTime? - splitwiseGroupId String? @unique - createdBy User @relation(fields: [userId], references: [id], onDelete: Cascade) - groupUsers GroupUser[] - expenses Expense[] - groupBalances GroupBalance[] - simplifyDebts Boolean @default(false) + id Int @id @default(autoincrement()) + publicId String @unique + name String + userId Int + defaultCurrency String @default("USD") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + archivedAt DateTime? + splitwiseGroupId String? @unique + createdBy User @relation(fields: [userId], references: [id], onDelete: Cascade) + groupUsers GroupUser[] + expenses Expense[] + groupBalances GroupBalance[] + simplifyDebts Boolean @default(false) + + @@schema("public") } model GroupUser { - groupId Int - userId Int - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - group Group @relation(fields: [groupId], references: [id], onDelete: Cascade) + groupId Int + userId Int + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + group Group @relation(fields: [groupId], references: [id], onDelete: Cascade) - @@id([groupId, userId]) + @@id([groupId, userId]) + @@schema("public") } model GroupBalance { - groupId Int - currency String - userId Int - firendId Int - amount BigInt - updatedAt DateTime @updatedAt - group Group @relation(fields: [groupId], references: [id], onDelete: Cascade) - user User @relation(name: "GroupUserBalance", fields: [userId], references: [id], onDelete: Cascade) - friend User @relation(name: "GroupFriendBalance", fields: [firendId], references: [id], onDelete: Cascade) + groupId Int + currency String + userId Int + firendId Int + amount BigInt + updatedAt DateTime @updatedAt + group Group @relation(fields: [groupId], references: [id], onDelete: Cascade) + user User @relation(name: "GroupUserBalance", fields: [userId], references: [id], onDelete: Cascade) + friend User @relation(name: "GroupFriendBalance", fields: [firendId], references: [id], onDelete: Cascade) - @@id([groupId, currency, firendId, userId]) + @@id([groupId, currency, firendId, userId]) + @@schema("public") } enum SplitType { - EQUAL - PERCENTAGE - EXACT - SHARE - ADJUSTMENT - SETTLEMENT - CURRENCY_CONVERSION + EQUAL + PERCENTAGE + EXACT + SHARE + ADJUSTMENT + SETTLEMENT + CURRENCY_CONVERSION + + @@schema("public") } model Expense { - id String @id @default(cuid()) - paidBy Int - addedBy Int - name String - category String - amount BigInt - splitType SplitType @default(EQUAL) - expenseDate DateTime @default(now()) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - currency String - fileKey String? - groupId Int? - deletedAt DateTime? - deletedBy Int? - updatedBy Int? - otherConversion String? @unique - group Group? @relation(fields: [groupId], references: [id], onDelete: Cascade) - paidByUser User @relation(name: "PaidByUser", fields: [paidBy], references: [id], onDelete: Cascade) - addedByUser User @relation(name: "AddedByUser", fields: [addedBy], references: [id], onDelete: Cascade) - deletedByUser User? @relation(name: "DeletedByUser", fields: [deletedBy], references: [id], onDelete: Cascade) - updatedByUser User? @relation(name: "UpdatedByUser", fields: [updatedBy], references: [id], onDelete: SetNull) - conversionTo Expense? @relation(name: "CurrencyConversion", fields: [otherConversion], references: [id], onDelete: Cascade) - conversionFrom Expense? @relation(name: "CurrencyConversion") - expenseParticipants ExpenseParticipant[] - expenseNotes ExpenseNote[] - transactionId String? - - @@index([groupId]) - @@index([paidBy]) + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + paidBy Int + addedBy Int + name String + category String + amount BigInt + splitType SplitType @default(EQUAL) + expenseDate DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + currency String + fileKey String? + groupId Int? + deletedAt DateTime? + deletedBy Int? + updatedBy Int? + otherConversion String? @unique @db.Uuid + recurrenceId Int? + group Group? @relation(fields: [groupId], references: [id], onDelete: Cascade) + paidByUser User @relation(name: "PaidByUser", fields: [paidBy], references: [id], onDelete: Cascade) + addedByUser User @relation(name: "AddedByUser", fields: [addedBy], references: [id], onDelete: Cascade) + deletedByUser User? @relation(name: "DeletedByUser", fields: [deletedBy], references: [id], onDelete: Cascade) + updatedByUser User? @relation(name: "UpdatedByUser", fields: [updatedBy], references: [id], onDelete: SetNull) + conversionTo Expense? @relation(name: "CurrencyConversion", fields: [otherConversion], references: [id], onDelete: Cascade) + conversionFrom Expense? @relation(name: "CurrencyConversion") + recurrence ExpenseRecurrence? @relation(fields: [recurrenceId], references: [id], onDelete: Cascade) + expenseParticipants ExpenseParticipant[] + expenseNotes ExpenseNote[] + transactionId String? + + @@index([groupId]) + @@index([paidBy]) + @@schema("public") } model ExpenseParticipant { - expenseId String - userId Int - amount BigInt - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - expense Expense @relation(fields: [expenseId], references: [id], onDelete: Cascade) + expenseId String @db.Uuid + userId Int + amount BigInt + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + expense Expense @relation(fields: [expenseId], references: [id], onDelete: Cascade) - @@id([expenseId, userId]) + @@id([expenseId, userId]) + @@schema("public") } model ExpenseNote { - id String @id @default(cuid()) - expenseId String - note String - createdById Int - createdAt DateTime @default(now()) - createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade) - expense Expense @relation(fields: [expenseId], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + expenseId String @db.Uuid + note String + createdById Int + createdAt DateTime @default(now()) + createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade) + expense Expense @relation(fields: [expenseId], references: [id], onDelete: Cascade) + + @@schema("public") +} + +model ExpenseRecurrence { + id Int @id @default(autoincrement()) + jobId BigInt @unique + notified Boolean @default(true) + expense Expense[] + job job @relation(fields: [jobId], references: [jobid], onDelete: Cascade) + + @@schema("public") } model CurrencyRateCache { - from String - to String - date DateTime - rate Float - insertedAt DateTime @default(now()) + from String + to String + date DateTime + rate Float + insertedAt DateTime @default(now()) - @@id([from, to, date]) + @@id([from, to, date]) + @@schema("public") } model PushNotification { - userId Int @id - subscription String + userId Int @id + subscription String + + @@schema("public") +} + +/// This model contains row level security and requires additional setup for migrations. Visit https://pris.ly/d/row-level-security for more info. +model job { + jobid BigInt @id @default(autoincrement()) + schedule String + command String + nodename String @default("localhost") + nodeport Int @default(dbgenerated("inet_server_port()")) + database String @default(dbgenerated("current_database()")) + username String @default(dbgenerated("CURRENT_USER")) + active Boolean @default(true) + jobname String? + + recurrence ExpenseRecurrence? @relation() + runDetails job_run_details[] @relation("job_run_details") + + @@unique([jobname, username], map: "jobname_username_uniq") + @@schema("cron") +} + +/// This model contains row level security and requires additional setup for migrations. Visit https://pris.ly/d/row-level-security for more info. +model job_run_details { + jobid BigInt? + runid BigInt @id @default(autoincrement()) + job_pid Int? + database String? + username String? + command String? + status String? + return_message String? + start_time DateTime? @db.Timestamptz(6) + end_time DateTime? @db.Timestamptz(6) + + job job? @relation("job_run_details", fields: [jobid], references: [jobid], onDelete: Cascade) + + @@schema("cron") } diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 58fc167c..ca64e2db 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -129,7 +129,8 @@ "unsubscribe_error": "Error unsubscribing notification", "upload_failed": "Failed to upload file", "uploading_error": "Error uploading file", - "valid_email": "Enter valid email" + "valid_email": "Enter valid email", + "invalid_cron_expression": "Invalid cron expression" }, "bank_transactions": { "choose_bank_provider": "Choose bank provider", @@ -301,9 +302,31 @@ "activity": "Activity", "add": "Add", "add_expense": "Add Expense", + "recurring": "Recurring", "balances": "Balances", "groups": "Groups" }, + "recurrence": { + "title": "Recurrence", + "description": "Set up automatic recurrence for this expense.", + "cron_expression": "Cron Expression", + "time_of_day": "Time of Day", + "days_of_week": "Days of Week", + "days_of_month": "Days of Month", + "months": "Months", + "never": "No schedule configured", + "empty": "No recurring expenses yet", + "recurring": "Recurring", + "expense_for_the_amount_of": "Expense {{name}} for the amount of {{amount}} {{currency}}", + "schedule_type": { + "never": "Never", + "custom": "Custom", + "day": "Daily", + "month": "Monthly", + "week": "Weekly", + "year": "Yearly" + } + }, "ui": { "added_by": "Added by", "and": "and", diff --git a/src/components/AddExpense/AddExpensePage.tsx b/src/components/AddExpense/AddExpensePage.tsx index a8c6afa4..cfc08a30 100644 --- a/src/components/AddExpense/AddExpensePage.tsx +++ b/src/components/AddExpense/AddExpensePage.tsx @@ -1,4 +1,4 @@ -import { HeartHandshakeIcon, Landmark, X } from 'lucide-react'; +import { HeartHandshakeIcon, Landmark, RefreshCcwDot, X } from 'lucide-react'; import { useTranslation } from 'next-i18next'; import Link from 'next/link'; import { useRouter } from 'next/router'; @@ -11,6 +11,7 @@ import { currencyConversion, toSafeBigInt, toUIString } from '~/utils/numbers'; import { toast } from 'sonner'; import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils'; +import { cronToBackend } from '~/lib/cron'; import { cn } from '~/lib/utils'; import { CurrencyConversion } from '../Friend/CurrencyConversion'; import { Button } from '../ui/button'; @@ -20,6 +21,7 @@ import AddBankTransactions from './AddBankTransactions'; import { CategoryPicker } from './CategoryPicker'; import { CurrencyPicker } from './CurrencyPicker'; import { DateSelector } from './DateSelector'; +import { RecurrenceInput } from './RecurrenceInput'; import { SelectUserOrGroup } from './SelectUserOrGroup'; import { SplitTypeSection } from './SplitTypeSection'; import { UploadFile } from './UploadFile'; @@ -48,6 +50,7 @@ export const AddOrEditExpensePage: React.FC<{ const splitType = useAddExpenseStore((s) => s.splitType); const fileKey = useAddExpenseStore((s) => s.fileKey); const transactionId = useAddExpenseStore((s) => s.transactionId); + const cronExpression = useAddExpenseStore((s) => s.cronExpression); const { setCurrency, @@ -120,6 +123,7 @@ export const AddOrEditExpensePage: React.FC<{ expenseDate, expenseId, transactionId, + cronExpression: cronExpression ? cronToBackend(cronExpression) : undefined, }, { onSuccess: (d) => { @@ -162,6 +166,7 @@ export const AddOrEditExpensePage: React.FC<{ setMultipleTransactions, transactionId, setIsTransactionLoading, + cronExpression, ]); const handleDescriptionChange = useCallback( @@ -275,7 +280,7 @@ export const AddOrEditExpensePage: React.FC<{ <> -
+
- {t('actions.submit')} + {t('actions.save')}
) : null} -
- {/* place for recurring button */} +
+ + +
- - +
@@ -342,7 +357,7 @@ export const AddOrEditExpensePage: React.FC<{ const SponsorUs = () => { const { t } = useTranslation(); return ( -
+
diff --git a/src/components/ui/calendar.tsx b/src/components/ui/calendar.tsx index 4b8959c1..ac1c817e 100644 --- a/src/components/ui/calendar.tsx +++ b/src/components/ui/calendar.tsx @@ -1,7 +1,7 @@ import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'; +import { useTranslation } from 'next-i18next'; import * as React from 'react'; -import { type DayButton, DayPicker, getDefaultClassNames } from 'react-day-picker'; - +import { type DayButton, DayPicker, getDefaultClassNames, Locale } from 'react-day-picker'; import { Button, buttonVariants } from '~/components/ui/button'; import { cn } from '~/lib/utils'; @@ -18,9 +18,26 @@ function Calendar({ buttonVariant?: React.ComponentProps['variant']; }) { const defaultClassNames = getDefaultClassNames(); + const { i18n } = useTranslation(); + const [locale, setLocale] = React.useState(undefined); + React.useEffect(() => { + const userLocale = i18n.language; + (async () => { + try { + const [first, second] = i18n.language.split('-'); + const key = `${first}${second?.toUpperCase() ?? ''}`; + // @ts-ignore + const { [key]: dayPickerLocale } = await import('react-day-picker/locale'); + setLocale(dayPickerLocale); + } catch (e) { + setLocale(undefined); + } + })(); + }, [i18n]); return ( void; + value: string; + className?: string; +} + +interface ParsedCron { + type: string; + values: { + minutes?: number[]; + hours?: number[]; + daysOfMonth?: number[]; + months?: number[]; + daysOfWeek?: number[]; + custom?: string; + }; +} + +const SCHEDULE_TYPES = (t: TFunction) => + [ + { value: 'never', label: t('recurrence.schedule_type.never') }, + { value: 'day', label: t('recurrence.schedule_type.day') }, + { value: 'week', label: t('recurrence.schedule_type.week') }, + { value: 'month', label: t('recurrence.schedule_type.month') }, + { value: 'year', label: t('recurrence.schedule_type.year') }, + { value: 'custom', label: t('recurrence.schedule_type.custom') }, + ] as const; + +type ScheduleType = ReturnType[number]; + +const DAYS_OF_MONTH = Array.from({ length: 31 }, (_, i) => i + 1); + +const MONTHS_SHORT = (code: string) => + Array.from({ length: 12 }, (_, i) => { + const date = new Date(2000, i, 1); // Year 2000, month i, day 1 + return new Intl.DateTimeFormat(code, { month: 'short' }).format(date); + }); + +const DAYS_SHORT = (code: string) => + Array.from({ length: 7 }, (_, i) => { + const date = new Date(2000, 0, 2 + i); // Jan 2, 2000 was a Sunday + return new Intl.DateTimeFormat(code, { weekday: 'short' }).format(date); + }); + +// GridButton component for reusable grid buttons +interface GridButtonProps { + value: number | string; + isSelected: boolean; + onClick: (value: number | string) => void; + children?: React.ReactNode; + minWidth?: string; + className?: string; + disabled?: boolean; +} + +const GridButton = React.memo( + ({ + value, + isSelected, + onClick, + children, + minWidth = '36px', + className = '', + disabled = false, + }) => { + return ( + + ); + }, +); + +GridButton.displayName = 'GridButton'; + +// ScheduleFields component to handle layout complexity +interface ScheduleFieldsProps { + scheduleType: string; + renderDaysOfWeekList: () => React.ReactNode; + renderMonthsGrid: () => React.ReactNode; + renderDaysOfMonthGrid: () => React.ReactNode; + renderTimeInput: () => React.ReactNode; +} + +const ScheduleFields = React.memo( + ({ + scheduleType, + renderDaysOfWeekList, + renderMonthsGrid, + renderDaysOfMonthGrid, + renderTimeInput, + }) => { + const outputs = [renderTimeInput]; + + if (scheduleType === 'never') { + return null; + } + + if (scheduleType === 'week') { + outputs.push(renderDaysOfWeekList); + } else if (scheduleType === 'year') { + outputs.push(renderMonthsGrid); + outputs.push(renderDaysOfMonthGrid); + } else if (scheduleType === 'month') { + outputs.push(renderDaysOfMonthGrid); + } + + return outputs.map((RenderFunc, index) => ( + {RenderFunc()} + )); + }, +); + +ScheduleFields.displayName = 'ScheduleFields'; + +export function CronBuilder({ onChange, value, className }: CronBuilderProps) { + const { t, i18n } = useTranslation(); + + const defaultSchedule = value; // Use provided default or fallback + + // Helper function to parse cron expression and determine schedule type + const parseCronExpression = (cronExpr: string): ParsedCron => { + if (!cronExpr || cronExpr === '') return { type: 'never', values: {} }; + + // Clean and validate the cron expression + const cleanExpr = cronExpr.trim(); + const parts = cleanExpr.split(' '); + if (parts.length !== 5) return { type: 'custom', values: { custom: cleanExpr } }; + + const [min, hour, dom, month, dow] = parts; + + // Helper to safely parse numeric values from cron parts + const parseNumbers = (part?: string): number[] => { + if (part === '*' || !part) return []; + return part + .split(',') + .map((v) => parseInt(v.trim(), 10)) + .filter((v) => !isNaN(v)); + }; + + try { + // Check for standard patterns + if (min !== '*' && hour === '*' && dom === '*' && month === '*' && dow === '*') { + return { type: 'hour', values: { minutes: parseNumbers(min) } }; + } else if (min !== '*' && hour !== '*' && dom === '*' && month === '*' && dow === '*') { + return { + type: 'day', + values: { + minutes: parseNumbers(min), + hours: parseNumbers(hour), + }, + }; + } else if (min !== '*' && hour !== '*' && dom === '*' && month === '*' && dow !== '*') { + return { + type: 'week', + values: { + minutes: parseNumbers(min), + hours: parseNumbers(hour), + daysOfWeek: parseNumbers(dow), + }, + }; + } else if (min !== '*' && hour !== '*' && dom !== '*' && month === '*' && dow === '*') { + return { + type: 'month', + values: { + minutes: parseNumbers(min), + hours: parseNumbers(hour), + daysOfMonth: parseNumbers(dom), + }, + }; + } else { + return { type: 'custom', values: { custom: cleanExpr } }; + } + } catch (error) { + console.warn('Error parsing cron expression:', error); + return { type: 'custom', values: { custom: cleanExpr } }; + } + }; + + const initialParsed = parseCronExpression(defaultSchedule); + + const [scheduleType, setScheduleType] = useState(initialParsed.type); + const [minutes, setMinutes] = useState(initialParsed.values.minutes || [0]); + const [hours, setHours] = useState(initialParsed.values.hours || [0]); + const [daysOfMonth, setDaysOfMonth] = useState>( + initialParsed.values.daysOfMonth || [1], + ); + const [months, setMonths] = useState(initialParsed.values.months || [1]); + const [daysOfWeek, setDaysOfWeek] = useState>( + initialParsed.values.daysOfWeek || [0], + ); + const [custom, setCustom] = useState(initialParsed.values.custom || defaultSchedule); + const [cronExpression, setCronExpression] = useState(defaultSchedule); + + const { cronParser } = useIntlCronParser(); + + function loadDefaults() { + setMinutes([0]); + setHours([0]); + setDaysOfMonth([1]); + setMonths([1]); + setDaysOfWeek([0]); + } + + useEffect(() => { + let expression = ''; + + // Filter out undefined/null values and ensure valid arrays + const cleanMonths = (months || []).filter((v) => v !== undefined && v !== null); + const cleanDaysOfMonth = (daysOfMonth || []).filter((v) => v !== undefined && v !== null); + const cleanDaysOfWeek = (daysOfWeek || []).filter((v) => v !== undefined && v !== null); + const cleanHours = (hours || []).filter((v) => v !== undefined && v !== null); + const cleanMinutes = (minutes || []).filter((v) => v !== undefined && v !== null); + + const monthsCSV = cleanMonths.length === 12 ? '*' : cleanMonths.join(','); + const domCSV = cleanDaysOfMonth.length === 31 ? '*' : cleanDaysOfMonth.join(','); + const dowCSV = cleanDaysOfWeek.length === 7 ? '*' : cleanDaysOfWeek.join(','); + const hoursCSV = cleanHours.length === 24 ? '*' : cleanHours.join(','); + const minutesCSV = cleanMinutes.length === 60 ? '*' : cleanMinutes.join(','); + + switch (scheduleType) { + case 'day': + expression = `${minutesCSV} ${hoursCSV} * * *`; + break; + case 'week': + expression = `${minutesCSV} ${hoursCSV} * * ${dowCSV}`; + break; + case 'month': + expression = `${minutesCSV} ${hoursCSV} ${domCSV} * *`; + break; + case 'year': + expression = `${minutesCSV} ${hoursCSV} ${domCSV} ${monthsCSV} *`; + break; + case 'custom': + expression = custom || ''; + break; + default: + expression = ''; + } + + if (getCronText(cronParser, expression).status) { + setCronExpression(expression); + onChange(expression); + } else { + setCronExpression(''); + onChange(''); + } + }, [scheduleType, minutes, hours, daysOfMonth, months, daysOfWeek, custom]); + + const handleMonthToggle = useCallback((monthIndex: number | string) => { + const monthNum = (typeof monthIndex === 'number' ? monthIndex : parseInt(monthIndex, 10)) + 1; + setMonths([monthNum]); + }, []); + + // Month button component with pressed state + interface MonthButtonProps { + month: string; + index: number; + isSelected: boolean; + onClick: (index: number) => void; + } + + const MonthButton = React.memo(({ month, index, isSelected, onClick }) => { + return ( + + ); + }); + + MonthButton.displayName = 'MonthButton'; + + const renderMonthsGrid = () => ( +
+ +
+ {MONTHS_SHORT(i18n.language).map((month, index) => ( + + ))} +
+
+ ); + + const handleDayOfWeekToggle = useCallback((dayIndex: number | string) => { + const dayNum = + typeof dayIndex === 'number' || dayIndex === 'L' ? dayIndex : parseInt(dayIndex, 10); + setDaysOfWeek([dayNum]); + }, []); + + // Day of week button component with pressed state + interface DayOfWeekButtonProps { + day: string; + index: number; + isSelected: boolean; + isWeekend: boolean; + onClick: (index: number) => void; + } + + const DayOfWeekButton = React.memo( + ({ day, index, isSelected, isWeekend, onClick }) => { + return ( + + ); + }, + ); + + DayOfWeekButton.displayName = 'DayOfWeekButton'; + + const renderDaysOfWeekList = () => { + const weekendDays = [0, 6]; + + return ( +
+ +
+ {DAYS_SHORT(i18n.language).map((day, index) => { + const isWeekend = weekendDays.includes(index); + const isSelected = daysOfWeek.includes(index); + return ( + + ); + })} +
+
+ ); + }; + + const handleDayOfMonthToggle = useCallback((day: number | string) => { + const dayNum = typeof day === 'number' || day === 'L' ? day : parseInt(day, 10); + setDaysOfMonth([dayNum]); + }, []); + + const renderDaysOfMonthGrid = () => ( +
+ +
+ {DAYS_OF_MONTH.map((day) => ( + + ))} + +
+
+ ); + + const renderTimeInput = () => { + const [value, setValue] = useState( + format(new Date().setHours(hours[0] || 0, minutes[0] || 0), 'HH:mm'), + ); + + return ( +
+ + { + const date = e.target.valueAsDate; + setValue(e.target.value); + if (date) { + setHours([date.getUTCHours()]); + setMinutes([date.getUTCMinutes()]); + } + }} + className="bg-background appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none" + /> +
+ ); + }; + + // Render Cron expression builder UI + return ( +
+
+
+ { + if (value) { + loadDefaults(); + setScheduleType(value); + } + }} + className="w-fit flex-wrap justify-start" + > + {SCHEDULE_TYPES(t).map(({ value, label }) => ( + + {label} + + ))} + +
+ + {scheduleType === 'never' ? ( +
{t('recurrence.never')}
+ ) : ( +
+ {scheduleType === 'custom' ? ( +
+ + { + setCustom(event.target.value); + }} + className="border-input bg-background text-foreground placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-[50%] rounded-md border px-3 py-1 text-sm shadow-sm transition-colors focus-visible:ring-1 focus-visible:outline-none" + placeholder="0 0 * * 0" + /> +
+ ) : ( + + )} + +
+ {(() => { + const cronString = getCronText(cronParser, cronExpression); + if (cronString.status) + return ( +

+ {cronString.value}{' '} + + cron({cronExpression}) + +

+ ); + else + return ( +

+ {t('errors.invalid_cron_expression')} +

+ ); + })()} +
+
+ )} +
+
+ ); +} + +export default CronBuilder; diff --git a/src/components/ui/drawer.tsx b/src/components/ui/drawer.tsx index 7fa63496..e012ea82 100644 --- a/src/components/ui/drawer.tsx +++ b/src/components/ui/drawer.tsx @@ -141,7 +141,7 @@ interface AppDrawerProps { trigger: React.ReactNode; disableTrigger?: boolean; onTriggerClick?: React.MouseEventHandler; - className: string; + className?: string; open?: boolean; onOpenChange?: (open: boolean) => void; title?: string; diff --git a/src/components/ui/toggle-group.tsx b/src/components/ui/toggle-group.tsx new file mode 100644 index 00000000..c977117c --- /dev/null +++ b/src/components/ui/toggle-group.tsx @@ -0,0 +1,57 @@ +'use client'; + +import * as React from 'react'; +import { ToggleGroup as ToggleGroupPrimitive } from 'radix-ui'; +import { type VariantProps } from 'class-variance-authority'; + +import { cn } from '~/lib/utils'; +import { toggleVariants } from '~/components/ui/toggle'; + +const ToggleGroupContext = React.createContext>({ + size: 'default', + variant: 'default', +}); + +const ToggleGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, variant, size, children, ...props }, ref) => ( + + {children} + +)); + +ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName; + +const ToggleGroupItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, children, variant, size, ...props }, ref) => { + const context = React.useContext(ToggleGroupContext); + + return ( + + {children} + + ); +}); + +ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName; + +export { ToggleGroup, ToggleGroupItem }; diff --git a/src/components/ui/toggle.tsx b/src/components/ui/toggle.tsx new file mode 100644 index 00000000..e7cd3ed6 --- /dev/null +++ b/src/components/ui/toggle.tsx @@ -0,0 +1,44 @@ +import * as React from 'react'; +import { Toggle as TogglePrimitive } from 'radix-ui'; +import { cva, type VariantProps } from 'class-variance-authority'; + +import { cn } from '~/lib/utils'; + +const toggleVariants = cva( + "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap", + { + variants: { + variant: { + default: 'bg-transparent', + outline: + 'border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground', + }, + size: { + default: 'h-9 px-2 min-w-9', + sm: 'h-8 px-1.5 min-w-8', + lg: 'h-10 px-2.5 min-w-10', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + }, +); + +function Toggle({ + className, + variant, + size, + ...props +}: React.ComponentProps & VariantProps) { + return ( + + ); +} + +export { Toggle, toggleVariants }; diff --git a/src/hooks/useIntlCronParser.ts b/src/hooks/useIntlCronParser.ts new file mode 100644 index 00000000..83eca44e --- /dev/null +++ b/src/hooks/useIntlCronParser.ts @@ -0,0 +1,24 @@ +import React from 'react'; +import cronstrue from 'cronstrue'; +import { useTranslation } from 'next-i18next'; + +export const useIntlCronParser = () => { + const { i18n } = useTranslation(); + + const [_cronParser, setCronParser] = React.useState(null); + + React.useEffect(() => { + void import('cronstrue/i18n').then((mod) => setCronParser(mod)); + }, []); + + const cronParser = React.useCallback( + (expression: string) => + (_cronParser ?? cronstrue).toString(expression, { + locale: i18n.language.split('-')[0], + use24HourTimeFormat: true, + }), + [i18n.language, _cronParser], + ); + + return { cronParser, i18nReady: !!_cronParser }; +}; diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 72747ee4..16aebb5b 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -8,6 +8,12 @@ export async function register() { console.log('Registering instrumentation'); const { validateAuthEnv } = await import('./server/auth'); validateAuthEnv(); + + const { checkRecurrenceNotifications } = await import( + './server/api/services/notificationService' + ); + console.log('Starting recurrent expense notification checking...'); + setTimeout(checkRecurrenceNotifications, 1000 * 10); // Start after 10 seconds } if (process.env.NEXT_RUNTIME !== 'nodejs') { @@ -15,11 +21,6 @@ export async function register() { return; } - if (process.env.DATABASE_URL) { - const { db } = await import('./server/db'); - await db.$executeRaw`CREATE EXTENSION IF NOT EXISTS pg_cron;`; - } - // Create cron jobs if (process.env.CLEAR_BANK_CACHE_FREQUENCY && process.env.DATABASE_URL) { const frequencies = ['weekly', 'monthly']; diff --git a/src/lib/cron.ts b/src/lib/cron.ts new file mode 100644 index 00000000..b6cfbe97 --- /dev/null +++ b/src/lib/cron.ts @@ -0,0 +1,141 @@ +import cronParser from 'cron-parser'; + +export const cronToBackend = (expression: string) => + convertCronTz(expression, new Date().getTimezoneOffset()).replaceAll('L', '$'); + +export const cronFromBackend = (expression: string) => + convertCronTz(expression, -new Date().getTimezoneOffset()).replaceAll('$', 'L'); + +const convertCronTz = (cronExpression: string, timeZoneOffset: number): string => { + const interval = cronParser.parseExpression(cronExpression); + + if (timeZoneOffset === 0) { + return cronExpression; + } + + const c = getDaysHoursMinutes( + interval.fields.hour[0]!, + interval.fields.minute[0]!, + timeZoneOffset, + ); + const cronExpressionFields = getFieldsCron(cronExpression); + + // Minute + cronExpressionFields.minute = addMinutes(cronExpressionFields.minute, c.minutes); + + // Hour + cronExpressionFields.hour = addHours(cronExpressionFields.hour, c.hours); + + // Month + if ( + (cronExpressionFields.dayOfMonth.indexOf(1) >= 0 && c.days === -1) || + (cronExpressionFields.dayOfMonth.indexOf(31) >= 0 && c.days === 1) + ) { + cronExpressionFields.month = addMonth(cronExpressionFields.month, c.days); + } + + // Day of month + cronExpressionFields.dayOfMonth = addDayOfMonth(cronExpressionFields.dayOfMonth, c.days); + + // Day of week + cronExpressionFields.dayOfWeek = addDayOfWeek(cronExpressionFields.dayOfWeek, c.days); + try { + return setFieldsCron(cronExpressionFields); + } catch (err: any) { + if (err.message.includes('Invalid explicit day of month definition')) { + cronExpressionFields.dayOfMonth = [1]; + cronExpressionFields.month = addMonth(cronExpressionFields.month, 1); + return setFieldsCron(cronExpressionFields); + } + return cronExpression; + } +}; + +const getDaysHoursMinutes = (hour: number, minute: number, timeZoneOffset: number) => { + const minutes = hour * 60 + minute; + const newMinutes = minutes + timeZoneOffset; + const diffHour = (Math.floor(newMinutes / 60) % 24) - hour; + const diffMinutes = (newMinutes % 60) - minute; + const diffDays = Math.floor(newMinutes / (60 * 24)); + + return { hours: diffHour, minutes: diffMinutes, days: diffDays }; +}; + +const getFieldsCron = (expression: string): any => { + const interval = cronParser.parseExpression(expression); + return JSON.parse(JSON.stringify(interval.fields)); +}; + +const setFieldsCron = (fields: any): string => cronParser.fieldsToExpression(fields).stringify(); + +const addHours = (hours: number[], hour: number) => + hours.map((n) => { + const h = n + hour; + if (h > 23) { + return h - 24; + } + if (h < 0) { + return h + 24; + } + return h; + }); + +const addMinutes = (minutes: number[], minute: number) => + minutes.map((n) => { + const m = n + minute; + if (m > 59) { + return m - 60; + } + if (m < 0) { + return m + 60; + } + return m; + }); + +const addDayOfMonth = (dayOfMonth: any[], day: number) => { + if (dayOfMonth.length > 30) { + return dayOfMonth; + } + return dayOfMonth.map((n) => { + const d = n + day; + if (d > 31 || n === 'L') { + return 1; + } + if (d < 1) { + return 'L'; + } + return d; + }); +}; + +const addDayOfWeek = (dayOfWeek: any[], day: number) => { + if (dayOfWeek.length > 6) { + return dayOfWeek; + } + return dayOfWeek.map((n) => { + const d = n + day; + if (d > 6) { + return 0; + } + if (d < 0) { + return 6; + } + return d; + }); +}; + +const addMonth = (month: any[], mon: number) => { + if (month.length > 11) { + return month; + } + return month.map((n) => { + const m = n + mon; + if (m > 12) { + return 1; + } + if (m < 1) { + return 12; + } + return m; + }); +}; diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 4316f179..44e372c0 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -8,7 +8,7 @@ import { SessionProvider, useSession } from 'next-auth/react'; import { useEffect, useState } from 'react'; import { Toaster } from 'sonner'; import { appWithTranslation, useTranslation } from 'next-i18next'; -import i18nConfig from 'next-i18next.config.js'; +import i18nConfig from '@/next-i18next.config.js'; import { ThemeProvider } from '~/components/ui/theme-provider'; import '~/styles/globals.css'; import { LoadingSpinner } from '~/components/ui/spinner'; diff --git a/src/pages/activity.tsx b/src/pages/activity.tsx index 3b4ef0f0..d2eff99a 100644 --- a/src/pages/activity.tsx +++ b/src/pages/activity.tsx @@ -10,6 +10,9 @@ import { BigMath, toUIString } from '~/utils/numbers'; import { type TFunction } from 'next-i18next'; import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils'; import { withI18nStaticProps } from '~/utils/i18n/server'; +import { RefreshCcwDot } from 'lucide-react'; +import { Button } from '~/components/ui/button'; +import React from 'react'; function getPaymentString( user: User, @@ -48,13 +51,28 @@ const ActivityPage: NextPageWithUser = ({ user }) => { const { displayName, t, toUIDate } = useTranslationWithUtils(); const expensesQuery = api.expense.getAllExpenses.useQuery(); + const actions = React.useMemo( + () => ( + + + + ), + [], + ); + return ( <> {t('navigation.activity')} - +
{!expensesQuery.data?.length ? (
{t('ui.no_activity')}
@@ -86,18 +104,16 @@ const ActivityPage: NextPageWithUser = ({ user }) => {

)} -
- {getPaymentString( - user, - e.expense.amount, - e.expense.paidBy, - e.amount, - e.expense.splitType === SplitType.SETTLEMENT, - e.expense.currency, - t, - !!e.expense.deletedBy, - )} -
+ {getPaymentString( + user, + e.expense.amount, + e.expense.paidBy, + e.amount, + e.expense.splitType === SplitType.SETTLEMENT, + e.expense.currency, + t, + !!e.expense.deletedBy, + )}

{toUIDate(e.expense.expenseDate)}

diff --git a/src/pages/add.tsx b/src/pages/add.tsx index c583f43c..97e496f6 100644 --- a/src/pages/add.tsx +++ b/src/pages/add.tsx @@ -1,18 +1,19 @@ +import { type GetServerSideProps } from 'next'; +import { useTranslation } from 'next-i18next'; import Head from 'next/head'; import { useRouter } from 'next/router'; import { useEffect } from 'react'; -import { useTranslation } from 'next-i18next'; import { AddOrEditExpensePage } from '~/components/AddExpense/AddExpensePage'; import MainLayout from '~/components/Layout/MainLayout'; import { env } from '~/env'; +import { cronFromBackend } from '~/lib/cron'; import { parseCurrencyCode } from '~/lib/currency'; +import { isBankConnectionConfigured } from '~/server/bankTransactionHelper'; import { isStorageConfigured } from '~/server/storage'; import { useAddExpenseStore } from '~/store/addStore'; import { type NextPageWithUser } from '~/types'; import { api } from '~/utils/api'; import { customServerSideTranslations } from '~/utils/i18n/server'; -import { type GetServerSideProps } from 'next'; -import { isBankConnectionConfigured } from '~/server/bankTransactionHelper'; const AddPage: NextPageWithUser<{ isStorageConfigured: boolean; @@ -32,6 +33,7 @@ const AddPage: NextPageWithUser<{ setExpenseDate, setCategory, resetState, + setCronExpression, } = useAddExpenseStore((s) => s.actions); const currentUser = useAddExpenseStore((s) => s.currentUser); @@ -115,6 +117,9 @@ const AddPage: NextPageWithUser<{ ); useAddExpenseStore.setState({ showFriends: false }); setExpenseDate(expenseQuery.data.expenseDate); + if (expenseQuery.data.recurrence) { + setCronExpression(cronFromBackend(expenseQuery.data.recurrence.job.schedule)); + } }, [ _expenseId, expenseQuery.data, @@ -127,6 +132,7 @@ const AddPage: NextPageWithUser<{ setGroup, setPaidBy, setParticipants, + setCronExpression, ]); return ( diff --git a/src/pages/recurring.tsx b/src/pages/recurring.tsx new file mode 100644 index 00000000..fef71c98 --- /dev/null +++ b/src/pages/recurring.tsx @@ -0,0 +1,80 @@ +import { ChevronLeftIcon } from 'lucide-react'; +import Head from 'next/head'; +import Link from 'next/link'; +import MainLayout from '~/components/Layout/MainLayout'; +import { EntityAvatar } from '~/components/ui/avatar'; +import { Button } from '~/components/ui/button'; +import { useIntlCronParser } from '~/hooks/useIntlCronParser'; +import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils'; +import { cronFromBackend } from '~/lib/cron'; +import { type NextPageWithUser } from '~/types'; +import { api } from '~/utils/api'; +import { withI18nStaticProps } from '~/utils/i18n/server'; +import { toUIString } from '~/utils/numbers'; + +const RecurringPage: NextPageWithUser = () => { + const { t, toUIDate } = useTranslationWithUtils(); + + const recurringExpensesQuery = api.expense.getRecurringExpenses.useQuery(); + + const { cronParser, i18nReady } = useIntlCronParser(); + + return ( + <> + + {t('navigation.recurring')} + + + + + + +

{t('navigation.recurring')}

+
+ } + loading={recurringExpensesQuery.isPending} + > +
+ {!recurringExpensesQuery.data?.length ? ( +
{t('recurrence.empty')}
+ ) : null} + {recurringExpensesQuery.data?.map((e) => ( + +
+ +
+
+

+ {t('recurrence.expense_for_the_amount_of', { + name: e.expense.name, + amount: toUIString(e.expense.amount), + currency: e.expense.currency, + })} +

+

+ {t('recurrence.recurring')} + {i18nReady + ? `: + + ${cronParser(cronFromBackend(e.job.schedule))}` + : ''} +

+

{toUIDate(e.expense.expenseDate)}

+
+ + ))} +
+ + + ); +}; + +RecurringPage.auth = true; + +export const getStaticProps = withI18nStaticProps(['common']); + +export default RecurringPage; diff --git a/src/server/api/routers/expense.ts b/src/server/api/routers/expense.ts index d6f26374..d11b1ad4 100644 --- a/src/server/api/routers/expense.ts +++ b/src/server/api/routers/expense.ts @@ -19,6 +19,7 @@ import { currencyRateProvider } from '../services/currencyRateService'; import { isCurrencyCode } from '~/lib/currency'; import { SplitType } from '@prisma/client'; import { DEFAULT_CATEGORY } from '~/lib/category'; +import { createRecurringExpenseJob } from '../services/scheduleService'; export const expenseRouter = createTRPCRouter({ getBalances: protectedProcedure.query(async ({ ctx }) => { @@ -106,6 +107,31 @@ export const expenseRouter = createTRPCRouter({ ? await editExpense(input, ctx.session.user.id) : await createExpense(input, ctx.session.user.id); + if (expense && input.cronExpression) { + const [{ schedule }] = await createRecurringExpenseJob(expense.id, input.cronExpression); + console.log('Created recurring expense job with jobid:', schedule); + + await db.expense.update({ + where: { id: expense.id }, + data: { + recurrence: { + upsert: { + create: { + job: { + connect: { jobid: schedule }, + }, + }, + update: { + job: { + connect: { jobid: schedule }, + }, + }, + }, + }, + }, + }); + } + return expense; } catch (error) { console.error(error); @@ -313,6 +339,15 @@ export const expenseRouter = createTRPCRouter({ deletedByUser: true, updatedByUser: true, group: true, + recurrence: { + include: { + job: { + select: { + schedule: true, + }, + }, + }, + }, conversionTo: { include: { expenseParticipants: { @@ -353,6 +388,10 @@ export const expenseRouter = createTRPCRouter({ }); } + if (expense?.recurrence?.job.schedule) { + expense.recurrence.job.schedule = expense.recurrence.job.schedule.replaceAll('$', 'L'); + } + return expense; }), @@ -393,6 +432,41 @@ export const expenseRouter = createTRPCRouter({ return expenses; }), + getRecurringExpenses: protectedProcedure.query(async ({ ctx }) => { + const recurrences = await db.expenseRecurrence.findMany({ + include: { + job: true, + expense: { + take: 1, + orderBy: { createdAt: 'desc' }, + where: { + deletedBy: null, + expenseParticipants: { + some: { + userId: ctx.session.user.id, + }, + }, + recurrenceId: { not: null }, + }, + include: { + addedByUser: { + select: { + name: true, + email: true, + image: true, + id: true, + }, + }, + }, + }, + }, + }); + + return recurrences + .filter((r) => r.expense.length > 0) + .map((r) => ({ ...r, expense: r.expense[0]! })); + }), + getUploadUrl: protectedProcedure .input(z.object({ fileName: z.string(), fileType: z.string(), fileSize: z.number() })) .mutation(async ({ input, ctx }) => { diff --git a/src/server/api/services/notificationService.ts b/src/server/api/services/notificationService.ts index c0c9bccf..d270d039 100644 --- a/src/server/api/services/notificationService.ts +++ b/src/server/api/services/notificationService.ts @@ -99,3 +99,42 @@ export async function sendExpensePushNotification(expenseId: string) { await Promise.all(pushNotifications); } + +export async function checkRecurrenceNotifications() { + try { + const recurrences = await db.expenseRecurrence.findMany({ + where: { + NOT: { + notified: true, + }, + }, + include: { + expense: { + select: { id: true }, + orderBy: { createdAt: 'desc' }, + take: 1, + }, + }, + }); + + await Promise.all( + recurrences + .filter((r) => r.expense[0]) + .map(async (r) => { + await sendExpensePushNotification(r.expense[0]!.id); + await db.expenseRecurrence.update({ + where: { + id: r.id, + }, + data: { + notified: true, + }, + }); + }), + ); + } catch (e) { + console.error('Error sending recurrence notifications', e); + } finally { + setTimeout(checkRecurrenceNotifications, 1000 * 60); // Check every minute + } +} diff --git a/src/server/api/services/scheduleService.ts b/src/server/api/services/scheduleService.ts index e17c01b8..c91a8111 100644 --- a/src/server/api/services/scheduleService.ts +++ b/src/server/api/services/scheduleService.ts @@ -20,3 +20,10 @@ export const createRecurringDeleteBankCacheJob = async (frequency: 'weekly' | 'm `; } }; + +export const createRecurringExpenseJob = (expenseId: string, cronExpression: string) => + db.$queryRawUnsafe<[{ schedule: bigint }]>( + `SELECT cron.schedule($1, $2, $$ SELECT duplicate_expense_with_participants('${expenseId}'::UUID); $$);`, + expenseId, + cronExpression, + ); diff --git a/src/server/api/services/splitService.ts b/src/server/api/services/splitService.ts index 1cb8a81e..3268c3ba 100644 --- a/src/server/api/services/splitService.ts +++ b/src/server/api/services/splitService.ts @@ -208,6 +208,11 @@ export async function deleteExpense(expenseId: string, deletedBy: number) { }, include: { expenseParticipants: true, + recurrence: { + include: { + job: true, + }, + }, }, }); @@ -335,6 +340,27 @@ export async function deleteExpense(expenseId: string, deletedBy: number) { }), ); + if (expense.recurrence?.job) { + // Only delete the cron job if there's no other linked expense + const linkedExpenses = await db.expense.count({ + where: { + recurrenceId: expense.recurrenceId, + id: { + not: expense.id, + }, + }, + }); + + if (linkedExpenses === 0) { + operations.push(db.$executeRaw`SELECT cron.unschedule(${expense.recurrence.job.jobname})`); + operations.push( + db.expenseRecurrence.delete({ + where: { id: expense.recurrence.id }, + }), + ); + } + } + await db.$transaction(operations); sendExpensePushNotification(expenseId).catch(console.error); } @@ -365,6 +391,11 @@ export async function editExpense( where: { id: expenseId }, include: { expenseParticipants: true, + recurrence: { + include: { + job: true, + }, + }, }, }); @@ -589,6 +620,9 @@ export async function editExpense( } }); + if (expense.recurrence?.job) { + operations.push(db.$executeRaw`SELECT cron.unschedule(${expense.recurrence.job.jobname})`); + } await db.$transaction(operations); await updateGroupExpenseForIfBalanceIsZero( paidBy, diff --git a/src/store/addStore.ts b/src/store/addStore.ts index bbf01986..96755dd5 100644 --- a/src/store/addStore.ts +++ b/src/store/addStore.ts @@ -15,22 +15,23 @@ export interface AddExpenseState { amount: bigint; amountStr: string; isNegative: boolean; - currentUser: User | undefined; + currentUser?: User; splitType: SplitType; - group: Group | undefined; + group?: Group; participants: Participant[]; splitShares: SplitShares; description: string; currency: CurrencyCode; category: string; nameOrEmail: string; - paidBy: User | undefined; + paidBy?: User; showFriends: boolean; isFileUploading: boolean; fileKey?: string; canSplitScreenClosed: boolean; splitScreenOpen: boolean; - expenseDate: Date | undefined; + expenseDate: Date; + cronExpression: string; transactionId?: string; multipleTransactions: TransactionAddInputModel[]; isTransactionLoading: boolean; @@ -58,6 +59,7 @@ export interface AddExpenseState { setTransactionId: (transactionId?: string) => void; setMultipleTransactions: (multipleTransactions: TransactionAddInputModel[]) => void; setIsTransactionLoading: (isTransactionLoading: boolean) => void; + setCronExpression: (cronExpression: string) => void; }; } @@ -67,7 +69,6 @@ export const useAddExpenseStore = create()((set) => ({ isNegative: false, splitType: SplitType.EQUAL, participants: [], - group: undefined, splitShares: { [SplitType.EQUAL]: {}, [SplitType.PERCENTAGE]: {}, @@ -79,17 +80,16 @@ export const useAddExpenseStore = create()((set) => ({ currency: 'USD', category: DEFAULT_CATEGORY, nameOrEmail: '', - paidBy: undefined, - currentUser: undefined, description: '', showFriends: true, isFileUploading: false, - fileKey: undefined, canSplitScreenClosed: true, splitScreenOpen: false, - expenseDate: undefined, + expenseDate: new Date(), + repeatEvery: 1, multipleTransactions: [], isTransactionLoading: false, + cronExpression: '', actions: { setAmount: (realAmount) => set((s) => { @@ -278,6 +278,16 @@ export const useAddExpenseStore = create()((set) => ({ group: undefined, amountStr: '', splitShares: s.currentUser ? { [s.currentUser.id]: initSplitShares() } : {}, + isNegative: false, + canSplitScreenClosed: true, + splitScreenOpen: false, + expenseDate: new Date(), + transactionId: undefined, + multipleTransactions: [], + isTransactionLoading: false, + cronExpression: '', + isFileUploading: false, + paidBy: s.currentUser, })); }, setSplitScreenOpen: (splitScreenOpen) => set({ splitScreenOpen }), @@ -285,6 +295,7 @@ export const useAddExpenseStore = create()((set) => ({ setTransactionId: (transactionId) => set({ transactionId }), setMultipleTransactions: (multipleTransactions) => set({ multipleTransactions }), setIsTransactionLoading: (isTransactionLoading) => set({ isTransactionLoading }), + setCronExpression: (cronExpression) => set({ cronExpression }), }, })); diff --git a/src/types/expense.types.ts b/src/types/expense.types.ts index d80118c1..c9f7bcb5 100644 --- a/src/types/expense.types.ts +++ b/src/types/expense.types.ts @@ -14,6 +14,7 @@ export type CreateExpense = Omit< | 'fileKey' | 'transactionId' | 'otherConversion' + | 'recurrenceId' > & { expenseDate?: Date; fileKey?: string; @@ -45,6 +46,7 @@ export const createExpenseSchema = z.object({ expenseDate: z.date().optional(), expenseId: z.string().optional(), otherConversion: z.string().optional(), + cronExpression: z.string().optional(), }) satisfies z.ZodType; export const createCurrencyConversionSchema = z.object({ diff --git a/src/utils/i18n/server.ts b/src/utils/i18n/server.ts index 12a4f2fa..d34b7b74 100644 --- a/src/utils/i18n/server.ts +++ b/src/utils/i18n/server.ts @@ -1,6 +1,6 @@ import { type SSRConfig } from 'next-i18next'; import { serverSideTranslations } from 'next-i18next/serverSideTranslations'; -import i18nConfig from 'next-i18next.config.js'; +import i18nConfig from '@/next-i18next.config.js'; export const customServerSideTranslations = async ( locale: string | undefined, diff --git a/tsconfig.json b/tsconfig.json index 2409a5a1..d7cb57b6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -26,7 +26,7 @@ /* Path Aliases */ "paths": { "~/*": ["./src/*"], - "*": ["./*"] + "@/*": ["./*"] } }, "include": [