diff --git a/.env.sample b/.env.sample index 584cdef8..3bc960bc 100644 --- a/.env.sample +++ b/.env.sample @@ -2,4 +2,7 @@ POSTGRES_DATABASE= POSTGRES_USER= POSTGRES_PASSWORD= POSTGRES_DATABASE_URL= - +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_REGION= +SES_EMAIL_FROM= diff --git a/README.md b/README.md index 8a1fe137..f2f02323 100644 --- a/README.md +++ b/README.md @@ -243,6 +243,18 @@ pdm run ruff format . All code needs to pass ruff formatting and linting before it can be merged. +### Logging + +To add a logger to a new service or file, use the `LOGGER_NAME` function in `app/utilities/constants.py` + +```python +from app.utilities.constants import LOGGER_NAME + +log = logging.getLogger(LOGGER_NAME("my_service")) +``` + +If you'd like to create a new logger name in the hierarchy, you'll need to add it to `alembic.ini` under the logger section. Following the pre-existing examples for `logger_uvicorn` for example. + ### Frontend #### Prettier diff --git a/backend/README.md b/backend/README.md index 4b8315e4..4db9cae3 100644 --- a/backend/README.md +++ b/backend/README.md @@ -136,3 +136,15 @@ To apply the migration, run the following command: ```bash pdm run alembic upgrade head ``` + +### Logging + +To add a logger to a new service or file, use the `LOGGER_NAME` function in `app/utilities/constants.py` + +```python +from app.utilities.constants import LOGGER_NAME + +log = logging.getLogger(LOGGER_NAME("my_service")) +``` + +If you'd like to create a new logger name in the hierarchy, you'll need to add it to `alembic.ini` under the logger section. Following the pre-existing examples for `logger_uvicorn` for example. diff --git a/backend/alembic.ini b/backend/alembic.ini index 877c7d00..eef9ad4a 100644 --- a/backend/alembic.ini +++ b/backend/alembic.ini @@ -62,7 +62,7 @@ version_path_separator = os # Use os.pathsep. Default configuration used for ne # output_encoding = utf-8 # Updated in env.py using the POSTGRES_DATABASE_URL environment variable -# sqlalchemy.url = +# sqlalchemy.url = [post_write_hooks] @@ -83,8 +83,10 @@ ruff.executable = %(here)s/.venv/bin/ruff ruff.options = check --fix REVISION_SCRIPT_FILENAME # Logging configuration +# Every time you want to define a new sub-logger, you need to add it to loggers or it won't show up. +# Would recommend just using uvicorn."name of area you want to log" to identify a smaller scope [loggers] -keys = root,sqlalchemy,alembic +keys = root,sqlalchemy,alembic,uvicorn [handlers] keys = console @@ -107,6 +109,11 @@ level = INFO handlers = qualname = alembic +[logger_uvicorn] +level = INFO +handlers = +qualname = uvicorn + [handler_console] class = StreamHandler args = (sys.stderr,) @@ -114,5 +121,5 @@ level = NOTSET formatter = generic [formatter_generic] -format = %(levelname)-5.5s [%(name)s] %(message)s +format = %(levelname)-5.5s [%(name)s] %(message)s datefmt = %H:%M:%S diff --git a/backend/app/interfaces/email_service.py b/backend/app/interfaces/email_service.py index 50d0ab32..346df5f6 100644 --- a/backend/app/interfaces/email_service.py +++ b/backend/app/interfaces/email_service.py @@ -1,5 +1,7 @@ from abc import ABC, abstractmethod +from app.schemas.email_template import EmailContent, EmailTemplateType + class IEmailService(ABC): """ @@ -8,65 +10,20 @@ class IEmailService(ABC): """ @abstractmethod - def send_email(self, to: str, subject: str, body: str) -> dict: - """ - Sends an email with the given parameters. - - :param to: Recipient's email address - :type to: str - :param subject: Subject of the email - :type subject: str - :param body: HTML body content of the email - :type body: str - :return: Provider-specific metadata (like message ID, thread ID, label IDs) - :rtype: dict - :raises Exception: if email was not sent successfully - """ - pass - - @abstractmethod - def send_welcome_email(self, recipient: str, user_name: str) -> dict: - """ - Sends a welcome email to the specified user. - - :param recipient: Email address of the user - :type recipient: str - :param user_name: Name of the user - :type user_name: str - :return: Provider-specific metadata for the sent email - :rtype: dict - :raises Exception: if email was not sent successfully - """ - pass - - @abstractmethod - def send_password_reset_email(self, recipient: str, reset_link: str) -> dict: - """ - Sends a password reset email with the provided reset link. - - :param recipient: Email address of the user requesting the reset - :type recipient: str - :param reset_link: Password reset link - :type reset_link: str - :return: Provider-specific metadata for the sent email - :rtype: dict - :raises Exception: if email was not sent successfully - """ - pass - - @abstractmethod - def send_notification_email(self, recipient: str, notification_text: str) -> dict: - """ - Sends a notification email to the user with the provided notification text. - Examples of use case include matches completed and ready to view, new messages, - meeting time scheduled, etc. - - :param recipient: Email address of the user - :type recipient: str - :param notification_text: The notification content - :type notification_text: str - :return: Provider-specific metadata for the sent email - :rtype: dict - :raises Exception: if email was not sent successfully + def send_email( + self, templateType: EmailTemplateType, content: EmailContent + ) -> dict: + """Send an email using the given template and content with a + respective service provider. + + Args: + templateType (EmailTemplateType): Specifies the template + to be used for the email + content (EmailContent): Contains the recipient and data + to be used in the email + + Returns: + dict: Provider-specific metadata if any + (like message ID, thread ID, label IDs) """ pass diff --git a/backend/app/interfaces/email_service_provider.py b/backend/app/interfaces/email_service_provider.py index edc64170..3f00e122 100644 --- a/backend/app/interfaces/email_service_provider.py +++ b/backend/app/interfaces/email_service_provider.py @@ -1,5 +1,7 @@ from abc import ABC, abstractmethod +from app.schemas.email_template import EmailContent, EmailTemplateType + class IEmailServiceProvider(ABC): """ @@ -9,22 +11,18 @@ class IEmailServiceProvider(ABC): @abstractmethod def send_email( - self, recipient: str, subject: str, body_html: str, body_text: str + self, templateType: EmailTemplateType, content: EmailContent ) -> dict: - """ - Sends an email using the provider's service. + """_summary_ + + Args: + templateType (EmailTemplate): Helps provider determine which + template to use for the given email + content (EmailContent): Contains the recipient and data to be + used in the email - :param recipient: Email address of the recipient - :type recipient: str - :param subject: Subject of the email - :type subject: str - :param body_html: HTML body content of the email - :type body_html: str - :param body_text: Plain text content of the email - :type body_text: str - :return: Provider-specific metadata related to the sent email - (like message ID, status, etc.) - :rtype: dict - :raises Exception: if the email fails to send + Returns: + dict: Provider-specific metadata if any + (like message ID, thread ID, label IDs) """ pass diff --git a/backend/app/models/Matches.py b/backend/app/models/Matches.py new file mode 100644 index 00000000..06d0e7e5 --- /dev/null +++ b/backend/app/models/Matches.py @@ -0,0 +1,46 @@ +import enum +import uuid + +from sqlalchemy import Column, DateTime, Enum, ForeignKey +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func + +from .Base import Base + + +class MatchStatus(enum.Enum): + PENDING_ADMIN_APPROVAL = "pending_admin_approval" + APPROVED = "approved" + REJECTED = "rejected" + + +class ScheduleStatus(enum.Enum): + SCHEDULED = "scheduled" + COMPLETED = "completed" + DECLINED = "declined" # This status is for when a user declines a scheduled match + PENDING_PARTICIPANT_RESPONSE = "pending_participant_response" + PENDING_VOLUNTEER_RESPONSE = "pending_volunteer_response" + CANCELLED = "cancelled" # This status is for when a match is cancelled by an admin + + +class Matches(Base): + __tablename__ = "matches" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + + participant_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) + volunteer_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) + + match_status = Column( + Enum(MatchStatus), nullable=True, default=MatchStatus.PENDING_ADMIN_APPROVAL + ) + schedule_status = Column(Enum(ScheduleStatus), nullable=True) + + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + participant = relationship("User", foreign_keys=[participant_id]) + volunteer = relationship("User", foreign_keys=[volunteer_id]) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index e51f0274..fae30bd4 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,6 +1,10 @@ +import logging + from alembic import command from alembic.config import Config +from app.utilities.constants import LOGGER_NAME + # Make sure all models are here to reflect all current models # when autogenerating new migration from .Base import Base @@ -10,8 +14,12 @@ # Used to avoid import errors for the models __all__ = ["Base", "User", "Role"] +log = logging.getLogger(LOGGER_NAME("models")) + def run_migrations(): + log.info("Running run_migrations in models/__init__ on server startup") + alembic_cfg = Config("alembic.ini") # Emulates `alembic upgrade head` to migrate up to latest revision command.upgrade(alembic_cfg, "head") diff --git a/backend/app/routes/email.py b/backend/app/routes/send_email.py similarity index 51% rename from backend/app/routes/email.py rename to backend/app/routes/send_email.py index f607966f..007867a0 100644 --- a/backend/app/routes/email.py +++ b/backend/app/routes/send_email.py @@ -3,8 +3,11 @@ from fastapi import APIRouter, Depends from app.interfaces.email_service import IEmailService +from app.schemas.email_template import EmailContent, EmailTemplateType, MockEmailData +from app.services.email.amazon_ses_provider import ( + get_email_service_provider, +) from app.services.email.email_service import EmailService -from app.services.email.email_service_provider import AmazonSESEmailProvider router = APIRouter( prefix="/email", @@ -13,16 +16,19 @@ def get_email_service() -> IEmailService: - email_provider = AmazonSESEmailProvider(aws_access_key="", aws_secret_key="") - return EmailService(email_provider) + return EmailService(provider=get_email_service_provider()) # TODO (Mayank, Nov 30th) - Remove test emails once email service is fully implemented -@router.post("/send-test-email/") +@router.post("/send-test") async def send_welcome_email( recipient: str, user_name: str, email_service: Annotated[IEmailService, Depends(get_email_service)], ): - email_service.send_welcome_email(recipient, user_name) - return {"message": f"Welcome email sent to {user_name}!"} + return email_service.send_email( + templateType=EmailTemplateType.TEST, + content=EmailContent[MockEmailData]( + recipient=recipient, data=MockEmailData(name=user_name, date="2021-12-01") + ), + ) diff --git a/backend/app/schemas/email_template.py b/backend/app/schemas/email_template.py new file mode 100644 index 00000000..4cf19ab7 --- /dev/null +++ b/backend/app/schemas/email_template.py @@ -0,0 +1,34 @@ +import json +from abc import ABC +from dataclasses import dataclass +from enum import Enum +from typing import Generic, TypeVar + +EmailData = TypeVar("EmailData") + + +class TemplateData(ABC): + def get_formatted_string(self) -> str: + class_dict = self.__dict__ + try: + formatted_string = json.dumps(class_dict) # Try to convert to a JSON string + except (TypeError, ValueError) as e: + raise Exception(f"Error converting class to JSON: {e}") + + return formatted_string + + +@dataclass +class MockEmailData(TemplateData): + name: str + date: str + + +class EmailTemplateType(Enum): + TEST = "Test" + + +@dataclass +class EmailContent(Generic[EmailData]): + recipient: str + data: EmailData diff --git a/backend/app/server.py b/backend/app/server.py index 94057419..7e29098f 100644 --- a/backend/app/server.py +++ b/backend/app/server.py @@ -5,21 +5,21 @@ from dotenv import load_dotenv from fastapi import FastAPI -from app.routes import email +from . import models +from .routes import send_email, user +from .utilities.constants import LOGGER_NAME +from .utilities.firebase_init import initialize_firebase +from .utilities.ses.ses_init import ensure_ses_templates load_dotenv() -# we need to load env variables before initialization code runs -from . import models # noqa: E402 -from .routes import user # noqa: E402 -from .utilities.firebase_init import initialize_firebase # noqa: E402 - -log = logging.getLogger("uvicorn") +log = logging.getLogger(LOGGER_NAME("server")) @asynccontextmanager async def lifespan(_: FastAPI): log.info("Starting up...") + ensure_ses_templates() models.run_migrations() initialize_firebase() yield @@ -30,12 +30,12 @@ async def lifespan(_: FastAPI): # running-alembic-migrations-on-fastapi-startup app = FastAPI(lifespan=lifespan) app.include_router(user.router) - -app.include_router(email.router) +app.include_router(send_email.router) @app.get("/") def read_root(): + log.info("Hello World") return {"Hello": "World"} diff --git a/backend/app/services/email/amazon_ses_provider.py b/backend/app/services/email/amazon_ses_provider.py new file mode 100644 index 00000000..addde12c --- /dev/null +++ b/backend/app/services/email/amazon_ses_provider.py @@ -0,0 +1,87 @@ +import os + +import boto3 +from botocore.exceptions import ClientError + +from app.interfaces.email_service_provider import IEmailServiceProvider +from app.schemas.email_template import EmailContent, EmailTemplateType + + +class AmazonSESEmailProvider(IEmailServiceProvider): + """Amazon SES Email Provider. + + This class is responsible for sending emails using Amazon SES. + """ + + """ + Args: + aws_access_key (str): AWS Access Key ID + aws_secret_key (str): AWS Secret Access Key + region (str): AWS region where SES is configured + source_email (str): Email address from which the email will be sent + is_sandbox (bool): If True, the amazon provider will only be able to send emails + to previously verified email addresses and domains + """ + + def __init__( + self, + aws_access_key: str, + aws_secret_key: str, + region: str, + source_email: str, + is_sandbox: bool = True, + ): + self.source_email = source_email + self.is_sandbox = is_sandbox + self.ses_client = boto3.client( + "ses", + region_name=region, + aws_access_key_id=aws_access_key, + aws_secret_access_key=aws_secret_key, + ) + + self.verified_emails = None + if self.is_sandbox: + response = self.ses_client.list_verified_email_addresses() + self.verified_emails = response.get("VerifiedEmailAddresses", []) + + def _verify_email(self, email: str, templateType: EmailTemplateType) -> None: + try: + if self.is_sandbox and email not in self.verified_emails: + self.ses_client.verify_email_identity(EmailAddress=email) + print(f"Verification email sent to {email}.") + if self.ses_client.get_template(TemplateName=templateType.value): + print(f"Template {templateType.value} exists.") + except Exception as e: + print(f"Failed to verify email: {e}") + + def send_email( + self, templateType: EmailTemplateType, content: EmailContent + ) -> dict: + try: + self._verify_email(content.recipient, templateType) + + template_data = content.data.get_formatted_string() + + response = self.ses_client.send_templated_email( + Source=self.source_email, + Destination={"ToAddresses": [content.recipient]}, + Template=templateType.value, + TemplateData=template_data, + ) + + return { + "message": "Email sent successfully!", + "message_id": response["MessageId"], + } + except ClientError as e: + return {"error": f"An error occurred: {e.response['Error']['Message']}"} + + +def get_email_service_provider() -> IEmailServiceProvider: + return AmazonSESEmailProvider( + aws_access_key=os.getenv("AWS_ACCESS_KEY"), + aws_secret_key=os.getenv("AWS_SECRET_KEY"), + region=os.getenv("AWS_REGION"), + source_email=os.getenv("SES_SOURCE_EMAIL"), + ) diff --git a/backend/app/services/email/email_service.py b/backend/app/services/email/email_service.py index 9a214237..8ef10b8b 100644 --- a/backend/app/services/email/email_service.py +++ b/backend/app/services/email/email_service.py @@ -1,20 +1,19 @@ +import logging + from app.interfaces.email_service import IEmailService from app.interfaces.email_service_provider import IEmailServiceProvider +from app.schemas.email_template import EmailContent, EmailData, EmailTemplateType -# TODO (Mayank, Nov 30th) - Implement the email service methods and use User object class EmailService(IEmailService): def __init__(self, provider: IEmailServiceProvider): self.provider = provider + self.logger = logging.getLogger(__name__) - def send_email(self, to: str, subject: str, body: str) -> dict: - pass - - def send_welcome_email(self, recipient: str, user_name: str) -> dict: - pass - - def send_password_reset_email(self, recipient: str, reset_link: str) -> dict: - pass - - def send_notification_email(self, recipient: str, notification_text: str) -> dict: - pass + def send_email( + self, templateType: EmailTemplateType, content: EmailContent[EmailData] + ) -> dict: + self.logger.info( + f"Sending email to {content.recipient} with template {templateType.value}" + ) + return self.provider.send_email(templateType, content) diff --git a/backend/app/services/email/email_service_provider.py b/backend/app/services/email/email_service_provider.py deleted file mode 100644 index 81a6609c..00000000 --- a/backend/app/services/email/email_service_provider.py +++ /dev/null @@ -1,12 +0,0 @@ -from app.interfaces.email_service_provider import IEmailServiceProvider - - -class AmazonSESEmailProvider(IEmailServiceProvider): - def __init__(self, aws_access_key: str, aws_secret_key: str): - pass - - # TODO (Mayank, Nov 30th) - Create an email object to pass into this method - def send_email( - self, recipient: str, subject: str, body_html: str, body_text: str - ) -> dict: - pass diff --git a/backend/app/services/email/email_templates/test.html b/backend/app/services/email/email_templates/test.html deleted file mode 100644 index f62d5be6..00000000 --- a/backend/app/services/email/email_templates/test.html +++ /dev/null @@ -1,6 +0,0 @@ - -
-We are glad to have you with us.
- - diff --git a/backend/app/services/implementations/user_service.py b/backend/app/services/implementations/user_service.py index d3081ced..c2148213 100644 --- a/backend/app/services/implementations/user_service.py +++ b/backend/app/services/implementations/user_service.py @@ -12,12 +12,13 @@ UserCreateResponse, UserRole, ) +from app.utilities.constants import LOGGER_NAME class UserService(IUserService): def __init__(self, db: Session): self.db = db - self.logger = logging.getLogger(__name__) + self.logger = logging.getLogger(LOGGER_NAME("user_service")) async def create_user(self, user: UserCreateRequest) -> UserCreateResponse: firebase_user = None diff --git a/backend/app/utilities/constants.py b/backend/app/utilities/constants.py new file mode 100644 index 00000000..eeedcb93 --- /dev/null +++ b/backend/app/utilities/constants.py @@ -0,0 +1,5 @@ +SERVER_LOGGER_NAME = "uvicorn" + + +def LOGGER_NAME(name: str): + return f"{SERVER_LOGGER_NAME}.{name}" diff --git a/backend/app/utilities/firebase_init.py b/backend/app/utilities/firebase_init.py index 7ebc8091..af9426e0 100644 --- a/backend/app/utilities/firebase_init.py +++ b/backend/app/utilities/firebase_init.py @@ -1,11 +1,19 @@ +import logging import os import firebase_admin from firebase_admin import credentials +from app.utilities.constants import LOGGER_NAME + +log = logging.getLogger(LOGGER_NAME("firebase_init")) + def initialize_firebase(): + log.info("Running initialize_firebase") cwd = os.getcwd() service_account_path = os.path.join(cwd, "serviceAccountKey.json") cred = credentials.Certificate(service_account_path) + firebase_admin.initialize_app(cred) + log.info("Finished initializing firebase") diff --git a/backend/app/utilities/ses/ses_init.py b/backend/app/utilities/ses/ses_init.py new file mode 100644 index 00000000..d4279328 --- /dev/null +++ b/backend/app/utilities/ses/ses_init.py @@ -0,0 +1,81 @@ +import json +import os +from typing import Dict + +import boto3 +from botocore.exceptions import ClientError + +TEMPLATES_FILE = "app/utilities/ses/ses_templates.json" +TEMPLATES_DIR = "app/utilities/ses/template_files" + +ses_client = boto3.client( + "ses", + region_name=os.getenv("AWS_REGION"), + aws_access_key_id=os.getenv("AWS_ACCESS_KEY"), + aws_secret_access_key=os.getenv("AWS_SECRET_KEY"), +) + + +def load_templates_metadata(file_path: str) -> Dict: + try: + with open(file_path, "r") as file: + return json.load(file) + except FileNotFoundError: + print(f"Error: {file_path} not found.") + return [] + except json.JSONDecodeError as e: + print(f"Error parsing {file_path}: {e}") + return [] + + +def load_file_content(file_path: str) -> str: + """Reads the content of a file.""" + try: + with open(file_path, "r") as file: + return file.read() + except FileNotFoundError: + print(f"Error: File '{file_path}' not found.") + return "" + + +# Function to create SES template +def create_ses_template(template_metadata): + name = template_metadata["TemplateName"] + try: + text_part = load_file_content(template_metadata["TextPart"]) + html_part = load_file_content(template_metadata["HtmlPart"]) + if not text_part or not html_part: + print(f"Skipping template '{name}' missing content.") + return + + template = { + "TemplateName": template_metadata["TemplateName"], + "SubjectPart": template_metadata["SubjectPart"], + "TextPart": text_part, + "HtmlPart": html_part, + } + ses_client.create_template(Template=template) + print(f"SES template '{name}' created successfully!") + except ClientError as e: + if e.response["Error"]["Code"] == "TemplateAlreadyExists": + print(f"SES template '{name}' already exists.") + else: + print(f"An error occurred while creating the SES template: {e}") + + +# Ensure SES templates are available at app startup +def ensure_ses_templates(): + templates_metadata = load_templates_metadata(TEMPLATES_FILE) + + for template_metadata in templates_metadata: + name = template_metadata["TemplateName"] + try: + # Check if the template exists + ses_client.get_template(TemplateName=name) + print(f"SES template '{name}' already exists.") + except ClientError as e: + if e.response["Error"]["Code"] == "TemplateDoesNotExist": + print(f"SES template '{name}' does not exist. Creating template...") + create_ses_template(template_metadata) + else: + print(f"An error occurred while checking the SES template: {e}") diff --git a/backend/app/utilities/ses/ses_templates.json b/backend/app/utilities/ses/ses_templates.json new file mode 100644 index 00000000..394b3052 --- /dev/null +++ b/backend/app/utilities/ses/ses_templates.json @@ -0,0 +1,8 @@ +[ + { + "HtmlPart": "app/utilities/ses/template_files/test.html", + "SubjectPart": "Testing Email SES Template", + "TemplateName": "Test", + "TextPart": "app/utilities/ses/template_files/test.txt" + } +] diff --git a/backend/app/utilities/ses/template_files/test.html b/backend/app/utilities/ses/template_files/test.html new file mode 100644 index 00000000..3ad7bf1d --- /dev/null +++ b/backend/app/utilities/ses/template_files/test.html @@ -0,0 +1,6 @@ + + +We are glad to have you with us. Thank you for joining us on {{date}}.
+ + diff --git a/backend/app/utilities/ses/template_files/test.txt b/backend/app/utilities/ses/template_files/test.txt new file mode 100644 index 00000000..0707fbc3 --- /dev/null +++ b/backend/app/utilities/ses/template_files/test.txt @@ -0,0 +1,3 @@ +Welcome, {{name}}! + +We are glad to have you with us. Thank you for joining us on {{date}}. diff --git a/backend/migrations/env.py b/backend/migrations/env.py index cb165d2d..806a9598 100644 --- a/backend/migrations/env.py +++ b/backend/migrations/env.py @@ -1,3 +1,4 @@ +import logging import os from logging.config import fileConfig @@ -9,20 +10,25 @@ load_dotenv() +log = logging.getLogger("alembic") +log.info("Entering env.py for alembic migration") # this is the Alembic Config object, which provides # access to the values within the .ini file in use. config = context.config +log.info("Finished setting up alembic config object") # 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) + fileConfig(config.config_file_name, disable_existing_loggers=False) # add your model's MetaData object here # for 'autogenerate' support # from myapp import mymodel # target_metadata = mymodel.Base.metadata +log.info("Pulling model metadata") + target_metadata = Base.metadata # other values from the config, defined by the needs of env.py, @@ -30,6 +36,7 @@ # my_important_option = config.get_main_option("my_important_option") # ... etc. config.set_main_option("sqlalchemy.url", os.environ["POSTGRES_DATABASE_URL"]) +log.info("Finished migration env config setup") def run_migrations_offline() -> None: @@ -69,13 +76,19 @@ def run_migrations_online() -> None: prefix="sqlalchemy.", poolclass=pool.NullPool, ) + try: + log.info("Established database connection") + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) - with connectable.connect() as connection: - context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + log.info("Finished running migrations in alembic env") + except Exception as e: + log.error(e) - with context.begin_transaction(): - context.run_migrations() +log.info("Starting up migration env") if context.is_offline_mode(): run_migrations_offline() diff --git a/backend/pdm.lock b/backend/pdm.lock index 33dad4d6..fd86902e 100644 --- a/backend/pdm.lock +++ b/backend/pdm.lock @@ -5,7 +5,7 @@ groups = ["default"] strategy = ["inherit_metadata"] lock_version = "4.5.0" -content_hash = "sha256:44d434f159ee5f2f6461bd1b1e4b2c919f3bc85586e3cc4c17f30b788232373c" +content_hash = "sha256:25df3f1d1ac88503375e4065bbb639f76ed00694c3c838384908755aba2ad608" [[metadata.targets]] requires_python = "==3.12.*" @@ -59,6 +59,39 @@ files = [ {file = "anyio-4.6.0.tar.gz", hash = "sha256:137b4559cbb034c477165047febb6ff83f390fc3b20bf181c1fc0a728cb8beeb"}, ] +[[package]] +name = "boto3" +version = "1.35.71" +requires_python = ">=3.8" +summary = "The AWS SDK for Python" +groups = ["default"] +dependencies = [ + "botocore<1.36.0,>=1.35.71", + "jmespath<2.0.0,>=0.7.1", + "s3transfer<0.11.0,>=0.10.0", +] +files = [ + {file = "boto3-1.35.71-py3-none-any.whl", hash = "sha256:e2969a246bb3208122b3c349c49cc6604c6fc3fc2b2f65d99d3e8ccd745b0c16"}, + {file = "boto3-1.35.71.tar.gz", hash = "sha256:3ed7172b3d4fceb6218bb0ec3668c4d40c03690939c2fca4f22bb875d741a07f"}, +] + +[[package]] +name = "botocore" +version = "1.35.71" +requires_python = ">=3.8" +summary = "Low-level, data-driven core of boto 3." +groups = ["default"] +dependencies = [ + "jmespath<2.0.0,>=0.7.1", + "python-dateutil<3.0.0,>=2.1", + "urllib3!=2.2.0,<3,>=1.25.4; python_version >= \"3.10\"", + "urllib3<1.27,>=1.25.4; python_version < \"3.10\"", +] +files = [ + {file = "botocore-1.35.71-py3-none-any.whl", hash = "sha256:fc46e7ab1df3cef66dfba1633f4da77c75e07365b36f03bd64a3793634be8fc1"}, + {file = "botocore-1.35.71.tar.gz", hash = "sha256:f9fa058e0393660c3fe53c1e044751beb64b586def0bd2212448a7c328b0cbba"}, +] + [[package]] name = "cachecontrol" version = "0.14.0" @@ -731,6 +764,17 @@ files = [ {file = "jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369"}, ] +[[package]] +name = "jmespath" +version = "1.0.1" +requires_python = ">=3.7" +summary = "JSON Matching Expressions" +groups = ["default"] +files = [ + {file = "jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980"}, + {file = "jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe"}, +] + [[package]] name = "mako" version = "1.3.5" @@ -1078,6 +1122,34 @@ files = [ {file = "pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181"}, ] +[[package]] +name = "pytest-asyncio" +version = "0.25.3" +requires_python = ">=3.9" +summary = "Pytest support for asyncio" +groups = ["default"] +dependencies = [ + "pytest<9,>=8.2", +] +files = [ + {file = "pytest_asyncio-0.25.3-py3-none-any.whl", hash = "sha256:9e89518e0f9bd08928f97a3482fdc4e244df17529460bc038291ccaf8f85c7c3"}, + {file = "pytest_asyncio-0.25.3.tar.gz", hash = "sha256:fc1da2cf9f125ada7e710b4ddad05518d4cee187ae9412e9ac9271003497f07a"}, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +summary = "Extensions to the standard Python datetime module" +groups = ["default"] +dependencies = [ + "six>=1.5", +] +files = [ + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, +] + [[package]] name = "python-dotenv" version = "1.0.1" @@ -1193,6 +1265,20 @@ files = [ {file = "ruff-0.6.7.tar.gz", hash = "sha256:44e52129d82266fa59b587e2cd74def5637b730a69c4542525dfdecfaae38bd5"}, ] +[[package]] +name = "s3transfer" +version = "0.10.4" +requires_python = ">=3.8" +summary = "An Amazon S3 Transfer Manager" +groups = ["default"] +dependencies = [ + "botocore<2.0a.0,>=1.33.2", +] +files = [ + {file = "s3transfer-0.10.4-py3-none-any.whl", hash = "sha256:244a76a24355363a68164241438de1b72f8781664920260c48465896b712a41e"}, + {file = "s3transfer-0.10.4.tar.gz", hash = "sha256:29edc09801743c21eb5ecbc617a152df41d3c287f67b615f73e5f750583666a7"}, +] + [[package]] name = "shellingham" version = "1.5.4" @@ -1204,6 +1290,17 @@ files = [ {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, ] +[[package]] +name = "six" +version = "1.16.0" +requires_python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +summary = "Python 2 and 3 compatibility utilities" +groups = ["default"] +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] + [[package]] name = "sniffio" version = "1.3.1" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 9ccef99d..51f1635d 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -17,6 +17,8 @@ dependencies = [ "inflection>=0.5.1", "pre-commit>=4.0.0", "psycopg2>=2.9.9", + "boto3>=1.35.71", + "pytest-asyncio>=0.25.3", ] requires-python = "==3.12.*" readme = "README.md" @@ -30,8 +32,13 @@ distribution = false dev = "fastapi dev app/server.py" precommit = "pre-commit run" precommit-install = "pre-commit install" +dc-down = "docker-compose down -v" +dc-up = "docker-compose up -d" +docker-db = {composite = ["dc-down", "dc-up"]} +db-dev = {composite = ["docker-db", "dev"]} revision = "alembic revision --autogenerate" upgrade = "alembic upgrade head" +tests = "pytest -v" [tool.pytest.ini_options] asyncio_mode = "strict" diff --git a/backend/test.db b/backend/test.db index b75c4f91..23b7fbeb 100644 Binary files a/backend/test.db and b/backend/test.db differ diff --git a/backend/tests/unit/test_email_provider.py b/backend/tests/unit/test_email_provider.py new file mode 100644 index 00000000..4dc273a6 --- /dev/null +++ b/backend/tests/unit/test_email_provider.py @@ -0,0 +1,81 @@ +import pytest +from botocore.exceptions import ClientError + +from app.schemas.email_template import EmailContent, EmailTemplateType, MockEmailData +from app.services.email.amazon_ses_provider import AmazonSESEmailProvider + +# Language: python + + +# Fake SES client to simulate boto3 SES client behavior. +class FakeSESClient: + def list_verified_email_addresses(self): + return {"VerifiedEmailAddresses": ["verified@example.com"]} + + def send_templated_email(self, **kwargs): + return {"MessageId": "abc123"} + + def verify_email_identity(self, EmailAddress): + # Simulate sending verification email + print(f"Verification email sent to {EmailAddress}.") + + def get_template(self, TemplateName): + # Simulate that template exists + return True + + +@pytest.fixture +def fake_ses_client(): + return FakeSESClient() + + +@pytest.fixture +def provider(monkeypatch, fake_ses_client): + # Patch boto3.client so that when AmazonSESEmailProvider is instantiated, + # it uses our fake SES client. + monkeypatch.setattr( + "app.services.email.amazon_ses_provider.boto3.client", + lambda service, **kwargs: fake_ses_client, + ) + + # Instantiate provider with sandbox mode enabled. + provider = AmazonSESEmailProvider( + aws_access_key="fake", + aws_secret_key="fake", + region="us-east-1", + source_email="source@example.com", + is_sandbox=True, + ) + return provider + + +def test_send_email_success(provider): + # Create dummy email content with a verified recipient. + test_data = MockEmailData(name="User", date="2021-12-01") + email_content = EmailContent[MockEmailData]( + recipient="verified@example.com", data=test_data + ) + + response = provider.send_email(EmailTemplateType.TEST, email_content) + + assert response["message"] == "Email sent successfully!" + assert response["message_id"] == "abc123" + + +def test_send_email_failure(provider): + # Simulate failure in send_templated_email by raising ClientError. + def fake_send_templated_email(**kwargs): + raise ClientError( + {"Error": {"Message": "Failed to send email"}}, "send_templated_email" + ) + + provider.ses_client.send_templated_email = fake_send_templated_email + + test_data = MockEmailData(name="User", date="2021-12-01") + email_content = EmailContent[MockEmailData]( + recipient="verified@example.com", data=test_data + ) + + response = provider.send_email(EmailTemplateType.TEST, email_content) + assert "error" in response + assert "Failed to send email" in response["error"] diff --git a/frontend/.eslintrc.json b/frontend/.eslintrc.json index d9cdaf7b..37224185 100644 --- a/frontend/.eslintrc.json +++ b/frontend/.eslintrc.json @@ -1,3 +1,3 @@ { "extends": ["next/core-web-vitals", "next/typescript"] -} \ No newline at end of file +} diff --git a/frontend/.prettierignore b/frontend/.prettierignore new file mode 100644 index 00000000..1b8ac889 --- /dev/null +++ b/frontend/.prettierignore @@ -0,0 +1,3 @@ +# Ignore artifacts: +build +coverage diff --git a/frontend/.prettierrc b/frontend/.prettierrc new file mode 100644 index 00000000..c3fdff80 --- /dev/null +++ b/frontend/.prettierrc @@ -0,0 +1,10 @@ +{ + "semi": true, + "singleQuote": true, + "trailingComma": "all", + "tabWidth": 2, + "printWidth": 100, + "arrowParens": "always", + "bracketSpacing": true, + "endOfLine": "lf" +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1e66f9d6..d1967c61 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,7 +8,7 @@ "name": "frontend", "version": "0.1.0", "dependencies": { - "next": "14.2.13", + "next": "^14.2.24", "react": "^18", "react-dom": "^18" }, @@ -235,10 +235,9 @@ } }, "node_modules/@next/env": { - "version": "14.2.13", - "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.13.tgz", - "integrity": "sha512-s3lh6K8cbW1h5Nga7NNeXrbe0+2jIIYK9YaA9T7IufDWnZpozdFUp6Hf0d5rNWUKu4fEuSX2rCKlGjCrtylfDw==", - "license": "MIT" + "version": "14.2.24", + "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.24.tgz", + "integrity": "sha512-LAm0Is2KHTNT6IT16lxT+suD0u+VVfYNQqM+EJTKuFRRuY2z+zj01kueWXPCxbMBDt0B5vONYzabHGUNbZYAhA==" }, "node_modules/@next/eslint-plugin-next": { "version": "14.2.13", @@ -251,13 +250,12 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "14.2.13", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.13.tgz", - "integrity": "sha512-IkAmQEa2Htq+wHACBxOsslt+jMoV3msvxCn0WFSfJSkv/scy+i/EukBKNad36grRxywaXUYJc9mxEGkeIs8Bzg==", + "version": "14.2.24", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.24.tgz", + "integrity": "sha512-7Tdi13aojnAZGpapVU6meVSpNzgrFwZ8joDcNS8cJVNuP3zqqrLqeory9Xec5TJZR/stsGJdfwo8KeyloT3+rQ==", "cpu": [ "arm64" ], - "license": "MIT", "optional": true, "os": [ "darwin" @@ -267,13 +265,12 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "14.2.13", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.13.tgz", - "integrity": "sha512-Dv1RBGs2TTjkwEnFMVL5XIfJEavnLqqwYSD6LXgTPdEy/u6FlSrLBSSfe1pcfqhFEXRAgVL3Wpjibe5wXJzWog==", + "version": "14.2.24", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.24.tgz", + "integrity": "sha512-lXR2WQqUtu69l5JMdTwSvQUkdqAhEWOqJEYUQ21QczQsAlNOW2kWZCucA6b3EXmPbcvmHB1kSZDua/713d52xg==", "cpu": [ "x64" ], - "license": "MIT", "optional": true, "os": [ "darwin" @@ -283,13 +280,12 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "14.2.13", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.13.tgz", - "integrity": "sha512-yB1tYEFFqo4ZNWkwrJultbsw7NPAAxlPXURXioRl9SdW6aIefOLS+0TEsKrWBtbJ9moTDgU3HRILL6QBQnMevg==", + "version": "14.2.24", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.24.tgz", + "integrity": "sha512-nxvJgWOpSNmzidYvvGDfXwxkijb6hL9+cjZx1PVG6urr2h2jUqBALkKjT7kpfurRWicK6hFOvarmaWsINT1hnA==", "cpu": [ "arm64" ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -299,13 +295,12 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "14.2.13", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.13.tgz", - "integrity": "sha512-v5jZ/FV/eHGoWhMKYrsAweQ7CWb8xsWGM/8m1mwwZQ/sutJjoFaXchwK4pX8NqwImILEvQmZWyb8pPTcP7htWg==", + "version": "14.2.24", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.24.tgz", + "integrity": "sha512-PaBgOPhqa4Abxa3y/P92F3kklNPsiFjcjldQGT7kFmiY5nuFn8ClBEoX8GIpqU1ODP2y8P6hio6vTomx2Vy0UQ==", "cpu": [ "arm64" ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -315,13 +310,12 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "14.2.13", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.13.tgz", - "integrity": "sha512-aVc7m4YL7ViiRv7SOXK3RplXzOEe/qQzRA5R2vpXboHABs3w8vtFslGTz+5tKiQzWUmTmBNVW0UQdhkKRORmGA==", + "version": "14.2.24", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.24.tgz", + "integrity": "sha512-vEbyadiRI7GOr94hd2AB15LFVgcJZQWu7Cdi9cWjCMeCiUsHWA0U5BkGPuoYRnTxTn0HacuMb9NeAmStfBCLoQ==", "cpu": [ "x64" ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -331,13 +325,12 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "14.2.13", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.13.tgz", - "integrity": "sha512-4wWY7/OsSaJOOKvMsu1Teylku7vKyTuocvDLTZQq0TYv9OjiYYWt63PiE1nTuZnqQ4RPvME7Xai+9enoiN0Wrg==", + "version": "14.2.24", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.24.tgz", + "integrity": "sha512-df0FC9ptaYsd8nQCINCzFtDWtko8PNRTAU0/+d7hy47E0oC17tI54U/0NdGk7l/76jz1J377dvRjmt6IUdkpzQ==", "cpu": [ "x64" ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -347,13 +340,12 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "14.2.13", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.13.tgz", - "integrity": "sha512-uP1XkqCqV2NVH9+g2sC7qIw+w2tRbcMiXFEbMihkQ8B1+V6m28sshBwAB0SDmOe0u44ne1vFU66+gx/28RsBVQ==", + "version": "14.2.24", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.24.tgz", + "integrity": "sha512-ZEntbLjeYAJ286eAqbxpZHhDFYpYjArotQ+/TW9j7UROh0DUmX7wYDGtsTPpfCV8V+UoqHBPU7q9D4nDNH014Q==", "cpu": [ "arm64" ], - "license": "MIT", "optional": true, "os": [ "win32" @@ -363,13 +355,12 @@ } }, "node_modules/@next/swc-win32-ia32-msvc": { - "version": "14.2.13", - "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.13.tgz", - "integrity": "sha512-V26ezyjPqQpDBV4lcWIh8B/QICQ4v+M5Bo9ykLN+sqeKKBxJVDpEc6biDVyluTXTC40f5IqCU0ttth7Es2ZuMw==", + "version": "14.2.24", + "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.24.tgz", + "integrity": "sha512-9KuS+XUXM3T6v7leeWU0erpJ6NsFIwiTFD5nzNg8J5uo/DMIPvCp3L1Ao5HjbHX0gkWPB1VrKoo/Il4F0cGK2Q==", "cpu": [ "ia32" ], - "license": "MIT", "optional": true, "os": [ "win32" @@ -379,13 +370,12 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "14.2.13", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.13.tgz", - "integrity": "sha512-WwzOEAFBGhlDHE5Z73mNU8CO8mqMNLqaG+AO9ETmzdCQlJhVtWZnOl2+rqgVQS+YHunjOWptdFmNfbpwcUuEsw==", + "version": "14.2.24", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.24.tgz", + "integrity": "sha512-cXcJ2+x0fXQ2CntaE00d7uUH+u1Bfp/E0HsNQH79YiLaZE5Rbm7dZzyAYccn3uICM7mw+DxoMqEfGXZtF4Fgaw==", "cpu": [ "x64" ], - "license": "MIT", "optional": true, "os": [ "win32" @@ -1277,11 +1267,10 @@ "license": "MIT" }, "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, - "license": "MIT", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -3445,16 +3434,15 @@ } }, "node_modules/nanoid": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", - "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", + "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/ai" } ], - "license": "MIT", "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -3470,12 +3458,11 @@ "license": "MIT" }, "node_modules/next": { - "version": "14.2.13", - "resolved": "https://registry.npmjs.org/next/-/next-14.2.13.tgz", - "integrity": "sha512-BseY9YNw8QJSwLYD7hlZzl6QVDoSFHL/URN5K64kVEVpCsSOWeyjbIGK+dZUaRViHTaMQX8aqmnn0PHBbGZezg==", - "license": "MIT", + "version": "14.2.24", + "resolved": "https://registry.npmjs.org/next/-/next-14.2.24.tgz", + "integrity": "sha512-En8VEexSJ0Py2FfVnRRh8gtERwDRaJGNvsvad47ShkC2Yi8AXQPXEA2vKoDJlGFSj5WE5SyF21zNi4M5gyi+SQ==", "dependencies": { - "@next/env": "14.2.13", + "@next/env": "14.2.24", "@swc/helpers": "0.5.5", "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", @@ -3490,15 +3477,15 @@ "node": ">=18.17.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "14.2.13", - "@next/swc-darwin-x64": "14.2.13", - "@next/swc-linux-arm64-gnu": "14.2.13", - "@next/swc-linux-arm64-musl": "14.2.13", - "@next/swc-linux-x64-gnu": "14.2.13", - "@next/swc-linux-x64-musl": "14.2.13", - "@next/swc-win32-arm64-msvc": "14.2.13", - "@next/swc-win32-ia32-msvc": "14.2.13", - "@next/swc-win32-x64-msvc": "14.2.13" + "@next/swc-darwin-arm64": "14.2.24", + "@next/swc-darwin-x64": "14.2.24", + "@next/swc-linux-arm64-gnu": "14.2.24", + "@next/swc-linux-arm64-musl": "14.2.24", + "@next/swc-linux-x64-gnu": "14.2.24", + "@next/swc-linux-x64-musl": "14.2.24", + "@next/swc-win32-arm64-msvc": "14.2.24", + "@next/swc-win32-ia32-msvc": "14.2.24", + "@next/swc-win32-x64-msvc": "14.2.24" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", diff --git a/frontend/package.json b/frontend/package.json index 24872426..26fea87b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,18 +9,18 @@ "lint": "next lint" }, "dependencies": { + "next": "^14.2.24", "react": "^18", - "react-dom": "^18", - "next": "14.2.13" + "react-dom": "^18" }, "devDependencies": { - "typescript": "^5", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", + "eslint": "^8", + "eslint-config-next": "14.2.13", "postcss": "^8", "tailwindcss": "^3.4.1", - "eslint": "^8", - "eslint-config-next": "14.2.13" + "typescript": "^5" } } diff --git a/frontend/src/pages/fonts/GeistMonoVF.woff b/frontend/public/fonts/GeistMonoVF.woff similarity index 100% rename from frontend/src/pages/fonts/GeistMonoVF.woff rename to frontend/public/fonts/GeistMonoVF.woff diff --git a/frontend/src/pages/fonts/GeistVF.woff b/frontend/public/fonts/GeistVF.woff similarity index 100% rename from frontend/src/pages/fonts/GeistVF.woff rename to frontend/public/fonts/GeistVF.woff diff --git a/frontend/src/pages/_app.tsx b/frontend/src/pages/_app.tsx index a7a790fb..c14313e8 100644 --- a/frontend/src/pages/_app.tsx +++ b/frontend/src/pages/_app.tsx @@ -1,5 +1,5 @@ -import "@/styles/globals.css"; -import type { AppProps } from "next/app"; +import '@/styles/globals.css'; +import type { AppProps } from 'next/app'; export default function App({ Component, pageProps }: AppProps) { return
src/pages/index.tsx
diff --git a/frontend/src/setupTests.ts b/frontend/src/setupTests.ts
index 1dd407a6..8f2609b7 100644
--- a/frontend/src/setupTests.ts
+++ b/frontend/src/setupTests.ts
@@ -2,4 +2,4 @@
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
-import "@testing-library/jest-dom";
+import '@testing-library/jest-dom';
diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts
index 021c3937..4dcc94ff 100644
--- a/frontend/tailwind.config.ts
+++ b/frontend/tailwind.config.ts
@@ -1,16 +1,16 @@
-import type { Config } from "tailwindcss";
+import type { Config } from 'tailwindcss';
const config: Config = {
content: [
- "./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
- "./src/components/**/*.{js,ts,jsx,tsx,mdx}",
- "./src/app/**/*.{js,ts,jsx,tsx,mdx}",
+ './src/pages/**/*.{js,ts,jsx,tsx,mdx}',
+ './src/components/**/*.{js,ts,jsx,tsx,mdx}',
+ './src/app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
colors: {
- background: "var(--background)",
- foreground: "var(--foreground)",
+ background: 'var(--background)',
+ foreground: 'var(--foreground)',
},
},
},