diff --git a/README.md b/README.md index 7000bce..139b77a 100644 --- a/README.md +++ b/README.md @@ -1 +1,15 @@ # FinanceGPT + +Personal Automated Finance Copilot. + +## MAIN FUNCTIONAL REQUIREMENTS + +- Automated SMS capture of expenses + income and automatically catergorize them (Food, Transport, Bills, etc). +- Scan bill/receipt (OCR). +- AI Chatbot (Gemini AI API) to provid financial insights based on user's finances. + +## TECH STACK + +- React Native + Expo Routing for mobile frontend +- Python FASTAPI for backend +- Supabase (AUTH + Database) diff --git a/apps/backend/.env.example b/apps/backend/.env.example new file mode 100644 index 0000000..59a7852 --- /dev/null +++ b/apps/backend/.env.example @@ -0,0 +1,8 @@ +# Copy this file to .env and fill in real values. Never commit .env. + +# Supabase project settings -> Database -> Connection string +DATABASE_URL=postgresql+psycopg://postgres.[YOUR-PROJECT-REF]:[YOUR-PASSWORD]@[YOUR-POOLER-HOST]:6543/postgres + +# Supabase project settings -> API -> Project URL +# Used to derive the JWKS endpoint for verifying auth tokens: /auth/v1/.well-known/jwks.json +SUPABASE_URL=https://[YOUR-PROJECT-REF].supabase.co diff --git a/apps/backend/.gitignore b/apps/backend/.gitignore new file mode 100644 index 0000000..6b82adb --- /dev/null +++ b/apps/backend/.gitignore @@ -0,0 +1,5 @@ +.venv/ +__pycache__/ +*.pyc +.env +BACKEND.md diff --git a/apps/backend/README.md b/apps/backend/README.md new file mode 100644 index 0000000..cf1137c --- /dev/null +++ b/apps/backend/README.md @@ -0,0 +1,55 @@ +# FinanceGPT Backend + +Python FastAPI backend for FinanceGPT, backed by Supabase (Auth + Postgres). + +## Stack + +- **FastAPI** — web framework +- **SQLModel** — ORM (SQLAlchemy + Pydantic combined) for talking to Postgres +- **Alembic** — database schema migrations +- **psycopg** (v3) — Postgres driver +- **Supabase** — hosted Postgres + Auth (JWT-based, asymmetric/JWKS signing keys) + +## Local setup + +1. Create and activate a virtual environment (from this `apps/backend` folder): + + ``` + python -m venv .venv + source .venv/Scripts/activate # Git Bash + .venv\Scripts\Activate.ps1 # PowerShell + ``` + +2. Install dependencies: + + ``` + pip install "fastapi[standard]" sqlmodel "psycopg[binary]" pydantic-settings alembic + ``` + +3. Copy `.env.example` to `.env` and fill in real values from your Supabase project (Project Settings → Database for the connection string, Project Settings → API for the project URL). Use the **pooler** connection string (port `6543`) and the `postgresql+psycopg://` scheme. + +4. Apply database migrations: + + ``` + alembic upgrade head + ``` + +5. Run the dev server: + + ``` + fastapi dev main.py + ``` + + Visit `http://127.0.0.1:8000/health` and `http://127.0.0.1:8000/docs`. + +## Working with migrations + +After changing a model in `app/models/`, remember to add it to `app/models/__init__.py` (this is how Alembic's autogenerate discovers it), then: + +``` +alembic revision --autogenerate -m "describe the change" +``` + +**Always review the generated file before applying it.** Known quirk: autogenerate sometimes references `sqlmodel.sql.sqltypes.AutoString()` for string columns without adding `import sqlmodel` to the file — check for this and add the import manually if needed, or the migration will crash on `alembic upgrade head`. + +Apply with `alembic upgrade head`. Check current state with `alembic current`. diff --git a/apps/backend/alembic.ini b/apps/backend/alembic.ini new file mode 100644 index 0000000..df39977 --- /dev/null +++ b/apps/backend/alembic.ini @@ -0,0 +1,149 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/app/migrations + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s +# Or organize into date-based subdirectories (requires recursive_version_locations = true) +# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the tzdata library which can be installed by adding +# `alembic[tz]` to the pip requirements. +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/apps/backend/.gitkeep b/apps/backend/app/__init__.py similarity index 100% rename from apps/backend/.gitkeep rename to apps/backend/app/__init__.py diff --git a/packages/ai/.gitkeep b/apps/backend/app/core/__init__.py similarity index 100% rename from packages/ai/.gitkeep rename to apps/backend/app/core/__init__.py diff --git a/apps/backend/app/core/config.py b/apps/backend/app/core/config.py new file mode 100644 index 0000000..29cf480 --- /dev/null +++ b/apps/backend/app/core/config.py @@ -0,0 +1,15 @@ +from functools import lru_cache + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env") + + database_url: str + supabase_url: str + + +@lru_cache +def get_settings() -> Settings: + return Settings() diff --git a/apps/backend/app/core/security.py b/apps/backend/app/core/security.py new file mode 100644 index 0000000..a013a2c --- /dev/null +++ b/apps/backend/app/core/security.py @@ -0,0 +1,30 @@ +import jwt +from fastapi import HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials + +from app.core.config import get_settings + +settings = get_settings() + +# PyJWT's PyJWKClient fetches Supabase's public signing keys and caches them, +# so we don't hit the JWKS endpoint on every single request. +_jwks_client = jwt.PyJWKClient(f"{settings.supabase_url}/auth/v1/.well-known/jwks.json") + + +def decode_supabase_jwt(credentials: HTTPAuthorizationCredentials) -> dict: + """Verify a Supabase-issued JWT and return its claims.""" + token = credentials.credentials + try: + signing_key = _jwks_client.get_signing_key_from_jwt(token) + return jwt.decode( + token, + signing_key.key, + algorithms=["ES256"], + audience="authenticated", + ) + except jwt.PyJWTError as exc: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired token", + headers={"WWW-Authenticate": "Bearer"}, + ) from exc diff --git a/packages/api/.gitkeep b/apps/backend/app/db/__init__.py similarity index 100% rename from packages/api/.gitkeep rename to apps/backend/app/db/__init__.py diff --git a/apps/backend/app/db/session.py b/apps/backend/app/db/session.py new file mode 100644 index 0000000..c54f593 --- /dev/null +++ b/apps/backend/app/db/session.py @@ -0,0 +1,13 @@ +from collections.abc import Generator + +from sqlmodel import Session, create_engine + +from app.core.config import get_settings + +settings = get_settings() +engine = create_engine(settings.database_url) + + +def get_session() -> Generator[Session, None, None]: + with Session(engine) as session: + yield session diff --git a/apps/backend/app/deps.py b/apps/backend/app/deps.py new file mode 100644 index 0000000..85aeb30 --- /dev/null +++ b/apps/backend/app/deps.py @@ -0,0 +1,23 @@ +from typing import Annotated +from uuid import UUID + +from fastapi import Depends +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from sqlmodel import Session + +from app.core.security import decode_supabase_jwt +from app.db.session import get_session + +SessionDep = Annotated[Session, Depends(get_session)] + +bearer_scheme = HTTPBearer() + + +def get_current_user_id( + credentials: Annotated[HTTPAuthorizationCredentials, Depends(bearer_scheme)], +) -> UUID: + claims = decode_supabase_jwt(credentials) + return UUID(claims["sub"]) + + +CurrentUserId = Annotated[UUID, Depends(get_current_user_id)] diff --git a/apps/backend/app/migrations/README b/apps/backend/app/migrations/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/apps/backend/app/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/apps/backend/app/migrations/env.py b/apps/backend/app/migrations/env.py new file mode 100644 index 0000000..f492591 --- /dev/null +++ b/apps/backend/app/migrations/env.py @@ -0,0 +1,85 @@ +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool +from sqlmodel import SQLModel + +from alembic import context + +from app.core.config import get_settings +# Importing app.models registers every table on SQLModel.metadata below — +# without this import, autogenerate would see no tables at all. Every new +# model must be added to app/models/__init__.py to be picked up here. +from app import models # noqa: F401 + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# Use our own Settings (reads .env) as the single source of truth for the +# DB URL, instead of duplicating it in alembic.ini. +config.set_main_option("sqlalchemy.url", get_settings().database_url) + +target_metadata = SQLModel.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, target_metadata=target_metadata + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/apps/backend/app/migrations/script.py.mako b/apps/backend/app/migrations/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/apps/backend/app/migrations/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/apps/backend/app/migrations/versions/301e0705bd43_add_user_id_to_categories_for_custom_.py b/apps/backend/app/migrations/versions/301e0705bd43_add_user_id_to_categories_for_custom_.py new file mode 100644 index 0000000..cca8a52 --- /dev/null +++ b/apps/backend/app/migrations/versions/301e0705bd43_add_user_id_to_categories_for_custom_.py @@ -0,0 +1,40 @@ +"""add user_id to categories for custom categories + +Revision ID: 301e0705bd43 +Revises: 5c29549a0a12 +Create Date: 2026-08-07 23:35:27.329365 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '301e0705bd43' +down_revision: Union[str, Sequence[str], None] = '5c29549a0a12' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('categories', sa.Column('user_id', sa.Uuid(), nullable=True)) + op.drop_index(op.f('ix_categories_name'), table_name='categories') + op.create_index(op.f('ix_categories_name'), 'categories', ['name'], unique=False) + op.create_index(op.f('ix_categories_user_id'), 'categories', ['user_id'], unique=False) + op.create_unique_constraint('uq_category_user_name', 'categories', ['user_id', 'name']) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint('uq_category_user_name', 'categories', type_='unique') + op.drop_index(op.f('ix_categories_user_id'), table_name='categories') + op.drop_index(op.f('ix_categories_name'), table_name='categories') + op.create_index(op.f('ix_categories_name'), 'categories', ['name'], unique=True) + op.drop_column('categories', 'user_id') + # ### end Alembic commands ### diff --git a/apps/backend/app/migrations/versions/3a8f9b560d7c_enable_row_level_security_on_categories_.py b/apps/backend/app/migrations/versions/3a8f9b560d7c_enable_row_level_security_on_categories_.py new file mode 100644 index 0000000..a3d17c9 --- /dev/null +++ b/apps/backend/app/migrations/versions/3a8f9b560d7c_enable_row_level_security_on_categories_.py @@ -0,0 +1,70 @@ +"""enable row level security on categories and transactions + +Revision ID: 3a8f9b560d7c +Revises: 301e0705bd43 +Create Date: 2026-08-12 17:18:40.994133 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '3a8f9b560d7c' +down_revision: Union[str, Sequence[str], None] = '301e0705bd43' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + op.execute("ALTER TABLE categories ENABLE ROW LEVEL SECURITY") + op.execute("ALTER TABLE transactions ENABLE ROW LEVEL SECURITY") + + op.execute(""" + CREATE POLICY categories_select ON categories + FOR SELECT + USING (user_id IS NULL OR user_id = auth.uid()) + """) + op.execute(""" + CREATE POLICY categories_insert ON categories + FOR INSERT + WITH CHECK (user_id = auth.uid()) + """) + + op.execute(""" + CREATE POLICY transactions_select ON transactions + FOR SELECT + USING (user_id = auth.uid()) + """) + op.execute(""" + CREATE POLICY transactions_insert ON transactions + FOR INSERT + WITH CHECK (user_id = auth.uid()) + """) + op.execute(""" + CREATE POLICY transactions_update ON transactions + FOR UPDATE + USING (user_id = auth.uid()) + WITH CHECK (user_id = auth.uid()) + """) + op.execute(""" + CREATE POLICY transactions_delete ON transactions + FOR DELETE + USING (user_id = auth.uid()) + """) + + +def downgrade() -> None: + """Downgrade schema.""" + op.execute("DROP POLICY IF EXISTS categories_select ON categories") + op.execute("DROP POLICY IF EXISTS categories_insert ON categories") + op.execute("DROP POLICY IF EXISTS transactions_select ON transactions") + op.execute("DROP POLICY IF EXISTS transactions_insert ON transactions") + op.execute("DROP POLICY IF EXISTS transactions_update ON transactions") + op.execute("DROP POLICY IF EXISTS transactions_delete ON transactions") + + op.execute("ALTER TABLE categories DISABLE ROW LEVEL SECURITY") + op.execute("ALTER TABLE transactions DISABLE ROW LEVEL SECURITY") diff --git a/apps/backend/app/migrations/versions/5c29549a0a12_add_server_side_uuid_default_for_id_.py b/apps/backend/app/migrations/versions/5c29549a0a12_add_server_side_uuid_default_for_id_.py new file mode 100644 index 0000000..36ec4fa --- /dev/null +++ b/apps/backend/app/migrations/versions/5c29549a0a12_add_server_side_uuid_default_for_id_.py @@ -0,0 +1,37 @@ +"""add server-side uuid default for id columns + +Revision ID: 5c29549a0a12 +Revises: d744ccb27d1f +Create Date: 2026-08-07 22:01:36.800395 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '5c29549a0a12' +down_revision: Union[str, Sequence[str], None] = 'd744ccb27d1f' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + op.execute('CREATE EXTENSION IF NOT EXISTS pgcrypto') + op.alter_column( + 'categories', 'id', + server_default=sa.text('gen_random_uuid()'), + ) + op.alter_column( + 'transactions', 'id', + server_default=sa.text('gen_random_uuid()'), + ) + + +def downgrade() -> None: + """Downgrade schema.""" + op.alter_column('categories', 'id', server_default=None) + op.alter_column('transactions', 'id', server_default=None) diff --git a/apps/backend/app/migrations/versions/901f4615b9a9_enable_rls_on_alembic_version_and_.py b/apps/backend/app/migrations/versions/901f4615b9a9_enable_rls_on_alembic_version_and_.py new file mode 100644 index 0000000..f16dbb7 --- /dev/null +++ b/apps/backend/app/migrations/versions/901f4615b9a9_enable_rls_on_alembic_version_and_.py @@ -0,0 +1,112 @@ +"""enable rls on alembic_version and optimize auth.uid() calls in policies + +Revision ID: 901f4615b9a9 +Revises: 3a8f9b560d7c +Create Date: 2026-08-12 17:27:18.673761 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '901f4615b9a9' +down_revision: Union[str, Sequence[str], None] = '3a8f9b560d7c' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # alembic_version has no user data and is never queried via the anon/authenticated + # roles, so RLS with zero policies (default-deny) is correct here. + op.execute("ALTER TABLE alembic_version ENABLE ROW LEVEL SECURITY") + + # Rewrite existing policies to use (SELECT auth.uid()) instead of bare auth.uid(). + # The subquery form lets Postgres evaluate it once per query instead of once per + # row, per Supabase's own performance advisory. Postgres has no "ALTER POLICY + # ... USING" for changing the condition, so drop + recreate. + op.execute("DROP POLICY categories_select ON categories") + op.execute(""" + CREATE POLICY categories_select ON categories + FOR SELECT + USING (user_id IS NULL OR user_id = (SELECT auth.uid())) + """) + op.execute("DROP POLICY categories_insert ON categories") + op.execute(""" + CREATE POLICY categories_insert ON categories + FOR INSERT + WITH CHECK (user_id = (SELECT auth.uid())) + """) + + op.execute("DROP POLICY transactions_select ON transactions") + op.execute(""" + CREATE POLICY transactions_select ON transactions + FOR SELECT + USING (user_id = (SELECT auth.uid())) + """) + op.execute("DROP POLICY transactions_insert ON transactions") + op.execute(""" + CREATE POLICY transactions_insert ON transactions + FOR INSERT + WITH CHECK (user_id = (SELECT auth.uid())) + """) + op.execute("DROP POLICY transactions_update ON transactions") + op.execute(""" + CREATE POLICY transactions_update ON transactions + FOR UPDATE + USING (user_id = (SELECT auth.uid())) + WITH CHECK (user_id = (SELECT auth.uid())) + """) + op.execute("DROP POLICY transactions_delete ON transactions") + op.execute(""" + CREATE POLICY transactions_delete ON transactions + FOR DELETE + USING (user_id = (SELECT auth.uid())) + """) + + +def downgrade() -> None: + """Downgrade schema.""" + op.execute("DROP POLICY categories_select ON categories") + op.execute(""" + CREATE POLICY categories_select ON categories + FOR SELECT + USING (user_id IS NULL OR user_id = auth.uid()) + """) + op.execute("DROP POLICY categories_insert ON categories") + op.execute(""" + CREATE POLICY categories_insert ON categories + FOR INSERT + WITH CHECK (user_id = auth.uid()) + """) + + op.execute("DROP POLICY transactions_select ON transactions") + op.execute(""" + CREATE POLICY transactions_select ON transactions + FOR SELECT + USING (user_id = auth.uid()) + """) + op.execute("DROP POLICY transactions_insert ON transactions") + op.execute(""" + CREATE POLICY transactions_insert ON transactions + FOR INSERT + WITH CHECK (user_id = auth.uid()) + """) + op.execute("DROP POLICY transactions_update ON transactions") + op.execute(""" + CREATE POLICY transactions_update ON transactions + FOR UPDATE + USING (user_id = auth.uid()) + WITH CHECK (user_id = auth.uid()) + """) + op.execute("DROP POLICY transactions_delete ON transactions") + op.execute(""" + CREATE POLICY transactions_delete ON transactions + FOR DELETE + USING (user_id = auth.uid()) + """) + + op.execute("ALTER TABLE alembic_version DISABLE ROW LEVEL SECURITY") diff --git a/apps/backend/app/migrations/versions/9cc188e6e30a_add_categories_table.py b/apps/backend/app/migrations/versions/9cc188e6e30a_add_categories_table.py new file mode 100644 index 0000000..8c785b5 --- /dev/null +++ b/apps/backend/app/migrations/versions/9cc188e6e30a_add_categories_table.py @@ -0,0 +1,39 @@ +"""add categories table + +Revision ID: 9cc188e6e30a +Revises: +Create Date: 2026-08-05 16:36:31.174854 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +# revision identifiers, used by Alembic. +revision: str = '9cc188e6e30a' +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('categories', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_categories_name'), 'categories', ['name'], unique=True) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_categories_name'), table_name='categories') + op.drop_table('categories') + # ### end Alembic commands ### diff --git a/apps/backend/app/migrations/versions/d744ccb27d1f_add_transactions_table.py b/apps/backend/app/migrations/versions/d744ccb27d1f_add_transactions_table.py new file mode 100644 index 0000000..0b29efc --- /dev/null +++ b/apps/backend/app/migrations/versions/d744ccb27d1f_add_transactions_table.py @@ -0,0 +1,46 @@ +"""add transactions table + +Revision ID: d744ccb27d1f +Revises: 9cc188e6e30a +Create Date: 2026-08-05 17:39:59.134059 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +# revision identifiers, used by Alembic. +revision: str = 'd744ccb27d1f' +down_revision: Union[str, Sequence[str], None] = '9cc188e6e30a' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('transactions', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('user_id', sa.Uuid(), nullable=False), + sa.Column('title', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('amount', sa.Numeric(precision=12, scale=2), nullable=False), + sa.Column('category_id', sa.Uuid(), nullable=True), + sa.Column('source', sa.Enum('manual', 'sms', name='transactionsource'), nullable=False), + sa.Column('occurred_at', sa.Date(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['category_id'], ['categories.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_transactions_user_id'), 'transactions', ['user_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_transactions_user_id'), table_name='transactions') + op.drop_table('transactions') + # ### end Alembic commands ### diff --git a/apps/backend/app/models/__init__.py b/apps/backend/app/models/__init__.py new file mode 100644 index 0000000..4d6e6e5 --- /dev/null +++ b/apps/backend/app/models/__init__.py @@ -0,0 +1,4 @@ +from app.models.category import Category +from app.models.transaction import Transaction + +__all__ = ["Category", "Transaction"] diff --git a/apps/backend/app/models/category.py b/apps/backend/app/models/category.py new file mode 100644 index 0000000..77930d1 --- /dev/null +++ b/apps/backend/app/models/category.py @@ -0,0 +1,19 @@ +from uuid import UUID, uuid4 + +from sqlalchemy import text +from sqlmodel import Field, SQLModel, UniqueConstraint + + +class Category(SQLModel, table=True): + __tablename__ = "categories" + __table_args__ = ( + UniqueConstraint("user_id", "name", name="uq_category_user_name"), + ) + + id: UUID = Field( + default_factory=uuid4, + primary_key=True, + sa_column_kwargs={"server_default": text("gen_random_uuid()")}, + ) + user_id: UUID | None = Field(default=None, index=True) + name: str = Field(index=True) diff --git a/apps/backend/app/models/transaction.py b/apps/backend/app/models/transaction.py new file mode 100644 index 0000000..ab96f3a --- /dev/null +++ b/apps/backend/app/models/transaction.py @@ -0,0 +1,31 @@ +from datetime import date, datetime +from decimal import Decimal +from enum import Enum +from uuid import UUID, uuid4 + +from sqlalchemy import text +from sqlmodel import Field, SQLModel + + +class TransactionSource(str, Enum): + manual = "manual" + sms = "sms" + + +class Transaction(SQLModel, table=True): + __tablename__ = "transactions" + + id: UUID = Field( + default_factory=uuid4, + primary_key=True, + sa_column_kwargs={"server_default": text("gen_random_uuid()")}, + ) + user_id: UUID = Field(index=True) + + title: str + amount: Decimal = Field(max_digits=12, decimal_places=2) + category_id: UUID | None = Field(default=None, foreign_key="categories.id") + source: TransactionSource + occurred_at: date + + created_at: datetime = Field(default_factory=datetime.utcnow) diff --git a/packages/config/.gitkeep b/apps/backend/app/routers/__init__.py similarity index 100% rename from packages/config/.gitkeep rename to apps/backend/app/routers/__init__.py diff --git a/apps/backend/app/routers/categories.py b/apps/backend/app/routers/categories.py new file mode 100644 index 0000000..fa844dd --- /dev/null +++ b/apps/backend/app/routers/categories.py @@ -0,0 +1,33 @@ +from fastapi import APIRouter, HTTPException, status +from sqlalchemy.exc import IntegrityError +from sqlmodel import or_, select + +from app.deps import CurrentUserId, SessionDep +from app.models import Category +from app.schemas.category import CategoryCreate, CategoryRead + +router = APIRouter(prefix="/categories", tags=["categories"]) + + +@router.get("", response_model=list[CategoryRead]) +def list_categories(session: SessionDep, user_id: CurrentUserId): + statement = select(Category).where( + or_(Category.user_id.is_(None), Category.user_id == user_id) + ) + return session.exec(statement).all() + + +@router.post("", response_model=CategoryRead) +def create_category(category: CategoryCreate, session: SessionDep, user_id: CurrentUserId): + db_category = Category(name=category.name, user_id=user_id) + session.add(db_category) + try: + session.commit() + except IntegrityError: + session.rollback() + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Category '{category.name}' already exists", + ) + session.refresh(db_category) + return db_category diff --git a/apps/backend/app/routers/health.py b/apps/backend/app/routers/health.py new file mode 100644 index 0000000..b4cb63b --- /dev/null +++ b/apps/backend/app/routers/health.py @@ -0,0 +1,8 @@ +from fastapi import APIRouter + +router = APIRouter() + + +@router.get("/health") +def health(): + return {"status": "ok"} diff --git a/apps/backend/app/routers/transactions.py b/apps/backend/app/routers/transactions.py new file mode 100644 index 0000000..71459f2 --- /dev/null +++ b/apps/backend/app/routers/transactions.py @@ -0,0 +1,179 @@ +from datetime import date +from typing import Literal +from uuid import UUID + +from fastapi import APIRouter, HTTPException, status +from sqlalchemy import func +from sqlalchemy.exc import IntegrityError +from sqlmodel import select + +from app.deps import CurrentUserId, SessionDep +from app.models import Transaction +from app.models.transaction import TransactionSource +from app.schemas.transaction import ( + TransactionCreate, + TransactionRead, + TransactionSummary, + TransactionTrendPoint, + TransactionUpdate, +) + +_TREND_BUCKET_UNIT = { + "daily": "day", + "weekly": "week", + "monthly": "month", + "yearly": "year", +} + +router = APIRouter(prefix="/transactions", tags=["transactions"]) + + +def _get_owned_transaction(session: SessionDep, transaction_id: UUID, user_id: UUID) -> Transaction: + transaction = session.get(Transaction, transaction_id) + if transaction is None or transaction.user_id != user_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Transaction not found") + return transaction + + +def _apply_date_and_category_filters(statement, date_from, date_to, category_id): + if date_from is not None: + statement = statement.where(Transaction.occurred_at >= date_from) + if date_to is not None: + statement = statement.where(Transaction.occurred_at <= date_to) + if category_id is not None: + statement = statement.where(Transaction.category_id == category_id) + return statement + + +@router.post("", response_model=TransactionRead) +def create_transaction( + transaction: TransactionCreate, session: SessionDep, user_id: CurrentUserId +): + db_transaction = Transaction( + **transaction.model_dump(), + user_id=user_id, + source=TransactionSource.manual, + ) + session.add(db_transaction) + try: + session.commit() + except IntegrityError: + session.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid category_id", + ) + session.refresh(db_transaction) + return db_transaction + + +@router.get("", response_model=list[TransactionRead]) +def list_transactions( + session: SessionDep, + user_id: CurrentUserId, + date_from: date | None = None, + date_to: date | None = None, + source: TransactionSource | None = None, + category_id: UUID | None = None, + type: Literal["income", "expense"] | None = None, +): + statement = select(Transaction).where(Transaction.user_id == user_id) + statement = _apply_date_and_category_filters(statement, date_from, date_to, category_id) + if source is not None: + statement = statement.where(Transaction.source == source) + if type == "income": + statement = statement.where(Transaction.amount > 0) + elif type == "expense": + statement = statement.where(Transaction.amount < 0) + return session.exec(statement).all() + + +@router.get("/summary", response_model=TransactionSummary) +def get_transaction_summary( + session: SessionDep, + user_id: CurrentUserId, + date_from: date | None = None, + date_to: date | None = None, + category_id: UUID | None = None, +): + statement = select( + func.coalesce(func.sum(Transaction.amount).filter(Transaction.amount > 0), 0).label( + "income" + ), + func.coalesce(func.sum(Transaction.amount).filter(Transaction.amount < 0), 0).label( + "expenses" + ), + func.coalesce(func.sum(Transaction.amount), 0).label("net"), + ).where(Transaction.user_id == user_id) + statement = _apply_date_and_category_filters(statement, date_from, date_to, category_id) + + income, expenses, net = session.exec(statement).one() + return TransactionSummary(income=income, expenses=abs(expenses), net=net) + + +@router.get("/trend", response_model=list[TransactionTrendPoint]) +def get_transaction_trend( + session: SessionDep, + user_id: CurrentUserId, + range: Literal["daily", "weekly", "monthly", "yearly"], + date_from: date | None = None, + date_to: date | None = None, + category_id: UUID | None = None, +): + bucket = func.date_trunc(_TREND_BUCKET_UNIT[range], Transaction.occurred_at).label("bucket") + statement = ( + select( + bucket, + func.coalesce(func.sum(Transaction.amount).filter(Transaction.amount > 0), 0).label( + "income" + ), + func.coalesce(func.sum(Transaction.amount).filter(Transaction.amount < 0), 0).label( + "expenses" + ), + func.coalesce(func.sum(Transaction.amount), 0).label("net"), + ) + .where(Transaction.user_id == user_id) + .group_by(bucket) + .order_by(bucket) + ) + statement = _apply_date_and_category_filters(statement, date_from, date_to, category_id) + + return [ + TransactionTrendPoint(bucket=row.bucket, income=row.income, expenses=abs(row.expenses), net=row.net) + for row in session.exec(statement).all() + ] + + +@router.get("/{transaction_id}", response_model=TransactionRead) +def get_transaction(transaction_id: UUID, session: SessionDep, user_id: CurrentUserId): + return _get_owned_transaction(session, transaction_id, user_id) + + +@router.patch("/{transaction_id}", response_model=TransactionRead) +def update_transaction( + transaction_id: UUID, + update: TransactionUpdate, + session: SessionDep, + user_id: CurrentUserId, +): + transaction = _get_owned_transaction(session, transaction_id, user_id) + for field, value in update.model_dump(exclude_unset=True).items(): + setattr(transaction, field, value) + session.add(transaction) + try: + session.commit() + except IntegrityError: + session.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid category_id", + ) + session.refresh(transaction) + return transaction + + +@router.delete("/{transaction_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_transaction(transaction_id: UUID, session: SessionDep, user_id: CurrentUserId): + transaction = _get_owned_transaction(session, transaction_id, user_id) + session.delete(transaction) + session.commit() diff --git a/packages/database/.gitkeep b/apps/backend/app/schemas/__init__.py similarity index 100% rename from packages/database/.gitkeep rename to apps/backend/app/schemas/__init__.py diff --git a/apps/backend/app/schemas/category.py b/apps/backend/app/schemas/category.py new file mode 100644 index 0000000..9367081 --- /dev/null +++ b/apps/backend/app/schemas/category.py @@ -0,0 +1,13 @@ +from uuid import UUID + +from sqlmodel import SQLModel + + +class CategoryRead(SQLModel): + id: UUID + user_id: UUID | None + name: str + + +class CategoryCreate(SQLModel): + name: str diff --git a/apps/backend/app/schemas/transaction.py b/apps/backend/app/schemas/transaction.py new file mode 100644 index 0000000..5b8913e --- /dev/null +++ b/apps/backend/app/schemas/transaction.py @@ -0,0 +1,45 @@ +from datetime import date, datetime +from decimal import Decimal +from uuid import UUID + +from sqlmodel import SQLModel + +from app.models.transaction import TransactionSource + + +class TransactionCreate(SQLModel): + title: str + amount: Decimal + category_id: UUID | None = None + occurred_at: date + + +class TransactionUpdate(SQLModel): + title: str | None = None + amount: Decimal | None = None + category_id: UUID | None = None + occurred_at: date | None = None + + +class TransactionRead(SQLModel): + id: UUID + user_id: UUID + title: str + amount: Decimal + category_id: UUID | None + source: TransactionSource + occurred_at: date + created_at: datetime + + +class TransactionSummary(SQLModel): + income: Decimal + expenses: Decimal + net: Decimal + + +class TransactionTrendPoint(SQLModel): + bucket: date + income: Decimal + expenses: Decimal + net: Decimal diff --git a/apps/backend/main.py b/apps/backend/main.py new file mode 100644 index 0000000..3bd02c6 --- /dev/null +++ b/apps/backend/main.py @@ -0,0 +1,18 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.routers import categories, health, transactions + +app = FastAPI() + +# Allow the mobile app (a different origin) to call this API during local dev. +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(health.router) +app.include_router(categories.router) +app.include_router(transactions.router) diff --git a/apps/mobile/.env.example b/apps/mobile/.env.example new file mode 100644 index 0000000..51fab6f --- /dev/null +++ b/apps/mobile/.env.example @@ -0,0 +1,12 @@ +# Copy this file to .env and fill in real values. Never commit .env. +# Must be prefixed EXPO_PUBLIC_ to be readable in client code (Expo convention). + +# Supabase project settings -> API -> Project URL +EXPO_PUBLIC_SUPABASE_URL=https://[YOUR-PROJECT-REF].supabase.co + +# Supabase project settings -> API Keys -> Publishable key +EXPO_PUBLIC_SUPABASE_ANON_KEY=[YOUR-PUBLISHABLE-KEY] + +# Your FastAPI backend's base URL. On a physical device over Wi-Fi, use your +# machine's local network IP (not localhost/127.0.0.1 — the phone can't reach that). +EXPO_PUBLIC_API_URL=http://192.168.1.5:8000 diff --git a/apps/mobile/.gitignore b/apps/mobile/.gitignore index f8c6c2e..0c2c5f2 100644 --- a/apps/mobile/.gitignore +++ b/apps/mobile/.gitignore @@ -31,6 +31,7 @@ yarn-error.* *.pem # local env files +.env .env*.local # typescript diff --git a/apps/mobile/.vscode/settings.json b/apps/mobile/.vscode/settings.json index e2798e4..9363af7 100644 --- a/apps/mobile/.vscode/settings.json +++ b/apps/mobile/.vscode/settings.json @@ -3,5 +3,8 @@ "source.fixAll": "explicit", "source.organizeImports": "explicit", "source.sortMembers": "explicit" - } + }, + "css.lint.unknownAtRules": "ignore", + "scss.lint.unknownAtRules": "ignore", + "less.lint.unknownAtRules": "ignore" } diff --git a/apps/mobile/app/(add)/add-expense.tsx b/apps/mobile/app/(add)/add-expense.tsx index 7b6d14a..9fd1f9c 100644 --- a/apps/mobile/app/(add)/add-expense.tsx +++ b/apps/mobile/app/(add)/add-expense.tsx @@ -1,12 +1,123 @@ -import { View, Text } from 'react-native' -import React from 'react' +import { useAddTransaction } from "@/hooks/useAddTransaction"; +import { Ionicons } from "@expo/vector-icons"; +import { router } from "expo-router"; +import { styled } from "nativewind"; +import React from "react"; +import { Pressable, Text, TextInput, View } from "react-native"; +import { SafeAreaView as RNSafeAreaView } from "react-native-safe-area-context"; +const SafeAreaView = styled(RNSafeAreaView); const AddExpense = () => { + const { title, setTitle, amount, setAmount, categoryId, setCategoryId, categories, saving, error, save } = + useAddTransaction("expense"); + + const handleSubmit = async () => { + const success = await save(); + if (success) router.back(); + }; + return ( - - Add Expense - - ) -} + + + router.back()}> + + + + Add Expense + + + + + + + + + $ + + + + + Amount + + + + + + + + Category (optional) + + + setCategoryId(null)} + className={`px-4 py-2 rounded-full ${ + categoryId === null ? "bg-primary" : "bg-card border border-border" + }`} + > + + None + + + {categories.map((c) => { + const isActive = categoryId === c.id; + return ( + setCategoryId(c.id)} + className={`px-4 py-2 rounded-full ${ + isActive ? "bg-primary" : "bg-card border border-border" + }`} + > + + {c.name} + + + ); + })} + + + + {error && {error}} + + + + + + {saving ? "Saving..." : "Save Expense"} + + + + + ); +}; -export default AddExpense \ No newline at end of file +export default AddExpense; diff --git a/apps/mobile/app/(add)/add-income.tsx b/apps/mobile/app/(add)/add-income.tsx index 0be6b22..c30a6e8 100644 --- a/apps/mobile/app/(add)/add-income.tsx +++ b/apps/mobile/app/(add)/add-income.tsx @@ -1,12 +1,123 @@ -import { View, Text } from 'react-native' -import React from 'react' +import { useAddTransaction } from "@/hooks/useAddTransaction"; +import { Ionicons } from "@expo/vector-icons"; +import { router } from "expo-router"; +import { styled } from "nativewind"; +import React from "react"; +import { Pressable, Text, TextInput, View } from "react-native"; +import { SafeAreaView as RNSafeAreaView } from "react-native-safe-area-context"; +const SafeAreaView = styled(RNSafeAreaView); const AddIncome = () => { + const { title, setTitle, amount, setAmount, categoryId, setCategoryId, categories, saving, error, save } = + useAddTransaction("income"); + + const handleSubmit = async () => { + const success = await save(); + if (success) router.back(); + }; + return ( - - Add Income - - ) -} + + + router.back()}> + + + + Add Income + + + + + + + + + $ + + + + + Amount + + + + + + + + Category (optional) + + + setCategoryId(null)} + className={`px-4 py-2 rounded-full ${ + categoryId === null ? "bg-primary" : "bg-card border border-border" + }`} + > + + None + + + {categories.map((c) => { + const isActive = categoryId === c.id; + return ( + setCategoryId(c.id)} + className={`px-4 py-2 rounded-full ${ + isActive ? "bg-primary" : "bg-card border border-border" + }`} + > + + {c.name} + + + ); + })} + + + + {error && {error}} + + + + + + {saving ? "Saving..." : "Save Income"} + + + + + ); +}; -export default AddIncome \ No newline at end of file +export default AddIncome; diff --git a/apps/mobile/app/(auth)/sign-in.tsx b/apps/mobile/app/(auth)/sign-in.tsx index 69fc750..6f3c9ee 100644 --- a/apps/mobile/app/(auth)/sign-in.tsx +++ b/apps/mobile/app/(auth)/sign-in.tsx @@ -1,12 +1,94 @@ -import { Link } from "expo-router"; -import { Text, View } from "react-native"; +import { useSignIn } from "@/hooks/useSignIn"; +import { Ionicons } from "@expo/vector-icons"; +import { Link, router } from "expo-router"; +import { styled } from "nativewind"; +import { useState } from "react"; +import { KeyboardAvoidingView, Platform, Pressable, Text, TextInput, View } from "react-native"; +import { SafeAreaView as RNSafeAreaView } from "react-native-safe-area-context"; +const SafeAreaView = styled(RNSafeAreaView); const SignIn = () => { + const { email, setEmail, password, setPassword, loading, error, signIn } = useSignIn(); + const [showPassword, setShowPassword] = useState(false); + + const handleSubmit = async () => { + const success = await signIn(); + if (success) { + router.replace("/(tabs)"); + } + }; + return ( - - Sign In - Create Account - + + + + + + FinGPT + + + Sign In + + + + + + + + setShowPassword((prev) => !prev)} + className="absolute right-4" + > + + + + + {error && {error}} + + + + {loading ? "Signing in..." : "Sign In"} + + + + + + + Create Account + + + + + ); }; diff --git a/apps/mobile/app/(auth)/sign-up.tsx b/apps/mobile/app/(auth)/sign-up.tsx index 36a09f3..4c102fb 100644 --- a/apps/mobile/app/(auth)/sign-up.tsx +++ b/apps/mobile/app/(auth)/sign-up.tsx @@ -1,12 +1,99 @@ -import { Link } from "expo-router"; -import { Text, View } from "react-native"; +import { useSignUp } from "@/hooks/useSignUp"; +import { Ionicons } from "@expo/vector-icons"; +import { Link, router } from "expo-router"; +import { styled } from "nativewind"; +import { useState } from "react"; +import { KeyboardAvoidingView, Platform, Pressable, Text, TextInput, View } from "react-native"; +import { SafeAreaView as RNSafeAreaView } from "react-native-safe-area-context"; +const SafeAreaView = styled(RNSafeAreaView); const SignUp = () => { + const { email, setEmail, password, setPassword, loading, error, signUp } = useSignUp(); + const [message, setMessage] = useState(null); + const [showPassword, setShowPassword] = useState(false); + + const handleSubmit = async () => { + const { success, needsEmailConfirmation } = await signUp(); + if (!success) return; + if (needsEmailConfirmation) { + setMessage("Check your email to confirm your account before signing in."); + } else { + router.replace("/(tabs)"); + } + }; + return ( - - Sign Up - Already have an account? Sign In - + + + + + + FinGPT + + + Create Account + + + + + + + + setShowPassword((prev) => !prev)} + className="absolute right-4" + > + + + + + {error && {error}} + {message && {message}} + + + + {loading ? "Creating account..." : "Sign Up"} + + + + + + + Already have an account? Sign In + + + + + ); }; diff --git a/apps/mobile/app/(tabs)/_layout.tsx b/apps/mobile/app/(tabs)/_layout.tsx new file mode 100644 index 0000000..7457af8 --- /dev/null +++ b/apps/mobile/app/(tabs)/_layout.tsx @@ -0,0 +1,190 @@ +import { Ionicons } from "@expo/vector-icons"; +import { router, Tabs } from "expo-router"; +import { useState } from "react"; +import { Image, Modal, Pressable, Text, View } from "react-native"; + +const TAB_COLORS = { + background: "#0B0E11", + surface: "#1B2025", + primary: "#2E6FF2", + primaryForeground: "#FFFFFF", + mutedForeground: "#8B939B", + border: "#22272C", +}; + +const TabLayout = () => { + const [sheetVisible, setSheetVisible] = useState(false); + + const openOption = (path: "/(add)/add-expense" | "/(add)/add-income") => { + setSheetVisible(false); + router.push(path); + }; + + return ( + <> + + ( + + ), + }} + /> + ( + + ), + }} + /> + ( + setSheetVisible(true)} + style={{ + flex: 1, + alignItems: "center", + justifyContent: "center", + }} + > + + + + + ), + }} + listeners={{ + tabPress: (e) => { + e.preventDefault(); + setSheetVisible(true); + }, + }} + /> + ( + + ), + }} + /> + ( + + ), + }} + /> + + + + setSheetVisible(false)} + > + setSheetVisible(false)} + style={{ flex: 1, backgroundColor: "rgba(0,0,0,0.5)", justifyContent: "flex-end" }} + > + e.stopPropagation()} + > + + + openOption("/(add)/add-expense")} + className="bg-card rounded-2xl p-4 flex-row items-center gap-3" + > + + + Add Expense + + + + openOption("/(add)/add-income")} + className="bg-card rounded-2xl p-4 flex-row items-center gap-3" + > + + + Add Income + + + + + + + ); +}; + +export default TabLayout; diff --git a/apps/mobile/app/(tabs)/add.tsx b/apps/mobile/app/(tabs)/add.tsx new file mode 100644 index 0000000..6dd1588 --- /dev/null +++ b/apps/mobile/app/(tabs)/add.tsx @@ -0,0 +1,5 @@ +// Placeholder route required by Expo Router's file-based tabs. +// The tab press is intercepted in _layout.tsx to open the bottom sheet instead of navigating here. +export default function Add() { + return null; +} diff --git a/apps/mobile/app/(tabs)/finances.tsx b/apps/mobile/app/(tabs)/finances.tsx index 7b526fc..06b8df9 100644 --- a/apps/mobile/app/(tabs)/finances.tsx +++ b/apps/mobile/app/(tabs)/finances.tsx @@ -1,11 +1,201 @@ +import { SOURCES, TYPES, useFinances } from "@/hooks/useFinances"; +import { styled } from "nativewind"; import React from "react"; -import { Text, View } from "react-native"; +import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native"; +import { SafeAreaView as RNSafeAreaView } from "react-native-safe-area-context"; +const SafeAreaView = styled(RNSafeAreaView); + +const formatAmount = (amount: number) => + `${amount >= 0 ? "+" : "−"}$${Math.abs(amount).toFixed(2)}`; const Finances = () => { + const { + source, + setSource, + type, + setType, + monthLabel, + goToPreviousMonth, + goToNextMonth, + groups, + summary, + loading, + error, + } = useFinances(); + return ( - - Finances - + + + + Finances + + + + + + + + {monthLabel} + + + + + + + + + Source + + + {SOURCES.map((s) => { + const isActive = s === source; + return ( + setSource(s)} + className={`flex-row items-center gap-2 px-4 py-2 rounded-full ${ + isActive ? "bg-primary" : "bg-card border border-border" + }`} + > + {s !== "All" && ( + + )} + + {s} + + + ); + })} + + + + + + Type + + + {TYPES.map((t) => { + const isActive = t === type; + return ( + setType(t)} + className={`flex-1 items-center py-2 rounded-full ${ + isActive ? "bg-primary" : "" + }`} + > + + {t} + + + ); + })} + + + + {loading && } + {error && {error}} + + {summary && ( + + + + Income + + + {formatAmount(summary.income)} + + + + + Expenses + + + {formatAmount(-summary.expenses)} + + + + + Net + + + {formatAmount(summary.net)} + + + + )} + + {groups.map(({ label, items }) => { + return ( + + + {label} + + + {items.map((tx) => ( + + + + + + {tx.title} + + + {tx.category} · {tx.source} + + + + = 0 ? "text-success" : "text-destructive" + }`} + > + {formatAmount(tx.amount)} + + + ))} + + + ); + })} + + ); }; diff --git a/apps/mobile/app/(tabs)/index.tsx b/apps/mobile/app/(tabs)/index.tsx index df4dd59..39b8ffb 100644 --- a/apps/mobile/app/(tabs)/index.tsx +++ b/apps/mobile/app/(tabs)/index.tsx @@ -1,12 +1,28 @@ import "@/global.css"; +import { styled } from "nativewind"; import { Text, View } from "react-native"; +import { SafeAreaView as RNSafeAreaView } from "react-native-safe-area-context"; +const SafeAreaView = styled(RNSafeAreaView); export default function App() { return ( - - - Welcome to Nativewind! - - + + + + Total Spent + + + + − $3,240 + + + + + + 12% vs last period + + + + ); } diff --git a/apps/mobile/app/(tabs)/insights.tsx b/apps/mobile/app/(tabs)/insights.tsx index c003366..fc5aadc 100644 --- a/apps/mobile/app/(tabs)/insights.tsx +++ b/apps/mobile/app/(tabs)/insights.tsx @@ -1,11 +1,141 @@ +import { TIME_RANGES, useInsights } from "@/hooks/useInsights"; +import { styled } from "nativewind"; import React from "react"; -import { Text, View } from "react-native"; +import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native"; +import { SafeAreaView as RNSafeAreaView } from "react-native-safe-area-context"; +const SafeAreaView = styled(RNSafeAreaView); + +const formatAmount = (amount: number) => + `${amount >= 0 ? "" : "− "}$${Math.abs(amount).toFixed(0)}`; const Insights = () => { + const { + timeRange, + setTimeRange, + monthLabel, + goToPreviousMonth, + goToNextMonth, + categoryOptions, + selectedCategoryIds, + toggleCategory, + total, + trendBars, + loading, + error, + } = useInsights(); + return ( - - Insights - + + + + Insights + + + + + Time Range + + + {TIME_RANGES.map((range) => { + const isActive = range === timeRange; + return ( + setTimeRange(range)} + className={`flex-1 items-center py-2 rounded-full ${ + isActive ? "bg-primary" : "" + }`} + > + + {range} + + + ); + })} + + + + + + + + + {monthLabel} + + + + + + + + + Category Filter (multi-select) + + + {categoryOptions.map((category) => { + const isActive = + category.id === null + ? selectedCategoryIds.length === 0 + : selectedCategoryIds.includes(category.id); + return ( + toggleCategory(category.id)} + className={`px-4 py-2 rounded-full ${ + isActive + ? "bg-primary" + : "bg-card border border-border" + }`} + > + + {category.name} + + + ); + })} + + + + {loading && } + {error && {error}} + + + + Total (filtered) + + + {formatAmount(-total)} + + + + + + Trend + + + {trendBars.map((bar) => ( + + ))} + + + + ); }; diff --git a/apps/mobile/app/(tabs)/profile.tsx b/apps/mobile/app/(tabs)/profile.tsx index e85d353..0178ce2 100644 --- a/apps/mobile/app/(tabs)/profile.tsx +++ b/apps/mobile/app/(tabs)/profile.tsx @@ -1,11 +1,44 @@ +import { useProfile } from "@/hooks/useProfile"; +import { styled } from "nativewind"; import React from "react"; -import { Text, View } from "react-native"; +import { Pressable, Text, View } from "react-native"; +import { SafeAreaView as RNSafeAreaView } from "react-native-safe-area-context"; +const SafeAreaView = styled(RNSafeAreaView); const Profile = () => { + const { email, initial, signOut } = useProfile(); + return ( - - Profile - + + + Profile + + + + + + {initial} + + + + + Signed in + + + {email} + + + + + + + Sign Out + + + ); }; diff --git a/apps/mobile/app/(tabs)/finances/[id].tsx b/apps/mobile/app/finances/[id].tsx similarity index 100% rename from apps/mobile/app/(tabs)/finances/[id].tsx rename to apps/mobile/app/finances/[id].tsx diff --git a/apps/mobile/app/index.tsx b/apps/mobile/app/index.tsx new file mode 100644 index 0000000..80aff11 --- /dev/null +++ b/apps/mobile/app/index.tsx @@ -0,0 +1,23 @@ +import { supabase } from "@/lib/supabase"; +import { Redirect } from "expo-router"; +import { useEffect, useState } from "react"; +import { Session } from "@supabase/supabase-js"; +import { ActivityIndicator, View } from "react-native"; + +export default function Index() { + const [session, setSession] = useState(undefined); + + useEffect(() => { + supabase.auth.getSession().then(({ data }) => setSession(data.session)); + }, []); + + if (session === undefined) { + return ( + + + + ); + } + + return ; +} diff --git a/apps/mobile/assets/icons/home.png b/apps/mobile/assets/icons/home.png new file mode 100644 index 0000000..e565dac Binary files /dev/null and b/apps/mobile/assets/icons/home.png differ diff --git a/apps/mobile/assets/icons/insights.png b/apps/mobile/assets/icons/insights.png new file mode 100644 index 0000000..7d5ac44 Binary files /dev/null and b/apps/mobile/assets/icons/insights.png differ diff --git a/apps/mobile/assets/icons/wallet.png b/apps/mobile/assets/icons/wallet.png new file mode 100644 index 0000000..8e36172 Binary files /dev/null and b/apps/mobile/assets/icons/wallet.png differ diff --git a/apps/mobile/global.css b/apps/mobile/global.css index 9431a57..4252676 100644 --- a/apps/mobile/global.css +++ b/apps/mobile/global.css @@ -3,3 +3,46 @@ @import "tailwindcss/utilities.css"; @import "nativewind/theme"; + +@theme { + --color-background: #0b0e11; + --color-foreground: #e6e8eb; + --color-card: #14181c; + --color-surface: #1b2025; + --color-muted: #14181c; + --color-muted-foreground: #8b939b; + --color-placeholder: #5a6068; + --color-primary: #2e6ff2; + --color-primary-foreground: #ffffff; + --color-border: #22272c; + --color-success: #00d26a; + --color-destructive: #ff5c5c; + --color-warning: #ffb84d; + + --spacing-0: 0px; + --spacing-1: 4px; + --spacing-2: 8px; + --spacing-3: 12px; + --spacing-4: 16px; + --spacing-5: 20px; + --spacing-6: 24px; + --spacing-7: 28px; + --spacing-8: 32px; + --spacing-9: 36px; + --spacing-10: 40px; + --spacing-11: 44px; + --spacing-12: 48px; + --spacing-14: 56px; + --spacing-16: 64px; + --spacing-18: 72px; + --spacing-20: 80px; + --spacing-24: 96px; + --spacing-30: 120px; + + --font-sans: sans-regular; + --font-sans-light: sans-light; + --font-sans-medium: sans-medium; + --font-sans-semibold: sans-semibold; + --font-sans-bold: sans-bold; + --font-sans-extrabold: sans-extrabold; +} diff --git a/apps/mobile/hooks/useAddTransaction.ts b/apps/mobile/hooks/useAddTransaction.ts new file mode 100644 index 0000000..ea9d2ab --- /dev/null +++ b/apps/mobile/hooks/useAddTransaction.ts @@ -0,0 +1,59 @@ +import { api } from "@/lib/api"; +import { useEffect, useState } from "react"; + +type Category = { id: string; name: string }; + +export function useAddTransaction(kind: "expense" | "income") { + const [title, setTitle] = useState(""); + const [amount, setAmount] = useState(""); + const [categoryId, setCategoryId] = useState(null); + const [categories, setCategories] = useState([]); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + api.get("/categories").then(setCategories).catch((err) => setError(err.message)); + }, []); + + const save = async () => { + const parsed = Number(amount); + if (!title.trim()) { + setError("Title is required"); + return false; + } + if (!Number.isFinite(parsed) || parsed <= 0) { + setError("Enter a valid amount greater than 0"); + return false; + } + + setSaving(true); + setError(null); + try { + await api.post("/transactions", { + title: title.trim(), + amount: kind === "expense" ? -parsed : parsed, + category_id: categoryId, + occurred_at: new Date().toISOString().slice(0, 10), + }); + return true; + } catch (err) { + setError((err as Error).message); + return false; + } finally { + setSaving(false); + } + }; + + return { + title, + setTitle, + amount, + setAmount, + categoryId, + setCategoryId, + categories, + saving, + error, + save, + }; +} diff --git a/apps/mobile/hooks/useFinances.ts b/apps/mobile/hooks/useFinances.ts new file mode 100644 index 0000000..ca1b7e6 --- /dev/null +++ b/apps/mobile/hooks/useFinances.ts @@ -0,0 +1,134 @@ +import { api } from "@/lib/api"; +import { useEffect, useMemo, useState } from "react"; + +export const SOURCES = ["All", "Auto (SMS)", "Manual"] as const; +export type Source = (typeof SOURCES)[number]; + +export const TYPES = ["All", "Expenses", "Income"] as const; +export type TxType = (typeof TYPES)[number]; + +export type Transaction = { + id: string; + title: string; + category: string; + source: Exclude; + amount: number; + occurredAt: string; +}; + +type Category = { id: string; name: string }; + +type ApiTransaction = { + id: string; + title: string; + amount: string; + category_id: string | null; + source: "manual" | "sms"; + occurred_at: string; +}; + +type Summary = { income: string; expenses: string; net: string }; + +const isSameDay = (a: Date, b: Date) => + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate(); + +const monthLabel = (date: Date) => + date.toLocaleDateString("en-US", { month: "short", year: "numeric" }); + +const monthRange = (date: Date) => { + const from = new Date(date.getFullYear(), date.getMonth(), 1); + const to = new Date(date.getFullYear(), date.getMonth() + 1, 0); + const toISO = (d: Date) => d.toISOString().slice(0, 10); + return { date_from: toISO(from), date_to: toISO(to) }; +}; + +export function useFinances() { + const [month, setMonth] = useState(() => new Date()); + const [source, setSource] = useState("All"); + const [type, setType] = useState("All"); + + const [categories, setCategories] = useState([]); + const [transactions, setTransactions] = useState([]); + const [summary, setSummary] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const { date_from, date_to } = monthRange(month); + const params = new URLSearchParams({ date_from, date_to }); + if (source === "Auto (SMS)") params.set("source", "sms"); + if (source === "Manual") params.set("source", "manual"); + if (type === "Income") params.set("type", "income"); + if (type === "Expenses") params.set("type", "expense"); + + setLoading(true); + setError(null); + Promise.all([ + categories.length ? Promise.resolve(categories) : api.get("/categories"), + api.get(`/transactions?${params.toString()}`), + api.get(`/transactions/summary?date_from=${date_from}&date_to=${date_to}`), + ]) + .then(([cats, txs, summ]) => { + setCategories(cats); + setTransactions(txs); + setSummary(summ); + }) + .catch((err) => setError(err.message)) + .finally(() => setLoading(false)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [month, source, type]); + + const categoryName = useMemo(() => { + const map = new Map(categories.map((c) => [c.id, c.name])); + return (id: string | null) => (id ? (map.get(id) ?? "Uncategorized") : "Uncategorized"); + }, [categories]); + + const items: Transaction[] = useMemo( + () => + transactions.map((tx) => ({ + id: tx.id, + title: tx.title, + category: categoryName(tx.category_id), + source: tx.source === "sms" ? "Auto (SMS)" : "Manual", + amount: Number(tx.amount), + occurredAt: tx.occurred_at, + })), + [transactions, categoryName], + ); + + const groups = useMemo(() => { + const today = new Date(); + const yesterday = new Date(); + yesterday.setDate(today.getDate() - 1); + + const buckets = new Map(); + for (const item of items) { + const occurred = new Date(item.occurredAt); + const label = isSameDay(occurred, today) + ? "Today" + : isSameDay(occurred, yesterday) + ? "Yesterday" + : occurred.toLocaleDateString("en-US", { month: "short", day: "numeric" }); + buckets.set(label, [...(buckets.get(label) ?? []), item]); + } + return Array.from(buckets.entries()).map(([label, txs]) => ({ label, items: txs })); + }, [items]); + + return { + source, + setSource, + type, + setType, + monthLabel: monthLabel(month), + goToPreviousMonth: () => setMonth((m) => new Date(m.getFullYear(), m.getMonth() - 1, 1)), + goToNextMonth: () => setMonth((m) => new Date(m.getFullYear(), m.getMonth() + 1, 1)), + groups, + summary: summary + ? { income: Number(summary.income), expenses: Number(summary.expenses), net: Number(summary.net) } + : null, + loading, + error, + }; +} diff --git a/apps/mobile/hooks/useInsights.ts b/apps/mobile/hooks/useInsights.ts new file mode 100644 index 0000000..ce5eafa --- /dev/null +++ b/apps/mobile/hooks/useInsights.ts @@ -0,0 +1,136 @@ +import { api } from "@/lib/api"; +import { useEffect, useMemo, useState } from "react"; + +export const TIME_RANGES = ["Daily", "Weekly", "Monthly", "Yearly"] as const; +export type TimeRange = (typeof TIME_RANGES)[number]; + +const RANGE_PARAM: Record = { + Daily: "daily", + Weekly: "weekly", + Monthly: "monthly", + Yearly: "yearly", +}; + +type Category = { id: string; name: string }; +type TrendPoint = { bucket: string; income: string; expenses: string; net: string }; +type Summary = { income: string; expenses: string; net: string }; + +const monthLabel = (date: Date) => + date.toLocaleDateString("en-US", { month: "short", year: "numeric" }); + +const monthRange = (date: Date) => { + const from = new Date(date.getFullYear(), date.getMonth(), 1); + const to = new Date(date.getFullYear(), date.getMonth() + 1, 0); + const toISO = (d: Date) => d.toISOString().slice(0, 10); + return { date_from: toISO(from), date_to: toISO(to) }; +}; + +const sumSummaries = (summaries: Summary[]): Summary => + summaries.reduce( + (acc, s) => ({ + income: String(Number(acc.income) + Number(s.income)), + expenses: String(Number(acc.expenses) + Number(s.expenses)), + net: String(Number(acc.net) + Number(s.net)), + }), + { income: "0", expenses: "0", net: "0" }, + ); + +export function useInsights() { + const [timeRange, setTimeRange] = useState("Monthly"); + const [month, setMonth] = useState(() => new Date()); + const [categories, setCategories] = useState([]); + const [selectedCategoryIds, setSelectedCategoryIds] = useState([]); + + const [total, setTotal] = useState(null); + const [trend, setTrend] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + api.get("/categories").then(setCategories).catch((err) => setError(err.message)); + }, []); + + useEffect(() => { + const { date_from, date_to } = monthRange(month); + const categoryIds = selectedCategoryIds.length ? selectedCategoryIds : [null]; + + setLoading(true); + setError(null); + Promise.all([ + Promise.all( + categoryIds.map((categoryId) => { + const params = new URLSearchParams({ date_from, date_to }); + if (categoryId) params.set("category_id", categoryId); + return api.get(`/transactions/summary?${params.toString()}`); + }), + ), + Promise.all( + categoryIds.map((categoryId) => { + const params = new URLSearchParams({ date_from, date_to, range: RANGE_PARAM[timeRange] }); + if (categoryId) params.set("category_id", categoryId); + return api.get(`/transactions/trend?${params.toString()}`); + }), + ), + ]) + .then(([summaries, trends]) => { + setTotal(sumSummaries(summaries)); + + const merged = new Map(); + for (const points of trends) { + for (const point of points as TrendPoint[]) { + const existing = merged.get(point.bucket); + merged.set( + point.bucket, + existing ? sumSummaries([existing, point]) as TrendPoint & { bucket: string } : point, + ); + merged.get(point.bucket)!.bucket = point.bucket; + } + } + setTrend(Array.from(merged.values()).sort((a, b) => a.bucket.localeCompare(b.bucket))); + }) + .catch((err) => setError(err.message)) + .finally(() => setLoading(false)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [month, timeRange, selectedCategoryIds]); + + const categoryOptions = useMemo( + () => [{ id: null as string | null, name: "All" }, ...categories], + [categories], + ); + + const toggleCategory = (id: string | null) => { + if (id === null) { + setSelectedCategoryIds([]); + return; + } + setSelectedCategoryIds((prev) => { + const isSelected = prev.includes(id); + const next = isSelected ? prev.filter((c) => c !== id) : [...prev, id]; + return next; + }); + }; + + const trendBars = useMemo(() => { + const expenses = trend.map((t) => Math.abs(Number(t.expenses))); + const max = Math.max(1, ...expenses); + return trend.map((t, i) => ({ + bucket: t.bucket, + heightPercent: Math.round((expenses[i] / max) * 100), + })); + }, [trend]); + + return { + timeRange, + setTimeRange, + monthLabel: monthLabel(month), + goToPreviousMonth: () => setMonth((m) => new Date(m.getFullYear(), m.getMonth() - 1, 1)), + goToNextMonth: () => setMonth((m) => new Date(m.getFullYear(), m.getMonth() + 1, 1)), + categoryOptions, + selectedCategoryIds, + toggleCategory, + total: total ? Number(total.expenses) : 0, + trendBars, + loading, + error, + }; +} diff --git a/apps/mobile/hooks/useProfile.ts b/apps/mobile/hooks/useProfile.ts new file mode 100644 index 0000000..b2b765d --- /dev/null +++ b/apps/mobile/hooks/useProfile.ts @@ -0,0 +1,30 @@ +import { supabase } from "@/lib/supabase"; +import { router } from "expo-router"; +import { useEffect, useState } from "react"; + +export function useProfile() { + const [email, setEmail] = useState(null); + + useEffect(() => { + supabase.auth.getSession().then(({ data }) => { + setEmail(data.session?.user.email ?? null); + }); + + const { data: subscription } = supabase.auth.onAuthStateChange( + (_event, session) => { + setEmail(session?.user.email ?? null); + } + ); + + return () => subscription.subscription.unsubscribe(); + }, []); + + const initial = email ? email.charAt(0).toUpperCase() : "?"; + + const signOut = async () => { + await supabase.auth.signOut(); + router.replace("/(auth)/sign-in"); + }; + + return { email, initial, signOut }; +} diff --git a/apps/mobile/hooks/useSignIn.ts b/apps/mobile/hooks/useSignIn.ts new file mode 100644 index 0000000..ba7979d --- /dev/null +++ b/apps/mobile/hooks/useSignIn.ts @@ -0,0 +1,23 @@ +import { supabase } from "@/lib/supabase"; +import { useState } from "react"; + +export function useSignIn() { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const signIn = async () => { + setLoading(true); + setError(null); + const { error } = await supabase.auth.signInWithPassword({ email, password }); + setLoading(false); + if (error) { + setError(error.message); + return false; + } + return true; + }; + + return { email, setEmail, password, setPassword, loading, error, signIn }; +} diff --git a/apps/mobile/hooks/useSignUp.ts b/apps/mobile/hooks/useSignUp.ts new file mode 100644 index 0000000..33bc6af --- /dev/null +++ b/apps/mobile/hooks/useSignUp.ts @@ -0,0 +1,24 @@ +import { supabase } from "@/lib/supabase"; +import { useState } from "react"; + +export function useSignUp() { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const signUp = async () => { + setLoading(true); + setError(null); + const { data, error } = await supabase.auth.signUp({ email, password }); + setLoading(false); + if (error) { + setError(error.message); + return { success: false, needsEmailConfirmation: false }; + } + const needsEmailConfirmation = data.session === null; + return { success: true, needsEmailConfirmation }; + }; + + return { email, setEmail, password, setPassword, loading, error, signUp }; +} diff --git a/apps/mobile/lib/api.ts b/apps/mobile/lib/api.ts new file mode 100644 index 0000000..9f94aee --- /dev/null +++ b/apps/mobile/lib/api.ts @@ -0,0 +1,38 @@ +import { supabase } from "@/lib/supabase"; + +const API_URL = process.env.EXPO_PUBLIC_API_URL!; + +async function request(path: string, options: RequestInit = {}) { + const { + data: { session }, + } = await supabase.auth.getSession(); + + const response = await fetch(`${API_URL}${path}`, { + ...options, + headers: { + "Content-Type": "application/json", + ...(session ? { Authorization: `Bearer ${session.access_token}` } : {}), + ...options.headers, + }, + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`${response.status} ${response.statusText}: ${body}`); + } + + if (response.status === 204) { + return null; + } + + return response.json(); +} + +export const api = { + get: (path: string) => request(path), + post: (path: string, body: unknown) => + request(path, { method: "POST", body: JSON.stringify(body) }), + patch: (path: string, body: unknown) => + request(path, { method: "PATCH", body: JSON.stringify(body) }), + delete: (path: string) => request(path, { method: "DELETE" }), +}; diff --git a/apps/mobile/lib/supabase.ts b/apps/mobile/lib/supabase.ts new file mode 100644 index 0000000..8315345 --- /dev/null +++ b/apps/mobile/lib/supabase.ts @@ -0,0 +1,14 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { createClient } from "@supabase/supabase-js"; + +const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL!; +const supabaseAnonKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY!; + +export const supabase = createClient(supabaseUrl, supabaseAnonKey, { + auth: { + storage: AsyncStorage, + autoRefreshToken: true, + persistSession: true, + detectSessionInUrl: false, + }, +}); diff --git a/apps/mobile/package-lock.json b/apps/mobile/package-lock.json index 1bb0eec..0b822c1 100644 --- a/apps/mobile/package-lock.json +++ b/apps/mobile/package-lock.json @@ -9,9 +9,11 @@ "version": "1.0.0", "dependencies": { "@expo/vector-icons": "^15.0.3", + "@react-native-async-storage/async-storage": "2.2.0", "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/elements": "^2.6.3", "@react-navigation/native": "^7.1.8", + "@supabase/supabase-js": "^2.112.3", "expo": "~54.0.35", "expo-constants": "~18.0.13", "expo-font": "~14.0.12", @@ -2927,6 +2929,18 @@ } } }, + "node_modules/@react-native-async-storage/async-storage": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz", + "integrity": "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==", + "license": "MIT", + "dependencies": { + "merge-options": "^3.0.4" + }, + "peerDependencies": { + "react-native": "^0.0.0-0 || >=0.65 <1.0" + } + }, "node_modules/@react-native/assets-registry": { "version": "0.81.5", "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz", @@ -3603,6 +3617,98 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@supabase/auth-js": { + "version": "2.112.3", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.112.3.tgz", + "integrity": "sha512-NA0rsgAlWZPvbhw8aUdmgfpHVgUAcd8zK5ov43l++o1bLIPXZhRiAlRobhwF5AatQuovpqxsMH50F4oyyV4XZw==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.112.3", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.112.3.tgz", + "integrity": "sha512-gfv481mTOVWtZIJgXupxZpni2V2UWPf6jeF/jOK7HdMHdH+mt6sU0sHHwf0POsPip8ltlulu9OUHgwVzl5ddRw==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/phoenix": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.5.tgz", + "integrity": "sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==", + "license": "MIT" + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.112.3", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.112.3.tgz", + "integrity": "sha512-+Mf6uCpzr00bqxwX8hTK2X2L9eAL/1vuOjdEjx6upz9ulb0RmQT16XeU/JkMUlVHw/B46ZnPa2busY4Kd9YCzw==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.112.3", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.112.3.tgz", + "integrity": "sha512-E6wljXWs7DUOloyIB69i3YFInWE6IyCvgTAbQ0KYxOHv26FdA1KzEXTuzxrYEdf70t406Z9BRwUlGyclGF2FXA==", + "license": "MIT", + "dependencies": { + "@supabase/phoenix": "0.4.5", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.112.3", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.112.3.tgz", + "integrity": "sha512-oSK61tzlUvg+BWPqpKQCu9qqonsO26btaoAR9D6Gest2aj7xUqToj9rKyaoYOJczkhg9BjqA1REbYy9tPI4bDA==", + "license": "MIT", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.112.3", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.112.3.tgz", + "integrity": "sha512-Jv1bxVQmEJNkjvPEhFaKjPzsh+Ozyew6lWGD+SoYcsclDEP1z7yEvKvfUQfzy0DkxRIQnZNxmmWtAzw5XLTQoA==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.112.3", + "@supabase/functions-js": "2.112.3", + "@supabase/postgrest-js": "2.112.3", + "@supabase/realtime-js": "2.112.3", + "@supabase/storage-js": "2.112.3" + }, + "engines": { + "node": ">=22.0.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } + } + }, "node_modules/@tailwindcss/node": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", @@ -9102,6 +9208,15 @@ "integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==", "license": "BSD-3-Clause" }, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -9552,6 +9667,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -10563,6 +10687,18 @@ "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", "license": "MIT" }, + "node_modules/merge-options": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/merge-options/-/merge-options-3.0.4.tgz", + "integrity": "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==", + "license": "MIT", + "dependencies": { + "is-plain-obj": "^2.1.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 6fb19af..3cc81e5 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -12,9 +12,11 @@ }, "dependencies": { "@expo/vector-icons": "^15.0.3", + "@react-native-async-storage/async-storage": "2.2.0", "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/elements": "^2.6.3", "@react-navigation/native": "^7.1.8", + "@supabase/supabase-js": "^2.112.3", "expo": "~54.0.35", "expo-constants": "~18.0.13", "expo-font": "~14.0.12", diff --git a/packages/domain/.gitkeep b/packages/domain/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/packages/validation/.gitkeep b/packages/validation/.gitkeep deleted file mode 100644 index e69de29..0000000