Compare commits

..

7 Commits

Author SHA1 Message Date
James Pattinson
e1659c07ea Arch changes and feature flags 2025-11-23 15:46:51 +00:00
James Pattinson
6f1d09cd77 Using alembic 2025-11-22 21:18:43 +00:00
James Pattinson
b8f2d12011 Square enhancements 2025-11-13 17:41:28 +00:00
James Pattinson
dac8b43915 Event editing 2025-11-12 21:04:00 +00:00
James Pattinson
9edfe6aa62 Layout tweaks 2025-11-12 20:55:24 +00:00
James Pattinson
107c208746 Basic event management 2025-11-12 18:08:11 +00:00
James Pattinson
e5fdd0ecb8 Mobile improvements 2025-11-12 16:47:21 +00:00
38 changed files with 2919 additions and 620 deletions

View File

@@ -104,7 +104,7 @@ docker-compose logs -f
**Admin**: admin@swanseaairport.org / admin123 **Admin**: admin@swanseaairport.org / admin123
**Database**: membership_user / SecureMembershipPass2024! **Database**: Configured via environment variables (see .env file)
## What's Next ## What's Next

View File

@@ -78,11 +78,11 @@ docker-compose up -d --build
# Check status # Check status
docker-compose ps docker-compose ps
# Access MySQL CLI # Access MySQL CLI (using environment variables)
docker exec -it membership_mysql mysql -u membership_user -pSecureMembershipPass2024! membership_db docker exec -it membership_mysql mysql -u "${DATABASE_USER}" -p"${DATABASE_PASSWORD}" "${DATABASE_NAME}"
# Create database backup # Create database backup
docker exec membership_mysql mysqldump -u membership_user -pSecureMembershipPass2024! membership_db > backup_$(date +%Y%m%d_%H%M%S).sql docker exec membership_mysql mysqldump -u "${DATABASE_USER}" -p"${DATABASE_PASSWORD}" "${DATABASE_NAME}" > backup_$(date +%Y%m%d_%H%M%S).sql
``` ```
## Default Admin Access ## Default Admin Access

View File

@@ -25,6 +25,11 @@ A comprehensive membership management system built with FastAPI, MySQL, and Dock
``` ```
membership/ membership/
├── backend/ ├── backend/
│ ├── alembic/ # Database migration scripts
│ │ ├── versions/ # Migration files
│ │ ├── env.py # Migration environment
│ │ └── script.py.mako # Migration template
│ ├── alembic.ini # Alembic configuration
│ ├── app/ │ ├── app/
│ │ ├── api/ │ │ ├── api/
│ │ │ ├── v1/ │ │ │ ├── v1/
@@ -46,7 +51,7 @@ membership/
│ ├── Dockerfile │ ├── Dockerfile
│ └── requirements.txt │ └── requirements.txt
├── database/ ├── database/
│ └── init.sql # Database initialization │ └── init.sql # Legacy database initialization (deprecated - use Alembic migrations)
├── docker-compose.yml ├── docker-compose.yml
├── .env.example ├── .env.example
└── README.md └── README.md
@@ -205,16 +210,36 @@ docker-compose ps
### Database Operations ### Database Operations
```bash ```bash
# Access MySQL CLI # Access MySQL CLI (using environment variables)
docker exec -it membership_mysql mysql -u membership_user -pSecureMembershipPass2024! membership_db docker exec -it membership_mysql mysql -u "${DATABASE_USER}" -p"${DATABASE_PASSWORD}" "${DATABASE_NAME}"
# Create backup # Create backup
docker exec membership_mysql mysqldump -u membership_user -pSecureMembershipPass2024! membership_db > backup_$(date +%Y%m%d_%H%M%S).sql docker exec membership_mysql mysqldump -u "${DATABASE_USER}" -p"${DATABASE_PASSWORD}" "${DATABASE_NAME}" > backup_$(date +%Y%m%d_%H%M%S).sql
# Restore database # Restore database
docker exec -i membership_mysql mysql -u membership_user -pSecureMembershipPass2024! membership_db < backup.sql docker exec -i membership_mysql mysql -u "${DATABASE_USER}" -p"${DATABASE_PASSWORD}" "${DATABASE_NAME}" < backup.sql
``` ```
### Database Migrations
The application uses Alembic for database schema migrations. Migrations are automatically run when the backend container starts.
```bash
# Create a new migration (after making model changes)
sudo docker compose exec backend alembic revision --autogenerate -m "Description of changes"
# Apply migrations
sudo docker compose exec backend alembic upgrade head
# View migration status
sudo docker compose exec backend alembic current
# View migration history
sudo docker compose exec backend alembic history
```
**Note**: The `database/init.sql` file is deprecated. All schema changes should now be made through Alembic migrations.
## API Testing ## API Testing
You can use the interactive API documentation at http://localhost:8000/docs to test endpoints: You can use the interactive API documentation at http://localhost:8000/docs to test endpoints:

View File

@@ -141,7 +141,7 @@ docker-compose exec backend pip list | grep square
docker-compose exec backend python -c "from app.core.config import settings; print(settings.SQUARE_ENVIRONMENT)" docker-compose exec backend python -c "from app.core.config import settings; print(settings.SQUARE_ENVIRONMENT)"
# Check database has payments # Check database has payments
docker-compose exec mysql mysql -u membership_user -p -e "SELECT * FROM membership_db.payments LIMIT 5;" docker-compose exec mysql mysql -u "${DATABASE_USER}" -p -e "SELECT * FROM ${DATABASE_NAME}.payments LIMIT 5;"
# Check frontend files # Check frontend files
ls -la frontend/src/components/SquarePayment.tsx ls -la frontend/src/components/SquarePayment.tsx

118
backend/alembic.ini Normal file
View File

@@ -0,0 +1,118 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
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.
# 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 = os # Use os.pathsep. Default configuration used for new projects.
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# Database URL - will be overridden by environment variables in production
# sqlalchemy.url = mysql+pymysql://username:password@host:port/database
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 = --fix REVISION_SCRIPT_FILENAME
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
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
backend/alembic/README Normal file
View File

@@ -0,0 +1 @@
Generic single-database configuration.

97
backend/alembic/env.py Normal file
View File

@@ -0,0 +1,97 @@
from logging.config import fileConfig
import os
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
from app.models.models import Base
target_metadata = Base.metadata
# Set database URL from environment variables if available
def get_database_url():
"""Get database URL from environment variables or config"""
# Try to get from environment variables first
db_host = os.getenv("DATABASE_HOST")
db_port = os.getenv("DATABASE_PORT", "3306")
db_user = os.getenv("DATABASE_USER")
db_password = os.getenv("DATABASE_PASSWORD")
db_name = os.getenv("DATABASE_NAME")
if all([db_host, db_user, db_password, db_name]):
return f"mysql+pymysql://{db_user}:{db_password}@{db_host}:{db_port}/{db_name}"
# Fallback to config file
return config.get_main_option("sqlalchemy.url")
# Set the database URL
config.set_main_option("sqlalchemy.url", get_database_url())
# 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 = get_database_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()

View File

