diff --git a/.idea/sqldialects.xml b/.idea/sqldialects.xml new file mode 100644 index 0000000..5908863 --- /dev/null +++ b/.idea/sqldialects.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/docs/backend-development.md b/docs/backend-development.md new file mode 100644 index 0000000..85b2177 --- /dev/null +++ b/docs/backend-development.md @@ -0,0 +1,150 @@ +## Backend Development Guide + +This guide documents how we build backend features in this repository to ensure consistency, type-safety, and security. + +### Tech Stack and Principles + +- **Runtime**: Node/TypeScript with strict typing. Avoid `any`. +- **API**: tRPC routers under `src/server/routers` using `protectedProcedure`. +- **ORM**: Drizzle ORM with PostgreSQL; schema in `src/db/schema.ts`. +- **Validation**: Zod (`import { z } from "zod"`). +- **Errors**: TRPCError helpers in `src/errors/trpc.ts` and message keys in `src/errors/messages.ts`. +- **Types**: Shared API-facing types in `src/types/index.ts`. + +### Directory Layout + +- `src/server/routers/*`: One router per domain (e.g., `accounts.ts`, `transactions.ts`). +- `src/server/api/*`: tRPC setup (`trpc.ts`, `root.ts`). +- `src/db/*`: Drizzle DB client and schema. +- `src/errors/*`: Error helpers and message keys. +- `src/types/*`: Shared TypeScript interfaces for API responses. +- `drizzle/*`: SQL migrations and snapshot metadata. + +### Authentication and Authorization + +- **Always use `protectedProcedure`** for endpoints that require a logged-in user; access the user via `ctx.userId`. +- **Never trust client-provided user IDs**. Filter by `ctx.userId` on the server. +- **Ownership checks are mandatory** for any resource scoped to a user. + - Example: Before creating a `transaction` for an `account`, verify that `account.userId === ctx.userId`. + - Example: When listing transactions, always restrict results to accounts owned by the current user. + +### Error Handling Conventions + +- Use the error factory in `src/errors/trpc.ts` to throw typed errors (e.g., `errors.forbidden(...)`). +- **Do not hardcode strings**. Use `src/errors/messages.ts` for error message keys. +- **Message values are camelCase** and represent i18n keys, e.g., `currencyNotFound`, `accountNotOwned`. +- Prefer reusing generic keys where possible (`forbidden`, `notFound`, `badRequest`). Add new keys when necessary. + +### Drizzle ORM Guidelines + +- Import comparison helpers from `drizzle-orm`: `eq`, `gte`, `lte`, `and`. +- Prefer typed selects with aliases: + - Use `select({ transactions: transactionsTable, categories: categoriesTable, accounts: accountsTable })`. + - Use `innerJoin` when related rows must exist; use `leftJoin` only when nulls are expected. +- Compose filters using a single `where` expression (build an `and(...)` chain) instead of mutating the builder repeatedly. +- For conditional filtering, build a `whereExpr` (`SQL | undefined`) and apply it once: `const rows = whereExpr ? await qb.where(whereExpr).execute() : await qb.execute()`. + +### Date/Time and Numeric Types + +- Use `timestamp({ withTimezone: true })` in schema; pass and compare as `Date` objects in code. +- Use `gte`/`lte` with `Date` values (not milliseconds). Example: `gte(transactionsTable.datetime, input.startDate)`. +- For currency amounts, use Drizzle `decimal({ precision, scale, mode: "string" })` and handle values as strings at the API boundary to avoid floating-point issues. + +### Zod Validation Patterns + +- Import with `import { z } from "zod"`. +- Validate timestamps as `z.date()`. +- Validate amounts as `z.string()` (paired with `decimal(..., mode: "string")`). +- Optional fields use `.optional()`; keep inputs minimal and explicit. + +### Types and Response Shapes + +- Use `src/types/index.ts` interfaces for API responses (e.g., `AccountWithCurrencyAndType`, `TransactionWithCategoryAndAccount`). +- Build responses by mapping typed Drizzle rows to these interfaces. Avoid returning raw Drizzle shapes if they do not match the shared types. +- Avoid `any`, `unknown`, and explicit `PgSelectBase` casts. Let Drizzle infer types via `select({ ... })` with aliases. + +### Ownership Enforcement Patterns + +- Create operations (example: transactions): + - First, verify ownership of the parent resource via Drizzle `select` on the owner table (e.g., `accountsTable.userId === ctx.userId`). + - If not owned, throw `errors.forbidden(error_messages.accountNotOwned)`. +- Read/List operations: + - Scope always results by joining the owner table first and applying `where(eq(ownerTable.userId, ctx.userId))`. + - If the client passes an ID filter (e.g., `accountId`), optionally pre-validate ownership and throw `forbidden` if not owned. + +### Example: Secure and Typed List Query + +```ts +import type { SQL } from "drizzle-orm"; +const conditions: (SQL | undefined)[] = [ + eq(accountsTable.userId, ctx.userId!), + input.accountId + ? eq(transactionsTable.accountId, input.accountId) + : undefined, + input.categoryId + ? eq(transactionsTable.categoryId, input.categoryId) + : undefined, + input.startDate + ? gte(transactionsTable.datetime, input.startDate) + : undefined, + input.endDate ? lte(transactionsTable.datetime, input.endDate) : undefined, +]; +const typed = conditions.filter((c): c is SQL => Boolean(c)); +const whereExpr = typed.length ? and(...typed) : undefined; + +const qb = db + .select({ + transactions: transactionsTable, + categories: categoriesTable, + accounts: accountsTable, + }) + .from(transactionsTable) + .innerJoin(accountsTable, eq(transactionsTable.accountId, accountsTable.id)) + .innerJoin( + categoriesTable, + eq(transactionsTable.categoryId, categoriesTable.id), + ); + +const rows = whereExpr + ? await qb.where(whereExpr).execute() + : await qb.execute(); +return rows.map((row) => ({ + ...row.transactions, + category: row.categories, + account: row.accounts, +})); +``` + +### Error Messages (i18n-ready) + +- Add new keys to `src/errors/messages.ts` with camelCase values, e.g.: + - `currencyNotFound`, `accountTypeNotFound`, `accountNotOwned`, `transactionNotFound`. +- UI will translate these keys later via i18n; backend should only emit the keys. + +### Router Conventions + +- Name operations clearly: `addX` for create, `getX` for list, `updateX`, `deleteX` if needed. +- Keep each router focused on a single domain, and register it in `src/server/api/root.ts`. +- Inputs: Zod schemas colocated in the procedure. +- Outputs: Typed return values using shared interfaces. + +### Migrations and Seeding + +- Manage schema changes with Drizzle migrations in `drizzle/`. +- Keep `src/scripts/seed.ts` aligned with current schema and business rules (ownership, required fields). +- Prefer deterministic seeds for development. + +### Linting and Quality + +- No `any` in production code. Let types flow from Drizzle and Zod. +- Keep functions small and focused. Use early returns. +- Avoid catching errors unless adding meaningful handling or mapping to typed TRPC errors. + +### Adding a New Feature (Checklist) + +- Define/extend schema in `src/db/schema.ts` and generate a migration. +- Add router or extend existing one under `src/server/routers`. +- Validate inputs with Zod; enforce ownership with Drizzle where applicable. +- Use `errors` + `error_messages` for failures (camelCase message keys). +- Return typed responses using `src/types/index.ts`. +- Add seeds/tests if relevant. diff --git a/drizzle/0000_mute_thunderbolt_ross.sql b/drizzle/0000_mute_thunderbolt_ross.sql deleted file mode 100644 index a658123..0000000 --- a/drizzle/0000_mute_thunderbolt_ross.sql +++ /dev/null @@ -1,11 +0,0 @@ - ---> statement-breakpoint -CREATE TABLE "accounts" ( - "id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "accounts_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1), - "bankName" varchar(255) NOT NULL, - "currentAmount" numeric(10, 2) NOT NULL, - "reference" varchar(255) NOT NULL, - "usage" varchar(255) NOT NULL, - "userId" varchar(255) NOT NULL, - "createdAt" timestamp with time zone DEFAULT now() NOT NULL -); diff --git a/drizzle/0000_unique_mercury.sql b/drizzle/0000_unique_mercury.sql new file mode 100644 index 0000000..cc72706 --- /dev/null +++ b/drizzle/0000_unique_mercury.sql @@ -0,0 +1,49 @@ +CREATE TABLE "accounts" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "userId" varchar(255) NOT NULL, + "bankName" varchar(255) NOT NULL, + "currentBalance" numeric(18, 2) NOT NULL, + "reference" varchar(255) NOT NULL, + "usage" varchar(255) NOT NULL, + "currencyId" uuid NOT NULL, + "createdAt" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "categories" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" varchar(100) NOT NULL, + "slug" varchar(120) NOT NULL +); +--> statement-breakpoint +CREATE TABLE "currencies" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" varchar(100) NOT NULL, + "code" varchar(10) NOT NULL +); +--> statement-breakpoint +CREATE TABLE "transactions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "accountId" uuid NOT NULL, + "categoryId" uuid NOT NULL, + "company" varchar(255) NOT NULL, + "amount" numeric(18, 2) NOT NULL, + "datetime" timestamp with time zone NOT NULL, + "description" text NOT NULL, + "createdAt" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "users" ( + "id" varchar(255) PRIMARY KEY NOT NULL +); +--> statement-breakpoint +ALTER TABLE "accounts" ADD CONSTRAINT "accounts_userId_users_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "accounts" ADD CONSTRAINT "accounts_currencyId_currencies_id_fk" FOREIGN KEY ("currencyId") REFERENCES "public"."currencies"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "transactions" ADD CONSTRAINT "transactions_accountId_accounts_id_fk" FOREIGN KEY ("accountId") REFERENCES "public"."accounts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "transactions" ADD CONSTRAINT "transactions_categoryId_categories_id_fk" FOREIGN KEY ("categoryId") REFERENCES "public"."categories"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "accounts_user_idx" ON "accounts" USING btree ("userId");--> statement-breakpoint +CREATE INDEX "accounts_currency_idx" ON "accounts" USING btree ("currencyId");--> statement-breakpoint +CREATE UNIQUE INDEX "categories_slug_unique_idx" ON "categories" USING btree ("slug");--> statement-breakpoint +CREATE UNIQUE INDEX "currencies_code_unique_idx" ON "currencies" USING btree ("code");--> statement-breakpoint +CREATE INDEX "transactions_account_idx" ON "transactions" USING btree ("accountId");--> statement-breakpoint +CREATE INDEX "transactions_category_idx" ON "transactions" USING btree ("categoryId");--> statement-breakpoint +CREATE INDEX "transactions_datetime_idx" ON "transactions" USING btree ("datetime"); \ No newline at end of file diff --git a/drizzle/0001_nostalgic_master_mold.sql b/drizzle/0001_nostalgic_master_mold.sql new file mode 100644 index 0000000..d663e7a --- /dev/null +++ b/drizzle/0001_nostalgic_master_mold.sql @@ -0,0 +1,41 @@ +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +CREATE TABLE "account_types" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" varchar(100) NOT NULL, + "slug" varchar(120) NOT NULL +); +--> statement-breakpoint + +-- 1) Add column nullable +ALTER TABLE "accounts" ADD COLUMN "typeId" uuid;--> statement-breakpoint + +-- 2) (Optional but recommended) Ensure a default account type exists for backfilling +INSERT INTO "account_types" ("name", "slug") +SELECT 'Private', 'private' +ON CONFLICT ("slug") DO NOTHING;--> statement-breakpoint + +-- 3) Backfill existing rows (choose the appropriate default type) +UPDATE "accounts" a +SET "typeId" = at.id +FROM "account_types" at +WHERE a."typeId" IS NULL + AND at."slug" = 'private';--> statement-breakpoint + +-- 4) Enforce NOT NULL +ALTER TABLE "accounts" ALTER COLUMN "typeId" SET NOT NULL;--> statement-breakpoint + +-- 5) Keep the unique index on account_types.slug +CREATE UNIQUE INDEX "account_types_slug_unique_idx" + ON "account_types" USING btree ("slug");--> statement-breakpoint + +-- 6) Add the foreign key constraint +ALTER TABLE "accounts" + ADD CONSTRAINT "accounts_typeId_account_types_id_fk" + FOREIGN KEY ("typeId") + REFERENCES "public"."account_types"("id") + ON DELETE RESTRICT + ON UPDATE NO ACTION;--> statement-breakpoint + +-- 7) Add an index on accounts.typeId for performance +CREATE INDEX "accounts_type_idx" ON "accounts" USING btree ("typeId"); \ No newline at end of file diff --git a/drizzle/0001_tense_satana.sql b/drizzle/0001_tense_satana.sql deleted file mode 100644 index eb7ac8d..0000000 --- a/drizzle/0001_tense_satana.sql +++ /dev/null @@ -1,12 +0,0 @@ -CREATE TABLE "welcomes" ( - "id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "welcomes_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1), - "bankName" varchar(255) NOT NULL, - "currentAmount" numeric(10, 2) NOT NULL, - "reference" varchar(255) NOT NULL, - "usage" varchar(255) NOT NULL, - "userId" varchar(255) NOT NULL, - "createdAt" timestamp with time zone DEFAULT now() NOT NULL -); ---> statement-breakpoint -DROP TABLE "users" CASCADE;--> statement-breakpoint -DROP TABLE "accounts" CASCADE; \ No newline at end of file diff --git a/drizzle/0002_careless_gladiator.sql b/drizzle/0002_careless_gladiator.sql new file mode 100644 index 0000000..645c35c --- /dev/null +++ b/drizzle/0002_careless_gladiator.sql @@ -0,0 +1,7 @@ +CREATE TYPE "public"."references" AS ENUM('private', 'business', 'savings', 'shared');--> statement-breakpoint +ALTER TABLE "account_types" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint +DROP TABLE "account_types" CASCADE;--> statement-breakpoint +--> statement-breakpoint +DROP INDEX "accounts_type_idx";--> statement-breakpoint +ALTER TABLE "accounts" ALTER COLUMN "reference" SET DATA TYPE "public"."references" USING "reference"::"public"."references";--> statement-breakpoint +ALTER TABLE "accounts" DROP COLUMN "typeId"; diff --git a/drizzle/0002_icy_madrox.sql b/drizzle/0002_icy_madrox.sql deleted file mode 100644 index 247beb7..0000000 --- a/drizzle/0002_icy_madrox.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE "welcomes" ALTER COLUMN "createdAt" SET DATA TYPE timestamp with time zone;--> statement-breakpoint -ALTER TABLE "welcomes" ALTER COLUMN "createdAt" DROP DEFAULT; \ No newline at end of file diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json index 40cae6a..3df20b1 100644 --- a/drizzle/meta/0000_snapshot.json +++ b/drizzle/meta/0000_snapshot.json @@ -1,120 +1,355 @@ { - "id": "7b572c16-9880-4dd2-a708-6db7a9ab2bfd", + "id": "9f87a7a3-7c20-4245-a39d-f4e17eb70443", "prevId": "00000000-0000-0000-0000-000000000000", "version": "7", "dialect": "postgresql", "tables": { - "public.users": { - "name": "users", + "public.accounts": { + "name": "accounts", "schema": "", "columns": { "id": { "name": "id", - "type": "integer", + "type": "uuid", "primaryKey": true, "notNull": true, - "identity": { - "type": "always", - "name": "users_id_seq", - "schema": "public", - "increment": "1", - "startWith": "1", - "minValue": "1", - "maxValue": "2147483647", - "cache": "1", - "cycle": false - } + "default": "gen_random_uuid()" }, - "name": { - "name": "name", + "userId": { + "name": "userId", "type": "varchar(255)", "primaryKey": false, "notNull": true }, - "age": { - "name": "age", - "type": "integer", + "bankName": { + "name": "bankName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "currentBalance": { + "name": "currentBalance", + "type": "numeric(18, 2)", + "primaryKey": false, + "notNull": true + }, + "reference": { + "name": "reference", + "type": "varchar(255)", "primaryKey": false, "notNull": true }, - "email": { - "name": "email", + "usage": { + "name": "usage", "type": "varchar(255)", "primaryKey": false, "notNull": true + }, + "currencyId": { + "name": "currencyId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "accounts_user_idx": { + "name": "accounts_user_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "accounts_currency_idx": { + "name": "accounts_currency_idx", + "columns": [ + { + "expression": "currencyId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_userId_users_id_fk": { + "name": "accounts_userId_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "accounts_currencyId_currencies_id_fk": { + "name": "accounts_currencyId_currencies_id_fk", + "tableFrom": "accounts", + "tableTo": "currencies", + "columnsFrom": ["currencyId"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.categories": { + "name": "categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "categories_slug_unique_idx": { + "name": "categories_slug_unique_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} } }, - "indexes": {}, "foreignKeys": {}, "compositePrimaryKeys": {}, - "uniqueConstraints": { - "users_email_unique": { - "name": "users_email_unique", - "nullsNotDistinct": false, - "columns": ["email"] + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.currencies": { + "name": "currencies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "currencies_code_unique_idx": { + "name": "currencies_code_unique_idx", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} } }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, "policies": {}, "checkConstraints": {}, "isRLSEnabled": false }, - "public.accounts": { - "name": "accounts", + "public.transactions": { + "name": "transactions", "schema": "", "columns": { "id": { "name": "id", - "type": "integer", + "type": "uuid", "primaryKey": true, "notNull": true, - "identity": { - "type": "always", - "name": "accounts_id_seq", - "schema": "public", - "increment": "1", - "startWith": "1", - "minValue": "1", - "maxValue": "2147483647", - "cache": "1", - "cycle": false - } + "default": "gen_random_uuid()" }, - "bankName": { - "name": "bankName", - "type": "varchar(255)", + "accountId": { + "name": "accountId", + "type": "uuid", "primaryKey": false, "notNull": true }, - "currentAmount": { - "name": "currentAmount", - "type": "numeric(10, 2)", + "categoryId": { + "name": "categoryId", + "type": "uuid", "primaryKey": false, "notNull": true }, - "reference": { - "name": "reference", + "company": { + "name": "company", "type": "varchar(255)", "primaryKey": false, "notNull": true }, - "usage": { - "name": "usage", - "type": "varchar(255)", + "amount": { + "name": "amount", + "type": "numeric(18, 2)", "primaryKey": false, "notNull": true }, - "userId": { - "name": "userId", - "type": "varchar(255)", + "datetime": { + "name": "datetime", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", "primaryKey": false, "notNull": true }, "createdAt": { "name": "createdAt", - "type": "varchar(255)", + "type": "timestamp with time zone", "primaryKey": false, "notNull": true, - "default": "'now()'" + "default": "now()" + } + }, + "indexes": { + "transactions_account_idx": { + "name": "transactions_account_idx", + "columns": [ + { + "expression": "accountId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "transactions_category_idx": { + "name": "transactions_category_idx", + "columns": [ + { + "expression": "categoryId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "transactions_datetime_idx": { + "name": "transactions_datetime_idx", + "columns": [ + { + "expression": "datetime", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "transactions_accountId_accounts_id_fk": { + "name": "transactions_accountId_accounts_id_fk", + "tableFrom": "transactions", + "tableTo": "accounts", + "columnsFrom": ["accountId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "transactions_categoryId_categories_id_fk": { + "name": "transactions_categoryId_categories_id_fk", + "tableFrom": "transactions", + "tableTo": "categories", + "columnsFrom": ["categoryId"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true } }, "indexes": {}, diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json index 1610920..647de6c 100644 --- a/drizzle/meta/0001_snapshot.json +++ b/drizzle/meta/0001_snapshot.json @@ -1,29 +1,73 @@ { - "id": "b61453c7-755e-4503-ad8a-e539246cacb6", - "prevId": "7b572c16-9880-4dd2-a708-6db7a9ab2bfd", + "id": "ccc0eca7-766c-431b-930d-bdfb833c2101", + "prevId": "9f87a7a3-7c20-4245-a39d-f4e17eb70443", "version": "7", "dialect": "postgresql", "tables": { - "public.welcomes": { - "name": "welcomes", + "public.account_types": { + "name": "account_types", "schema": "", "columns": { "id": { "name": "id", - "type": "integer", + "type": "uuid", "primaryKey": true, "notNull": true, - "identity": { - "type": "always", - "name": "welcomes_id_seq", - "schema": "public", - "increment": "1", - "startWith": "1", - "minValue": "1", - "maxValue": "2147483647", - "cache": "1", - "cycle": false - } + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_types_slug_unique_idx": { + "name": "account_types_slug_unique_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "userId": { + "name": "userId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true }, "bankName": { "name": "bankName", @@ -31,9 +75,9 @@ "primaryKey": false, "notNull": true }, - "currentAmount": { - "name": "currentAmount", - "type": "numeric(10, 2)", + "currentBalance": { + "name": "currentBalance", + "type": "numeric(18, 2)", "primaryKey": false, "notNull": true }, @@ -49,18 +93,341 @@ "primaryKey": false, "notNull": true }, - "userId": { - "name": "userId", - "type": "varchar(255)", + "typeId": { + "name": "typeId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "currencyId": { + "name": "currencyId", + "type": "uuid", "primaryKey": false, "notNull": true }, "createdAt": { "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "accounts_user_idx": { + "name": "accounts_user_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "accounts_currency_idx": { + "name": "accounts_currency_idx", + "columns": [ + { + "expression": "currencyId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "accounts_type_idx": { + "name": "accounts_type_idx", + "columns": [ + { + "expression": "typeId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_userId_users_id_fk": { + "name": "accounts_userId_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "accounts_typeId_account_types_id_fk": { + "name": "accounts_typeId_account_types_id_fk", + "tableFrom": "accounts", + "tableTo": "account_types", + "columnsFrom": ["typeId"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "accounts_currencyId_currencies_id_fk": { + "name": "accounts_currencyId_currencies_id_fk", + "tableFrom": "accounts", + "tableTo": "currencies", + "columnsFrom": ["currencyId"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.categories": { + "name": "categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "categories_slug_unique_idx": { + "name": "categories_slug_unique_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.currencies": { + "name": "currencies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "currencies_code_unique_idx": { + "name": "currencies_code_unique_idx", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.transactions": { + "name": "transactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "accountId": { + "name": "accountId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "categoryId": { + "name": "categoryId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company": { + "name": "company", "type": "varchar(255)", "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(18, 2)", + "primaryKey": false, + "notNull": true + }, + "datetime": { + "name": "datetime", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, "notNull": true, - "default": "'now()'" + "default": "now()" + } + }, + "indexes": { + "transactions_account_idx": { + "name": "transactions_account_idx", + "columns": [ + { + "expression": "accountId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "transactions_category_idx": { + "name": "transactions_category_idx", + "columns": [ + { + "expression": "categoryId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "transactions_datetime_idx": { + "name": "transactions_datetime_idx", + "columns": [ + { + "expression": "datetime", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "transactions_accountId_accounts_id_fk": { + "name": "transactions_accountId_accounts_id_fk", + "tableFrom": "transactions", + "tableTo": "accounts", + "columnsFrom": ["accountId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "transactions_categoryId_categories_id_fk": { + "name": "transactions_categoryId_categories_id_fk", + "tableFrom": "transactions", + "tableTo": "categories", + "columnsFrom": ["categoryId"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true } }, "indexes": {}, diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json index fa30aa0..a6e8fcb 100644 --- a/drizzle/meta/0002_snapshot.json +++ b/drizzle/meta/0002_snapshot.json @@ -1,29 +1,25 @@ { - "id": "c747747e-f4bd-42a3-a570-8a4d02fd5253", - "prevId": "b61453c7-755e-4503-ad8a-e539246cacb6", + "id": "91f744b4-b913-457a-abc5-f5a7ac42e454", + "prevId": "ccc0eca7-766c-431b-930d-bdfb833c2101", "version": "7", "dialect": "postgresql", "tables": { - "public.welcomes": { - "name": "welcomes", + "public.accounts": { + "name": "accounts", "schema": "", "columns": { "id": { "name": "id", - "type": "integer", + "type": "uuid", "primaryKey": true, "notNull": true, - "identity": { - "type": "always", - "name": "welcomes_id_seq", - "schema": "public", - "increment": "1", - "startWith": "1", - "minValue": "1", - "maxValue": "2147483647", - "cache": "1", - "cycle": false - } + "default": "gen_random_uuid()" + }, + "userId": { + "name": "userId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true }, "bankName": { "name": "bankName", @@ -31,15 +27,16 @@ "primaryKey": false, "notNull": true }, - "currentAmount": { - "name": "currentAmount", - "type": "numeric(10, 2)", + "currentBalance": { + "name": "currentBalance", + "type": "numeric(18, 2)", "primaryKey": false, "notNull": true }, "reference": { "name": "reference", - "type": "varchar(255)", + "type": "references", + "typeSchema": "public", "primaryKey": false, "notNull": true }, @@ -49,16 +46,310 @@ "primaryKey": false, "notNull": true }, - "userId": { - "name": "userId", + "currencyId": { + "name": "currencyId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "accounts_user_idx": { + "name": "accounts_user_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "accounts_currency_idx": { + "name": "accounts_currency_idx", + "columns": [ + { + "expression": "currencyId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_userId_users_id_fk": { + "name": "accounts_userId_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "accounts_currencyId_currencies_id_fk": { + "name": "accounts_currencyId_currencies_id_fk", + "tableFrom": "accounts", + "tableTo": "currencies", + "columnsFrom": ["currencyId"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.categories": { + "name": "categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "categories_slug_unique_idx": { + "name": "categories_slug_unique_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.currencies": { + "name": "currencies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "currencies_code_unique_idx": { + "name": "currencies_code_unique_idx", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.transactions": { + "name": "transactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "accountId": { + "name": "accountId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "categoryId": { + "name": "categoryId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company": { + "name": "company", "type": "varchar(255)", "primaryKey": false, "notNull": true }, + "amount": { + "name": "amount", + "type": "numeric(18, 2)", + "primaryKey": false, + "notNull": true + }, + "datetime": { + "name": "datetime", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, "createdAt": { "name": "createdAt", "type": "timestamp with time zone", "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "transactions_account_idx": { + "name": "transactions_account_idx", + "columns": [ + { + "expression": "accountId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "transactions_category_idx": { + "name": "transactions_category_idx", + "columns": [ + { + "expression": "categoryId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "transactions_datetime_idx": { + "name": "transactions_datetime_idx", + "columns": [ + { + "expression": "datetime", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "transactions_accountId_accounts_id_fk": { + "name": "transactions_accountId_accounts_id_fk", + "tableFrom": "transactions", + "tableTo": "accounts", + "columnsFrom": ["accountId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "transactions_categoryId_categories_id_fk": { + "name": "transactions_categoryId_categories_id_fk", + "tableFrom": "transactions", + "tableTo": "categories", + "columnsFrom": ["categoryId"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, "notNull": true } }, @@ -71,7 +362,13 @@ "isRLSEnabled": false } }, - "enums": {}, + "enums": { + "public.references": { + "name": "references", + "schema": "public", + "values": ["private", "business", "savings", "shared"] + } + }, "schemas": {}, "sequences": {}, "roles": {}, diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index d4211ac..88876b3 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -5,15 +5,22 @@ { "idx": 0, "version": "7", - "when": 1749731867682, - "tag": "0000_mute_thunderbolt_ross", + "when": 1760046554794, + "tag": "0000_unique_mercury", "breakpoints": true }, { "idx": 1, "version": "7", - "when": 1749733185632, - "tag": "0001_tense_satana", + "when": 1760204489591, + "tag": "0001_nostalgic_master_mold", + "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1761913195425, + "tag": "0002_careless_gladiator", "breakpoints": true } ] diff --git a/localization/ar/common.json b/localization/ar/common.json index 5464335..7460c9c 100644 --- a/localization/ar/common.json +++ b/localization/ar/common.json @@ -94,7 +94,8 @@ "dashboardDailyAccounts": "الحسابات اليومية", "dashboardPrivate": "خاص", "dashboardBusiness": "تجاري", - "dashboardSafeAccounts": "الحسابات المحمية", + "dashboardSavings": "مدخرات", + "dashboardShared": "مشترك", "navigationDashboard": "لوحة المعلومات", "navigationTransactions": "المعاملات", diff --git a/localization/bn/common.json b/localization/bn/common.json index efcd57e..5b2f852 100644 --- a/localization/bn/common.json +++ b/localization/bn/common.json @@ -92,7 +92,8 @@ "dashboardDailyAccounts": "দৈনিক অ্যাকাউন্টসমূহ", "dashboardPrivate": "ব্যক্তিগত", "dashboardBusiness": "ব্যবসা", - "dashboardSafeAccounts": "সেভিংস অ্যাকাউন্ট", + "dashboardSavings": "সঞ্চয়", + "dashboardShared": "যৌথ", "navigationDashboard": "ড্যাশবোর্ড", "navigationTransactions": "লেনদেন", "navigationInsights": "বিশ্লেষণ", diff --git a/localization/de/common.json b/localization/de/common.json index 3739c8d..4679903 100644 --- a/localization/de/common.json +++ b/localization/de/common.json @@ -110,10 +110,11 @@ "verify": "Bestätigen", "verifying": "Wird überprüft...", - "dashboardDailyAccounts": "Tägliche Konten", + "dashboardDailyAccounts": "Tageskonten", "dashboardPrivate": "Privat", "dashboardBusiness": "Geschäftlich", - "dashboardSafeAccounts": "Sichere Konten", + "dashboardSavings": "Ersparnisse", + "dashboardShared": "Gemeinsam", "navigationDashboard": "Dashboard", "navigationTransactions": "Transaktionen", diff --git a/localization/en/common.json b/localization/en/common.json index 1746e06..f9c80d7 100644 --- a/localization/en/common.json +++ b/localization/en/common.json @@ -93,7 +93,8 @@ "dashboardDailyAccounts": "Daily Accounts", "dashboardPrivate": "Private", "dashboardBusiness": "Business", - "dashboardSafeAccounts": "Safe Accounts", + "dashboardSavings": "Savings", + "dashboardShared": "Shared", "navigationDashboard": "Dashboard", "navigationTransactions": "Transactions", diff --git a/localization/es/common.json b/localization/es/common.json index e941b72..4c9ec2b 100644 --- a/localization/es/common.json +++ b/localization/es/common.json @@ -101,7 +101,8 @@ "dashboardDailyAccounts": "Cuentas Diarias", "dashboardPrivate": "Privado", "dashboardBusiness": "Empresarial", - "dashboardSafeAccounts": "Cuentas Protegidas", + "dashboardSavings": "Ahorros", + "dashboardShared": "Compartido", "navigationDashboard": "Panel", "navigationTransactions": "Transacciones", diff --git a/localization/fa/common.json b/localization/fa/common.json index 0f4c257..f14b43b 100644 --- a/localization/fa/common.json +++ b/localization/fa/common.json @@ -173,7 +173,8 @@ "dashboardDailyAccounts": "حساب‌های روزانه", "dashboardPrivate": "خصوصی", "dashboardBusiness": "تجاری", - "dashboardSafeAccounts": "حساب‌های محفوظ", + "dashboardSavings": "پس‌انداز", + "dashboardShared": "مشترک", "navigationDashboard": "داشبورد", "navigationTransactions": "تراکنش‌ها", diff --git a/localization/fr/common.json b/localization/fr/common.json index 97eeba7..b8b0dce 100644 --- a/localization/fr/common.json +++ b/localization/fr/common.json @@ -93,7 +93,8 @@ "dashboardDailyAccounts": "Comptes Quotidiens", "dashboardPrivate": "Privé", "dashboardBusiness": "Professionnel", - "dashboardSafeAccounts": "Comptes Protégés", + "dashboardSavings": "Économies", + "dashboardShared": "Partagé", "navigationDashboard": "Tableau de Bord", "navigationTransactions": "Transactions", diff --git a/localization/ha/common.json b/localization/ha/common.json index da5132b..cc5adc3 100644 --- a/localization/ha/common.json +++ b/localization/ha/common.json @@ -89,10 +89,13 @@ "usageTooLong": "Amfani dole ya kasance ƙasa da haruffa 50", "verify": "Tabbatar", "verifying": "Ana tabbatarwa...", + "dashboardDailyAccounts": "Asusun yau da kullum", "dashboardPrivate": "Na kaina", "dashboardBusiness": "Kasuwanci", - "dashboardSafeAccounts": "Asusun ajiya", + "dashboardSavings": "Ajiya", + "dashboardShared": "Haɗin gwiwa", + "navigationDashboard": "Dashibod", "navigationTransactions": "Ma'amaloli", "navigationInsights": "Haske", diff --git a/localization/hi/common.json b/localization/hi/common.json index 24b8cde..3629973 100644 --- a/localization/hi/common.json +++ b/localization/hi/common.json @@ -101,7 +101,8 @@ "dashboardDailyAccounts": "दैनिक खाते", "dashboardPrivate": "निजी", "dashboardBusiness": "व्यापारिक", - "dashboardSafeAccounts": "सुरक्षित खाते", + "dashboardSavings": "बचत", + "dashboardShared": "साझा", "navigationDashboard": "डैशबोर्ड", "navigationTransactions": "लेन-देन", diff --git a/localization/id/common.json b/localization/id/common.json index 201002c..3506965 100644 --- a/localization/id/common.json +++ b/localization/id/common.json @@ -89,10 +89,13 @@ "usageTooLong": "Penggunaan maksimal 50 karakter", "verify": "Verifikasi", "verifying": "Memverifikasi...", + "dashboardDailyAccounts": "Akun Harian", "dashboardPrivate": "Pribadi", "dashboardBusiness": "Bisnis", - "dashboardSafeAccounts": "Akun Tabungan", + "dashboardSavings": "Tabungan", + "dashboardShared": "Bersama", + "navigationDashboard": "Dasbor", "navigationTransactions": "Transaksi", "navigationInsights": "Wawasan", diff --git a/localization/it/common.json b/localization/it/common.json index 232393a..39b4d15 100644 --- a/localization/it/common.json +++ b/localization/it/common.json @@ -45,7 +45,7 @@ "currentAmountPlaceholder": "2000.00", "enterCurrentAmount": "Inserisci l'importo attuale", "reference": "Riferimento", - "referencePlaceholder": "Business, Privato, altro", + "referencePlaceholder": "Aziendale, Privato, altro", "emailRequired": "L'email è obbligatoria", "invalidEmail": "Per favore inserisci un indirizzo email valido", "firstNameRequired": "Il nome è obbligatorio", @@ -100,7 +100,8 @@ "dashboardDailyAccounts": "Conti Quotidiani", "dashboardPrivate": "Privato", "dashboardBusiness": "Aziendale", - "dashboardSafeAccounts": "Conti protetti", + "dashboardSavings": "Risparmi", + "dashboardShared": "Condiviso", "navigationDashboard": "Dashboard", "navigationTransactions": "Transazioni", diff --git a/localization/ja/common.json b/localization/ja/common.json index e8115d0..77bccfd 100644 --- a/localization/ja/common.json +++ b/localization/ja/common.json @@ -101,7 +101,8 @@ "dashboardDailyAccounts": "日常口座", "dashboardPrivate": "プライベート", "dashboardBusiness": "ビジネス", - "dashboardSafeAccounts": "保護口座", + "dashboardSavings": "貯蓄", + "dashboardShared": "共有", "navigationDashboard": "ダッシュボード", "navigationTransactions": "取引履歴", diff --git a/localization/ko/common.json b/localization/ko/common.json index f955ec8..c26b11f 100644 --- a/localization/ko/common.json +++ b/localization/ko/common.json @@ -101,7 +101,8 @@ "dashboardDailyAccounts": "일상 계좌", "dashboardPrivate": "개인", "dashboardBusiness": "비즈니스", - "dashboardSafeAccounts": "보호 계좌", + "dashboardSavings": "저축", + "dashboardShared": "공유", "navigationDashboard": "대시보드", "navigationTransactions": "거래내역", diff --git a/localization/ps/common.json b/localization/ps/common.json index a472341..60a5613 100644 --- a/localization/ps/common.json +++ b/localization/ps/common.json @@ -103,7 +103,8 @@ "dashboardDailyAccounts": "ورځني حسابونه", "dashboardPrivate": "شخصي", "dashboardBusiness": "سوداګریز", - "dashboardSafeAccounts": "خوندي حسابونه", + "dashboardSavings": "سپما", + "dashboardShared": "شریک شوی", "navigationDashboard": "کنټرول پینل", "navigationTransactions": "معاملې", diff --git a/localization/pt/common.json b/localization/pt/common.json index 64e6b22..74c4158 100644 --- a/localization/pt/common.json +++ b/localization/pt/common.json @@ -100,7 +100,8 @@ "dashboardDailyAccounts": "Contas Diárias", "dashboardPrivate": "Privado", "dashboardBusiness": "Empresarial", - "dashboardSafeAccounts": "Contas Protegidas", + "dashboardSavings": "Poupança", + "dashboardShared": "Compartilhado", "navigationDashboard": "Painel", "navigationTransactions": "Transações", diff --git a/localization/ru/common.json b/localization/ru/common.json index f673662..5da850c 100644 --- a/localization/ru/common.json +++ b/localization/ru/common.json @@ -100,7 +100,8 @@ "dashboardDailyAccounts": "Ежедневные счета", "dashboardPrivate": "Личный", "dashboardBusiness": "Бизнес", - "dashboardSafeAccounts": "Защищенные счета", + "dashboardSavings": "Сбережения", + "dashboardShared": "Совместный", "navigationDashboard": "Панель", "navigationTransactions": "Транзакции", diff --git a/localization/sw/common.json b/localization/sw/common.json index 806b32f..0fabc1e 100644 --- a/localization/sw/common.json +++ b/localization/sw/common.json @@ -89,10 +89,13 @@ "usageTooLong": "Matumizi yasizidi herufi 50", "verify": "Thibitisha", "verifying": "Inathibitisha...", + "dashboardDailyAccounts": "Akaunti za kila siku", "dashboardPrivate": "Binafsi", "dashboardBusiness": "Biashara", - "dashboardSafeAccounts": "Akaunti za akiba", + "dashboardSavings": "Akiba", + "dashboardShared": "Pamoja", + "navigationDashboard": "Dashibodi", "navigationTransactions": "Miamala", "navigationInsights": "Uchambuzi", diff --git a/localization/th/common.json b/localization/th/common.json index 1f76029..306c053 100644 --- a/localization/th/common.json +++ b/localization/th/common.json @@ -89,10 +89,13 @@ "usageTooLong": "รูปแบบการใช้งานต้องไม่เกิน 50 อักขระ", "verify": "ยืนยัน", "verifying": "กำลังยืนยัน...", + "dashboardDailyAccounts": "บัญชีรายวัน", "dashboardPrivate": "ส่วนบุคคล", "dashboardBusiness": "ธุรกิจ", - "dashboardSafeAccounts": "บัญชีออมทรัพย์", + "dashboardSavings": "ออมทรัพย์", + "dashboardShared": "ร่วม", + "navigationDashboard": "แดชบอร์ด", "navigationTransactions": "ธุรกรรม", "navigationInsights": "ข้อมูลเชิงลึก", diff --git a/localization/tr/common.json b/localization/tr/common.json index 77a2484..a1e3724 100644 --- a/localization/tr/common.json +++ b/localization/tr/common.json @@ -100,7 +100,8 @@ "dashboardDailyAccounts": "Günlük Hesaplar", "dashboardPrivate": "Özel", "dashboardBusiness": "İş", - "dashboardSafeAccounts": "Korumalı Hesaplar", + "dashboardSavings": "Biriktirme", + "dashboardShared": "Paylaşılan", "navigationDashboard": "Kontrol Paneli", "navigationTransactions": "İşlemler", diff --git a/localization/ur/common.json b/localization/ur/common.json index 4023ae8..b279196 100644 --- a/localization/ur/common.json +++ b/localization/ur/common.json @@ -101,7 +101,8 @@ "dashboardDailyAccounts": "روزانہ کھاتے", "dashboardPrivate": "نجی", "dashboardBusiness": "کاروباری", - "dashboardSafeAccounts": "محفوظ کھاتے", + "dashboardSavings": "بچت", + "dashboardShared": "مشترکہ", "navigationDashboard": "ڈیش بورڈ", "navigationTransactions": "لین دین", diff --git a/localization/vi/common.json b/localization/vi/common.json index 4b90b40..eb9e5d0 100644 --- a/localization/vi/common.json +++ b/localization/vi/common.json @@ -89,10 +89,13 @@ "usageTooLong": "Mục đích sử dụng không quá 50 ký tự", "verify": "Xác minh", "verifying": "Đang xác minh...", + "dashboardDailyAccounts": "Tài khoản chi tiêu", "dashboardPrivate": "Cá nhân", "dashboardBusiness": "Doanh nghiệp", - "dashboardSafeAccounts": "Tài khoản tiết kiệm", + "dashboardSavings": "Tiết kiệm", + "dashboardShared": "Chung", + "navigationDashboard": "Bảng điều khiển", "navigationTransactions": "Giao dịch", "navigationInsights": "Thông tin chi tiết", diff --git a/localization/zh/common.json b/localization/zh/common.json index c836fc7..f735837 100644 --- a/localization/zh/common.json +++ b/localization/zh/common.json @@ -100,7 +100,8 @@ "dashboardDailyAccounts": "日常账户", "dashboardPrivate": "个人", "dashboardBusiness": "商务", - "dashboardSafeAccounts": "安全账户", + "dashboardSavings": "储蓄", + "dashboardShared": "共享", "navigationDashboard": "仪表板", "navigationTransactions": "交易记录", diff --git a/package.json b/package.json index ef223d9..3b7ee08 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,10 @@ "android": "expo run:android", "ios": "expo run:ios", "web": "expo start --web", + "seed": "tsx scripts/seed.ts", + "db:migrate": "drizzle-kit migrate", + "db:studio": "drizzle-kit studio", + "db:generate": "drizzle-kit generate", "test": "jest --watchAll", "lint": "expo lint", "typecheck": "tsc --noEmit", diff --git a/scripts/seed.ts b/scripts/seed.ts new file mode 100644 index 0000000..a924395 --- /dev/null +++ b/scripts/seed.ts @@ -0,0 +1,69 @@ +import "dotenv/config"; +import { db } from "~/db"; +import { categoriesTable, currenciesTable } from "~/db/schema"; +import { inArray } from "drizzle-orm"; + +async function seedCurrencies() { + const currencies = [ + { code: "USD", name: "US Dollar" }, + { code: "EUR", name: "Euro" }, + { code: "ARS", name: "Argentine Peso" }, + ]; + + const codes = currencies.map((c) => c.code); + + const existing = await db + .select({ code: currenciesTable.code }) + .from(currenciesTable) + .where(inArray(currenciesTable.code, codes)); + + const existingCodes = new Set(existing.map((e) => e.code)); + const toInsert = currencies.filter((c) => !existingCodes.has(c.code)); + + if (toInsert.length > 0) { + await db.insert(currenciesTable).values(toInsert); + console.log( + `Inserted currencies: ${toInsert.map((c) => c.code).join(", ")}`, + ); + } else { + console.log("Currencies already seeded"); + } +} + +async function seedCategories() { + const categories = [ + { name: "Groceries", slug: "groceries" }, + { name: "Restaurants", slug: "restaurants" }, + { name: "Transport", slug: "transport" }, + { name: "Bills", slug: "bills" }, + { name: "Entertainment", slug: "entertainment" }, + ]; + + const slugs = categories.map((c) => c.slug); + const existing = await db + .select({ slug: categoriesTable.slug }) + .from(categoriesTable) + .where(inArray(categoriesTable.slug, slugs)); + + const existingSlugs = new Set(existing.map((e) => e.slug)); + const toInsert = categories.filter((c) => !existingSlugs.has(c.slug)); + + if (toInsert.length > 0) { + await db.insert(categoriesTable).values(toInsert); + console.log( + `Inserted categories: ${toInsert.map((c) => c.slug).join(", ")}`, + ); + } else { + console.log("Categories already seeded"); + } +} + +async function main() { + await seedCurrencies(); + await seedCategories(); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/app/(auth)/sign-in.tsx b/src/app/(auth)/sign-in.tsx index b9be1f5..d2ac9bc 100644 --- a/src/app/(auth)/sign-in.tsx +++ b/src/app/(auth)/sign-in.tsx @@ -99,7 +99,10 @@ export default function Page() { }); if (signInAttempt.status === "complete") { await setActive({ session: signInAttempt.createdSessionId }); - router.replace("/start"); + // The / route is currently managing redirects based on authentication state. + // After signing in, we navigate there to let it handle the redirect. + // We can consider changing this in the future if needed so that the user directly lands on the intended page after sign-in. + router.replace("/"); } else { setError( t("signInFailed", "Sign in failed. Please check your credentials"), @@ -110,7 +113,7 @@ export default function Page() { } } } catch (err) { - // Type guard to check if err is an object and has 'errors' property + // Type guard to check if err is an object and has an 'errors' property if ( typeof err === "object" && err !== null && diff --git a/src/app/(auth)/sign-up.tsx b/src/app/(auth)/sign-up.tsx index 37e32e5..9ac22f0 100644 --- a/src/app/(auth)/sign-up.tsx +++ b/src/app/(auth)/sign-up.tsx @@ -17,6 +17,7 @@ import { languageService } from "~/services/languageService"; import Button from "~/components/ui/button"; import AppImage from "~/components/ui/AppImage"; import { z } from "zod"; +import { trpc } from "~/utils/trpc"; type newErrorType = { firstname?: string; @@ -31,7 +32,10 @@ export default function SignUpScreen() { const router = useRouter(); const { t } = useTranslation(); - // Initialize language when component mounts with better error handling + // Create a user mutation + const createUser = trpc.users.createUser.useMutation(); + + // Initialize language when a component mounts with better error handling useEffect(() => { const initLanguage = async () => { try { @@ -140,6 +144,8 @@ export default function SignUpScreen() { }); if (completeSignUp.status === "complete") { await setActive({ session: completeSignUp.createdSessionId }); + // Add the user in the database + await createUser.mutateAsync(); router.replace("/start"); } else { setError("Verification failed. Please try again."); @@ -155,6 +161,8 @@ export default function SignUpScreen() { }); if (signInAttempt?.status === "complete") { await setActive({ session: signInAttempt.createdSessionId }); + // Add the user in the database + await createUser.mutateAsync(); router.replace("/start"); } else { setError("Email already verified. Please sign in."); diff --git a/src/app/(tabs)/banking/index.tsx b/src/app/(tabs)/banking/index.tsx index 41f1cd3..e2aa82b 100644 --- a/src/app/(tabs)/banking/index.tsx +++ b/src/app/(tabs)/banking/index.tsx @@ -1,24 +1,82 @@ import React from "react"; import { useTheme } from "~/contexts/ThemeContext"; import { ScrollView, StatusBar } from "react-native"; -import { mockDashboardData } from "~/data/mockData"; import { SectionHeader } from "~/components/SectionHeader"; import { AccountItem } from "~/components/AccountItem"; import { useTranslation } from "react-i18next"; import { SafeAreaView } from "react-native-safe-area-context"; +import { trpc } from "~/utils/trpc"; +import type { References } from "~/schemas/welcomeSchema"; +import type { Account } from "~/types"; const Dashboard = () => { const { colors, isDark } = useTheme(); const { t } = useTranslation(); + const { data: accounts } = trpc.accounts.getAccounts.useQuery(); + + // TODO: Implement loading and error states + const handleAccountPress = (accountName: string, accountId: string) => { console.log(`Pressed ${accountName} with ID: ${accountId}`); // modal logic here }; - const handleCardPress = (cardName: string, cardId: string) => { - console.log(`Pressed ${cardName} card with ID: ${cardId}`); - // modal logic here + // Type-safe reference constants + const REFERENCE = React.useMemo( + () => + ({ + PRIVATE: "private" as References, + BUSINESS: "business" as References, + SAVINGS: "savings" as References, + SHARED: "shared" as References, + }) as const, + [], + ); + + // Pre-filter accounts once per reference type + const privateAccounts = React.useMemo( + () => (accounts ?? []).filter((a) => a.reference === REFERENCE.PRIVATE), + [accounts, REFERENCE], + ); + const businessAccounts = React.useMemo( + () => (accounts ?? []).filter((a) => a.reference === REFERENCE.BUSINESS), + [accounts, REFERENCE], + ); + const savingsAccounts = React.useMemo( + () => (accounts ?? []).filter((a) => a.reference === REFERENCE.SAVINGS), + [accounts, REFERENCE], + ); + const sharedAccounts = React.useMemo( + () => (accounts ?? []).filter((a) => a.reference === REFERENCE.SHARED), + [accounts, REFERENCE], + ); + + const hasDailyAccounts = + privateAccounts.length > 0 || businessAccounts.length > 0; + + // Reusable section renderer + const AccountSection: React.FC<{ + title: string; + accounts: Account[]; + baseDelay?: number; // delay for SectionHeader; items will start at baseDelay + 100 + onPress: (accountName: string, accountId: string) => void; + }> = ({ title, accounts, baseDelay = 0, onPress }) => { + if (!accounts || accounts.length === 0) return null; + return ( + <> + + {accounts.map((account, index) => ( + onPress(account.bankName, account.id)} + /> + ))} + + ); }; return ( @@ -55,61 +113,42 @@ const Dashboard = () => { onPress={() => handleCardPress(card.title, card.id)} /> ))} */} - - - {mockDashboardData.accounts.private.map((account, index) => ( - handleAccountPress(account.name, account.id)} - /> - ))} - - {mockDashboardData.accounts.business.map((account, index) => ( - handleAccountPress(account.name, account.id)} - /> - ))} + {hasDailyAccounts ? ( + + ) : null} - {/* Safe Accounts Section */} - - {mockDashboardData.accounts.safe.map((account, index) => ( - handleAccountPress(account.name, account.id)} - /> - ))} + {/* Private Accounts Section */} + - - {mockDashboardData.sharedFunds && ( - - handleAccountPress( - mockDashboardData.sharedFunds.title, - mockDashboardData.sharedFunds.id, - ) - } - /> - )} + {/* Business Accounts Section */} + + + {/* Savings Accounts Section */} + + + {/* Shared Funds Section */} + ); diff --git a/src/app/index.tsx b/src/app/index.tsx index c939364..91c4a8a 100644 --- a/src/app/index.tsx +++ b/src/app/index.tsx @@ -20,6 +20,7 @@ import { useTranslation } from "react-i18next"; import { languageService } from "~/services/languageService"; import Button from "~/components/ui/button"; import AppImage from "~/components/ui/AppImage"; +import { trpc } from "~/utils/trpc"; const LanguageDropdown = () => { const [visible, setVisible] = useState(false); @@ -142,13 +143,48 @@ const LanguageDropdown = () => { }; export default function Index() { - const scheme = useColorScheme(); - const { isSignedIn } = useAuth(); - const { t } = useTranslation(); + const scheme = useColorScheme(); // Used later for theming (icon colors, etc.) + const { isSignedIn } = useAuth(); // Clerk auth state; true when a session is present + const { t } = useTranslation(); // i18n translations - if (isSignedIn) { - return ; + // ---------------------------------------------------------------------------- + // Redirect decision flow + // ---------------------------------------------------------------------------- + // High-level: + // - We only query accounts once the user is authenticated (enabled: isSignedIn). + // - While the "accounts" query is loading, we DO NOT redirect to avoid flicker. + // - After loading: + // - If authenticated and no accounts exist -> send user to onboarding (`./start`). + // - If authenticated and accounts exist -> send the user to the main app (`./(tabs)/banking`). + // - If not authenticated, we fall through and render the marketing/landing UI below. + // ---------------------------------------------------------------------------- + + const { data: accounts, isLoading: isLoadingAccounts } = + trpc.accounts.getAccounts.useQuery(undefined, { + // Only fetch accounts when signed in to avoid unauthorized requests + enabled: isSignedIn, + // Note: You could tune caching behavior here (e.g., `staleTime`) if needed + }); + + // Redirect once we have both an auth state AND a settled accounts query + // Case A: Signed in, accounts finished loading, and none found -> onboarding + if ( + isSignedIn && + !isLoadingAccounts && + (!accounts || accounts?.length === 0) + ) { + return ; + } + // Case B: Signed in, accounts finished loading, and at least one found -> the main app + else if ( + isSignedIn && + !isLoadingAccounts && + accounts && + accounts.length > 0 + ) { + return ; } + // Else: Not signed in OR still loading -> render the rest of this screen const iconColor = scheme === "dark" ? "#E0E0E0" : "#111827"; const iconBackground = scheme === "dark" ? "black" : "white"; diff --git a/src/app/start/index.tsx b/src/app/start/index.tsx index 6fef10f..00d9649 100644 --- a/src/app/start/index.tsx +++ b/src/app/start/index.tsx @@ -21,6 +21,7 @@ import Button from "~/components/ui/button"; import { useRouter } from "expo-router"; import AppImage from "~/components/ui/AppImage"; import { WelcomeFormValues, welcomeSchema } from "~/schemas/welcomeSchema"; +import Select from "~/components/ui/select"; const Home = () => { const { user, isLoaded } = useUser(); @@ -44,7 +45,7 @@ const Home = () => { initLanguage(); }, []); - const createAccount = trpc.account.create.useMutation({ + const createAccount = trpc.accounts.addAccount.useMutation({ onSuccess: () => { Alert.alert(t("success"), t("accountCreated")); reset(); @@ -67,7 +68,7 @@ const Home = () => { defaultValues: { bankName: "", currentAmount: 0, - reference: "", + reference: undefined, usage: "", }, mode: "onChange", // Validate on change @@ -75,11 +76,21 @@ const Home = () => { const handleCreateAccount = async (data: WelcomeFormValues) => { await createAccount.mutateAsync({ - ...data, - currentAmount: data.currentAmount, + bankName: data.bankName, + currentBalance: data.currentAmount.toString(), + reference: data.reference, + usage: data.usage, }); }; + // Reference options must match backend enum: ["private","business","savings","shared"] + const referenceOptions = [ + { value: "private", label: t("dashboardPrivate", "Private") }, + { value: "business", label: t("dashboardBusiness", "Business") }, + { value: "savings", label: t("dashboardSafeAccounts", "Savings") }, + { value: "shared", label: t("sharedFunds", "Shared") }, + ]; + return ( { ( - - - - - - + render={({ field: { onChange, value } }) => ( +