diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..ed6e28eb --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,19 @@ +version: 2 +updates: + - package-ecosystem: "pip" + directory: "/backend" + schedule: + interval: "weekly" + open-pull-requests-limit: 10 + + - package-ecosystem: "npm" + directory: "/frontend" + schedule: + interval: "weekly" + open-pull-requests-limit: 10 + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 10 diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml new file mode 100644 index 00000000..29d90b1b --- /dev/null +++ b/.github/workflows/backend-ci.yml @@ -0,0 +1,53 @@ +name: Backend CI + +on: + pull_request: + paths: + - "backend/**" + - ".github/workflows/backend-ci.yml" + push: + branches: + - main + paths: + - "backend/**" + - ".github/workflows/backend-ci.yml" + +concurrency: + group: backend-ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + checks: + runs-on: ubuntu-latest + defaults: + run: + working-directory: backend + env: + DJANGO_ENV: development + DJANGO_DEBUG: "true" + DJANGO_SECRET_KEY: ci-insecure-secret-key + EMAIL_BACKEND: django.core.mail.backends.console.EmailBackend + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: "pip" + cache-dependency-path: backend/requirements.txt + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Django check + run: python manage.py check + + - name: Migrations check + run: python manage.py makemigrations --check --dry-run + + - name: Run tests + run: python manage.py test diff --git a/.github/workflows/due-reminders.yml b/.github/workflows/due-reminders.yml new file mode 100644 index 00000000..55f7eaf1 --- /dev/null +++ b/.github/workflows/due-reminders.yml @@ -0,0 +1,45 @@ +name: Due Reminders + +on: + schedule: + - cron: "*/10 * * * *" + workflow_dispatch: + +jobs: + send-reminders: + if: ${{ secrets.PROD_DATABASE_URL != '' }} + runs-on: ubuntu-latest + defaults: + run: + working-directory: backend + env: + DJANGO_ENV: production + DJANGO_DEBUG: "false" + DJANGO_SECRET_KEY: ${{ secrets.PROD_DJANGO_SECRET_KEY }} + DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }} + ALLOWED_HOSTS: ${{ secrets.PROD_ALLOWED_HOSTS }} + EMAIL_BACKEND: django.core.mail.backends.smtp.EmailBackend + EMAIL_HOST: ${{ secrets.PROD_EMAIL_HOST }} + EMAIL_PORT: ${{ secrets.PROD_EMAIL_PORT }} + EMAIL_USE_TLS: "true" + EMAIL_HOST_USER: ${{ secrets.PROD_EMAIL_HOST_USER }} + EMAIL_HOST_PASSWORD: ${{ secrets.PROD_EMAIL_HOST_PASSWORD }} + DEFAULT_FROM_EMAIL: ${{ secrets.PROD_DEFAULT_FROM_EMAIL }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: "pip" + cache-dependency-path: backend/requirements.txt + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Run due reminders command + run: python manage.py send_due_reminders diff --git a/.github/workflows/gitleaks.yml b/.github/workflows/gitleaks.yml new file mode 100644 index 00000000..2bdd95c2 --- /dev/null +++ b/.github/workflows/gitleaks.yml @@ -0,0 +1,25 @@ +name: Secret Scan + +on: + pull_request: + push: + branches: + - main + - master + +jobs: + gitleaks: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Run gitleaks + uses: gitleaks/gitleaks-action@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/prod-smoke.yml b/.github/workflows/prod-smoke.yml new file mode 100644 index 00000000..0f532c97 --- /dev/null +++ b/.github/workflows/prod-smoke.yml @@ -0,0 +1,20 @@ +name: Production Smoke + +on: + workflow_dispatch: + +jobs: + smoke: + runs-on: ubuntu-latest + steps: + - name: Health endpoint + run: | + curl --fail --silent --show-error "${{ secrets.PROD_BACKEND_HEALTH_URL }}" + + - name: Frontend homepage + run: | + curl --fail --silent --show-error "${{ secrets.PROD_FRONTEND_URL }}" + + - name: Frontend manifest + run: | + curl --fail --silent --show-error "${{ secrets.PROD_FRONTEND_URL }}/manifest.json" diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 00000000..942d5f12 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,10 @@ +title = "Boardly Gitleaks Config" + +[allowlist] +description = "Allow local/dev placeholders that are not real credentials." +regexes = [ + '''replace-with-long-random-secret''', + '''ci-insecure-secret-key''', + '''your-login''', + '''your-password''', +] diff --git a/README.md b/README.md index 0eca1b74..ac49de88 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,8 @@ For authenticated smoke tests, set: - Backend uses ASGI (`daphne`) for HTTP + WebSocket support. - Frontend production build is served via `nginx`. - CI workflow lives in `.github/workflows/frontend-ci.yml`. +- Backend CI workflow lives in `.github/workflows/backend-ci.yml`. +- Secret scan workflow lives in `.github/workflows/gitleaks.yml`. ### Realtime Config (Redis + WS reconnect) - Backend channel layer: @@ -76,6 +78,29 @@ For authenticated smoke tests, set: - `REACT_APP_WS_MAX_RECONNECT_MS` - `REACT_APP_WS_RECONNECT_JITTER_MS` +### Observability +- Backend Sentry: + - `SENTRY_DSN` + - `SENTRY_TRACES_SAMPLE_RATE` +- Frontend Sentry: + - `REACT_APP_SENTRY_DSN` + - `REACT_APP_SENTRY_ENV` + - `REACT_APP_SENTRY_TRACES_SAMPLE_RATE` +- Health endpoints: + - `GET /api/health/` + - `GET /healthz/` + +### Security Baseline +- Rotate all leaked/legacy SMTP credentials and update production secrets. +- Dependabot config: `.github/dependabot.yml`. +- Secret scanning in CI: `gitleaks` workflow. +- For GitHub branch protection (requires repository admin), run: + - `pwsh scripts/github/apply-branch-protection.ps1` +- To enable GitHub secret scanning/push protection (requires repository admin), run: + - `pwsh scripts/github/enable-security-analysis.ps1` +- Production deploy checklist: + - `docs/ops/deploy-checklist.md` + ## License MIT License. See `LICENSE`. diff --git a/backend/.env.example b/backend/.env.example index 5088d14a..7a92b2bb 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -12,6 +12,10 @@ SECURE_HSTS_INCLUDE_SUBDOMAINS=True SECURE_HSTS_PRELOAD=True SECURE_PROXY_SSL_HEADER_ENABLED=True +# Observability +SENTRY_DSN= +SENTRY_TRACES_SAMPLE_RATE=0.1 + # PostgreSQL (recommended for production) DATABASE_URL=postgresql://user:password@host:5432/dbname?sslmode=require @@ -20,7 +24,8 @@ DATABASE_URL=postgresql://user:password@host:5432/dbname?sslmode=require # If empty, backend falls back to in-memory layer (single instance/dev only). CHANNEL_REDIS_URL=redis://default:password@host:6379/0 -# SMTP +# SMTP (required in production when EMAIL_BACKEND is SMTP) +# Never commit real credentials to git. EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend EMAIL_HOST_USER= EMAIL_HOST_PASSWORD= diff --git a/backend/boardly_project/settings.py b/backend/boardly_project/settings.py index 8b994c50..8b137dd3 100644 --- a/backend/boardly_project/settings.py +++ b/backend/boardly_project/settings.py @@ -6,6 +6,7 @@ import os import warnings +import logging import dj_database_url from pathlib import Path from django.core.exceptions import ImproperlyConfigured @@ -43,9 +44,41 @@ def _env_int(name: str, default: int) -> int: raise ImproperlyConfigured(f'{name} must be an integer.') from exc +def _env_float(name: str, default: float) -> float: + value = os.getenv(name) + if value is None: + return default + try: + return float(value) + except ValueError as exc: + raise ImproperlyConfigured(f'{name} must be a float.') from exc + + DJANGO_ENV = os.getenv('DJANGO_ENV', 'development').strip().lower() IS_PRODUCTION = DJANGO_ENV in {'production', 'prod'} +SENTRY_DSN = os.getenv('SENTRY_DSN', '').strip() +SENTRY_TRACES_SAMPLE_RATE = _env_float('SENTRY_TRACES_SAMPLE_RATE', 0.0) + +if SENTRY_DSN: + try: + import sentry_sdk + from sentry_sdk.integrations.django import DjangoIntegration + from sentry_sdk.integrations.logging import LoggingIntegration + + sentry_sdk.init( + dsn=SENTRY_DSN, + environment=DJANGO_ENV, + traces_sample_rate=SENTRY_TRACES_SAMPLE_RATE, + integrations=[ + DjangoIntegration(), + LoggingIntegration(level=logging.INFO, event_level=logging.ERROR), + ], + send_default_pii=False, + ) + except Exception as exc: + warnings.warn(f'Failed to initialize Sentry: {exc}', RuntimeWarning) + # SECURITY WARNING: keep the secret key used in production secret! SECRET_KEY = os.getenv('DJANGO_SECRET_KEY', 'unsafe-dev-key-change-me') if IS_PRODUCTION and (not os.getenv('DJANGO_SECRET_KEY') or SECRET_KEY == 'unsafe-dev-key-change-me'): @@ -321,8 +354,8 @@ def _split_env(value: str) -> list[str]: EMAIL_USE_TLS = _env_bool('EMAIL_USE_TLS', True) # У dev автоматично вмикаємо SMTP, якщо креденшали задані. -EMAIL_HOST_USER = os.getenv('EMAIL_HOST_USER', 'tititimurc@gmail.com').strip() -EMAIL_HOST_PASSWORD = os.getenv('EMAIL_HOST_PASSWORD', 'fptmriinkxgsseln').strip() +EMAIL_HOST_USER = os.getenv('EMAIL_HOST_USER', '').strip() +EMAIL_HOST_PASSWORD = os.getenv('EMAIL_HOST_PASSWORD', '').strip() EMAIL_BACKEND = os.getenv('EMAIL_BACKEND', '').strip() if not EMAIL_BACKEND: @@ -335,6 +368,15 @@ def _split_env(value: str) -> list[str]: else 'django.core.mail.backends.smtp.EmailBackend' ) +if ( + IS_PRODUCTION + and EMAIL_BACKEND == 'django.core.mail.backends.smtp.EmailBackend' + and (not EMAIL_HOST_USER or not EMAIL_HOST_PASSWORD) +): + raise ImproperlyConfigured( + 'SMTP backend requires EMAIL_HOST_USER and EMAIL_HOST_PASSWORD in production.' + ) + DEFAULT_FROM_EMAIL = os.getenv( 'DEFAULT_FROM_EMAIL', f'Boardly <{EMAIL_HOST_USER}>' if EMAIL_HOST_USER else 'Boardly ' diff --git a/backend/boardly_project/urls.py b/backend/boardly_project/urls.py index 6bbffed2..d56430e0 100644 --- a/backend/boardly_project/urls.py +++ b/backend/boardly_project/urls.py @@ -3,9 +3,11 @@ from django.conf import settings from django.conf.urls.static import static from django.views.static import serve +from core.api.views import HealthCheckView urlpatterns = [ path('admin/', admin.site.urls), + path('healthz/', HealthCheckView.as_view(), name='healthz'), # Маршрути аутентифікації Djoser (реєстрація, логін, токени) path('api/auth/', include('djoser.urls')), diff --git a/backend/core/api/serializers/boards.py b/backend/core/api/serializers/boards.py index 94fe2e91..8354fca1 100644 --- a/backend/core/api/serializers/boards.py +++ b/backend/core/api/serializers/boards.py @@ -1,6 +1,7 @@ from rest_framework import serializers from django.contrib.auth.models import User from core.models import Board, Membership, Label, Activity +from core.services.board_templates import BOARD_TEMPLATES from .users import UserSerializer import logging @@ -39,6 +40,12 @@ class BoardSerializer(serializers.ModelSerializer): members = MembershipSerializer(source='membership_set', many=True, read_only=True) lists = serializers.SerializerMethodField() labels = LabelSerializer(many=True, read_only=True) + template_key = serializers.ChoiceField( + choices=tuple((key, key) for key in BOARD_TEMPLATES.keys()), + required=False, + write_only=True, + default='blank', + ) # Динамічне поле: чи є дошка в обраному у поточного користувача is_favorite = serializers.SerializerMethodField() @@ -48,12 +55,17 @@ class Meta: 'id', 'title', 'description', 'background_url', 'is_archived', 'is_favorite', 'owner', 'created_at', 'invite_link', 'members', 'lists', 'labels', + 'template_key', 'dev_can_create_cards', 'dev_can_edit_assigned_cards', 'dev_can_archive_assigned_cards', 'dev_can_join_card', 'dev_can_create_lists' ) read_only_fields = ('owner', 'invite_link', 'created_at') + def create(self, validated_data): + validated_data.pop('template_key', None) + return super().create(validated_data) + def get_lists(self, obj): from .cards import ListSerializer queryset = obj.lists.all() diff --git a/backend/core/api/serializers/cards.py b/backend/core/api/serializers/cards.py index 84153bd2..dd416e0e 100644 --- a/backend/core/api/serializers/cards.py +++ b/backend/core/api/serializers/cards.py @@ -24,7 +24,7 @@ class Meta: model = Card fields = ( 'id', 'title', 'description', 'card_color', 'cover_size', 'order', 'due_date', - 'is_completed', 'is_archived', 'is_public', # <-- ДОДАНО + 'is_completed', 'is_archived', 'is_public', 'recurrence', 'reminder_minutes_before', 'list', 'board', 'board_title', 'members', 'labels', 'label_ids', 'checklists', 'attachments', 'comments' @@ -70,7 +70,21 @@ class MyCardSerializer(serializers.ModelSerializer): class Meta: model = Card # Додано is_public - fields = ('id', 'title', 'description', 'card_color', 'cover_size', 'due_date', 'is_archived', 'board', 'list', 'labels', 'is_public') + fields = ( + 'id', + 'title', + 'description', + 'card_color', + 'cover_size', + 'due_date', + 'is_archived', + 'board', + 'list', + 'labels', + 'is_public', + 'recurrence', + 'reminder_minutes_before', + ) def get_labels(self, obj): labels = Label.objects.filter(cardlabel__card=obj) diff --git a/backend/core/api/urls.py b/backend/core/api/urls.py index ab6bc02c..29c5a1f3 100644 --- a/backend/core/api/urls.py +++ b/backend/core/api/urls.py @@ -4,7 +4,7 @@ UserViewSet, BoardViewSet, ListViewSet, CardViewSet, LabelViewSet, ChecklistViewSet, ChecklistItemViewSet, ActivityViewSet, ActivityLogViewSet, GoogleLogin, BoardMemberViewSet, FavoriteBoardViewSet, - AttachmentViewSet, CommentViewSet, MyCardsViewSet + AttachmentViewSet, CommentViewSet, MyCardsViewSet, HealthCheckView, ) # Створюємо роутер і реєструємо всі ViewSet'и @@ -25,6 +25,7 @@ router.register(r'activity', ActivityLogViewSet, basename='activity-log') urlpatterns = [ + path('health/', HealthCheckView.as_view(), name='health'), # Всі маршрути з роутера path('', include(router.urls)), diff --git a/backend/core/api/views/__init__.py b/backend/core/api/views/__init__.py index d537cd64..298c2de0 100644 --- a/backend/core/api/views/__init__.py +++ b/backend/core/api/views/__init__.py @@ -2,10 +2,12 @@ from .boards import BoardViewSet, FavoriteBoardViewSet, BoardMemberViewSet, LabelViewSet, ActivityViewSet from .cards import ListViewSet, CardViewSet, MyCardsViewSet from .details import ChecklistViewSet, ChecklistItemViewSet, AttachmentViewSet, CommentViewSet +from .system import HealthCheckView __all__ = [ 'UserViewSet', 'GoogleLogin', 'ActivityLogViewSet', 'BoardViewSet', 'FavoriteBoardViewSet', 'BoardMemberViewSet', 'LabelViewSet', 'ActivityViewSet', 'ListViewSet', 'CardViewSet', 'MyCardsViewSet', - 'ChecklistViewSet', 'ChecklistItemViewSet', 'AttachmentViewSet', 'CommentViewSet' + 'ChecklistViewSet', 'ChecklistItemViewSet', 'AttachmentViewSet', 'CommentViewSet', + 'HealthCheckView', ] diff --git a/backend/core/api/views/boards.py b/backend/core/api/views/boards.py index 1b003c55..aa52ad6f 100644 --- a/backend/core/api/views/boards.py +++ b/backend/core/api/views/boards.py @@ -9,6 +9,7 @@ from core.api.serializers import BoardSerializer, MembershipSerializer, LabelSerializer, ActivitySerializer from core.services.activity_logger import log_activity from core.services.permissions import IsOwnerOrReadOnly, ensure_board_admin +from core.services.board_templates import get_board_template, list_board_templates class BoardViewSet(viewsets.ModelViewSet): serializer_class = BoardSerializer @@ -30,19 +31,28 @@ def get_queryset(self): return Board.objects.filter(Q(owner=user) | Q(members=user)).distinct() def perform_create(self, serializer): + template_key = serializer.validated_data.get('template_key') + template = get_board_template(template_key) board = serializer.save(owner=self.request.user) # Створюємо членство для власника Membership.objects.create(user=self.request.user, board=board, role='admin') - - List.objects.bulk_create([ - List(title='To Do', board=board, order=1), - List(title='In Progress', board=board, order=2), - List(title='Done', board=board, order=3), - ]) + + list_titles = template.get('lists') or ['To Do', 'In Progress', 'Done'] + List.objects.bulk_create( + [List(title=title, board=board, order=index) for index, title in enumerate(list_titles, start=1)] + ) + + for label in template.get('labels', []): + name = (label.get('name') or '').strip() + color = (label.get('color') or '').strip() or '#0079bf' + if name: + Label.objects.get_or_create(board=board, name=name, defaults={'color': color}) + log_activity(self.request.user, 'create_board', 'board', board.id, { 'title': board.title, 'board_id': board.id, - 'board_title': board.title + 'board_title': board.title, + 'template_key': template_key or 'blank', }) def perform_update(self, serializer): @@ -93,6 +103,10 @@ def join(self, request): serializer = self.get_serializer(board) return Response(serializer.data) + @action(detail=False, methods=['get'], url_path='templates') + def templates(self, request): + return Response(list_board_templates()) + class FavoriteBoardViewSet(viewsets.ReadOnlyModelViewSet): serializer_class = BoardSerializer permission_classes = [permissions.IsAuthenticated] diff --git a/backend/core/api/views/cards.py b/backend/core/api/views/cards.py index 6b3ce4d9..f43bcb4d 100644 --- a/backend/core/api/views/cards.py +++ b/backend/core/api/views/cards.py @@ -11,6 +11,7 @@ from core.models import List, Card, CardMember, Checklist, ChecklistItem, CardLabel, Membership, Label from core.api.serializers import ListSerializer, CardSerializer, MyCardSerializer from core.services.activity_logger import log_activity +from core.services.recurrence import get_next_due_date from core.services.permissions import ( ensure_board_admin, ensure_card_edit, @@ -162,7 +163,9 @@ def copy(self, request, pk=None): order=card.order, due_date=card.due_date, is_completed=card.is_completed, - is_public=card.is_public + is_public=card.is_public, + recurrence=card.recurrence, + reminder_minutes_before=card.reminder_minutes_before, ) # Копіюємо мітки @@ -290,12 +293,35 @@ def perform_update(self, serializer): prev_description = previous.description prev_due_date = previous.due_date prev_completed = previous.is_completed + prev_recurrence = previous.recurrence + prev_reminder_minutes_before = previous.reminder_minutes_before prev_label_ids = set(CardLabel.objects.filter(card=previous).values_list('label_id', flat=True)) card = serializer.save() board_id = card.list.board_id if card.list_id else None board_title = card.list.board.title if card.list_id else None + completed_just_now = ( + 'is_completed' in serializer.validated_data + and prev_completed is False + and card.is_completed is True + ) + if completed_just_now and card.recurrence != 'none' and card.due_date: + next_due_date = get_next_due_date(card.due_date, card.recurrence) + card.is_completed = False + card.due_date = next_due_date + card.last_reminder_sent_at = None + card.save(update_fields=['is_completed', 'due_date', 'last_reminder_sent_at']) + log_activity(self.request.user, 'reschedule_recurring_card', 'card', card.id, { + 'board_id': board_id, + 'board_title': board_title, + 'card_id': card.id, + 'title': card.title, + 'recurrence': card.recurrence, + 'due_before': prev_due_date.isoformat() if prev_due_date else None, + 'due_after': next_due_date.isoformat(), + }) + # --- Логування --- if 'list' in serializer.validated_data and card.list_id != prev_list_id: from_list = List.objects.filter(id=prev_list_id).first() @@ -344,6 +370,23 @@ def perform_update(self, serializer): 'due_before': prev_due_date.isoformat() if prev_due_date else None, 'due_after': card.due_date.isoformat() if card.due_date else None }) + if ( + 'recurrence' in serializer.validated_data + and card.recurrence != prev_recurrence + ) or ( + 'reminder_minutes_before' in serializer.validated_data + and card.reminder_minutes_before != prev_reminder_minutes_before + ): + log_activity(self.request.user, 'update_card_schedule', 'card', card.id, { + 'board_id': board_id, + 'board_title': board_title, + 'card_id': card.id, + 'title': card.title, + 'recurrence_before': prev_recurrence, + 'recurrence_after': card.recurrence, + 'reminder_before': prev_reminder_minutes_before, + 'reminder_after': card.reminder_minutes_before, + }) if 'label_ids' in serializer.validated_data: new_label_ids = set(CardLabel.objects.filter(card=card).values_list('label_id', flat=True)) added_ids = new_label_ids - prev_label_ids @@ -452,7 +495,9 @@ def copy(self, request, pk=None): cover_size=original_card.cover_size, order=original_card.order + 1, due_date=original_card.due_date, - is_public=original_card.is_public + is_public=original_card.is_public, + recurrence=original_card.recurrence, + reminder_minutes_before=original_card.reminder_minutes_before, ) for card_label in CardLabel.objects.filter(card=original_card): diff --git a/backend/core/api/views/system.py b/backend/core/api/views/system.py new file mode 100644 index 00000000..df690d7b --- /dev/null +++ b/backend/core/api/views/system.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from django.conf import settings +from django.db import connection +from django.utils import timezone +from rest_framework import permissions +from rest_framework.response import Response +from rest_framework.views import APIView + + +def _check_database() -> tuple[bool, str | None]: + try: + with connection.cursor() as cursor: + cursor.execute('SELECT 1') + cursor.fetchone() + return True, None + except Exception as exc: + return False, str(exc) + + +class HealthCheckView(APIView): + permission_classes = [permissions.AllowAny] + authentication_classes = [] + + def get(self, request): + db_ok, db_error = _check_database() + status_code = 200 if db_ok else 503 + + payload = { + 'status': 'ok' if db_ok else 'degraded', + 'timestamp': timezone.now().isoformat(), + 'services': { + 'database': 'ok' if db_ok else 'error', + 'redis': 'configured' if getattr(settings, 'CHANNEL_REDIS_URL', '') else 'not_configured', + 'sentry': 'enabled' if getattr(settings, 'SENTRY_DSN', '') else 'disabled', + }, + } + if db_error: + payload['errors'] = {'database': db_error} + return Response(payload, status=status_code) diff --git a/backend/core/management/__init__.py b/backend/core/management/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/backend/core/management/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/core/management/commands/__init__.py b/backend/core/management/commands/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/backend/core/management/commands/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/core/management/commands/send_due_reminders.py b/backend/core/management/commands/send_due_reminders.py new file mode 100644 index 00000000..1d1e19f2 --- /dev/null +++ b/backend/core/management/commands/send_due_reminders.py @@ -0,0 +1,11 @@ +from django.core.management.base import BaseCommand + +from core.services.due_reminders import send_due_reminders + + +class Command(BaseCommand): + help = 'Send due-date reminder emails for cards with reminder settings.' + + def handle(self, *args, **options): + sent = send_due_reminders() + self.stdout.write(self.style.SUCCESS(f'Sent reminders: {sent}')) diff --git a/backend/core/migrations/0023_card_last_reminder_sent_at_card_recurrence_and_more.py b/backend/core/migrations/0023_card_last_reminder_sent_at_card_recurrence_and_more.py new file mode 100644 index 00000000..86617651 --- /dev/null +++ b/backend/core/migrations/0023_card_last_reminder_sent_at_card_recurrence_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 5.2 on 2026-03-01 13:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0022_profile_pending_password_fields'), + ] + + operations = [ + migrations.AddField( + model_name='card', + name='last_reminder_sent_at', + field=models.DateTimeField(blank=True, null=True, verbose_name='Last due reminder sent at'), + ), + migrations.AddField( + model_name='card', + name='recurrence', + field=models.CharField(choices=[('none', 'None'), ('daily', 'Daily'), ('weekly', 'Weekly'), ('monthly', 'Monthly')], default='none', max_length=10, verbose_name='Recurring schedule'), + ), + migrations.AddField( + model_name='card', + name='reminder_minutes_before', + field=models.PositiveIntegerField(blank=True, null=True, verbose_name='Reminder minutes before due date'), + ), + ] diff --git a/backend/core/models/cards.py b/backend/core/models/cards.py index 8070d974..edfb997b 100644 --- a/backend/core/models/cards.py +++ b/backend/core/models/cards.py @@ -28,6 +28,13 @@ class Card(models.Model): """ Завдання (Картка). """ + RECURRENCE_CHOICES = ( + ('none', 'None'), + ('daily', 'Daily'), + ('weekly', 'Weekly'), + ('monthly', 'Monthly'), + ) + list = models.ForeignKey(List, on_delete=models.CASCADE, related_name='cards', verbose_name="Список") title = models.CharField(max_length=255, verbose_name="Заголовок Картки") description = models.TextField(blank=True, verbose_name="Опис Картки") @@ -40,6 +47,22 @@ class Card(models.Model): # НОВЕ ПОЛЕ: Статус приватності картки is_public = models.BooleanField(default=True, verbose_name="Публічна картка") + recurrence = models.CharField( + max_length=10, + choices=RECURRENCE_CHOICES, + default='none', + verbose_name="Recurring schedule", + ) + reminder_minutes_before = models.PositiveIntegerField( + null=True, + blank=True, + verbose_name="Reminder minutes before due date", + ) + last_reminder_sent_at = models.DateTimeField( + null=True, + blank=True, + verbose_name="Last due reminder sent at", + ) members = models.ManyToManyField(User, through='CardMember', related_name='assigned_cards', verbose_name="Призначені учасники") diff --git a/backend/core/services/board_templates.py b/backend/core/services/board_templates.py new file mode 100644 index 00000000..21199e48 --- /dev/null +++ b/backend/core/services/board_templates.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from typing import Any + + +DEFAULT_TEMPLATE_KEY = 'blank' + +BOARD_TEMPLATES: dict[str, dict[str, Any]] = { + 'blank': { + 'name': 'Blank board', + 'description': 'Simple kanban board with 3 default lists.', + 'lists': ['To Do', 'In Progress', 'Done'], + 'labels': [], + }, + 'product_roadmap': { + 'name': 'Product roadmap', + 'description': 'Plan roadmap initiatives from idea to release.', + 'lists': ['Ideas', 'Planned', 'In Progress', 'Released'], + 'labels': [ + {'name': 'Feature', 'color': '#0079bf'}, + {'name': 'Improvement', 'color': '#61bd4f'}, + {'name': 'Research', 'color': '#f2d600'}, + {'name': 'Blocker', 'color': '#eb5a46'}, + ], + }, + 'bug_triage': { + 'name': 'Bug triage', + 'description': 'Track incoming bugs and prioritize fixes.', + 'lists': ['Inbox', 'Needs Repro', 'Ready to Fix', 'In QA', 'Done'], + 'labels': [ + {'name': 'P0', 'color': '#eb5a46'}, + {'name': 'P1', 'color': '#ff9f1a'}, + {'name': 'P2', 'color': '#f2d600'}, + {'name': 'P3', 'color': '#61bd4f'}, + ], + }, +} + + +def get_template_key_or_default(raw_key: str | None) -> str: + key = (raw_key or '').strip() or DEFAULT_TEMPLATE_KEY + return key if key in BOARD_TEMPLATES else DEFAULT_TEMPLATE_KEY + + +def get_board_template(raw_key: str | None) -> dict[str, Any]: + return BOARD_TEMPLATES[get_template_key_or_default(raw_key)] + + +def list_board_templates() -> list[dict[str, Any]]: + items: list[dict[str, Any]] = [] + for key, data in BOARD_TEMPLATES.items(): + items.append( + { + 'key': key, + 'name': data['name'], + 'description': data['description'], + 'lists': list(data.get('lists', [])), + 'labels': list(data.get('labels', [])), + } + ) + return items diff --git a/backend/core/services/due_reminders.py b/backend/core/services/due_reminders.py new file mode 100644 index 00000000..e8affd13 --- /dev/null +++ b/backend/core/services/due_reminders.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +from datetime import timedelta + +from django.conf import settings +from django.core.mail import send_mail +from django.utils import timezone + +from core.models import Card + + +def _card_recipients(card: Card) -> list[str]: + recipients: set[str] = set() + + for member in card.members.select_related('profile').all(): + profile = getattr(member, 'profile', None) + if member.email and (profile is None or profile.notify_email): + recipients.add(member.email) + + board_owner = getattr(card.list.board, 'owner', None) + if board_owner and board_owner.email: + owner_profile = getattr(board_owner, 'profile', None) + if owner_profile is None or owner_profile.notify_email: + recipients.add(board_owner.email) + + return sorted(recipients) + + +def get_cards_due_for_reminder(now=None): + now = now or timezone.now() + candidates = ( + Card.objects.select_related('list__board__owner') + .prefetch_related('members__profile') + .filter( + is_archived=False, + is_completed=False, + due_date__isnull=False, + reminder_minutes_before__isnull=False, + ) + ) + + due_cards = [] + for card in candidates: + if not card.due_date: + continue + reminder_minutes = card.reminder_minutes_before or 0 + reminder_at = card.due_date - timedelta(minutes=reminder_minutes) + if reminder_at > now: + continue + if card.last_reminder_sent_at and card.last_reminder_sent_at >= reminder_at: + continue + due_cards.append(card) + + return due_cards + + +def send_due_reminders(now=None) -> int: + now = now or timezone.now() + sent = 0 + cards = get_cards_due_for_reminder(now=now) + + for card in cards: + recipients = _card_recipients(card) + if not recipients: + continue + + subject = f'Boardly reminder: "{card.title}" is due soon' + due = card.due_date.astimezone().strftime('%Y-%m-%d %H:%M') + board_title = card.list.board.title if card.list_id else 'Board' + list_title = card.list.title if card.list_id else 'List' + message = ( + "This is an automatic reminder from Boardly.\n\n" + f"Card: {card.title}\n" + f"Board: {board_title}\n" + f"List: {list_title}\n" + f"Due date: {due}\n\n" + "Open your board to review and update the task." + ) + + send_mail( + subject=subject, + message=message, + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=recipients, + fail_silently=False, + ) + + card.last_reminder_sent_at = now + card.save(update_fields=['last_reminder_sent_at']) + sent += 1 + + return sent diff --git a/backend/core/services/recurrence.py b/backend/core/services/recurrence.py new file mode 100644 index 00000000..db1d6c0f --- /dev/null +++ b/backend/core/services/recurrence.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +import calendar +from datetime import datetime, timedelta + + +def get_next_due_date(current_due_date: datetime, recurrence: str) -> datetime: + if recurrence == 'daily': + return current_due_date + timedelta(days=1) + if recurrence == 'weekly': + return current_due_date + timedelta(days=7) + if recurrence == 'monthly': + year = current_due_date.year + month = current_due_date.month + 1 + if month > 12: + month = 1 + year += 1 + last_day = calendar.monthrange(year, month)[1] + day = min(current_due_date.day, last_day) + return current_due_date.replace(year=year, month=month, day=day) + return current_due_date diff --git a/backend/core/tests_extended.py b/backend/core/tests_extended.py new file mode 100644 index 00000000..e90ad58f --- /dev/null +++ b/backend/core/tests_extended.py @@ -0,0 +1,205 @@ +from datetime import timedelta + +from django.contrib.auth.models import User +from django.core import mail +from django.test import override_settings +from django.utils import timezone +from rest_framework import status +from rest_framework.test import APITestCase + +from core.models import Board, Card, CardMember, List, Membership, Profile + + +class PasswordFlowTests(APITestCase): + def setUp(self): + self.password = 'CurrentPass123!' + self.user = User.objects.create_user( + username='password_user', + email='password_user@example.com', + password=self.password, + ) + + def _login(self): + response = self.client.post( + '/api/auth/token/login/', + {'username': self.user.username, 'password': self.password}, + format='json', + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + token = response.data['auth_token'] + self.client.credentials(HTTP_AUTHORIZATION=f'Token {token}') + + @override_settings( + EMAIL_BACKEND='django.core.mail.backends.locmem.EmailBackend', + DEFAULT_FROM_EMAIL='noreply@example.com', + ) + def test_request_and_confirm_password_change(self): + self._login() + new_password = 'BrandNewPass456!' + + request_response = self.client.post( + '/api/users/me/password/request-change/', + { + 'current_password': self.password, + 'new_password': new_password, + 're_new_password': new_password, + }, + format='json', + ) + self.assertEqual(request_response.status_code, status.HTTP_200_OK) + self.assertEqual(len(mail.outbox), 1) + + body = mail.outbox[0].body + link = next((line.strip() for line in body.splitlines() if '/password-change-confirm/' in line), '') + self.assertTrue(link) + parts = link.rstrip('/').split('/') + uid, token = parts[-2], parts[-1] + + confirm_response = self.client.post( + '/api/users/password/confirm-change/', + {'uid': uid, 'token': token}, + format='json', + ) + self.assertEqual(confirm_response.status_code, status.HTTP_200_OK, confirm_response.data) + + self.user.refresh_from_db() + profile = Profile.objects.get(user=self.user) + self.assertTrue(self.user.check_password(new_password)) + self.assertEqual(profile.pending_password_hash, '') + self.assertIsNone(profile.pending_password_requested_at) + + +class BoardPermissionMatrixTests(APITestCase): + def setUp(self): + self.owner = User.objects.create_user('owner', 'owner@example.com', 'OwnerPass123!') + self.developer = User.objects.create_user('developer', 'developer@example.com', 'DeveloperPass123!') + self.viewer = User.objects.create_user('viewer', 'viewer@example.com', 'ViewerPass123!') + + self.board = Board.objects.create(title='Permission Board', owner=self.owner) + Membership.objects.create(user=self.owner, board=self.board, role='admin') + Membership.objects.create(user=self.developer, board=self.board, role='developer') + Membership.objects.create(user=self.viewer, board=self.board, role='viewer') + self.list = List.objects.create(board=self.board, title='To Do', order=1) + self.card = Card.objects.create(list=self.list, title='Test card', order=1) + CardMember.objects.create(card=self.card, user=self.developer) + + def _login(self, username: str, password: str): + response = self.client.post( + '/api/auth/token/login/', + {'username': username, 'password': password}, + format='json', + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.client.credentials(HTTP_AUTHORIZATION=f"Token {response.data['auth_token']}") + + def test_viewer_cannot_create_card(self): + self._login('viewer', 'ViewerPass123!') + response = self.client.post( + '/api/cards/', + {'list': self.list.id, 'title': 'Viewer card', 'order': 2}, + format='json', + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_developer_can_create_card_when_board_allows_it(self): + self._login('developer', 'DeveloperPass123!') + response = self.client.post( + '/api/cards/', + {'list': self.list.id, 'title': 'Developer card', 'order': 2}, + format='json', + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + def test_developer_cannot_create_list_when_forbidden(self): + self.board.dev_can_create_lists = False + self.board.save(update_fields=['dev_can_create_lists']) + self._login('developer', 'DeveloperPass123!') + response = self.client.post( + '/api/lists/', + {'board': self.board.id, 'title': 'New list', 'order': 2}, + format='json', + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_developer_archive_permission_respects_flag(self): + self._login('developer', 'DeveloperPass123!') + response_allowed = self.client.patch( + f'/api/cards/{self.card.id}/', + {'is_archived': True}, + format='json', + ) + self.assertEqual(response_allowed.status_code, status.HTTP_200_OK) + + self.card.refresh_from_db() + self.card.is_archived = False + self.card.save(update_fields=['is_archived']) + self.board.dev_can_archive_assigned_cards = False + self.board.save(update_fields=['dev_can_archive_assigned_cards']) + + response_forbidden = self.client.patch( + f'/api/cards/{self.card.id}/', + {'is_archived': True}, + format='json', + ) + self.assertEqual(response_forbidden.status_code, status.HTTP_403_FORBIDDEN) + + +class BoardTemplateAndRecurrenceTests(APITestCase): + def setUp(self): + self.password = 'OwnerPass123!' + self.owner = User.objects.create_user('template_owner', 'template@example.com', self.password) + + def _login(self): + response = self.client.post( + '/api/auth/token/login/', + {'username': self.owner.username, 'password': self.password}, + format='json', + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.client.credentials(HTTP_AUTHORIZATION=f"Token {response.data['auth_token']}") + + def test_create_board_with_template(self): + self._login() + response = self.client.post( + '/api/boards/', + {'title': 'Roadmap Board', 'template_key': 'product_roadmap'}, + format='json', + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + board = Board.objects.get(id=response.data['id']) + titles = list(board.lists.order_by('order').values_list('title', flat=True)) + self.assertEqual(titles, ['Ideas', 'Planned', 'In Progress', 'Released']) + self.assertGreater(board.labels.count(), 0) + + def test_health_endpoint_is_public(self): + response = self.client.get('/api/health/') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['services']['database'], 'ok') + + def test_complete_recurring_card_reschedules_due_date(self): + self._login() + board = Board.objects.create(title='Recurring Board', owner=self.owner) + Membership.objects.create(user=self.owner, board=board, role='admin') + list_obj = List.objects.create(board=board, title='To Do', order=1) + due = timezone.now() + timedelta(days=1) + card = Card.objects.create( + list=list_obj, + title='Recurring card', + order=1, + due_date=due, + recurrence='daily', + ) + + response = self.client.patch( + f'/api/cards/{card.id}/', + {'is_completed': True}, + format='json', + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + card.refresh_from_db() + self.assertFalse(card.is_completed) + self.assertEqual(card.recurrence, 'daily') + self.assertIsNotNone(card.due_date) + self.assertGreater(card.due_date, due) diff --git a/backend/requirements.txt b/backend/requirements.txt index 3dafa5c2..81b14c65 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -11,3 +11,4 @@ dj-database-url>=2.1 psycopg2-binary>=2.9 Pillow>=10.0 whitenoise>=6.8 +sentry-sdk[django]>=2.22 diff --git a/docs/ops/deploy-checklist.md b/docs/ops/deploy-checklist.md new file mode 100644 index 00000000..42c2ea7d --- /dev/null +++ b/docs/ops/deploy-checklist.md @@ -0,0 +1,58 @@ +# Deployment Checklist + +## 1. Security First +- Rotate SMTP credentials used previously. +- Update production secrets: + - `PROD_EMAIL_HOST_USER` + - `PROD_EMAIL_HOST_PASSWORD` + - `PROD_DEFAULT_FROM_EMAIL` +- Run repository hardening scripts under an admin account: + - `pwsh scripts/github/enable-security-analysis.ps1` + - `pwsh scripts/github/apply-branch-protection.ps1 -Branches @("broadly","main","master")` + +## 2. Observability & Realtime +- Backend env: + - `SENTRY_DSN` + - `SENTRY_TRACES_SAMPLE_RATE` (e.g. `0.1`) + - `CHANNEL_REDIS_URL` +- Frontend env: + - `REACT_APP_SENTRY_DSN` + - `REACT_APP_SENTRY_ENV` (`production`) + - `REACT_APP_SENTRY_TRACES_SAMPLE_RATE` (e.g. `0.05`) + +## 3. Migrations +- Run backend migration: + - `python manage.py migrate` +- Verify migration `0023` is applied. + +## 4. Recurring Reminders Job +- Configure repository secrets for scheduled workflow: + - `PROD_DATABASE_URL` + - `PROD_DJANGO_SECRET_KEY` + - `PROD_ALLOWED_HOSTS` + - `PROD_EMAIL_HOST` + - `PROD_EMAIL_PORT` + - `PROD_EMAIL_HOST_USER` + - `PROD_EMAIL_HOST_PASSWORD` + - `PROD_DEFAULT_FROM_EMAIL` +- Confirm `.github/workflows/due-reminders.yml` runs every 10 minutes. + +## 5. Sentry Alerts (minimum) +- Backend unhandled errors (5xx). +- Frontend `error` events. +- WebSocket reconnect exhaustion / disconnect spike. + +## 6. Manual Smoke +- Templates: + - Create board with `Product roadmap` template. + - Verify lists and labels are prefilled. +- Recurrence: + - Set card recurrence to `daily`. + - Complete card and verify due date auto-moves and card reopens. +- Reminder: + - Set due date + reminder and verify mail after command run. +- Health: + - `GET /api/health/` and `GET /healthz/` return `200`. +- PWA/offline: + - Install prompt appears. + - Edit card description offline, reload, restore draft. diff --git a/frontend/.env.example b/frontend/.env.example index f529338a..b7921d94 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,6 +1,9 @@ REACT_APP_API_URL=http://localhost:8000/api REACT_APP_WS_URL=ws://localhost:8000/ws REACT_APP_GOOGLE_CLIENT_ID= +REACT_APP_SENTRY_DSN= +REACT_APP_SENTRY_ENV=production +REACT_APP_SENTRY_TRACES_SAMPLE_RATE=0.0 # Optional WebSocket reconnect tuning REACT_APP_WS_MAX_RECONNECT_ATTEMPTS=20 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9227ad3d..5f67745f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@hello-pangea/dnd": "^18.0.1", "@reduxjs/toolkit": "^2.11.2", + "@sentry/react": "^10.22.0", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", @@ -33,7 +34,7 @@ "web-vitals": "^2.1.4" }, "devDependencies": { - "@playwright/test": "^1.49.1", + "@playwright/test": "^1.58.2", "browserstack-node-sdk": "^1.49.2" } }, @@ -3338,13 +3339,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz", - "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.57.0" + "playwright": "1.58.2" }, "bin": { "playwright": "cli.js" @@ -3645,6 +3646,97 @@ "integrity": "sha512-ojSshQPKwVvSMR8yT2L/QtUkV5SXi/IfDiJ4/8d6UbTPjiHVmxZzUAzGD8Tzks1b9+qQkZa0isUOvYObedITaw==", "license": "MIT" }, + "node_modules/@sentry-internal/browser-utils": { + "version": "10.40.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.40.0.tgz", + "integrity": "sha512-3CDeVNBXYOIvBVdT0SOdMZx5LzYDLuhGK/z7A14sYZz4Cd2+f4mSeFDaEOoH/g2SaY2CKR5KGkAADy8IyjZ21w==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.40.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry-internal/feedback": { + "version": "10.40.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.40.0.tgz", + "integrity": "sha512-V/ixkcdCNMo04KgsCEeNEu966xUUTD6czKT2LOAO5siZACqFjT/Rp9VR1n7QQrVo3sL7P3QNiTHtX0jaeWbwzg==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.40.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry-internal/replay": { + "version": "10.40.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.40.0.tgz", + "integrity": "sha512-vsH2Ut0KIIQIHNdS3zzEGLJ2C9btbpvJIWAVk7l7oft66JzlUNC89qNaQ5SAypjLQx4Ln2V/ZTqfEoNzXOAsoQ==", + "license": "MIT", + "dependencies": { + "@sentry-internal/browser-utils": "10.40.0", + "@sentry/core": "10.40.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry-internal/replay-canvas": { + "version": "10.40.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.40.0.tgz", + "integrity": "sha512-wzQwilFHO2baeCt0dTMf0eW+rgK8O+mkisf9sQzPXzG3Krr/iVtFg1T5T1Th3YsCsEdn6yQ3hcBPLEXjMSvccg==", + "license": "MIT", + "dependencies": { + "@sentry-internal/replay": "10.40.0", + "@sentry/core": "10.40.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/browser": { + "version": "10.40.0", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.40.0.tgz", + "integrity": "sha512-nCt3FKUMFad0C6xl5wCK0Jz+qT4Vev4fv6HJRn0YoNRRDQCfsUVxAz7pNyyiPNGM/WCDp9wJpGJsRvbBRd2anw==", + "license": "MIT", + "dependencies": { + "@sentry-internal/browser-utils": "10.40.0", + "@sentry-internal/feedback": "10.40.0", + "@sentry-internal/replay": "10.40.0", + "@sentry-internal/replay-canvas": "10.40.0", + "@sentry/core": "10.40.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/core": { + "version": "10.40.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.40.0.tgz", + "integrity": "sha512-/wrcHPp9Avmgl6WBimPjS4gj810a1wU5oX9fF1bzJfeIIbF3jTsAbv0oMbgDp0cSDnkwv2+NvcPnn3+c5J6pBA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/react": { + "version": "10.40.0", + "resolved": "https://registry.npmjs.org/@sentry/react/-/react-10.40.0.tgz", + "integrity": "sha512-3T5W/e3QJMimXRIOx8xMEZbxeIuFiKlXvHLcMTLGygGBYnxQGeb8Oz/8heov+3zF1JoCIxeVQNFW0woySApfyA==", + "license": "MIT", + "dependencies": { + "@sentry/browser": "10.40.0", + "@sentry/core": "10.40.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.14.0 || 17.x || 18.x || 19.x" + } + }, "node_modules/@sinclair/typebox": { "version": "0.24.51", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.51.tgz", @@ -15637,13 +15729,13 @@ } }, "node_modules/playwright": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", - "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.57.0" + "playwright-core": "1.58.2" }, "bin": { "playwright": "cli.js" @@ -15656,9 +15748,9 @@ } }, "node_modules/playwright-core": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", - "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -19966,23 +20058,6 @@ } } }, - "node_modules/tailwindcss/node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, "node_modules/tapable": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index e96ff3c1..408d81d8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,6 +6,7 @@ "dependencies": { "@hello-pangea/dnd": "^18.0.1", "@reduxjs/toolkit": "^2.11.2", + "@sentry/react": "^10.22.0", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", @@ -29,7 +30,7 @@ "web-vitals": "^2.1.4" }, "devDependencies": { - "@playwright/test": "^1.49.1", + "@playwright/test": "^1.58.2", "browserstack-node-sdk": "^1.49.2" }, "scripts": { diff --git a/frontend/public/sw.js b/frontend/public/sw.js new file mode 100644 index 00000000..acc55abf --- /dev/null +++ b/frontend/public/sw.js @@ -0,0 +1,33 @@ +const CACHE_NAME = 'boardly-static-v1'; +const OFFLINE_ASSETS = ['/', '/index.html', '/manifest.json', '/logo.png']; + +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => cache.addAll(OFFLINE_ASSETS)).catch(() => undefined) + ); + self.skipWaiting(); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((keys) => + Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))) + ) + ); + self.clients.claim(); +}); + +self.addEventListener('fetch', (event) => { + if (event.request.method !== 'GET') return; + if (!event.request.url.startsWith(self.location.origin)) return; + + event.respondWith( + fetch(event.request) + .then((response) => { + const clone = response.clone(); + caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone)).catch(() => undefined); + return response; + }) + .catch(() => caches.match(event.request).then((cached) => cached || caches.match('/index.html'))) + ); +}); diff --git a/frontend/src/components/layout/Layout.tsx b/frontend/src/components/layout/Layout.tsx index ff0ef56f..d7d2c50b 100644 --- a/frontend/src/components/layout/Layout.tsx +++ b/frontend/src/components/layout/Layout.tsx @@ -8,6 +8,7 @@ import { logoutUser } from '../../store/slices/authSlice'; import { useI18n } from '../../context/I18nContext'; import { LanguageSelect } from '../LanguageSelect'; import { resolveMediaUrl } from '../../utils/mediaUrl'; +import { PwaInstallButton } from '../../shared/ui/PwaInstallButton'; export const Layout: React.FC = () => { const dispatch = useAppDispatch(); @@ -112,6 +113,7 @@ export const Layout: React.FC = () => {
+ @@ -145,6 +147,7 @@ export const Layout: React.FC = () => {
+ {t('auth.signIn')} diff --git a/frontend/src/features/boards/BoardListScreen.tsx b/frontend/src/features/boards/BoardListScreen.tsx index 3bb59624..d2332d69 100644 --- a/frontend/src/features/boards/BoardListScreen.tsx +++ b/frontend/src/features/boards/BoardListScreen.tsx @@ -12,6 +12,8 @@ import { createBoardAction, fetchBoardsAction } from '../../store/slices/boardSl import { useDialogA11y } from '../../shared/hooks/useDialogA11y'; import { validateImageFile } from '../../shared/utils/fileValidation'; import { resolveMediaUrl } from '../../utils/mediaUrl'; +import { getBoardTemplates } from './api'; +import { type BoardTemplate } from '../../types'; const BOARD_LIST_BACKGROUND_KEY = 'boardly.boards.background'; const DEFAULT_BOARDS_BACKGROUND = '/board-backgrounds/board-default.jpg'; @@ -27,6 +29,9 @@ export const BoardListScreen: React.FC = () => { const [newBoardTitle, setNewBoardTitle] = useState(''); const [isCreating, setIsCreating] = useState(false); const [searchQuery, setSearchQuery] = useState(''); + const [templates, setTemplates] = useState([]); + const [selectedTemplateKey, setSelectedTemplateKey] = useState('blank'); + const [templatesLoading, setTemplatesLoading] = useState(false); const [isAccountOpen, setIsAccountOpen] = useState(false); const [isBackgroundModalOpen, setIsBackgroundModalOpen] = useState(false); @@ -54,6 +59,34 @@ export const BoardListScreen: React.FC = () => { dispatch(fetchBoardsAction()); }, [dispatch]); + useEffect(() => { + let mounted = true; + const loadTemplates = async () => { + setTemplatesLoading(true); + try { + const items = await getBoardTemplates(); + if (!mounted) return; + setTemplates(items); + setSelectedTemplateKey((prev) => { + if (!items.length) return 'blank'; + return items.some((item) => item.key === prev) ? prev : items[0].key; + }); + } catch { + // fallback to blank if templates endpoint is unavailable + if (mounted) { + setTemplates([]); + setSelectedTemplateKey('blank'); + } + } finally { + if (mounted) setTemplatesLoading(false); + } + }; + void loadTemplates(); + return () => { + mounted = false; + }; + }, []); + useEffect(() => { if (typeof window === 'undefined') return; window.localStorage.setItem(BOARD_LIST_BACKGROUND_KEY, dashboardBackground); @@ -94,7 +127,12 @@ export const BoardListScreen: React.FC = () => { setIsCreating(true); try { - await dispatch(createBoardAction(newBoardTitle)).unwrap(); + await dispatch( + createBoardAction({ + title: newBoardTitle, + templateKey: selectedTemplateKey || 'blank', + }) + ).unwrap(); setNewBoardTitle(''); setIsModalOpen(false); } catch (error) { @@ -391,6 +429,49 @@ export const BoardListScreen: React.FC = () => { onChange={e => setNewBoardTitle(e.target.value)} />
+
+ + + {templates.length > 0 && ( +
+ {templates.find((item) => item.key === selectedTemplateKey)?.description || ''} +
+ )} +
+ {templates.length > 0 && ( +
+ +
+ {templates.slice(0, 3).map((template) => ( + + ))} +
+
+ )}
- {isGlobalMenuOpen && ( - <> -
-
- - {t('nav.board')} - - - {t('nav.myCards')} - - - {t('nav.help')} - - - {t('nav.community')} - -
-
- -
- - {t('nav.profile')} - - -
- - )} -
+