@@ -0,0 +1,26 @@
"""${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:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,300 @@
"""Create initial database schema from scratch
Revision ID: b583fd2cf202
Revises: f9fbfa70654e
Create Date: 2025-11-22 21:02:08.977255
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'b583fd2cf202'
down_revision: Union[str, None] = 'f9fbfa70654e'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Create tables in dependency order (no foreign keys first, then with foreign keys)
# Users table (no dependencies)
op.create_table('users',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('email', sa.String(length=255), nullable=False),
sa.Column('hashed_password', sa.String(length=255), nullable=False),
sa.Column('first_name', sa.String(length=100), nullable=False),
sa.Column('last_name', sa.String(length=100), nullable=False),
sa.Column('phone', sa.String(length=20), nullable=True),
sa.Column('address', sa.Text(), nullable=True),
sa.Column('role', sa.Enum('member', 'admin', 'super_admin', name='userrole'), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('last_login', sa.DateTime(), nullable=True),
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)
# Membership tiers table (no dependencies)
op.create_table('membership_tiers',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('annual_fee', sa.Float(), nullable=False),
sa.Column('benefits', sa.Text(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_membership_tiers_id'), 'membership_tiers', ['id'], unique=False)
op.create_index('ix_membership_tiers_name', 'membership_tiers', ['name'], unique=True)
# Volunteer roles table (no dependencies)
op.create_table('volunteer_roles',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_volunteer_roles_id'), 'volunteer_roles', ['id'], unique=False)
# Email templates table (no dependencies)
op.create_table('email_templates',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('template_key', sa.String(length=100), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('subject', sa.String(length=255), nullable=False),
sa.Column('html_body', sa.Text(), nullable=False),
sa.Column('text_body', sa.Text(), nullable=True),
sa.Column('variables', sa.Text(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_email_templates_id'), 'email_templates', ['id'], unique=False)
op.create_index(op.f('ix_email_templates_template_key'), 'email_templates', ['template_key'], unique=True)
# Email bounces table (no dependencies)
op.create_table('email_bounces',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('email', sa.String(length=255), nullable=False),
sa.Column('bounce_type', sa.Enum('hard', 'soft', 'complaint', 'unsubscribe', name='bouncetype'), nullable=False),
sa.Column('bounce_reason', sa.String(length=500), nullable=True),
sa.Column('smtp2go_message_id', sa.String(length=255), nullable=True),
sa.Column('bounce_date', sa.DateTime(), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_email_bounces_email'), 'email_bounces', ['email'], unique=False)
op.create_index(op.f('ix_email_bounces_id'), 'email_bounces', ['id'], unique=False)
op.create_index(op.f('ix_email_bounces_smtp2go_message_id'), 'email_bounces', ['smtp2go_message_id'], unique=False)
# Memberships table (depends on users, membership_tiers)
op.create_table('memberships',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('tier_id', sa.Integer(), nullable=False),
sa.Column('status', sa.Enum('active', 'expired', 'pending', 'cancelled', name='membershipstatus'), nullable=False),
sa.Column('start_date', sa.Date(), nullable=False),
sa.Column('end_date', sa.Date(), nullable=False),
sa.Column('auto_renew', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['tier_id'], ['membership_tiers.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_memberships_id'), 'memberships', ['id'], unique=False)
# Payments table (depends on users, memberships)
op.create_table('payments',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('membership_id', sa.Integer(), nullable=True),
sa.Column('amount', sa.Float(), nullable=False),
sa.Column('payment_method', sa.Enum('square', 'cash', 'check', 'dummy', name='paymentmethod'), nullable=False),
sa.Column('status', sa.Enum('pending', 'completed', 'failed', 'refunded', name='paymentstatus'), nullable=False),
sa.Column('transaction_id', sa.String(length=255), nullable=True),
sa.Column('payment_date', sa.DateTime(), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['membership_id'], ['memberships.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_payments_id'), 'payments', ['id'], unique=False)
# Events table (depends on users)
op.create_table('events',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('title', sa.String(length=255), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('event_date', sa.DateTime(), nullable=False),
sa.Column('event_time', sa.String(length=10), nullable=True),
sa.Column('location', sa.String(length=255), nullable=True),
sa.Column('max_attendees', sa.Integer(), nullable=True),
sa.Column('status', sa.Enum('draft', 'published', 'cancelled', 'completed', name='eventstatus'), nullable=False),
sa.Column('created_by', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['created_by'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_events_id'), 'events', ['id'], unique=False)
# Event RSVPs table (depends on events, users)
op.create_table('event_rsvps',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('event_id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('status', sa.Enum('pending', 'attending', 'not_attending', 'maybe', name='rsvpstatus'), nullable=False),
sa.Column('attended', sa.Boolean(), nullable=False),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['event_id'], ['events.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_event_rsvps_id'), 'event_rsvps', ['id'], unique=False)
op.create_index('ix_event_rsvps_event_id_user_id', 'event_rsvps', ['event_id', 'user_id'], unique=True)
# Volunteer assignments table (depends on users, volunteer_roles)
op.create_table('volunteer_assignments',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('role_id', sa.Integer(), nullable=False),
sa.Column('assigned_date', sa.Date(), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['role_id'], ['volunteer_roles.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_volunteer_assignments_id'), 'volunteer_assignments', ['id'], unique=False)
# Volunteer schedules table (depends on volunteer_assignments)
op.create_table('volunteer_schedules',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('assignment_id', sa.Integer(), nullable=False),
sa.Column('schedule_date', sa.Date(), nullable=False),
sa.Column('start_time', sa.DateTime(), nullable=False),
sa.Column('end_time', sa.DateTime(), nullable=False),
sa.Column('location', sa.String(length=255), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('completed', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['assignment_id'], ['volunteer_assignments.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_volunteer_schedules_id'), 'volunteer_schedules', ['id'], unique=False)
# Certificates table (depends on users)
op.create_table('certificates',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('certificate_name', sa.String(length=255), nullable=False),
sa.Column('issuing_organization', sa.String(length=255), nullable=True),
sa.Column('issue_date', sa.Date(), nullable=False),
sa.Column('expiry_date', sa.Date(), nullable=True),
sa.Column('certificate_number', sa.String(length=100), nullable=True),
sa.Column('file_path', sa.String(length=500), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_certificates_id'), 'certificates', ['id'], unique=False)
# Files table (depends on users, membership_tiers)
op.create_table('files',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('filename', sa.String(length=255), nullable=False),
sa.Column('original_filename', sa.String(length=255), nullable=False),
sa.Column('file_path', sa.String(length=500), nullable=False),
sa.Column('file_size', sa.Integer(), nullable=False),
sa.Column('mime_type', sa.String(length=100), nullable=False),
sa.Column('min_tier_id', sa.Integer(), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('uploaded_by', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['min_tier_id'], ['membership_tiers.id'], ),
sa.ForeignKeyConstraint(['uploaded_by'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_files_id'), 'files', ['id'], unique=False)
# Notifications table (depends on users)
op.create_table('notifications',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('subject', sa.String(length=255), nullable=False),
sa.Column('message', sa.Text(), nullable=False),
sa.Column('email_sent', sa.Boolean(), nullable=False),
sa.Column('sent_at', sa.DateTime(), nullable=True),
sa.Column('error_message', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_notifications_id'), 'notifications', ['id'], unique=False)
# Password reset tokens table (depends on users)
op.create_table('password_reset_tokens',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('token', sa.String(length=255), nullable=False),
sa.Column('expires_at', sa.DateTime(), nullable=False),
sa.Column('used', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_password_reset_tokens_id'), 'password_reset_tokens', ['id'], unique=False)
op.create_index(op.f('ix_password_reset_tokens_token'), 'password_reset_tokens', ['token'], unique=True)
def downgrade() -> None:
# Drop tables in reverse dependency order
op.drop_table('password_reset_tokens')
op.drop_table('notifications')
op.drop_table('files')
op.drop_table('certificates')
op.drop_table('volunteer_schedules')
op.drop_table('volunteer_assignments')
op.drop_table('event_rsvps')
op.drop_table('events')
op.drop_table('payments')
op.drop_table('memberships')
op.drop_table('email_bounces')
op.drop_table('email_templates')
op.drop_table('volunteer_roles')
op.drop_table('membership_tiers')
op.drop_table('users')
# Drop enums
op.execute("DROP TYPE IF EXISTS userrole")
op.execute("DROP TYPE IF EXISTS membershipstatus")
op.execute("DROP TYPE IF EXISTS paymentmethod")
op.execute("DROP TYPE IF EXISTS paymentstatus")
op.execute("DROP TYPE IF EXISTS eventstatus")
op.execute("DROP TYPE IF EXISTS rsvpstatus")
op.execute("DROP TYPE IF EXISTS bouncetype")

View File

@@ -0,0 +1,26 @@
"""Initial baseline migration
Revision ID: f9fbfa70654e
Revises:
Create Date: 2025-11-22 20:53:20.604227
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'f9fbfa70654e'
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:
pass
def downgrade() -> None:
pass

View File

@@ -1,5 +1,5 @@
from fastapi import APIRouter from fastapi import APIRouter
from . import auth, users, tiers, memberships, payments, email, email_templates from . import auth, users, tiers, memberships, payments, email, email_templates, events, feature_flags
api_router = APIRouter() api_router = APIRouter()
@@ -10,3 +10,5 @@ api_router.include_router(memberships.router, prefix="/memberships", tags=["memb
api_router.include_router(payments.router, prefix="/payments", tags=["payments"]) api_router.include_router(payments.router, prefix="/payments", tags=["payments"])
api_router.include_router(email.router, prefix="/email", tags=["email"]) api_router.include_router(email.router, prefix="/email", tags=["email"])
api_router.include_router(email_templates.router, prefix="/email-templates", tags=["email-templates"]) api_router.include_router(email_templates.router, prefix="/email-templates", tags=["email-templates"])
api_router.include_router(events.router, prefix="/events", tags=["events"])
api_router.include_router(feature_flags.router, prefix="/feature-flags", tags=["feature-flags"])

View File

@@ -0,0 +1,207 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List
from datetime import datetime
from ...core.database import get_db
from ...models.models import Event, EventRSVP, User, EventStatus
from ...schemas import (
EventCreate, EventUpdate, EventResponse, EventRSVPResponse, EventRSVPUpdate, MessageResponse
)
from ...api.dependencies import get_current_active_user, get_admin_user
router = APIRouter()
@router.get("/", response_model=List[EventResponse])
async def get_events(
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""Get all events (admin) or published events (members)"""
if current_user.role in ['admin', 'super_admin']:
events = db.query(Event).order_by(Event.event_date).all()
else:
events = db.query(Event).filter(
Event.status == EventStatus.PUBLISHED
).order_by(Event.event_date).all()
return events
@router.get("/upcoming", response_model=List[EventResponse])
async def get_upcoming_events(
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""Get upcoming events"""
now = datetime.now()
events = db.query(Event).filter(
Event.event_date >= now.date(),
Event.status == EventStatus.PUBLISHED
).order_by(Event.event_date).all()
return events
@router.post("/", response_model=EventResponse, status_code=status.HTTP_201_CREATED)
async def create_event(
event_data: EventCreate,
current_user: User = Depends(get_admin_user),
db: Session = Depends(get_db)
):
"""Create a new event (admin only)"""
# Validate event date is in the future
if event_data.event_date < datetime.now():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Event date must be in the future"
)
event = Event(
title=event_data.title,
description=event_data.description,
event_date=event_data.event_date,
event_time=event_data.event_time,
location=event_data.location,
max_attendees=event_data.max_attendees,
status=EventStatus.DRAFT,
created_by=current_user.id
)
db.add(event)
db.commit()
db.refresh(event)
return event
@router.put("/{event_id}", response_model=EventResponse)
async def update_event(
event_id: int,
event_data: EventUpdate,
current_user: User = Depends(get_admin_user),
db: Session = Depends(get_db)
):
"""Update an event (admin only)"""
event = db.query(Event).filter(Event.id == event_id).first()
if not event:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Event not found"
)
# Update fields
for field, value in event_data.dict(exclude_unset=True).items():
setattr(event, field, value)
event.updated_at = datetime.now()
db.commit()
db.refresh(event)
return event
@router.delete("/{event_id}", response_model=MessageResponse)
async def delete_event(
event_id: int,
current_user: User = Depends(get_admin_user),
db: Session = Depends(get_db)
):
"""Delete an event (admin only)"""
event = db.query(Event).filter(Event.id == event_id).first()
if not event:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Event not found"
)
db.delete(event)
db.commit()
return {"message": "Event deleted successfully"}
@router.get("/{event_id}/rsvps", response_model=List[EventRSVPResponse])
async def get_event_rsvps(
event_id: int,
current_user: User = Depends(get_admin_user),
db: Session = Depends(get_db)
):
"""Get RSVPs for an event (admin only)"""
event = db.query(Event).filter(Event.id == event_id).first()
if not event:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Event not found"
)
rsvps = db.query(EventRSVP).filter(EventRSVP.event_id == event_id).all()
return rsvps
@router.post("/{event_id}/rsvp", response_model=EventRSVPResponse)
async def create_or_update_rsvp(
event_id: int,
rsvp_data: EventRSVPUpdate,
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""Create or update RSVP for an event"""
event = db.query(Event).filter(Event.id == event_id).first()
if not event:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Event not found"
)
if event.status != EventStatus.PUBLISHED:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Event is not available for RSVP"
)
# Check if RSVP already exists
existing_rsvp = db.query(EventRSVP).filter(
EventRSVP.event_id == event_id,
EventRSVP.user_id == current_user.id
).first()
if existing_rsvp:
# Update existing RSVP
existing_rsvp.status = rsvp_data.status
if rsvp_data.notes is not None:
existing_rsvp.notes = rsvp_data.notes
existing_rsvp.updated_at = datetime.now()
db.commit()
db.refresh(existing_rsvp)
return existing_rsvp
else:
# Check attendee limit
if event.max_attendees:
current_rsvp_count = db.query(EventRSVP).filter(
EventRSVP.event_id == event_id,
EventRSVP.status == 'attending'
).count()
if current_rsvp_count >= event.max_attendees:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Event is at maximum capacity"
)
# Create new RSVP
rsvp = EventRSVP(
event_id=event_id,
user_id=current_user.id,
status=rsvp_data.status,
notes=rsvp_data.notes
)
db.add(rsvp)
db.commit()
db.refresh(rsvp)
return rsvp
@router.get("/my-rsvps", response_model=List[EventRSVPResponse])
async def get_my_rsvps(
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""Get current user's RSVPs"""
rsvps = db.query(EventRSVP).filter(EventRSVP.user_id == current_user.id).all()
return rsvps

View File

@@ -0,0 +1,47 @@
from fastapi import APIRouter, Depends
from typing import Dict, Any
from app.services.feature_flag_service import feature_flags
from app.schemas.feature_flags import FeatureFlagsResponse, FeatureFlagResponse
router = APIRouter()
@router.get("/flags", response_model=FeatureFlagsResponse)
async def get_all_feature_flags() -> FeatureFlagsResponse:
"""
Get all feature flags for the frontend
This endpoint is public as it only returns feature configuration
"""
all_flags = feature_flags.get_all_flags()
enabled_flags = feature_flags.get_enabled_flags()
return FeatureFlagsResponse(
flags=all_flags,
enabled_flags=enabled_flags
)
@router.get("/flags/{flag_name}", response_model=FeatureFlagResponse)
async def get_feature_flag(flag_name: str) -> FeatureFlagResponse:
"""
Get a specific feature flag value
"""
flag_name_upper = flag_name.upper()
enabled = feature_flags.is_enabled(flag_name_upper)
value = feature_flags.get_flag_value(flag_name_upper)
return FeatureFlagResponse(
name=flag_name_upper,
enabled=enabled,
value=value
)
@router.post("/flags/reload")
async def reload_feature_flags():
"""
Reload feature flags from environment variables
This could be protected with admin permissions in production
"""
feature_flags.reload_flags()
return {"message": "Feature flags reloaded successfully"}

View File

@@ -190,7 +190,8 @@ async def process_square_payment(
source_id=payment_request.source_id, source_id=payment_request.source_id,
idempotency_key=payment_request.idempotency_key, idempotency_key=payment_request.idempotency_key,
reference_id=reference_id, reference_id=reference_id,
note=payment_request.note or f"Membership payment for {tier.name} - {current_user.email}" note=payment_request.note or f"Membership payment for {tier.name} - {current_user.email}",
billing_details=payment_request.billing_details
) )
if not square_result.get('success'): if not square_result.get('success'):

View File

@@ -41,7 +41,7 @@ class Settings(BaseSettings):
FRONTEND_URL: str = "http://localhost:3500" FRONTEND_URL: str = "http://localhost:3500"
# CORS # CORS
BACKEND_CORS_ORIGINS: List[str] = ["http://localhost:3000", "http://localhost:8080"] BACKEND_CORS_ORIGINS: List[str] = ["http://localhost:3000", "http://localhost:8080", "https://members.sasalliance.org"]
# File Storage # File Storage
UPLOAD_DIR: str = "/app/uploads" UPLOAD_DIR: str = "/app/uploads"

View File

@@ -1,12 +1,34 @@
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager
from .core.config import settings from .core.config import settings
from .api.v1 import api_router from .api.v1 import api_router
from .core.database import get_db
from .core.init_db import init_default_data
from sqlalchemy.orm import Session
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Handle startup and shutdown events"""
# Startup
db: Session = next(get_db())
try:
init_default_data(db)
finally:
db.close()
yield
# Shutdown (if needed)
pass
app = FastAPI( app = FastAPI(
title=settings.APP_NAME, title=settings.APP_NAME,
version=settings.APP_VERSION, version=settings.APP_VERSION,
openapi_url=f"{settings.API_V1_PREFIX}/openapi.json" openapi_url=f"{settings.API_V1_PREFIX}/openapi.json",
lifespan=lifespan
) )
# Set up CORS # Set up CORS

View File

@@ -135,6 +135,7 @@ class Event(Base):
title = Column(String(255), nullable=False) title = Column(String(255), nullable=False)
description = Column(Text, nullable=True) description = Column(Text, nullable=True)
event_date = Column(DateTime, nullable=False) event_date = Column(DateTime, nullable=False)
event_time = Column(String(10), nullable=True) # HH:MM format
location = Column(String(255), nullable=True) location = Column(String(255), nullable=True)
max_attendees = Column(Integer, nullable=True) max_attendees = Column(Integer, nullable=True)
status = Column(SQLEnum(EventStatus, values_callable=lambda x: [e.value for e in x]), default=EventStatus.DRAFT, nullable=False) status = Column(SQLEnum(EventStatus, values_callable=lambda x: [e.value for e in x]), default=EventStatus.DRAFT, nullable=False)

View File

@@ -30,6 +30,13 @@ from .schemas import (
EmailTemplateCreate, EmailTemplateCreate,
EmailTemplateUpdate, EmailTemplateUpdate,
EmailTemplateResponse, EmailTemplateResponse,
EventBase,
EventCreate,
EventUpdate,
EventResponse,
EventRSVPBase,
EventRSVPUpdate,
EventRSVPResponse,
) )
__all__ = [ __all__ = [
@@ -64,4 +71,11 @@ __all__ = [
"EmailTemplateCreate", "EmailTemplateCreate",
"EmailTemplateUpdate", "EmailTemplateUpdate",
"EmailTemplateResponse", "EmailTemplateResponse",
"EventBase",
"EventCreate",
"EventUpdate",
"EventResponse",
"EventRSVPBase",
"EventRSVPUpdate",
"EventRSVPResponse",
] ]

View File

@@ -0,0 +1,15 @@
from pydantic import BaseModel
from typing import Dict, Any, List
class FeatureFlagsResponse(BaseModel):
"""Response model for feature flags"""
flags: Dict[str, Any]
enabled_flags: List[str]
class FeatureFlagResponse(BaseModel):
"""Response model for a single feature flag"""
name: str
enabled: bool
value: Any

View File

@@ -171,6 +171,7 @@ class SquarePaymentRequest(BaseModel):
amount: float = Field(..., gt=0, description="Payment amount in GBP") amount: float = Field(..., gt=0, description="Payment amount in GBP")
idempotency_key: Optional[str] = Field(None, description="Unique key to prevent duplicate payments") idempotency_key: Optional[str] = Field(None, description="Unique key to prevent duplicate payments")
note: Optional[str] = Field(None, description="Optional payment note") note: Optional[str] = Field(None, description="Optional payment note")
billing_details: Optional[dict] = Field(None, description="Billing address and cardholder name for AVS")
class SquarePaymentResponse(BaseModel): class SquarePaymentResponse(BaseModel):
@@ -229,3 +230,58 @@ class EmailTemplateResponse(EmailTemplateBase):
is_active: bool is_active: bool
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
# Event Schemas
class EventBase(BaseModel):
title: str = Field(..., min_length=1, max_length=255)
description: Optional[str] = None
event_date: datetime
event_time: Optional[str] = Field(None, pattern=r'^([01]?[0-9]|2[0-3]):[0-5][0-9]$')
location: Optional[str] = None
max_attendees: Optional[int] = Field(None, gt=0)
class EventCreate(EventBase):
pass
class EventUpdate(BaseModel):
title: Optional[str] = Field(None, min_length=1, max_length=255)
description: Optional[str] = None
event_date: Optional[datetime] = None
event_time: Optional[str] = Field(None, pattern=r'^([01]?[0-9]|2[0-3]):[0-5][0-9]$')
location: Optional[str] = None
max_attendees: Optional[int] = Field(None, gt=0)
status: Optional[str] = None
class EventResponse(EventBase):
model_config = ConfigDict(from_attributes=True)
id: int
status: str
created_by: int
created_at: datetime
updated_at: datetime
# Event RSVP Schemas
class EventRSVPBase(BaseModel):
status: str = Field(..., pattern="^(pending|attending|not_attending|maybe)$")
notes: Optional[str] = None
class EventRSVPUpdate(EventRSVPBase):
pass
class EventRSVPResponse(EventRSVPBase):
model_config = ConfigDict(from_attributes=True)
id: int
event_id: int
user_id: int
attended: bool
created_at: datetime
updated_at: datetime

View File

@@ -0,0 +1,80 @@
"""
Feature Flag Service for managing application features
"""
import os
from typing import Dict, List, Any
from app.core.config import settings
class FeatureFlagService:
"""Service for managing feature flags"""
def __init__(self):
"""Initialize feature flags from environment variables"""
self._flags = self._load_flags_from_env()
def _load_flags_from_env(self) -> Dict[str, Any]:
"""Load feature flags from environment variables"""
# Get the FEATURE_FLAGS environment variable (comma-separated list)
feature_flags_env = os.getenv("FEATURE_FLAGS", "")
# Default feature flags - these can be overridden by environment
default_flags = {
"CASH_PAYMENT_ENABLED": True,
"EMAIL_NOTIFICATIONS_ENABLED": True,
"EVENT_MANAGEMENT_ENABLED": True,
"AUTO_RENEWAL_ENABLED": False,
"MEMBERSHIP_TRANSFERS_ENABLED": False,
"BULK_OPERATIONS_ENABLED": False,
"ADVANCED_REPORTING_ENABLED": False,
"API_RATE_LIMITING_ENABLED": True,
}
# Parse environment variable
flags = default_flags.copy()
if feature_flags_env:
# Parse comma-separated key=value pairs
for flag_pair in feature_flags_env.split(","):
flag_pair = flag_pair.strip()
if "=" in flag_pair:
key, value = flag_pair.split("=", 1)
key = key.strip().upper()
value = value.strip().lower()
# Convert string to boolean
if value in ("true", "1", "yes", "on"):
flags[key] = True
elif value in ("false", "0", "no", "off"):
flags[key] = False
else:
# For non-boolean values, keep as string
flags[key] = value
return flags
def is_enabled(self, flag_name: str) -> bool:
"""Check if a feature flag is enabled"""
flag_name = flag_name.upper()
return bool(self._flags.get(flag_name, False))
def get_flag_value(self, flag_name: str, default: Any = None) -> Any:
"""Get the value of a feature flag"""
flag_name = flag_name.upper()
return self._flags.get(flag_name, default)
def get_all_flags(self) -> Dict[str, Any]:
"""Get all feature flags"""
return self._flags.copy()
def get_enabled_flags(self) -> List[str]:
"""Get list of enabled feature flag names"""
return [name for name, value in self._flags.items() if value is True]
def reload_flags(self) -> None:
"""Reload feature flags from environment (useful for runtime updates)"""
self._flags = self._load_flags_from_env()
# Global instance
feature_flags = FeatureFlagService()

View File

@@ -35,7 +35,8 @@ class SquareService:
idempotency_key: Optional[str] = None, idempotency_key: Optional[str] = None,
customer_id: Optional[str] = None, customer_id: Optional[str] = None,
reference_id: Optional[str] = None, reference_id: Optional[str] = None,
note: Optional[str] = None note: Optional[str] = None,
billing_details: Optional[Dict] = None
) -> Dict: ) -> Dict:
""" """
Create a payment using Square Create a payment using Square
@@ -47,6 +48,7 @@ class SquareService:
customer_id: Optional Square customer ID customer_id: Optional Square customer ID
reference_id: Optional reference ID for internal tracking reference_id: Optional reference ID for internal tracking
note: Optional note about the payment note: Optional note about the payment
billing_details: Optional billing address and cardholder name for AVS
Returns: Returns:
Dict with payment result including payment_id, status, and details Dict with payment result including payment_id, status, and details
@@ -60,19 +62,49 @@ class SquareService:
# For GBP, this is pence # For GBP, this is pence
amount_in_pence = int(amount_money * 100) amount_in_pence = int(amount_money * 100)
# Create payment - pass parameters directly as keyword arguments # Build payment parameters
result = self.client.payments.create( payment_params = {
source_id=source_id, 'source_id': source_id,
idempotency_key=idempotency_key, 'idempotency_key': idempotency_key,
amount_money={ 'amount_money': {
'amount': amount_in_pence, 'amount': amount_in_pence,
'currency': 'GBP' 'currency': 'GBP'
}, },
location_id=self.location_id, 'location_id': self.location_id
customer_id=customer_id if customer_id else None, }
reference_id=reference_id if reference_id else None,
note=note if note else None # Add billing address for AVS if provided
) if billing_details:
# Add buyer email if available
if billing_details.get('email'):
payment_params['buyer_email_address'] = billing_details.get('email')
# Build billing address for AVS
billing_address = {}
if billing_details.get('address_line_1'):
billing_address['address_line_1'] = billing_details.get('address_line_1')
if billing_details.get('address_line_2'):
billing_address['address_line_2'] = billing_details.get('address_line_2')
if billing_details.get('city'):
billing_address['locality'] = billing_details.get('city')
if billing_details.get('postal_code'):
billing_address['postal_code'] = billing_details.get('postal_code')
if billing_details.get('country'):
billing_address['country'] = billing_details.get('country')
if billing_address:
payment_params['billing_address'] = billing_address
# Add optional parameters
if customer_id:
payment_params['customer_id'] = customer_id
if reference_id:
payment_params['reference_id'] = reference_id
if note:
payment_params['note'] = note
# Create payment - pass parameters directly as keyword arguments
result = self.client.payments.create(**payment_params)
if result.errors: if result.errors:
# Payment failed - extract user-friendly error messages # Payment failed - extract user-friendly error messages

View File

@@ -1,269 +0,0 @@
-- Initialize database with complete schema and default data
-- Set character set to UTF-8 to prevent encoding issues
SET NAMES utf8mb4;
SET CHARACTER SET utf8mb4;
-- Create all tables
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
hashed_password VARCHAR(255) NOT NULL,
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL,
phone VARCHAR(20),
address TEXT,
role ENUM('member', 'admin', 'super_admin') NOT NULL DEFAULT 'member',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
last_login TIMESTAMP NULL,
INDEX idx_email (email)
);
CREATE TABLE membership_tiers (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) UNIQUE NOT NULL,
description TEXT,
annual_fee DECIMAL(10,2) NOT NULL,
benefits TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
CREATE TABLE memberships (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
tier_id INT NOT NULL,
status ENUM('active', 'expired', 'pending', 'cancelled') NOT NULL DEFAULT 'pending',
start_date DATE NOT NULL,
end_date DATE NOT NULL,
auto_renew BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (tier_id) REFERENCES membership_tiers(id),
INDEX idx_user_id (user_id),
INDEX idx_tier_id (tier_id)
);
CREATE TABLE payments (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
membership_id INT,
amount DECIMAL(10,2) NOT NULL,
payment_method ENUM('square', 'cash', 'check', 'dummy') NOT NULL,
status ENUM('pending', 'completed', 'failed', 'refunded') NOT NULL DEFAULT 'pending',
transaction_id VARCHAR(255),
payment_date TIMESTAMP NULL,
notes TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (membership_id) REFERENCES memberships(id),
INDEX idx_user_id (user_id),
INDEX idx_membership_id (membership_id)
);
CREATE TABLE events (
id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255) NOT NULL,
description TEXT,
event_date TIMESTAMP NOT NULL,
location VARCHAR(255),
max_attendees INT,
status ENUM('draft', 'published', 'cancelled', 'completed') NOT NULL DEFAULT 'draft',
created_by INT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (created_by) REFERENCES users(id),
INDEX idx_created_by (created_by),
INDEX idx_event_date (event_date)
);
CREATE TABLE event_rsvps (
id INT AUTO_INCREMENT PRIMARY KEY,
event_id INT NOT NULL,
user_id INT NOT NULL,
status ENUM('pending', 'attending', 'not_attending', 'maybe') NOT NULL DEFAULT 'pending',
attended BOOLEAN NOT NULL DEFAULT FALSE,
notes TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE KEY unique_event_user (event_id, user_id),
INDEX idx_event_id (event_id),
INDEX idx_user_id (user_id)
);
CREATE TABLE volunteer_roles (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
description TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
CREATE TABLE volunteer_assignments (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
role_id INT NOT NULL,
assigned_date DATE NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
notes TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (role_id) REFERENCES volunteer_roles(id),
INDEX idx_user_id (user_id),
INDEX idx_role_id (role_id)
);
CREATE TABLE volunteer_schedules (
id INT AUTO_INCREMENT PRIMARY KEY,
assignment_id INT NOT NULL,
schedule_date DATE NOT NULL,
start_time TIMESTAMP NOT NULL,
end_time TIMESTAMP NOT NULL,
location VARCHAR(255),
notes TEXT,
completed BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (assignment_id) REFERENCES volunteer_assignments(id) ON DELETE CASCADE,
INDEX idx_assignment_id (assignment_id),
INDEX idx_schedule_date (schedule_date)
);
CREATE TABLE certificates (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
certificate_name VARCHAR(255) NOT NULL,
issuing_organization VARCHAR(255),
issue_date DATE NOT NULL,
expiry_date DATE,
certificate_number VARCHAR(100),
file_path VARCHAR(500),
notes TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_user_id (user_id)
);
CREATE TABLE files (
id INT AUTO_INCREMENT PRIMARY KEY,
filename VARCHAR(255) NOT NULL,
original_filename VARCHAR(255) NOT NULL,
file_path VARCHAR(500) NOT NULL,
file_size INT NOT NULL,
mime_type VARCHAR(100) NOT NULL,
min_tier_id INT,
description TEXT,
uploaded_by INT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (min_tier_id) REFERENCES membership_tiers(id),
FOREIGN KEY (uploaded_by) REFERENCES users(id),
INDEX idx_min_tier_id (min_tier_id),
INDEX idx_uploaded_by (uploaded_by)
);
CREATE TABLE notifications (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
subject VARCHAR(255) NOT NULL,
message TEXT NOT NULL,
email_sent BOOLEAN NOT NULL DEFAULT FALSE,
sent_at TIMESTAMP NULL,
error_message TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_user_id (user_id)
);
CREATE TABLE password_reset_tokens (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
token VARCHAR(255) UNIQUE NOT NULL,
expires_at TIMESTAMP NOT NULL,
used BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_token (token),
INDEX idx_user_id (user_id)
);
CREATE TABLE email_templates (
id INT AUTO_INCREMENT PRIMARY KEY,
template_key VARCHAR(100) UNIQUE NOT NULL,
name VARCHAR(255) NOT NULL,
subject VARCHAR(255) NOT NULL,
html_body TEXT NOT NULL,
text_body TEXT,
variables TEXT, -- JSON string of available variables
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_template_key (template_key)
);
CREATE TABLE email_bounces (
id INT AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL,
bounce_type ENUM('hard', 'soft', 'complaint', 'unsubscribe') NOT NULL,
bounce_reason VARCHAR(500),
smtp2go_message_id VARCHAR(255),
bounce_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_email (email),
INDEX idx_smtp2go_message_id (smtp2go_message_id),
INDEX idx_active (is_active)
);
-- Seed initial data
-- Create default membership tiers
INSERT INTO membership_tiers (name, description, annual_fee, benefits, is_active, created_at, updated_at)
VALUES
('Personal', 'Basic membership for individual members', 5.00, 'Access to member portal, meeting notifications, event participation', TRUE, NOW(), NOW()),
('Aircraft Owners', 'Group membership for aircraft owners', 25.00, 'All Personal benefits plus priority event registration, aircraft owner resources', TRUE, NOW(), NOW()),
('Corporate', 'Corporate membership for businesses', 100.00, 'All benefits plus corporate recognition, promotional opportunities, file access', TRUE, NOW(), NOW());
-- Create default admin user (password: admin123)
INSERT INTO users (email, hashed_password, first_name, last_name, role, is_active, created_at, updated_at)
VALUES
('admin@swanseaairport.org', '$2b$12$eeuS7kW4xUYZPLx4LgBGaeoGhPu/cg/9M3WEanWToTwtLOLppJmzq', 'System', 'Administrator', 'super_admin', TRUE, NOW(), NOW());
-- Create default email templates
INSERT INTO email_templates (template_key, name, subject, html_body, text_body, variables, is_active, created_at, updated_at)
VALUES
('welcome', 'Welcome Email', 'Welcome to Swansea Airport Stakeholders Alliance - Your Account is Ready',
'<html><body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;"><h2 style="color: #0066cc;">Welcome to Swansea Airport Stakeholders Alliance!</h2><p>Hello {first_name},</p><p>Thank you for registering with us. Your account has been successfully created.</p><p>You can now:</p><ul><li>Browse membership tiers and select one that suits you</li><li>View upcoming events and meetings</li><li>Access your membership portal</li></ul><p>If you have any questions, please don\'t hesitate to contact us.</p><p>Best regards,<br><strong>Swansea Airport Stakeholders Alliance</strong></p></body></html>',
'Welcome to Swansea Airport Stakeholders Alliance!\n\nHello {first_name},\n\nThank you for registering with us. Your account has been successfully created.\n\nYou can now:\n- Browse membership tiers and select one that suits you\n- View upcoming events and meetings\n- Access your membership portal\n\nIf you have any questions, please don\'t hesitate to contact us.\n\nBest regards,\nSwansea Airport Stakeholders Alliance',
'["first_name"]', TRUE, NOW(), NOW()),
('payment_confirmation', 'Payment Confirmation', 'Payment Confirmation - Swansea Airport Stakeholders Alliance',
'<html><body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;"><h2 style="color: #28a745;">Payment Confirmed!</h2><p>Hello {first_name},</p><p>We have received your payment. Thank you!</p><div style="background-color: #f5f5f5; padding: 15px; border-radius: 5px; margin: 20px 0;"><p style="margin: 5px 0;"><strong>Amount:</strong> {amount}</p><p style="margin: 5px 0;"><strong>Payment Method:</strong> {payment_method}</p><p style="margin: 5px 0;"><strong>Membership Tier:</strong> {membership_tier}</p></div><p>Your membership is now active. You can access all the benefits associated with your tier.</p><p>Best regards,<br><strong>Swansea Airport Stakeholders Alliance</strong></p></body></html>',
'Payment Confirmed!\n\nHello {first_name},\n\nWe have received your payment. Thank you!\n\nAmount: {amount}\nPayment Method: {payment_method}\nMembership Tier: {membership_tier}\n\nYour membership is now active. You can access all the benefits associated with your tier.\n\nBest regards,\nSwansea Airport Stakeholders Alliance',
'["first_name", "amount", "payment_method", "membership_tier"]', TRUE, NOW(), NOW()),
('membership_activation', 'Membership Activation', 'Your Swansea Airport Stakeholders Alliance Membership is Now Active!',
'<html><body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;"><h2 style="color: #28a745;">Welcome to Swansea Airport Stakeholders Alliance!</h2><p>Hello {first_name},</p><p>Great news! Your membership has been successfully activated. You now have full access to all the benefits of your membership tier.</p><div style="background-color: #f8f9fa; padding: 20px; border-radius: 8px; margin: 20px 0; border-left: 4px solid #28a745;"><h3 style="margin-top: 0; color: #28a745;">Membership Details</h3><p style="margin: 8px 0;"><strong>Membership Tier:</strong> {membership_tier}</p><p style="margin: 8px 0;"><strong>Annual Fee:</strong> {annual_fee}</p><p style="margin: 8px 0;"><strong>Next Renewal Date:</strong> {renewal_date}</p></div><div style="background-color: #e9ecef; padding: 20px; border-radius: 8px; margin: 20px 0;"><h3 style="margin-top: 0; color: #495057;">Payment Information</h3><p style="margin: 8px 0;"><strong>Amount Paid:</strong> {payment_amount}</p><p style="margin: 8px 0;"><strong>Payment Method:</strong> {payment_method}</p><p style="margin: 8px 0;"><strong>Payment Date:</strong> {payment_date}</p></div><p>Your membership will automatically renew on <strong>{renewal_date}</strong> unless you choose to cancel it. You can manage your membership settings in your account dashboard.</p><p>If you have any questions about your membership or need assistance, please don\'t hesitate to contact us.</p><p>Welcome to the Swansea Airport Stakeholders Alliance community!</p><p>Best regards,<br><strong>Swansea Airport Stakeholders Alliance Team</strong></p></body></html>',
'Welcome to Swansea Airport Stakeholders Alliance!\n\nHello {first_name},\n\nGreat news! Your membership has been successfully activated. You now have full access to all the benefits of your membership tier.\n\nMEMBERSHIP DETAILS\n------------------\nMembership Tier: {membership_tier}\nAnnual Fee: {annual_fee}\nNext Renewal Date: {renewal_date}\n\nPAYMENT INFORMATION\n-------------------\nAmount Paid: {payment_amount}\nPayment Method: {payment_method}\nPayment Date: {payment_date}\n\nYour membership will automatically renew on {renewal_date} unless you choose to cancel it. You can manage your membership settings in your account dashboard.\n\nIf you have any questions about your membership or need assistance, please don\'t hesitate to contact us.\n\nWelcome to the Swansea Airport Stakeholders Alliance community!\n\nBest regards,\nSwansea Airport Stakeholders Alliance Team',
'["first_name", "membership_tier", "annual_fee", "payment_amount", "payment_method", "renewal_date", "payment_date"]', TRUE, NOW(), NOW()),
('renewal_reminder', 'Renewal Reminder', 'Membership Renewal Reminder - Swansea Airport Stakeholders Alliance',
'<html><body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;"><h2 style="color: #0066cc;">Membership Renewal Reminder</h2><p>Hello {first_name},</p><p>This is a friendly reminder that your <strong>{membership_tier}</strong> membership will expire on <strong>{expiry_date}</strong>.</p><p>To continue enjoying your membership benefits, please renew your membership.</p><div style="background-color: #f5f5f5; padding: 15px; border-radius: 5px; margin: 20px 0;"><p style="margin: 5px 0;"><strong>Membership Tier:</strong> {membership_tier}</p><p style="margin: 5px 0;"><strong>Annual Fee:</strong> {annual_fee}</p><p style="margin: 5px 0;"><strong>Expires:</strong> {expiry_date}</p></div><p>Please log in to your account to renew your membership.</p><p>Best regards,<br><strong>Swansea Airport Stakeholders Alliance</strong></p></body></html>',
'Membership Renewal Reminder\n\nHello {first_name},\n\nThis is a friendly reminder that your {membership_tier} membership will expire on {expiry_date}.\n\nTo continue enjoying your membership benefits, please renew your membership.\n\nMembership Tier: {membership_tier}\nAnnual Fee: {annual_fee}\nExpires: {expiry_date}\n\nPlease log in to your account to renew your membership.\n\nBest regards,\nSwansea Airport Stakeholders Alliance',
'["first_name", "expiry_date", "membership_tier", "annual_fee"]', TRUE, NOW(), NOW()),
('password_reset', 'Password Reset', 'Password Reset Request - Swansea Airport Stakeholders Alliance Member Portal',
'<html><body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;"><h2 style="color: #0066cc;">Password Reset Request</h2><p>Hello {first_name},</p><p>You have requested to reset your password for your Swansea Airport Stakeholders Alliance member account.</p><p>Please click the button below to reset your password:</p><div style="text-align: center; margin: 30px 0;"><a href="{reset_url}" style="background-color: #0066cc; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; display: inline-block;">Reset Password</a></div><p>If the button doesn\'t work, you can copy and paste this link into your browser:</p><p style="word-break: break-all; background-color: #f5f5f5; padding: 10px; border-radius: 3px;">{reset_url}</p><p><strong>This link will expire in 1 hour.</strong></p><p>If you didn\'t request this password reset, please ignore this email. Your password will remain unchanged.</p><p>For security reasons, please don\'t share this email with anyone.</p><p>Best regards,<br><strong>Swansea Airport Stakeholders Alliance</strong></p></body></html>',
'Password Reset Request\n\nHello {first_name},\n\nYou have requested to reset your password for your Swansea Airport Stakeholders Alliance member account.\n\nPlease use this link to reset your password: {reset_url}\n\nThis link will expire in 1 hour.\n\nIf you didn\'t request this password reset, please ignore this email. Your password will remain unchanged.\n\nFor security reasons, please don\'t share this email with anyone.\n\nBest regards,\nSwansea Airport Stakeholders Alliance',
'["first_name", "reset_url"]', TRUE, NOW(), NOW());

View File

@@ -1,21 +0,0 @@
services:
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
target: development
container_name: membership_frontend
restart: unless-stopped
environment:
- VITE_HOST_CHECK=false
- VITE_ALLOWED_HOSTS=${VITE_ALLOWED_HOSTS}
ports:
- "8050:3000" # Expose frontend to host
volumes:
- ./frontend/src:/app/src
- ./frontend/public:/app/public
- ./frontend/vite.config.ts:/app/vite.config.ts
depends_on:
- backend
networks:
- membership_private

View File

@@ -1,14 +0,0 @@
services:
frontend-prod:
build:
context: ./frontend
dockerfile: Dockerfile
target: production
container_name: membership_frontend_prod
restart: unless-stopped
ports:
- "8050:80" # Nginx default port
depends_on:
- backend
networks:
- membership_private

View File

@@ -1,53 +1,65 @@
services: services:
mysql: # mysql:
image: mysql:8.0 # image: mysql:8.0
container_name: membership_mysql # container_name: membership_mysql
restart: unless-stopped # restart: unless-stopped
environment: # environment:
MYSQL_ROOT_PASSWORD: ${DATABASE_PASSWORD:-rootpassword} # MYSQL_ROOT_PASSWORD: ${DATABASE_PASSWORD:-secure_root_password_change_this}
MYSQL_DATABASE: ${DATABASE_NAME:-membership_db} # MYSQL_DATABASE: ${DATABASE_NAME:-membership_db}
MYSQL_USER: ${DATABASE_USER:-membership_user} # MYSQL_USER: ${DATABASE_USER:-membership_user}
MYSQL_PASSWORD: ${DATABASE_PASSWORD:-change_this_password} # MYSQL_PASSWORD: ${DATABASE_PASSWORD:-secure_password_change_this}
# No external port exposure - database only accessible on private network # # No external port exposure - database only accessible on private network
expose: # expose:
- "3306" # - "3306"
volumes: # volumes:
- mysql_data:/var/lib/mysql # - mysql_data:/var/lib/mysql
- ./database/init.sql:/docker-entrypoint-initdb.d/init.sql:ro # networks:
networks: # - membership_private
- membership_private # healthcheck:
healthcheck: # test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] # start_period: 10s
start_period: 10s # interval: 5s
interval: 5s # timeout: 5s
timeout: 5s # retries: 10
retries: 10
backend: backend:
build: build:
context: ./backend context: ./backend
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: membership_backend
restart: unless-stopped restart: unless-stopped
env_file: env_file:
- .env - .env
environment:
# Database configuration
- DATABASE_HOST=${DATABASE_HOST}
- DATABASE_PORT=${DATABASE_PORT}
- DATABASE_USER=${DATABASE_USER}
- DATABASE_PASSWORD=${DATABASE_PASSWORD}
- DATABASE_NAME=${DATABASE_NAME}
# Application configuration
- SECRET_KEY=${SECRET_KEY}
- ALGORITHM=${ALGORITHM}
- ACCESS_TOKEN_EXPIRE_MINUTES=${ACCESS_TOKEN_EXPIRE_MINUTES}
extra_hosts:
- "host.docker.internal:host-gateway"
ports: ports:
- "6000:8000" # Only expose backend API to host - "6000:8000" # Only expose backend API to host
volumes: volumes:
- ./backend/app:/app/app - ./backend/app:/app/app
- ./backend/alembic:/app/alembic
- ./backend/alembic.ini:/app/alembic.ini
- uploads_data:/app/uploads - uploads_data:/app/uploads
depends_on: command: >
mysql: sh -c "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload"
condition: service_healthy # depends_on:
networks: # mysql:
- membership_private # Access to database on private network # condition: service_healthy
frontend: frontend:
build: build:
context: ./frontend context: ./frontend
dockerfile: Dockerfile dockerfile: Dockerfile
target: development target: development
container_name: membership_frontend
restart: unless-stopped restart: unless-stopped
environment: environment:
- VITE_HOST_CHECK=false - VITE_HOST_CHECK=false
@@ -60,8 +72,6 @@ services:
- ./frontend/vite.config.ts:/app/vite.config.ts - ./frontend/vite.config.ts:/app/vite.config.ts
depends_on: depends_on:
- backend - backend
networks:
- membership_private
#frontend-prod: #frontend-prod:
# build: # build:
@@ -77,12 +87,7 @@ services:
# networks: # networks:
# - membership_private # - membership_private
networks:
membership_private:
driver: bridge
internal: false # Allow outbound internet access for backend
# Database is not exposed to host - only accessible within this network
volumes: volumes:
mysql_data: # mysql_data:
uploads_data: uploads_data:

View File

@@ -4,8 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SASA Membership Portal</title> <title>SASA Membership Portal</title>
<!-- Square Web Payments SDK --> <!-- Square Web Payments SDK loaded dynamically based on environment -->
<script type="text/javascript" src="https://sandbox.web.squarecdn.com/v1/square.js"></script>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -63,6 +63,15 @@ body {
background-color: #5a6268; background-color: #5a6268;
} }
.btn-danger {
background-color: #dc3545;
color: white;
}
.btn-danger:hover {
background-color: #c82333;
}
.form-group { .form-group {
margin-bottom: 16px; margin-bottom: 16px;
} }
@@ -79,7 +88,7 @@ body {
padding: 10px; padding: 10px;
border: 1px solid #ddd; border: 1px solid #ddd;
border-radius: 4px; border-radius: 4px;
font-size: 14px; font-size: 16px;
} }
.form-group input:focus, .form-group input:focus,
@@ -180,11 +189,87 @@ body {
.dashboard-grid { .dashboard-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); grid-template-columns: 1fr;
gap: 20px; gap: 20px;
margin-top: 20px; margin-top: 20px;
} }
/* Desktop view: side-by-side layout */
@media (min-width: 769px) {
.dashboard-grid {
grid-template-columns: 1fr 1fr;
align-items: start;
}
.dashboard-grid > .card {
margin-bottom: 0;
}
}
/* Mobile responsive adjustments */
@media (max-width: 768px) {
.dashboard-grid {
grid-template-columns: 1fr;
}
.container {
padding: 10px;
}
.card {
padding: 16px;
}
.navbar {
padding: 12px 16px;
}
.navbar h1 {
font-size: 18px;
}
/* Make tables responsive */
table {
width: 100%;
min-width: 600px; /* Ensure minimum width for readability */
}
.table-container {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
/* Auth pages mobile adjustments */
.auth-container {
flex-direction: column;
padding: 20px;
gap: 20px;
}
.auth-card {
max-width: 100%;
padding: 24px;
}
/* Welcome section mobile adjustments */
.welcome-section {
max-width: 100% !important;
padding: 20px !important;
}
.welcome-section h1 {
font-size: 1.8rem !important;
}
/* Form grid mobile adjustments */
@media (max-width: 768px) {
form[style*="grid-template-columns"] {
grid-template-columns: 1fr !important;
gap: 16px !important;
}
}
}
.status-badge { .status-badge {
display: inline-block; display: inline-block;
padding: 4px 12px; padding: 4px 12px;
@@ -255,7 +340,7 @@ body {
padding: 8px; padding: 8px;
border: 1px solid #ddd; border: 1px solid #ddd;
border-radius: 4px; border-radius: 4px;
font-size: 14px; font-size: 16px;
color: #333; color: #333;
background-color: #fff; background-color: #fff;
} }
@@ -385,3 +470,176 @@ body {
border-color: #c82333; border-color: #c82333;
color: white !important; color: white !important;
} }
/* Events Container Styles */
.events-container {
display: flex;
flex-direction: column;
gap: 16px;
}
.event-card {
border: 1px solid #ddd;
border-radius: 8px;
padding: 16px;
background-color: #f9f9f9;
}
.event-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
margin-bottom: 12px;
}
.event-info {
flex: 1;
min-width: 0;
}
.event-title {
margin: 0 0 4px 0;
color: #0066cc;
font-size: 18px;
word-wrap: break-word;
}
.event-datetime {
margin: 0;
font-size: 14px;
color: #666;
}
.event-location {
margin: 4px 0 0 0;
font-size: 14px;
color: #666;
}
.event-rsvp-buttons {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.rsvp-btn {
font-size: 12px;
padding: 8px 16px;
border: 2px solid #adb5bd;
border-radius: 4px;
background-color: transparent;
color: #6c757d;
cursor: pointer;
font-weight: normal;
transition: all 0.3s ease;
white-space: nowrap;
}
.rsvp-btn:hover:not(:disabled) {
opacity: 0.8;
}
.rsvp-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.rsvp-btn.active {
font-weight: bold;
transform: scale(1.05);
}
.rsvp-btn:not(.active) {
opacity: 0.5;
filter: grayscale(50%);
}
.rsvp-btn-attending.active {
border: 3px solid #28a745;
background-color: #28a745;
color: white;
box-shadow: 0 4px 8px rgba(40, 167, 69, 0.3);
}
.rsvp-btn-maybe.active {
border: 3px solid #ffc107;
background-color: #ffc107;
color: #212529;
box-shadow: 0 4px 8px rgba(255, 193, 7, 0.3);
}
.rsvp-btn-not-attending.active {
border: 3px solid #dc3545;
background-color: #dc3545;
color: white;
box-shadow: 0 4px 8px rgba(220, 53, 69, 0.3);
}
.event-description {
margin: 0;
font-size: 14px;
line-height: 1.4;
}
.event-rsvp-status {
margin-top: 12px;
padding: 8px 12px;
border-radius: 4px;
font-size: 12px;
}
.event-rsvp-status.attending {
background-color: #d4edda;
border: 1px solid #c3e6cb;
color: #155724;
}
.event-rsvp-status.maybe {
background-color: #fff3cd;
border: 1px solid #ffeaa7;
color: #856404;
}
.event-rsvp-status.not_attending {
background-color: #f8d7da;
border: 1px solid #f5c6cb;
color: #721c24;
}
/* Mobile responsive adjustments for events */
@media (max-width: 768px) {
.event-header {
flex-direction: column;
align-items: stretch;
}
.event-rsvp-buttons {
flex-direction: column;
width: 100%;
gap: 8px;
}
.rsvp-btn {
width: 100%;
padding: 12px 16px;
font-size: 14px;
}
.event-title {
font-size: 16px;
}
.event-card {
padding: 12px;
}
/* Event modal responsive */
.modal-content[style*="600px"] {
max-width: 95% !important;
}
.modal-content div[style*="grid-template-columns"] {
grid-template-columns: 1fr !important;
}
}

View File

@@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { FeatureFlagProvider } from './contexts/FeatureFlagContext';
import Register from './pages/Register'; import Register from './pages/Register';
import Login from './pages/Login'; import Login from './pages/Login';
import ForgotPassword from './pages/ForgotPassword'; import ForgotPassword from './pages/ForgotPassword';
@@ -12,6 +13,7 @@ import './App.css';
const App: React.FC = () => { const App: React.FC = () => {
return ( return (
<FeatureFlagProvider>
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
<Route path="/" element={<Navigate to="/login" />} /> <Route path="/" element={<Navigate to="/login" />} />
@@ -25,6 +27,7 @@ const App: React.FC = () => {
<Route path="/bounce-management" element={<BounceManagement />} /> <Route path="/bounce-management" element={<BounceManagement />} />
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
</FeatureFlagProvider>
); );
}; };

View File

@@ -0,0 +1,80 @@
import React from 'react';
import { useFeatureFlags } from '../contexts/FeatureFlagContext';
const FeatureFlagStatus: React.FC = () => {
const { flags, loading, error, reloadFlags } = useFeatureFlags();
if (loading) {
return <div style={{ fontSize: '14px', color: '#666' }}>Loading feature flags...</div>;
}
if (error) {
return <div style={{ fontSize: '14px', color: '#d32f2f' }}>Error loading feature flags</div>;
}
if (!flags) {
return null;
}
const handleReload = async () => {
try {
await reloadFlags();
console.log('Feature flags reloaded');
} catch (error) {
console.error('Failed to reload feature flags:', error);
}
};
return (
<div className="card" style={{ marginBottom: '20px' }}>
<h4 style={{ marginBottom: '16px' }}>Feature Flags Status</h4>
<div style={{ display: 'grid', gap: '8px', marginBottom: '16px' }}>
{Object.entries(flags.flags).map(([name, value]) => (
<div
key={name}
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '8px 12px',
backgroundColor: '#f5f5f5',
borderRadius: '4px',
fontSize: '14px'
}}
>
<span style={{ fontWeight: '500' }}>
{name.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, l => l.toUpperCase())}
</span>
<span
style={{
padding: '2px 8px',
borderRadius: '12px',
fontSize: '12px',
fontWeight: '500',
backgroundColor: value ? '#4CAF50' : '#f44336',
color: 'white'
}}
>
{String(value)}
</span>
</div>
))}
</div>
<button
className="btn btn-secondary"
onClick={handleReload}
style={{ fontSize: '12px', padding: '6px 12px' }}
>
Reload Flags
</button>
<p style={{ fontSize: '12px', color: '#666', marginTop: '12px', marginBottom: 0 }}>
Feature flags are loaded from environment variables. Changes require updating the .env file and reloading.
</p>
</div>
);
};
export default FeatureFlagStatus;

View File

@@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { membershipService, paymentService, MembershipTier, MembershipCreateData, PaymentCreateData } from '../services/membershipService'; import { membershipService, paymentService, MembershipTier, MembershipCreateData, PaymentCreateData } from '../services/membershipService';
import { useFeatureFlags } from '../contexts/FeatureFlagContext';
import SquarePayment from './SquarePayment'; import SquarePayment from './SquarePayment';
interface MembershipSetupProps { interface MembershipSetupProps {
@@ -16,6 +17,8 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
const [error, setError] = useState(''); const [error, setError] = useState('');
const [createdMembershipId, setCreatedMembershipId] = useState<number | null>(null); const [createdMembershipId, setCreatedMembershipId] = useState<number | null>(null);
const { isEnabled } = useFeatureFlags();
useEffect(() => { useEffect(() => {
loadTiers(); loadTiers();
}, []); }, []);
@@ -202,6 +205,7 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
<span></span> <span></span>
</button> </button>
{isEnabled('CASH_PAYMENT_ENABLED') && (
<button <button
className="btn btn-secondary" className="btn btn-secondary"
onClick={() => handlePaymentMethodSelect('cash')} onClick={() => handlePaymentMethodSelect('cash')}
@@ -222,6 +226,7 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
</div> </div>
<span></span> <span></span>
</button> </button>
)}
</div> </div>
<div style={{ marginTop: '20px', textAlign: 'center' }}> <div style={{ marginTop: '20px', textAlign: 'center' }}>

View File

@@ -1,13 +1,15 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { authService } from '../services/membershipService'; import { authService, User } from '../services/membershipService';
interface ProfileMenuProps { interface ProfileMenuProps {
userName: string; userName: string;
userRole: string; userRole: string;
user?: User | null;
onEditProfile?: () => void;
} }
const ProfileMenu: React.FC<ProfileMenuProps> = ({ userName, userRole }) => { const ProfileMenu: React.FC<ProfileMenuProps> = ({ userName, userRole, user, onEditProfile }) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [showChangePassword, setShowChangePassword] = useState(false); const [showChangePassword, setShowChangePassword] = useState(false);
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
@@ -36,6 +38,14 @@ const ProfileMenu: React.FC<ProfileMenuProps> = ({ userName, userRole }) => {
setIsOpen(false); setIsOpen(false);
}; };
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-GB', {
day: 'numeric',
month: 'long',
year: 'numeric'
});
};
const dropdownStyle: React.CSSProperties = { const dropdownStyle: React.CSSProperties = {
position: 'absolute', position: 'absolute',
top: '100%', top: '100%',
@@ -44,7 +54,8 @@ const ProfileMenu: React.FC<ProfileMenuProps> = ({ userName, userRole }) => {
border: '1px solid #ddd', border: '1px solid #ddd',
borderRadius: '4px', borderRadius: '4px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)', boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
minWidth: '160px', minWidth: '280px',
maxWidth: '320px',
zIndex: 1000, zIndex: 1000,
}; };
@@ -82,10 +93,52 @@ const ProfileMenu: React.FC<ProfileMenuProps> = ({ userName, userRole }) => {
{isOpen && ( {isOpen && (
<div style={dropdownStyle}> <div style={dropdownStyle}>
{/* Profile Details Section */}
{user && (
<div style={{
padding: '16px',
borderBottom: '1px solid #eee',
backgroundColor: '#f9f9f9',
borderRadius: '4px 4px 0 0'
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
<h4 style={{ margin: 0, fontSize: '14px', fontWeight: 'bold', color: '#333' }}>Profile Details</h4>
{onEditProfile && (
<button
onClick={() => {
onEditProfile();
setIsOpen(false);
}}
style={{
background: '#0066cc',
color: 'white',
border: 'none',
padding: '4px 8px',
borderRadius: '3px',
cursor: 'pointer',
fontSize: '11px',
fontWeight: '500'
}}
>
Edit
</button>
)}
</div>
<div style={{ fontSize: '12px', color: '#555', lineHeight: '1.6' }}>
<p style={{ margin: '4px 0' }}><strong>Name:</strong> {user.first_name} {user.last_name}</p>
<p style={{ margin: '4px 0' }}><strong>Email:</strong> {user.email}</p>
{user.phone && <p style={{ margin: '4px 0' }}><strong>Phone:</strong> {user.phone}</p>}
{user.address && <p style={{ margin: '4px 0' }}><strong>Address:</strong> {user.address}</p>}
<p style={{ margin: '4px 0' }}><strong>Member since:</strong> {formatDate(user.created_at)}</p>
</div>
</div>
)}
{/* Menu Items */}
{userRole === 'super_admin' && ( {userRole === 'super_admin' && (
<> <>
<button <button
style={{ ...menuItemStyle, borderRadius: '4px 4px 0 0' }} style={{ ...menuItemStyle, borderRadius: user ? '0' : '4px 4px 0 0' }}
onClick={() => { onClick={() => {
navigate('/membership-tiers'); navigate('/membership-tiers');
setIsOpen(false); setIsOpen(false);
@@ -116,15 +169,15 @@ const ProfileMenu: React.FC<ProfileMenuProps> = ({ userName, userRole }) => {
<button <button
style={{ style={{
...menuItemStyle, ...menuItemStyle,
borderRadius: userRole === 'super_admin' ? '0 0 4px 4px' : '4px 4px 0 0', borderRadius: '0',
borderTop: userRole === 'super_admin' ? '1px solid #eee' : 'none' borderTop: (userRole === 'super_admin' || user) ? '1px solid #eee' : 'none'
}} }}
onClick={handleChangePassword} onClick={handleChangePassword}
> >
Change Password Change Password
</button> </button>
<button <button
style={{ ...menuItemStyle, borderRadius: '0 0 4px 4px' }} style={{ ...menuItemStyle, borderRadius: '0 0 4px 4px', borderTop: '1px solid #eee' }}
onClick={handleLogout} onClick={handleLogout}
> >
Log Out Log Out
@@ -232,19 +285,33 @@ const ChangePasswordModal: React.FC<ChangePasswordModalProps> = ({ onClose }) =>
</div> </div>
)} )}
<div className="modal-buttons"> <div style={{ display: 'flex', justifyContent: 'flex-end', gap: '10px', marginTop: '16px' }}>
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
disabled={loading} disabled={loading}
className="modal-btn-cancel" style={{
padding: '10px 20px',
backgroundColor: '#6c757d',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
}}
> >
Cancel Cancel
</button> </button>
<button <button
type="submit" type="submit"
disabled={loading} disabled={loading}
className="modal-btn-primary" style={{
padding: '10px 20px',
backgroundColor: '#28a745',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
}}
> >
{loading ? 'Changing...' : 'Change Password'} {loading ? 'Changing...' : 'Change Password'}
</button> </button>

View File

@@ -18,6 +18,14 @@ const SquarePayment: React.FC<SquarePaymentProps> = ({
const [payments, setPayments] = useState<any>(null); const [payments, setPayments] = useState<any>(null);
const [squareConfig, setSquareConfig] = useState<any>(null); const [squareConfig, setSquareConfig] = useState<any>(null);
// Billing details state
const [cardholderName, setCardholderName] = useState('');
const [addressLine1, setAddressLine1] = useState('');
const [addressLine2, setAddressLine2] = useState('');
const [city, setCity] = useState('');
const [postalCode, setPostalCode] = useState('');
const [isProcessing, setIsProcessing] = useState(false);
useEffect(() => { useEffect(() => {
loadSquareConfig(); loadSquareConfig();
}, []); }, []);
@@ -28,6 +36,31 @@ const SquarePayment: React.FC<SquarePaymentProps> = ({
} }
}, [squareConfig]); }, [squareConfig]);
const loadSquareSDK = (environment: string): Promise<void> => {
return new Promise((resolve, reject) => {
// Check if Square SDK is already loaded
if (window.Square) {
resolve();
return;
}
const script = document.createElement('script');
script.type = 'text/javascript';
// Load the correct SDK based on environment
if (environment?.toLowerCase() === 'sandbox') {
script.src = 'https://sandbox.web.squarecdn.com/v1/square.js';
} else {
script.src = 'https://web.squarecdn.com/v1/square.js';
}
script.onload = () => resolve();
script.onerror = () => reject(new Error('Failed to load Square SDK'));
document.head.appendChild(script);
});
};
const loadSquareConfig = async () => { const loadSquareConfig = async () => {
try { try {
const response = await fetch('/api/v1/payments/config/square', { const response = await fetch('/api/v1/payments/config/square', {
@@ -36,6 +69,12 @@ const SquarePayment: React.FC<SquarePaymentProps> = ({
} }
}); });
const config = await response.json(); const config = await response.json();
console.log('Square config received:', config);
// Load the appropriate Square SDK based on environment
await loadSquareSDK(config.environment);
console.log('Square SDK loaded for environment:', config.environment);
setSquareConfig(config); setSquareConfig(config);
} catch (error) { } catch (error) {
console.error('Failed to load Square config:', error); console.error('Failed to load Square config:', error);
@@ -53,13 +92,42 @@ const SquarePayment: React.FC<SquarePaymentProps> = ({
} }
try { try {
// Determine environment - default to production if not explicitly set to sandbox
const environment = squareConfig.environment?.toLowerCase() === 'sandbox' ? 'sandbox' : 'production';
console.log('Initializing Square with environment:', environment);
console.log('Application ID:', squareConfig.application_id);
console.log('Location ID:', squareConfig.location_id);
const paymentsInstance = window.Square.payments( const paymentsInstance = window.Square.payments(
squareConfig.application_id, squareConfig.application_id,
squareConfig.location_id squareConfig.location_id,
{
environment: environment
}
); );
setPayments(paymentsInstance); setPayments(paymentsInstance);
const cardInstance = await paymentsInstance.card(); // Initialize card without postal code (we collect it separately in billing form)
const cardInstance = await paymentsInstance.card({
style: {
'.input-container': {
borderColor: '#E0E0E0',
borderRadius: '4px'
},
'.input-container.is-focus': {
borderColor: '#4CAF50'
},
'.message-text': {
color: '#999'
},
'.message-icon': {
color: '#999'
},
'input': {
fontSize: '14px'
}
}
});
await cardInstance.attach('#card-container'); await cardInstance.attach('#card-container');
setCard(cardInstance); setCard(cardInstance);
setIsLoading(false); setIsLoading(false);
@@ -76,14 +144,33 @@ const SquarePayment: React.FC<SquarePaymentProps> = ({
return; return;
} }
// Validate billing details
if (!cardholderName.trim()) {
onPaymentError('Please enter cardholder name');
return;
}
if (!addressLine1.trim()) {
onPaymentError('Please enter address line 1');
return;
}
if (!city.trim()) {
onPaymentError('Please enter city');
return;
}
if (!postalCode.trim()) {
onPaymentError('Please enter postal code');
return;
}
setIsLoading(true); setIsLoading(true);
setIsProcessing(true);
try { try {
// Tokenize the payment method // Tokenize the payment method with billing details
const result = await card.tokenize(); const result = await card.tokenize();
if (result.status === 'OK') { if (result.status === 'OK') {
// Send the token to your backend // Send the token to your backend with billing details
const response = await fetch('/api/v1/payments/square/process', { const response = await fetch('/api/v1/payments/square/process', {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -94,7 +181,15 @@ const SquarePayment: React.FC<SquarePaymentProps> = ({
source_id: result.token, source_id: result.token,
amount: amount, amount: amount,
tier_id: tierId, tier_id: tierId,
note: `Membership payment - £${amount.toFixed(2)}` note: `Membership payment - £${amount.toFixed(2)}`,
billing_details: {
cardholder_name: cardholderName,
address_line_1: addressLine1,
address_line_2: addressLine2 || undefined,
city: city,
postal_code: postalCode,
country: 'GB'
}
}) })
}); });
@@ -124,33 +219,149 @@ const SquarePayment: React.FC<SquarePaymentProps> = ({
onPaymentError(error.message || 'Payment processing failed'); onPaymentError(error.message || 'Payment processing failed');
} finally { } finally {
setIsLoading(false); setIsLoading(false);
setIsProcessing(false);
} }
}; };
return ( return (
<div style={{ maxWidth: '400px', margin: '0 auto' }}> <div style={{ maxWidth: '500px', margin: '0 auto' }}>
<div style={{ marginBottom: '20px' }}> <div style={{ marginBottom: '24px' }}>
<h4 style={{ marginBottom: '10px' }}>Card Payment</h4> <h4 style={{ marginBottom: '10px' }}>Card Payment</h4>
<p style={{ fontSize: '14px', color: '#666' }}> <p style={{ fontSize: '14px', color: '#666' }}>
Amount: <strong>£{amount.toFixed(2)}</strong> Amount: <strong>£{amount.toFixed(2)}</strong>
</p> </p>
</div> </div>
{/* Cardholder Name */}
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: '500' }}>
Cardholder Name *
</label>
<input
type="text"
value={cardholderName}
onChange={(e) => setCardholderName(e.target.value)}
placeholder="Name as it appears on card"
style={{
width: '100%',
padding: '10px 12px',
fontSize: '14px',
border: '1px solid #E0E0E0',
borderRadius: '4px',
boxSizing: 'border-box'
}}
disabled={isLoading || !card}
/>
</div>
{/* Address Line 1 */}
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: '500' }}>
Address Line 1 *
</label>
<input
type="text"
value={addressLine1}
onChange={(e) => setAddressLine1(e.target.value)}
placeholder="Street address"
style={{
width: '100%',
padding: '10px 12px',
fontSize: '14px',
border: '1px solid #E0E0E0',
borderRadius: '4px',
boxSizing: 'border-box'
}}
disabled={isLoading || !card}
/>
</div>
{/* Address Line 2 */}
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: '500' }}>
Address Line 2
</label>
<input
type="text"
value={addressLine2}
onChange={(e) => setAddressLine2(e.target.value)}
placeholder="Apartment, suite, etc. (optional)"
style={{
width: '100%',
padding: '10px 12px',
fontSize: '14px',
border: '1px solid #E0E0E0',
borderRadius: '4px',
boxSizing: 'border-box'
}}
disabled={isLoading || !card}
/>
</div>
{/* City and Postal Code */}
<div style={{ display: 'flex', gap: '12px', marginBottom: '16px' }}>
<div style={{ flex: '1' }}>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: '500' }}>
Town/City *
</label>
<input
type="text"
value={city}
onChange={(e) => setCity(e.target.value)}
placeholder="City"
style={{
width: '100%',
padding: '10px 12px',
fontSize: '14px',
border: '1px solid #E0E0E0',
borderRadius: '4px',
boxSizing: 'border-box'
}}
disabled={isLoading || !card}
/>
</div>
<div style={{ flex: '1' }}>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: '500' }}>
Postcode *
</label>
<input
type="text"
value={postalCode}
onChange={(e) => setPostalCode(e.target.value.toUpperCase())}
placeholder="SW1A 1AA"
style={{
width: '100%',
padding: '10px 12px',
fontSize: '14px',
border: '1px solid #E0E0E0',
borderRadius: '4px',
boxSizing: 'border-box'
}}
disabled={isLoading || !card}
/>
</div>
</div>
{/* Card Details */}
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: '500' }}>
Card Details *
</label>
<div <div
id="card-container" id="card-container"
style={{ style={{
minHeight: '200px', minHeight: '120px'
marginBottom: '20px'
}} }}
/> />
</div>
<button <button
className="btn btn-primary" className="btn btn-primary"
onClick={handlePayment} onClick={handlePayment}
disabled={isLoading || !card} disabled={isLoading || !card || isProcessing}
style={{ width: '100%' }} style={{ width: '100%', padding: '12px' }}
> >
{isLoading ? 'Processing...' : `Pay £${amount.toFixed(2)}`} {isProcessing ? 'Processing...' : `Pay £${amount.toFixed(2)}`}
</button> </button>
<div style={{ marginTop: '16px', fontSize: '12px', color: '#666', textAlign: 'center' }}> <div style={{ marginTop: '16px', fontSize: '12px', color: '#666', textAlign: 'center' }}>

View File

@@ -0,0 +1,94 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import { featureFlagService, FeatureFlags } from '../services/featureFlagService';
interface FeatureFlagContextType {
flags: FeatureFlags | null;
loading: boolean;
error: string | null;
isEnabled: (flagName: string) => boolean;
getFlagValue: (flagName: string, defaultValue?: any) => any;
reloadFlags: () => Promise<void>;
}
const FeatureFlagContext = createContext<FeatureFlagContextType | undefined>(undefined);
export const useFeatureFlags = (): FeatureFlagContextType => {
const context = useContext(FeatureFlagContext);
if (context === undefined) {
throw new Error('useFeatureFlags must be used within a FeatureFlagProvider');
}
return context;
};
interface FeatureFlagProviderProps {
children: React.ReactNode;
}
export const FeatureFlagProvider: React.FC<FeatureFlagProviderProps> = ({ children }) => {
const [flags, setFlags] = useState<FeatureFlags | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadFlags = async () => {
try {
setLoading(true);
setError(null);
const flagsData = await featureFlagService.getAllFlags();
setFlags(flagsData);
} catch (err: any) {
console.error('Failed to load feature flags:', err);
setError('Failed to load feature flags');
// Set default flags on error
setFlags({
flags: {
CASH_PAYMENT_ENABLED: true,
EMAIL_NOTIFICATIONS_ENABLED: true,
EVENT_MANAGEMENT_ENABLED: true,
AUTO_RENEWAL_ENABLED: false,
MEMBERSHIP_TRANSFERS_ENABLED: false,
BULK_OPERATIONS_ENABLED: false,
ADVANCED_REPORTING_ENABLED: false,
API_RATE_LIMITING_ENABLED: true,
},
enabled_flags: ['CASH_PAYMENT_ENABLED', 'EMAIL_NOTIFICATIONS_ENABLED', 'EVENT_MANAGEMENT_ENABLED', 'API_RATE_LIMITING_ENABLED']
});
} finally {
setLoading(false);
}
};
const reloadFlags = async () => {
await loadFlags();
};
const isEnabled = (flagName: string): boolean => {
if (!flags) return false;
const upperFlagName = flagName.toUpperCase();
return Boolean(flags.flags[upperFlagName]);
};
const getFlagValue = (flagName: string, defaultValue: any = null): any => {
if (!flags) return defaultValue;
const upperFlagName = flagName.toUpperCase();
return flags.flags[upperFlagName] ?? defaultValue;
};
useEffect(() => {
loadFlags();
}, []);
const contextValue: FeatureFlagContextType = {
flags,
loading,
error,
isEnabled,
getFlagValue,
reloadFlags,
};
return (
<FeatureFlagContext.Provider value={contextValue}>
{children}
</FeatureFlagContext.Provider>
);
};

View File

@@ -1,9 +1,10 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { authService, userService, membershipService, paymentService, User, Membership, Payment } from '../services/membershipService'; import { authService, userService, membershipService, paymentService, eventService, User, Membership, Payment, Event, EventRSVP } from '../services/membershipService';
import MembershipSetup from '../components/MembershipSetup'; import MembershipSetup from '../components/MembershipSetup';
import ProfileMenu from '../components/ProfileMenu'; import ProfileMenu from '../components/ProfileMenu';
import ProfileEdit from '../components/ProfileEdit'; import ProfileEdit from '../components/ProfileEdit';
import FeatureFlagStatus from '../components/FeatureFlagStatus';
const Dashboard: React.FC = () => { const Dashboard: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -21,6 +22,25 @@ const Dashboard: React.FC = () => {
const [showUserDetails, setShowUserDetails] = useState(false); const [showUserDetails, setShowUserDetails] = useState(false);
const [isEditingUser, setIsEditingUser] = useState(false); const [isEditingUser, setIsEditingUser] = useState(false);
const [editFormData, setEditFormData] = useState<Partial<User>>({}); const [editFormData, setEditFormData] = useState<Partial<User>>({});
const [upcomingEvents, setUpcomingEvents] = useState<Event[]>([]);
const [allEvents, setAllEvents] = useState<Event[]>([]);
const [eventRSVPCounts, setEventRSVPCounts] = useState<{[eventId: number]: {attending: number, maybe: number, not_attending: number}}>({});
const [rsvpLoading, setRsvpLoading] = useState<{[eventId: number]: boolean}>({});
const [showEventModal, setShowEventModal] = useState(false);
const [editingEvent, setEditingEvent] = useState<Event | null>(null);
const [eventFormData, setEventFormData] = useState({
title: '',
description: '',
event_date: '',
event_time: '',
location: '',
max_attendees: ''
});
const [showRSVPModal, setShowRSVPModal] = useState(false);
const [selectedEventForRSVP, setSelectedEventForRSVP] = useState<Event | null>(null);
const [eventRSVPList, setEventRSVPList] = useState<EventRSVP[]>([]);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [userToDelete, setUserToDelete] = useState<User | null>(null);
useEffect(() => { useEffect(() => {
if (!authService.isAuthenticated()) { if (!authService.isAuthenticated()) {
@@ -31,6 +51,16 @@ const Dashboard: React.FC = () => {
loadData(); loadData();
}, []); }, []);
const mergeRSVPStatus = (events: Event[], rsvps: EventRSVP[]): Event[] => {
return events.map(event => {
const rsvp = rsvps.find(r => r.event_id === event.id);
return {
...event,
rsvp_status: rsvp ? rsvp.status : undefined
};
});
};
const loadData = async () => { const loadData = async () => {
try { try {
const [userData, membershipData, paymentData] = await Promise.all([ const [userData, membershipData, paymentData] = await Promise.all([
@@ -43,16 +73,31 @@ const Dashboard: React.FC = () => {
setMemberships(membershipData); setMemberships(membershipData);
setPayments(paymentData); setPayments(paymentData);
// Load upcoming events and user's RSVPs
const [eventsData, rsvpsData] = await Promise.all([
eventService.getUpcomingEvents(),
eventService.getMyRSVPs()
]);
// Merge RSVP status with events
const eventsWithRSVP = mergeRSVPStatus(eventsData, rsvpsData);
setUpcomingEvents(eventsWithRSVP);
// Load admin data if user is admin // Load admin data if user is admin
if (userData.role === 'admin' || userData.role === 'super_admin') { if (userData.role === 'admin' || userData.role === 'super_admin') {
const [allPaymentsData, allMembershipsData, allUsersData] = await Promise.all([ const [allPaymentsData, allMembershipsData, allUsersData, allEventsData] = await Promise.all([
paymentService.getAllPayments(), paymentService.getAllPayments(),
membershipService.getAllMemberships(), membershipService.getAllMemberships(),
userService.getAllUsers() userService.getAllUsers(),
eventService.getAllEvents()
]); ]);
setAllPayments(allPaymentsData); setAllPayments(allPaymentsData);
setAllMemberships(allMembershipsData); setAllMemberships(allMembershipsData);
setAllUsers(allUsersData); setAllUsers(allUsersData);
setAllEvents(allEventsData);
// Load RSVP counts for all events
await loadEventRSVPCounts(allEventsData);
} }
} catch (error) { } catch (error) {
console.error('Failed to load data:', error); console.error('Failed to load data:', error);
@@ -61,6 +106,26 @@ const Dashboard: React.FC = () => {
} }
}; };
const loadEventRSVPCounts = async (events: Event[]) => {
const counts: {[eventId: number]: {attending: number, maybe: number, not_attending: number}} = {};
for (const event of events) {
try {
const rsvps = await eventService.getEventRSVPs(event.id);
counts[event.id] = {
attending: rsvps.filter(r => r.status === 'attending').length,
maybe: rsvps.filter(r => r.status === 'maybe').length,
not_attending: rsvps.filter(r => r.status === 'not_attending').length
};
} catch (error) {
console.error(`Failed to load RSVPs for event ${event.id}:`, error);
counts[event.id] = { attending: 0, maybe: 0, not_attending: 0 };
}
}
setEventRSVPCounts(counts);
};
const handleMembershipSetup = () => { const handleMembershipSetup = () => {
setShowMembershipSetup(true); setShowMembershipSetup(true);
}; };
@@ -121,6 +186,26 @@ const Dashboard: React.FC = () => {
} }
}; };
const handleDeleteUser = async () => {
if (!userToDelete) return;
try {
await userService.deleteUser(userToDelete.id);
await loadData(); // Reload data to reflect changes
setShowDeleteConfirm(false);
setUserToDelete(null);
alert('User deleted successfully');
} catch (error: any) {
console.error('Failed to delete user:', error);
alert(`Failed to delete user: ${error.response?.data?.detail || error.message}`);
}
};
const confirmDeleteUser = (u: User) => {
setUserToDelete(u);
setShowDeleteConfirm(true);
};
const getStatusClass = (status: string) => { const getStatusClass = (status: string) => {
switch (status.toLowerCase()) { switch (status.toLowerCase()) {
case 'active': case 'active':
@@ -179,6 +264,13 @@ const Dashboard: React.FC = () => {
} }
}; };
const handleFormChange = (field: string, value: string) => {
setEditFormData(prev => ({
...prev,
[field]: value
}));
};
const handleSaveUser = async () => { const handleSaveUser = async () => {
if (!selectedUser) return; if (!selectedUser) return;
@@ -198,13 +290,169 @@ const Dashboard: React.FC = () => {
} }
}; };
const handleFormChange = (field: keyof User, value: string) => { const handleRSVP = async (eventId: number, status: 'attending' | 'not_attending' | 'maybe') => {
setEditFormData(prev => ({ // Set loading state for this event
setRsvpLoading(prev => ({ ...prev, [eventId]: true }));
// Optimistically update the UI
setUpcomingEvents(prevEvents =>
prevEvents.map(event =>
event.id === eventId
? { ...event, rsvp_status: status }
: event
)
);
try {
await eventService.createOrUpdateRSVP(eventId, { status });
// Reload RSVPs and merge with events to get the latest data
const [eventsData, rsvpsData] = await Promise.all([
eventService.getUpcomingEvents(),
eventService.getMyRSVPs()
]);
const eventsWithRSVP = mergeRSVPStatus(eventsData, rsvpsData);
setUpcomingEvents(eventsWithRSVP);
} catch (error) {
console.error('Failed to update RSVP:', error);
alert('Failed to update RSVP. Please try again.');
// Revert optimistic update on error
const [eventsData, rsvpsData] = await Promise.all([
eventService.getUpcomingEvents(),
eventService.getMyRSVPs()
]);
const eventsWithRSVP = mergeRSVPStatus(eventsData, rsvpsData);
setUpcomingEvents(eventsWithRSVP);
} finally {
// Clear loading state
setRsvpLoading(prev => ({ ...prev, [eventId]: false }));
}
};
const handlePublishEvent = async (eventId: number) => {
try {
await eventService.updateEvent(eventId, { status: 'published' });
// Reload events to reflect the change
const eventsData = await eventService.getAllEvents();
setAllEvents(eventsData);
// Reload RSVP counts
await loadEventRSVPCounts(eventsData);
} catch (error) {
console.error('Failed to publish event:', error);
alert('Failed to publish event. Please try again.');
}
};
const handleCancelEvent = async (eventId: number) => {
if (!confirm('Are you sure you want to cancel this event?')) {
return;
}
try {
await eventService.updateEvent(eventId, { status: 'cancelled' });
// Reload events to reflect the change
const eventsData = await eventService.getAllEvents();
setAllEvents(eventsData);
// Reload RSVP counts
await loadEventRSVPCounts(eventsData);
} catch (error) {
console.error('Failed to cancel event:', error);
alert('Failed to cancel event. Please try again.');
}
};
const handleCreateEvent = () => {
setEditingEvent(null);
setEventFormData({
title: '',
description: '',
event_date: '',
event_time: '',
location: '',
max_attendees: ''
});
setShowEventModal(true);
};
const handleEditEvent = (event: Event) => {
setEditingEvent(event);
// Convert event_date to YYYY-MM-DD format for date input
const dateObj = new Date(event.event_date);
const formattedDate = dateObj.toISOString().split('T')[0];
setEventFormData({
title: event.title,
description: event.description || '',
event_date: formattedDate,
event_time: event.event_time || '',
location: event.location || '',
max_attendees: event.max_attendees?.toString() || ''
});
setShowEventModal(true);
};
const handleEventFormChange = (field: string, value: string) => {
setEventFormData(prev => ({
...prev, ...prev,
[field]: value [field]: value
})); }));
}; };
const handleSaveEvent = async () => {
try {
const eventData = {
title: eventFormData.title,
description: eventFormData.description || undefined,
event_date: eventFormData.event_date,
event_time: eventFormData.event_time || undefined,
location: eventFormData.location || undefined,
max_attendees: eventFormData.max_attendees ? parseInt(eventFormData.max_attendees) : undefined
};
if (editingEvent) {
// Update existing event
await eventService.updateEvent(editingEvent.id, eventData);
} else {
// Create new event
await eventService.createEvent(eventData);
}
// Reload events
const eventsData = await eventService.getAllEvents();
setAllEvents(eventsData);
await loadEventRSVPCounts(eventsData);
// Close modal
setShowEventModal(false);
setEditingEvent(null);
} catch (error) {
console.error('Failed to save event:', error);
alert('Failed to save event. Please try again.');
}
};
const handleCloseEventModal = () => {
setShowEventModal(false);
setEditingEvent(null);
};
const handleViewRSVPs = async (event: Event) => {
setSelectedEventForRSVP(event);
try {
const rsvps = await eventService.getEventRSVPs(event.id);
setEventRSVPList(rsvps);
setShowRSVPModal(true);
} catch (error) {
console.error('Failed to load RSVPs:', error);
alert('Failed to load RSVPs. Please try again.');
}
};
const handleCloseRSVPModal = () => {
setShowRSVPModal(false);
setSelectedEventForRSVP(null);
setEventRSVPList([]);
};
const formatDate = (dateString: string) => { const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-GB', { return new Date(dateString).toLocaleDateString('en-GB', {
day: 'numeric', day: 'numeric',
@@ -240,41 +488,28 @@ const Dashboard: React.FC = () => {
<> <>
<nav className="navbar"> <nav className="navbar">
<h1>SASA Membership Portal</h1> <h1>SASA Membership Portal</h1>
<ProfileMenu userName={`${user?.first_name} ${user?.last_name}`} userRole={user?.role || ''} /> <ProfileMenu
userName={`${user?.first_name} ${user?.last_name}`}
userRole={user?.role || ''}
user={user}
onEditProfile={handleProfileEdit}
/>
</nav> </nav>
<div className="container"> <div className="container">
<h2 style={{ marginTop: '20px', marginBottom: '20px' }}>Welcome, {user?.first_name}!</h2> <h2 style={{ marginTop: '20px', marginBottom: '20px' }}>Welcome, {user?.first_name}!</h2>
<div className="dashboard-grid"> <div className="dashboard-grid">
{/* Profile Card */}
<div className="card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
<h3>Your Profile</h3>
<button
className="btn btn-secondary"
onClick={handleProfileEdit}
style={{ fontSize: '14px', padding: '6px 12px' }}
>
Edit Profile
</button>
</div>
<p><strong>Name:</strong> {user?.first_name} {user?.last_name}</p>
<p><strong>Email:</strong> {user?.email}</p>
{user?.phone && <p><strong>Phone:</strong> {user.phone}</p>}
{user?.address && <p><strong>Address:</strong> {user.address}</p>}
<p><strong>Registered since:</strong> {user && formatDate(user.created_at)}</p>
</div>
{/* Membership Card */} {/* Membership Card */}
{activeMembership ? ( {activeMembership ? (
<div className="card"> <div className="card">
<h3 style={{ marginBottom: '16px' }}>Your Membership</h3> <h3 style={{ marginBottom: '16px' }}>Your Membership</h3>
<h4 style={{ color: '#0066cc', marginBottom: '8px' }}>{activeMembership.tier.name}</h4> <h4 style={{ color: '#0066cc', marginBottom: '8px' }}>{activeMembership.tier.name}</h4>
<p><strong>Membership Number:</strong> {activeMembership.id}</p>
<p><strong>Status:</strong> <span className={`status-badge ${getStatusClass(activeMembership.status)}`}>{activeMembership.status.toUpperCase()}</span></p> <p><strong>Status:</strong> <span className={`status-badge ${getStatusClass(activeMembership.status)}`}>{activeMembership.status.toUpperCase()}</span></p>
<p><strong>Annual Fee:</strong> £{activeMembership.tier.annual_fee.toFixed(2)}</p> <p><strong>Annual Fee:</strong> £{activeMembership.tier.annual_fee.toFixed(2)}</p>
<p><strong>Member since:</strong> {formatDate(activeMembership.start_date)}</p> <p><strong>Valid From:</strong> {formatDate(activeMembership.start_date)}</p>
<p><strong>Renewal Date:</strong> {formatDate(activeMembership.end_date)}</p> <p><strong>Valid Until:</strong> {formatDate(activeMembership.end_date)}</p>
<p><strong>Auto Renew:</strong> {activeMembership.auto_renew ? 'Yes' : 'No'}</p> <p><strong>Auto Renew:</strong> {activeMembership.auto_renew ? 'Yes' : 'No'}</p>
<div style={{ marginTop: '12px', padding: '12px', backgroundColor: '#f5f5f5', borderRadius: '4px' }}> <div style={{ marginTop: '12px', padding: '12px', backgroundColor: '#f5f5f5', borderRadius: '4px' }}>
<strong>Benefits:</strong> <strong>Benefits:</strong>
@@ -295,12 +530,74 @@ const Dashboard: React.FC = () => {
</button> </button>
</div> </div>
)} )}
{/* Upcoming Events */}
<div className="card">
<h3 style={{ marginBottom: '16px' }}>Upcoming Events</h3>
{upcomingEvents.length > 0 ? (
<div className="events-container">
{upcomingEvents.map(event => (
<div key={event.id} className="event-card">
<div className="event-header">
<div className="event-info">
<h4 className="event-title">{event.title}</h4>
<p className="event-datetime">
{formatDate(event.event_date)} at {event.event_time}
</p>
{event.location && (
<p className="event-location">
📍 {event.location}
</p>
)}
</div>
<div className="event-rsvp-buttons">
<button
className={`rsvp-btn rsvp-btn-attending ${event.rsvp_status === 'attending' ? 'active' : ''}`}
onClick={() => handleRSVP(event.id, 'attending')}
disabled={rsvpLoading[event.id]}
>
{rsvpLoading[event.id] ? '...' : 'Attending'}
</button>
<button
className={`rsvp-btn rsvp-btn-maybe ${event.rsvp_status === 'maybe' ? 'active' : ''}`}
onClick={() => handleRSVP(event.id, 'maybe')}
disabled={rsvpLoading[event.id]}
>
{rsvpLoading[event.id] ? '...' : 'Maybe'}
</button>
<button
className={`rsvp-btn rsvp-btn-not-attending ${event.rsvp_status === 'not_attending' ? 'active' : ''}`}
onClick={() => handleRSVP(event.id, 'not_attending')}
disabled={rsvpLoading[event.id]}
>
{rsvpLoading[event.id] ? '...' : 'Not Attending'}
</button>
</div>
</div>
{event.description && (
<p className="event-description">
{event.description}
</p>
)}
{event.rsvp_status && (
<div className={`event-rsvp-status ${event.rsvp_status}`}>
<strong>Your RSVP:</strong> <span style={{ textTransform: 'capitalize' }}>{event.rsvp_status.replace('_', ' ')}</span>
</div>
)}
</div>
))}
</div>
) : (
<p style={{ color: '#666' }}>No upcoming events at this time.</p>
)}
</div>
</div> </div>
{/* Payment History */} {/* Payment History */}
<div className="card" style={{ marginTop: '20px' }}> <div className="card" style={{ marginTop: '20px' }}>
<h3 style={{ marginBottom: '16px' }}>Payment History</h3> <h3 style={{ marginBottom: '16px' }}>Payment History</h3>
{payments.length > 0 ? ( {payments.length > 0 ? (
<div className="table-container">
<table style={{ width: '100%', borderCollapse: 'collapse' }}> <table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead> <thead>
<tr style={{ borderBottom: '2px solid #ddd' }}> <tr style={{ borderBottom: '2px solid #ddd' }}>
@@ -325,6 +622,7 @@ const Dashboard: React.FC = () => {
))} ))}
</tbody> </tbody>
</table> </table>
</div>
) : ( ) : (
<p style={{ color: '#666' }}>No payment history available.</p> <p style={{ color: '#666' }}>No payment history available.</p>
)} )}
@@ -339,6 +637,7 @@ const Dashboard: React.FC = () => {
{allPayments.filter(p => p.status === 'pending').length > 0 && ( {allPayments.filter(p => p.status === 'pending').length > 0 && (
<div style={{ marginBottom: '20px' }}> <div style={{ marginBottom: '20px' }}>
<h4 style={{ marginBottom: '12px' }}>Pending Payments</h4> <h4 style={{ marginBottom: '12px' }}>Pending Payments</h4>
<div className="table-container">
<table style={{ width: '100%', borderCollapse: 'collapse' }}> <table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead> <thead>
<tr style={{ borderBottom: '2px solid #ddd' }}> <tr style={{ borderBottom: '2px solid #ddd' }}>
@@ -375,12 +674,14 @@ const Dashboard: React.FC = () => {
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
)} )}
{/* Pending Memberships */} {/* Pending Memberships */}
{allMemberships.filter(m => m.status === 'pending').length > 0 && ( {allMemberships.filter(m => m.status === 'pending').length > 0 && (
<div> <div>
<h4 style={{ marginBottom: '12px' }}>Pending Memberships</h4> <h4 style={{ marginBottom: '12px' }}>Pending Memberships</h4>
<div className="table-container">
<table style={{ width: '100%', borderCollapse: 'collapse' }}> <table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead> <thead>
<tr style={{ borderBottom: '2px solid #ddd' }}> <tr style={{ borderBottom: '2px solid #ddd' }}>
@@ -406,6 +707,7 @@ const Dashboard: React.FC = () => {
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
)} )}
{allPayments.filter(p => p.status === 'pending').length === 0 && {allPayments.filter(p => p.status === 'pending').length === 0 &&
@@ -415,6 +717,11 @@ const Dashboard: React.FC = () => {
</div> </div>
)} )}
{/* Feature Flag Management - Super Admin Only */}
{user?.role === 'super_admin' && (
<FeatureFlagStatus />
)}
{/* User Management Section */} {/* User Management Section */}
{(user?.role === 'admin' || user?.role === 'super_admin') && ( {(user?.role === 'admin' || user?.role === 'super_admin') && (
<div className="card" style={{ marginTop: '20px' }}> <div className="card" style={{ marginTop: '20px' }}>
@@ -437,11 +744,13 @@ const Dashboard: React.FC = () => {
/> />
</div> </div>
<div className="table-container">
<table style={{ width: '100%', borderCollapse: 'collapse' }}> <table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead> <thead>
<tr style={{ borderBottom: '2px solid #ddd' }}> <tr style={{ borderBottom: '2px solid #ddd' }}>
<th style={{ padding: '12px', textAlign: 'left' }}>Name</th> <th style={{ padding: '12px', textAlign: 'left' }}>Name</th>
<th style={{ padding: '12px', textAlign: 'left' }}>Email</th> <th style={{ padding: '12px', textAlign: 'left' }}>Email</th>
<th style={{ padding: '12px', textAlign: 'left' }}>Membership #</th>
<th style={{ padding: '12px', textAlign: 'left' }}>Role</th> <th style={{ padding: '12px', textAlign: 'left' }}>Role</th>
<th style={{ padding: '12px', textAlign: 'left' }}>Status</th> <th style={{ padding: '12px', textAlign: 'left' }}>Status</th>
<th style={{ padding: '12px', textAlign: 'left' }}>Joined</th> <th style={{ padding: '12px', textAlign: 'left' }}>Joined</th>
@@ -449,7 +758,9 @@ const Dashboard: React.FC = () => {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{filteredUsers.map(u => ( {filteredUsers.map(u => {
const userMembership = allMemberships.find(m => m.user_id === u.id && m.status === 'active');
return (
<tr <tr
key={u.id} key={u.id}
style={{ borderBottom: '1px solid #eee', cursor: 'pointer' }} style={{ borderBottom: '1px solid #eee', cursor: 'pointer' }}
@@ -457,6 +768,7 @@ const Dashboard: React.FC = () => {
> >
<td style={{ padding: '12px' }}>{u.first_name} {u.last_name}</td> <td style={{ padding: '12px' }}>{u.first_name} {u.last_name}</td>
<td style={{ padding: '12px' }}>{u.email}</td> <td style={{ padding: '12px' }}>{u.email}</td>
<td style={{ padding: '12px' }}>{userMembership ? userMembership.id : 'N/A'}</td>
<td style={{ padding: '12px' }}> <td style={{ padding: '12px' }}>
<span style={{ <span style={{
backgroundColor: u.role === 'super_admin' ? '#dc3545' : backgroundColor: u.role === 'super_admin' ? '#dc3545' :
@@ -504,12 +816,26 @@ const Dashboard: React.FC = () => {
{u.role === 'super_admin' && ( {u.role === 'super_admin' && (
<span style={{ fontSize: '12px', color: '#666' }}>Super Admin</span> <span style={{ fontSize: '12px', color: '#666' }}>Super Admin</span>
)} )}
{user?.role === 'super_admin' && u.id !== user?.id && (
<button
className="btn btn-danger"
onClick={(e) => {
e.stopPropagation(); // Prevent row click
confirmDeleteUser(u);
}}
style={{ fontSize: '12px', padding: '4px 8px', marginLeft: '4px' }}
>
Delete
</button>
)}
</td> </td>
</tr> </tr>
))} );
})}
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
)} )}
</div> </div>
@@ -521,6 +847,123 @@ const Dashboard: React.FC = () => {
/> />
)} )}
{/* Event Management Section for Admins */}
{(user?.role === 'admin' || user?.role === 'super_admin') && (
<div className="card" style={{ marginTop: '20px' }}>
<h3 style={{ marginBottom: '16px' }}>Event Management</h3>
{/* Create New Event Button */}
<div style={{ marginBottom: '16px' }}>
<button
className="btn btn-primary"
onClick={handleCreateEvent}
style={{ fontSize: '14px', padding: '8px 16px' }}
>
Create New Event
</button>
</div>
{/* Events List */}
<div className="table-container">
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '2px solid #ddd' }}>
<th style={{ padding: '12px', textAlign: 'left' }}>Event</th>
<th style={{ padding: '12px', textAlign: 'left' }}>Date & Time</th>
<th style={{ padding: '12px', textAlign: 'left' }}>Location</th>
<th style={{ padding: '12px', textAlign: 'left' }}>Status</th>
<th style={{ padding: '12px', textAlign: 'left' }}>RSVPs</th>
<th style={{ padding: '12px', textAlign: 'left' }}>Actions</th>
</tr>
</thead>
<tbody>
{allEvents.map(event => (
<tr
key={event.id}
style={{ borderBottom: '1px solid #eee', cursor: 'pointer' }}
onClick={() => handleViewRSVPs(event)}
>
<td style={{ padding: '12px' }}>
<div>
<strong>{event.title}</strong>
{event.description && (
<div style={{ fontSize: '12px', color: '#666', marginTop: '4px', maxWidth: '300px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{event.description}
</div>
)}
</div>
</td>
<td style={{ padding: '12px' }}>
<div>{formatDate(event.event_date)}</div>
<div style={{ fontSize: '12px', color: '#666' }}>{event.event_time}</div>
</td>
<td style={{ padding: '12px' }}>{event.location || 'TBD'}</td>
<td style={{ padding: '12px' }}>
<span className={`status-badge ${event.status === 'published' ? 'status-active' : event.status === 'cancelled' ? 'status-expired' : 'status-pending'}`}>
{event.status.toUpperCase()}
</span>
</td>
<td style={{ padding: '12px' }}>
{eventRSVPCounts[event.id] ? (
<div style={{ fontSize: '12px' }}>
<div>Attending: {eventRSVPCounts[event.id].attending}</div>
<div>Maybe: {eventRSVPCounts[event.id].maybe}</div>
<div>Not: {eventRSVPCounts[event.id].not_attending}</div>
</div>
) : (
<span style={{ fontSize: '12px', color: '#666' }}>Loading...</span>
)}
</td>
<td style={{ padding: '12px' }}>
<div style={{ display: 'flex', gap: '4px' }}>
<button
className="btn btn-secondary"
onClick={(e) => {
e.stopPropagation();
handleEditEvent(event);
}}
style={{ fontSize: '12px', padding: '4px 8px' }}
>
Edit
</button>
{event.status === 'draft' && (
<button
className="btn btn-primary"
onClick={(e) => {
e.stopPropagation();
handlePublishEvent(event.id);
}}
style={{ fontSize: '12px', padding: '4px 8px' }}
>
Publish
</button>
)}
{event.status === 'published' && (
<button
className="btn btn-secondary"
onClick={(e) => {
e.stopPropagation();
handleCancelEvent(event.id);
}}
style={{ fontSize: '12px', padding: '4px 8px' }}
>
Cancel
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{allEvents.length === 0 && (
<p style={{ color: '#666', textAlign: 'center', padding: '20px' }}>No events created yet.</p>
)}
</div>
)}
{/* User Details Modal */} {/* User Details Modal */}
{showUserDetails && selectedUser && ( {showUserDetails && selectedUser && (
<div style={{ <div style={{
@@ -765,6 +1208,248 @@ const Dashboard: React.FC = () => {
</div> </div>
</div> </div>
)} )}
{/* Event Create/Edit Modal */}
{showEventModal && (
<div className="modal-overlay">
<div className="modal-content" style={{ maxWidth: '600px' }}>
<h3>{editingEvent ? 'Edit Event' : 'Create New Event'}</h3>
<div className="modal-form-group">
<label>Event Title *</label>
<input
type="text"
value={eventFormData.title}
onChange={(e) => handleEventFormChange('title', e.target.value)}
required
placeholder="Annual General Meeting"
/>
</div>
<div className="modal-form-group">
<label>Description</label>
<textarea
value={eventFormData.description}
onChange={(e) => handleEventFormChange('description', e.target.value)}
rows={4}
style={{
width: '100%',
padding: '8px',
border: '1px solid #ddd',
borderRadius: '4px',
fontSize: '16px',
resize: 'vertical'
}}
placeholder="Event details and agenda..."
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
<div className="modal-form-group">
<label>Event Date *</label>
<input
type="date"
value={eventFormData.event_date}
onChange={(e) => handleEventFormChange('event_date', e.target.value)}
required
/>
</div>
<div className="modal-form-group">
<label>Event Time</label>
<input
type="time"
value={eventFormData.event_time}
onChange={(e) => handleEventFormChange('event_time', e.target.value)}
/>
</div>
</div>
<div className="modal-form-group">
<label>Location</label>
<input
type="text"
value={eventFormData.location}
onChange={(e) => handleEventFormChange('location', e.target.value)}
placeholder="Swansea Airport Conference Room"
/>
</div>
<div className="modal-form-group">
<label>Max Attendees (optional)</label>
<input
type="number"
value={eventFormData.max_attendees}
onChange={(e) => handleEventFormChange('max_attendees', e.target.value)}
min="1"
placeholder="Leave blank for unlimited"
/>
</div>
<div className="modal-buttons">
<button
type="button"
onClick={handleCloseEventModal}
className="modal-btn-cancel"
>
Cancel
</button>
<button
type="button"
onClick={handleSaveEvent}
className="modal-btn-primary"
disabled={!eventFormData.title || !eventFormData.event_date}
>
{editingEvent ? 'Update Event' : 'Create Event'}
</button>
</div>
</div>
</div>
)}
{/* RSVP List Modal */}
{showRSVPModal && selectedEventForRSVP && (
<div className="modal-overlay" onClick={handleCloseRSVPModal}>
<div
className="modal-content"
style={{ maxWidth: '700px', maxHeight: '80vh', overflow: 'auto' }}
onClick={(e) => e.stopPropagation()}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
<h3 style={{ margin: 0 }}>RSVPs for {selectedEventForRSVP.title}</h3>
<button
onClick={handleCloseRSVPModal}
style={{
background: 'none',
border: 'none',
fontSize: '24px',
cursor: 'pointer',
color: '#666'
}}
>
×
</button>
</div>
<div style={{ marginBottom: '16px', padding: '12px', backgroundColor: '#f5f5f5', borderRadius: '4px' }}>
<p style={{ margin: '4px 0', fontSize: '14px' }}><strong>Date:</strong> {formatDate(selectedEventForRSVP.event_date)}</p>
{selectedEventForRSVP.event_time && (
<p style={{ margin: '4px 0', fontSize: '14px' }}><strong>Time:</strong> {selectedEventForRSVP.event_time}</p>
)}
{selectedEventForRSVP.location && (
<p style={{ margin: '4px 0', fontSize: '14px' }}><strong>Location:</strong> {selectedEventForRSVP.location}</p>
)}
</div>
{eventRSVPList.length > 0 ? (
<>
<div style={{ marginBottom: '16px', display: 'flex', gap: '16px', fontSize: '14px' }}>
<div>
<strong>Attending:</strong> {eventRSVPList.filter(r => r.status === 'attending').length}
</div>
<div>
<strong>Maybe:</strong> {eventRSVPList.filter(r => r.status === 'maybe').length}
</div>
<div>
<strong>Not Attending:</strong> {eventRSVPList.filter(r => r.status === 'not_attending').length}
</div>
</div>
<div className="table-container">
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '2px solid #ddd' }}>
<th style={{ padding: '12px', textAlign: 'left' }}>Name</th>
<th style={{ padding: '12px', textAlign: 'left' }}>Email</th>
<th style={{ padding: '12px', textAlign: 'left' }}>RSVP</th>
<th style={{ padding: '12px', textAlign: 'left' }}>Date</th>
</tr>
</thead>
<tbody>
{eventRSVPList.map(rsvp => {
const rsvpUser = allUsers.find(u => u.id === rsvp.user_id);
return (
<tr key={rsvp.id} style={{ borderBottom: '1px solid #eee' }}>
<td style={{ padding: '12px' }}>
{rsvpUser ? `${rsvpUser.first_name} ${rsvpUser.last_name}` : `User #${rsvp.user_id}`}
</td>
<td style={{ padding: '12px' }}>
{rsvpUser?.email || 'N/A'}
</td>
<td style={{ padding: '12px' }}>
<span className={`status-badge ${
rsvp.status === 'attending' ? 'status-active' :
rsvp.status === 'maybe' ? 'status-pending' :
'status-expired'
}`}>
{rsvp.status === 'attending' ? 'ATTENDING' :
rsvp.status === 'maybe' ? 'MAYBE' :
'NOT ATTENDING'}
</span>
</td>
<td style={{ padding: '12px', fontSize: '12px', color: '#666' }}>
{rsvp.created_at ? formatDate(rsvp.created_at) : 'N/A'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</>
) : (
<p style={{ textAlign: 'center', color: '#666', padding: '20px' }}>No RSVPs yet for this event.</p>
)}
</div>
</div>
)}
{/* Delete User Confirmation Modal */}
{showDeleteConfirm && userToDelete && (
<div className="modal-overlay">
<div className="modal-content">
<h3 style={{ color: '#dc3545' }}>Delete User</h3>
<p>Are you sure you want to delete the user <strong>{userToDelete.first_name} {userToDelete.last_name}</strong> ({userToDelete.email})?</p>
<p style={{ color: '#dc3545', fontSize: '14px' }}>
This action cannot be undone. All associated memberships and payments will also be deleted.
</p>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '10px', marginTop: '20px' }}>
<button
type="button"
onClick={() => {
setShowDeleteConfirm(false);
setUserToDelete(null);
}}
style={{
padding: '10px 20px',
backgroundColor: '#6c757d',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
}}
>
Cancel
</button>
<button
type="button"
onClick={handleDeleteUser}
style={{
padding: '10px 20px',
backgroundColor: '#dc3545',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
}}
>
Delete User
</button>
</div>
</div>
</div>
)}
</> </>
); );
}; };

View File

@@ -43,7 +43,7 @@ const Login: React.FC = () => {
}; };
return ( return (
<div className="auth-container" style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: '40px', padding: '20px' }}> <div className="auth-container" style={{ gap: '40px', padding: '20px' }}>
<div className="welcome-section" style={{ <div className="welcome-section" style={{
flex: '1', flex: '1',
maxWidth: '400px', maxWidth: '400px',

View File

@@ -0,0 +1,29 @@
import api from './api';
export interface FeatureFlag {
name: string;
enabled: boolean;
value: any;
}
export interface FeatureFlags {
flags: { [key: string]: any };
enabled_flags: string[];
}
export const featureFlagService = {
async getAllFlags(): Promise<FeatureFlags> {
const response = await api.get('/feature-flags/flags');
return response.data;
},
async getFlag(flagName: string): Promise<FeatureFlag> {
const response = await api.get(`/feature-flags/flags/${flagName}`);
return response.data;
},
async reloadFlags(): Promise<{ message: string }> {
const response = await api.post('/feature-flags/flags/reload');
return response.data;
}
};

View File

@@ -14,6 +14,10 @@ export interface LoginData {
password: string; password: string;
} }
export interface ForgotPasswordData {
email: string;
}
export interface User { export interface User {
id: number; id: number;
email: string; email: string;
@@ -62,10 +66,6 @@ export interface Payment {
created_at: string; created_at: string;
} }
export interface ForgotPasswordData {
email: string;
}
export interface ResetPasswordData { export interface ResetPasswordData {
token: string; token: string;
new_password: string; new_password: string;
@@ -83,6 +83,14 @@ export interface MembershipCreateData {
auto_renew: boolean; auto_renew: boolean;
} }
export interface MembershipTierUpdateData {
name?: string;
description?: string;
annual_fee?: number;
benefits?: string;
is_active?: boolean;
}
export interface MembershipUpdateData { export interface MembershipUpdateData {
status?: string; status?: string;
start_date?: string; start_date?: string;
@@ -111,12 +119,54 @@ export interface MembershipTierCreateData {
benefits?: string; benefits?: string;
} }
export interface MembershipTierUpdateData { export interface Event {
name?: string; id: number;
title: string;
description: string | null;
event_date: string;
event_time: string | null;
location: string | null;
max_attendees: number | null;
status: string;
created_by: number;
created_at: string;
updated_at: string;
rsvp_status?: string; // Current user's RSVP status
}
export interface EventCreateData {
title: string;
description?: string; description?: string;
annual_fee?: number; event_date: string;
benefits?: string; event_time?: string;
is_active?: boolean; location?: string;
max_attendees?: number;
}
export interface EventUpdateData {
title?: string;
description?: string;
event_date?: string;
event_time?: string;
location?: string;
max_attendees?: number;
status?: string;
}
export interface EventRSVP {
id: number;
event_id: number;
user_id: number;
status: string;
attended: boolean;
notes: string | null;
created_at: string;
updated_at: string;
}
export interface EventRSVPData {
status: string;
notes?: string;
} }
export const authService = { export const authService = {
@@ -175,6 +225,11 @@ export const userService = {
const response = await api.put(`/users/${userId}`, data); const response = await api.put(`/users/${userId}`, data);
return response.data; return response.data;
}, },
async deleteUser(userId: number): Promise<{ message: string }> {
const response = await api.delete(`/users/${userId}`);
return response.data;
},
}; };
export const membershipService = { export const membershipService = {
@@ -245,3 +300,45 @@ export const paymentService = {
return response.data; return response.data;
} }
}; };
export const eventService = {
async getAllEvents(): Promise<Event[]> {
const response = await api.get('/events/');
return response.data;
},
async getUpcomingEvents(): Promise<Event[]> {
const response = await api.get('/events/upcoming');
return response.data;
},
async createEvent(data: EventCreateData): Promise<Event> {
const response = await api.post('/events/', data);
return response.data;
},
async updateEvent(eventId: number, data: EventUpdateData): Promise<Event> {
const response = await api.put(`/events/${eventId}`, data);
return response.data;
},
async deleteEvent(eventId: number): Promise<{ message: string }> {
const response = await api.delete(`/events/${eventId}`);
return response.data;
},
async getEventRSVPs(eventId: number): Promise<EventRSVP[]> {
const response = await api.get(`/events/${eventId}/rsvps`);
return response.data;
},
async createOrUpdateRSVP(eventId: number, data: EventRSVPData): Promise<EventRSVP> {
const response = await api.post(`/events/${eventId}/rsvp`, data);
return response.data;
},
async getMyRSVPs(): Promise<EventRSVP[]> {
const response = await api.get('/events/my-rsvps');
return response.data;
}
};