Compare commits
5 Commits
a73bd17f47
...
a94485b138
| Author | SHA1 | Date | |
|---|---|---|---|
| a94485b138 | |||
| 2ab7121e42 | |||
| 846019d5c1 | |||
| 1f2083ee61 | |||
| dbad9c69b3 |
44
backend/.dockerignore
Normal file
44
backend/.dockerignore
Normal file
@ -0,0 +1,44 @@
|
||||
# Version control
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Python artifacts
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.egg-info
|
||||
dist
|
||||
build
|
||||
.eggs
|
||||
|
||||
# Virtual environments
|
||||
.venv
|
||||
venv
|
||||
env
|
||||
|
||||
# IDE
|
||||
.vscode
|
||||
.idea
|
||||
|
||||
# Environment files — never bake secrets into the image
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# Tests
|
||||
tests
|
||||
pytest.ini
|
||||
.pytest_cache
|
||||
.coverage
|
||||
htmlcov
|
||||
|
||||
# Documentation
|
||||
README.md
|
||||
CHANGELOG.md
|
||||
LICENSE
|
||||
|
||||
# Dev scripts
|
||||
start.sh
|
||||
|
||||
# Docker files (no need to copy into the image)
|
||||
Dockerfile
|
||||
docker-compose*.yaml
|
||||
@ -1,5 +1,5 @@
|
||||
# ── Build stage: compile C extensions ──────────────────────────────────
|
||||
FROM python:3.12-slim AS builder
|
||||
FROM python:3.12.9-slim-bookworm AS builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
@ -11,24 +11,25 @@ COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
|
||||
|
||||
# ── Runtime stage: lean production image ───────────────────────────────
|
||||
FROM python:3.12-slim
|
||||
FROM python:3.12.9-slim-bookworm
|
||||
|
||||
# Create non-root user first, then copy with correct ownership (DW-2)
|
||||
RUN useradd -m -u 1000 appuser
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy pre-built Python packages from builder
|
||||
COPY --from=builder /install /usr/local
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
# Copy application code with correct ownership — avoids redundant chown layer
|
||||
COPY --chown=appuser:appuser . .
|
||||
|
||||
# Make entrypoint executable
|
||||
RUN chmod +x entrypoint.sh
|
||||
|
||||
# Create non-root user
|
||||
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
|
||||
USER appuser
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
# Run migrations and start server
|
||||
# --no-server-header: suppresses uvicorn version disclosure
|
||||
# --proxy-headers: reads X-Forwarded-Proto/For from reverse proxy so redirects use correct scheme
|
||||
# --forwarded-allow-ips '*': trusts proxy headers from any IP (nginx is on Docker bridge network)
|
||||
CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000 --no-server-header --proxy-headers --forwarded-allow-ips '*'"]
|
||||
# Use entrypoint with exec so uvicorn runs as PID 1 and receives signals (DW-5)
|
||||
ENTRYPOINT ["./entrypoint.sh"]
|
||||
|
||||
29
backend/alembic/versions/053_add_composite_indexes.py
Normal file
29
backend/alembic/versions/053_add_composite_indexes.py
Normal file
@ -0,0 +1,29 @@
|
||||
"""Add composite indexes for calendar_members and ntfy_sent
|
||||
|
||||
Revision ID: 053
|
||||
Revises: 052
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
revision = "053"
|
||||
down_revision = "052"
|
||||
|
||||
|
||||
def upgrade():
|
||||
# AW-1: Hot query polled every 5s uses (user_id, status) together
|
||||
op.create_index(
|
||||
"ix_calendar_members_user_id_status",
|
||||
"calendar_members",
|
||||
["user_id", "status"],
|
||||
)
|
||||
# AS-6: Dedup lookup in notification dispatch uses (user_id, sent_at)
|
||||
op.create_index(
|
||||
"ix_ntfy_sent_user_id_sent_at",
|
||||
"ntfy_sent",
|
||||
["user_id", "sent_at"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_index("ix_ntfy_sent_user_id_sent_at", table_name="ntfy_sent")
|
||||
op.drop_index("ix_calendar_members_user_id_status", table_name="calendar_members")
|
||||
@ -2,11 +2,15 @@ from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sess
|
||||
from sqlalchemy.orm import declarative_base
|
||||
from app.config import settings
|
||||
|
||||
# Create async engine
|
||||
# Create async engine with tuned pool (AW-7)
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
echo=False,
|
||||
future=True
|
||||
future=True,
|
||||
pool_size=10,
|
||||
max_overflow=5,
|
||||
pool_pre_ping=True,
|
||||
pool_recycle=1800,
|
||||
)
|
||||
|
||||
# Create async session factory
|
||||
|
||||
@ -56,8 +56,8 @@ async def _get_sent_keys(db: AsyncSession, user_id: int) -> set[str]:
|
||||
|
||||
|
||||
async def _mark_sent(db: AsyncSession, key: str, user_id: int) -> None:
|
||||
"""Stage a sent record — caller must commit (AW-4: bulk commit per user)."""
|
||||
db.add(NtfySent(notification_key=key, user_id=user_id))
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ── Dispatch functions ────────────────────────────────────────────────────────
|
||||
@ -239,14 +239,20 @@ async def _dispatch_for_user(db: AsyncSession, settings: Settings, now: datetime
|
||||
# Batch-fetch all sent keys once per user instead of one query per entity
|
||||
sent_keys = await _get_sent_keys(db, settings.user_id)
|
||||
|
||||
# AW-4: Commit after each category to preserve dedup records if a later
|
||||
# category fails (prevents re-sending already-sent notifications)
|
||||
if settings.ntfy_reminders_enabled:
|
||||
await _dispatch_reminders(db, settings, now, sent_keys)
|
||||
await db.commit()
|
||||
if settings.ntfy_events_enabled:
|
||||
await _dispatch_events(db, settings, now, sent_keys)
|
||||
await db.commit()
|
||||
if settings.ntfy_todos_enabled:
|
||||
await _dispatch_todos(db, settings, now.date(), sent_keys)
|
||||
await db.commit()
|
||||
if settings.ntfy_projects_enabled:
|
||||
await _dispatch_projects(db, settings, now.date(), sent_keys)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def _purge_old_sent_records(db: AsyncSession) -> None:
|
||||
|
||||
@ -51,7 +51,7 @@ from app.schemas.admin import (
|
||||
UserListResponse,
|
||||
)
|
||||
from app.services.audit import get_client_ip, log_audit_event
|
||||
from app.services.auth import hash_password
|
||||
from app.services.auth import ahash_password
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Router — all endpoints inherit require_admin
|
||||
@ -225,7 +225,7 @@ async def create_user(
|
||||
new_user = User(
|
||||
username=data.username,
|
||||
umbral_name=data.username,
|
||||
password_hash=hash_password(data.password),
|
||||
password_hash=await ahash_password(data.password),
|
||||
role=data.role,
|
||||
email=email,
|
||||
first_name=data.first_name,
|
||||
@ -341,7 +341,7 @@ async def reset_user_password(
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
temp_password = secrets.token_urlsafe(16)
|
||||
user.password_hash = hash_password(temp_password)
|
||||
user.password_hash = await ahash_password(temp_password)
|
||||
user.must_change_password = True
|
||||
user.last_password_change_at = datetime.now()
|
||||
|
||||
@ -740,18 +740,18 @@ async def admin_dashboard(
|
||||
_actor: User = Depends(get_current_user),
|
||||
):
|
||||
"""Aggregate stats for the admin portal dashboard."""
|
||||
total_users = await db.scalar(
|
||||
sa.select(sa.func.count()).select_from(User)
|
||||
)
|
||||
active_users = await db.scalar(
|
||||
sa.select(sa.func.count()).select_from(User).where(User.is_active == True)
|
||||
)
|
||||
admin_count = await db.scalar(
|
||||
sa.select(sa.func.count()).select_from(User).where(User.role == "admin")
|
||||
)
|
||||
totp_count = await db.scalar(
|
||||
sa.select(sa.func.count()).select_from(User).where(User.totp_enabled == True)
|
||||
# AW-6: Single conditional aggregation instead of 5 separate COUNT queries
|
||||
user_stats = await db.execute(
|
||||
sa.select(
|
||||
sa.func.count().label("total"),
|
||||
sa.func.count().filter(User.is_active == True).label("active"),
|
||||
sa.func.count().filter(User.role == "admin").label("admins"),
|
||||
sa.func.count().filter(User.totp_enabled == True).label("totp"),
|
||||
).select_from(User)
|
||||
)
|
||||
row = user_stats.one()
|
||||
total_users, active_users, admin_count, totp_count = row.tuple()
|
||||
|
||||
active_sessions = await db.scalar(
|
||||
sa.select(sa.func.count()).select_from(UserSession).where(
|
||||
UserSession.revoked == False,
|
||||
|
||||
@ -37,6 +37,9 @@ from app.schemas.auth import (
|
||||
ProfileUpdate, ProfileResponse,
|
||||
)
|
||||
from app.services.auth import (
|
||||
ahash_password,
|
||||
averify_password,
|
||||
averify_password_with_upgrade,
|
||||
hash_password,
|
||||
verify_password,
|
||||
verify_password_with_upgrade,
|
||||
@ -101,25 +104,22 @@ async def get_current_user(
|
||||
if user_id is None or session_id is None:
|
||||
raise HTTPException(status_code=401, detail="Malformed session token")
|
||||
|
||||
# Verify session is active in DB (covers revocation + expiry)
|
||||
session_result = await db.execute(
|
||||
select(UserSession).where(
|
||||
# AC-1: Single JOIN query for session + user (was 2 sequential queries)
|
||||
result = await db.execute(
|
||||
select(UserSession, User)
|
||||
.join(User, UserSession.user_id == User.id)
|
||||
.where(
|
||||
UserSession.id == session_id,
|
||||
UserSession.user_id == user_id,
|
||||
UserSession.revoked == False,
|
||||
UserSession.expires_at > datetime.now(),
|
||||
User.is_active == True,
|
||||
)
|
||||
)
|
||||
db_session = session_result.scalar_one_or_none()
|
||||
if not db_session:
|
||||
raise HTTPException(status_code=401, detail="Session has been revoked or expired")
|
||||
|
||||
user_result = await db.execute(
|
||||
select(User).where(User.id == user_id, User.is_active == True)
|
||||
)
|
||||
user = user_result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="User not found or inactive")
|
||||
row = result.one_or_none()
|
||||
if not row:
|
||||
raise HTTPException(status_code=401, detail="Session expired or user inactive")
|
||||
db_session, user = row.tuple()
|
||||
|
||||
# L-03: Sliding window renewal — extend session if >1 day has elapsed since
|
||||
# last renewal (i.e. remaining time < SESSION_MAX_AGE_DAYS - 1 day).
|
||||
@ -149,19 +149,26 @@ async def get_current_user(
|
||||
|
||||
|
||||
async def get_current_settings(
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Settings:
|
||||
"""
|
||||
Convenience dependency for routers that need Settings access.
|
||||
Always chain after get_current_user — never use standalone.
|
||||
|
||||
AC-3: Cache in request.state so multiple dependencies don't re-query.
|
||||
"""
|
||||
cached = getattr(request.state, "settings", None)
|
||||
if cached is not None:
|
||||
return cached
|
||||
result = await db.execute(
|
||||
select(Settings).where(Settings.user_id == current_user.id)
|
||||
)
|
||||
settings_obj = result.scalar_one_or_none()
|
||||
if not settings_obj:
|
||||
raise HTTPException(status_code=500, detail="Settings not found for user")
|
||||
request.state.settings = settings_obj
|
||||
return settings_obj
|
||||
|
||||
|
||||
@ -299,7 +306,7 @@ async def setup(
|
||||
if user_count.scalar_one() > 0:
|
||||
raise HTTPException(status_code=400, detail="Setup already completed")
|
||||
|
||||
password_hash = hash_password(data.password)
|
||||
password_hash = await ahash_password(data.password)
|
||||
new_user = User(
|
||||
username=data.username,
|
||||
umbral_name=data.username,
|
||||
@ -352,12 +359,12 @@ async def login(
|
||||
if not user:
|
||||
# M-02: Run Argon2id against a dummy hash so the response time is
|
||||
# indistinguishable from a wrong-password attempt (prevents username enumeration).
|
||||
verify_password("x", _DUMMY_HASH)
|
||||
await averify_password("x", _DUMMY_HASH)
|
||||
raise HTTPException(status_code=401, detail="Invalid username or password")
|
||||
|
||||
# M-02: Run password verification BEFORE lockout check so Argon2id always
|
||||
# executes — prevents distinguishing "locked" from "wrong password" via timing.
|
||||
valid, new_hash = verify_password_with_upgrade(data.password, user.password_hash)
|
||||
valid, new_hash = await averify_password_with_upgrade(data.password, user.password_hash)
|
||||
|
||||
await _check_account_lockout(user)
|
||||
|
||||
@ -465,7 +472,7 @@ async def register(
|
||||
if existing_email.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="Registration could not be completed. Please check your details and try again.")
|
||||
|
||||
password_hash = hash_password(data.password)
|
||||
password_hash = await ahash_password(data.password)
|
||||
# SEC-01: Explicit field assignment — never **data.model_dump()
|
||||
new_user = User(
|
||||
username=data.username,
|
||||
@ -630,7 +637,7 @@ async def verify_password(
|
||||
"""
|
||||
await _check_account_lockout(current_user)
|
||||
|
||||
valid, new_hash = verify_password_with_upgrade(data.password, current_user.password_hash)
|
||||
valid, new_hash = await averify_password_with_upgrade(data.password, current_user.password_hash)
|
||||
if not valid:
|
||||
await _record_failed_login(db, current_user)
|
||||
raise HTTPException(status_code=401, detail="Invalid password")
|
||||
@ -656,7 +663,7 @@ async def change_password(
|
||||
"""Change the current user's password. Requires old password verification."""
|
||||
await _check_account_lockout(current_user)
|
||||
|
||||
valid, _ = verify_password_with_upgrade(data.old_password, current_user.password_hash)
|
||||
valid, _ = await averify_password_with_upgrade(data.old_password, current_user.password_hash)
|
||||
if not valid:
|
||||
await _record_failed_login(db, current_user)
|
||||
raise HTTPException(status_code=401, detail="Invalid current password")
|
||||
@ -664,7 +671,7 @@ async def change_password(
|
||||
if data.new_password == data.old_password:
|
||||
raise HTTPException(status_code=400, detail="New password must be different from your current password")
|
||||
|
||||
current_user.password_hash = hash_password(data.new_password)
|
||||
current_user.password_hash = await ahash_password(data.new_password)
|
||||
current_user.last_password_change_at = datetime.now()
|
||||
|
||||
# Clear forced password change flag if set (SEC-12)
|
||||
|
||||
@ -294,16 +294,20 @@ async def reorder_tasks(
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
for item in items:
|
||||
# AC-4: Batch-fetch all tasks in one query instead of N sequential queries
|
||||
task_ids = [item.id for item in items]
|
||||
task_result = await db.execute(
|
||||
select(ProjectTask).where(
|
||||
ProjectTask.id == item.id,
|
||||
ProjectTask.project_id == project_id
|
||||
ProjectTask.id.in_(task_ids),
|
||||
ProjectTask.project_id == project_id,
|
||||
)
|
||||
)
|
||||
task = task_result.scalar_one_or_none()
|
||||
if task:
|
||||
task.sort_order = item.sort_order
|
||||
tasks_by_id = {t.id: t for t in task_result.scalars().all()}
|
||||
|
||||
order_map = {item.id: item.sort_order for item in items}
|
||||
for task_id, task in tasks_by_id.items():
|
||||
if task_id in order_map:
|
||||
task.sort_order = order_map[task_id]
|
||||
|
||||
await db.commit()
|
||||
|
||||
|
||||
@ -17,6 +17,7 @@ Security:
|
||||
- Failed TOTP attempts increment user.failed_login_count (shared lockout counter)
|
||||
- totp-verify uses mfa_token (not session cookie) — user is not yet authenticated
|
||||
"""
|
||||
import asyncio
|
||||
import uuid
|
||||
import secrets
|
||||
import logging
|
||||
@ -37,8 +38,7 @@ from app.models.backup_code import BackupCode
|
||||
from app.routers.auth import get_current_user, _set_session_cookie
|
||||
from app.services.audit import get_client_ip
|
||||
from app.services.auth import (
|
||||
verify_password_with_upgrade,
|
||||
hash_password,
|
||||
averify_password_with_upgrade,
|
||||
verify_mfa_token,
|
||||
verify_mfa_enforce_token,
|
||||
create_session_token,
|
||||
@ -117,8 +117,10 @@ class EnforceConfirmRequest(BaseModel):
|
||||
|
||||
async def _store_backup_codes(db: AsyncSession, user_id: int, plaintext_codes: list[str]) -> None:
|
||||
"""Hash and insert backup codes for the given user."""
|
||||
# AC-2: Run Argon2id hashing in executor to avoid blocking event loop
|
||||
loop = asyncio.get_running_loop()
|
||||
for code in plaintext_codes:
|
||||
code_hash = _ph.hash(code)
|
||||
code_hash = await loop.run_in_executor(None, _ph.hash, code)
|
||||
db.add(BackupCode(user_id=user_id, code_hash=code_hash))
|
||||
await db.commit()
|
||||
|
||||
@ -145,9 +147,12 @@ async def _verify_backup_code(
|
||||
)
|
||||
unused_codes = result.scalars().all()
|
||||
|
||||
# AC-2: Run Argon2id verification in executor to avoid blocking event loop
|
||||
loop = asyncio.get_running_loop()
|
||||
for record in unused_codes:
|
||||
try:
|
||||
if _ph.verify(record.code_hash, submitted_code):
|
||||
matched = await loop.run_in_executor(None, _ph.verify, record.code_hash, submitted_code)
|
||||
if matched:
|
||||
record.used_at = datetime.now()
|
||||
await db.commit()
|
||||
return True
|
||||
@ -355,7 +360,8 @@ async def totp_disable(
|
||||
raise HTTPException(status_code=400, detail="TOTP is not enabled")
|
||||
|
||||
# Verify password (handles bcrypt→Argon2id upgrade transparently)
|
||||
valid, new_hash = verify_password_with_upgrade(data.password, current_user.password_hash)
|
||||
# AC-2: async wrapper to avoid blocking event loop
|
||||
valid, new_hash = await averify_password_with_upgrade(data.password, current_user.password_hash)
|
||||
if not valid:
|
||||
raise HTTPException(status_code=401, detail="Invalid password")
|
||||
|
||||
@ -391,7 +397,8 @@ async def regenerate_backup_codes(
|
||||
if not current_user.totp_enabled:
|
||||
raise HTTPException(status_code=400, detail="TOTP is not enabled")
|
||||
|
||||
valid, new_hash = verify_password_with_upgrade(data.password, current_user.password_hash)
|
||||
# AC-2: async wrapper to avoid blocking event loop
|
||||
valid, new_hash = await averify_password_with_upgrade(data.password, current_user.password_hash)
|
||||
if not valid:
|
||||
raise HTTPException(status_code=401, detail="Invalid password")
|
||||
|
||||
|
||||
@ -6,6 +6,8 @@ Password strategy:
|
||||
- Legacy bcrypt hashes (migrated from PIN auth): accepted on login, immediately
|
||||
rehashed to Argon2id on first successful use.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import VerifyMismatchError, VerificationError, InvalidHashError
|
||||
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
|
||||
@ -76,6 +78,28 @@ def verify_password_with_upgrade(password: str, hashed: str) -> tuple[bool, str
|
||||
return valid, new_hash
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Async wrappers — run CPU-bound Argon2id ops in a thread pool (AC-2/S-01)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def ahash_password(password: str) -> str:
|
||||
"""Async wrapper for hash_password — runs Argon2id in executor."""
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(None, hash_password, password)
|
||||
|
||||
|
||||
async def averify_password(password: str, hashed: str) -> bool:
|
||||
"""Async wrapper for verify_password — runs Argon2id in executor."""
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(None, verify_password, password, hashed)
|
||||
|
||||
|
||||
async def averify_password_with_upgrade(password: str, hashed: str) -> tuple[bool, str | None]:
|
||||
"""Async wrapper for verify_password_with_upgrade — runs Argon2id in executor."""
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(None, verify_password_with_upgrade, password, hashed)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session tokens
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@ -7,7 +7,7 @@ import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import delete, select, text
|
||||
from sqlalchemy import delete, select, text, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.calendar import Calendar
|
||||
@ -24,25 +24,29 @@ async def get_user_permission(db: AsyncSession, calendar_id: int, user_id: int)
|
||||
"""
|
||||
Returns "owner" if the user owns the calendar, the permission string
|
||||
if they are an accepted member, or None if they have no access.
|
||||
"""
|
||||
cal = await db.execute(
|
||||
select(Calendar).where(Calendar.id == calendar_id)
|
||||
)
|
||||
calendar = cal.scalar_one_or_none()
|
||||
if not calendar:
|
||||
return None
|
||||
if calendar.user_id == user_id:
|
||||
return "owner"
|
||||
|
||||
member = await db.execute(
|
||||
select(CalendarMember).where(
|
||||
CalendarMember.calendar_id == calendar_id,
|
||||
CalendarMember.user_id == user_id,
|
||||
CalendarMember.status == "accepted",
|
||||
AW-5: Single query with LEFT JOIN instead of 2 sequential queries.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(
|
||||
Calendar.user_id,
|
||||
CalendarMember.permission,
|
||||
)
|
||||
.outerjoin(
|
||||
CalendarMember,
|
||||
(CalendarMember.calendar_id == Calendar.id)
|
||||
& (CalendarMember.user_id == user_id)
|
||||
& (CalendarMember.status == "accepted"),
|
||||
)
|
||||
row = member.scalar_one_or_none()
|
||||
return row.permission if row else None
|
||||
.where(Calendar.id == calendar_id)
|
||||
)
|
||||
row = result.one_or_none()
|
||||
if not row:
|
||||
return None
|
||||
owner_id, member_permission = row.tuple()
|
||||
if owner_id == user_id:
|
||||
return "owner"
|
||||
return member_permission
|
||||
|
||||
|
||||
async def require_permission(
|
||||
@ -202,16 +206,22 @@ async def cascade_on_disconnect(db: AsyncSession, user_a_id: int, user_b_id: int
|
||||
{"user_id": user_a_id, "cal_ids": b_cal_ids},
|
||||
)
|
||||
|
||||
# Reset is_shared on calendars with no remaining members
|
||||
# AC-5: Single aggregation query instead of N per-calendar checks
|
||||
all_cal_ids = a_cal_ids + b_cal_ids
|
||||
for cal_id in all_cal_ids:
|
||||
remaining = await db.execute(
|
||||
select(CalendarMember.id).where(CalendarMember.calendar_id == cal_id).limit(1)
|
||||
if all_cal_ids:
|
||||
# Find which calendars still have members
|
||||
has_members_result = await db.execute(
|
||||
select(CalendarMember.calendar_id)
|
||||
.where(CalendarMember.calendar_id.in_(all_cal_ids))
|
||||
.group_by(CalendarMember.calendar_id)
|
||||
)
|
||||
if not remaining.scalar_one_or_none():
|
||||
cal_result = await db.execute(
|
||||
select(Calendar).where(Calendar.id == cal_id)
|
||||
cals_with_members = {row[0] for row in has_members_result.all()}
|
||||
|
||||
# Reset is_shared on calendars with no remaining members
|
||||
empty_cal_ids = [cid for cid in all_cal_ids if cid not in cals_with_members]
|
||||
if empty_cal_ids:
|
||||
await db.execute(
|
||||
update(Calendar)
|
||||
.where(Calendar.id.in_(empty_cal_ids))
|
||||
.values(is_shared=False)
|
||||
)
|
||||
cal = cal_result.scalar_one_or_none()
|
||||
if cal:
|
||||
cal.is_shared = False
|
||||
|
||||
13
backend/entrypoint.sh
Normal file
13
backend/entrypoint.sh
Normal file
@ -0,0 +1,13 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
echo "Running database migrations..."
|
||||
alembic upgrade head
|
||||
|
||||
echo "Starting uvicorn..."
|
||||
exec uvicorn app.main:app \
|
||||
--host 0.0.0.0 \
|
||||
--port 8000 \
|
||||
--no-server-header \
|
||||
--proxy-headers \
|
||||
--forwarded-allow-ips '*'
|
||||
@ -1,9 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Run database migrations
|
||||
echo "Running database migrations..."
|
||||
alembic upgrade head
|
||||
|
||||
# Start the FastAPI application
|
||||
echo "Starting FastAPI application..."
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
@ -5,11 +5,18 @@ services:
|
||||
env_file: .env
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- backend_net
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $POSTGRES_USER -d $POSTGRES_DB"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
cpus: "1.0"
|
||||
|
||||
backend:
|
||||
build: ./backend
|
||||
@ -18,11 +25,20 @@ services:
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- backend_net
|
||||
- frontend_net
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health')\""]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
cpus: "1.0"
|
||||
|
||||
frontend:
|
||||
build: ./frontend
|
||||
@ -32,6 +48,24 @@ services:
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- frontend_net
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--spider", "--quiet", "http://localhost:8080/"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 128M
|
||||
cpus: "0.5"
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
|
||||
networks:
|
||||
backend_net:
|
||||
driver: bridge
|
||||
frontend_net:
|
||||
driver: bridge
|
||||
|
||||
25
frontend/.dockerignore
Normal file
25
frontend/.dockerignore
Normal file
@ -0,0 +1,25 @@
|
||||
# Dependencies — rebuilt inside the container from lockfile
|
||||
node_modules
|
||||
|
||||
# Build output — rebuilt inside the container
|
||||
dist
|
||||
|
||||
# Version control
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# IDE
|
||||
.vscode
|
||||
.idea
|
||||
|
||||
# Documentation
|
||||
*.md
|
||||
LICENSE
|
||||
|
||||
# Docker files
|
||||
Dockerfile
|
||||
docker-compose*.yaml
|
||||
@ -1,13 +1,13 @@
|
||||
# Build stage
|
||||
FROM node:20-alpine AS build
|
||||
FROM node:20.18-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm install
|
||||
# Install dependencies from lockfile (DW-3)
|
||||
RUN npm ci
|
||||
|
||||
# Copy source files
|
||||
COPY . .
|
||||
@ -16,7 +16,7 @@ COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Production stage — unprivileged nginx (runs as non-root, listens on 8080)
|
||||
FROM nginxinc/nginx-unprivileged:alpine
|
||||
FROM nginxinc/nginx-unprivileged:1.27-alpine
|
||||
|
||||
# Copy built files from build stage
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
@ -41,7 +41,7 @@ server {
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/javascript application/json;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/javascript application/json image/svg+xml;
|
||||
|
||||
# Block dotfiles (except .well-known for ACME/Let's Encrypt) (PT-04)
|
||||
location ~ /\.(?!well-known) {
|
||||
@ -122,28 +122,12 @@ server {
|
||||
include /etc/nginx/proxy-params.conf;
|
||||
}
|
||||
|
||||
# API proxy
|
||||
# API proxy (catch-all for non-rate-limited endpoints)
|
||||
location /api {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $forwarded_proto;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
|
||||
# PT-L01: Prevent browser caching of authenticated API responses
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
||||
# Security headers (must be repeated — nginx add_header in a location block
|
||||
# overrides server-level add_header directives, so all headers must be explicit)
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; connect-src 'self';" always;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always;
|
||||
include /etc/nginx/proxy-params.conf;
|
||||
}
|
||||
|
||||
# SPA fallback - serve index.html for all routes
|
||||
|
||||
@ -4,3 +4,13 @@ proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $forwarded_proto;
|
||||
|
||||
# Security headers (repeated per location — nginx add_header in a location block
|
||||
# overrides server-level directives, so all headers must be explicit)
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; connect-src 'self';" always;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always;
|
||||
|
||||
@ -3,19 +3,24 @@ import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import LockScreen from '@/components/auth/LockScreen';
|
||||
import AppLayout from '@/components/layout/AppLayout';
|
||||
import DashboardPage from '@/components/dashboard/DashboardPage';
|
||||
import TodosPage from '@/components/todos/TodosPage';
|
||||
import CalendarPage from '@/components/calendar/CalendarPage';
|
||||
import RemindersPage from '@/components/reminders/RemindersPage';
|
||||
import ProjectsPage from '@/components/projects/ProjectsPage';
|
||||
import ProjectDetail from '@/components/projects/ProjectDetail';
|
||||
import PeoplePage from '@/components/people/PeoplePage';
|
||||
import LocationsPage from '@/components/locations/LocationsPage';
|
||||
import SettingsPage from '@/components/settings/SettingsPage';
|
||||
import NotificationsPage from '@/components/notifications/NotificationsPage';
|
||||
|
||||
// AS-2: Lazy-load all route components to reduce initial bundle parse time
|
||||
const DashboardPage = lazy(() => import('@/components/dashboard/DashboardPage'));
|
||||
const TodosPage = lazy(() => import('@/components/todos/TodosPage'));
|
||||
const CalendarPage = lazy(() => import('@/components/calendar/CalendarPage'));
|
||||
const RemindersPage = lazy(() => import('@/components/reminders/RemindersPage'));
|
||||
const ProjectsPage = lazy(() => import('@/components/projects/ProjectsPage'));
|
||||
const ProjectDetail = lazy(() => import('@/components/projects/ProjectDetail'));
|
||||
const PeoplePage = lazy(() => import('@/components/people/PeoplePage'));
|
||||
const LocationsPage = lazy(() => import('@/components/locations/LocationsPage'));
|
||||
const SettingsPage = lazy(() => import('@/components/settings/SettingsPage'));
|
||||
const NotificationsPage = lazy(() => import('@/components/notifications/NotificationsPage'));
|
||||
const AdminPortal = lazy(() => import('@/components/admin/AdminPortal'));
|
||||
|
||||
const RouteFallback = () => (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">Loading...</div>
|
||||
);
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { authStatus, isLoading } = useAuth();
|
||||
|
||||
@ -57,21 +62,21 @@ function App() {
|
||||
}
|
||||
>
|
||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="dashboard" element={<DashboardPage />} />
|
||||
<Route path="todos" element={<TodosPage />} />
|
||||
<Route path="calendar" element={<CalendarPage />} />
|
||||
<Route path="reminders" element={<RemindersPage />} />
|
||||
<Route path="projects" element={<ProjectsPage />} />
|
||||
<Route path="projects/:id" element={<ProjectDetail />} />
|
||||
<Route path="people" element={<PeoplePage />} />
|
||||
<Route path="locations" element={<LocationsPage />} />
|
||||
<Route path="notifications" element={<NotificationsPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="dashboard" element={<Suspense fallback={<RouteFallback />}><DashboardPage /></Suspense>} />
|
||||
<Route path="todos" element={<Suspense fallback={<RouteFallback />}><TodosPage /></Suspense>} />
|
||||
<Route path="calendar" element={<Suspense fallback={<RouteFallback />}><CalendarPage /></Suspense>} />
|
||||
<Route path="reminders" element={<Suspense fallback={<RouteFallback />}><RemindersPage /></Suspense>} />
|
||||
<Route path="projects" element={<Suspense fallback={<RouteFallback />}><ProjectsPage /></Suspense>} />
|
||||
<Route path="projects/:id" element={<Suspense fallback={<RouteFallback />}><ProjectDetail /></Suspense>} />
|
||||
<Route path="people" element={<Suspense fallback={<RouteFallback />}><PeoplePage /></Suspense>} />
|
||||
<Route path="locations" element={<Suspense fallback={<RouteFallback />}><LocationsPage /></Suspense>} />
|
||||
<Route path="notifications" element={<Suspense fallback={<RouteFallback />}><NotificationsPage /></Suspense>} />
|
||||
<Route path="settings" element={<Suspense fallback={<RouteFallback />}><SettingsPage /></Suspense>} />
|
||||
<Route
|
||||
path="admin/*"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<Suspense fallback={<div className="flex h-full items-center justify-center text-muted-foreground">Loading...</div>}>
|
||||
<Suspense fallback={<RouteFallback />}>
|
||||
<AdminPortal />
|
||||
</Suspense>
|
||||
</AdminRoute>
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { useState, useRef, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useMediaQuery, DESKTOP } from '@/hooks/useMediaQuery';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { format } from 'date-fns';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import FullCalendar from '@fullcalendar/react';
|
||||
@ -205,13 +206,28 @@ export default function CalendarPage() {
|
||||
return () => el.removeEventListener('wheel', handleWheel);
|
||||
}, []);
|
||||
|
||||
// AW-2: Track visible date range for scoped event fetching
|
||||
// W-02 fix: Initialize from current month to avoid unscoped first fetch
|
||||
const [visibleRange, setVisibleRange] = useState<{ start: string; end: string }>(() => {
|
||||
const now = new Date();
|
||||
const y = now.getFullYear();
|
||||
const m = now.getMonth();
|
||||
// FullCalendar month view typically fetches prev month to next month
|
||||
const start = format(new Date(y, m - 1, 1), 'yyyy-MM-dd');
|
||||
const end = format(new Date(y, m + 2, 0), 'yyyy-MM-dd');
|
||||
return { start, end };
|
||||
});
|
||||
|
||||
const { data: events = [] } = useQuery({
|
||||
queryKey: ['calendar-events'],
|
||||
queryKey: ['calendar-events', visibleRange.start, visibleRange.end],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get<CalendarEvent[]>('/events');
|
||||
const { data } = await api.get<CalendarEvent[]>('/events', {
|
||||
params: { start: visibleRange.start, end: visibleRange.end },
|
||||
});
|
||||
return data;
|
||||
},
|
||||
refetchInterval: 5_000,
|
||||
// AW-3: Reduce from 5s to 30s — personal organiser doesn't need 12 calls/min
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const selectedEvent = useMemo(
|
||||
@ -261,7 +277,10 @@ export default function CalendarPage() {
|
||||
allDay: boolean,
|
||||
revert: () => void,
|
||||
) => {
|
||||
queryClient.setQueryData<CalendarEvent[]>(['calendar-events'], (old) =>
|
||||
// C-01 fix: match active query key which includes date range
|
||||
queryClient.setQueryData<CalendarEvent[]>(
|
||||
['calendar-events', visibleRange.start, visibleRange.end],
|
||||
(old) =>
|
||||
old?.map((e) =>
|
||||
e.id === id
|
||||
? { ...e, start_datetime: start, end_datetime: end, all_day: allDay }
|
||||
@ -467,6 +486,13 @@ export default function CalendarPage() {
|
||||
const handleDatesSet = (arg: DatesSetArg) => {
|
||||
setCalendarTitle(arg.view.title);
|
||||
setCurrentView(arg.view.type as CalendarView);
|
||||
// AW-2: Capture visible range for scoped event fetching
|
||||
// C-02 fix: use format() not toISOString() to avoid UTC date shift
|
||||
const start = format(arg.start, 'yyyy-MM-dd');
|
||||
const end = format(arg.end, 'yyyy-MM-dd');
|
||||
setVisibleRange((prev) =>
|
||||
prev.start === start && prev.end === end ? prev : { start, end }
|
||||
);
|
||||
};
|
||||
|
||||
const navigatePrev = () => calendarRef.current?.getApi().prev();
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useState, useEffect, useRef, useCallback, memo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { format } from 'date-fns';
|
||||
@ -34,6 +34,75 @@ function getGreeting(name?: string): string {
|
||||
return `Good night${suffix}`;
|
||||
}
|
||||
|
||||
// AS-3: Isolated clock component — only this re-renders every minute,
|
||||
// not all 8 dashboard widgets.
|
||||
const ClockDisplay = memo(function ClockDisplay({ dataUpdatedAt, onRefresh }: {
|
||||
dataUpdatedAt?: number;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const [now, setNow] = useState(() => new Date());
|
||||
|
||||
useEffect(() => {
|
||||
let intervalId: ReturnType<typeof setInterval>;
|
||||
let timeoutId: ReturnType<typeof setTimeout>;
|
||||
|
||||
function startClock() {
|
||||
clearTimeout(timeoutId);
|
||||
clearInterval(intervalId);
|
||||
setNow(new Date());
|
||||
const msUntilNextMinute = (60 - new Date().getSeconds()) * 1000 - new Date().getMilliseconds();
|
||||
timeoutId = setTimeout(() => {
|
||||
setNow(new Date());
|
||||
intervalId = setInterval(() => setNow(new Date()), 60_000);
|
||||
}, msUntilNextMinute);
|
||||
}
|
||||
|
||||
startClock();
|
||||
function handleVisibility() {
|
||||
if (document.visibilityState === 'visible') startClock();
|
||||
}
|
||||
document.addEventListener('visibilitychange', handleVisibility);
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
clearInterval(intervalId);
|
||||
document.removeEventListener('visibilitychange', handleVisibility);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const updatedAgo = dataUpdatedAt
|
||||
? (() => {
|
||||
const mins = Math.floor((now.getTime() - dataUpdatedAt) / 60_000);
|
||||
if (mins < 1) return 'just now';
|
||||
if (mins === 1) return '1 min ago';
|
||||
return `${mins} min ago`;
|
||||
})()
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<span className="tabular-nums">{format(now, 'h:mm a')}</span>
|
||||
<span className="mx-1.5 text-muted-foreground/30">|</span>
|
||||
{format(now, 'EEEE, MMMM d, yyyy')}
|
||||
</p>
|
||||
{updatedAgo && (
|
||||
<>
|
||||
<span className="text-muted-foreground/40 text-xs">·</span>
|
||||
<span className="text-muted-foreground/60 text-xs">Updated {updatedAgo}</span>
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
className="p-0.5 rounded text-muted-foreground/40 hover:text-accent transition-colors"
|
||||
title="Refresh dashboard"
|
||||
aria-label="Refresh dashboard"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default function DashboardPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
@ -42,38 +111,7 @@ export default function DashboardPage() {
|
||||
const [quickAddType, setQuickAddType] = useState<'event' | 'todo' | 'reminder' | null>(null);
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [clockNow, setClockNow] = useState(() => new Date());
|
||||
|
||||
// Live clock — synced to the minute boundary, re-syncs after tab sleep/resume
|
||||
useEffect(() => {
|
||||
let intervalId: ReturnType<typeof setInterval>;
|
||||
let timeoutId: ReturnType<typeof setTimeout>;
|
||||
|
||||
function startClock() {
|
||||
clearTimeout(timeoutId);
|
||||
clearInterval(intervalId);
|
||||
setClockNow(new Date());
|
||||
const msUntilNextMinute = (60 - new Date().getSeconds()) * 1000 - new Date().getMilliseconds();
|
||||
timeoutId = setTimeout(() => {
|
||||
setClockNow(new Date());
|
||||
intervalId = setInterval(() => setClockNow(new Date()), 60_000);
|
||||
}, msUntilNextMinute);
|
||||
}
|
||||
|
||||
startClock();
|
||||
|
||||
// Re-sync when tab becomes visible again (after sleep/background throttle)
|
||||
function handleVisibility() {
|
||||
if (document.visibilityState === 'visible') startClock();
|
||||
}
|
||||
document.addEventListener('visibilitychange', handleVisibility);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
clearInterval(intervalId);
|
||||
document.removeEventListener('visibilitychange', handleVisibility);
|
||||
};
|
||||
}, []);
|
||||
// Clock state moved to <ClockDisplay /> (AS-3)
|
||||
|
||||
// Click outside to close dropdown
|
||||
useEffect(() => {
|
||||
@ -191,15 +229,6 @@ export default function DashboardPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const updatedAgo = dataUpdatedAt
|
||||
? (() => {
|
||||
const mins = Math.floor((clockNow.getTime() - dataUpdatedAt) / 60_000);
|
||||
if (mins < 1) return 'just now';
|
||||
if (mins === 1) return '1 min ago';
|
||||
return `${mins} min ago`;
|
||||
})()
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header — greeting + date + quick add */}
|
||||
@ -208,27 +237,7 @@ export default function DashboardPage() {
|
||||
<h1 className="font-heading text-3xl font-bold tracking-tight animate-fade-in">
|
||||
{getGreeting(settings?.preferred_name || undefined)}
|
||||
</h1>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<span className="tabular-nums">{format(clockNow, 'h:mm a')}</span>
|
||||
<span className="mx-1.5 text-muted-foreground/30">|</span>
|
||||
{format(clockNow, 'EEEE, MMMM d, yyyy')}
|
||||
</p>
|
||||
{updatedAgo && (
|
||||
<>
|
||||
<span className="text-muted-foreground/40 text-xs">·</span>
|
||||
<span className="text-muted-foreground/60 text-xs">Updated {updatedAgo}</span>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="p-0.5 rounded text-muted-foreground/40 hover:text-accent transition-colors"
|
||||
title="Refresh dashboard"
|
||||
aria-label="Refresh dashboard"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<ClockDisplay dataUpdatedAt={dataUpdatedAt} onRefresh={handleRefresh} />
|
||||
</div>
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<Button
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import api from '@/lib/api';
|
||||
import type { Calendar, SharedCalendarMembership } from '@/types';
|
||||
@ -16,16 +16,26 @@ export function useCalendars({ pollingEnabled = false }: UseCalendarsOptions = {
|
||||
},
|
||||
});
|
||||
|
||||
// AS-4: Gate shared-calendar polling on whether user participates in sharing.
|
||||
// Saves ~12 API calls/min for personal-only users.
|
||||
// Use a ref to latch "has sharing" once discovered, so polling doesn't flicker.
|
||||
const hasSharingRef = useRef(false);
|
||||
const ownsShared = (ownedQuery.data ?? []).some((c) => c.is_shared);
|
||||
if (ownsShared) hasSharingRef.current = true;
|
||||
|
||||
const sharedQuery = useQuery({
|
||||
queryKey: ['calendars', 'shared'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get<SharedCalendarMembership[]>('/shared-calendars');
|
||||
return data;
|
||||
},
|
||||
refetchInterval: pollingEnabled ? 5_000 : false,
|
||||
refetchInterval: pollingEnabled && hasSharingRef.current ? 5_000 : false,
|
||||
staleTime: 3_000,
|
||||
});
|
||||
|
||||
// Also latch if user is a member of others' shared calendars
|
||||
if ((sharedQuery.data ?? []).length > 0) hasSharingRef.current = true;
|
||||
|
||||
const allCalendarIds = useMemo(() => {
|
||||
const owned = (ownedQuery.data ?? []).map((c) => c.id);
|
||||
const shared = (sharedQuery.data ?? []).map((m) => m.calendar_id);
|
||||
|
||||
@ -9,6 +9,25 @@ export default defineConfig({
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
// AS-1: Split large dependencies into separate chunks for better caching
|
||||
manualChunks: {
|
||||
'vendor-react': ['react', 'react-dom', 'react-router-dom'],
|
||||
'vendor-query': ['@tanstack/react-query'],
|
||||
'vendor-fullcalendar': [
|
||||
'@fullcalendar/react',
|
||||
'@fullcalendar/core',
|
||||
'@fullcalendar/daygrid',
|
||||
'@fullcalendar/timegrid',
|
||||
'@fullcalendar/interaction',
|
||||
],
|
||||
'vendor-ui': ['sonner', 'lucide-react', 'date-fns'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user