diff --git a/be/alembic/versions/7c26d62e8005_add_missing_indexes_and_constraints.py b/be/alembic/versions/7c26d62e8005_add_missing_indexes_and_constraints.py new file mode 100644 index 0000000..cee370d --- /dev/null +++ b/be/alembic/versions/7c26d62e8005_add_missing_indexes_and_constraints.py @@ -0,0 +1,60 @@ +"""add_missing_indexes_and_constraints + +Revision ID: 7c26d62e8005 +Revises: bc37e9c7ae19 +Create Date: 2025-05-13 21:44:46.408395 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '7c26d62e8005' +down_revision: Union[str, None] = 'bc37e9c7ae19' +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_index('ix_expense_splits_user_id', 'expense_splits', ['user_id'], unique=False) + op.create_index(op.f('ix_expenses_group_id'), 'expenses', ['group_id'], unique=False) + op.create_index(op.f('ix_expenses_list_id'), 'expenses', ['list_id'], unique=False) + op.create_index(op.f('ix_expenses_paid_by_user_id'), 'expenses', ['paid_by_user_id'], unique=False) + op.create_index(op.f('ix_settlements_group_id'), 'settlements', ['group_id'], unique=False) + op.create_index(op.f('ix_settlements_paid_by_user_id'), 'settlements', ['paid_by_user_id'], unique=False) + op.create_index(op.f('ix_settlements_paid_to_user_id'), 'settlements', ['paid_to_user_id'], unique=False) + + # Add check constraints + op.create_check_constraint( + 'chk_expense_context', + 'expenses', + '(item_id IS NOT NULL) OR (list_id IS NOT NULL) OR (group_id IS NOT NULL)' + ) + op.create_check_constraint( + 'chk_settlement_different_users', + 'settlements', + 'paid_by_user_id != paid_to_user_id' + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + # Drop check constraints + op.drop_constraint('chk_settlement_different_users', 'settlements', type_='check') + op.drop_constraint('chk_expense_context', 'expenses', type_='check') + + op.drop_index(op.f('ix_settlements_paid_to_user_id'), table_name='settlements') + op.drop_index(op.f('ix_settlements_paid_by_user_id'), table_name='settlements') + op.drop_index(op.f('ix_settlements_group_id'), table_name='settlements') + op.drop_index(op.f('ix_expenses_paid_by_user_id'), table_name='expenses') + op.drop_index(op.f('ix_expenses_list_id'), table_name='expenses') + op.drop_index(op.f('ix_expenses_group_id'), table_name='expenses') + op.drop_index('ix_expense_splits_user_id', table_name='expense_splits') + # ### end Alembic commands ### diff --git a/be/app/config.py b/be/app/config.py index 3d6d95a..13796bd 100644 --- a/be/app/config.py +++ b/be/app/config.py @@ -11,6 +11,7 @@ logger = logging.getLogger(__name__) class Settings(BaseSettings): DATABASE_URL: str | None = None GEMINI_API_KEY: str | None = None + SENTRY_DSN: str | None = None # Sentry DSN for error tracking # --- JWT Settings --- SECRET_KEY: str # Must be set via environment variable diff --git a/be/app/main.py b/be/app/main.py index fca6ecb..5fc94b3 100644 --- a/be/app/main.py +++ b/be/app/main.py @@ -3,6 +3,8 @@ import logging import uvicorn from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +import sentry_sdk +from sentry_sdk.integrations.fastapi import FastApiIntegration from app.api.api_router import api_router from app.config import settings @@ -10,6 +12,20 @@ from app.core.api_config import API_METADATA, API_TAGS # Import database and models if needed for startup/shutdown events later # from . import database, models +# Initialize Sentry +sentry_sdk.init( + dsn=settings.SENTRY_DSN, + integrations=[ + FastApiIntegration(), + ], + # Set traces_sample_rate to 1.0 to capture 100% of transactions for performance monitoring. + # We recommend adjusting this value in production. + traces_sample_rate=1.0, + # If you wish to associate users to errors (assuming you are using + # FastAPI's users system) you may enable sending PII data. + send_default_pii=True +) + # --- Logging Setup --- logging.basicConfig( level=getattr(logging, settings.LOG_LEVEL), diff --git a/be/app/models.py b/be/app/models.py index ec1e941..3494fb3 100644 --- a/be/app/models.py +++ b/be/app/models.py @@ -19,7 +19,8 @@ from sqlalchemy import ( func, text as sa_text, Text, # <-- Add Text for description - Numeric # <-- Add Numeric for price + Numeric, # <-- Add Numeric for price + CheckConstraint ) from sqlalchemy.orm import relationship, backref @@ -184,15 +185,15 @@ class Expense(Base): id = Column(Integer, primary_key=True, index=True) description = Column(String, nullable=False) total_amount = Column(Numeric(10, 2), nullable=False) - currency = Column(String, nullable=False, default="USD") # Consider making this an Enum too if few currencies + currency = Column(String, nullable=False, default="USD") expense_date = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) split_type = Column(SAEnum(SplitTypeEnum, name="splittypeenum", create_type=True), nullable=False) # Foreign Keys - list_id = Column(Integer, ForeignKey("lists.id"), nullable=True) - group_id = Column(Integer, ForeignKey("groups.id"), nullable=True) # If not list-specific but group-specific - item_id = Column(Integer, ForeignKey("items.id"), nullable=True) # If the expense is for a specific item - paid_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + list_id = Column(Integer, ForeignKey("lists.id"), nullable=True, index=True) + group_id = Column(Integer, ForeignKey("groups.id"), nullable=True, index=True) + item_id = Column(Integer, ForeignKey("items.id"), nullable=True) + paid_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True) created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) @@ -206,27 +207,25 @@ class Expense(Base): splits = relationship("ExpenseSplit", back_populates="expense", cascade="all, delete-orphan") __table_args__ = ( - # Example: Ensure either list_id or group_id is present if item_id is null - # CheckConstraint('(item_id IS NOT NULL) OR (list_id IS NOT NULL) OR (group_id IS NOT NULL)', name='chk_expense_context'), + # Ensure at least one context is provided + CheckConstraint('(item_id IS NOT NULL) OR (list_id IS NOT NULL) OR (group_id IS NOT NULL)', name='chk_expense_context'), ) class ExpenseSplit(Base): __tablename__ = "expense_splits" - __table_args__ = (UniqueConstraint('expense_id', 'user_id', name='uq_expense_user_split'),) + __table_args__ = ( + UniqueConstraint('expense_id', 'user_id', name='uq_expense_user_split'), + Index('ix_expense_splits_user_id', 'user_id'), # For looking up user's splits + ) id = Column(Integer, primary_key=True, index=True) expense_id = Column(Integer, ForeignKey("expenses.id", ondelete="CASCADE"), nullable=False) user_id = Column(Integer, ForeignKey("users.id"), nullable=False) - owed_amount = Column(Numeric(10, 2), nullable=False) # For EQUAL or EXACT_AMOUNTS - # For PERCENTAGE split (value from 0.00 to 100.00) + owed_amount = Column(Numeric(10, 2), nullable=False) share_percentage = Column(Numeric(5, 2), nullable=True) - # For SHARES split (e.g., user A has 2 shares, user B has 3 shares) share_units = Column(Integer, nullable=True) - # is_settled might be better tracked via actual Settlement records or a reconciliation process - # is_settled = Column(Boolean, default=False, nullable=False) - created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) @@ -234,14 +233,13 @@ class ExpenseSplit(Base): expense = relationship("Expense", back_populates="splits") user = relationship("User", foreign_keys=[user_id], back_populates="expense_splits") - class Settlement(Base): __tablename__ = "settlements" id = Column(Integer, primary_key=True, index=True) - group_id = Column(Integer, ForeignKey("groups.id"), nullable=False) # Settlements usually within a group - paid_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=False) - paid_to_user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + group_id = Column(Integer, ForeignKey("groups.id"), nullable=False, index=True) + paid_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True) + paid_to_user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True) amount = Column(Numeric(10, 2), nullable=False) settlement_date = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) description = Column(Text, nullable=True) @@ -257,7 +255,7 @@ class Settlement(Base): __table_args__ = ( # Ensure payer and payee are different users - # CheckConstraint('paid_by_user_id <> paid_to_user_id', name='chk_settlement_payer_ne_payee'), + CheckConstraint('paid_by_user_id != paid_to_user_id', name='chk_settlement_different_users'), ) # Potential future: PaymentMethod model, etc. \ No newline at end of file diff --git a/be/requirements.txt b/be/requirements.txt index c72bf96..ca66a1e 100644 --- a/be/requirements.txt +++ b/be/requirements.txt @@ -9,4 +9,5 @@ python-dotenv>=1.0.0 # To load .env file for scripts/alembic passlib[bcrypt]>=1.7.4 python-jose[cryptography]>=3.3.0 pydantic[email] -google-generativeai>=0.5.0 \ No newline at end of file +google-generativeai>=0.5.0 +sentry-sdk[fastapi]>=1.39.0 \ No newline at end of file diff --git a/fe/package-lock.json b/fe/package-lock.json index 0b09d9d..cd6b435 100644 --- a/fe/package-lock.json +++ b/fe/package-lock.json @@ -8,6 +8,8 @@ "name": "fe", "version": "0.0.0", "dependencies": { + "@sentry/tracing": "^7.120.3", + "@sentry/vue": "^7.120.3", "@supabase/auth-js": "^2.69.1", "@supabase/supabase-js": "^2.49.4", "@vueuse/core": "^13.1.0", @@ -3825,6 +3827,162 @@ "dev": true, "license": "MIT" }, + "node_modules/@sentry-internal/feedback": { + "version": "7.120.3", + "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-7.120.3.tgz", + "integrity": "sha512-ewJJIQ0mbsOX6jfiVFvqMjokxNtgP3dNwUv+4nenN+iJJPQsM6a0ocro3iscxwVdbkjw5hY3BUV2ICI5Q0UWoA==", + "license": "MIT", + "dependencies": { + "@sentry/core": "7.120.3", + "@sentry/types": "7.120.3", + "@sentry/utils": "7.120.3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@sentry-internal/replay-canvas": { + "version": "7.120.3", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-7.120.3.tgz", + "integrity": "sha512-s5xy+bVL1eDZchM6gmaOiXvTqpAsUfO7122DxVdEDMtwVq3e22bS2aiGa8CUgOiJkulZ+09q73nufM77kOmT/A==", + "license": "MIT", + "dependencies": { + "@sentry/core": "7.120.3", + "@sentry/replay": "7.120.3", + "@sentry/types": "7.120.3", + "@sentry/utils": "7.120.3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@sentry-internal/tracing": { + "version": "7.120.3", + "resolved": "https://registry.npmjs.org/@sentry-internal/tracing/-/tracing-7.120.3.tgz", + "integrity": "sha512-Ausx+Jw1pAMbIBHStoQ6ZqDZR60PsCByvHdw/jdH9AqPrNE9xlBSf9EwcycvmrzwyKspSLaB52grlje2cRIUMg==", + "license": "MIT", + "dependencies": { + "@sentry/core": "7.120.3", + "@sentry/types": "7.120.3", + "@sentry/utils": "7.120.3" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sentry/browser": { + "version": "7.120.3", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-7.120.3.tgz", + "integrity": "sha512-i9vGcK9N8zZ/JQo1TCEfHHYZ2miidOvgOABRUc9zQKhYdcYQB2/LU1kqlj77Pxdxf4wOa9137d6rPrSn9iiBxg==", + "license": "MIT", + "dependencies": { + "@sentry-internal/feedback": "7.120.3", + "@sentry-internal/replay-canvas": "7.120.3", + "@sentry-internal/tracing": "7.120.3", + "@sentry/core": "7.120.3", + "@sentry/integrations": "7.120.3", + "@sentry/replay": "7.120.3", + "@sentry/types": "7.120.3", + "@sentry/utils": "7.120.3" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sentry/core": { + "version": "7.120.3", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-7.120.3.tgz", + "integrity": "sha512-vyy11fCGpkGK3qI5DSXOjgIboBZTriw0YDx/0KyX5CjIjDDNgp5AGgpgFkfZyiYiaU2Ww3iFuKo4wHmBusz1uA==", + "license": "MIT", + "dependencies": { + "@sentry/types": "7.120.3", + "@sentry/utils": "7.120.3" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sentry/integrations": { + "version": "7.120.3", + "resolved": "https://registry.npmjs.org/@sentry/integrations/-/integrations-7.120.3.tgz", + "integrity": "sha512-6i/lYp0BubHPDTg91/uxHvNui427df9r17SsIEXa2eKDwQ9gW2qRx5IWgvnxs2GV/GfSbwcx4swUB3RfEWrXrQ==", + "license": "MIT", + "dependencies": { + "@sentry/core": "7.120.3", + "@sentry/types": "7.120.3", + "@sentry/utils": "7.120.3", + "localforage": "^1.8.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sentry/replay": { + "version": "7.120.3", + "resolved": "https://registry.npmjs.org/@sentry/replay/-/replay-7.120.3.tgz", + "integrity": "sha512-CjVq1fP6bpDiX8VQxudD5MPWwatfXk8EJ2jQhJTcWu/4bCSOQmHxnnmBM+GVn5acKUBCodWHBN+IUZgnJheZSg==", + "license": "MIT", + "dependencies": { + "@sentry-internal/tracing": "7.120.3", + "@sentry/core": "7.120.3", + "@sentry/types": "7.120.3", + "@sentry/utils": "7.120.3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@sentry/tracing": { + "version": "7.120.3", + "resolved": "https://registry.npmjs.org/@sentry/tracing/-/tracing-7.120.3.tgz", + "integrity": "sha512-B7bqyYFgHuab1Pn7w5KXsZP/nfFo4VDBDdSXDSWYk5+TYJ3IDruO3eJFhOrircfsz4YwazWm9kbeZhkpsHDyHg==", + "license": "MIT", + "dependencies": { + "@sentry-internal/tracing": "7.120.3" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sentry/types": { + "version": "7.120.3", + "resolved": "https://registry.npmjs.org/@sentry/types/-/types-7.120.3.tgz", + "integrity": "sha512-C4z+3kGWNFJ303FC+FxAd4KkHvxpNFYAFN8iMIgBwJdpIl25KZ8Q/VdGn0MLLUEHNLvjob0+wvwlcRBBNLXOow==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@sentry/utils": { + "version": "7.120.3", + "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-7.120.3.tgz", + "integrity": "sha512-UDAOQJtJDxZHQ5Nm1olycBIsz2wdGX8SdzyGVHmD8EOQYAeDZQyIlQYohDe9nazdIOQLZCIc3fU0G9gqVLkaGQ==", + "license": "MIT", + "dependencies": { + "@sentry/types": "7.120.3" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sentry/vue": { + "version": "7.120.3", + "resolved": "https://registry.npmjs.org/@sentry/vue/-/vue-7.120.3.tgz", + "integrity": "sha512-YKKLGx6VNk5OTz5JqIsjIqOgaU8u88Q1OBfLZgOpm55vhrvpZGGc+rHyh8XtXxh4DfC+6vTRTrAngvdPOG9Oxw==", + "license": "MIT", + "dependencies": { + "@sentry/browser": "7.120.3", + "@sentry/core": "7.120.3", + "@sentry/types": "7.120.3", + "@sentry/utils": "7.120.3" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "vue": "2.x || 3.x" + } + }, "node_modules/@sindresorhus/merge-streams": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", @@ -7440,6 +7598,12 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/immutable": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.2.tgz", @@ -8413,6 +8577,24 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.1.1.tgz", + "integrity": "sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/localforage": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/localforage/-/localforage-1.10.0.tgz", + "integrity": "sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==", + "license": "Apache-2.0", + "dependencies": { + "lie": "3.1.1" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", diff --git a/fe/package.json b/fe/package.json index 7b1374f..ae02d36 100644 --- a/fe/package.json +++ b/fe/package.json @@ -17,6 +17,8 @@ "format": "prettier --write src/" }, "dependencies": { + "@sentry/tracing": "^7.120.3", + "@sentry/vue": "^7.120.3", "@supabase/auth-js": "^2.69.1", "@supabase/supabase-js": "^2.49.4", "@vueuse/core": "^13.1.0", diff --git a/fe/src/main.ts b/fe/src/main.ts index e65fd52..2a271cb 100644 --- a/fe/src/main.ts +++ b/fe/src/main.ts @@ -1,5 +1,7 @@ import { createApp } from 'vue'; import { createPinia } from 'pinia'; +import * as Sentry from '@sentry/vue'; +import { BrowserTracing } from '@sentry/tracing'; import App from './App.vue'; import router from './router'; // import { createI18n } from 'vue-i18n'; @@ -33,6 +35,23 @@ const app = createApp(App); const pinia = createPinia(); app.use(pinia); +// Initialize Sentry +Sentry.init({ + app, + dsn: import.meta.env.VITE_SENTRY_DSN, + integrations: [ + new BrowserTracing({ + routingInstrumentation: Sentry.vueRouterInstrumentation(router), + tracingOrigins: ['localhost', /^\//], + }), + ], + // Set tracesSampleRate to 1.0 to capture 100% of transactions for performance monitoring. + // We recommend adjusting this value in production + tracesSampleRate: 1.0, + // Set environment + environment: import.meta.env.MODE, +}); + // Initialize auth state before mounting the app const authStore = useAuthStore(); if (authStore.accessToken) {