weeee💃
This commit is contained in:
commit
4b7415e1c3
30
be/.dockerignore
Normal file
30
be/.dockerignore
Normal file
@ -0,0 +1,30 @@
|
||||
# Git files
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Virtual environment
|
||||
.venv
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
*.env # Ignore local .env files within the backend directory if any
|
||||
|
||||
# Python cache
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# IDE files
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# Test artifacts
|
||||
.pytest_cache/
|
||||
htmlcov/
|
||||
.coverage*
|
||||
|
||||
# Other build/temp files
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
*.db # e.g., sqlite temp dbs
|
141
be/.gitignore
vendored
Normal file
141
be/.gitignore
vendored
Normal file
@ -0,0 +1,141 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
pip-wheel-metadata/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
.python-version
|
||||
|
||||
# PEP 582; used by PDM, Flit and potentially other tools.
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv/
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static analysis results
|
||||
.pytype/
|
||||
|
||||
# alembic default temp file
|
||||
*.db # If using sqlite for alembic versions locally for instance
|
||||
|
||||
# If you use alembic autogenerate, it might create temporary files
|
||||
# Depending on your DB, adjust if necessary
|
||||
# *.sql.tmp
|
||||
|
||||
# IDE files
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# OS generated files
|
||||
.DS_Store
|
||||
Thumbs.db
|
35
be/Dockerfile
Normal file
35
be/Dockerfile
Normal file
@ -0,0 +1,35 @@
|
||||
# be/Dockerfile
|
||||
|
||||
# Choose a suitable Python base image
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Set environment variables
|
||||
ENV PYTHONDONTWRITEBYTECODE 1 # Prevent python from writing pyc files
|
||||
ENV PYTHONUNBUFFERED 1 # Keep stdout/stderr unbuffered
|
||||
|
||||
# Set the working directory in the container
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies if needed (e.g., for psycopg2 build)
|
||||
# RUN apt-get update && apt-get install -y --no-install-recommends gcc build-essential libpq-dev && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Python dependencies
|
||||
# Upgrade pip first
|
||||
RUN pip install --no-cache-dir --upgrade pip
|
||||
# Copy only requirements first to leverage Docker cache
|
||||
COPY requirements.txt requirements.txt
|
||||
# Install dependencies
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy the rest of the application code into the working directory
|
||||
COPY . .
|
||||
# This includes your 'app/' directory, alembic.ini, etc.
|
||||
|
||||
# Expose the port the app runs on
|
||||
EXPOSE 8000
|
||||
|
||||
# Command to run the application using uvicorn
|
||||
# The default command for production (can be overridden in docker-compose for development)
|
||||
# Note: Make sure 'app.main:app' correctly points to your FastAPI app instance
|
||||
# relative to the WORKDIR (/app). If your main.py is directly in /app, this is correct.
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
119
be/alembic.ini
Normal file
119
be/alembic.ini
Normal file
@ -0,0 +1,119 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts
|
||||
# Use forward slashes (/) also on windows to provide an os agnostic path
|
||||
script_location = alembic
|
||||
|
||||
# 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
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# defaults to the current working directory.
|
||||
prepend_sys_path = .
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library.
|
||||
# Any required deps can 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 alembic/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 "version_path_separator" below.
|
||||
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
|
||||
|
||||
# version path separator; As mentioned above, this is the character used to split
|
||||
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
|
||||
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
|
||||
# Valid values for version_path_separator are:
|
||||
#
|
||||
# version_path_separator = :
|
||||
# version_path_separator = ;
|
||||
# version_path_separator = space
|
||||
# version_path_separator = newline
|
||||
#
|
||||
# Use os.pathsep. Default configuration used for new projects.
|
||||
version_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
|
||||
|
||||
; 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 exec runner, execute a binary
|
||||
# hooks = ruff
|
||||
# ruff.type = exec
|
||||
# ruff.executable = %(here)s/.venv/bin/ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration
|
||||
[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
|
1
be/alembic/README
Normal file
1
be/alembic/README
Normal file
@ -0,0 +1 @@
|
||||
Generic single-database configuration.
|
96
be/alembic/env.py
Normal file
96
be/alembic/env.py
Normal file
@ -0,0 +1,96 @@
|
||||
from logging.config import fileConfig
|
||||
import os
|
||||
import sys
|
||||
|
||||
from sqlalchemy import engine_from_config
|
||||
from sqlalchemy import pool
|
||||
|
||||
from alembic import context
|
||||
|
||||
|
||||
# Ensure the 'app' directory is in the Python path
|
||||
# Adjust the path if your project structure is different
|
||||
sys.path.insert(0, os.path.realpath(os.path.join(os.path.dirname(__file__), '..')))
|
||||
|
||||
# Import your app's Base and settings
|
||||
from app.models import Base # Import Base from your models module
|
||||
from app.config import settings # Import settings to get DATABASE_URL
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Set the sqlalchemy.url from your application settings
|
||||
# Use a synchronous version of the URL for Alembic's operations
|
||||
sync_db_url = settings.DATABASE_URL.replace("+asyncpg", "") if settings.DATABASE_URL else None
|
||||
if not sync_db_url:
|
||||
raise ValueError("DATABASE_URL not found in settings for Alembic.")
|
||||
config.set_main_option('sqlalchemy.url', sync_db_url)
|
||||
|
||||
# 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)
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
# from myapp import mymodel
|
||||
# target_metadata = mymodel.Base.metadata
|
||||
target_metadata = Base.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()
|
28
be/alembic/script.py.mako
Normal file
28
be/alembic/script.py.mako
Normal file
@ -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, 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"}
|
32
be/alembic/versions/643956b3f4de_initial_database_setup.py
Normal file
32
be/alembic/versions/643956b3f4de_initial_database_setup.py
Normal file
@ -0,0 +1,32 @@
|
||||
"""Initial database setup
|
||||
|
||||
Revision ID: 643956b3f4de
|
||||
Revises:
|
||||
Create Date: 2025-03-29 20:49:01.018626
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '643956b3f4de'
|
||||
down_revision: Union[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! ###
|
||||
pass
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
pass
|
||||
# ### end Alembic commands ###
|
@ -0,0 +1,72 @@
|
||||
"""Add User, Group, UserGroup models
|
||||
|
||||
Revision ID: 85a3c075e73a
|
||||
Revises: c6cbef99588b
|
||||
Create Date: 2025-03-30 12:46:07.322285
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '85a3c075e73a'
|
||||
down_revision: Union[str, None] = 'c6cbef99588b'
|
||||
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('users',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('email', sa.String(), nullable=False),
|
||||
sa.Column('password_hash', sa.String(), nullable=False),
|
||||
sa.Column('name', sa.String(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
|
||||
op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_users_name'), 'users', ['name'], unique=False)
|
||||
op.create_table('groups',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.String(), nullable=False),
|
||||
sa.Column('created_by_id', sa.Integer(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['created_by_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_groups_id'), 'groups', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_groups_name'), 'groups', ['name'], unique=False)
|
||||
op.create_table('user_groups',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('group_id', sa.Integer(), nullable=False),
|
||||
sa.Column('role', sa.Enum('owner', 'member', name='userroleenum'), nullable=False),
|
||||
sa.Column('joined_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['group_id'], ['groups.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('user_id', 'group_id', name='uq_user_group')
|
||||
)
|
||||
op.create_index(op.f('ix_user_groups_id'), 'user_groups', ['id'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_user_groups_id'), table_name='user_groups')
|
||||
op.drop_table('user_groups')
|
||||
op.drop_index(op.f('ix_groups_name'), table_name='groups')
|
||||
op.drop_index(op.f('ix_groups_id'), table_name='groups')
|
||||
op.drop_table('groups')
|
||||
op.drop_index(op.f('ix_users_name'), table_name='users')
|
||||
op.drop_index(op.f('ix_users_id'), table_name='users')
|
||||
op.drop_index(op.f('ix_users_email'), table_name='users')
|
||||
op.drop_table('users')
|
||||
# ### end Alembic commands ###
|
32
be/alembic/versions/c6cbef99588b_initial_database_setup.py
Normal file
32
be/alembic/versions/c6cbef99588b_initial_database_setup.py
Normal file
@ -0,0 +1,32 @@
|
||||
"""Initial database setup
|
||||
|
||||
Revision ID: c6cbef99588b
|
||||
Revises: 643956b3f4de
|
||||
Create Date: 2025-03-30 12:18:51.207858
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'c6cbef99588b'
|
||||
down_revision: Union[str, None] = '643956b3f4de'
|
||||
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! ###
|
||||
pass
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
pass
|
||||
# ### end Alembic commands ###
|
0
be/app/__init__.py
Normal file
0
be/app/__init__.py
Normal file
0
be/app/api/__init__.py
Normal file
0
be/app/api/__init__.py
Normal file
12
be/app/api/api_router.py
Normal file
12
be/app/api/api_router.py
Normal file
@ -0,0 +1,12 @@
|
||||
# app/api/api_router.py
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1.api import api_router_v1 # Import the v1 router
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
# Include versioned routers here, adding the /api prefix
|
||||
api_router.include_router(api_router_v1, prefix="/v1") # Mounts v1 endpoints under /api/v1/...
|
||||
|
||||
# Add other API versions later
|
||||
# e.g., api_router.include_router(api_router_v2, prefix="/v2")
|
0
be/app/api/v1/__init__.py
Normal file
0
be/app/api/v1/__init__.py
Normal file
12
be/app/api/v1/api.py
Normal file
12
be/app/api/v1/api.py
Normal file
@ -0,0 +1,12 @@
|
||||
# app/api/v1/api.py
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1.endpoints import health # Import the health endpoint router
|
||||
|
||||
api_router_v1 = APIRouter()
|
||||
|
||||
# Include endpoint routers here, adding the desired prefix for v1
|
||||
api_router_v1.include_router(health.router) # The path "/health" is defined inside health.router
|
||||
|
||||
# Add other v1 endpoint routers here later
|
||||
# e.g., api_router_v1.include_router(users.router, prefix="/users", tags=["Users"])
|
0
be/app/api/v1/endpoints/__init__.py
Normal file
0
be/app/api/v1/endpoints/__init__.py
Normal file
45
be/app/api/v1/endpoints/health.py
Normal file
45
be/app/api/v1/endpoints/health.py
Normal file
@ -0,0 +1,45 @@
|
||||
# app/api/v1/endpoints/health.py
|
||||
import logging
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.sql import text
|
||||
|
||||
from app.database import get_db # Import the dependency function
|
||||
from app.schemas.health import HealthStatus # Import the response schema
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
@router.get(
|
||||
"/health",
|
||||
response_model=HealthStatus,
|
||||
summary="Perform a Health Check",
|
||||
description="Checks the operational status of the API and its connection to the database.",
|
||||
tags=["Health"] # Group this endpoint in Swagger UI
|
||||
)
|
||||
async def check_health(db: AsyncSession = Depends(get_db)):
|
||||
"""
|
||||
Health check endpoint. Verifies API reachability and database connection.
|
||||
"""
|
||||
try:
|
||||
# Try executing a simple query to check DB connection
|
||||
result = await db.execute(text("SELECT 1"))
|
||||
if result.scalar_one() == 1:
|
||||
logger.info("Health check successful: Database connection verified.")
|
||||
return HealthStatus(status="ok", database="connected")
|
||||
else:
|
||||
# This case should ideally not happen with 'SELECT 1'
|
||||
logger.error("Health check failed: Database connection check returned unexpected result.")
|
||||
# Raise 503 Service Unavailable
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Database connection error: Unexpected result"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Health check failed: Database connection error - {e}", exc_info=True) # Log stack trace
|
||||
# Raise 503 Service Unavailable
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=f"Database connection error: {e}"
|
||||
)
|
24
be/app/config.py
Normal file
24
be/app/config.py
Normal file
@ -0,0 +1,24 @@
|
||||
# app/config.py
|
||||
import os
|
||||
from pydantic_settings import BaseSettings
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
class Settings(BaseSettings):
|
||||
DATABASE_URL: str | None = None
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
env_file_encoding = 'utf-8'
|
||||
extra = "ignore"
|
||||
|
||||
settings = Settings()
|
||||
|
||||
# Basic validation to ensure DATABASE_URL is set
|
||||
if settings.DATABASE_URL is None:
|
||||
print("Error: DATABASE_URL environment variable not set.")
|
||||
# Consider raising an exception for clearer failure
|
||||
# raise ValueError("DATABASE_URL environment variable not set.")
|
||||
# else: # Optional: Log the URL being used (without credentials ideally) for debugging
|
||||
# print(f"DATABASE_URL loaded: {settings.DATABASE_URL[:settings.DATABASE_URL.find('@')] if '@' in settings.DATABASE_URL else 'URL structure unexpected'}")
|
47
be/app/database.py
Normal file
47
be/app/database.py
Normal file
@ -0,0 +1,47 @@
|
||||
# app/database.py
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
||||
from sqlalchemy.orm import sessionmaker, declarative_base
|
||||
from app.config import settings
|
||||
|
||||
# Ensure DATABASE_URL is set before proceeding
|
||||
if not settings.DATABASE_URL:
|
||||
raise ValueError("DATABASE_URL is not configured in settings.")
|
||||
|
||||
# Create the SQLAlchemy async engine
|
||||
# pool_recycle=3600 helps prevent stale connections on some DBs
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
echo=True, # Log SQL queries (useful for debugging)
|
||||
future=True, # Use SQLAlchemy 2.0 style features
|
||||
pool_recycle=3600 # Optional: recycle connections after 1 hour
|
||||
)
|
||||
|
||||
# Create a configured "Session" class
|
||||
# expire_on_commit=False prevents attributes from expiring after commit
|
||||
AsyncSessionLocal = sessionmaker(
|
||||
bind=engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autoflush=False,
|
||||
autocommit=False,
|
||||
)
|
||||
|
||||
# Base class for our ORM models
|
||||
Base = declarative_base()
|
||||
|
||||
# Dependency to get DB session in path operations
|
||||
async def get_db() -> AsyncSession: # type: ignore
|
||||
"""
|
||||
Dependency function that yields an AsyncSession.
|
||||
Ensures the session is closed after the request.
|
||||
"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
# Optionally commit if your endpoints modify data directly
|
||||
# await session.commit() # Usually commit happens within endpoint logic
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close() # Not strictly necessary with async context manager, but explicit
|
90
be/app/main.py
Normal file
90
be/app/main.py
Normal file
@ -0,0 +1,90 @@
|
||||
# app/main.py
|
||||
import logging
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.api.api_router import api_router # Import the main combined router
|
||||
# Import database and models if needed for startup/shutdown events later
|
||||
# from . import database, models
|
||||
|
||||
# --- Logging Setup ---
|
||||
# Configure logging (can be more sophisticated later, e.g., using logging.yaml)
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# --- FastAPI App Instance ---
|
||||
app = FastAPI(
|
||||
title="Shared Lists API",
|
||||
description="API for managing shared shopping lists, OCR, and cost splitting.",
|
||||
version="0.1.0",
|
||||
openapi_url="/api/openapi.json", # Place OpenAPI spec under /api
|
||||
docs_url="/api/docs", # Place Swagger UI under /api
|
||||
redoc_url="/api/redoc" # Place ReDoc under /api
|
||||
)
|
||||
|
||||
# --- CORS Middleware ---
|
||||
# Define allowed origins. Be specific in production!
|
||||
# Use ["*"] for wide open access during early development if needed,
|
||||
# but restrict it as soon as possible.
|
||||
# SvelteKit default dev port is 5173
|
||||
origins = [
|
||||
"http://localhost:5173",
|
||||
"http://localhost:8000", # Allow requests from the API itself (e.g., Swagger UI)
|
||||
# Add your deployed frontend URL here later
|
||||
# "https://your-frontend-domain.com",
|
||||
]
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=origins, # List of origins that are allowed to make requests
|
||||
allow_credentials=True, # Allow cookies to be included in requests
|
||||
allow_methods=["*"], # Allow all methods (GET, POST, PUT, DELETE, etc.)
|
||||
allow_headers=["*"], # Allow all headers
|
||||
)
|
||||
# --- End CORS Middleware ---
|
||||
|
||||
|
||||
# --- Include API Routers ---
|
||||
# All API endpoints will be prefixed with /api
|
||||
app.include_router(api_router, prefix="/api")
|
||||
# --- End Include API Routers ---
|
||||
|
||||
|
||||
# --- Root Endpoint (Optional - outside the main API structure) ---
|
||||
@app.get("/", tags=["Root"])
|
||||
async def read_root():
|
||||
"""
|
||||
Provides a simple welcome message at the root path.
|
||||
Useful for basic reachability checks.
|
||||
"""
|
||||
logger.info("Root endpoint '/' accessed.")
|
||||
# You could redirect to the docs or return a simple message
|
||||
# from fastapi.responses import RedirectResponse
|
||||
# return RedirectResponse(url="/api/docs")
|
||||
return {"message": "Welcome to the Shared Lists API! Docs available at /api/docs"}
|
||||
# --- End Root Endpoint ---
|
||||
|
||||
|
||||
# --- Application Startup/Shutdown Events (Optional) ---
|
||||
# @app.on_event("startup")
|
||||
# async def startup_event():
|
||||
# logger.info("Application startup: Connecting to database...")
|
||||
# # You might perform initial checks or warm-up here
|
||||
# # await database.engine.connect() # Example check (get_db handles sessions per request)
|
||||
# logger.info("Application startup complete.")
|
||||
|
||||
# @app.on_event("shutdown")
|
||||
# async def shutdown_event():
|
||||
# logger.info("Application shutdown: Disconnecting from database...")
|
||||
# # await database.engine.dispose() # Close connection pool
|
||||
# logger.info("Application shutdown complete.")
|
||||
# --- End Events ---
|
||||
|
||||
|
||||
# --- Direct Run (for simple local testing if needed) ---
|
||||
# It's better to use `uvicorn app.main:app --reload` from the terminal
|
||||
# if __name__ == "__main__":
|
||||
# logger.info("Starting Uvicorn server directly from main.py")
|
||||
# uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
# ------------------------------------------------------
|
108
be/app/models.py
Normal file
108
be/app/models.py
Normal file
@ -0,0 +1,108 @@
|
||||
# app/models.py
|
||||
import enum
|
||||
from datetime import datetime
|
||||
from sqlalchemy import (
|
||||
Column,
|
||||
Integer,
|
||||
String,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Boolean,
|
||||
Enum as SAEnum, # Renamed to avoid clash with Python's enum
|
||||
UniqueConstraint,
|
||||
event,
|
||||
DDL
|
||||
)
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func # For server_default=func.now()
|
||||
|
||||
from app.database import Base # Import Base from database setup
|
||||
|
||||
# Define Enum for User Roles in Groups
|
||||
class UserRoleEnum(enum.Enum):
|
||||
owner = "owner"
|
||||
member = "member"
|
||||
|
||||
# --- User Model ---
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
email = Column(String, unique=True, index=True, nullable=False)
|
||||
password_hash = Column(String, nullable=False)
|
||||
name = Column(String, index=True, nullable=True) # Allow nullable name initially
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
|
||||
# Relationships
|
||||
# Groups created by this user
|
||||
created_groups = relationship("Group", back_populates="creator")
|
||||
# Association object for group membership
|
||||
group_associations = relationship("UserGroup", back_populates="user", cascade="all, delete-orphan")
|
||||
|
||||
# Items added by this user (Add later when Item model is defined)
|
||||
# added_items = relationship("Item", foreign_keys="[Item.added_by_id]", back_populates="added_by_user")
|
||||
# Items completed by this user (Add later)
|
||||
# completed_items = relationship("Item", foreign_keys="[Item.completed_by_id]", back_populates="completed_by_user")
|
||||
# Expense shares for this user (Add later)
|
||||
# expense_shares = relationship("ExpenseShare", back_populates="user")
|
||||
# Lists created by this user (Add later)
|
||||
# created_lists = relationship("List", foreign_keys="[List.created_by_id]", back_populates="creator")
|
||||
|
||||
|
||||
# --- Group Model ---
|
||||
class Group(Base):
|
||||
__tablename__ = "groups"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, index=True, nullable=False)
|
||||
created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
|
||||
# Relationships
|
||||
# The user who created this group
|
||||
creator = relationship("User", back_populates="created_groups")
|
||||
# Association object for group membership
|
||||
member_associations = relationship("UserGroup", back_populates="group", cascade="all, delete-orphan")
|
||||
# Lists belonging to this group (Add later)
|
||||
# lists = relationship("List", back_populates="group")
|
||||
|
||||
# --- UserGroup Association Model ---
|
||||
class UserGroup(Base):
|
||||
__tablename__ = "user_groups"
|
||||
__table_args__ = (UniqueConstraint('user_id', 'group_id', name='uq_user_group'),) # Ensure user cannot be in same group twice
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True) # Surrogate primary key
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
group_id = Column(Integer, ForeignKey("groups.id", ondelete="CASCADE"), nullable=False)
|
||||
role = Column(SAEnum(UserRoleEnum), nullable=False, default=UserRoleEnum.member)
|
||||
joined_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
|
||||
# Relationships back to User and Group
|
||||
user = relationship("User", back_populates="group_associations")
|
||||
group = relationship("Group", back_populates="member_associations")
|
||||
|
||||
|
||||
# --- Add other models below when needed ---
|
||||
# class List(Base): ...
|
||||
# class Item(Base): ...
|
||||
# class Expense(Base): ...
|
||||
# class ExpenseShare(Base): ...
|
||||
|
||||
# Optional: Trigger for automatically creating an 'owner' UserGroup entry when a Group is created.
|
||||
# This requires importing event and DDL. It's advanced and DB-specific, might be simpler to handle in application logic.
|
||||
# Example for PostgreSQL (might need adjustment):
|
||||
# group_owner_trigger = DDL("""
|
||||
# CREATE OR REPLACE FUNCTION add_group_owner()
|
||||
# RETURNS TRIGGER AS $$
|
||||
# BEGIN
|
||||
# INSERT INTO user_groups (user_id, group_id, role, joined_at)
|
||||
# VALUES (NEW.created_by_id, NEW.id, 'owner', NOW());
|
||||
# RETURN NEW;
|
||||
# END;
|
||||
# $$ LANGUAGE plpgsql;
|
||||
#
|
||||
# CREATE TRIGGER trg_add_group_owner
|
||||
# AFTER INSERT ON groups
|
||||
# FOR EACH ROW EXECUTE FUNCTION add_group_owner();
|
||||
# """)
|
||||
# event.listen(Group.__table__, 'after_create', group_owner_trigger)
|
0
be/app/schemas/__init__.py
Normal file
0
be/app/schemas/__init__.py
Normal file
9
be/app/schemas/health.py
Normal file
9
be/app/schemas/health.py
Normal file
@ -0,0 +1,9 @@
|
||||
# app/schemas/health.py
|
||||
from pydantic import BaseModel
|
||||
|
||||
class HealthStatus(BaseModel):
|
||||
"""
|
||||
Response model for the health check endpoint.
|
||||
"""
|
||||
status: str = "ok" # Provide a default value
|
||||
database: str
|
8
be/requirements.txt
Normal file
8
be/requirements.txt
Normal file
@ -0,0 +1,8 @@
|
||||
fastapi>=0.95.0
|
||||
uvicorn[standard]>=0.20.0
|
||||
sqlalchemy[asyncio]>=2.0.0 # Core ORM + Async support
|
||||
asyncpg>=0.27.0 # Async PostgreSQL driver
|
||||
psycopg2-binary>=2.9.0 # Often needed by Alembic even if app uses asyncpg
|
||||
alembic>=1.9.0 # Database migrations
|
||||
pydantic-settings>=2.0.0 # For loading settings from .env
|
||||
python-dotenv>=1.0.0 # To load .env file for scripts/alembic
|
65
docker-compose.yml
Normal file
65
docker-compose.yml
Normal file
@ -0,0 +1,65 @@
|
||||
# docker-compose.yml (in project root)
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:15 # Use a specific PostgreSQL version
|
||||
container_name: postgres_db
|
||||
environment:
|
||||
POSTGRES_USER: dev_user # Define DB user
|
||||
POSTGRES_PASSWORD: dev_password # Define DB password
|
||||
POSTGRES_DB: dev_db # Define Database name
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data # Persist data using a named volume
|
||||
ports:
|
||||
- "5432:5432" # Expose PostgreSQL port to host (optional, for direct access)
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
restart: unless-stopped
|
||||
|
||||
backend:
|
||||
container_name: fastapi_backend
|
||||
build:
|
||||
context: ./be # Path to the directory containing the Dockerfile
|
||||
dockerfile: Dockerfile
|
||||
volumes:
|
||||
# Mount local code into the container for development hot-reloading
|
||||
# The code inside the container at /app will mirror your local ./be directory
|
||||
- ./be:/app
|
||||
ports:
|
||||
- "8000:8000" # Map container port 8000 to host port 8000
|
||||
environment:
|
||||
# Pass the database URL to the backend container
|
||||
# Uses the service name 'db' as the host, and credentials defined above
|
||||
# IMPORTANT: Use the correct async driver prefix if your app needs it!
|
||||
- DATABASE_URL=postgresql+asyncpg://dev_user:dev_password@db:5432/dev_db
|
||||
# Add other environment variables needed by the backend here
|
||||
# - SOME_OTHER_VAR=some_value
|
||||
depends_on:
|
||||
db: # Wait for the db service to be healthy before starting backend
|
||||
condition: service_healthy
|
||||
command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] # Override CMD for development reload
|
||||
restart: unless-stopped
|
||||
|
||||
pgadmin: # Optional service for database administration
|
||||
image: dpage/pgadmin4:latest
|
||||
container_name: pgadmin4_server
|
||||
environment:
|
||||
PGADMIN_DEFAULT_EMAIL: admin@example.com # Change as needed
|
||||
PGADMIN_DEFAULT_PASSWORD: admin_password # Change to a secure password
|
||||
PGADMIN_CONFIG_SERVER_MODE: 'False' # Run in Desktop mode for easier local dev server setup
|
||||
volumes:
|
||||
- pgadmin_data:/var/lib/pgadmin # Persist pgAdmin configuration
|
||||
ports:
|
||||
- "5050:80" # Map container port 80 to host port 5050
|
||||
depends_on:
|
||||
- db # Depends on the database service
|
||||
restart: unless-stopped
|
||||
|
||||
volumes: # Define named volumes for data persistence
|
||||
postgres_data:
|
||||
pgadmin_data:
|
23
fe/.gitignore
vendored
Normal file
23
fe/.gitignore
vendored
Normal file
@ -0,0 +1,23 @@
|
||||
node_modules
|
||||
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
.wrangler
|
||||
/.svelte-kit
|
||||
/build
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
6
fe/.prettierignore
Normal file
6
fe/.prettierignore
Normal file
@ -0,0 +1,6 @@
|
||||
# Package Managers
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
bun.lock
|
||||
bun.lockb
|
15
fe/.prettierrc
Normal file
15
fe/.prettierrc
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"useTabs": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.svelte",
|
||||
"options": {
|
||||
"parser": "svelte"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
38
fe/README.md
Normal file
38
fe/README.md
Normal file
@ -0,0 +1,38 @@
|
||||
# sv
|
||||
|
||||
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
||||
|
||||
## Creating a project
|
||||
|
||||
If you're seeing this, you've probably already done this step. Congrats!
|
||||
|
||||
```bash
|
||||
# create a new project in the current directory
|
||||
npx sv create
|
||||
|
||||
# create a new project in my-app
|
||||
npx sv create my-app
|
||||
```
|
||||
|
||||
## Developing
|
||||
|
||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
|
||||
# or start the server and open the app in a new browser tab
|
||||
npm run dev -- --open
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
To create a production version of your app:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
You can preview the production build with `npm run preview`.
|
||||
|
||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
2401
fe/package-lock.json
generated
Normal file
2401
fe/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
fe/package.json
Normal file
31
fe/package.json
Normal file
@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "fe",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"format": "prettier --write .",
|
||||
"lint": "prettier --check . && eslint ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-node": "^5.2.11",
|
||||
"@sveltejs/kit": "^2.16.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tailwindcss/forms": "^0.5.9",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-svelte": "^3.3.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
2
fe/src/app.css
Normal file
2
fe/src/app.css
Normal file
@ -0,0 +1,2 @@
|
||||
@import 'tailwindcss';
|
||||
@plugin '@tailwindcss/forms';
|
13
fe/src/app.d.ts
vendored
Normal file
13
fe/src/app.d.ts
vendored
Normal file
@ -0,0 +1,13 @@
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
17
fe/src/app.html
Normal file
17
fe/src/app.html
Normal file
@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="theme-color" content="#4a90e2">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
|
||||
</html>
|
163
fe/src/lib/apiClient.ts
Normal file
163
fe/src/lib/apiClient.ts
Normal file
@ -0,0 +1,163 @@
|
||||
// src/lib/apiClient.ts
|
||||
import { error } from '@sveltejs/kit'; // SvelteKit's error helper
|
||||
|
||||
// --- Configuration ---
|
||||
// Get the base URL from environment variables provided by Vite/SvelteKit
|
||||
// Ensure VITE_API_BASE_URL is set in your .env file (e.g., VITE_API_BASE_URL=http://localhost:8000/api)
|
||||
const BASE_URL = import.meta.env.VITE_API_BASE_URL;
|
||||
|
||||
if (!BASE_URL) {
|
||||
console.error('VITE_API_BASE_URL is not defined. Please set it in your .env file.');
|
||||
// In a real app, you might throw an error here or have a default,
|
||||
// but logging is often sufficient during development.
|
||||
}
|
||||
|
||||
export class ApiClientError extends Error {
|
||||
status: number;
|
||||
errorData: unknown;
|
||||
|
||||
constructor(message: string, status: number, errorData: unknown = null) {
|
||||
super(message);
|
||||
this.name = 'ApiClientError';
|
||||
this.status = status;
|
||||
this.errorData = errorData;
|
||||
|
||||
// --- Corrected Conditional Check ---
|
||||
// Check if the static method exists on the Error constructor object
|
||||
if (typeof (Error as any).captureStackTrace === 'function') {
|
||||
// Call it if it exists, casting Error to 'any' to bypass static type check
|
||||
(Error as any).captureStackTrace(this, ApiClientError);
|
||||
}
|
||||
// else {
|
||||
// Optional: Fallback if captureStackTrace is not available
|
||||
// You might assign the stack from a new error instance,
|
||||
// though `super(message)` often handles basic stack creation.
|
||||
// this.stack = new Error(message).stack;
|
||||
// }
|
||||
// --- End Corrected Check ---
|
||||
}
|
||||
}
|
||||
|
||||
// --- Core Fetch Function ---
|
||||
interface RequestOptions extends Omit<RequestInit, 'body'> {
|
||||
// Can add custom options here if needed later
|
||||
}
|
||||
|
||||
async function request<T = unknown>( // Generic type T for expected response data
|
||||
method: string,
|
||||
path: string, // Relative path (e.g., /v1/health)
|
||||
data?: unknown, // Optional request body data
|
||||
options: RequestOptions = {} // Optional fetch options (headers, etc.)
|
||||
): Promise<T> {
|
||||
|
||||
if (!BASE_URL) {
|
||||
// Or use SvelteKit's error helper for server-side/universal loads
|
||||
// error(500, 'API Base URL is not configured.');
|
||||
throw new Error('API Base URL (VITE_API_BASE_URL) is not configured.');
|
||||
}
|
||||
|
||||
// Construct the full URL, handling potential leading/trailing slashes
|
||||
const cleanBase = BASE_URL.replace(/\/$/, ''); // Remove trailing slash from base
|
||||
const cleanPath = path.replace(/^\//, ''); // Remove leading slash from path
|
||||
const url = `${cleanBase}/${cleanPath}`;
|
||||
|
||||
// Default headers
|
||||
const headers = new Headers({
|
||||
Accept: 'application/json',
|
||||
...options.headers // Spread custom headers from options
|
||||
});
|
||||
|
||||
// Fetch options
|
||||
const fetchOptions: RequestInit = {
|
||||
method: method.toUpperCase(),
|
||||
headers,
|
||||
...options // Spread other custom options (credentials, mode, cache, etc.)
|
||||
};
|
||||
|
||||
// Add body and Content-Type header if data is provided
|
||||
if (data !== undefined && data !== null) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
fetchOptions.body = JSON.stringify(data);
|
||||
}
|
||||
|
||||
// Add credentials option if needed for cookies/auth later
|
||||
// fetchOptions.credentials = 'include';
|
||||
|
||||
try {
|
||||
const response = await fetch(url, fetchOptions);
|
||||
|
||||
// Check if the response is successful (status code 200-299)
|
||||
if (!response.ok) {
|
||||
let errorJson: unknown = null;
|
||||
try {
|
||||
// Try to parse error details from the response body
|
||||
errorJson = await response.json();
|
||||
} catch (e) {
|
||||
// Ignore if response body isn't valid JSON
|
||||
console.warn('API Error response was not valid JSON.', response.status, response.statusText)
|
||||
}
|
||||
// Throw a custom error with status and potentially parsed error data
|
||||
throw new ApiClientError(
|
||||
`API request failed: ${response.status} ${response.statusText}`,
|
||||
response.status,
|
||||
errorJson
|
||||
);
|
||||
}
|
||||
|
||||
// Handle successful responses with no content (e.g., 204 No Content)
|
||||
if (response.status === 204) {
|
||||
// Type assertion needed because Promise<T> expects a value,
|
||||
// but 204 has no body. We return null. Adjust T if needed.
|
||||
return null as T;
|
||||
}
|
||||
|
||||
// Parse successful JSON response
|
||||
const responseData = await response.json();
|
||||
return responseData as T; // Assert the type based on the generic T
|
||||
|
||||
} catch (err) {
|
||||
// Handle network errors or errors thrown above
|
||||
console.error(`API Client request error: ${method} ${path}`, err);
|
||||
|
||||
// Re-throw the error so calling code can handle it
|
||||
// If it's already our custom error, re-throw it directly
|
||||
if (err instanceof ApiClientError) {
|
||||
throw err;
|
||||
}
|
||||
// Otherwise, wrap network or other errors
|
||||
throw new ApiClientError(
|
||||
`Network or unexpected error during API request: ${err instanceof Error ? err.message : String(err)}`,
|
||||
0, // Use 0 or a specific code for network errors
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helper Methods ---
|
||||
|
||||
export const apiClient = {
|
||||
get: <T = unknown>(path: string, options: RequestOptions = {}): Promise<T> => {
|
||||
return request<T>('GET', path, undefined, options);
|
||||
},
|
||||
|
||||
post: <T = unknown>(path: string, data: unknown, options: RequestOptions = {}): Promise<T> => {
|
||||
return request<T>('POST', path, data, options);
|
||||
},
|
||||
|
||||
put: <T = unknown>(path: string, data: unknown, options: RequestOptions = {}): Promise<T> => {
|
||||
return request<T>('PUT', path, data, options);
|
||||
},
|
||||
|
||||
delete: <T = unknown>(path: string, options: RequestOptions = {}): Promise<T> => {
|
||||
// Note: DELETE requests might have a body, but often don't. Adjust if needed.
|
||||
return request<T>('DELETE', path, undefined, options);
|
||||
},
|
||||
|
||||
patch: <T = unknown>(path: string, data: unknown, options: RequestOptions = {}): Promise<T> => {
|
||||
return request<T>('PATCH', path, data, options);
|
||||
}
|
||||
// Can add other methods (HEAD, OPTIONS) if necessary
|
||||
};
|
||||
|
||||
// Default export can sometimes be convenient, but named export is clear
|
||||
// export default apiClient;
|
1
fe/src/lib/index.ts
Normal file
1
fe/src/lib/index.ts
Normal file
@ -0,0 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
4
fe/src/lib/schemas/health.ts
Normal file
4
fe/src/lib/schemas/health.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export interface HealthStatus {
|
||||
status: string;
|
||||
database: string;
|
||||
}
|
41
fe/src/routes/+layout.svelte
Normal file
41
fe/src/routes/+layout.svelte
Normal file
@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
// Import global styles if you have them, e.g., app.css
|
||||
// We'll rely on Tailwind configured via app.postcss for now.
|
||||
import '../app.css'; // Import the main PostCSS file where Tailwind directives are
|
||||
console.log('Root layout loaded'); // For debugging in browser console
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-screen flex-col bg-gray-50">
|
||||
<!-- Header Placeholder -->
|
||||
<header class="bg-gradient-to-r from-blue-600 to-indigo-700 p-4 text-white shadow-md">
|
||||
<div class="container mx-auto flex items-center justify-between">
|
||||
<h1 class="text-xl font-bold">Shared Lists App</h1>
|
||||
<!-- Navigation Placeholder -->
|
||||
<nav class="space-x-4">
|
||||
<a href="/" class="hover:text-blue-200 hover:underline">Home</a>
|
||||
<a href="/login" class="hover:text-blue-200 hover:underline">Login</a>
|
||||
<!-- Add other basic links later -->
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<main class="container mx-auto flex-grow p-4 md:p-8">
|
||||
<!-- The <slot /> component renders the content of the current page (+page.svelte) -->
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
<!-- Footer Placeholder -->
|
||||
<footer class="mt-auto bg-gray-200 p-4 text-center text-sm text-gray-600">
|
||||
<p>© {new Date().getFullYear()} Shared Lists App. All rights reserved.</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<style lang="postcss">
|
||||
/* You can add global non-utility styles here if needed, */
|
||||
/* but Tailwind is generally preferred for component styling. */
|
||||
/* Example: */
|
||||
/* :global(body) { */
|
||||
/* font-family: 'Inter', sans-serif; */
|
||||
/* } */
|
||||
</style>
|
0
fe/src/routes/+page.svelte
Normal file
0
fe/src/routes/+page.svelte
Normal file
64
fe/src/service-worker.ts
Normal file
64
fe/src/service-worker.ts
Normal file
@ -0,0 +1,64 @@
|
||||
/// <reference types="@sveltejs/kit" />
|
||||
// REMOVED: /// <reference types="@types/workbox-sw" />
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
// Import SvelteKit-provided variables ONLY
|
||||
import { build, files, version } from '$service-worker';
|
||||
|
||||
declare let self: ServiceWorkerGlobalScope;
|
||||
// Declare 'workbox' as any for now IF TypeScript still complains after removing @types/workbox-sw.
|
||||
// Often, SvelteKit's types are enough, but this is a fallback.
|
||||
declare const workbox: any; // Uncomment this line ONLY if 'Cannot find name workbox' persists
|
||||
|
||||
console.log(`[Service Worker] Version: ${version}`);
|
||||
|
||||
// --- Precaching ---
|
||||
// Use the global workbox object (assuming SvelteKit injects it)
|
||||
workbox.precaching.precacheAndRoute(build);
|
||||
workbox.precaching.precacheAndRoute(files.map(f => ({ url: f, revision: null })));
|
||||
|
||||
// --- Runtime Caching ---
|
||||
// Google Fonts
|
||||
workbox.routing.registerRoute(
|
||||
({ url }) => url.origin === 'https://fonts.googleapis.com' || url.origin === 'https://fonts.gstatic.com',
|
||||
new workbox.strategies.StaleWhileRevalidate({
|
||||
cacheName: 'google-fonts',
|
||||
plugins: [
|
||||
new workbox.cacheableResponse.CacheableResponse({ statuses: [0, 200] }),
|
||||
new workbox.expiration.ExpirationPlugin({ maxEntries: 20, maxAgeSeconds: 30 * 24 * 60 * 60 }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
// Images from origin
|
||||
workbox.routing.registerRoute(
|
||||
({ request, url }) => !!request && request.destination === 'image' && url.origin === self.location.origin,
|
||||
new workbox.strategies.CacheFirst({
|
||||
cacheName: 'images',
|
||||
plugins: [
|
||||
new workbox.cacheableResponse.CacheableResponse({ statuses: [0, 200] }),
|
||||
new workbox.expiration.ExpirationPlugin({
|
||||
maxEntries: 50,
|
||||
maxAgeSeconds: 30 * 24 * 60 * 60,
|
||||
purgeOnQuotaError: true,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
// --- Lifecycle ---
|
||||
self.addEventListener('install', (event) => {
|
||||
console.log('[Service Worker] Install event');
|
||||
// event.waitUntil(self.skipWaiting());
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
const extendableEvent = event as ExtendableEvent;
|
||||
console.log('[Service Worker] Activate event');
|
||||
extendableEvent.waitUntil(workbox.precaching.cleanupOutdatedCaches());
|
||||
// event.waitUntil(self.clients.claim());
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
// console.log(`[Service Worker] Fetching: ${event.request.url}`);
|
||||
});
|
BIN
fe/static/favicon.png
Normal file
BIN
fe/static/favicon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.5 KiB |
BIN
fe/static/icon-144x144.png
Normal file
BIN
fe/static/icon-144x144.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 7.1 KiB |
BIN
fe/static/icon-192x192.png
Normal file
BIN
fe/static/icon-192x192.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 7.1 KiB |
BIN
fe/static/icon-512x512.png
Normal file
BIN
fe/static/icon-512x512.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 19 KiB |
29
fe/static/manifest.json
Normal file
29
fe/static/manifest.json
Normal file
@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "Shared Household Lists",
|
||||
"short_name": "SharedLists",
|
||||
"description": "Collaborative shopping lists, OCR, and cost splitting for households.",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#f3f3f3",
|
||||
"theme_color": "#c0377b",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icon-144x144.png",
|
||||
"sizes": "144x144",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
9
fe/svelte.config.js
Normal file
9
fe/svelte.config.js
Normal file
@ -0,0 +1,9 @@
|
||||
import adapter from '@sveltejs/adapter-node';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
const config = {
|
||||
preprocess: vitePreprocess(),
|
||||
kit: { adapter: adapter() }
|
||||
};
|
||||
|
||||
export default config;
|
19
fe/tsconfig.json
Normal file
19
fe/tsconfig.json
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||
//
|
||||
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
|
||||
// from the referenced tsconfig.json - TypeScript does not merge them in
|
||||
}
|
7
fe/vite.config.ts
Normal file
7
fe/vite.config.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), sveltekit()]
|
||||
});
|
Loading…
Reference in New Issue
Block a user