From 32e3164873fb2c8ccd6812ece3d4b7d1e981fa84 Mon Sep 17 00:00:00 2001 From: Federico Leiva Date: Thu, 9 Oct 2025 18:53:43 -0300 Subject: [PATCH 01/22] recreated backend database --- drizzle/0000_mute_thunderbolt_ross.sql | 11 - drizzle/0000_unique_mercury.sql | 49 ++++ drizzle/0001_tense_satana.sql | 12 - drizzle/0002_icy_madrox.sql | 2 - drizzle/meta/0000_snapshot.json | 369 +++++++++++++++++++++---- drizzle/meta/0001_snapshot.json | 86 ------ drizzle/meta/0002_snapshot.json | 85 ------ drizzle/meta/_journal.json | 13 +- src/db/schema.ts | 90 +++++- src/index.ts | 24 +- src/server/routers/welcome.ts | 37 ++- 11 files changed, 485 insertions(+), 293 deletions(-) delete mode 100644 drizzle/0000_mute_thunderbolt_ross.sql create mode 100644 drizzle/0000_unique_mercury.sql delete mode 100644 drizzle/0001_tense_satana.sql delete mode 100644 drizzle/0002_icy_madrox.sql delete mode 100644 drizzle/meta/0001_snapshot.json delete mode 100644 drizzle/meta/0002_snapshot.json 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_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_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..cf2b838 100644 --- a/drizzle/meta/0000_snapshot.json +++ b/drizzle/meta/0000_snapshot.json @@ -1,120 +1,371 @@ { - "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": {}, @@ -137,4 +388,4 @@ "schemas": {}, "tables": {} } -} +} \ No newline at end of file diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json deleted file mode 100644 index 1610920..0000000 --- a/drizzle/meta/0001_snapshot.json +++ /dev/null @@ -1,86 +0,0 @@ -{ - "id": "b61453c7-755e-4503-ad8a-e539246cacb6", - "prevId": "7b572c16-9880-4dd2-a708-6db7a9ab2bfd", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.welcomes": { - "name": "welcomes", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "integer", - "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 - } - }, - "bankName": { - "name": "bankName", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "currentAmount": { - "name": "currentAmount", - "type": "numeric(10, 2)", - "primaryKey": false, - "notNull": true - }, - "reference": { - "name": "reference", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "usage": { - "name": "usage", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "userId": { - "name": "userId", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "createdAt": { - "name": "createdAt", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true, - "default": "'now()'" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": {}, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json deleted file mode 100644 index fa30aa0..0000000 --- a/drizzle/meta/0002_snapshot.json +++ /dev/null @@ -1,85 +0,0 @@ -{ - "id": "c747747e-f4bd-42a3-a570-8a4d02fd5253", - "prevId": "b61453c7-755e-4503-ad8a-e539246cacb6", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.welcomes": { - "name": "welcomes", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "integer", - "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 - } - }, - "bankName": { - "name": "bankName", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "currentAmount": { - "name": "currentAmount", - "type": "numeric(10, 2)", - "primaryKey": false, - "notNull": true - }, - "reference": { - "name": "reference", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "usage": { - "name": "usage", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "userId": { - "name": "userId", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "createdAt": { - "name": "createdAt", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": {}, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index d4211ac..4352e32 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -5,16 +5,9 @@ { "idx": 0, "version": "7", - "when": 1749731867682, - "tag": "0000_mute_thunderbolt_ross", - "breakpoints": true - }, - { - "idx": 1, - "version": "7", - "when": 1749733185632, - "tag": "0001_tense_satana", + "when": 1760046554794, + "tag": "0000_unique_mercury", "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/src/db/schema.ts b/src/db/schema.ts index c36c297..9dff02f 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,17 +1,89 @@ import { pgTable, - integer, + uuid, varchar, + text, decimal, timestamp, + index, + uniqueIndex, } from "drizzle-orm/pg-core"; -export const welcomeTable = pgTable("welcomes", { - id: integer().primaryKey().generatedAlwaysAsIdentity(), - bankName: varchar({ length: 255 }).notNull(), - currentAmount: decimal({ precision: 10, scale: 2, mode: "number" }).notNull(), - reference: varchar({ length: 255 }).notNull(), - usage: varchar({ length: 255 }).notNull(), - userId: varchar({ length: 255 }).notNull(), // Clerk user ID - createdAt: timestamp({ withTimezone: true }).notNull(), +// Users (Clerk user id as primary key) +export const usersTable = pgTable("users", { + id: varchar({ length: 255 }).primaryKey(), }); + +// Currencies +export const currenciesTable = pgTable( + "currencies", + { + id: uuid().defaultRandom().primaryKey(), + name: varchar({ length: 100 }).notNull(), + code: varchar({ length: 10 }).notNull(), // e.g. USD, EUR, ARS + }, + (table) => ({ + codeUniqueIdx: uniqueIndex("currencies_code_unique_idx").on(table.code), + }), +); + +// Categories +export const categoriesTable = pgTable( + "categories", + { + id: uuid().defaultRandom().primaryKey(), + name: varchar({ length: 100 }).notNull(), + slug: varchar({ length: 120 }).notNull(), + }, + (table) => ({ + slugUniqueIdx: uniqueIndex("categories_slug_unique_idx").on(table.slug), + }), +); + +// Accounts +export const accountsTable = pgTable( + "accounts", + { + id: uuid().defaultRandom().primaryKey(), + userId: varchar({ length: 255 }) + .notNull() + .references(() => usersTable.id, { onDelete: "cascade" }), + bankName: varchar({ length: 255 }).notNull(), + // Use high-precision decimal stored as string in code to prevent JS float issues + currentBalance: decimal({ precision: 18, scale: 2, mode: "string" }).notNull(), + reference: varchar({ length: 255 }).notNull(), + usage: varchar({ length: 255 }).notNull(), + currencyId: uuid() + .notNull() + .references(() => currenciesTable.id, { onDelete: "restrict" }), + createdAt: timestamp({ withTimezone: true }).defaultNow().notNull(), + }, + (table) => ({ + userIdx: index("accounts_user_idx").on(table.userId), + currencyIdx: index("accounts_currency_idx").on(table.currencyId), + }), +); + +// Transactions +export const transactionsTable = pgTable( + "transactions", + { + id: uuid().defaultRandom().primaryKey(), + accountId: uuid() + .notNull() + .references(() => accountsTable.id, { onDelete: "cascade" }), + categoryId: uuid() + .notNull() + .references(() => categoriesTable.id, { onDelete: "restrict" }), + company: varchar({ length: 255 }).notNull(), + amount: decimal({ precision: 18, scale: 2, mode: "string" }).notNull(), + datetime: timestamp({ withTimezone: true }).notNull(), + description: text().notNull(), + createdAt: timestamp({ withTimezone: true }).defaultNow().notNull(), + }, + (table) => ({ + accountIdx: index("transactions_account_idx").on(table.accountId), + categoryIdx: index("transactions_category_idx").on(table.categoryId), + datetimeIdx: index("transactions_datetime_idx").on(table.datetime), + }), +); diff --git a/src/index.ts b/src/index.ts index dd94a58..938cc78 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,18 +1,30 @@ import { db } from "./db/index"; -import { welcomeTable } from "./db/schema"; +import { accountsTable, currenciesTable } from "./db/schema"; +import { eq } from "drizzle-orm"; async function main() { - const welcome: typeof welcomeTable.$inferInsert = { + const [usd] = await db + .select() + .from(currenciesTable) + .where(eq(currenciesTable.code, "USD")) + .limit(1); + + if (!usd) { + console.log("Please seed currencies (expecting USD)"); + return; + } + + const account: typeof accountsTable.$inferInsert = { bankName: "Sample Bank", - currentAmount: 1000.0, + currentBalance: "1000.00", reference: "INIT-REF", usage: "Initial setup", userId: "sample-user-id", - createdAt: new Date(), + currencyId: usd.id, }; - await db.insert(welcomeTable).values(welcome); - console.log("New welcome row created!"); + await db.insert(accountsTable).values(account); + console.log("New account row created!"); } main(); diff --git a/src/server/routers/welcome.ts b/src/server/routers/welcome.ts index 0b685b9..b2831a1 100644 --- a/src/server/routers/welcome.ts +++ b/src/server/routers/welcome.ts @@ -1,7 +1,10 @@ import { createTRPCRouter, protectedProcedure } from "../api/trpc"; import { db } from "~/db"; -import { welcomeTable } from "~/db/schema"; -import { eq } from "drizzle-orm"; +import { + accountsTable, + currenciesTable, +} from "~/db/schema"; +import { and, eq } from "drizzle-orm"; import { welcomeSchemaBase } from "~/schemas/welcomeSchema"; export const welcomeRouter = createTRPCRouter({ @@ -10,20 +13,29 @@ export const welcomeRouter = createTRPCRouter({ .mutation(async ({ input, ctx }) => { const { userId } = ctx; - const amount = Number(input.currentAmount); - if (Number.isNaN(amount)) { - throw new Error("Provided amount is not a valid number"); + // Pick any currency code you wish to default to; try USD first, fall back to any + const [currency] = await db + .select() + .from(currenciesTable) + .where(eq(currenciesTable.code, "USD")) + .limit(1); + + const currencyId = currency?.id; + if (!currencyId) { + throw new Error( + "No default currency found (expected code USD). Seed currencies first.", + ); } const result = await db - .insert(welcomeTable) + .insert(accountsTable) .values({ bankName: input.bankName, - currentAmount: amount, + currentBalance: String(input.currentAmount.toFixed(2)), reference: input.reference, usage: input.usage, userId, - createdAt: new Date(), + currencyId, }) .returning(); @@ -32,11 +44,10 @@ export const welcomeRouter = createTRPCRouter({ getByUserId: protectedProcedure.query(async ({ ctx }) => { const { userId } = ctx; - - const welcomes = await db + const accounts = await db .select() - .from(welcomeTable) - .where(eq(welcomeTable.userId, userId)); - return welcomes; + .from(accountsTable) + .where(eq(accountsTable.userId, userId)); + return accounts; }), }); From 87b0d71d458a5000c423dc2bcbe523f09f9f17e7 Mon Sep 17 00:00:00 2001 From: Federico Leiva Date: Thu, 9 Oct 2025 19:26:40 -0300 Subject: [PATCH 02/22] feat: add seed script to package.json for database seeding --- package.json | 1 + src/scripts/seed.ts | 66 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 src/scripts/seed.ts diff --git a/package.json b/package.json index 867b22e..91b841e 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "android": "expo run:android", "ios": "expo run:ios", "web": "expo start --web", + "seed": "tsx src/scripts/seed.ts", "test": "jest --watchAll", "lint": "expo lint", "typecheck": "tsc --noEmit", diff --git a/src/scripts/seed.ts b/src/scripts/seed.ts new file mode 100644 index 0000000..7041e0c --- /dev/null +++ b/src/scripts/seed.ts @@ -0,0 +1,66 @@ +import "dotenv/config"; +import { db } from "../db"; +import { categoriesTable, currenciesTable } from "../db/schema"; +import { eq, 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); +}); + From 705d96ddacb08f9eab0252ca8127bf17f400facb Mon Sep 17 00:00:00 2001 From: Federico Leiva Date: Mon, 13 Oct 2025 19:05:04 -0300 Subject: [PATCH 03/22] feat: implement account types and enhance accounts management - Added account types to the database schema and created related migrations. - Introduced new API routes for managing accounts and retrieving user data. - Updated the frontend to handle account creation and retrieval with type support. - Added error handling for account-related operations. - Created a backend development guide for consistency and best practices. --- docs/backend-development.md | 119 +++++++ drizzle/0001_nostalgic_master_mold.sql | 10 + drizzle/meta/0001_snapshot.json | 473 +++++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + package.json | 3 + src/app/(auth)/sign-in.tsx | 2 +- src/app/index.tsx | 17 +- src/app/start/index.tsx | 6 +- src/db/schema.ts | 17 + src/errors/messages.ts | 18 + src/errors/trpc.ts | 22 ++ src/scripts/seed.ts | 28 +- src/server/api/root.ts | 8 +- src/server/api/trpc.ts | 18 +- src/server/index.ts | 10 +- src/server/routers/accounts.ts | 94 +++++ src/server/routers/categories.ts | 15 + src/server/routers/post.ts | 13 - src/server/routers/transactions.ts | 160 +++++++++ src/server/routers/users.ts | 30 ++ src/server/routers/welcome.ts | 53 --- src/types/index.ts | 51 ++- 22 files changed, 1085 insertions(+), 89 deletions(-) create mode 100644 docs/backend-development.md create mode 100644 drizzle/0001_nostalgic_master_mold.sql create mode 100644 drizzle/meta/0001_snapshot.json create mode 100644 src/errors/messages.ts create mode 100644 src/errors/trpc.ts create mode 100644 src/server/routers/accounts.ts create mode 100644 src/server/routers/categories.ts delete mode 100644 src/server/routers/post.ts create mode 100644 src/server/routers/transactions.ts create mode 100644 src/server/routers/users.ts delete mode 100644 src/server/routers/welcome.ts diff --git a/docs/backend-development.md b/docs/backend-development.md new file mode 100644 index 0000000..64d027f --- /dev/null +++ b/docs/backend-development.md @@ -0,0 +1,119 @@ +## 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: + - Always scope 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 +const whereExpr = and( + 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, +).filter(Boolean) as any; // build conditionally, or build stepwise without arrays + +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/0001_nostalgic_master_mold.sql b/drizzle/0001_nostalgic_master_mold.sql new file mode 100644 index 0000000..2837b6c --- /dev/null +++ b/drizzle/0001_nostalgic_master_mold.sql @@ -0,0 +1,10 @@ +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 +ALTER TABLE "accounts" ADD COLUMN "typeId" uuid NOT NULL;--> statement-breakpoint +CREATE UNIQUE INDEX "account_types_slug_unique_idx" ON "account_types" USING btree ("slug");--> statement-breakpoint +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 +CREATE INDEX "accounts_type_idx" ON "accounts" USING btree ("typeId"); \ No newline at end of file diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..bab07d1 --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,473 @@ +{ + "id": "ccc0eca7-766c-431b-930d-bdfb833c2101", + "prevId": "9f87a7a3-7c20-4245-a39d-f4e17eb70443", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account_types": { + "name": "account_types", + "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": { + "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", + "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 + }, + "usage": { + "name": "usage", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "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()" + } + }, + "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": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 4352e32..948a20f 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1760046554794, "tag": "0000_unique_mercury", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1760204489591, + "tag": "0001_nostalgic_master_mold", + "breakpoints": true } ] } \ No newline at end of file diff --git a/package.json b/package.json index 91b841e..81164b2 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,9 @@ "ios": "expo run:ios", "web": "expo start --web", "seed": "tsx src/scripts/seed.ts", + "migrate": "drizzle-kit migrate", + "studio": "drizzle-kit studio", + "generate": "drizzle-kit generate", "test": "jest --watchAll", "lint": "expo lint", "typecheck": "tsc --noEmit", diff --git a/src/app/(auth)/sign-in.tsx b/src/app/(auth)/sign-in.tsx index b9be1f5..6b02727 100644 --- a/src/app/(auth)/sign-in.tsx +++ b/src/app/(auth)/sign-in.tsx @@ -99,7 +99,7 @@ export default function Page() { }); if (signInAttempt.status === "complete") { await setActive({ session: signInAttempt.createdSessionId }); - router.replace("/start"); + router.replace("/"); } else { setError( t("signInFailed", "Sign in failed. Please check your credentials"), diff --git a/src/app/index.tsx b/src/app/index.tsx index 71591b7..1c2deca 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); @@ -140,10 +141,22 @@ export default function Index() { const { isSignedIn } = useAuth(); const { t } = useTranslation(); - if (isSignedIn) { - return ; + const { data: accounts, isLoading: isLoadingAccounts } = trpc.accounts.getAccounts.useQuery(); + + console.log({accounts, isSignedIn, isLoadingAccounts}) + if (isSignedIn && !isLoadingAccounts && (!accounts || accounts?.length === 0)) { + return ; + } else if (isSignedIn && !isLoadingAccounts && (accounts || [])?.length > 0) { + return ; } + /// TODO: Add loading state + // if (isLoadingAccounts) { + // return + // + // ; + // } + 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..4ea8145 100644 --- a/src/app/start/index.tsx +++ b/src/app/start/index.tsx @@ -44,7 +44,9 @@ const Home = () => { initLanguage(); }, []); - const createAccount = trpc.account.create.useMutation({ + const { data: userData } = trpc.users.getUser.useQuery(); + + const createAccount = trpc.accounts.addAccount.useMutation({ onSuccess: () => { Alert.alert(t("success"), t("accountCreated")); reset(); @@ -76,7 +78,7 @@ const Home = () => { const handleCreateAccount = async (data: WelcomeFormValues) => { await createAccount.mutateAsync({ ...data, - currentAmount: data.currentAmount, + currentBalance: data.currentAmount.toString(), }); }; diff --git a/src/db/schema.ts b/src/db/schema.ts index 9dff02f..076cdbc 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -40,6 +40,19 @@ export const categoriesTable = pgTable( }), ); +// Account types +export const accountTypesTable = pgTable( + "account_types", + { + id: uuid().defaultRandom().primaryKey(), + name: varchar({ length: 100 }).notNull(), + slug: varchar({ length: 120 }).notNull(), + }, + (table) => ({ + slugUniqueIdx: uniqueIndex("account_types_slug_unique_idx").on(table.slug), + }), +); + // Accounts export const accountsTable = pgTable( "accounts", @@ -53,6 +66,9 @@ export const accountsTable = pgTable( currentBalance: decimal({ precision: 18, scale: 2, mode: "string" }).notNull(), reference: varchar({ length: 255 }).notNull(), usage: varchar({ length: 255 }).notNull(), + typeId: uuid() + .notNull() + .references(() => accountTypesTable.id, { onDelete: "restrict" }), currencyId: uuid() .notNull() .references(() => currenciesTable.id, { onDelete: "restrict" }), @@ -61,6 +77,7 @@ export const accountsTable = pgTable( (table) => ({ userIdx: index("accounts_user_idx").on(table.userId), currencyIdx: index("accounts_currency_idx").on(table.currencyId), + typeIdx: index("accounts_type_idx").on(table.typeId), }), ); diff --git a/src/errors/messages.ts b/src/errors/messages.ts new file mode 100644 index 0000000..3316da0 --- /dev/null +++ b/src/errors/messages.ts @@ -0,0 +1,18 @@ +export const error_messages = { + // Generic + forbidden: "forbidden", + notFound: "notFound", + badRequest: "badRequest", + + // Accounts + currencyNotFound: "currencyNotFound", + accountTypeNotFound: "accountTypeNotFound", + accountNotOwned: "accountNotOwned", + + // Transactions + transactionNotFound: "transactionNotFound", +} as const; + +export type ErrorMessageKey = keyof typeof error_messages; + + diff --git a/src/errors/trpc.ts b/src/errors/trpc.ts new file mode 100644 index 0000000..bd1a65c --- /dev/null +++ b/src/errors/trpc.ts @@ -0,0 +1,22 @@ +import { TRPCError } from "@trpc/server"; + +export const errors = { + notAuthenticated: () => + new TRPCError({ code: "UNAUTHORIZED", message: "Not authenticated" }), + forbidden: (message: string = "Forbidden") => + new TRPCError({ code: "FORBIDDEN", message }), + badRequest: (message: string = "Bad request") => + new TRPCError({ code: "BAD_REQUEST", message }), + notFound: (message: string = "Not found") => + new TRPCError({ code: "NOT_FOUND", message }), + conflict: (message: string = "Conflict") => + new TRPCError({ code: "CONFLICT", message }), + internal: (message: string = "Internal server error") => + new TRPCError({ code: "INTERNAL_SERVER_ERROR", message }), + tooManyRequests: (message: string = "Too many requests") => + new TRPCError({ code: "TOO_MANY_REQUESTS", message }), +}; + +export type StandardErrorFactory = typeof errors; + + diff --git a/src/scripts/seed.ts b/src/scripts/seed.ts index 7041e0c..5cc76a1 100644 --- a/src/scripts/seed.ts +++ b/src/scripts/seed.ts @@ -1,6 +1,6 @@ import "dotenv/config"; import { db } from "../db"; -import { categoriesTable, currenciesTable } from "../db/schema"; +import { accountTypesTable, categoriesTable, currenciesTable } from "../db/schema"; import { eq, inArray } from "drizzle-orm"; async function seedCurrencies() { @@ -54,9 +54,35 @@ async function seedCategories() { } } +async function seedAccountTypes() { + const accountTypes = [ + { name: "Private", slug: "private" }, + { name: "Business", slug: "business" }, + { name: "Safe", slug: "safe" }, + ]; + + const slugs = accountTypes.map((c) => c.slug); + const existing = await db + .select({ slug: accountTypesTable.slug }) + .from(accountTypesTable) + .where(inArray(accountTypesTable.slug, slugs)); + + const existingSlugs = new Set(existing.map((e) => e.slug)); + + const toInsert = accountTypes.filter((c) => !existingSlugs.has(c.slug)); + + if (toInsert.length > 0) { + await db.insert(accountTypesTable).values(toInsert); + console.log(`Inserted account types: ${toInsert.map((c) => c.slug).join(", ")}`); + } else { + console.log("Account types already seeded"); + } +} + async function main() { await seedCurrencies(); await seedCategories(); + await seedAccountTypes(); } main().catch((err) => { diff --git a/src/server/api/root.ts b/src/server/api/root.ts index 810ec6e..4896181 100644 --- a/src/server/api/root.ts +++ b/src/server/api/root.ts @@ -1,6 +1,6 @@ -import { postRouter } from "../routers/post"; -import { welcomeRouter } from "../routers/welcome"; import { createTRPCRouter } from "./trpc"; +import { usersRouter } from "../routers/users"; +import { accountsRouter } from "../routers/accounts"; /** * This is the primary router for your server. @@ -8,8 +8,8 @@ import { createTRPCRouter } from "./trpc"; * All routers added in /api/routers should be manually added here. */ export const appRouter = createTRPCRouter({ - post: postRouter, - account: welcomeRouter, + users: usersRouter, + accounts: accountsRouter, }); // export type definition of API diff --git a/src/server/api/trpc.ts b/src/server/api/trpc.ts index 6fc23ff..048598b 100644 --- a/src/server/api/trpc.ts +++ b/src/server/api/trpc.ts @@ -9,13 +9,18 @@ import { initTRPC } from "@trpc/server"; import superjson from "superjson"; import { z, ZodError } from "zod"; -import { verifyToken } from "@clerk/backend"; +import { verifyToken, createClerkClient } from "@clerk/backend"; +import { errors } from "~/errors/trpc"; // import { db } from "~/server/db"; import { db } from "~/db"; const CLERK_SECRET_KEY = process.env.CLERK_SECRET_KEY!; +const clerkClient = createClerkClient({ + secretKey: CLERK_SECRET_KEY, +}); + /** * 1. CONTEXT * @@ -123,14 +128,21 @@ export const publicProcedure = t.procedure; * This is the base piece you use to build new queries and mutations on your tRPC API. It guarantees * that a user querying is authorized and can access their own data. */ -export const protectedProcedure = t.procedure.use(({ ctx, next }) => { +export const protectedProcedure = t.procedure.use(async ({ ctx, next }) => { if (!ctx.userId) { - throw new Error("Not authenticated"); + throw errors.notAuthenticated(); } + + const user = await clerkClient.users.getUser(ctx.userId); + if (!user) { + throw errors.notAuthenticated(); + } + return next({ ctx: { ...ctx, userId: ctx.userId, + user, }, }); }); diff --git a/src/server/index.ts b/src/server/index.ts index c8cb635..fcd6147 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,8 +1,2 @@ -import { router } from "./trpc"; -import { welcomeRouter } from "./routers/welcome"; - -export const appRouter = router({ - account: welcomeRouter, -}); - -export type AppRouter = typeof appRouter; +export { appRouter } from "./api/root"; +export type { AppRouter } from "./api/root"; \ No newline at end of file diff --git a/src/server/routers/accounts.ts b/src/server/routers/accounts.ts new file mode 100644 index 0000000..09f6f75 --- /dev/null +++ b/src/server/routers/accounts.ts @@ -0,0 +1,94 @@ +import { createTRPCRouter, protectedProcedure } from "../api/trpc"; +import { db } from "~/db"; +import { accountsTable, accountTypesTable, currenciesTable } from "~/db/schema"; +import { eq } from "drizzle-orm"; +import { AccountWithCurrencyAndType } from "~/types"; +import { z } from "zod"; +import { errors } from "~/errors/trpc"; +import { error_messages } from "~/errors/messages"; + +export const accountsRouter = createTRPCRouter({ + addAccount: protectedProcedure + .input( + z.object({ + bankName: z.string(), + currentBalance: z.string(), + reference: z.string(), + usage: z.string(), + typeId: z.string().optional(), + }), + ) + .mutation(async ({ ctx, input }): Promise => { + const userId = ctx.userId!; + const currency = await db + .select() + .from(currenciesTable) + .where(eq(currenciesTable.code, "EUR")) + .limit(1); // Hardcoded to EUR + if (currency.length === 0) { + throw errors.notFound(error_messages.currencyNotFound); + } + + const accountType = await db + .select() + .from(accountTypesTable) + .where(eq(accountTypesTable.slug, input.typeId || "private")) + .limit(1); + if (accountType.length === 0) { + throw errors.notFound(error_messages.accountTypeNotFound); + } + + const account = await db + .insert(accountsTable) + .values({ + userId, + bankName: input.bankName, + currentBalance: input.currentBalance, + reference: input.reference, + usage: input.usage, + currencyId: currency[0].id, + typeId: accountType[0].id, + }) + .returning(); + + return { + id: account[0].id, + bankName: account[0].bankName, + currentBalance: account[0].currentBalance, + reference: account[0].reference, + usage: account[0].usage, + currencyId: account[0].currencyId, + currency: currency[0], + typeId: account[0].typeId, + type: accountType[0], + }; + }), + getAccounts: protectedProcedure.query( + async ({ ctx }): Promise => { + const userId = ctx.userId!; + const rows = await db + .select({ + accounts: accountsTable, + accountType: accountTypesTable, + currency: currenciesTable, + }) + .from(accountsTable) + .where(eq(accountsTable.userId, userId)) + .innerJoin(accountTypesTable, eq(accountsTable.typeId, accountTypesTable.id)) + .innerJoin(currenciesTable, eq(accountsTable.currencyId, currenciesTable.id)) + .execute(); + + return rows.map(({ accounts, currency, accountType }) => ({ + id: accounts.id, + bankName: accounts.bankName, + currentBalance: accounts.currentBalance, + reference: accounts.reference, + usage: accounts.usage, + currencyId: accounts.currencyId, + currency, + typeId: accounts.typeId, + type: accountType, + })); + }, + ), +}); diff --git a/src/server/routers/categories.ts b/src/server/routers/categories.ts new file mode 100644 index 0000000..5a214a0 --- /dev/null +++ b/src/server/routers/categories.ts @@ -0,0 +1,15 @@ +import { createTRPCRouter, protectedProcedure } from "../api/trpc"; +import { db } from "~/db"; +import { categoriesTable } from "~/db/schema"; +import { Category } from "~/types"; + +export const categoriesRouter = createTRPCRouter({ + getCategories: protectedProcedure.query(async (): Promise => { + const categories = await db.select().from(categoriesTable); + return categories.map((category) => ({ + id: category.id, + name: category.name, + slug: category.slug, + })); + }), +}); diff --git a/src/server/routers/post.ts b/src/server/routers/post.ts deleted file mode 100644 index 2b489d1..0000000 --- a/src/server/routers/post.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { z } from "zod"; -import { createTRPCRouter, publicProcedure } from "../api/trpc"; - -export const postRouter = createTRPCRouter({ - hello: publicProcedure - .input(z.object({ text: z.string() })) - .query(async ({ input }) => { - // Remove artificial delay for production - return { - greeting: `Hi ${input.text}`, - }; - }), -}); diff --git a/src/server/routers/transactions.ts b/src/server/routers/transactions.ts new file mode 100644 index 0000000..c9d919f --- /dev/null +++ b/src/server/routers/transactions.ts @@ -0,0 +1,160 @@ +import { createTRPCRouter, protectedProcedure } from "../api/trpc"; +import { db } from "~/db"; +import { accountsTable, categoriesTable, transactionsTable } from "~/db/schema"; +import { eq, gte, lte, and } from "drizzle-orm"; +import type { SQL } from "drizzle-orm"; +import { TransactionWithCategoryAndAccount } from "~/types"; +import { z } from "zod"; +import { errors } from "~/errors/trpc"; +import { error_messages } from "~/errors/messages"; + +export const transactionsRouter = createTRPCRouter({ + addTransaction: protectedProcedure + .input( + z.object({ + categoryId: z.string(), + company: z.string(), + amount: z.string(), + datetime: z.date(), + description: z.string().optional(), + accountId: z.string(), + }), + ) + .mutation( + async ({ ctx, input }): Promise => { + // Validate account ownership + const account = await db + .select({ id: accountsTable.id }) + .from(accountsTable) + .where( + and( + eq(accountsTable.id, input.accountId), + eq(accountsTable.userId, ctx.userId!), + ), + ) + .limit(1); + + if (account.length === 0) { + throw errors.forbidden(error_messages.accountNotOwned); + } + + const transaction = await db + .insert(transactionsTable) + .values({ + categoryId: input.categoryId, + company: input.company, + amount: input.amount, + datetime: input.datetime, + description: input.description || "", + accountId: input.accountId, + }) + .returning(); + + const transactionWithCategoryAndAccount = await db + .select({ + transactions: transactionsTable, + categories: categoriesTable, + accounts: accountsTable, + }) + .from(transactionsTable) + .innerJoin( + accountsTable, + eq(transactionsTable.accountId, accountsTable.id), + ) + .innerJoin( + categoriesTable, + eq(transactionsTable.categoryId, categoriesTable.id), + ) + .where( + and( + eq(transactionsTable.id, transaction[0].id), + eq(accountsTable.userId, ctx.userId!), + ), + ) + .limit(1) + .execute(); + + const row = transactionWithCategoryAndAccount[0]; + return { + ...row.transactions, + category: row.categories, + account: row.accounts, + }; + }, + ), + getTransactions: protectedProcedure + .input( + z.object({ + accountId: z.string().optional(), + categoryId: z.string().optional(), + startDate: z.date().optional(), + endDate: z.date().optional(), + }), + ) + .query( + async ({ ctx, input }): Promise => { + if (input.accountId) { + const owned = await db + .select({ id: accountsTable.id }) + .from(accountsTable) + .where( + and( + eq(accountsTable.id, input.accountId), + eq(accountsTable.userId, ctx.userId!), + ), + ) + .limit(1); + if (owned.length === 0) { + throw errors.forbidden(error_messages.accountNotOwned); + } + } + + // Always restrict to the current user's accounts + let whereExpr: SQL | undefined = eq(accountsTable.userId, ctx.userId!); + if (input.accountId) { + whereExpr = whereExpr + ? and(whereExpr, eq(transactionsTable.accountId, input.accountId)) + : eq(transactionsTable.accountId, input.accountId); + } + if (input.categoryId) { + whereExpr = whereExpr + ? and(whereExpr, eq(transactionsTable.categoryId, input.categoryId)) + : eq(transactionsTable.categoryId, input.categoryId); + } + if (input.startDate) { + whereExpr = whereExpr + ? and(whereExpr, gte(transactionsTable.datetime, input.startDate)) + : gte(transactionsTable.datetime, input.startDate); + } + if (input.endDate) { + whereExpr = whereExpr + ? and(whereExpr, lte(transactionsTable.datetime, input.endDate)) + : lte(transactionsTable.datetime, input.endDate); + } + + 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, + })); + }, + ), +}); diff --git a/src/server/routers/users.ts b/src/server/routers/users.ts new file mode 100644 index 0000000..3f20eb0 --- /dev/null +++ b/src/server/routers/users.ts @@ -0,0 +1,30 @@ +import { createTRPCRouter, protectedProcedure } from "../api/trpc"; +import { db } from "~/db"; +import { usersTable } from "~/db/schema"; +import { eq } from "drizzle-orm"; +import { User } from "~/types"; + +export const usersRouter = createTRPCRouter({ + getUser: protectedProcedure.query(async ({ ctx }): Promise => { + const userId = ctx.userId!; + const user = ctx.user; + + const existingUsers = await db + .select() + .from(usersTable) + .where(eq(usersTable.id, userId)) + .limit(1); + + if (existingUsers.length === 0) { + await db.insert(usersTable).values({ id: userId }); + } + + return { + id: existingUsers[0].id, + firstName: user.firstName || "", + lastName: user.lastName || "", + emails: user.emailAddresses.map((email) => email.emailAddress), + phoneNumbers: user.phoneNumbers.map((phone) => phone.phoneNumber), + }; + }), +}); diff --git a/src/server/routers/welcome.ts b/src/server/routers/welcome.ts deleted file mode 100644 index b2831a1..0000000 --- a/src/server/routers/welcome.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { createTRPCRouter, protectedProcedure } from "../api/trpc"; -import { db } from "~/db"; -import { - accountsTable, - currenciesTable, -} from "~/db/schema"; -import { and, eq } from "drizzle-orm"; -import { welcomeSchemaBase } from "~/schemas/welcomeSchema"; - -export const welcomeRouter = createTRPCRouter({ - create: protectedProcedure - .input(welcomeSchemaBase) - .mutation(async ({ input, ctx }) => { - const { userId } = ctx; - - // Pick any currency code you wish to default to; try USD first, fall back to any - const [currency] = await db - .select() - .from(currenciesTable) - .where(eq(currenciesTable.code, "USD")) - .limit(1); - - const currencyId = currency?.id; - if (!currencyId) { - throw new Error( - "No default currency found (expected code USD). Seed currencies first.", - ); - } - - const result = await db - .insert(accountsTable) - .values({ - bankName: input.bankName, - currentBalance: String(input.currentAmount.toFixed(2)), - reference: input.reference, - usage: input.usage, - userId, - currencyId, - }) - .returning(); - - return { success: true, account: result[0] }; - }), - - getByUserId: protectedProcedure.query(async ({ ctx }) => { - const { userId } = ctx; - const accounts = await db - .select() - .from(accountsTable) - .where(eq(accountsTable.userId, userId)); - return accounts; - }), -}); diff --git a/src/types/index.ts b/src/types/index.ts index a93d057..c6838c0 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,11 +1,58 @@ import type React from "react"; +export interface User { + id: string; + firstName: string; + lastName: string; + emails: string[]; + phoneNumbers: string[]; +} + +export interface AccountType { + id: string; + name: string; + slug: string; +} + export interface Account { + id: string; + bankName: string; + currentBalance: string; + reference: string; + usage: string; + currencyId: string; + typeId: string; +} + +export interface Category { id: string; name: string; + slug: string; +} + +export interface Currency { + id: string; + code: string; +} + +export interface AccountWithCurrencyAndType extends Account { + currency: Currency; + type: AccountType; +} + +export interface Transaction { + id: string; + categoryId: string; + company: string; amount: string; - icon: React.ReactNode; - type: "paypal" | "dkb" | "revolut" | "depot"; + datetime: Date; + description: string; + accountId: string; +} + +export interface TransactionWithCategoryAndAccount extends Transaction { + category: Category; + account: Account; } export interface Card { From 60718aeb637657533a86a5a4f6c1913f302bd992 Mon Sep 17 00:00:00 2001 From: Federico Leiva Date: Mon, 13 Oct 2025 19:14:08 -0300 Subject: [PATCH 04/22] docs: update backend development guide and improve code formatting - Enhanced the backend development documentation with additional guidelines and best practices. - Improved code formatting for better readability in various files, including schema and router definitions. - Added missing newlines in JSON files for consistency. - Updated mock data structure to align with new account properties. --- docs/backend-development.md | 46 ++++++++++++++++++++++++------ drizzle/meta/0000_snapshot.json | 34 ++++++---------------- drizzle/meta/0001_snapshot.json | 42 +++++++-------------------- drizzle/meta/_journal.json | 2 +- src/app/(tabs)/banking/index.tsx | 12 ++++---- src/app/index.tsx | 11 +++++-- src/data/mockData.ts | 7 +++-- src/db/schema.ts | 6 +++- src/errors/messages.ts | 2 -- src/errors/trpc.ts | 2 -- src/index.ts | 1 + src/scripts/seed.ts | 19 ++++++++---- src/server/index.ts | 2 +- src/server/routers/accounts.ts | 10 +++++-- src/server/routers/transactions.ts | 8 +++--- 15 files changed, 109 insertions(+), 95 deletions(-) diff --git a/docs/backend-development.md b/docs/backend-development.md index 64d027f..a828437 100644 --- a/docs/backend-development.md +++ b/docs/backend-development.md @@ -3,6 +3,7 @@ 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`. @@ -11,6 +12,7 @@ This guide documents how we build backend features in this repository to ensure - **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. @@ -19,6 +21,7 @@ This guide documents how we build backend features in this repository to ensure - `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. @@ -26,12 +29,14 @@ This guide documents how we build backend features in this repository to ensure - 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 })`. @@ -40,22 +45,26 @@ This guide documents how we build backend features in this repository to ensure - 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)`. @@ -64,22 +73,38 @@ This guide documents how we build backend features in this repository to ensure - 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 const whereExpr = and( 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.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, ).filter(Boolean) as any; // build conditionally, or build stepwise without arrays const qb = db - .select({ transactions: transactionsTable, categories: categoriesTable, accounts: accountsTable }) + .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(); + .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, @@ -88,32 +113,35 @@ return rows.map((row) => ({ ``` ### 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/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json index cf2b838..3df20b1 100644 --- a/drizzle/meta/0000_snapshot.json +++ b/drizzle/meta/0000_snapshot.json @@ -96,12 +96,8 @@ "name": "accounts_userId_users_id_fk", "tableFrom": "accounts", "tableTo": "users", - "columnsFrom": [ - "userId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["userId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -109,12 +105,8 @@ "name": "accounts_currencyId_currencies_id_fk", "tableFrom": "accounts", "tableTo": "currencies", - "columnsFrom": [ - "currencyId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["currencyId"], + "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" } @@ -328,12 +320,8 @@ "name": "transactions_accountId_accounts_id_fk", "tableFrom": "transactions", "tableTo": "accounts", - "columnsFrom": [ - "accountId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["accountId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -341,12 +329,8 @@ "name": "transactions_categoryId_categories_id_fk", "tableFrom": "transactions", "tableTo": "categories", - "columnsFrom": [ - "categoryId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["categoryId"], + "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" } @@ -388,4 +372,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json index bab07d1..647de6c 100644 --- a/drizzle/meta/0001_snapshot.json +++ b/drizzle/meta/0001_snapshot.json @@ -165,12 +165,8 @@ "name": "accounts_userId_users_id_fk", "tableFrom": "accounts", "tableTo": "users", - "columnsFrom": [ - "userId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["userId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -178,12 +174,8 @@ "name": "accounts_typeId_account_types_id_fk", "tableFrom": "accounts", "tableTo": "account_types", - "columnsFrom": [ - "typeId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["typeId"], + "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" }, @@ -191,12 +183,8 @@ "name": "accounts_currencyId_currencies_id_fk", "tableFrom": "accounts", "tableTo": "currencies", - "columnsFrom": [ - "currencyId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["currencyId"], + "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" } @@ -410,12 +398,8 @@ "name": "transactions_accountId_accounts_id_fk", "tableFrom": "transactions", "tableTo": "accounts", - "columnsFrom": [ - "accountId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["accountId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -423,12 +407,8 @@ "name": "transactions_categoryId_categories_id_fk", "tableFrom": "transactions", "tableTo": "categories", - "columnsFrom": [ - "categoryId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["categoryId"], + "columnsTo": ["id"], "onDelete": "restrict", "onUpdate": "no action" } @@ -470,4 +450,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 948a20f..93f4d9d 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -17,4 +17,4 @@ "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/src/app/(tabs)/banking/index.tsx b/src/app/(tabs)/banking/index.tsx index 41f1cd3..5958ba1 100644 --- a/src/app/(tabs)/banking/index.tsx +++ b/src/app/(tabs)/banking/index.tsx @@ -61,10 +61,10 @@ const Dashboard = () => { handleAccountPress(account.name, account.id)} + onPress={() => handleAccountPress(account?.name || "", account.id)} /> ))} @@ -73,10 +73,10 @@ const Dashboard = () => { handleAccountPress(account.name, account.id)} + onPress={() => handleAccountPress(account?.name || "", account.id)} /> ))} @@ -86,10 +86,10 @@ const Dashboard = () => { handleAccountPress(account.name, account.id)} + onPress={() => handleAccountPress(account?.name || "", account.id)} /> ))} diff --git a/src/app/index.tsx b/src/app/index.tsx index 1c2deca..836959f 100644 --- a/src/app/index.tsx +++ b/src/app/index.tsx @@ -141,10 +141,15 @@ export default function Index() { const { isSignedIn } = useAuth(); const { t } = useTranslation(); - const { data: accounts, isLoading: isLoadingAccounts } = trpc.accounts.getAccounts.useQuery(); + const { data: accounts, isLoading: isLoadingAccounts } = + trpc.accounts.getAccounts.useQuery(); - console.log({accounts, isSignedIn, isLoadingAccounts}) - if (isSignedIn && !isLoadingAccounts && (!accounts || accounts?.length === 0)) { + console.log({ accounts, isSignedIn, isLoadingAccounts }); + if ( + isSignedIn && + !isLoadingAccounts && + (!accounts || accounts?.length === 0) + ) { return ; } else if (isSignedIn && !isLoadingAccounts && (accounts || [])?.length > 0) { return ; diff --git a/src/data/mockData.ts b/src/data/mockData.ts index 93baad6..b0b4216 100644 --- a/src/data/mockData.ts +++ b/src/data/mockData.ts @@ -5,10 +5,11 @@ import { RevolutIcon, SharedFundsIcon, } from "~/components/Icons"; -import type { DashboardData } from "~/types"; +import type { Account } from "~/types"; import { CircleArrowRightIcon, LandmarkIcon } from "lucide-react-native"; -export const mockDashboardData: DashboardData = { +// TODO: Remove mock data +export const mockDashboardData = { user: { name: "Julia's", }, @@ -32,7 +33,7 @@ export const mockDashboardData: DashboardData = { private: [ { id: "1", - name: "Pay Pal", + bankName: "Pay Pal", amount: "53.99€", icon: React.createElement(PayPalIcon), type: "paypal", diff --git a/src/db/schema.ts b/src/db/schema.ts index 076cdbc..f47fc92 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -63,7 +63,11 @@ export const accountsTable = pgTable( .references(() => usersTable.id, { onDelete: "cascade" }), bankName: varchar({ length: 255 }).notNull(), // Use high-precision decimal stored as string in code to prevent JS float issues - currentBalance: decimal({ precision: 18, scale: 2, mode: "string" }).notNull(), + currentBalance: decimal({ + precision: 18, + scale: 2, + mode: "string", + }).notNull(), reference: varchar({ length: 255 }).notNull(), usage: varchar({ length: 255 }).notNull(), typeId: uuid() diff --git a/src/errors/messages.ts b/src/errors/messages.ts index 3316da0..c53b5b2 100644 --- a/src/errors/messages.ts +++ b/src/errors/messages.ts @@ -14,5 +14,3 @@ export const error_messages = { } as const; export type ErrorMessageKey = keyof typeof error_messages; - - diff --git a/src/errors/trpc.ts b/src/errors/trpc.ts index bd1a65c..d443b04 100644 --- a/src/errors/trpc.ts +++ b/src/errors/trpc.ts @@ -18,5 +18,3 @@ export const errors = { }; export type StandardErrorFactory = typeof errors; - - diff --git a/src/index.ts b/src/index.ts index 938cc78..dc66bd7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,6 +21,7 @@ async function main() { usage: "Initial setup", userId: "sample-user-id", currencyId: usd.id, + typeId: "private", }; await db.insert(accountsTable).values(account); diff --git a/src/scripts/seed.ts b/src/scripts/seed.ts index 5cc76a1..f32c7b1 100644 --- a/src/scripts/seed.ts +++ b/src/scripts/seed.ts @@ -1,6 +1,10 @@ import "dotenv/config"; import { db } from "../db"; -import { accountTypesTable, categoriesTable, currenciesTable } from "../db/schema"; +import { + accountTypesTable, + categoriesTable, + currenciesTable, +} from "../db/schema"; import { eq, inArray } from "drizzle-orm"; async function seedCurrencies() { @@ -22,7 +26,9 @@ async function seedCurrencies() { if (toInsert.length > 0) { await db.insert(currenciesTable).values(toInsert); - console.log(`Inserted currencies: ${toInsert.map((c) => c.code).join(", ")}`); + console.log( + `Inserted currencies: ${toInsert.map((c) => c.code).join(", ")}`, + ); } else { console.log("Currencies already seeded"); } @@ -48,7 +54,9 @@ async function seedCategories() { if (toInsert.length > 0) { await db.insert(categoriesTable).values(toInsert); - console.log(`Inserted categories: ${toInsert.map((c) => c.slug).join(", ")}`); + console.log( + `Inserted categories: ${toInsert.map((c) => c.slug).join(", ")}`, + ); } else { console.log("Categories already seeded"); } @@ -73,7 +81,9 @@ async function seedAccountTypes() { if (toInsert.length > 0) { await db.insert(accountTypesTable).values(toInsert); - console.log(`Inserted account types: ${toInsert.map((c) => c.slug).join(", ")}`); + console.log( + `Inserted account types: ${toInsert.map((c) => c.slug).join(", ")}`, + ); } else { console.log("Account types already seeded"); } @@ -89,4 +99,3 @@ main().catch((err) => { console.error(err); process.exit(1); }); - diff --git a/src/server/index.ts b/src/server/index.ts index fcd6147..fd5cab1 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,2 +1,2 @@ export { appRouter } from "./api/root"; -export type { AppRouter } from "./api/root"; \ No newline at end of file +export type { AppRouter } from "./api/root"; diff --git a/src/server/routers/accounts.ts b/src/server/routers/accounts.ts index 09f6f75..34df2b9 100644 --- a/src/server/routers/accounts.ts +++ b/src/server/routers/accounts.ts @@ -74,8 +74,14 @@ export const accountsRouter = createTRPCRouter({ }) .from(accountsTable) .where(eq(accountsTable.userId, userId)) - .innerJoin(accountTypesTable, eq(accountsTable.typeId, accountTypesTable.id)) - .innerJoin(currenciesTable, eq(accountsTable.currencyId, currenciesTable.id)) + .innerJoin( + accountTypesTable, + eq(accountsTable.typeId, accountTypesTable.id), + ) + .innerJoin( + currenciesTable, + eq(accountsTable.currencyId, currenciesTable.id), + ) .execute(); return rows.map(({ accounts, currency, accountType }) => ({ diff --git a/src/server/routers/transactions.ts b/src/server/routers/transactions.ts index c9d919f..310745e 100644 --- a/src/server/routers/transactions.ts +++ b/src/server/routers/transactions.ts @@ -146,10 +146,10 @@ export const transactionsRouter = createTRPCRouter({ .innerJoin( categoriesTable, eq(transactionsTable.categoryId, categoriesTable.id), - ) - ; - - const rows = whereExpr ? await qb.where(whereExpr).execute() : await qb.execute(); + ); + const rows = whereExpr + ? await qb.where(whereExpr).execute() + : await qb.execute(); return rows.map((row) => ({ ...row.transactions, category: row.categories, From af684b4061e820d90714f9f31b3f1ec591eb72ed Mon Sep 17 00:00:00 2001 From: Federico Leiva Date: Mon, 13 Oct 2025 19:24:41 -0300 Subject: [PATCH 05/22] fix: update mock data structure for consistency - Changed the property name from 'bankName' to 'name' in the mock dashboard data to align with the updated data structure. --- src/data/mockData.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/data/mockData.ts b/src/data/mockData.ts index b0b4216..370ff01 100644 --- a/src/data/mockData.ts +++ b/src/data/mockData.ts @@ -33,7 +33,7 @@ export const mockDashboardData = { private: [ { id: "1", - bankName: "Pay Pal", + name: "Pay Pal", amount: "53.99€", icon: React.createElement(PayPalIcon), type: "paypal", From e9ba12540a87b499dad5b8e17798a5aff755c2d8 Mon Sep 17 00:00:00 2001 From: Federico Leiva Date: Mon, 13 Oct 2025 22:47:14 -0300 Subject: [PATCH 06/22] refactor: improve query conditions and enhance user handling - Refactored the query conditions in the transactions router for better readability and maintainability by using an array to build conditions. - Updated the user retrieval logic in the users router to use `onConflictDoNothing` for inserting users, ensuring no duplicates are created. - Enhanced the backend development documentation with clearer examples and improved code formatting. --- docs/backend-development.md | 19 ++++++------- src/app/(tabs)/banking/index.tsx | 6 ++--- src/app/index.tsx | 5 ++-- src/index.ts | 31 --------------------- src/server/api/root.ts | 4 +++ src/server/routers/accounts.ts | 19 ++++++++----- src/server/routers/transactions.ts | 43 +++++++++++++++--------------- src/server/routers/users.ts | 17 +++--------- src/types/index.ts | 6 ++++- 9 files changed, 62 insertions(+), 88 deletions(-) delete mode 100644 src/index.ts diff --git a/docs/backend-development.md b/docs/backend-development.md index a828437..a545e68 100644 --- a/docs/backend-development.md +++ b/docs/backend-development.md @@ -75,19 +75,16 @@ This guide documents how we build backend features in this repository to ensure ### Example: Secure and Typed List Query ```ts -const whereExpr = and( +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.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, -).filter(Boolean) as any; // build conditionally, or build stepwise without arrays +]; +const typed = conditions.filter((c): c is SQL => Boolean(c)); +const whereExpr = typed.length ? and(...typed) : undefined; const qb = db .select({ diff --git a/src/app/(tabs)/banking/index.tsx b/src/app/(tabs)/banking/index.tsx index 5958ba1..1a25361 100644 --- a/src/app/(tabs)/banking/index.tsx +++ b/src/app/(tabs)/banking/index.tsx @@ -61,7 +61,7 @@ const Dashboard = () => { handleAccountPress(account?.name || "", account.id)} @@ -73,7 +73,7 @@ const Dashboard = () => { handleAccountPress(account?.name || "", account.id)} @@ -86,7 +86,7 @@ const Dashboard = () => { handleAccountPress(account?.name || "", account.id)} diff --git a/src/app/index.tsx b/src/app/index.tsx index 6a578b3..242d0f4 100644 --- a/src/app/index.tsx +++ b/src/app/index.tsx @@ -142,9 +142,10 @@ export default function Index() { const { t } = useTranslation(); const { data: accounts, isLoading: isLoadingAccounts } = - trpc.accounts.getAccounts.useQuery(); + trpc.accounts.getAccounts.useQuery(undefined, { + enabled: isSignedIn, + }); - console.log({ accounts, isSignedIn, isLoadingAccounts }); if ( isSignedIn && !isLoadingAccounts && diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index dc66bd7..0000000 --- a/src/index.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { db } from "./db/index"; -import { accountsTable, currenciesTable } from "./db/schema"; -import { eq } from "drizzle-orm"; - -async function main() { - const [usd] = await db - .select() - .from(currenciesTable) - .where(eq(currenciesTable.code, "USD")) - .limit(1); - - if (!usd) { - console.log("Please seed currencies (expecting USD)"); - return; - } - - const account: typeof accountsTable.$inferInsert = { - bankName: "Sample Bank", - currentBalance: "1000.00", - reference: "INIT-REF", - usage: "Initial setup", - userId: "sample-user-id", - currencyId: usd.id, - typeId: "private", - }; - - await db.insert(accountsTable).values(account); - console.log("New account row created!"); -} - -main(); diff --git a/src/server/api/root.ts b/src/server/api/root.ts index 4896181..67036e5 100644 --- a/src/server/api/root.ts +++ b/src/server/api/root.ts @@ -1,6 +1,8 @@ import { createTRPCRouter } from "./trpc"; import { usersRouter } from "../routers/users"; import { accountsRouter } from "../routers/accounts"; +import { transactionsRouter } from "../routers/transactions"; +import { categoriesRouter } from "../routers/categories"; /** * This is the primary router for your server. @@ -10,6 +12,8 @@ import { accountsRouter } from "../routers/accounts"; export const appRouter = createTRPCRouter({ users: usersRouter, accounts: accountsRouter, + transactions: transactionsRouter, + categories: categoriesRouter, }); // export type definition of API diff --git a/src/server/routers/accounts.ts b/src/server/routers/accounts.ts index 34df2b9..f28146f 100644 --- a/src/server/routers/accounts.ts +++ b/src/server/routers/accounts.ts @@ -1,7 +1,7 @@ import { createTRPCRouter, protectedProcedure } from "../api/trpc"; import { db } from "~/db"; import { accountsTable, accountTypesTable, currenciesTable } from "~/db/schema"; -import { eq } from "drizzle-orm"; +import { eq, or } from "drizzle-orm"; import { AccountWithCurrencyAndType } from "~/types"; import { z } from "zod"; import { errors } from "~/errors/trpc"; @@ -29,11 +29,18 @@ export const accountsRouter = createTRPCRouter({ throw errors.notFound(error_messages.currencyNotFound); } - const accountType = await db - .select() - .from(accountTypesTable) - .where(eq(accountTypesTable.slug, input.typeId || "private")) - .limit(1); + const accountType = input.typeId + ? await db + .select() + .from(accountTypesTable) + .where(eq(accountTypesTable.id, input.typeId)) + .limit(1) + : await db + .select() + .from(accountTypesTable) + .where(eq(accountTypesTable.slug, "private")) + .limit(1); + if (accountType.length === 0) { throw errors.notFound(error_messages.accountTypeNotFound); } diff --git a/src/server/routers/transactions.ts b/src/server/routers/transactions.ts index 310745e..4d5a6c9 100644 --- a/src/server/routers/transactions.ts +++ b/src/server/routers/transactions.ts @@ -75,6 +75,11 @@ export const transactionsRouter = createTRPCRouter({ .execute(); const row = transactionWithCategoryAndAccount[0]; + + if (!row) { + throw errors.notFound(error_messages.transactionNotFound); + } + return { ...row.transactions, category: row.categories, @@ -110,27 +115,23 @@ export const transactionsRouter = createTRPCRouter({ } // Always restrict to the current user's accounts - let whereExpr: SQL | undefined = eq(accountsTable.userId, ctx.userId!); - if (input.accountId) { - whereExpr = whereExpr - ? and(whereExpr, eq(transactionsTable.accountId, input.accountId)) - : eq(transactionsTable.accountId, input.accountId); - } - if (input.categoryId) { - whereExpr = whereExpr - ? and(whereExpr, eq(transactionsTable.categoryId, input.categoryId)) - : eq(transactionsTable.categoryId, input.categoryId); - } - if (input.startDate) { - whereExpr = whereExpr - ? and(whereExpr, gte(transactionsTable.datetime, input.startDate)) - : gte(transactionsTable.datetime, input.startDate); - } - if (input.endDate) { - whereExpr = whereExpr - ? and(whereExpr, lte(transactionsTable.datetime, input.endDate)) - : lte(transactionsTable.datetime, input.endDate); - } + 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: SQL | undefined = typed.length ? and(...typed) : undefined; const qb = db .select({ diff --git a/src/server/routers/users.ts b/src/server/routers/users.ts index 3f20eb0..c7c15d0 100644 --- a/src/server/routers/users.ts +++ b/src/server/routers/users.ts @@ -1,26 +1,17 @@ import { createTRPCRouter, protectedProcedure } from "../api/trpc"; import { db } from "~/db"; import { usersTable } from "~/db/schema"; -import { eq } from "drizzle-orm"; -import { User } from "~/types"; +import { ClerkUser } from "~/types"; export const usersRouter = createTRPCRouter({ - getUser: protectedProcedure.query(async ({ ctx }): Promise => { + getUser: protectedProcedure.query(async ({ ctx }): Promise => { const userId = ctx.userId!; const user = ctx.user; - const existingUsers = await db - .select() - .from(usersTable) - .where(eq(usersTable.id, userId)) - .limit(1); - - if (existingUsers.length === 0) { - await db.insert(usersTable).values({ id: userId }); - } + await db.insert(usersTable).values({ id: userId }).onConflictDoNothing(); return { - id: existingUsers[0].id, + id: userId, firstName: user.firstName || "", lastName: user.lastName || "", emails: user.emailAddresses.map((email) => email.emailAddress), diff --git a/src/types/index.ts b/src/types/index.ts index c6838c0..040da02 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,6 +1,10 @@ import type React from "react"; -export interface User { +/** + * Our database only saves the Clerk user id, so we need to get the user details from Clerk. + * When we request the user defails from our backend it automatically gets the user details from Clerk. + */ +export interface ClerkUser { id: string; firstName: string; lastName: string; From 3cba21c435dca7408ec05ad93cd1388e9d7d0013 Mon Sep 17 00:00:00 2001 From: FleetAdmiralJakob Date: Thu, 23 Oct 2025 14:28:59 +0200 Subject: [PATCH 07/22] format correctly --- docs/backend-development.md | 12 +++++++++--- src/server/routers/transactions.ts | 4 +++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/docs/backend-development.md b/docs/backend-development.md index a545e68..9d097ab 100644 --- a/docs/backend-development.md +++ b/docs/backend-development.md @@ -78,9 +78,15 @@ This guide documents how we build backend features in this repository to ensure 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.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)); diff --git a/src/server/routers/transactions.ts b/src/server/routers/transactions.ts index 4d5a6c9..2ad5229 100644 --- a/src/server/routers/transactions.ts +++ b/src/server/routers/transactions.ts @@ -131,7 +131,9 @@ export const transactionsRouter = createTRPCRouter({ : undefined, ]; const typed = conditions.filter((c): c is SQL => Boolean(c)); - const whereExpr: SQL | undefined = typed.length ? and(...typed) : undefined; + const whereExpr: SQL | undefined = typed.length + ? and(...typed) + : undefined; const qb = db .select({ From 0bc50c49a700cc673954ca486dfaa5fe530243aa Mon Sep 17 00:00:00 2001 From: FleetAdmiralJakob Date: Thu, 23 Oct 2025 14:32:43 +0200 Subject: [PATCH 08/22] comment the behaviour --- src/app/(auth)/sign-in.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/app/(auth)/sign-in.tsx b/src/app/(auth)/sign-in.tsx index 6b02727..d2ac9bc 100644 --- a/src/app/(auth)/sign-in.tsx +++ b/src/app/(auth)/sign-in.tsx @@ -99,6 +99,9 @@ export default function Page() { }); if (signInAttempt.status === "complete") { await setActive({ session: signInAttempt.createdSessionId }); + // 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( @@ -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 && From c66112dda5ea01dadaeb1863dad12daa36cc6aab Mon Sep 17 00:00:00 2001 From: FleetAdmiralJakob Date: Tue, 28 Oct 2025 19:06:55 +0100 Subject: [PATCH 09/22] improve the names of the commands in the package.json --- package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index a4be02e..fa8855f 100644 --- a/package.json +++ b/package.json @@ -8,9 +8,9 @@ "ios": "expo run:ios", "web": "expo start --web", "seed": "tsx src/scripts/seed.ts", - "migrate": "drizzle-kit migrate", - "studio": "drizzle-kit studio", - "generate": "drizzle-kit generate", + "db:migrate": "drizzle-kit migrate", + "db:studio": "drizzle-kit studio", + "db:generate": "drizzle-kit generate", "test": "jest --watchAll", "lint": "expo lint", "typecheck": "tsc --noEmit", From c4957e76ee8b61274eacb219afb8e2fcc496e910 Mon Sep 17 00:00:00 2001 From: FleetAdmiralJakob Date: Tue, 28 Oct 2025 20:15:31 +0100 Subject: [PATCH 10/22] fix issues with the migrations --- drizzle/0001_nostalgic_master_mold.sql | 37 +++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/drizzle/0001_nostalgic_master_mold.sql b/drizzle/0001_nostalgic_master_mold.sql index 2837b6c..697f748 100644 --- a/drizzle/0001_nostalgic_master_mold.sql +++ b/drizzle/0001_nostalgic_master_mold.sql @@ -1,10 +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 -ALTER TABLE "accounts" ADD COLUMN "typeId" uuid NOT NULL;--> statement-breakpoint -CREATE UNIQUE INDEX "account_types_slug_unique_idx" ON "account_types" USING btree ("slug");--> statement-breakpoint -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 + +-- 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 From 200d8174989f079b129d1febcdbaa908e39c35da Mon Sep 17 00:00:00 2001 From: FleetAdmiralJakob Date: Tue, 28 Oct 2025 20:26:28 +0100 Subject: [PATCH 11/22] fix typos --- .idea/sqldialects.xml | 7 +++++++ drizzle/0001_nostalgic_master_mold.sql | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 .idea/sqldialects.xml diff --git a/.idea/sqldialects.xml b/.idea/sqldialects.xml new file mode 100644 index 0000000..969561e --- /dev/null +++ b/.idea/sqldialects.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/drizzle/0001_nostalgic_master_mold.sql b/drizzle/0001_nostalgic_master_mold.sql index 697f748..d663e7a 100644 --- a/drizzle/0001_nostalgic_master_mold.sql +++ b/drizzle/0001_nostalgic_master_mold.sql @@ -19,7 +19,7 @@ ON CONFLICT ("slug") DO NOTHING;--> statement-breakpoint UPDATE "accounts" a SET "typeId" = at.id FROM "account_types" at -WHERE a."typeID" IS NULL +WHERE a."typeId" IS NULL AND at."slug" = 'private';--> statement-breakpoint -- 4) Enforce NOT NULL From 55c80cb04cc6d6dd631daeadabf2823a4feb680d Mon Sep 17 00:00:00 2001 From: FleetAdmiralJakob Date: Tue, 28 Oct 2025 20:36:56 +0100 Subject: [PATCH 12/22] fix typos --- src/app/index.tsx | 2 +- src/server/routers/accounts.ts | 6 +++--- src/types/index.ts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/app/index.tsx b/src/app/index.tsx index 4cad443..e7b9ceb 100644 --- a/src/app/index.tsx +++ b/src/app/index.tsx @@ -162,7 +162,7 @@ export default function Index() { return ; } - /// TODO: Add loading state + // TODO: Add loading state // if (isLoadingAccounts) { // return // diff --git a/src/server/routers/accounts.ts b/src/server/routers/accounts.ts index f28146f..ebc2da3 100644 --- a/src/server/routers/accounts.ts +++ b/src/server/routers/accounts.ts @@ -1,7 +1,7 @@ import { createTRPCRouter, protectedProcedure } from "../api/trpc"; import { db } from "~/db"; import { accountsTable, accountTypesTable, currenciesTable } from "~/db/schema"; -import { eq, or } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import { AccountWithCurrencyAndType } from "~/types"; import { z } from "zod"; import { errors } from "~/errors/trpc"; @@ -23,8 +23,8 @@ export const accountsRouter = createTRPCRouter({ const currency = await db .select() .from(currenciesTable) - .where(eq(currenciesTable.code, "EUR")) - .limit(1); // Hardcoded to EUR + .where(eq(currenciesTable.code, "EUR")) // Hardcoded to EUR + .limit(1); if (currency.length === 0) { throw errors.notFound(error_messages.currencyNotFound); } diff --git a/src/types/index.ts b/src/types/index.ts index 040da02..3196470 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -2,7 +2,7 @@ import type React from "react"; /** * Our database only saves the Clerk user id, so we need to get the user details from Clerk. - * When we request the user defails from our backend it automatically gets the user details from Clerk. + * When we request the user details from our backend, it automatically gets the user details from Clerk. */ export interface ClerkUser { id: string; From fd9d48eb9231249bef2ff4a38fd1471d9c1db110 Mon Sep 17 00:00:00 2001 From: FleetAdmiralJakob Date: Tue, 28 Oct 2025 20:39:47 +0100 Subject: [PATCH 13/22] remove unnecessary optional chaining --- src/app/(tabs)/banking/index.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/(tabs)/banking/index.tsx b/src/app/(tabs)/banking/index.tsx index 1a25361..41f1cd3 100644 --- a/src/app/(tabs)/banking/index.tsx +++ b/src/app/(tabs)/banking/index.tsx @@ -64,7 +64,7 @@ const Dashboard = () => { name={account.name} amount={account.amount} delay={600 + index * 100} - onPress={() => handleAccountPress(account?.name || "", account.id)} + onPress={() => handleAccountPress(account.name, account.id)} /> ))} @@ -76,7 +76,7 @@ const Dashboard = () => { name={account.name} amount={account.amount} delay={900 + index * 100} - onPress={() => handleAccountPress(account?.name || "", account.id)} + onPress={() => handleAccountPress(account.name, account.id)} /> ))} @@ -89,7 +89,7 @@ const Dashboard = () => { name={account.name} amount={account.amount} delay={1100 + index * 100} - onPress={() => handleAccountPress(account?.name || "", account.id)} + onPress={() => handleAccountPress(account.name, account.id)} /> ))} From 35bbb913b05dbcc422818971f64e9e7a92ff9839 Mon Sep 17 00:00:00 2001 From: FleetAdmiralJakob Date: Tue, 28 Oct 2025 20:50:52 +0100 Subject: [PATCH 14/22] improve commenting --- docs/backend-development.md | 4 ++-- src/app/index.tsx | 32 ++++++++++++++++++++++++++++---- src/scripts/seed.ts | 6 +++--- 3 files changed, 33 insertions(+), 9 deletions(-) diff --git a/docs/backend-development.md b/docs/backend-development.md index 9d097ab..85b2177 100644 --- a/docs/backend-development.md +++ b/docs/backend-development.md @@ -48,7 +48,7 @@ This guide documents how we build backend features in this repository to ensure - 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. +- 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 @@ -69,7 +69,7 @@ This guide documents how we build backend features in this repository to ensure - 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: - - Always scope results by joining the owner table first and applying `where(eq(ownerTable.userId, ctx.userId))`. + - 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 diff --git a/src/app/index.tsx b/src/app/index.tsx index e7b9ceb..aa16213 100644 --- a/src/app/index.tsx +++ b/src/app/index.tsx @@ -143,24 +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 + + // ---------------------------------------------------------------------------- + // 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 ; - } else if (isSignedIn && !isLoadingAccounts && (accounts || [])?.length > 0) { + } + // 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 // TODO: Add loading state // if (isLoadingAccounts) { diff --git a/src/scripts/seed.ts b/src/scripts/seed.ts index f32c7b1..0270ec2 100644 --- a/src/scripts/seed.ts +++ b/src/scripts/seed.ts @@ -1,11 +1,11 @@ import "dotenv/config"; -import { db } from "../db"; +import { db } from "~/db"; import { accountTypesTable, categoriesTable, currenciesTable, -} from "../db/schema"; -import { eq, inArray } from "drizzle-orm"; +} from "~/db/schema"; +import { inArray } from "drizzle-orm"; async function seedCurrencies() { const currencies = [ From 2e2443511fe0f68792f9b021e65dce74822ab0ba Mon Sep 17 00:00:00 2001 From: FleetAdmiralJakob Date: Tue, 28 Oct 2025 21:06:47 +0100 Subject: [PATCH 15/22] fix error handling and unused code --- src/app/start/index.tsx | 2 -- src/server/api/trpc.ts | 10 ++++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/app/start/index.tsx b/src/app/start/index.tsx index 4ea8145..e26a63a 100644 --- a/src/app/start/index.tsx +++ b/src/app/start/index.tsx @@ -44,8 +44,6 @@ const Home = () => { initLanguage(); }, []); - const { data: userData } = trpc.users.getUser.useQuery(); - const createAccount = trpc.accounts.addAccount.useMutation({ onSuccess: () => { Alert.alert(t("success"), t("accountCreated")); diff --git a/src/server/api/trpc.ts b/src/server/api/trpc.ts index 048598b..891c597 100644 --- a/src/server/api/trpc.ts +++ b/src/server/api/trpc.ts @@ -1,6 +1,6 @@ /** * YOU PROBABLY DON'T NEED TO EDIT THIS FILE, UNLESS: - * 1. You want to modify request context (see Part 1). + * 1. You want to modify the request context (see Part 1). * 2. You want to create a new middleware or type of procedure (see Part 3). * * TL;DR - This is where all the tRPC server stuff is created and plugged in. The pieces you will @@ -12,7 +12,6 @@ import { z, ZodError } from "zod"; import { verifyToken, createClerkClient } from "@clerk/backend"; import { errors } from "~/errors/trpc"; -// import { db } from "~/server/db"; import { db } from "~/db"; const CLERK_SECRET_KEY = process.env.CLERK_SECRET_KEY!; @@ -133,8 +132,11 @@ export const protectedProcedure = t.procedure.use(async ({ ctx, next }) => { throw errors.notAuthenticated(); } - const user = await clerkClient.users.getUser(ctx.userId); - if (!user) { + let user; + try { + user = await clerkClient.users.getUser(ctx.userId); + } catch (err) { + console.error("Error fetching user from Clerk:", err); throw errors.notAuthenticated(); } From ae7db7e704711868ed3a05d0c560cd550a2dbfdf Mon Sep 17 00:00:00 2001 From: FleetAdmiralJakob Date: Fri, 31 Oct 2025 16:41:31 +0100 Subject: [PATCH 16/22] fix the sign-up and account creation process - improve types - removed "account types" table --- .idea/sqldialects.xml | 1 + drizzle/0002_careless_gladiator.sql | 7 + drizzle/meta/0002_snapshot.json | 382 ++++++++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + localization/it/common.json | 2 +- src/app/(auth)/sign-up.tsx | 10 +- src/app/start/index.tsx | 61 +++-- src/components/ui/select.tsx | 121 +++++++++ src/db/schema.ts | 24 +- src/errors/messages.ts | 1 - src/schemas/welcomeSchema.ts | 21 +- src/scripts/seed.ts | 34 +-- src/server/routers/accounts.ts | 87 ++++--- src/server/routers/users.ts | 11 +- src/types/index.ts | 35 +-- 15 files changed, 629 insertions(+), 175 deletions(-) create mode 100644 drizzle/0002_careless_gladiator.sql create mode 100644 drizzle/meta/0002_snapshot.json create mode 100644 src/components/ui/select.tsx diff --git a/.idea/sqldialects.xml b/.idea/sqldialects.xml index 969561e..5908863 100644 --- a/.idea/sqldialects.xml +++ b/.idea/sqldialects.xml @@ -3,5 +3,6 @@ + \ 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..e71fb8d --- /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"; \ No newline at end of file diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..a6e8fcb --- /dev/null +++ b/drizzle/meta/0002_snapshot.json @@ -0,0 +1,382 @@ +{ + "id": "91f744b4-b913-457a-abc5-f5a7ac42e454", + "prevId": "ccc0eca7-766c-431b-930d-bdfb833c2101", + "version": "7", + "dialect": "postgresql", + "tables": { + "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", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "currentBalance": { + "name": "currentBalance", + "type": "numeric(18, 2)", + "primaryKey": false, + "notNull": true + }, + "reference": { + "name": "reference", + "type": "references", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "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": {} + } + }, + "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 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.references": { + "name": "references", + "schema": "public", + "values": ["private", "business", "savings", "shared"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 93f4d9d..88876b3 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "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/it/common.json b/localization/it/common.json index 232393a..7c50a91 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", 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/start/index.tsx b/src/app/start/index.tsx index e26a63a..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(); @@ -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, + 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 } }) => ( +