Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 07d08bb179 | |||
| 378daad503 | |||
| 54f85a8f14 | |||
| 8bf92bd4a1 | |||
| 68a153e4a7 | |||
| cac06f22fa | |||
| 87e8417675 | |||
| 37218a436a | |||
| 50d734e192 | |||
| edf0186682 | |||
| 6e2188787e | |||
| fd527917e0 | |||
| 95c9e23c33 | |||
| 95f65141e1 | |||
| 3b68e29d7b | |||
| 16bf1a1043 | |||
| 2de5c1cb81 | |||
| 70b9050cb7 | |||
| 7ffa727832 | |||
| 43386b15fa | |||
| 67ba9826c7 | |||
| 54446d7b0f | |||
| 37d4226beb | |||
| 0d28123d0b | |||
| 2e78b3c1e6 | |||
| bdead6eef7 | |||
| 2fb553567f | |||
| e3240d0981 | |||
| f8aceabab5 | |||
| 322b553b80 | |||
| a4d8d085c6 | |||
| b906bfa777 | |||
| 8a80af32d8 | |||
| 3c3a2c507c | |||
| 71d56d2491 |
150
.cursor/rules/backend.mdc
Normal file
150
.cursor/rules/backend.mdc
Normal file
@@ -0,0 +1,150 @@
|
||||
---
|
||||
description: Rules for working with the Python backend (duty_teller/)
|
||||
globs:
|
||||
- duty_teller/**
|
||||
- alembic/**
|
||||
- tests/**
|
||||
---
|
||||
|
||||
# Backend — Python
|
||||
|
||||
## Package layout
|
||||
|
||||
```
|
||||
duty_teller/
|
||||
├── main.py / run.py # Entry point: bot + uvicorn
|
||||
├── config.py # Settings from env vars (python-dotenv)
|
||||
├── cache.py # TTL caches with pattern invalidation
|
||||
├── api/ # FastAPI app, routes, auth, ICS endpoints
|
||||
│ ├── app.py # FastAPI app creation, route registration, static mount
|
||||
│ ├── dependencies.py # get_db_session, require_miniapp_username, get_validated_dates
|
||||
│ ├── telegram_auth.py # initData HMAC validation
|
||||
│ ├── calendar_ics.py # External ICS fetch & parse
|
||||
│ └── personal_calendar_ics.py
|
||||
├── db/ # Database layer
|
||||
│ ├── models.py # SQLAlchemy ORM models (Base)
|
||||
│ ├── repository.py # CRUD functions (receive Session)
|
||||
│ ├── schemas.py # Pydantic response schemas
|
||||
│ └── session.py # session_scope, get_engine, get_session
|
||||
├── handlers/ # Telegram bot command/message handlers
|
||||
│ ├── commands.py # /start, /help, /set_phone, /calendar_link, /set_role
|
||||
│ ├── common.py # is_admin_async, invalidate_is_admin_cache
|
||||
│ ├── errors.py # Global error handler
|
||||
│ ├── group_duty_pin.py # Pinned duty message in group chats
|
||||
│ └── import_duty_schedule.py
|
||||
├── i18n/ # Translations
|
||||
│ ├── core.py # get_lang, t()
|
||||
│ ├── lang.py # normalize_lang
|
||||
│ └── messages.py # MESSAGES dict (ru/en)
|
||||
├── importers/ # File parsers
|
||||
│ └── duty_schedule.py # parse_duty_schedule
|
||||
├── services/ # Business logic (receives Session)
|
||||
│ ├── import_service.py # run_import
|
||||
│ └── group_duty_pin_service.py
|
||||
└── utils/ # Shared helpers
|
||||
├── dates.py
|
||||
├── handover.py
|
||||
├── http_client.py
|
||||
└── user.py
|
||||
```
|
||||
|
||||
## Imports
|
||||
|
||||
- Use absolute imports from the `duty_teller` package: `from duty_teller.db.repository import get_or_create_user`.
|
||||
- Never import handler modules from services or repository — dependency flows
|
||||
downward: handlers → services → repository → models.
|
||||
|
||||
## DB access pattern
|
||||
|
||||
Handlers are async; SQLAlchemy sessions are synchronous. Two patterns:
|
||||
|
||||
### In bot handlers — `session_scope` + `run_in_executor`
|
||||
|
||||
```python
|
||||
def do_work():
|
||||
with session_scope(config.DATABASE_URL) as session:
|
||||
# synchronous DB code here
|
||||
...
|
||||
|
||||
await asyncio.get_running_loop().run_in_executor(None, do_work)
|
||||
```
|
||||
|
||||
### In FastAPI endpoints — dependency injection
|
||||
|
||||
```python
|
||||
@router.get("/api/duties")
|
||||
def get_duties(session: Session = Depends(get_db_session)):
|
||||
...
|
||||
```
|
||||
|
||||
`get_db_session` yields a `session_scope` context — FastAPI closes it after the request.
|
||||
|
||||
## Handler patterns
|
||||
|
||||
### Admin-only commands
|
||||
|
||||
```python
|
||||
if not await is_admin_async(update.effective_user.id):
|
||||
await update.message.reply_text(t(lang, "import.admin_only"))
|
||||
return
|
||||
```
|
||||
|
||||
`is_admin_async` is cached for 60 s and invalidated on role changes.
|
||||
|
||||
### i18n in handlers
|
||||
|
||||
```python
|
||||
lang = get_lang(update.effective_user)
|
||||
text = t(lang, "start.greeting")
|
||||
```
|
||||
|
||||
`t(lang, key, **kwargs)` substitutes `{placeholders}` and falls back to English → raw key.
|
||||
|
||||
### Error handling
|
||||
|
||||
The global `error_handler` (registered via `app.add_error_handler`) logs the
|
||||
exception and sends a generic localized error reply.
|
||||
|
||||
## Service layer
|
||||
|
||||
- Services receive a `Session` — they never open their own.
|
||||
- Services call repository functions, never handler code.
|
||||
- After mutations that affect cached data, call `invalidate_duty_related_caches()`.
|
||||
|
||||
## Cache invalidation
|
||||
|
||||
Three TTL caches in `cache.py`:
|
||||
|
||||
| Cache | TTL | Invalidation trigger |
|
||||
|-------|-----|---------------------|
|
||||
| `ics_calendar_cache` | 600 s | After duty import |
|
||||
| `duty_pin_cache` | 90 s | After duty import |
|
||||
| `is_admin_cache` | 60 s | After `set_user_role` |
|
||||
|
||||
- `invalidate_duty_related_caches()` clears ICS and pin caches (call after any duty mutation).
|
||||
- `invalidate_is_admin_cache(telegram_user_id)` clears a single admin entry.
|
||||
- Pattern invalidation: `cache.invalidate_pattern(key_prefix_tuple)`.
|
||||
|
||||
## Alembic migrations
|
||||
|
||||
- Config in `pyproject.toml` under `[tool.alembic]`, script location: `alembic/`.
|
||||
- **Naming convention:** `NNN_description.py` — sequential zero-padded number (`001`, `002`, …).
|
||||
- Revision IDs are the string number: `revision = "008"`, `down_revision = "007"`.
|
||||
- Run: `alembic -c pyproject.toml upgrade head` / `downgrade -1`.
|
||||
- `entrypoint.sh` runs `alembic upgrade head` before starting the app in Docker.
|
||||
|
||||
## Configuration
|
||||
|
||||
- All config comes from environment variables, loaded with `python-dotenv`.
|
||||
- `duty_teller/config.py` exposes module-level constants (`BOT_TOKEN`, `DATABASE_URL`, etc.)
|
||||
built from `Settings.from_env()`.
|
||||
- Never hardcode secrets or URLs — always use `config.*` constants.
|
||||
- Key variables: `BOT_TOKEN`, `DATABASE_URL`, `MINI_APP_BASE_URL`, `HTTP_HOST`, `HTTP_PORT`,
|
||||
`ADMIN_USERNAMES`, `ALLOWED_USERNAMES`, `DUTY_DISPLAY_TZ`, `DEFAULT_LANGUAGE`.
|
||||
|
||||
## Code style
|
||||
|
||||
- Formatter: Black (line-length 120).
|
||||
- Linter: Ruff (`ruff check duty_teller tests`).
|
||||
- Follow PEP 8 and Google Python Style Guide for docstrings.
|
||||
- Max line length: 120 characters.
|
||||
53
.cursor/rules/frontend.mdc
Normal file
53
.cursor/rules/frontend.mdc
Normal file
@@ -0,0 +1,53 @@
|
||||
---
|
||||
description: Rules for working with the Telegram Mini App frontend (webapp-next/)
|
||||
globs:
|
||||
- webapp-next/**
|
||||
---
|
||||
|
||||
# Frontend — Telegram Mini App (Next.js)
|
||||
|
||||
The Mini App lives in `webapp-next/`. It is built as a static export and served by FastAPI at `/app`.
|
||||
|
||||
## Stack
|
||||
|
||||
- **Next.js** (App Router, `output: 'export'`, `basePath: '/app'`)
|
||||
- **TypeScript**
|
||||
- **Tailwind CSS** — theme extended with custom tokens (surface, muted, accent, duty, today, etc.)
|
||||
- **shadcn/ui** — Button, Card, Sheet, Popover, Tooltip, Skeleton, Badge
|
||||
- **Zustand** — app store (month, lang, duties, calendar events, loading, view state)
|
||||
- **@telegram-apps/sdk-react** — SDKProvider, useThemeParams, useLaunchParams, useMiniApp, useBackButton
|
||||
|
||||
## Structure
|
||||
|
||||
| Area | Location |
|
||||
|------|----------|
|
||||
| App entry, layout | `src/app/layout.tsx`, `src/app/page.tsx` |
|
||||
| Providers | `src/components/providers/TelegramProvider.tsx` |
|
||||
| Calendar | `src/components/calendar/` — CalendarHeader, CalendarGrid, CalendarDay, DayIndicators |
|
||||
| Duty list | `src/components/duty/` — DutyList, DutyTimelineCard, DutyItem |
|
||||
| Day detail | `src/components/day-detail/` — DayDetail (Sheet/Popover), DayDetailContent |
|
||||
| Current duty view | `src/components/current-duty/CurrentDutyView.tsx` |
|
||||
| Contact links | `src/components/contact/ContactLinks.tsx` |
|
||||
| State views | `src/components/states/` — LoadingState, ErrorState, AccessDenied |
|
||||
| Hooks | `src/hooks/` — use-telegram-theme, use-telegram-auth, use-month-data, use-swipe, use-media-query, use-sticky-scroll, use-auto-refresh |
|
||||
| Lib | `src/lib/` — api, calendar-data, date-utils, phone-format, constants, utils |
|
||||
| i18n | `src/i18n/` — messages.ts, use-translation.ts |
|
||||
| Store | `src/store/app-store.ts` |
|
||||
| Types | `src/types/index.ts` |
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Client components:** Use `'use client'` where hooks or browser APIs are used (layout loads config script; page and most UI are client).
|
||||
- **Theme:** CSS variables in `globals.css`; `useTelegramTheme` maps Telegram theme params to `--tg-theme-*` and sets `data-theme` on `<html>`.
|
||||
- **Auth:** `useTelegramAuth` provides initData for API; access gated for non-Telegram except localhost.
|
||||
- **i18n:** `useTranslation()` from store lang; `window.__DT_LANG` set by `/app/config.js` (backend).
|
||||
- **API:** `fetchDuties`, `fetchCalendarEvents` in `src/lib/api.ts`; pass initData, lang, AbortSignal; handle ACCESS_DENIED.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Runner:** Vitest in `webapp-next/`; environment: jsdom; React Testing Library.
|
||||
- **Config:** `webapp-next/vitest.config.ts`; setup in `src/test/setup.ts`.
|
||||
- **Run:** `cd webapp-next && npm test` (or `npm run test`). Build: `npm run build`.
|
||||
- **Coverage:** Unit tests for lib (api, date-utils, calendar-data, i18n, etc.) and component tests for calendar, duty list, day detail, current duty, states.
|
||||
|
||||
Consider these rules when changing the Mini App or adding frontend features.
|
||||
113
.cursor/rules/project.mdc
Normal file
113
.cursor/rules/project.mdc
Normal file
@@ -0,0 +1,113 @@
|
||||
---
|
||||
description: Overall project architecture and context for duty-teller
|
||||
globs:
|
||||
- "**"
|
||||
---
|
||||
|
||||
# Project — duty-teller
|
||||
|
||||
A Telegram bot for team duty shift calendar management and group reminders,
|
||||
with a Telegram Mini App (webapp) for calendar visualization.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌──────────────┐ polling ┌──────────────────────┐
|
||||
│ Telegram │◄────────────►│ python-telegram-bot │
|
||||
│ Bot API │ │ (handlers/) │
|
||||
└──────────────┘ └──────────┬───────────┘
|
||||
│
|
||||
┌──────────────┐ HTTP ┌──────────▼───────────┐
|
||||
│ Telegram │◄────────────►│ FastAPI (api/) │
|
||||
│ Mini App │ initData │ + static webapp │
|
||||
│ (webapp-next/) │ auth └──────────┬───────────┘
|
||||
└──────────────┘ │
|
||||
┌──────────▼───────────┐
|
||||
│ SQLite + SQLAlchemy │
|
||||
│ (db/) │
|
||||
└──────────────────────┘
|
||||
```
|
||||
|
||||
- **Bot:** python-telegram-bot v22, async polling mode.
|
||||
- **API:** FastAPI served by uvicorn in a daemon thread alongside the bot.
|
||||
- **Database:** SQLite via SQLAlchemy 2.x ORM; Alembic for migrations.
|
||||
- **Frontend:** Next.js (TypeScript, Tailwind, shadcn/ui) static export at `/app`; source in `webapp-next/`.
|
||||
|
||||
## Key packages
|
||||
|
||||
| Package | Role |
|
||||
|---------|------|
|
||||
| `duty_teller/handlers/` | Telegram bot command and message handlers |
|
||||
| `duty_teller/api/` | FastAPI app, REST endpoints, Telegram initData auth, ICS feeds |
|
||||
| `duty_teller/db/` | ORM models, repository (CRUD), session management, Pydantic schemas |
|
||||
| `duty_teller/services/` | Business logic (import, duty pin) — receives Session |
|
||||
| `duty_teller/importers/` | Duty schedule file parsers |
|
||||
| `duty_teller/i18n/` | Bilingual translations (ru/en), `t()` function |
|
||||
| `duty_teller/utils/` | Date helpers, user utilities, HTTP client |
|
||||
| `duty_teller/cache.py` | TTL caches with pattern-based invalidation |
|
||||
| `duty_teller/config.py` | Environment-based configuration |
|
||||
| `webapp-next/` | Telegram Mini App (Next.js, Tailwind, shadcn/ui; build → `out/`) |
|
||||
|
||||
## API endpoints
|
||||
|
||||
| Method | Path | Auth | Purpose |
|
||||
|--------|------|------|---------|
|
||||
| GET | `/health` | None | Health check |
|
||||
| GET | `/api/duties` | initData | Duties for date range |
|
||||
| GET | `/api/calendar-events` | initData | External calendar events |
|
||||
| GET | `/api/calendar/ical/team/{token}.ics` | Token | Team ICS feed |
|
||||
| GET | `/api/calendar/ical/{token}.ics` | Token | Personal ICS feed |
|
||||
| GET | `/app` | None | Static Mini App (HTML/JS/CSS) |
|
||||
|
||||
## Deployment
|
||||
|
||||
- **Docker:** Multi-stage build (`Dockerfile`), `docker-compose.prod.yml` for production.
|
||||
- **Entrypoint:** `entrypoint.sh` runs `alembic -c pyproject.toml upgrade head`, then
|
||||
starts `python main.py` as `botuser`.
|
||||
- **Data:** SQLite DB persisted via Docker volume at `/app/data`.
|
||||
- **Health:** `curl -f http://localhost:8080/health` every 30 s.
|
||||
|
||||
## CI/CD (Gitea Actions)
|
||||
|
||||
### Lint & test (`.gitea/workflows/ci.yml`)
|
||||
|
||||
Triggered on push/PR to `main` and `develop`:
|
||||
|
||||
1. **Ruff:** `ruff check duty_teller tests`
|
||||
2. **Pytest:** `pytest tests/ -v` (80% coverage gate)
|
||||
3. **Bandit:** `bandit -r duty_teller -ll` (security scan)
|
||||
|
||||
### Docker build & release (`.gitea/workflows/docker-build.yml`)
|
||||
|
||||
Triggered on `v*` tags:
|
||||
|
||||
1. Build and push image to Gitea registry.
|
||||
2. Create release with auto-generated notes.
|
||||
|
||||
## Key environment variables
|
||||
|
||||
| Variable | Default | Required | Purpose |
|
||||
|----------|---------|----------|---------|
|
||||
| `BOT_TOKEN` | — | Yes | Telegram bot API token |
|
||||
| `DATABASE_URL` | `sqlite:///data/duty_teller.db` | No | SQLAlchemy database URL |
|
||||
| `MINI_APP_BASE_URL` | — | Yes (prod) | Public URL for the Mini App |
|
||||
| `HTTP_HOST` | `127.0.0.1` | No | FastAPI bind host |
|
||||
| `HTTP_PORT` | `8080` | No | FastAPI bind port |
|
||||
| `ADMIN_USERNAMES` | — | No | Comma-separated admin Telegram usernames |
|
||||
| `ALLOWED_USERNAMES` | — | No | Comma-separated allowed usernames |
|
||||
| `DUTY_DISPLAY_TZ` | `Europe/Moscow` | No | Timezone for duty display |
|
||||
| `DEFAULT_LANGUAGE` | `en` | No | Default UI language (`en` or `ru`) |
|
||||
| `EXTERNAL_CALENDAR_ICS_URL` | — | No | External ICS URL for holidays |
|
||||
| `CORS_ORIGINS` | `*` | No | Comma-separated CORS origins |
|
||||
|
||||
## Languages
|
||||
|
||||
- **Backend:** Python 3.12+
|
||||
- **Frontend:** Next.js (TypeScript), Tailwind CSS, shadcn/ui; Vitest for tests
|
||||
- **i18n:** Russian (default) and English
|
||||
|
||||
## Version control
|
||||
|
||||
- Git with Gitea Flow branching strategy.
|
||||
- Conventional Commits for commit messages.
|
||||
- PRs reviewed via Gitea Pull Requests.
|
||||
83
.cursor/rules/testing.mdc
Normal file
83
.cursor/rules/testing.mdc
Normal file
@@ -0,0 +1,83 @@
|
||||
---
|
||||
description: Rules for writing and running tests
|
||||
globs:
|
||||
- tests/**
|
||||
- webapp-next/src/**/*.test.{ts,tsx}
|
||||
---
|
||||
|
||||
# Testing
|
||||
|
||||
## Python tests (pytest)
|
||||
|
||||
### Configuration
|
||||
|
||||
- Config in `pyproject.toml` under `[tool.pytest.ini_options]`.
|
||||
- `asyncio_mode = "auto"` — async test functions are detected automatically, no decorator needed.
|
||||
- Coverage: `--cov=duty_teller --cov-fail-under=80`.
|
||||
- Run: `pytest tests/ -v` from project root.
|
||||
|
||||
### File layout
|
||||
|
||||
```
|
||||
tests/
|
||||
├── conftest.py # Shared fixtures (in-memory DB, sessions, bot app)
|
||||
├── helpers.py # Test helper functions
|
||||
└── test_*.py # Test modules
|
||||
```
|
||||
|
||||
### Key fixtures (conftest.py)
|
||||
|
||||
- `conftest.py` sets `BOT_TOKEN=test-token-for-pytest` if the env var is missing,
|
||||
so tests run without a real token.
|
||||
- Database fixtures use in-memory SQLite for isolation.
|
||||
|
||||
### Writing Python tests
|
||||
|
||||
- File naming: `tests/test_<module>.py` (e.g. `tests/test_import_service.py`).
|
||||
- Function naming: `test_<what>_<scenario>` or `test_<what>_<expected_result>`.
|
||||
- Use `session_scope` with the test database URL for DB tests.
|
||||
- Async tests: just use `async def test_...` — `asyncio_mode = "auto"` handles it.
|
||||
- Mock external dependencies (Telegram API, HTTP calls) with `unittest.mock` or `pytest-mock`.
|
||||
|
||||
### Example structure
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from duty_teller.db.session import session_scope
|
||||
|
||||
def test_get_or_create_user_creates_new(test_db_url):
|
||||
with session_scope(test_db_url) as session:
|
||||
user = get_or_create_user(session, telegram_user_id=123, full_name="Test")
|
||||
assert user.telegram_user_id == 123
|
||||
```
|
||||
|
||||
## Frontend tests (Vitest + React Testing Library)
|
||||
|
||||
### Configuration
|
||||
|
||||
- Config: `webapp-next/vitest.config.ts`.
|
||||
- Environment: jsdom; React Testing Library for components.
|
||||
- Test files: `webapp-next/src/**/*.test.{ts,tsx}` (co-located or in test files).
|
||||
- Setup: `webapp-next/src/test/setup.ts`.
|
||||
- Run: `cd webapp-next && npm test` (or `npm run test`).
|
||||
|
||||
### Writing frontend tests
|
||||
|
||||
- Pure lib modules: unit test with Vitest (`describe` / `it` / `expect`).
|
||||
- React components: use `@testing-library/react` (render, screen, userEvent); wrap with required providers (e.g. TelegramProvider, store) via `src/test/test-utils.tsx` where needed.
|
||||
- Mock Telegram SDK and API fetch where necessary.
|
||||
- File naming: `<module>.test.ts` or `<Component>.test.tsx`.
|
||||
|
||||
### Example structure
|
||||
|
||||
```ts
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { localDateString } from "./date-utils";
|
||||
|
||||
describe("localDateString", () => {
|
||||
it("formats date as YYYY-MM-DD", () => {
|
||||
const d = new Date(2025, 0, 15);
|
||||
expect(localDateString(d)).toBe("2025-01-15");
|
||||
});
|
||||
});
|
||||
```
|
||||
5
.cursor/worktrees.json
Normal file
5
.cursor/worktrees.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"setup-worktree": [
|
||||
"npm install"
|
||||
]
|
||||
}
|
||||
@@ -26,7 +26,10 @@ ADMIN_USERNAMES=admin1,admin2
|
||||
# When the pinned duty message is updated on schedule, re-pin so members get a notification (default: 1). Set to 0 or false to disable.
|
||||
# DUTY_PIN_NOTIFY=1
|
||||
|
||||
# Default UI language when user language is unknown: en or ru (default: en).
|
||||
# Log level for backend and Mini App console logs: DEBUG, INFO, WARNING, ERROR. Default: INFO.
|
||||
# LOG_LEVEL=INFO
|
||||
|
||||
# Single source of language for bot, API, and Mini App (en or ru). Default: en. No auto-detection.
|
||||
# DEFAULT_LANGUAGE=en
|
||||
|
||||
# Reject Telegram initData older than this (seconds). 0 = do not check (default).
|
||||
|
||||
@@ -26,6 +26,15 @@ jobs:
|
||||
run: |
|
||||
pip install ruff bandit
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: https://gitea.com/actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Webapp (Next.js) build and test
|
||||
run: |
|
||||
cd webapp-next && npm ci && npm test && npm run build
|
||||
|
||||
- name: Lint with Ruff
|
||||
run: |
|
||||
ruff check duty_teller tests
|
||||
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -7,10 +7,18 @@ venv/
|
||||
*.pyo
|
||||
data/
|
||||
*.db
|
||||
.cursor/
|
||||
|
||||
# Test and coverage artifacts
|
||||
.coverage
|
||||
htmlcov/
|
||||
.pytest_cache/
|
||||
*.cover
|
||||
*.plan.md
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Next.js webapp
|
||||
webapp-next/out/
|
||||
webapp-next/node_modules/
|
||||
webapp-next/.next/
|
||||
51
AGENTS.md
Normal file
51
AGENTS.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# Duty Teller — AI agent documentation
|
||||
|
||||
This file is for AI assistants (e.g. Cursor) and maintainers. All project documentation and docstrings must be in **English**. User-facing UI strings remain localized (ru/en) in `duty_teller/i18n/`.
|
||||
|
||||
## Project summary
|
||||
|
||||
Duty Teller is a Telegram bot plus Mini App for team duty shift calendar and group reminders. Stack: python-telegram-bot v22, FastAPI, SQLite (SQLAlchemy), Next.js (static export) Mini App. The bot and web UI support Russian and English; configuration and docs are in English.
|
||||
|
||||
## Key entry points
|
||||
|
||||
- **CLI / process:** `main.py` or, after `pip install -e .`, the `duty-teller` console command. Both delegate to `duty_teller.run.main()`.
|
||||
- **Application setup:** `duty_teller/run.py` — builds the Telegram `Application`, registers handlers via `register_handlers(app)`, runs polling and FastAPI in a thread, calls `config.require_bot_token()` so the app exits clearly if `BOT_TOKEN` is missing.
|
||||
- **HTTP API:** `duty_teller/api/app.py` — FastAPI app, route registration, static webapp mounted at `/app`.
|
||||
|
||||
## Where to change what
|
||||
|
||||
| Area | Location |
|
||||
|------|----------|
|
||||
| Telegram handlers | `duty_teller/handlers/` |
|
||||
| REST API | `duty_teller/api/` |
|
||||
| Business logic | `duty_teller/services/` |
|
||||
| Database (models, repository, schemas) | `duty_teller/db/` |
|
||||
| Translations (ru/en) | `duty_teller/i18n/` |
|
||||
| Duty-schedule parser | `duty_teller/importers/` |
|
||||
| Config (env vars) | `duty_teller/config.py` |
|
||||
| Miniapp frontend | `webapp-next/` (Next.js, Tailwind, shadcn/ui; static export in `webapp-next/out/`) |
|
||||
| Migrations | `alembic/` (config in `pyproject.toml` under `[tool.alembic]`) |
|
||||
|
||||
## Running and testing
|
||||
|
||||
- **Tests:** From repository root: `pytest`. Use `PYTHONPATH=.` if imports fail. See [CONTRIBUTING.md](CONTRIBUTING.md) and [README.md](README.md) for full setup. Frontend: `cd webapp-next && npm test && npm run build`.
|
||||
- **Lint:** `ruff check duty_teller tests`
|
||||
- **Security:** `bandit -r duty_teller -ll`
|
||||
|
||||
## Documentation
|
||||
|
||||
- User and architecture docs: [docs/](docs/), [docs/architecture.md](docs/architecture.md).
|
||||
- Configuration reference: [docs/configuration.md](docs/configuration.md).
|
||||
- Build docs: `pip install -e ".[docs]"`, then `mkdocs build` / `mkdocs serve`.
|
||||
|
||||
Docstrings and code comments must be in English (Google-style docstrings). UI strings are the only exception; they live in `duty_teller/i18n/`.
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Commits:** [Conventional Commits](https://www.conventionalcommits.org/) (e.g. `feat:`, `fix:`, `docs:`).
|
||||
- **Branches:** Gitea Flow; changes via Pull Request.
|
||||
- **Testing:** pytest, 80% coverage target; unit and integration tests.
|
||||
- **Config:** Environment variables (e.g. `.env`); no hardcoded secrets.
|
||||
- **Database:** One logical transaction per `session_scope` — a single `commit` at the end of the business operation (e.g. in `run_import`). Repository helpers used inside such a flow (e.g. `get_or_create_user_by_full_name`) accept `commit=False` and let the caller commit once.
|
||||
- **Error handling:** Do not send `str(exception)` from parsers or DB to the user. Use generic i18n keys (e.g. `import.parse_error_generic`, `import.import_error_generic`) and log the full exception server-side.
|
||||
- **Cursor:** The project does not version `.cursor/`. You can mirror this file in `.cursor/rules/` locally; [AGENTS.md](AGENTS.md) is the single versioned reference for AI and maintainers.
|
||||
11
CHANGELOG.md
11
CHANGELOG.md
@@ -7,9 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2.0.0] - 2026-03-03
|
||||
|
||||
### Added
|
||||
|
||||
- **Group duty pin**: when the pinned duty message is updated on schedule, the bot re-pins it so group members get a Telegram notification. Configurable via `DUTY_PIN_NOTIFY` (default: enabled); set to `0` or `false` to only edit the message without re-pinning.
|
||||
- **Group duty pin**: when the pinned duty message is updated on schedule, the bot re-pins it so group members get a Telegram notification. Configurable via `DUTY_PIN_NOTIFY` (default: enabled); set to `0` or `false` to pin without notification. The first pin (e.g. when the bot is added to the group or on `/pin_duty`) is always silent.
|
||||
- **Command `/refresh_pin`**: in a group, immediately refresh the pinned duty message (send new message, unpin old, pin new).
|
||||
- **Role-based access and `/set_role`**: Miniapp and admin access are determined by roles stored in the database (`roles` table, `users.role_id`). Roles: `user` (miniapp access), `admin` (miniapp + `/import_duty_schedule`, `/set_role`). Admins assign roles with `/set_role @username user|admin` (or reply to a message with `/set_role user|admin`). `ALLOWED_USERNAMES` and `ALLOWED_PHONES` are no longer used for access (kept for reference).
|
||||
- **Command `/calendar_link`**: in private chat, send the user their personal ICS subscription URL (and team calendar URL) for calendar apps.
|
||||
- **Config `MINI_APP_SHORT_NAME`**: when set, the pinned duty message "View contacts" button uses a direct Mini App link (`https://t.me/BotName/ShortName?startapp=duty`) so the app opens on the current-duty view.
|
||||
- **Config `LOG_LEVEL`**: control backend logging and the Miniapp console logger (`window.__DT_LOG_LEVEL`); one of `DEBUG`, `INFO`, `WARNING`, `ERROR` (default: `INFO`).
|
||||
- **Mini App**: migrated to Next.js (TypeScript, Tailwind, shadcn/ui) with static export; improved loading states, duty timeline styling, and content readiness handling; configurable loopback host for health checks.
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -36,4 +44,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Input validation and initData hash verification for Miniapp access.
|
||||
- Optional CORS and init_data_max_age; use env for secrets.
|
||||
|
||||
[2.0.0]: https://github.com/your-org/duty-teller/releases/tag/v2.0.0 <!-- placeholder: set to your repo URL when publishing -->
|
||||
[0.1.0]: https://github.com/your-org/duty-teller/releases/tag/v0.1.0 <!-- placeholder: set to your repo URL when publishing -->
|
||||
|
||||
@@ -53,6 +53,15 @@
|
||||
bandit -r duty_teller -ll
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
All project documentation must be in **English**. This includes:
|
||||
|
||||
- README, files in `docs/`, docstrings, and commit messages that touch documentation.
|
||||
- Exception: user-facing UI strings are localized (Russian/English) in `duty_teller/i18n/` and are not considered project documentation.
|
||||
|
||||
Docstrings and code comments must be in English (Google-style docstrings). See [AGENTS.md](AGENTS.md) for AI/maintainer context.
|
||||
|
||||
## Commit messages
|
||||
|
||||
Use [Conventional Commits](https://www.conventionalcommits.org/), e.g.:
|
||||
|
||||
16
Dockerfile
16
Dockerfile
@@ -1,14 +1,22 @@
|
||||
# Multi-stage: builder installs deps; runtime copies only site-packages and app code.
|
||||
# Multi-stage: webapp build (Next.js), Python builder, runtime.
|
||||
# Single image for both dev and prod; Compose files differentiate behavior.
|
||||
|
||||
# --- Stage 1: builder (dependencies only) ---
|
||||
# --- Stage 1: webapp build (Next.js static export) ---
|
||||
FROM node:20-slim AS webapp-builder
|
||||
WORKDIR /webapp
|
||||
COPY webapp-next/package.json webapp-next/package-lock.json* ./
|
||||
RUN npm ci
|
||||
COPY webapp-next/ ./
|
||||
RUN npm run build
|
||||
|
||||
# --- Stage 2: builder (Python dependencies only) ---
|
||||
FROM python:3.12-slim AS builder
|
||||
WORKDIR /app
|
||||
COPY pyproject.toml ./
|
||||
COPY duty_teller/ ./duty_teller/
|
||||
RUN pip install --no-cache-dir .
|
||||
|
||||
# --- Stage 2: runtime (minimal final image) ---
|
||||
# --- Stage 3: runtime (minimal final image) ---
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
@@ -27,7 +35,7 @@ COPY main.py pyproject.toml entrypoint.sh ./
|
||||
RUN chmod +x entrypoint.sh
|
||||
COPY duty_teller/ ./duty_teller/
|
||||
COPY alembic/ ./alembic/
|
||||
COPY webapp/ ./webapp/
|
||||
COPY --from=webapp-builder /webapp/out ./webapp-next/out
|
||||
|
||||
# Create data dir; entrypoint runs as root, fixes perms for volume, then runs app as botuser
|
||||
RUN adduser --disabled-password --gecos "" botuser \
|
||||
|
||||
@@ -106,7 +106,7 @@ High-level architecture (components, data flow, package relationships) is descri
|
||||
- `main.py` – Entry point: calls `duty_teller.run:main`. Alternatively, after `pip install -e .`, run the console command **`duty-teller`** (see `pyproject.toml` and `duty_teller/run.py`). The runner builds the `Application`, registers handlers, runs polling and FastAPI in a thread, and calls `duty_teller.config.require_bot_token()` so the app exits with a clear message if `BOT_TOKEN` is missing.
|
||||
- `duty_teller/` – Main package (install with `pip install -e .`). Contains:
|
||||
- `config.py` – Loads `BOT_TOKEN`, `DATABASE_URL`, `ALLOWED_USERNAMES`, etc. from env; no exit on import; use `require_bot_token()` in the entry point when running the bot. Optional `Settings` dataclass for tests. `PROJECT_ROOT` for webapp path.
|
||||
- `api/` – FastAPI app (`/api/duties`, `/api/calendar-events`), `dependencies.py` (DB session, auth, date validation), static webapp mounted from `PROJECT_ROOT/webapp`.
|
||||
- `api/` – FastAPI app (`/api/duties`, `/api/calendar-events`), `dependencies.py` (DB session, auth, date validation), static webapp mounted from `PROJECT_ROOT/webapp-next/out` (built from `webapp-next/`).
|
||||
- `db/` – SQLAlchemy models, session (`session_scope`), repository, schemas.
|
||||
- `handlers/` – Telegram command and chat handlers; register via `register_handlers(app)`.
|
||||
- `i18n/` – Translations and language detection (ru/en); used by handlers and API.
|
||||
@@ -114,7 +114,7 @@ High-level architecture (components, data flow, package relationships) is descri
|
||||
- `utils/` – Shared date, user, and handover helpers.
|
||||
- `importers/` – Duty-schedule JSON parser.
|
||||
- `alembic/` – Migrations; config in `pyproject.toml` under `[tool.alembic]`; URL and metadata from `duty_teller.config` and `duty_teller.db.models.Base`. Run: `alembic -c pyproject.toml upgrade head`.
|
||||
- `webapp/` – Miniapp UI (calendar, duty list); served at `/app`.
|
||||
- `webapp-next/` – Miniapp UI (Next.js, TypeScript, Tailwind, shadcn/ui); build output in `webapp-next/out/`, served at `/app`.
|
||||
- `tests/` – Tests; `helpers.py` provides `make_init_data` for auth tests.
|
||||
- `pyproject.toml` – Installable package (`pip install -e .`).
|
||||
|
||||
@@ -151,6 +151,7 @@ Tests cover `api/telegram_auth` (validate_init_data, auth_date expiry), `config`
|
||||
- **Commits:** Use [Conventional Commits](https://www.conventionalcommits.org/): `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`, etc.
|
||||
- **Branches:** Follow [Gitea Flow](https://docs.gitea.io/en-us/workflow-branching/): main branch `main`, features and fixes in separate branches.
|
||||
- **Changes:** Via **Pull Request** in Gitea; run linters and tests (`ruff check .`, `pytest`) before merge.
|
||||
- **Documentation:** Project documentation is in English; see [CONTRIBUTING.md](CONTRIBUTING.md) for details.
|
||||
|
||||
## Logs and rotation
|
||||
|
||||
|
||||
32
alembic/versions/009_trusted_groups.py
Normal file
32
alembic/versions/009_trusted_groups.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""Add trusted_groups table.
|
||||
|
||||
Revision ID: 009
|
||||
Revises: 008
|
||||
Create Date: 2025-03-02
|
||||
|
||||
Table for groups authorized to receive duty information.
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "009"
|
||||
down_revision: Union[str, None] = "008"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"trusted_groups",
|
||||
sa.Column("chat_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("added_by_user_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("added_at", sa.Text(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("chat_id"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("trusted_groups")
|
||||
@@ -5,7 +5,7 @@ High-level architecture of Duty Teller: components, data flow, and package relat
|
||||
## Components
|
||||
|
||||
- **Bot** — [python-telegram-bot](https://github.com/python-telegram-bot/python-telegram-bot) v22 (Application API). Handles commands and group messages; runs in polling mode.
|
||||
- **FastAPI** — HTTP server: REST API (`/api/duties`, `/api/calendar-events`, `/api/calendar/ical/{token}.ics`) and static miniapp at `/app`. Runs in a separate thread alongside the bot.
|
||||
- **FastAPI** — HTTP server: REST API (`/api/duties`, `/api/calendar-events`, `/api/calendar/ical/{token}.ics`) and static miniapp at `/app` (built from `webapp-next/`, Next.js static export). Runs in a separate thread alongside the bot.
|
||||
- **Database** — SQLAlchemy ORM with Alembic migrations. Default backend: SQLite (`data/duty_teller.db`). Stores users, duties (with event types: duty, unavailable, vacation), group duty pins, calendar subscription tokens.
|
||||
- **Duty-schedule import** — Two-step admin flow: handover time (timezone → UTC), then JSON file. Parser produces per-person date lists; import service deletes existing duties in range and inserts new ones.
|
||||
- **Group duty pin** — In groups, the bot can pin the current duty message; time/timezone for the pinned text come from `DUTY_DISPLAY_TZ`. Pin state is restored on startup from the database. When the duty changes on schedule, the bot sends a new message, unpins the previous one and pins the new one; if `DUTY_PIN_NOTIFY` is enabled (default), pinning the new message triggers a Telegram notification for members. The first pin (bot added to group or `/pin_duty`) is always silent.
|
||||
|
||||
@@ -5,21 +5,23 @@ All configuration is read from the environment (e.g. `.env` via python-dotenv).
|
||||
| Variable | Type / format | Default | Description |
|
||||
|----------|----------------|---------|-------------|
|
||||
| **BOT_TOKEN** | string | *(empty)* | Telegram bot token from [@BotFather](https://t.me/BotFather). Required for the bot to run; if unset, the entry point exits with a clear message. The server that serves the Mini App API must use the **same** token as the bot; otherwise initData validation returns `hash_mismatch`. |
|
||||
| **DATABASE_URL** | string (SQLAlchemy URL) | `sqlite:///data/duty_teller.db` | Database connection URL. Example: `sqlite:///data/duty_teller.db`. |
|
||||
| **DATABASE_URL** | string (SQLAlchemy URL) | `sqlite:///data/duty_teller.db` | Database connection URL. Should start with `sqlite://` or `postgresql://`; a warning is logged at startup if the format is unexpected. Example: `sqlite:///data/duty_teller.db`. |
|
||||
| **MINI_APP_BASE_URL** | string (URL, no trailing slash) | *(empty)* | Base URL of the miniapp (for documentation and CORS). Trailing slash is stripped. Example: `https://your-domain.com/app`. |
|
||||
| **MINI_APP_SHORT_NAME** | string | *(empty)* | Short name of the Web App in BotFather (e.g. `DutyApp`). When set, the pinned duty message "View contacts" button uses a direct Mini App link `https://t.me/BotName/ShortName?startapp=duty` so the app opens on the current-duty view. If unset, the button uses `https://t.me/BotName?startapp=duty` (user may land in bot chat first). |
|
||||
| **HTTP_HOST** | string | `127.0.0.1` | Host to bind the HTTP server to. Use `127.0.0.1` to listen only on localhost; use `0.0.0.0` to accept connections from all interfaces (e.g. when behind a reverse proxy on another machine). |
|
||||
| **HTTP_PORT** | integer | `8080` | Port for the HTTP server (FastAPI + static webapp). |
|
||||
| **HTTP_PORT** | integer (1–65535) | `8080` | Port for the HTTP server (FastAPI + static webapp). Invalid or out-of-range values are clamped; non-numeric values fall back to 8080. |
|
||||
| **ALLOWED_USERNAMES** | comma-separated list | *(empty)* | **Not used for access.** Kept for reference only. Access to the miniapp is controlled by **roles in the DB** (assigned by an admin via `/set_role`). |
|
||||
| **ADMIN_USERNAMES** | comma-separated list | *(empty)* | Telegram usernames treated as **admin fallback** when the user has **no role in the DB**. If a user has a role in the DB, only that role applies. Example: `admin1,admin2`. |
|
||||
| **ALLOWED_PHONES** | comma-separated list | *(empty)* | **Not used for access.** Kept for reference only. |
|
||||
| **ADMIN_PHONES** | comma-separated list | *(empty)* | Phones treated as **admin fallback** when the user has **no role in the DB** (user sets phone via `/set_phone`). Comparison uses digits only. Example: `+7 999 123-45-67`. |
|
||||
| **MINI_APP_SKIP_AUTH** | `1`, `true`, or `yes` | *(unset)* | If set, `/api/duties` and `/api/calendar-events` are allowed without Telegram initData. **Dev only — never use in production.** |
|
||||
| **INIT_DATA_MAX_AGE_SECONDS** | integer | `0` | Reject Telegram initData older than this many seconds. `0` = disabled. Example: `86400` for 24 hours. |
|
||||
| **CORS_ORIGINS** | comma-separated list | `*` | Allowed origins for CORS. Leave unset or set to `*` for allow-all. Example: `https://your-domain.com`. |
|
||||
| **MINI_APP_SKIP_AUTH** | `1`, `true`, or `yes` | *(unset)* | If set, `/api/duties` and `/api/calendar-events` are allowed without Telegram initData. **Dev only — never use in production.** The process exits with an error if this is set and **HTTP_HOST** is not localhost (127.0.0.1). |
|
||||
| **INIT_DATA_MAX_AGE_SECONDS** | integer (≥ 0) | `0` | Reject Telegram initData older than this many seconds. `0` = disabled. Invalid values fall back to 0. Example: `86400` for 24 hours. |
|
||||
| **CORS_ORIGINS** | comma-separated list | `*` | Allowed origins for CORS. Leave unset or set to `*` for allow-all. **In production**, set an explicit list (e.g. `https://your-domain.com`) instead of `*` to avoid allowing arbitrary origins. Example: `https://your-domain.com`. |
|
||||
| **EXTERNAL_CALENDAR_ICS_URL** | string (URL) | *(empty)* | URL of a public ICS calendar (e.g. holidays). If set, those days are highlighted on the duty grid; users can tap "i" on a cell to see the event summary. Empty = no external calendar. |
|
||||
| **DUTY_DISPLAY_TZ** | string (timezone name) | `Europe/Moscow` | Timezone for the pinned duty message in groups. Example: `Europe/Moscow`, `UTC`. |
|
||||
| **DUTY_PIN_NOTIFY** | `0`, `false`, or `no` to disable | `1` (enabled) | When the pinned duty message is updated on schedule, the bot sends a new message, unpins the old one and pins the new one. If enabled, pinning the new message sends a Telegram notification (“Bot pinned a message”). Set to `0`, `false`, or `no` to pin without notification. The first pin (e.g. when the bot is added to the group or on `/pin_duty`) is always silent. |
|
||||
| **DEFAULT_LANGUAGE** | `en` or `ru` (normalized) | `en` | Default UI language when the user's Telegram language is unknown. Values starting with `ru` are normalized to `ru`, otherwise `en`. |
|
||||
| **DEFAULT_LANGUAGE** | `en` or `ru` (normalized) | `en` | **Single source of language for the whole deployment:** bot messages, API error texts, and Mini App UI all use this value. No auto-detection from Telegram user, browser, or `Accept-Language`. Values starting with `ru` are normalized to `ru`; anything else becomes `en`. |
|
||||
| **LOG_LEVEL** | `DEBUG`, `INFO`, `WARNING`, or `ERROR` | `INFO` | Logging level for the backend (Python `logging`) and for the Mini App console logger (`window.__DT_LOG_LEVEL`). Use `DEBUG` for troubleshooting; in production `INFO` or higher is recommended. |
|
||||
|
||||
## Roles and access
|
||||
|
||||
|
||||
@@ -11,3 +11,5 @@ Telegram bot for team duty shift calendar and group reminder. The bot and web UI
|
||||
- [API Reference](api-reference.md) — Generated from code (api, db, services, handlers, importers, config).
|
||||
|
||||
For quick start, setup, and API overview see the main [README](../README.md).
|
||||
|
||||
**For maintainers and AI:** Project documentation and docstrings must be in English; see [CONTRIBUTING.md](../CONTRIBUTING.md#documentation). [AGENTS.md](../AGENTS.md) in the repo root provides entry points, conventions, and where to change what.
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
Metadata-Version: 2.4
|
||||
Name: duty-teller
|
||||
Version: 0.1.0
|
||||
Summary: Telegram bot for team duty shift calendar and group reminder
|
||||
Requires-Python: >=3.11
|
||||
Description-Content-Type: text/markdown
|
||||
Requires-Dist: python-telegram-bot[job-queue]<23.0,>=22.0
|
||||
Requires-Dist: python-dotenv<2.0,>=1.0
|
||||
Requires-Dist: fastapi<1.0,>=0.115
|
||||
Requires-Dist: uvicorn[standard]<1.0,>=0.32
|
||||
Requires-Dist: sqlalchemy<3.0,>=2.0
|
||||
Requires-Dist: alembic<2.0,>=1.14
|
||||
Requires-Dist: pydantic<3.0,>=2.0
|
||||
Requires-Dist: icalendar<6.0,>=5.0
|
||||
Provides-Extra: dev
|
||||
Requires-Dist: pytest<9.0,>=8.0; extra == "dev"
|
||||
Requires-Dist: pytest-asyncio<2.0,>=1.0; extra == "dev"
|
||||
Requires-Dist: pytest-cov<7.0,>=6.0; extra == "dev"
|
||||
Requires-Dist: httpx<1.0,>=0.27; extra == "dev"
|
||||
Provides-Extra: docs
|
||||
Requires-Dist: mkdocs<2,>=1.5; extra == "docs"
|
||||
Requires-Dist: mkdocstrings[python]<1,>=0.24; extra == "docs"
|
||||
Requires-Dist: mkdocs-material<10,>=9.0; extra == "docs"
|
||||
|
||||
# Duty Teller (Telegram Bot)
|
||||
|
||||
A minimal Telegram bot boilerplate using [python-telegram-bot](https://github.com/python-telegram-bot/python-telegram-bot) v22 with the `Application` API. The bot and web UI support **Russian and English** (language from Telegram or `DEFAULT_LANGUAGE`).
|
||||
|
||||
**History of changes:** [CHANGELOG.md](CHANGELOG.md).
|
||||
|
||||
## Get a bot token
|
||||
|
||||
1. Open Telegram and search for [@BotFather](https://t.me/BotFather).
|
||||
2. Send `/newbot` and follow the prompts to create a bot.
|
||||
3. Copy the token BotFather gives you.
|
||||
|
||||
## Setup
|
||||
|
||||
1. **Clone and enter the project**
|
||||
```bash
|
||||
cd duty-teller
|
||||
```
|
||||
|
||||
2. **Create a virtual environment (recommended)**
|
||||
```bash
|
||||
python -m venv venv
|
||||
source venv/bin/activate # Linux/macOS
|
||||
# or: venv\Scripts\activate # Windows
|
||||
```
|
||||
|
||||
3. **Install dependencies**
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
4. **Configure the bot**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
Edit `.env` and set `BOT_TOKEN` to the token from BotFather.
|
||||
|
||||
5. **Miniapp access (calendar)**
|
||||
Access is controlled by **roles in the DB** (assigned by an admin with `/set_role @username user|admin`). Set `ADMIN_USERNAMES` (and optionally `ADMIN_PHONES`) so that at least one admin can use the bot and assign roles; these also act as a fallback for admin when a user has no role in the DB. See [docs/configuration.md](docs/configuration.md).
|
||||
**Mini App URL:** When configuring the bot's menu button or Web App URL (e.g. in @BotFather or via `setChatMenuButton`), use the URL **with a trailing slash**, e.g. `https://your-domain.com/app/`. A redirect from `/app` to `/app/` can cause the browser to drop the fragment that Telegram sends, which breaks authorization.
|
||||
**How to open:** Users must open the calendar **via the bot's menu button** (⋮ → "Calendar" or the configured label) or a **Web App inline button**. If they use "Open in browser" or a direct link, Telegram may not send user data (`tgWebAppData`), and access will be denied.
|
||||
**BOT_TOKEN:** The server that serves `/api/duties` (e.g. your production host) must have in `.env` the **same** bot token as the bot from which users open the Mini App. If the token differs (e.g. test vs production bot), validation returns "hash_mismatch" and access is denied.
|
||||
|
||||
6. **Other options**
|
||||
Full list of environment variables (types, defaults, examples): **[docs/configuration.md](docs/configuration.md)**.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
Or after `pip install -e .`:
|
||||
|
||||
```bash
|
||||
duty-teller
|
||||
```
|
||||
|
||||
The bot runs in polling mode. Send `/start` or `/help` to your bot in Telegram to test.
|
||||
|
||||
## Bot commands
|
||||
|
||||
- **`/start`** — Greeting and user registration in the database.
|
||||
- **`/help`** — Help on available commands.
|
||||
- **`/set_phone [number]`** — Set or clear phone number (private chat only); used for access via `ALLOWED_PHONES` / `ADMIN_PHONES`.
|
||||
- **`/import_duty_schedule`** — Import duty schedule (admin only); see **Duty schedule import** below for the two-step flow.
|
||||
- **`/set_role @username user|admin`** — Set a user’s role (admin only). Alternatively, reply to a message and send `/set_role user|admin`.
|
||||
- **`/pin_duty`** — Pin the current duty message in a group (reply to the bot’s duty message); time/timezone for the pinned message come from `DUTY_DISPLAY_TZ`.
|
||||
|
||||
## Run with Docker
|
||||
|
||||
Ensure `.env` exists (e.g. `cp .env.example .env`) and contains `BOT_TOKEN`.
|
||||
|
||||
- **Dev** (volume mount; code changes apply without rebuild):
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml up --build
|
||||
```
|
||||
Stop with `Ctrl+C` or `docker compose -f docker-compose.dev.yml down`.
|
||||
|
||||
- **Prod** (no volume; runs the built image; restarts on failure):
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml up -d --build
|
||||
```
|
||||
For production deployments you may use Docker secrets or your orchestrator’s env instead of a `.env` file.
|
||||
|
||||
The image is built from `Dockerfile`; on start, `entrypoint.sh` runs Alembic migrations then starts the app as user `botuser`.
|
||||
|
||||
**Production behind a reverse proxy:** When the app is behind nginx/Caddy etc., `request.client.host` is usually the proxy (e.g. 127.0.0.1). The "private IP" bypass (allowing requests without initData from localhost) then applies to the proxy, not the real client. Either ensure the Mini App always sends initData, or forward the real client IP (e.g. `X-Forwarded-For`) and use it for that check. See `api/app.py` `_is_private_client` for details.
|
||||
|
||||
## API
|
||||
|
||||
The HTTP server is FastAPI; the miniapp is served at `/app`.
|
||||
|
||||
**Interactive API documentation (Swagger UI)** is available at **`/docs`**, e.g. `http://localhost:8080/docs` when running locally.
|
||||
|
||||
- **`GET /api/duties`** — List of duties (date params; auth via Telegram initData or, in dev, `MINI_APP_SKIP_AUTH` / private IP).
|
||||
- **`GET /api/calendar-events`** — Calendar events (including external ICS when `EXTERNAL_CALENDAR_ICS_URL` is set).
|
||||
- **`GET /api/calendar/ical/{token}.ics`** — Personal ICS calendar (by secret token; no Telegram auth).
|
||||
|
||||
For production, initData validation is required; see the reverse-proxy paragraph above for proxy/headers.
|
||||
|
||||
## Project layout
|
||||
|
||||
High-level architecture (components, data flow, package relationships) is described in [docs/architecture.md](docs/architecture.md).
|
||||
|
||||
- `main.py` – Entry point: calls `duty_teller.run:main`. Alternatively, after `pip install -e .`, run the console command **`duty-teller`** (see `pyproject.toml` and `duty_teller/run.py`). The runner builds the `Application`, registers handlers, runs polling and FastAPI in a thread, and calls `duty_teller.config.require_bot_token()` so the app exits with a clear message if `BOT_TOKEN` is missing.
|
||||
- `duty_teller/` – Main package (install with `pip install -e .`). Contains:
|
||||
- `config.py` – Loads `BOT_TOKEN`, `DATABASE_URL`, `ALLOWED_USERNAMES`, etc. from env; no exit on import; use `require_bot_token()` in the entry point when running the bot. Optional `Settings` dataclass for tests. `PROJECT_ROOT` for webapp path.
|
||||
- `api/` – FastAPI app (`/api/duties`, `/api/calendar-events`), `dependencies.py` (DB session, auth, date validation), static webapp mounted from `PROJECT_ROOT/webapp`.
|
||||
- `db/` – SQLAlchemy models, session (`session_scope`), repository, schemas.
|
||||
- `handlers/` – Telegram command and chat handlers; register via `register_handlers(app)`.
|
||||
- `i18n/` – Translations and language detection (ru/en); used by handlers and API.
|
||||
- `services/` – Business logic (group duty pin, import); accept session from caller.
|
||||
- `utils/` – Shared date, user, and handover helpers.
|
||||
- `importers/` – Duty-schedule JSON parser.
|
||||
- `alembic/` – Migrations; config in `pyproject.toml` under `[tool.alembic]`; URL and metadata from `duty_teller.config` and `duty_teller.db.models.Base`. Run: `alembic -c pyproject.toml upgrade head`.
|
||||
- `webapp/` – Miniapp UI (calendar, duty list); served at `/app`.
|
||||
- `tests/` – Tests; `helpers.py` provides `make_init_data` for auth tests.
|
||||
- `pyproject.toml` – Installable package (`pip install -e .`).
|
||||
|
||||
**Documentation:** The `docs/` folder contains configuration reference, architecture, import format, and runbook. API reference is generated from the code. Build: `mkdocs build` (requires `pip install -e ".[docs]"`). Preview: `mkdocs serve`.
|
||||
|
||||
To add commands, define async handlers in `duty_teller/handlers/commands.py` (or a new module) and register them in `duty_teller/handlers/__init__.py`.
|
||||
|
||||
## Duty schedule import (duty-schedule)
|
||||
|
||||
The **`/import_duty_schedule`** command is available only to users in `ADMIN_USERNAMES` or `ADMIN_PHONES`. Import is done in two steps:
|
||||
|
||||
1. **Handover time** — The bot asks for the shift handover time and optional timezone (e.g. `09:00 Europe/Moscow` or `06:00 UTC`). This is converted to UTC and used as the boundary between duty periods when creating records.
|
||||
2. **JSON file** — Send a file in duty-schedule format.
|
||||
|
||||
Format: at the root of the JSON — a **meta** object with `start_date` (YYYY-MM-DD) and a **schedule** array of objects with `name` (full name) and `duty` (string with separator `;`; characters **в/В/б/Б** = duty, **Н** = unavailable, **О** = vacation). The number of days is given by the length of the `duty` string. On re-import, duties in the same date range for each user are replaced by the new data.
|
||||
|
||||
**Full format description and example JSON:** [docs/import-format.md](docs/import-format.md).
|
||||
|
||||
## Tests
|
||||
|
||||
Run from the repository root (no `src/` directory; package is `duty_teller` at the root). Use `PYTHONPATH=.` if needed:
|
||||
|
||||
```bash
|
||||
pip install -r requirements-dev.txt
|
||||
pytest
|
||||
```
|
||||
|
||||
Tests cover `api/telegram_auth` (validate_init_data, auth_date expiry), `config` (is_admin, can_access_miniapp), and the API (date validation, 403/200 with mocked auth, plus an E2E auth test without auth mocks).
|
||||
|
||||
**CI (Gitea Actions):** Lint (ruff), tests (pytest), security (bandit). If the workflow uses `PYTHONPATH: src` or `bandit -r src`, update it to match the repo layout (no `src/`).
|
||||
|
||||
## Contributing
|
||||
|
||||
- **Commits:** Use [Conventional Commits](https://www.conventionalcommits.org/): `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`, etc.
|
||||
- **Branches:** Follow [Gitea Flow](https://docs.gitea.io/en-us/workflow-branching/): main branch `main`, features and fixes in separate branches.
|
||||
- **Changes:** Via **Pull Request** in Gitea; run linters and tests (`ruff check .`, `pytest`) before merge.
|
||||
|
||||
## Logs and rotation
|
||||
|
||||
To meet the 7-day log retention policy, configure log rotation at deploy time: e.g. [logrotate](https://manpages.ubuntu.com/logrotate), systemd logging settings, or Docker (size/time retention limits). Keep application logs for no more than 7 days.
|
||||
@@ -1,63 +0,0 @@
|
||||
README.md
|
||||
pyproject.toml
|
||||
duty_teller/__init__.py
|
||||
duty_teller/config.py
|
||||
duty_teller/run.py
|
||||
duty_teller.egg-info/PKG-INFO
|
||||
duty_teller.egg-info/SOURCES.txt
|
||||
duty_teller.egg-info/dependency_links.txt
|
||||
duty_teller.egg-info/entry_points.txt
|
||||
duty_teller.egg-info/requires.txt
|
||||
duty_teller.egg-info/top_level.txt
|
||||
duty_teller/api/__init__.py
|
||||
duty_teller/api/app.py
|
||||
duty_teller/api/calendar_ics.py
|
||||
duty_teller/api/dependencies.py
|
||||
duty_teller/api/personal_calendar_ics.py
|
||||
duty_teller/api/telegram_auth.py
|
||||
duty_teller/db/__init__.py
|
||||
duty_teller/db/models.py
|
||||
duty_teller/db/repository.py
|
||||
duty_teller/db/schemas.py
|
||||
duty_teller/db/session.py
|
||||
duty_teller/handlers/__init__.py
|
||||
duty_teller/handlers/commands.py
|
||||
duty_teller/handlers/common.py
|
||||
duty_teller/handlers/errors.py
|
||||
duty_teller/handlers/group_duty_pin.py
|
||||
duty_teller/handlers/import_duty_schedule.py
|
||||
duty_teller/i18n/__init__.py
|
||||
duty_teller/i18n/core.py
|
||||
duty_teller/i18n/lang.py
|
||||
duty_teller/i18n/messages.py
|
||||
duty_teller/importers/__init__.py
|
||||
duty_teller/importers/duty_schedule.py
|
||||
duty_teller/services/__init__.py
|
||||
duty_teller/services/group_duty_pin_service.py
|
||||
duty_teller/services/import_service.py
|
||||
duty_teller/utils/__init__.py
|
||||
duty_teller/utils/dates.py
|
||||
duty_teller/utils/handover.py
|
||||
duty_teller/utils/user.py
|
||||
tests/test_api_dependencies.py
|
||||
tests/test_app.py
|
||||
tests/test_calendar_ics.py
|
||||
tests/test_calendar_token_repository.py
|
||||
tests/test_config.py
|
||||
tests/test_db_session.py
|
||||
tests/test_duty_schedule_parser.py
|
||||
tests/test_group_duty_pin_service.py
|
||||
tests/test_handlers_commands.py
|
||||
tests/test_handlers_errors.py
|
||||
tests/test_handlers_group_duty_pin.py
|
||||
tests/test_handlers_init.py
|
||||
tests/test_i18n.py
|
||||
tests/test_import_duty_schedule_integration.py
|
||||
tests/test_import_service.py
|
||||
tests/test_package_init.py
|
||||
tests/test_personal_calendar_ics.py
|
||||
tests/test_repository_duty_range.py
|
||||
tests/test_repository_roles.py
|
||||
tests/test_run.py
|
||||
tests/test_telegram_auth.py
|
||||
tests/test_utils.py
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
[console_scripts]
|
||||
duty-teller = duty_teller.run:main
|
||||
@@ -1,19 +0,0 @@
|
||||
python-telegram-bot[job-queue]<23.0,>=22.0
|
||||
python-dotenv<2.0,>=1.0
|
||||
fastapi<1.0,>=0.115
|
||||
uvicorn[standard]<1.0,>=0.32
|
||||
sqlalchemy<3.0,>=2.0
|
||||
alembic<2.0,>=1.14
|
||||
pydantic<3.0,>=2.0
|
||||
icalendar<6.0,>=5.0
|
||||
|
||||
[dev]
|
||||
pytest<9.0,>=8.0
|
||||
pytest-asyncio<2.0,>=1.0
|
||||
pytest-cov<7.0,>=6.0
|
||||
httpx<1.0,>=0.27
|
||||
|
||||
[docs]
|
||||
mkdocs<2,>=1.5
|
||||
mkdocstrings[python]<1,>=0.24
|
||||
mkdocs-material<10,>=9.0
|
||||
@@ -1 +0,0 @@
|
||||
duty_teller
|
||||
@@ -5,9 +5,10 @@ import re
|
||||
from datetime import date, timedelta
|
||||
|
||||
import duty_teller.config as config
|
||||
|
||||
from fastapi import Depends, FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import Response
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -41,6 +42,16 @@ def _is_valid_calendar_token(token: str) -> bool:
|
||||
app = FastAPI(title="Duty Teller API")
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
def global_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||
"""Log unhandled exceptions and return 500 without exposing details to the client."""
|
||||
log.exception("Unhandled exception: %s", exc)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"detail": "Internal server error"},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/health", summary="Health check")
|
||||
def health() -> dict:
|
||||
"""Return 200 when the app is up. Used by Docker HEALTHCHECK."""
|
||||
@@ -56,6 +67,102 @@ app.add_middleware(
|
||||
)
|
||||
|
||||
|
||||
class NoCacheStaticMiddleware:
|
||||
"""
|
||||
Raw ASGI middleware: Cache-Control: no-store for all /app and /app/* static files;
|
||||
Vary: Accept-Language on all responses so reverse proxies do not serve one user's response to another.
|
||||
"""
|
||||
|
||||
def __init__(self, app, **kwargs):
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
if scope["type"] != "http":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
path = scope.get("path", "")
|
||||
is_app_path = path == "/app" or path.startswith("/app/")
|
||||
|
||||
async def send_wrapper(message):
|
||||
if message["type"] == "http.response.start":
|
||||
headers = list(message.get("headers", []))
|
||||
header_names = {h[0].lower(): i for i, h in enumerate(headers)}
|
||||
if is_app_path:
|
||||
cache_control = (b"cache-control", b"no-store")
|
||||
if b"cache-control" in header_names:
|
||||
headers[header_names[b"cache-control"]] = cache_control
|
||||
else:
|
||||
headers.append(cache_control)
|
||||
vary_val = b"Accept-Language"
|
||||
if b"vary" in header_names:
|
||||
idx = header_names[b"vary"]
|
||||
existing = headers[idx][1]
|
||||
tokens = [p.strip() for p in existing.split(b",")]
|
||||
if vary_val not in tokens:
|
||||
headers[idx] = (b"vary", existing + b", " + vary_val)
|
||||
else:
|
||||
headers.append((b"vary", vary_val))
|
||||
message = {
|
||||
"type": "http.response.start",
|
||||
"status": message["status"],
|
||||
"headers": headers,
|
||||
}
|
||||
await send(message)
|
||||
|
||||
await self.app(scope, receive, send_wrapper)
|
||||
|
||||
|
||||
app.add_middleware(NoCacheStaticMiddleware)
|
||||
|
||||
|
||||
# Allowed values for config.js to prevent script injection.
|
||||
_VALID_LANGS = frozenset({"en", "ru"})
|
||||
_VALID_LOG_LEVELS = frozenset({"debug", "info", "warning", "error"})
|
||||
|
||||
|
||||
def _safe_js_string(value: str, allowed: frozenset[str], default: str) -> str:
|
||||
"""Return value if it is in allowed set, else default. Prevents injection in config.js."""
|
||||
if value in allowed:
|
||||
return value
|
||||
return default
|
||||
|
||||
|
||||
# Timezone for duty display: allow only safe chars (letters, digits, /, _, -, +) to prevent injection.
|
||||
_TZ_SAFE_RE = re.compile(r"^[A-Za-z0-9_/+-]{1,50}$")
|
||||
|
||||
|
||||
def _safe_tz_string(value: str) -> str:
|
||||
"""Return value if it matches safe timezone pattern, else empty string."""
|
||||
if value and _TZ_SAFE_RE.match(value.strip()):
|
||||
return value.strip()
|
||||
return ""
|
||||
|
||||
|
||||
@app.get(
|
||||
"/app/config.js",
|
||||
summary="Mini App config (language, log level, timezone)",
|
||||
description=(
|
||||
"Returns JS that sets window.__DT_LANG, window.__DT_LOG_LEVEL and window.__DT_TZ. "
|
||||
"Loaded before main.js."
|
||||
),
|
||||
)
|
||||
def app_config_js() -> Response:
|
||||
"""Return JS assigning window.__DT_LANG, __DT_LOG_LEVEL and __DT_TZ for the webapp. No caching."""
|
||||
lang = _safe_js_string(config.DEFAULT_LANGUAGE, _VALID_LANGS, "en")
|
||||
log_level = _safe_js_string(config.LOG_LEVEL_STR.lower(), _VALID_LOG_LEVELS, "info")
|
||||
tz = _safe_tz_string(config.DUTY_DISPLAY_TZ)
|
||||
tz_js = f'\nwindow.__DT_TZ = "{tz}";' if tz else "\nwindow.__DT_TZ = undefined;"
|
||||
body = (
|
||||
f'window.__DT_LANG = "{lang}";\nwindow.__DT_LOG_LEVEL = "{log_level}";{tz_js}'
|
||||
)
|
||||
return Response(
|
||||
content=body,
|
||||
media_type="application/javascript; charset=utf-8",
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
|
||||
@app.get(
|
||||
"/api/duties",
|
||||
response_model=list[DutyWithUser],
|
||||
@@ -114,10 +221,10 @@ def get_team_calendar_ical(
|
||||
) -> Response:
|
||||
"""Return ICS calendar with all duties (event_type duty only). Token validates user."""
|
||||
if not _is_valid_calendar_token(token):
|
||||
return Response(status_code=404, content="Not found")
|
||||
return JSONResponse(status_code=404, content={"detail": "Not found"})
|
||||
user = get_user_by_calendar_token(session, token)
|
||||
if user is None:
|
||||
return Response(status_code=404, content="Not found")
|
||||
return JSONResponse(status_code=404, content={"detail": "Not found"})
|
||||
cache_key = ("team_ics",)
|
||||
ics_bytes, found = ics_calendar_cache.get(cache_key)
|
||||
if not found:
|
||||
@@ -126,7 +233,9 @@ def get_team_calendar_ical(
|
||||
to_date = (today + timedelta(days=365 * 2)).strftime("%Y-%m-%d")
|
||||
all_duties = get_duties(session, from_date=from_date, to_date=to_date)
|
||||
duties_duty_only = [
|
||||
(d, name) for d, name in all_duties if (d.event_type or "duty") == "duty"
|
||||
(d, name)
|
||||
for d, name, *_ in all_duties
|
||||
if (d.event_type or "duty") == "duty"
|
||||
]
|
||||
ics_bytes = build_team_ics(duties_duty_only)
|
||||
ics_calendar_cache.set(cache_key, ics_bytes)
|
||||
@@ -153,10 +262,10 @@ def get_personal_calendar_ical(
|
||||
No Telegram auth; access is by secret token in the URL.
|
||||
"""
|
||||
if not _is_valid_calendar_token(token):
|
||||
return Response(status_code=404, content="Not found")
|
||||
return JSONResponse(status_code=404, content={"detail": "Not found"})
|
||||
user = get_user_by_calendar_token(session, token)
|
||||
if user is None:
|
||||
return Response(status_code=404, content="Not found")
|
||||
return JSONResponse(status_code=404, content={"detail": "Not found"})
|
||||
cache_key = ("personal_ics", user.id)
|
||||
ics_bytes, found = ics_calendar_cache.get(cache_key)
|
||||
if not found:
|
||||
@@ -174,6 +283,6 @@ def get_personal_calendar_ical(
|
||||
)
|
||||
|
||||
|
||||
webapp_path = config.PROJECT_ROOT / "webapp"
|
||||
webapp_path = config.PROJECT_ROOT / "webapp-next" / "out"
|
||||
if webapp_path.is_dir():
|
||||
app.mount("/app", StaticFiles(directory=str(webapp_path), html=True), name="webapp")
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""FastAPI dependencies: DB session, Miniapp auth (initData/allowlist), date validation."""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Annotated, Generator
|
||||
|
||||
from fastapi import Depends, Header, HTTPException, Query, Request
|
||||
@@ -17,42 +16,18 @@ from duty_teller.db.repository import (
|
||||
from duty_teller.db.schemas import DUTY_EVENT_TYPES, DutyWithUser
|
||||
from duty_teller.db.session import session_scope
|
||||
from duty_teller.i18n import t
|
||||
from duty_teller.i18n.lang import normalize_lang
|
||||
from duty_teller.utils.dates import DateRangeValidationError, validate_date_range
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Extract primary language code from first Accept-Language tag (e.g. "ru-RU" -> "ru").
|
||||
_ACCEPT_LANG_CODE_RE = re.compile(r"^([a-zA-Z]{2,3})(?:-|;|,|\s|$)")
|
||||
|
||||
|
||||
def _parse_first_language_code(header: str | None) -> str | None:
|
||||
"""Extract the first language code from Accept-Language header.
|
||||
|
||||
Args:
|
||||
header: Raw Accept-Language value (e.g. "ru-RU,ru;q=0.9,en;q=0.8").
|
||||
|
||||
Returns:
|
||||
Two- or three-letter code (e.g. 'ru', 'en') or None if missing/invalid.
|
||||
"""
|
||||
if not header or not header.strip():
|
||||
return None
|
||||
first = header.strip().split(",")[0].strip()
|
||||
m = _ACCEPT_LANG_CODE_RE.match(first)
|
||||
return m.group(1).lower() if m else None
|
||||
|
||||
|
||||
def _lang_from_accept_language(header: str | None) -> str:
|
||||
"""Normalize Accept-Language header to 'ru' or 'en'; fallback to config.DEFAULT_LANGUAGE.
|
||||
"""Return the application language: always config.DEFAULT_LANGUAGE.
|
||||
|
||||
Args:
|
||||
header: Raw Accept-Language header value (e.g. "ru-RU,ru;q=0.9,en;q=0.8").
|
||||
|
||||
Returns:
|
||||
'ru' or 'en'.
|
||||
The header argument is kept for backward compatibility but is ignored.
|
||||
The whole deployment uses a single language from DEFAULT_LANGUAGE.
|
||||
"""
|
||||
code = _parse_first_language_code(header)
|
||||
return normalize_lang(code if code is not None else config.DEFAULT_LANGUAGE)
|
||||
return config.DEFAULT_LANGUAGE
|
||||
|
||||
|
||||
def _auth_error_detail(auth_reason: str, lang: str) -> str:
|
||||
@@ -67,7 +42,12 @@ def _validate_duty_dates(from_date: str, to_date: str, lang: str) -> None:
|
||||
try:
|
||||
validate_date_range(from_date, to_date)
|
||||
except DateRangeValidationError as e:
|
||||
key = "dates.bad_format" if e.kind == "bad_format" else "dates.from_after_to"
|
||||
key_map = {
|
||||
"bad_format": "dates.bad_format",
|
||||
"from_after_to": "dates.from_after_to",
|
||||
"range_too_large": "dates.range_too_large",
|
||||
}
|
||||
key = key_map.get(e.kind, "dates.bad_format")
|
||||
raise HTTPException(status_code=400, detail=t(lang, key)) from e
|
||||
except ValueError as e:
|
||||
# Backward compatibility if something else raises ValueError.
|
||||
@@ -190,7 +170,7 @@ def fetch_duties_response(
|
||||
to_date: End date YYYY-MM-DD.
|
||||
|
||||
Returns:
|
||||
List of DutyWithUser (id, user_id, start_at, end_at, full_name, event_type).
|
||||
List of DutyWithUser (id, user_id, start_at, end_at, full_name, event_type, phone, username).
|
||||
"""
|
||||
rows = get_duties(session, from_date=from_date, to_date=to_date)
|
||||
return [
|
||||
@@ -203,6 +183,8 @@ def fetch_duties_response(
|
||||
event_type=(
|
||||
duty.event_type if duty.event_type in DUTY_EVENT_TYPES else "duty"
|
||||
),
|
||||
phone=phone,
|
||||
username=username,
|
||||
)
|
||||
for duty, full_name in rows
|
||||
for duty, full_name, phone, username in rows
|
||||
]
|
||||
|
||||
@@ -6,7 +6,7 @@ import json
|
||||
import time
|
||||
from urllib.parse import unquote
|
||||
|
||||
from duty_teller.i18n.lang import normalize_lang
|
||||
import duty_teller.config as config
|
||||
|
||||
# Telegram algorithm: https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app
|
||||
# Data-check string: sorted key=value with URL-decoded values, then HMAC-SHA256(WebAppData, token) as secret.
|
||||
@@ -48,12 +48,12 @@ def validate_init_data_with_reason(
|
||||
Returns:
|
||||
Tuple (telegram_user_id, username, reason, lang). reason is one of: "ok",
|
||||
"empty", "no_hash", "hash_mismatch", "auth_date_expired", "no_user",
|
||||
"user_invalid", "no_user_id". lang is from user.language_code normalized
|
||||
to 'ru' or 'en'; 'en' when no user. On success: (user.id, username or None,
|
||||
"ok", lang).
|
||||
"user_invalid", "no_user_id". lang is always config.DEFAULT_LANGUAGE.
|
||||
On success: (user.id, username or None, "ok", lang).
|
||||
"""
|
||||
lang = config.DEFAULT_LANGUAGE
|
||||
if not init_data or not bot_token:
|
||||
return (None, None, "empty", "en")
|
||||
return (None, None, "empty", lang)
|
||||
init_data = init_data.strip()
|
||||
params = {}
|
||||
for part in init_data.split("&"):
|
||||
@@ -65,7 +65,7 @@ def validate_init_data_with_reason(
|
||||
params[key] = value
|
||||
hash_val = params.pop("hash", None)
|
||||
if not hash_val:
|
||||
return (None, None, "no_hash", "en")
|
||||
return (None, None, "no_hash", lang)
|
||||
data_pairs = sorted(params.items())
|
||||
# Data-check string: key=value with URL-decoded values (per Telegram example)
|
||||
data_string = "\n".join(f"{k}={unquote(v)}" for k, v in data_pairs)
|
||||
@@ -81,27 +81,26 @@ def validate_init_data_with_reason(
|
||||
digestmod=hashlib.sha256,
|
||||
).hexdigest()
|
||||
if not hmac.compare_digest(computed.lower(), hash_val.lower()):
|
||||
return (None, None, "hash_mismatch", "en")
|
||||
return (None, None, "hash_mismatch", lang)
|
||||
if max_age_seconds is not None and max_age_seconds > 0:
|
||||
auth_date_raw = params.get("auth_date")
|
||||
if not auth_date_raw:
|
||||
return (None, None, "auth_date_expired", "en")
|
||||
return (None, None, "auth_date_expired", lang)
|
||||
try:
|
||||
auth_date = int(float(auth_date_raw))
|
||||
except (ValueError, TypeError):
|
||||
return (None, None, "auth_date_expired", "en")
|
||||
return (None, None, "auth_date_expired", lang)
|
||||
if time.time() - auth_date > max_age_seconds:
|
||||
return (None, None, "auth_date_expired", "en")
|
||||
return (None, None, "auth_date_expired", lang)
|
||||
user_raw = params.get("user")
|
||||
if not user_raw:
|
||||
return (None, None, "no_user", "en")
|
||||
return (None, None, "no_user", lang)
|
||||
try:
|
||||
user = json.loads(unquote(user_raw))
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return (None, None, "user_invalid", "en")
|
||||
return (None, None, "user_invalid", lang)
|
||||
if not isinstance(user, dict):
|
||||
return (None, None, "user_invalid", "en")
|
||||
lang = normalize_lang(user.get("language_code"))
|
||||
return (None, None, "user_invalid", lang)
|
||||
raw_id = user.get("id")
|
||||
if raw_id is None:
|
||||
return (None, None, "no_user_id", lang)
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"""Load configuration from environment (e.g. .env via python-dotenv).
|
||||
|
||||
BOT_TOKEN is not validated on import; call require_bot_token() in the entry point
|
||||
when running the bot.
|
||||
when running the bot. Numeric env vars (HTTP_PORT, INIT_DATA_MAX_AGE_SECONDS) use
|
||||
safe parsing with defaults on invalid values.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
@@ -15,6 +17,14 @@ from duty_teller.i18n.lang import normalize_lang
|
||||
|
||||
load_dotenv()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Valid port range for HTTP_PORT.
|
||||
HTTP_PORT_MIN, HTTP_PORT_MAX = 1, 65535
|
||||
|
||||
# Host values treated as loopback (for health-check URL and MINI_APP_SKIP_AUTH safety).
|
||||
LOOPBACK_HTTP_HOSTS = ("127.0.0.1", "localhost", "::1", "")
|
||||
|
||||
# Project root (parent of duty_teller package). Used for webapp path, etc.
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
@@ -46,13 +56,65 @@ def _parse_phone_list(raw: str) -> set[str]:
|
||||
return result
|
||||
|
||||
|
||||
def _normalize_log_level(raw: str) -> str:
|
||||
"""Return a valid log level name (DEBUG, INFO, WARNING, ERROR); default INFO."""
|
||||
level = (raw or "").strip().upper()
|
||||
if level in ("DEBUG", "INFO", "WARNING", "ERROR"):
|
||||
return level
|
||||
return "INFO"
|
||||
|
||||
|
||||
def _parse_int_env(
|
||||
name: str, default: int, min_val: int | None = None, max_val: int | None = None
|
||||
) -> int:
|
||||
"""Parse an integer from os.environ; use default on invalid or out-of-range. Log on fallback."""
|
||||
raw = os.getenv(name)
|
||||
if raw is None or raw == "":
|
||||
return default
|
||||
try:
|
||||
value = int(raw.strip())
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"Invalid %s=%r (expected integer); using default %s",
|
||||
name,
|
||||
raw,
|
||||
default,
|
||||
)
|
||||
return default
|
||||
if min_val is not None and value < min_val:
|
||||
logger.warning(
|
||||
"%s=%s is below minimum %s; using %s", name, value, min_val, min_val
|
||||
)
|
||||
return min_val
|
||||
if max_val is not None and value > max_val:
|
||||
logger.warning(
|
||||
"%s=%s is above maximum %s; using %s", name, value, max_val, max_val
|
||||
)
|
||||
return max_val
|
||||
return value
|
||||
|
||||
|
||||
def _validate_database_url(url: str) -> bool:
|
||||
"""Return True if URL looks like a supported SQLAlchemy URL (sqlite or postgres)."""
|
||||
if not url or not isinstance(url, str):
|
||||
return False
|
||||
u = url.strip().split("?", 1)[0].lower()
|
||||
return (
|
||||
u.startswith("sqlite://")
|
||||
or u.startswith("postgresql://")
|
||||
or u.startswith("postgres://")
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Settings:
|
||||
"""Injectable settings built from environment. Used in tests or when env is overridden."""
|
||||
|
||||
bot_token: str
|
||||
database_url: str
|
||||
bot_username: str
|
||||
mini_app_base_url: str
|
||||
mini_app_short_name: str
|
||||
http_host: str
|
||||
http_port: int
|
||||
allowed_usernames: set[str]
|
||||
@@ -66,6 +128,7 @@ class Settings:
|
||||
duty_display_tz: str
|
||||
default_language: str
|
||||
duty_pin_notify: bool
|
||||
log_level: str
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "Settings":
|
||||
@@ -93,19 +156,34 @@ class Settings:
|
||||
)
|
||||
raw_host = (os.getenv("HTTP_HOST") or "127.0.0.1").strip()
|
||||
http_host = raw_host if raw_host else "127.0.0.1"
|
||||
bot_username = (os.getenv("BOT_USERNAME", "") or "").strip().lstrip("@").lower()
|
||||
database_url = os.getenv("DATABASE_URL", "sqlite:///data/duty_teller.db")
|
||||
if not _validate_database_url(database_url):
|
||||
logger.warning(
|
||||
"DATABASE_URL does not look like a supported URL (sqlite:// or postgresql://); "
|
||||
"DB connection may fail."
|
||||
)
|
||||
http_port = _parse_int_env(
|
||||
"HTTP_PORT", 8080, min_val=HTTP_PORT_MIN, max_val=HTTP_PORT_MAX
|
||||
)
|
||||
init_data_max_age = _parse_int_env("INIT_DATA_MAX_AGE_SECONDS", 0, min_val=0)
|
||||
return cls(
|
||||
bot_token=bot_token,
|
||||
database_url=os.getenv("DATABASE_URL", "sqlite:///data/duty_teller.db"),
|
||||
database_url=database_url,
|
||||
bot_username=bot_username,
|
||||
mini_app_base_url=os.getenv("MINI_APP_BASE_URL", "").rstrip("/"),
|
||||
mini_app_short_name=(os.getenv("MINI_APP_SHORT_NAME", "") or "")
|
||||
.strip()
|
||||
.strip("/"),
|
||||
http_host=http_host,
|
||||
http_port=int(os.getenv("HTTP_PORT", "8080")),
|
||||
http_port=http_port,
|
||||
allowed_usernames=allowed,
|
||||
admin_usernames=admin,
|
||||
allowed_phones=allowed_phones,
|
||||
admin_phones=admin_phones,
|
||||
mini_app_skip_auth=os.getenv("MINI_APP_SKIP_AUTH", "").strip()
|
||||
in ("1", "true", "yes"),
|
||||
init_data_max_age_seconds=int(os.getenv("INIT_DATA_MAX_AGE_SECONDS", "0")),
|
||||
init_data_max_age_seconds=init_data_max_age,
|
||||
cors_origins=cors,
|
||||
external_calendar_ics_url=os.getenv(
|
||||
"EXTERNAL_CALENDAR_ICS_URL", ""
|
||||
@@ -115,6 +193,7 @@ class Settings:
|
||||
default_language=normalize_lang(os.getenv("DEFAULT_LANGUAGE", "en")),
|
||||
duty_pin_notify=os.getenv("DUTY_PIN_NOTIFY", "1").strip().lower()
|
||||
not in ("0", "false", "no"),
|
||||
log_level=_normalize_log_level(os.getenv("LOG_LEVEL", "INFO")),
|
||||
)
|
||||
|
||||
|
||||
@@ -123,7 +202,9 @@ _settings = Settings.from_env()
|
||||
|
||||
BOT_TOKEN = _settings.bot_token
|
||||
DATABASE_URL = _settings.database_url
|
||||
BOT_USERNAME = _settings.bot_username
|
||||
MINI_APP_BASE_URL = _settings.mini_app_base_url
|
||||
MINI_APP_SHORT_NAME = _settings.mini_app_short_name
|
||||
HTTP_HOST = _settings.http_host
|
||||
HTTP_PORT = _settings.http_port
|
||||
ALLOWED_USERNAMES = _settings.allowed_usernames
|
||||
@@ -137,6 +218,8 @@ EXTERNAL_CALENDAR_ICS_URL = _settings.external_calendar_ics_url
|
||||
DUTY_DISPLAY_TZ = _settings.duty_display_tz
|
||||
DEFAULT_LANGUAGE = _settings.default_language
|
||||
DUTY_PIN_NOTIFY = _settings.duty_pin_notify
|
||||
LOG_LEVEL = getattr(logging, _settings.log_level.upper(), logging.INFO)
|
||||
LOG_LEVEL_STR = _settings.log_level
|
||||
|
||||
|
||||
def is_admin(username: str) -> bool:
|
||||
|
||||
@@ -84,3 +84,13 @@ class GroupDutyPin(Base):
|
||||
|
||||
chat_id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
message_id: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
|
||||
|
||||
class TrustedGroup(Base):
|
||||
"""Groups authorized to receive duty information."""
|
||||
|
||||
__tablename__ = "trusted_groups"
|
||||
|
||||
chat_id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
added_by_user_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
added_at: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
@@ -7,10 +7,12 @@ from datetime import datetime, timezone
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
import duty_teller.config as config
|
||||
from duty_teller.db.schemas import DUTY_EVENT_TYPES
|
||||
from duty_teller.db.models import (
|
||||
User,
|
||||
Duty,
|
||||
GroupDutyPin,
|
||||
TrustedGroup,
|
||||
CalendarSubscriptionToken,
|
||||
Role,
|
||||
)
|
||||
@@ -200,14 +202,19 @@ def get_or_create_user(
|
||||
return user
|
||||
|
||||
|
||||
def get_or_create_user_by_full_name(session: Session, full_name: str) -> User:
|
||||
def get_or_create_user_by_full_name(
|
||||
session: Session, full_name: str, *, commit: bool = True
|
||||
) -> User:
|
||||
"""Find user by exact full_name or create one (for duty-schedule import).
|
||||
|
||||
New users have telegram_user_id=None and name_manually_edited=True.
|
||||
When commit=False, caller is responsible for committing (e.g. single commit
|
||||
per import in run_import).
|
||||
|
||||
Args:
|
||||
session: DB session.
|
||||
full_name: Exact full name to match or set.
|
||||
commit: If True, commit immediately. If False, caller commits.
|
||||
|
||||
Returns:
|
||||
User instance (existing or newly created).
|
||||
@@ -224,8 +231,11 @@ def get_or_create_user_by_full_name(session: Session, full_name: str) -> User:
|
||||
name_manually_edited=True,
|
||||
)
|
||||
session.add(user)
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
if commit:
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
else:
|
||||
session.flush() # Assign id so caller can use user.id before commit
|
||||
return user
|
||||
|
||||
|
||||
@@ -316,8 +326,8 @@ def get_duties(
|
||||
session: Session,
|
||||
from_date: str,
|
||||
to_date: str,
|
||||
) -> list[tuple[Duty, str]]:
|
||||
"""Return duties overlapping the given date range with user full_name.
|
||||
) -> list[tuple[Duty, str, str | None, str | None]]:
|
||||
"""Return duties overlapping the given date range with user full_name, phone, username.
|
||||
|
||||
Args:
|
||||
session: DB session.
|
||||
@@ -325,11 +335,11 @@ def get_duties(
|
||||
to_date: End date YYYY-MM-DD.
|
||||
|
||||
Returns:
|
||||
List of (Duty, full_name) tuples.
|
||||
List of (Duty, full_name, phone, username) tuples.
|
||||
"""
|
||||
to_date_next = to_date_exclusive_iso(to_date)
|
||||
q = (
|
||||
session.query(Duty, User.full_name)
|
||||
session.query(Duty, User.full_name, User.phone, User.username)
|
||||
.join(User, Duty.user_id == User.id)
|
||||
.filter(Duty.start_at < to_date_next, Duty.end_at >= from_date)
|
||||
)
|
||||
@@ -342,7 +352,7 @@ def get_duties_for_user(
|
||||
from_date: str,
|
||||
to_date: str,
|
||||
event_types: list[str] | None = None,
|
||||
) -> list[tuple[Duty, str]]:
|
||||
) -> list[tuple[Duty, str, str | None, str | None]]:
|
||||
"""Return duties for one user overlapping the date range.
|
||||
|
||||
Optionally filter by event_type (e.g. "duty", "unavailable", "vacation").
|
||||
@@ -356,7 +366,7 @@ def get_duties_for_user(
|
||||
event_types: If not None, only return duties whose event_type is in this list.
|
||||
|
||||
Returns:
|
||||
List of (Duty, full_name) tuples.
|
||||
List of (Duty, full_name, phone, username) tuples.
|
||||
"""
|
||||
to_date_next = to_date_exclusive_iso(to_date)
|
||||
filters = [
|
||||
@@ -367,7 +377,7 @@ def get_duties_for_user(
|
||||
if event_types is not None:
|
||||
filters.append(Duty.event_type.in_(event_types))
|
||||
q = (
|
||||
session.query(Duty, User.full_name)
|
||||
session.query(Duty, User.full_name, User.phone, User.username)
|
||||
.join(User, Duty.user_id == User.id)
|
||||
.filter(*filters)
|
||||
)
|
||||
@@ -446,11 +456,13 @@ def insert_duty(
|
||||
user_id: User id.
|
||||
start_at: Start time UTC, ISO 8601 with Z (e.g. 2025-01-15T09:00:00Z).
|
||||
end_at: End time UTC, ISO 8601 with Z.
|
||||
event_type: One of "duty", "unavailable", "vacation". Default "duty".
|
||||
event_type: One of "duty", "unavailable", "vacation". Invalid values are stored as "duty".
|
||||
|
||||
Returns:
|
||||
Created Duty instance.
|
||||
"""
|
||||
if event_type not in DUTY_EVENT_TYPES:
|
||||
event_type = "duty"
|
||||
duty = Duty(
|
||||
user_id=user_id,
|
||||
start_at=start_at,
|
||||
@@ -593,6 +605,71 @@ def get_all_group_duty_pin_chat_ids(session: Session) -> list[int]:
|
||||
return [r[0] for r in rows]
|
||||
|
||||
|
||||
def is_trusted_group(session: Session, chat_id: int) -> bool:
|
||||
"""Check if the chat is in the trusted groups list.
|
||||
|
||||
Args:
|
||||
session: DB session.
|
||||
chat_id: Telegram chat id.
|
||||
|
||||
Returns:
|
||||
True if the group is trusted.
|
||||
"""
|
||||
return (
|
||||
session.query(TrustedGroup).filter(TrustedGroup.chat_id == chat_id).first()
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def add_trusted_group(
|
||||
session: Session, chat_id: int, added_by_user_id: int | None = None
|
||||
) -> TrustedGroup:
|
||||
"""Add a group to the trusted list.
|
||||
|
||||
Args:
|
||||
session: DB session.
|
||||
chat_id: Telegram chat id.
|
||||
added_by_user_id: Telegram user id of the admin who added the group (optional).
|
||||
|
||||
Returns:
|
||||
Created TrustedGroup instance.
|
||||
"""
|
||||
now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
record = TrustedGroup(
|
||||
chat_id=chat_id,
|
||||
added_by_user_id=added_by_user_id,
|
||||
added_at=now_iso,
|
||||
)
|
||||
session.add(record)
|
||||
session.commit()
|
||||
session.refresh(record)
|
||||
return record
|
||||
|
||||
|
||||
def remove_trusted_group(session: Session, chat_id: int) -> None:
|
||||
"""Remove a group from the trusted list.
|
||||
|
||||
Args:
|
||||
session: DB session.
|
||||
chat_id: Telegram chat id.
|
||||
"""
|
||||
session.query(TrustedGroup).filter(TrustedGroup.chat_id == chat_id).delete()
|
||||
session.commit()
|
||||
|
||||
|
||||
def get_all_trusted_group_ids(session: Session) -> list[int]:
|
||||
"""Return all chat_ids that are trusted.
|
||||
|
||||
Args:
|
||||
session: DB session.
|
||||
|
||||
Returns:
|
||||
List of trusted chat ids.
|
||||
"""
|
||||
rows = session.query(TrustedGroup.chat_id).all()
|
||||
return [r[0] for r in rows]
|
||||
|
||||
|
||||
def set_user_phone(
|
||||
session: Session, telegram_user_id: int, phone: str | None
|
||||
) -> User | None:
|
||||
|
||||
@@ -55,13 +55,16 @@ class DutyInDb(DutyBase):
|
||||
|
||||
|
||||
class DutyWithUser(DutyInDb):
|
||||
"""Duty with full_name and event_type for calendar display.
|
||||
"""Duty with full_name, event_type, and optional contact fields for calendar display.
|
||||
|
||||
event_type: only these values are returned; unknown DB values are mapped to "duty" in the API.
|
||||
phone and username are exposed only to authenticated Mini App users (role-gated).
|
||||
"""
|
||||
|
||||
full_name: str
|
||||
event_type: Literal["duty", "unavailable", "vacation"] = "duty"
|
||||
phone: str | None = None
|
||||
username: str | None = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
@@ -22,4 +22,6 @@ def register_handlers(app: Application) -> None:
|
||||
app.add_handler(group_duty_pin.group_duty_pin_handler)
|
||||
app.add_handler(group_duty_pin.pin_duty_handler)
|
||||
app.add_handler(group_duty_pin.refresh_pin_handler)
|
||||
app.add_handler(group_duty_pin.trust_group_handler)
|
||||
app.add_handler(group_duty_pin.untrust_group_handler)
|
||||
app.add_error_handler(errors.error_handler)
|
||||
|
||||
@@ -67,7 +67,8 @@ async def set_phone(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
phone = " ".join(args).strip() if args else None
|
||||
telegram_user_id = update.effective_user.id
|
||||
|
||||
def do_set_phone() -> str | None:
|
||||
def do_set_phone() -> tuple[str, str | None]:
|
||||
"""Returns (status, display_phone). status is 'error'|'saved'|'cleared'. display_phone for 'saved'."""
|
||||
with session_scope(config.DATABASE_URL) as session:
|
||||
full_name = build_full_name(
|
||||
update.effective_user.first_name, update.effective_user.last_name
|
||||
@@ -82,16 +83,20 @@ async def set_phone(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
)
|
||||
user = set_user_phone(session, telegram_user_id, phone or None)
|
||||
if user is None:
|
||||
return "error"
|
||||
return ("error", None)
|
||||
if phone:
|
||||
return "saved"
|
||||
return "cleared"
|
||||
return ("saved", user.phone or config.normalize_phone(phone))
|
||||
return ("cleared", None)
|
||||
|
||||
result = await asyncio.get_running_loop().run_in_executor(None, do_set_phone)
|
||||
result, display_phone = await asyncio.get_running_loop().run_in_executor(
|
||||
None, do_set_phone
|
||||
)
|
||||
if result == "error":
|
||||
await update.message.reply_text(t(lang, "set_phone.error"))
|
||||
elif result == "saved":
|
||||
await update.message.reply_text(t(lang, "set_phone.saved", phone=phone or ""))
|
||||
await update.message.reply_text(
|
||||
t(lang, "set_phone.saved", phone=display_phone or "")
|
||||
)
|
||||
else:
|
||||
await update.message.reply_text(t(lang, "set_phone.cleared"))
|
||||
|
||||
@@ -168,6 +173,8 @@ async def help_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
if await is_admin_async(update.effective_user.id):
|
||||
lines.append(t(lang, "help.import_schedule"))
|
||||
lines.append(t(lang, "help.set_role"))
|
||||
lines.append(t(lang, "help.trust_group"))
|
||||
lines.append(t(lang, "help.untrust_group"))
|
||||
await update.message.reply_text("\n".join(lines))
|
||||
|
||||
|
||||
|
||||
@@ -2,17 +2,19 @@
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
import random
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Literal
|
||||
|
||||
import duty_teller.config as config
|
||||
from telegram import Update
|
||||
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
|
||||
from telegram.constants import ChatMemberStatus
|
||||
from telegram.error import BadRequest, Forbidden
|
||||
from telegram.ext import ChatMemberHandler, CommandHandler, ContextTypes
|
||||
|
||||
from duty_teller.db.session import session_scope
|
||||
from duty_teller.i18n import get_lang, t
|
||||
from duty_teller.handlers.common import is_admin_async
|
||||
from duty_teller.services.group_duty_pin_service import (
|
||||
get_duty_message_text,
|
||||
get_message_id,
|
||||
@@ -21,10 +23,17 @@ from duty_teller.services.group_duty_pin_service import (
|
||||
save_pin,
|
||||
delete_pin,
|
||||
get_all_pin_chat_ids,
|
||||
is_group_trusted,
|
||||
trust_group,
|
||||
untrust_group,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Per-chat locks to prevent concurrent refresh for the same chat (avoids duplicate messages).
|
||||
_refresh_locks: dict[int, asyncio.Lock] = {}
|
||||
_lock_for_refresh_locks = asyncio.Lock()
|
||||
|
||||
JOB_NAME_PREFIX = "duty_pin_"
|
||||
RETRY_WHEN_NO_DUTY_MINUTES = 15
|
||||
|
||||
@@ -62,9 +71,70 @@ def _sync_get_message_id(chat_id: int) -> int | None:
|
||||
return get_message_id(session, chat_id)
|
||||
|
||||
|
||||
def _sync_is_trusted(chat_id: int) -> bool:
|
||||
"""Check if the group is trusted (sync wrapper for handlers)."""
|
||||
with session_scope(config.DATABASE_URL) as session:
|
||||
return is_group_trusted(session, chat_id)
|
||||
|
||||
|
||||
def _sync_trust_group(chat_id: int, added_by_user_id: int | None) -> bool:
|
||||
"""Add group to trusted list. Returns True if already trusted (no-op)."""
|
||||
with session_scope(config.DATABASE_URL) as session:
|
||||
if is_group_trusted(session, chat_id):
|
||||
return True
|
||||
trust_group(session, chat_id, added_by_user_id)
|
||||
return False
|
||||
|
||||
|
||||
def _get_contact_button_markup(lang: str) -> InlineKeyboardMarkup | None:
|
||||
"""Return inline keyboard with 'View contacts' URL button, or None if BOT_USERNAME not set.
|
||||
|
||||
Uses a t.me Mini App deep link so the app opens inside Telegram. Uses url (not web_app):
|
||||
InlineKeyboardButton with web_app is allowed only in private chats, so in groups
|
||||
Telegram returns Button_type_invalid. A plain URL button works everywhere.
|
||||
|
||||
When MINI_APP_SHORT_NAME is set, the URL is a direct Mini App link so the app opens
|
||||
with start_param=duty (current duty view). Otherwise the link is to the bot with
|
||||
?startapp=duty (user may land in bot chat; opening the app from menu does not pass
|
||||
start_param in some clients).
|
||||
"""
|
||||
if not config.BOT_USERNAME:
|
||||
return None
|
||||
short = (config.MINI_APP_SHORT_NAME or "").strip().strip("/")
|
||||
if short:
|
||||
url = f"https://t.me/{config.BOT_USERNAME}/{short}?startapp=duty"
|
||||
else:
|
||||
url = f"https://t.me/{config.BOT_USERNAME}?startapp=duty"
|
||||
button = InlineKeyboardButton(
|
||||
text=t(lang, "pin_duty.view_contacts"),
|
||||
url=url,
|
||||
)
|
||||
return InlineKeyboardMarkup([[button]])
|
||||
|
||||
|
||||
def _sync_untrust_group(chat_id: int) -> tuple[bool, int | None]:
|
||||
"""Remove group from trusted list.
|
||||
|
||||
Returns:
|
||||
(was_trusted, message_id): was_trusted False if group was not in list;
|
||||
message_id of pinned message if any (for cleanup), else None.
|
||||
"""
|
||||
with session_scope(config.DATABASE_URL) as session:
|
||||
if not is_group_trusted(session, chat_id):
|
||||
return (False, None)
|
||||
message_id = get_message_id(session, chat_id)
|
||||
delete_pin(session, chat_id)
|
||||
untrust_group(session, chat_id)
|
||||
return (True, message_id)
|
||||
|
||||
|
||||
async def _schedule_next_update(
|
||||
application, chat_id: int, when_utc: datetime | None
|
||||
application,
|
||||
chat_id: int,
|
||||
when_utc: datetime | None,
|
||||
jitter_seconds: float | None = None,
|
||||
) -> None:
|
||||
"""Schedule the next pin refresh job. Optional jitter spreads jobs when scheduling many chats."""
|
||||
job_queue = application.job_queue
|
||||
if job_queue is None:
|
||||
logger.warning("Job queue not available, cannot schedule pin update")
|
||||
@@ -75,8 +145,10 @@ async def _schedule_next_update(
|
||||
if when_utc is not None:
|
||||
now_utc = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
delay = when_utc - now_utc
|
||||
if jitter_seconds is not None and jitter_seconds > 0:
|
||||
delay += timedelta(seconds=random.uniform(0, jitter_seconds))
|
||||
if delay.total_seconds() < 1:
|
||||
delay = 1
|
||||
delay = timedelta(seconds=1)
|
||||
job_queue.run_once(
|
||||
update_group_pin,
|
||||
when=delay,
|
||||
@@ -85,8 +157,6 @@ async def _schedule_next_update(
|
||||
)
|
||||
logger.info("Scheduled pin update for chat_id=%s at %s", chat_id, when_utc)
|
||||
else:
|
||||
from datetime import timedelta
|
||||
|
||||
job_queue.run_once(
|
||||
update_group_pin,
|
||||
when=timedelta(minutes=RETRY_WHEN_NO_DUTY_MINUTES),
|
||||
@@ -102,17 +172,42 @@ async def _schedule_next_update(
|
||||
|
||||
async def _refresh_pin_for_chat(
|
||||
context: ContextTypes.DEFAULT_TYPE, chat_id: int
|
||||
) -> Literal["updated", "no_message", "failed"]:
|
||||
"""Refresh pinned duty message: send new message, unpin old, pin new, save new message_id.
|
||||
) -> Literal["updated", "no_message", "failed", "untrusted"]:
|
||||
"""Refresh pinned duty message: send new message, unpin old, pin new, save new message_id, delete old.
|
||||
|
||||
Uses single DB session for message_id, text, next_shift_end (consolidated).
|
||||
If the group is no longer trusted, removes pin record, job, and message; returns "untrusted".
|
||||
Unpin is best-effort (e.g. if user already unpinned we still pin the new message and save state).
|
||||
Per-chat lock prevents concurrent refresh for the same chat.
|
||||
|
||||
Returns:
|
||||
"updated" if the message was sent, pinned and saved successfully;
|
||||
"no_message" if there is no pin record for this chat;
|
||||
"failed" if send_message or permissions failed.
|
||||
"failed" if send_message or pin failed;
|
||||
"untrusted" if the group was removed from trusted list (pin record and message cleaned up).
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
trusted = await loop.run_in_executor(None, _sync_is_trusted, chat_id)
|
||||
if not trusted:
|
||||
old_message_id = await loop.run_in_executor(None, _sync_get_message_id, chat_id)
|
||||
await loop.run_in_executor(None, _sync_delete_pin, chat_id)
|
||||
name = f"{JOB_NAME_PREFIX}{chat_id}"
|
||||
if context.application.job_queue:
|
||||
for job in context.application.job_queue.get_jobs_by_name(name):
|
||||
job.schedule_removal()
|
||||
if old_message_id is not None:
|
||||
try:
|
||||
await context.bot.unpin_chat_message(chat_id=chat_id)
|
||||
except (BadRequest, Forbidden):
|
||||
pass
|
||||
try:
|
||||
await context.bot.delete_message(
|
||||
chat_id=chat_id, message_id=old_message_id
|
||||
)
|
||||
except (BadRequest, Forbidden):
|
||||
pass
|
||||
logger.info("Chat_id=%s no longer trusted, removed pin record and job", chat_id)
|
||||
return "untrusted"
|
||||
message_id, text, next_end = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: _sync_get_pin_refresh_data(chat_id, config.DEFAULT_LANGUAGE),
|
||||
@@ -120,28 +215,60 @@ async def _refresh_pin_for_chat(
|
||||
if message_id is None:
|
||||
logger.info("No pin record for chat_id=%s, skipping update", chat_id)
|
||||
return "no_message"
|
||||
old_message_id = message_id
|
||||
|
||||
async with _lock_for_refresh_locks:
|
||||
lock = _refresh_locks.setdefault(chat_id, asyncio.Lock())
|
||||
try:
|
||||
msg = await context.bot.send_message(chat_id=chat_id, text=text)
|
||||
except (BadRequest, Forbidden) as e:
|
||||
logger.warning(
|
||||
"Failed to send duty message for pin refresh chat_id=%s: %s", chat_id, e
|
||||
)
|
||||
await _schedule_next_update(context.application, chat_id, next_end)
|
||||
return "failed"
|
||||
try:
|
||||
await context.bot.unpin_chat_message(chat_id=chat_id)
|
||||
await context.bot.pin_chat_message(
|
||||
chat_id=chat_id,
|
||||
message_id=msg.message_id,
|
||||
disable_notification=not config.DUTY_PIN_NOTIFY,
|
||||
)
|
||||
except (BadRequest, Forbidden) as e:
|
||||
logger.warning("Unpin or pin after refresh failed chat_id=%s: %s", chat_id, e)
|
||||
await _schedule_next_update(context.application, chat_id, next_end)
|
||||
return "failed"
|
||||
await loop.run_in_executor(None, _sync_save_pin, chat_id, msg.message_id)
|
||||
await _schedule_next_update(context.application, chat_id, next_end)
|
||||
return "updated"
|
||||
async with lock:
|
||||
try:
|
||||
msg = await context.bot.send_message(
|
||||
chat_id=chat_id,
|
||||
text=text,
|
||||
reply_markup=_get_contact_button_markup(config.DEFAULT_LANGUAGE),
|
||||
)
|
||||
except (BadRequest, Forbidden) as e:
|
||||
logger.warning(
|
||||
"Failed to send duty message for pin refresh chat_id=%s: %s",
|
||||
chat_id,
|
||||
e,
|
||||
)
|
||||
await _schedule_next_update(context.application, chat_id, next_end)
|
||||
return "failed"
|
||||
try:
|
||||
await context.bot.unpin_chat_message(chat_id=chat_id)
|
||||
except (BadRequest, Forbidden) as e:
|
||||
logger.debug(
|
||||
"Unpin failed (e.g. no pinned message) chat_id=%s: %s", chat_id, e
|
||||
)
|
||||
try:
|
||||
await context.bot.pin_chat_message(
|
||||
chat_id=chat_id,
|
||||
message_id=msg.message_id,
|
||||
disable_notification=not config.DUTY_PIN_NOTIFY,
|
||||
)
|
||||
except (BadRequest, Forbidden) as e:
|
||||
logger.warning("Pin after refresh failed chat_id=%s: %s", chat_id, e)
|
||||
await _schedule_next_update(context.application, chat_id, next_end)
|
||||
return "failed"
|
||||
await loop.run_in_executor(None, _sync_save_pin, chat_id, msg.message_id)
|
||||
if old_message_id is not None:
|
||||
try:
|
||||
await context.bot.delete_message(
|
||||
chat_id=chat_id, message_id=old_message_id
|
||||
)
|
||||
except (BadRequest, Forbidden) as e:
|
||||
logger.warning(
|
||||
"Could not delete old pinned message %s in chat_id=%s: %s",
|
||||
old_message_id,
|
||||
chat_id,
|
||||
e,
|
||||
)
|
||||
await _schedule_next_update(context.application, chat_id, next_end)
|
||||
return "updated"
|
||||
finally:
|
||||
async with _lock_for_refresh_locks:
|
||||
_refresh_locks.pop(chat_id, None)
|
||||
|
||||
|
||||
async def update_group_pin(context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
@@ -175,12 +302,27 @@ async def my_chat_member_handler(
|
||||
ChatMemberStatus.BANNED,
|
||||
):
|
||||
loop = asyncio.get_running_loop()
|
||||
trusted = await loop.run_in_executor(None, _sync_is_trusted, chat_id)
|
||||
if not trusted:
|
||||
lang = get_lang(update.effective_user)
|
||||
try:
|
||||
await context.bot.send_message(
|
||||
chat_id=chat_id,
|
||||
text=t(lang, "group.not_trusted"),
|
||||
)
|
||||
except (BadRequest, Forbidden):
|
||||
pass
|
||||
return
|
||||
lang = get_lang(update.effective_user)
|
||||
text = await loop.run_in_executor(
|
||||
None, lambda: _get_duty_message_text_sync(lang)
|
||||
)
|
||||
try:
|
||||
msg = await context.bot.send_message(chat_id=chat_id, text=text)
|
||||
msg = await context.bot.send_message(
|
||||
chat_id=chat_id,
|
||||
text=text,
|
||||
reply_markup=_get_contact_button_markup(lang),
|
||||
)
|
||||
except (BadRequest, Forbidden) as e:
|
||||
logger.warning("Failed to send duty message in chat_id=%s: %s", chat_id, e)
|
||||
return
|
||||
@@ -224,12 +366,15 @@ def _get_all_pin_chat_ids_sync() -> list[int]:
|
||||
|
||||
|
||||
async def restore_group_pin_jobs(application) -> None:
|
||||
"""Restore scheduled pin-update jobs for all chats that have a pinned message (on startup)."""
|
||||
"""Restore scheduled pin-update jobs for all chats that have a pinned message (on startup).
|
||||
|
||||
Uses jitter (0–60 s) per chat to avoid thundering herd when many groups share the same shift end.
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
chat_ids = await loop.run_in_executor(None, _get_all_pin_chat_ids_sync)
|
||||
next_end = await loop.run_in_executor(None, _get_next_shift_end_sync)
|
||||
for chat_id in chat_ids:
|
||||
await _schedule_next_update(application, chat_id, next_end)
|
||||
await _schedule_next_update(application, chat_id, next_end, jitter_seconds=60.0)
|
||||
logger.info("Restored %s group pin jobs", len(chat_ids))
|
||||
|
||||
|
||||
@@ -244,9 +389,48 @@ async def pin_duty_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
|
||||
return
|
||||
chat_id = chat.id
|
||||
loop = asyncio.get_running_loop()
|
||||
trusted = await loop.run_in_executor(None, _sync_is_trusted, chat_id)
|
||||
if not trusted:
|
||||
await update.message.reply_text(t(lang, "group.not_trusted"))
|
||||
return
|
||||
message_id = await loop.run_in_executor(None, _sync_get_message_id, chat_id)
|
||||
if message_id is None:
|
||||
await update.message.reply_text(t(lang, "pin_duty.no_message"))
|
||||
text = await loop.run_in_executor(
|
||||
None, lambda: _get_duty_message_text_sync(lang)
|
||||
)
|
||||
try:
|
||||
msg = await context.bot.send_message(
|
||||
chat_id=chat_id,
|
||||
text=text,
|
||||
reply_markup=_get_contact_button_markup(lang),
|
||||
)
|
||||
except (BadRequest, Forbidden) as e:
|
||||
logger.warning(
|
||||
"Failed to send duty message for pin_duty chat_id=%s: %s", chat_id, e
|
||||
)
|
||||
await update.message.reply_text(t(lang, "pin_duty.failed"))
|
||||
return
|
||||
pinned = False
|
||||
try:
|
||||
await context.bot.pin_chat_message(
|
||||
chat_id=chat_id,
|
||||
message_id=msg.message_id,
|
||||
disable_notification=True,
|
||||
)
|
||||
pinned = True
|
||||
except (BadRequest, Forbidden) as e:
|
||||
logger.warning(
|
||||
"Failed to pin message for pin_duty chat_id=%s: %s", chat_id, e
|
||||
)
|
||||
await loop.run_in_executor(None, _sync_save_pin, chat_id, msg.message_id)
|
||||
next_end = await loop.run_in_executor(None, _get_next_shift_end_sync)
|
||||
await _schedule_next_update(context.application, chat_id, next_end)
|
||||
if pinned:
|
||||
await update.message.reply_text(t(lang, "pin_duty.pinned"))
|
||||
else:
|
||||
await update.message.reply_text(
|
||||
t(lang, "pin_duty.could_not_pin_make_admin")
|
||||
)
|
||||
return
|
||||
try:
|
||||
await context.bot.pin_chat_message(
|
||||
@@ -254,6 +438,8 @@ async def pin_duty_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
|
||||
message_id=message_id,
|
||||
disable_notification=True,
|
||||
)
|
||||
next_end = await loop.run_in_executor(None, _get_next_shift_end_sync)
|
||||
await _schedule_next_update(context.application, chat_id, next_end)
|
||||
await update.message.reply_text(t(lang, "pin_duty.pinned"))
|
||||
except (BadRequest, Forbidden) as e:
|
||||
logger.warning("pin_duty failed chat_id=%s: %s", chat_id, e)
|
||||
@@ -270,13 +456,113 @@ async def refresh_pin_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
|
||||
await update.message.reply_text(t(lang, "refresh_pin.group_only"))
|
||||
return
|
||||
chat_id = chat.id
|
||||
loop = asyncio.get_running_loop()
|
||||
trusted = await loop.run_in_executor(None, _sync_is_trusted, chat_id)
|
||||
if not trusted:
|
||||
await update.message.reply_text(t(lang, "group.not_trusted"))
|
||||
return
|
||||
result = await _refresh_pin_for_chat(context, chat_id)
|
||||
await update.message.reply_text(t(lang, f"refresh_pin.{result}"))
|
||||
|
||||
|
||||
async def trust_group_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
"""Handle /trust_group: add current group to trusted list (admin only)."""
|
||||
if not update.message or not update.effective_chat or not update.effective_user:
|
||||
return
|
||||
chat = update.effective_chat
|
||||
lang = get_lang(update.effective_user)
|
||||
if chat.type not in ("group", "supergroup"):
|
||||
await update.message.reply_text(t(lang, "trust_group.group_only"))
|
||||
return
|
||||
if not await is_admin_async(update.effective_user.id):
|
||||
await update.message.reply_text(t(lang, "import.admin_only"))
|
||||
return
|
||||
chat_id = chat.id
|
||||
loop = asyncio.get_running_loop()
|
||||
already_trusted = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: _sync_trust_group(
|
||||
chat_id, update.effective_user.id if update.effective_user else None
|
||||
),
|
||||
)
|
||||
if already_trusted:
|
||||
await update.message.reply_text(t(lang, "trust_group.already_trusted"))
|
||||
return
|
||||
await update.message.reply_text(t(lang, "trust_group.added"))
|
||||
message_id = await loop.run_in_executor(None, _sync_get_message_id, chat_id)
|
||||
if message_id is None:
|
||||
text = await loop.run_in_executor(
|
||||
None, lambda: _get_duty_message_text_sync(lang)
|
||||
)
|
||||
try:
|
||||
msg = await context.bot.send_message(
|
||||
chat_id=chat_id,
|
||||
text=text,
|
||||
reply_markup=_get_contact_button_markup(lang),
|
||||
)
|
||||
except (BadRequest, Forbidden) as e:
|
||||
logger.warning(
|
||||
"Failed to send duty message after trust_group chat_id=%s: %s",
|
||||
chat_id,
|
||||
e,
|
||||
)
|
||||
return
|
||||
try:
|
||||
await context.bot.pin_chat_message(
|
||||
chat_id=chat_id,
|
||||
message_id=msg.message_id,
|
||||
disable_notification=not config.DUTY_PIN_NOTIFY,
|
||||
)
|
||||
except (BadRequest, Forbidden) as e:
|
||||
logger.warning(
|
||||
"Failed to pin message after trust_group chat_id=%s: %s", chat_id, e
|
||||
)
|
||||
await loop.run_in_executor(None, _sync_save_pin, chat_id, msg.message_id)
|
||||
next_end = await loop.run_in_executor(None, _get_next_shift_end_sync)
|
||||
await _schedule_next_update(context.application, chat_id, next_end)
|
||||
|
||||
|
||||
async def untrust_group_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
"""Handle /untrust_group: remove current group from trusted list (admin only)."""
|
||||
if not update.message or not update.effective_chat or not update.effective_user:
|
||||
return
|
||||
chat = update.effective_chat
|
||||
lang = get_lang(update.effective_user)
|
||||
if chat.type not in ("group", "supergroup"):
|
||||
await update.message.reply_text(t(lang, "untrust_group.group_only"))
|
||||
return
|
||||
if not await is_admin_async(update.effective_user.id):
|
||||
await update.message.reply_text(t(lang, "import.admin_only"))
|
||||
return
|
||||
chat_id = chat.id
|
||||
loop = asyncio.get_running_loop()
|
||||
was_trusted, message_id = await loop.run_in_executor(
|
||||
None, _sync_untrust_group, chat_id
|
||||
)
|
||||
if not was_trusted:
|
||||
await update.message.reply_text(t(lang, "untrust_group.not_trusted"))
|
||||
return
|
||||
name = f"{JOB_NAME_PREFIX}{chat_id}"
|
||||
if context.application.job_queue:
|
||||
for job in context.application.job_queue.get_jobs_by_name(name):
|
||||
job.schedule_removal()
|
||||
if message_id is not None:
|
||||
try:
|
||||
await context.bot.unpin_chat_message(chat_id=chat_id)
|
||||
except (BadRequest, Forbidden):
|
||||
pass
|
||||
try:
|
||||
await context.bot.delete_message(chat_id=chat_id, message_id=message_id)
|
||||
except (BadRequest, Forbidden):
|
||||
pass
|
||||
await update.message.reply_text(t(lang, "untrust_group.removed"))
|
||||
|
||||
|
||||
group_duty_pin_handler = ChatMemberHandler(
|
||||
my_chat_member_handler,
|
||||
ChatMemberHandler.MY_CHAT_MEMBER,
|
||||
)
|
||||
pin_duty_handler = CommandHandler("pin_duty", pin_duty_cmd)
|
||||
refresh_pin_handler = CommandHandler("refresh_pin", refresh_pin_cmd)
|
||||
trust_group_handler = CommandHandler("trust_group", trust_group_cmd)
|
||||
untrust_group_handler = CommandHandler("untrust_group", untrust_group_cmd)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Import duty-schedule: /import_duty_schedule (admin only). Two steps: handover time -> JSON file."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import duty_teller.config as config
|
||||
from telegram import Update
|
||||
@@ -16,6 +17,8 @@ from duty_teller.importers.duty_schedule import (
|
||||
from duty_teller.services.import_service import run_import
|
||||
from duty_teller.utils.handover import parse_handover_time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def import_duty_schedule_cmd(
|
||||
update: Update, context: ContextTypes.DEFAULT_TYPE
|
||||
@@ -80,9 +83,10 @@ async def handle_duty_schedule_document(
|
||||
try:
|
||||
result = parse_duty_schedule(raw)
|
||||
except DutyScheduleParseError as e:
|
||||
logger.warning("Duty schedule parse error: %s", e, exc_info=True)
|
||||
context.user_data.pop("awaiting_duty_schedule_file", None)
|
||||
context.user_data.pop("handover_utc_time", None)
|
||||
await update.message.reply_text(t(lang, "import.parse_error", error=str(e)))
|
||||
await update.message.reply_text(t(lang, "import.parse_error_generic"))
|
||||
return
|
||||
|
||||
def run_import_with_scope():
|
||||
@@ -95,7 +99,8 @@ async def handle_duty_schedule_document(
|
||||
None, run_import_with_scope
|
||||
)
|
||||
except Exception as e:
|
||||
await update.message.reply_text(t(lang, "import.import_error", error=str(e)))
|
||||
logger.exception("Import failed: %s", e)
|
||||
await update.message.reply_text(t(lang, "import.import_error_generic"))
|
||||
else:
|
||||
total = num_duty + num_unavailable + num_vacation
|
||||
unavailable_suffix = (
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
"""get_lang and t(): language from Telegram user, translate by key with fallback to en."""
|
||||
"""get_lang and t(): language from config (DEFAULT_LANGUAGE), translate by key with fallback to en."""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import duty_teller.config as config
|
||||
from duty_teller.i18n.lang import normalize_lang
|
||||
from duty_teller.i18n.messages import MESSAGES
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -12,13 +11,12 @@ if TYPE_CHECKING:
|
||||
|
||||
def get_lang(user: "User | None") -> str:
|
||||
"""
|
||||
Normalize Telegram user language to 'ru' or 'en'.
|
||||
Uses normalize_lang for user.language_code; when user is None or has no
|
||||
language_code, returns config.DEFAULT_LANGUAGE.
|
||||
Return the application language: always config.DEFAULT_LANGUAGE.
|
||||
|
||||
The user argument is kept for backward compatibility but is ignored.
|
||||
The whole deployment uses a single language from DEFAULT_LANGUAGE.
|
||||
"""
|
||||
if user is None or not getattr(user, "language_code", None):
|
||||
return config.DEFAULT_LANGUAGE
|
||||
return normalize_lang(user.language_code)
|
||||
return config.DEFAULT_LANGUAGE
|
||||
|
||||
|
||||
def t(lang: str, key: str, **kwargs: str) -> str:
|
||||
|
||||
@@ -18,6 +18,19 @@ MESSAGES: dict[str, dict[str, str]] = {
|
||||
"refresh_pin.no_message": "There is no pinned duty message to refresh in this chat.",
|
||||
"refresh_pin.updated": "Pinned duty message updated.",
|
||||
"refresh_pin.failed": "Could not update the pinned message (permissions or edit error).",
|
||||
"refresh_pin.untrusted": "Group was removed from trusted list; pin record cleared.",
|
||||
"trust_group.added": "Group added to trusted list.",
|
||||
"trust_group.already_trusted": "This group is already trusted.",
|
||||
"trust_group.group_only": "The /trust_group command works only in groups.",
|
||||
"untrust_group.removed": "Group removed from trusted list.",
|
||||
"untrust_group.not_trusted": "This group is not in the trusted list.",
|
||||
"untrust_group.group_only": "The /untrust_group command works only in groups.",
|
||||
"group.not_trusted": (
|
||||
"This group is not authorized to receive duty data. "
|
||||
"An administrator can add the group with /trust_group."
|
||||
),
|
||||
"help.trust_group": "/trust_group — In a group: add group to trusted list (admin only)",
|
||||
"help.untrust_group": "/untrust_group — In a group: remove group from trusted list (admin only)",
|
||||
"calendar_link.private_only": "The /calendar_link command is only available in private chat.",
|
||||
"calendar_link.access_denied": "Access denied.",
|
||||
"calendar_link.success": (
|
||||
@@ -47,6 +60,7 @@ MESSAGES: dict[str, dict[str, str]] = {
|
||||
"administrator with «Pin messages» permission, then send /pin_duty in the "
|
||||
"chat — the current message will be pinned."
|
||||
),
|
||||
"pin_duty.view_contacts": "View contacts",
|
||||
"duty.no_duty": "No duty at the moment.",
|
||||
"duty.label": "Duty:",
|
||||
"import.admin_only": "Access for administrators only.",
|
||||
@@ -58,7 +72,9 @@ MESSAGES: dict[str, dict[str, str]] = {
|
||||
"import.send_json": "Send the duty-schedule file (JSON).",
|
||||
"import.need_json": "File must have .json extension.",
|
||||
"import.parse_error": "File parse error: {error}",
|
||||
"import.parse_error_generic": "The file could not be parsed. Check the format and try again.",
|
||||
"import.import_error": "Import error: {error}",
|
||||
"import.import_error_generic": "Import failed. Please try again or contact an administrator.",
|
||||
"import.done": (
|
||||
"Import done: {users} users, {duties} duties{unavailable}{vacation} "
|
||||
"({total} events total)."
|
||||
@@ -74,6 +90,14 @@ MESSAGES: dict[str, dict[str, str]] = {
|
||||
"api.access_denied": "Access denied",
|
||||
"dates.bad_format": "Parameters from and to must be in YYYY-MM-DD format",
|
||||
"dates.from_after_to": "from date must not be after to",
|
||||
"dates.range_too_large": "Date range is too large. Request a shorter period.",
|
||||
"contact.show": "Contacts",
|
||||
"contact.back": "Back",
|
||||
"current_duty.title": "Current Duty",
|
||||
"current_duty.no_duty": "No one is on duty right now",
|
||||
"current_duty.shift": "Shift",
|
||||
"current_duty.remaining": "Remaining: {hours}h {minutes}min",
|
||||
"current_duty.back": "Back to calendar",
|
||||
},
|
||||
"ru": {
|
||||
"start.greeting": "Привет! Я бот календаря дежурств. Используй /help для списка команд.",
|
||||
@@ -92,6 +116,19 @@ MESSAGES: dict[str, dict[str, str]] = {
|
||||
"refresh_pin.no_message": "В этом чате нет закреплённого сообщения о дежурстве для обновления.",
|
||||
"refresh_pin.updated": "Закреплённое сообщение о дежурстве обновлено.",
|
||||
"refresh_pin.failed": "Не удалось обновить закреплённое сообщение (права или ошибка редактирования).",
|
||||
"refresh_pin.untrusted": "Группа удалена из доверенных; запись о закреплении сброшена.",
|
||||
"trust_group.added": "Группа добавлена в доверенные.",
|
||||
"trust_group.already_trusted": "Эта группа уже в доверенных.",
|
||||
"trust_group.group_only": "Команда /trust_group работает только в группах.",
|
||||
"untrust_group.removed": "Группа удалена из доверенных.",
|
||||
"untrust_group.not_trusted": "Эта группа не в доверенных.",
|
||||
"untrust_group.group_only": "Команда /untrust_group работает только в группах.",
|
||||
"group.not_trusted": (
|
||||
"Эта группа не авторизована для получения данных дежурных. "
|
||||
"Администратор может добавить группу командой /trust_group."
|
||||
),
|
||||
"help.trust_group": "/trust_group — В группе: добавить группу в доверенные (только админ)",
|
||||
"help.untrust_group": "/untrust_group — В группе: удалить группу из доверенных (только админ)",
|
||||
"calendar_link.private_only": "Команда /calendar_link доступна только в личке.",
|
||||
"calendar_link.access_denied": "Доступ запрещён.",
|
||||
"calendar_link.success": (
|
||||
@@ -116,6 +153,7 @@ MESSAGES: dict[str, dict[str, str]] = {
|
||||
"pin_duty.could_not_pin_make_admin": "Сообщение о дежурстве отправлено, но закрепить его не удалось. "
|
||||
"Сделайте бота администратором с правом «Закреплять сообщения» (Pin messages), "
|
||||
"затем отправьте в чат команду /pin_duty — текущее сообщение будет закреплено.",
|
||||
"pin_duty.view_contacts": "Контакты",
|
||||
"duty.no_duty": "Сейчас дежурства нет.",
|
||||
"duty.label": "Дежурство:",
|
||||
"import.admin_only": "Доступ только для администраторов.",
|
||||
@@ -125,7 +163,9 @@ MESSAGES: dict[str, dict[str, str]] = {
|
||||
"import.send_json": "Отправьте файл в формате duty-schedule (JSON).",
|
||||
"import.need_json": "Нужен файл с расширением .json",
|
||||
"import.parse_error": "Ошибка разбора файла: {error}",
|
||||
"import.parse_error_generic": "Не удалось разобрать файл. Проверьте формат и попробуйте снова.",
|
||||
"import.import_error": "Ошибка импорта: {error}",
|
||||
"import.import_error_generic": "Импорт не выполнен. Попробуйте снова или обратитесь к администратору.",
|
||||
"import.done": "Импорт выполнен: {users} пользователей, {duties} дежурств{unavailable}{vacation} (всего {total} событий).",
|
||||
"import.done_unavailable": ", {count} недоступностей",
|
||||
"import.done_vacation": ", {count} отпусков",
|
||||
@@ -136,5 +176,13 @@ MESSAGES: dict[str, dict[str, str]] = {
|
||||
"api.access_denied": "Доступ запрещён",
|
||||
"dates.bad_format": "Параметры from и to должны быть в формате YYYY-MM-DD",
|
||||
"dates.from_after_to": "Дата from не должна быть позже to",
|
||||
"dates.range_too_large": "Диапазон дат слишком большой. Запросите более короткий период.",
|
||||
"contact.show": "Контакты",
|
||||
"contact.back": "Назад",
|
||||
"current_duty.title": "Текущее дежурство",
|
||||
"current_duty.no_duty": "Сейчас никто не дежурит",
|
||||
"current_duty.shift": "Смена",
|
||||
"current_duty.remaining": "Осталось: {hours}ч {minutes}мин",
|
||||
"current_duty.back": "Назад к календарю",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -9,6 +9,11 @@ DUTY_MARKERS = frozenset({"б", "Б", "в", "В"})
|
||||
UNAVAILABLE_MARKER = "Н"
|
||||
VACATION_MARKER = "О"
|
||||
|
||||
# Limits to avoid abuse and unreasonable input.
|
||||
MAX_SCHEDULE_ROWS = 500
|
||||
MAX_FULL_NAME_LENGTH = 200
|
||||
MAX_DUTY_STRING_LENGTH = 10000
|
||||
|
||||
|
||||
@dataclass
|
||||
class DutyScheduleEntry:
|
||||
@@ -69,10 +74,24 @@ def parse_duty_schedule(raw_bytes: bytes) -> DutyScheduleResult:
|
||||
except ValueError as e:
|
||||
raise DutyScheduleParseError(f"Invalid meta.start_date: {start_str}") from e
|
||||
|
||||
# Reject dates outside current year ± 1.
|
||||
today = date.today()
|
||||
min_year = today.year - 1
|
||||
max_year = today.year + 1
|
||||
if not (min_year <= start_date.year <= max_year):
|
||||
raise DutyScheduleParseError(
|
||||
f"meta.start_date year must be between {min_year} and {max_year}"
|
||||
)
|
||||
|
||||
schedule = data.get("schedule")
|
||||
if not isinstance(schedule, list):
|
||||
raise DutyScheduleParseError("Missing or invalid 'schedule' (must be array)")
|
||||
|
||||
if len(schedule) > MAX_SCHEDULE_ROWS:
|
||||
raise DutyScheduleParseError(
|
||||
f"schedule has too many rows (max {MAX_SCHEDULE_ROWS})"
|
||||
)
|
||||
|
||||
max_days = 0
|
||||
entries: list[DutyScheduleEntry] = []
|
||||
|
||||
@@ -85,12 +104,20 @@ def parse_duty_schedule(raw_bytes: bytes) -> DutyScheduleResult:
|
||||
full_name = name.strip()
|
||||
if not full_name:
|
||||
raise DutyScheduleParseError("schedule item 'name' cannot be empty")
|
||||
if len(full_name) > MAX_FULL_NAME_LENGTH:
|
||||
raise DutyScheduleParseError(
|
||||
f"schedule item 'name' must not exceed {MAX_FULL_NAME_LENGTH} characters"
|
||||
)
|
||||
|
||||
duty_str = row.get("duty")
|
||||
if duty_str is None:
|
||||
duty_str = ""
|
||||
if not isinstance(duty_str, str):
|
||||
raise DutyScheduleParseError("schedule item 'duty' must be string")
|
||||
if len(duty_str) > MAX_DUTY_STRING_LENGTH:
|
||||
raise DutyScheduleParseError(
|
||||
f"schedule item 'duty' must not exceed {MAX_DUTY_STRING_LENGTH} characters"
|
||||
)
|
||||
|
||||
cells = [c.strip() for c in duty_str.split(";")]
|
||||
max_days = max(max_days, len(cells))
|
||||
@@ -120,4 +147,9 @@ def parse_duty_schedule(raw_bytes: bytes) -> DutyScheduleResult:
|
||||
else:
|
||||
end_date = start_date + timedelta(days=max_days - 1)
|
||||
|
||||
if not (min_year <= end_date.year <= max_year):
|
||||
raise DutyScheduleParseError(
|
||||
f"Computed end_date year must be between {min_year} and {max_year}"
|
||||
)
|
||||
|
||||
return DutyScheduleResult(start_date=start_date, end_date=end_date, entries=entries)
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
from telegram.ext import ApplicationBuilder
|
||||
@@ -13,9 +15,21 @@ from duty_teller.config import require_bot_token
|
||||
from duty_teller.handlers import group_duty_pin, register_handlers
|
||||
from duty_teller.utils.http_client import safe_urlopen
|
||||
|
||||
# Seconds to wait for HTTP server to bind before health check.
|
||||
_HTTP_STARTUP_WAIT_SEC = 3
|
||||
|
||||
|
||||
async def _resolve_bot_username(application) -> None:
|
||||
"""If BOT_USERNAME is not set from env, resolve it via get_me()."""
|
||||
if not config.BOT_USERNAME:
|
||||
me = await application.bot.get_me()
|
||||
config.BOT_USERNAME = (me.username or "").lower()
|
||||
logger.info("Resolved BOT_USERNAME from API: %s", config.BOT_USERNAME)
|
||||
|
||||
|
||||
logging.basicConfig(
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
level=logging.INFO,
|
||||
level=config.LOG_LEVEL,
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -60,6 +74,25 @@ def _run_uvicorn(web_app, port: int) -> None:
|
||||
loop.run_until_complete(server.serve())
|
||||
|
||||
|
||||
def _wait_for_http_ready(port: int) -> bool:
|
||||
"""Return True if /health responds successfully within _HTTP_STARTUP_WAIT_SEC."""
|
||||
host = config.HTTP_HOST
|
||||
if host not in config.LOOPBACK_HTTP_HOSTS:
|
||||
host = "127.0.0.1"
|
||||
url = f"http://{host}:{port}/health"
|
||||
deadline = time.monotonic() + _HTTP_STARTUP_WAIT_SEC
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
req = urllib.request.Request(url)
|
||||
with safe_urlopen(req, timeout=2) as resp:
|
||||
if resp.status == 200:
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug("Health check not ready yet: %s", e)
|
||||
time.sleep(0.5)
|
||||
return False
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Build the bot and FastAPI, start uvicorn in a thread, run polling."""
|
||||
require_bot_token()
|
||||
@@ -69,22 +102,37 @@ def main() -> None:
|
||||
ApplicationBuilder()
|
||||
.token(config.BOT_TOKEN)
|
||||
.post_init(group_duty_pin.restore_group_pin_jobs)
|
||||
.post_init(_resolve_bot_username)
|
||||
.build()
|
||||
)
|
||||
register_handlers(app)
|
||||
|
||||
from duty_teller.api.app import app as web_app
|
||||
|
||||
t = threading.Thread(
|
||||
target=_run_uvicorn,
|
||||
args=(web_app, config.HTTP_PORT),
|
||||
daemon=True,
|
||||
)
|
||||
t.start()
|
||||
|
||||
if config.MINI_APP_SKIP_AUTH:
|
||||
logger.warning(
|
||||
"MINI_APP_SKIP_AUTH is set — API auth disabled (insecure); use only for dev"
|
||||
)
|
||||
if config.HTTP_HOST not in config.LOOPBACK_HTTP_HOSTS:
|
||||
print(
|
||||
"ERROR: MINI_APP_SKIP_AUTH must not be used in production (non-localhost).",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
t = threading.Thread(
|
||||
target=_run_uvicorn,
|
||||
args=(web_app, config.HTTP_PORT),
|
||||
daemon=False,
|
||||
)
|
||||
t.start()
|
||||
|
||||
if not _wait_for_http_ready(config.HTTP_PORT):
|
||||
logger.error(
|
||||
"HTTP server did not become ready on port %s within %s s; check port and permissions.",
|
||||
config.HTTP_PORT,
|
||||
_HTTP_STARTUP_WAIT_SEC,
|
||||
)
|
||||
sys.exit(1)
|
||||
logger.info("Bot starting (polling)... HTTP API on port %s", config.HTTP_PORT)
|
||||
app.run_polling(allowed_updates=["message", "my_chat_member"])
|
||||
|
||||
@@ -13,6 +13,9 @@ from duty_teller.db.repository import (
|
||||
save_group_duty_pin,
|
||||
delete_group_duty_pin,
|
||||
get_all_group_duty_pin_chat_ids,
|
||||
is_trusted_group,
|
||||
add_trusted_group,
|
||||
remove_trusted_group,
|
||||
)
|
||||
from duty_teller.i18n import t
|
||||
from duty_teller.utils.dates import parse_utc_iso
|
||||
@@ -82,10 +85,6 @@ def format_duty_message(duty, user, tz_name: str, lang: str = "en") -> str:
|
||||
f"🕐 {label} {time_range}",
|
||||
f"👤 {user.full_name}",
|
||||
]
|
||||
if user.phone:
|
||||
lines.append(f"📞 {user.phone}")
|
||||
if user.username:
|
||||
lines.append(f"@{user.username}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@@ -164,3 +163,39 @@ def get_all_pin_chat_ids(session: Session) -> list[int]:
|
||||
List of chat ids.
|
||||
"""
|
||||
return get_all_group_duty_pin_chat_ids(session)
|
||||
|
||||
|
||||
def is_group_trusted(session: Session, chat_id: int) -> bool:
|
||||
"""Check if the group is in the trusted list.
|
||||
|
||||
Args:
|
||||
session: DB session.
|
||||
chat_id: Telegram chat id.
|
||||
|
||||
Returns:
|
||||
True if the group is trusted.
|
||||
"""
|
||||
return is_trusted_group(session, chat_id)
|
||||
|
||||
|
||||
def trust_group(
|
||||
session: Session, chat_id: int, added_by_user_id: int | None = None
|
||||
) -> None:
|
||||
"""Add the group to the trusted list.
|
||||
|
||||
Args:
|
||||
session: DB session.
|
||||
chat_id: Telegram chat id.
|
||||
added_by_user_id: Telegram user id of the admin who added the group (optional).
|
||||
"""
|
||||
add_trusted_group(session, chat_id, added_by_user_id)
|
||||
|
||||
|
||||
def untrust_group(session: Session, chat_id: int) -> None:
|
||||
"""Remove the group from the trusted list.
|
||||
|
||||
Args:
|
||||
session: DB session.
|
||||
chat_id: Telegram chat id.
|
||||
"""
|
||||
remove_trusted_group(session, chat_id)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Import duty schedule: delete range, insert duties/unavailable/vacation. Accepts session."""
|
||||
|
||||
import logging
|
||||
from datetime import date, timedelta
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -14,6 +15,8 @@ from duty_teller.db.repository import (
|
||||
from duty_teller.importers.duty_schedule import DutyScheduleResult
|
||||
from duty_teller.utils.dates import day_start_iso, day_end_iso, duty_to_iso
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _consecutive_date_ranges(dates: list[date]) -> list[tuple[date, date]]:
|
||||
"""Sort dates and merge consecutive ones into (first, last) ranges. Empty list -> []."""
|
||||
@@ -53,16 +56,24 @@ def run_import(
|
||||
Returns:
|
||||
Tuple (num_users, num_duty, num_unavailable, num_vacation).
|
||||
"""
|
||||
logger.info(
|
||||
"Import started: range %s..%s, %d entries",
|
||||
result.start_date,
|
||||
result.end_date,
|
||||
len(result.entries),
|
||||
)
|
||||
from_date_str = result.start_date.isoformat()
|
||||
to_date_str = result.end_date.isoformat()
|
||||
num_duty = num_unavailable = num_vacation = 0
|
||||
|
||||
# Batch: get all users by full_name, create missing
|
||||
# Batch: get all users by full_name, create missing (no commit until end)
|
||||
names = [e.full_name for e in result.entries]
|
||||
users_map = get_users_by_full_names(session, names)
|
||||
for name in names:
|
||||
if name not in users_map:
|
||||
users_map[name] = get_or_create_user_by_full_name(session, name)
|
||||
users_map[name] = get_or_create_user_by_full_name(
|
||||
session, name, commit=False
|
||||
)
|
||||
|
||||
# Delete range per user (no commit)
|
||||
for entry in result.entries:
|
||||
@@ -113,4 +124,11 @@ def run_import(
|
||||
session.bulk_insert_mappings(Duty, duty_rows)
|
||||
session.commit()
|
||||
invalidate_duty_related_caches()
|
||||
logger.info(
|
||||
"Import done: %d users, %d duty, %d unavailable, %d vacation",
|
||||
len(result.entries),
|
||||
num_duty,
|
||||
num_unavailable,
|
||||
num_vacation,
|
||||
)
|
||||
return (len(result.entries), num_duty, num_unavailable, num_vacation)
|
||||
|
||||
@@ -24,10 +24,17 @@ def duty_to_iso(d: date, hour_utc: int, minute_utc: int) -> str:
|
||||
_ISO_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
||||
|
||||
|
||||
# Maximum allowed date range in days (e.g. 731 = 2 years).
|
||||
MAX_DATE_RANGE_DAYS = 731
|
||||
|
||||
|
||||
class DateRangeValidationError(ValueError):
|
||||
"""Raised when from_date/to_date validation fails. API uses kind for i18n key."""
|
||||
|
||||
def __init__(self, kind: Literal["bad_format", "from_after_to"]) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
kind: Literal["bad_format", "from_after_to", "range_too_large"],
|
||||
) -> None:
|
||||
self.kind = kind
|
||||
super().__init__(kind)
|
||||
|
||||
@@ -86,12 +93,20 @@ def parse_iso_date(s: str) -> date | None:
|
||||
|
||||
|
||||
def validate_date_range(from_date: str, to_date: str) -> None:
|
||||
"""Validate from_date and to_date are YYYY-MM-DD and from_date <= to_date.
|
||||
"""Validate from_date and to_date are YYYY-MM-DD, from_date <= to_date, and range <= MAX_DATE_RANGE_DAYS.
|
||||
|
||||
Raises:
|
||||
DateRangeValidationError: bad_format if format invalid, from_after_to if from > to.
|
||||
DateRangeValidationError: bad_format if format invalid, from_after_to if from > to,
|
||||
range_too_large if (to_date - from_date) > MAX_DATE_RANGE_DAYS.
|
||||
"""
|
||||
if not _ISO_DATE_RE.match(from_date or "") or not _ISO_DATE_RE.match(to_date or ""):
|
||||
raise DateRangeValidationError("bad_format")
|
||||
if from_date > to_date:
|
||||
raise DateRangeValidationError("from_after_to")
|
||||
try:
|
||||
from_d = date.fromisoformat(from_date)
|
||||
to_d = date.fromisoformat(to_date)
|
||||
except ValueError:
|
||||
raise DateRangeValidationError("bad_format") from None
|
||||
if (to_d - from_d).days > MAX_DATE_RANGE_DAYS:
|
||||
raise DateRangeValidationError("range_too_large")
|
||||
|
||||
@@ -10,24 +10,29 @@ import duty_teller.config as config
|
||||
|
||||
|
||||
class TestLangFromAcceptLanguage:
|
||||
"""Tests for _lang_from_accept_language."""
|
||||
"""Tests for _lang_from_accept_language: always returns config.DEFAULT_LANGUAGE."""
|
||||
|
||||
def test_none_returns_default(self):
|
||||
def test_always_returns_default_language(self):
|
||||
"""Header is ignored; result is always config.DEFAULT_LANGUAGE."""
|
||||
assert deps._lang_from_accept_language(None) == config.DEFAULT_LANGUAGE
|
||||
|
||||
def test_empty_string_returns_default(self):
|
||||
assert deps._lang_from_accept_language("") == config.DEFAULT_LANGUAGE
|
||||
assert deps._lang_from_accept_language(" ") == config.DEFAULT_LANGUAGE
|
||||
assert (
|
||||
deps._lang_from_accept_language("ru-RU,ru;q=0.9") == config.DEFAULT_LANGUAGE
|
||||
)
|
||||
assert deps._lang_from_accept_language("en-US") == config.DEFAULT_LANGUAGE
|
||||
assert deps._lang_from_accept_language("zz") == config.DEFAULT_LANGUAGE
|
||||
assert deps._lang_from_accept_language("x") == config.DEFAULT_LANGUAGE
|
||||
|
||||
def test_ru_ru_returns_ru(self):
|
||||
assert deps._lang_from_accept_language("ru-RU,ru;q=0.9") == "ru"
|
||||
def test_returns_ru_when_default_language_is_ru(self):
|
||||
with patch.object(config, "DEFAULT_LANGUAGE", "ru"):
|
||||
assert deps._lang_from_accept_language("en-US") == "ru"
|
||||
assert deps._lang_from_accept_language(None) == "ru"
|
||||
|
||||
def test_en_us_returns_en(self):
|
||||
assert deps._lang_from_accept_language("en-US") == "en"
|
||||
|
||||
def test_invalid_fallback_to_en(self):
|
||||
assert deps._lang_from_accept_language("zz") == "en"
|
||||
assert deps._lang_from_accept_language("x") == "en"
|
||||
def test_returns_en_when_default_language_is_en(self):
|
||||
with patch.object(config, "DEFAULT_LANGUAGE", "en"):
|
||||
assert deps._lang_from_accept_language("ru-RU") == "en"
|
||||
assert deps._lang_from_accept_language(None) == "en"
|
||||
|
||||
|
||||
class TestAuthErrorDetail:
|
||||
@@ -71,3 +76,31 @@ class TestValidateDutyDates:
|
||||
assert exc_info.value.status_code == 400
|
||||
assert exc_info.value.detail == "From after to message"
|
||||
mock_t.assert_called_with("ru", "dates.from_after_to")
|
||||
|
||||
|
||||
class TestFetchDutiesResponse:
|
||||
"""Tests for fetch_duties_response (DutyWithUser list with phone, username)."""
|
||||
|
||||
def test_fetch_duties_response_includes_phone_and_username(self):
|
||||
"""get_duties returns (Duty, full_name, phone, username); response has phone, username."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from duty_teller.db.schemas import DutyWithUser
|
||||
|
||||
duty = SimpleNamespace(
|
||||
id=1,
|
||||
user_id=10,
|
||||
start_at="2025-01-15T09:00:00Z",
|
||||
end_at="2025-01-15T18:00:00Z",
|
||||
event_type="duty",
|
||||
)
|
||||
rows = [(duty, "Alice", "+79001234567", "alice_dev")]
|
||||
with patch.object(deps, "get_duties", return_value=rows):
|
||||
result = deps.fetch_duties_response(
|
||||
type("Session", (), {})(), "2025-01-01", "2025-01-31"
|
||||
)
|
||||
assert len(result) == 1
|
||||
assert isinstance(result[0], DutyWithUser)
|
||||
assert result[0].full_name == "Alice"
|
||||
assert result[0].phone == "+79001234567"
|
||||
assert result[0].username == "alice_dev"
|
||||
|
||||
@@ -23,6 +23,80 @@ def test_health(client):
|
||||
assert r.json() == {"status": "ok"}
|
||||
|
||||
|
||||
def test_unhandled_exception_returns_500_json(client):
|
||||
"""Global exception handler returns 500 JSON without leaking exception details."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from duty_teller.api.app import global_exception_handler
|
||||
|
||||
# Call the registered handler directly: it returns JSON and does not expose str(exc).
|
||||
request = MagicMock()
|
||||
exc = RuntimeError("internal failure")
|
||||
response = global_exception_handler(request, exc)
|
||||
assert response.status_code == 500
|
||||
assert response.body.decode() == '{"detail":"Internal server error"}'
|
||||
assert "internal failure" not in response.body.decode()
|
||||
|
||||
|
||||
def test_health_has_vary_accept_language(client):
|
||||
"""NoCacheStaticMiddleware adds Vary: Accept-Language to all responses."""
|
||||
r = client.get("/health")
|
||||
assert r.status_code == 200
|
||||
assert "accept-language" in r.headers.get("vary", "").lower()
|
||||
|
||||
|
||||
def test_app_static_has_no_store_and_vary(client):
|
||||
"""Static files under /app get Cache-Control: no-store and Vary: Accept-Language."""
|
||||
r = client.get("/app/")
|
||||
if r.status_code != 200:
|
||||
r = client.get("/app")
|
||||
assert r.status_code == 200, (
|
||||
"webapp static mount should serve index at /app or /app/"
|
||||
)
|
||||
assert r.headers.get("cache-control") == "no-store"
|
||||
assert "accept-language" in r.headers.get("vary", "").lower()
|
||||
|
||||
|
||||
def test_app_js_has_no_store(client):
|
||||
"""JS and all static under /app get Cache-Control: no-store."""
|
||||
webapp_out = config.PROJECT_ROOT / "webapp-next" / "out"
|
||||
if not webapp_out.is_dir():
|
||||
pytest.skip("webapp-next/out not built")
|
||||
# Next.js static export serves JS under _next/static/chunks/<hash>.js
|
||||
js_files = list(webapp_out.glob("_next/static/chunks/*.js"))
|
||||
if not js_files:
|
||||
pytest.skip("no JS chunks in webapp-next/out")
|
||||
rel = js_files[0].relative_to(webapp_out)
|
||||
r = client.get(f"/app/{rel.as_posix()}")
|
||||
assert r.status_code == 200
|
||||
assert r.headers.get("cache-control") == "no-store"
|
||||
|
||||
|
||||
def test_app_config_js_returns_lang_from_default_language(client):
|
||||
"""GET /app/config.js returns JS setting window.__DT_LANG from config.DEFAULT_LANGUAGE."""
|
||||
r = client.get("/app/config.js")
|
||||
assert r.status_code == 200
|
||||
assert r.headers.get("content-type", "").startswith("application/javascript")
|
||||
assert r.headers.get("cache-control") == "no-store"
|
||||
body = r.text
|
||||
assert "window.__DT_LANG" in body
|
||||
assert config.DEFAULT_LANGUAGE in body
|
||||
|
||||
|
||||
@patch("duty_teller.api.app.config.DEFAULT_LANGUAGE", '"; alert(1); "')
|
||||
@patch("duty_teller.api.app.config.LOG_LEVEL_STR", "DEBUG\x00INJECT")
|
||||
def test_app_config_js_sanitizes_lang_and_log_level(client):
|
||||
"""config.js uses whitelist: invalid lang/log_level produce safe defaults, no script injection."""
|
||||
r = client.get("/app/config.js")
|
||||
assert r.status_code == 200
|
||||
body = r.text
|
||||
# Must be valid JS and not contain the raw malicious strings.
|
||||
assert 'window.__DT_LANG = "en"' in body or 'window.__DT_LANG = "ru"' in body
|
||||
assert "alert" not in body
|
||||
assert "INJECT" not in body
|
||||
assert "window.__DT_LOG_LEVEL" in body
|
||||
|
||||
|
||||
def test_duties_invalid_date_format(client):
|
||||
r = client.get("/api/duties", params={"from": "2025-01-01", "to": "invalid"})
|
||||
assert r.status_code == 400
|
||||
@@ -37,6 +111,30 @@ def test_duties_from_after_to(client):
|
||||
assert "from" in detail or "to" in detail or "after" in detail or "позже" in detail
|
||||
|
||||
|
||||
@patch("duty_teller.api.app.config.MINI_APP_SKIP_AUTH", True)
|
||||
def test_duties_range_too_large_400(client):
|
||||
"""Date range longer than MAX_DATE_RANGE_DAYS returns 400 with dates.range_too_large message."""
|
||||
from datetime import date, timedelta
|
||||
|
||||
from duty_teller.utils.dates import MAX_DATE_RANGE_DAYS
|
||||
|
||||
from_d = date(2020, 1, 1)
|
||||
to_d = from_d + timedelta(days=MAX_DATE_RANGE_DAYS + 1)
|
||||
r = client.get(
|
||||
"/api/duties",
|
||||
params={"from": from_d.isoformat(), "to": to_d.isoformat()},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
detail = r.json()["detail"]
|
||||
# EN: "Date range is too large. Request a shorter period." / RU: "Диапазон дат слишком большой..."
|
||||
assert (
|
||||
"range" in detail.lower()
|
||||
or "short" in detail.lower()
|
||||
or "короткий" in detail
|
||||
or "большой" in detail
|
||||
)
|
||||
|
||||
|
||||
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
|
||||
def test_duties_403_without_init_data(client):
|
||||
"""Without X-Telegram-Init-Data and without MINI_APP_SKIP_AUTH → 403 (any client)."""
|
||||
@@ -254,7 +352,8 @@ def test_duties_200_with_unknown_event_type_mapped_to_duty(client):
|
||||
)
|
||||
|
||||
def fake_get_duties(session, from_date, to_date):
|
||||
return [(fake_duty, "User A")]
|
||||
# get_duties returns (Duty, full_name, phone, username) tuples.
|
||||
return [(fake_duty, "User A", "+79001234567", "user_a")]
|
||||
|
||||
with patch("duty_teller.api.dependencies.get_duties", side_effect=fake_get_duties):
|
||||
r = client.get(
|
||||
@@ -266,23 +365,26 @@ def test_duties_200_with_unknown_event_type_mapped_to_duty(client):
|
||||
assert len(data) == 1
|
||||
assert data[0]["event_type"] == "duty"
|
||||
assert data[0]["full_name"] == "User A"
|
||||
assert data[0].get("phone") == "+79001234567"
|
||||
assert data[0].get("username") == "user_a"
|
||||
|
||||
|
||||
def test_calendar_ical_team_404_invalid_token_format(client):
|
||||
"""GET /api/calendar/ical/team/{token}.ics with invalid token format returns 404 without DB."""
|
||||
"""GET /api/calendar/ical/team/{token}.ics with invalid token format returns 404 JSON."""
|
||||
r = client.get("/api/calendar/ical/team/short.ics")
|
||||
assert r.status_code == 404
|
||||
assert "not found" in r.text.lower()
|
||||
assert r.headers.get("content-type", "").startswith("application/json")
|
||||
assert r.json() == {"detail": "Not found"}
|
||||
|
||||
|
||||
@patch("duty_teller.api.app.get_user_by_calendar_token")
|
||||
def test_calendar_ical_team_404_unknown_token(mock_get_user, client):
|
||||
"""GET /api/calendar/ical/team/{token}.ics with unknown token returns 404."""
|
||||
"""GET /api/calendar/ical/team/{token}.ics with unknown token returns 404 JSON."""
|
||||
mock_get_user.return_value = None
|
||||
valid_format_token = "B" * 43
|
||||
r = client.get(f"/api/calendar/ical/team/{valid_format_token}.ics")
|
||||
assert r.status_code == 404
|
||||
assert "not found" in r.text.lower()
|
||||
assert r.json() == {"detail": "Not found"}
|
||||
mock_get_user.assert_called_once()
|
||||
|
||||
|
||||
@@ -311,7 +413,11 @@ def test_calendar_ical_team_200_only_duty_and_description(
|
||||
end_at="2026-06-16T18:00:00Z",
|
||||
event_type="vacation",
|
||||
)
|
||||
mock_get_duties.return_value = [(duty, "User A"), (non_duty, "User B")]
|
||||
# get_duties returns (Duty, full_name, phone, username) tuples.
|
||||
mock_get_duties.return_value = [
|
||||
(duty, "User A", None, None),
|
||||
(non_duty, "User B", None, None),
|
||||
]
|
||||
mock_build_team_ics.return_value = b"BEGIN:VCALENDAR\r\nPRODID:Team\r\nVEVENT\r\nDESCRIPTION:User A\r\nEND:VCALENDAR"
|
||||
token = "y" * 43
|
||||
|
||||
@@ -330,11 +436,10 @@ def test_calendar_ical_team_200_only_duty_and_description(
|
||||
|
||||
|
||||
def test_calendar_ical_404_invalid_token_format(client):
|
||||
"""GET /api/calendar/ical/{token}.ics with invalid token format returns 404 without DB call."""
|
||||
# Token format must be base64url, 40–50 chars; short or invalid chars → 404
|
||||
"""GET /api/calendar/ical/{token}.ics with invalid token format returns 404 JSON."""
|
||||
r = client.get("/api/calendar/ical/short.ics")
|
||||
assert r.status_code == 404
|
||||
assert "not found" in r.text.lower()
|
||||
assert r.json() == {"detail": "Not found"}
|
||||
r2 = client.get("/api/calendar/ical/" + "x" * 60 + ".ics")
|
||||
assert r2.status_code == 404
|
||||
r3 = client.get("/api/calendar/ical/../../../etc/passwd.ics")
|
||||
@@ -343,13 +448,12 @@ def test_calendar_ical_404_invalid_token_format(client):
|
||||
|
||||
@patch("duty_teller.api.app.get_user_by_calendar_token")
|
||||
def test_calendar_ical_404_unknown_token(mock_get_user, client):
|
||||
"""GET /api/calendar/ical/{token}.ics with unknown token returns 404."""
|
||||
"""GET /api/calendar/ical/{token}.ics with unknown token returns 404 JSON."""
|
||||
mock_get_user.return_value = None
|
||||
# Use a token that passes format validation (base64url, 40–50 chars)
|
||||
valid_format_token = "A" * 43
|
||||
r = client.get(f"/api/calendar/ical/{valid_format_token}.ics")
|
||||
assert r.status_code == 404
|
||||
assert "not found" in r.text.lower()
|
||||
assert r.json() == {"detail": "Not found"}
|
||||
mock_get_user.assert_called_once()
|
||||
|
||||
|
||||
@@ -371,7 +475,8 @@ def test_calendar_ical_200_returns_only_that_users_duties(
|
||||
end_at="2026-06-15T18:00:00Z",
|
||||
event_type="duty",
|
||||
)
|
||||
mock_get_duties.return_value = [(duty, "User A")]
|
||||
# get_duties_for_user returns (Duty, full_name, phone, username) tuples.
|
||||
mock_get_duties.return_value = [(duty, "User A", None, None)]
|
||||
mock_build_ics.return_value = (
|
||||
b"BEGIN:VCALENDAR\r\nVEVENT\r\n2026-06-15\r\nEND:VCALENDAR"
|
||||
)
|
||||
@@ -415,7 +520,8 @@ def test_calendar_ical_ignores_unknown_query_params(
|
||||
end_at="2026-06-15T18:00:00Z",
|
||||
event_type="duty",
|
||||
)
|
||||
mock_get_duties.return_value = [(duty, "User A")]
|
||||
# get_duties_for_user returns (Duty, full_name, phone, username) tuples.
|
||||
mock_get_duties.return_value = [(duty, "User A", None, None)]
|
||||
mock_build_ics.return_value = b"BEGIN:VCALENDAR\r\nEND:VCALENDAR"
|
||||
token = "z" * 43
|
||||
|
||||
|
||||
@@ -91,3 +91,29 @@ def test_require_bot_token_does_not_raise_when_set(monkeypatch):
|
||||
"""require_bot_token() does nothing when BOT_TOKEN is set."""
|
||||
monkeypatch.setattr(config, "BOT_TOKEN", "123:ABC")
|
||||
config.require_bot_token()
|
||||
|
||||
|
||||
def test_settings_from_env_invalid_http_port_uses_default(monkeypatch):
|
||||
"""Invalid HTTP_PORT (non-numeric or out of range) yields default or clamped value."""
|
||||
monkeypatch.delenv("HTTP_PORT", raising=False)
|
||||
settings = config.Settings.from_env()
|
||||
assert 1 <= settings.http_port <= 65535
|
||||
|
||||
monkeypatch.setenv("HTTP_PORT", "not-a-number")
|
||||
settings = config.Settings.from_env()
|
||||
assert settings.http_port == 8080
|
||||
|
||||
monkeypatch.setenv("HTTP_PORT", "0")
|
||||
settings = config.Settings.from_env()
|
||||
assert settings.http_port == 1
|
||||
|
||||
monkeypatch.setenv("HTTP_PORT", "99999")
|
||||
settings = config.Settings.from_env()
|
||||
assert settings.http_port == 65535
|
||||
|
||||
|
||||
def test_settings_from_env_invalid_init_data_max_age_uses_default(monkeypatch):
|
||||
"""Invalid INIT_DATA_MAX_AGE_SECONDS yields default 0."""
|
||||
monkeypatch.setenv("INIT_DATA_MAX_AGE_SECONDS", "invalid")
|
||||
settings = config.Settings.from_env()
|
||||
assert settings.init_data_max_age_seconds == 0
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
"""Tests for duty-schedule JSON parser."""
|
||||
|
||||
import json
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
from duty_teller.importers.duty_schedule import (
|
||||
DUTY_MARKERS,
|
||||
MAX_FULL_NAME_LENGTH,
|
||||
MAX_SCHEDULE_ROWS,
|
||||
UNAVAILABLE_MARKER,
|
||||
VACATION_MARKER,
|
||||
DutyScheduleParseError,
|
||||
@@ -118,3 +121,38 @@ def test_unavailable_and_vacation_markers():
|
||||
assert entry.unavailable_dates == [date(2026, 2, 1)]
|
||||
assert entry.vacation_dates == [date(2026, 2, 2)]
|
||||
assert entry.duty_dates == [date(2026, 2, 3)]
|
||||
|
||||
|
||||
def test_parse_start_date_year_out_of_range():
|
||||
"""start_date year must be current ± 1; otherwise DutyScheduleParseError."""
|
||||
# Use a year far in the past/future so it fails regardless of test run date.
|
||||
raw_future = b'{"meta": {"start_date": "2030-01-01"}, "schedule": [{"name": "A", "duty": ""}]}'
|
||||
with pytest.raises(DutyScheduleParseError, match="year|2030"):
|
||||
parse_duty_schedule(raw_future)
|
||||
raw_past = b'{"meta": {"start_date": "2019-01-01"}, "schedule": [{"name": "A", "duty": ""}]}'
|
||||
with pytest.raises(DutyScheduleParseError, match="year|2019"):
|
||||
parse_duty_schedule(raw_past)
|
||||
|
||||
|
||||
def test_parse_too_many_schedule_rows():
|
||||
"""More than MAX_SCHEDULE_ROWS rows raises DutyScheduleParseError."""
|
||||
rows = [{"name": f"User {i}", "duty": ""} for i in range(MAX_SCHEDULE_ROWS + 1)]
|
||||
today = date.today()
|
||||
start = today.replace(month=1, day=1)
|
||||
payload = {"meta": {"start_date": start.isoformat()}, "schedule": rows}
|
||||
raw = json.dumps(payload).encode("utf-8")
|
||||
with pytest.raises(DutyScheduleParseError, match="too many|max"):
|
||||
parse_duty_schedule(raw)
|
||||
|
||||
|
||||
def test_parse_full_name_too_long():
|
||||
"""full_name longer than MAX_FULL_NAME_LENGTH raises DutyScheduleParseError."""
|
||||
long_name = "A" * (MAX_FULL_NAME_LENGTH + 1)
|
||||
today = date.today()
|
||||
start = today.replace(month=1, day=1)
|
||||
raw = (
|
||||
f'{{"meta": {{"start_date": "{start.isoformat()}"}}, '
|
||||
f'"schedule": [{{"name": "{long_name}", "duty": ""}}]}}'
|
||||
).encode("utf-8")
|
||||
with pytest.raises(DutyScheduleParseError, match="exceed|character"):
|
||||
parse_duty_schedule(raw)
|
||||
|
||||
@@ -81,6 +81,7 @@ class TestFormatDutyMessage:
|
||||
assert result == "No duty"
|
||||
|
||||
def test_with_duty_and_user_returns_formatted(self):
|
||||
"""Formatted message includes time range and full name only; no contact info (phone/username)."""
|
||||
duty = SimpleNamespace(
|
||||
start_at="2025-01-15T09:00:00Z",
|
||||
end_at="2025-01-15T18:00:00Z",
|
||||
@@ -94,9 +95,10 @@ class TestFormatDutyMessage:
|
||||
mock_t.side_effect = lambda lang, key: "Duty" if key == "duty.label" else ""
|
||||
result = svc.format_duty_message(duty, user, "Europe/Moscow", "ru")
|
||||
assert "Иван Иванов" in result
|
||||
assert "+79001234567" in result or "79001234567" in result
|
||||
assert "@ivan" in result
|
||||
assert "Duty" in result
|
||||
# Contact info is restricted to Mini App; not shown in pinned group message.
|
||||
assert "+79001234567" not in result and "79001234567" not in result
|
||||
assert "@ivan" not in result
|
||||
|
||||
|
||||
class TestGetDutyMessageText:
|
||||
|
||||
@@ -11,6 +11,13 @@ import duty_teller.config as config
|
||||
from duty_teller.handlers import group_duty_pin as mod
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def no_mini_app_url():
|
||||
"""Ensure BOT_USERNAME is empty so duty messages are sent without contact button (reply_markup=None)."""
|
||||
with patch.object(config, "BOT_USERNAME", ""):
|
||||
yield
|
||||
|
||||
|
||||
class TestSyncWrappers:
|
||||
"""Tests for _get_duty_message_text_sync, _sync_save_pin, _sync_delete_pin, _sync_get_message_id, _get_all_pin_chat_ids_sync."""
|
||||
|
||||
@@ -76,6 +83,49 @@ class TestSyncWrappers:
|
||||
# --- _schedule_next_update ---
|
||||
|
||||
|
||||
def test_get_contact_button_markup_empty_username_returns_none():
|
||||
"""_get_contact_button_markup: BOT_USERNAME empty -> returns None."""
|
||||
with patch.object(config, "BOT_USERNAME", ""):
|
||||
assert mod._get_contact_button_markup("en") is None
|
||||
|
||||
|
||||
def test_get_contact_button_markup_returns_markup_when_username_set():
|
||||
"""_get_contact_button_markup: BOT_USERNAME set, no short name -> t.me bot link with startapp=duty."""
|
||||
from telegram import InlineKeyboardMarkup
|
||||
|
||||
with (
|
||||
patch.object(config, "BOT_USERNAME", "MyDutyBot"),
|
||||
patch.object(config, "MINI_APP_SHORT_NAME", ""),
|
||||
):
|
||||
with patch.object(mod, "t", return_value="View contacts"):
|
||||
result = mod._get_contact_button_markup("en")
|
||||
assert result is not None
|
||||
assert isinstance(result, InlineKeyboardMarkup)
|
||||
assert len(result.inline_keyboard) == 1
|
||||
assert len(result.inline_keyboard[0]) == 1
|
||||
btn = result.inline_keyboard[0][0]
|
||||
assert btn.text == "View contacts"
|
||||
assert btn.url.startswith("https://t.me/")
|
||||
assert "startapp=duty" in btn.url
|
||||
assert btn.url == "https://t.me/MyDutyBot?startapp=duty"
|
||||
|
||||
|
||||
def test_get_contact_button_markup_with_short_name_uses_direct_miniapp_link():
|
||||
"""_get_contact_button_markup: MINI_APP_SHORT_NAME set -> direct Mini App URL with startapp=duty."""
|
||||
from telegram import InlineKeyboardMarkup
|
||||
|
||||
with (
|
||||
patch.object(config, "BOT_USERNAME", "MyDutyBot"),
|
||||
patch.object(config, "MINI_APP_SHORT_NAME", "DutyApp"),
|
||||
):
|
||||
with patch.object(mod, "t", return_value="View contacts"):
|
||||
result = mod._get_contact_button_markup("en")
|
||||
assert result is not None
|
||||
assert isinstance(result, InlineKeyboardMarkup)
|
||||
btn = result.inline_keyboard[0][0]
|
||||
assert btn.url == "https://t.me/MyDutyBot/DutyApp?startapp=duty"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_schedule_next_update_job_queue_none_returns_early():
|
||||
"""_schedule_next_update: job_queue is None -> log and return, no run_once."""
|
||||
@@ -127,7 +177,7 @@ async def test_schedule_next_update_when_utc_none_runs_once_with_retry_delay():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_group_pin_sends_new_unpins_pins_saves_schedules_next():
|
||||
"""update_group_pin: with message_id and text, sends new message, unpins, pins new, saves id, schedules next."""
|
||||
"""update_group_pin: with message_id and text, sends new message, unpins, pins new, saves id, deletes old, schedules next."""
|
||||
new_msg = MagicMock()
|
||||
new_msg.message_id = 999
|
||||
context = MagicMock()
|
||||
@@ -137,24 +187,70 @@ async def test_update_group_pin_sends_new_unpins_pins_saves_schedules_next():
|
||||
context.bot.send_message = AsyncMock(return_value=new_msg)
|
||||
context.bot.unpin_chat_message = AsyncMock()
|
||||
context.bot.pin_chat_message = AsyncMock()
|
||||
context.bot.delete_message = AsyncMock()
|
||||
context.application = MagicMock()
|
||||
context.application.job_queue = MagicMock()
|
||||
context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[])
|
||||
context.application.job_queue.run_once = MagicMock()
|
||||
|
||||
with patch.object(config, "DUTY_PIN_NOTIFY", True):
|
||||
with patch.object(
|
||||
mod, "_sync_get_pin_refresh_data", return_value=(1, "Current duty", None)
|
||||
):
|
||||
with patch.object(mod, "_schedule_next_update", AsyncMock()):
|
||||
with patch.object(mod, "_sync_save_pin") as mock_save:
|
||||
await mod.update_group_pin(context)
|
||||
context.bot.send_message.assert_called_once_with(chat_id=123, text="Current duty")
|
||||
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
||||
with patch.object(
|
||||
mod,
|
||||
"_sync_get_pin_refresh_data",
|
||||
return_value=(1, "Current duty", None),
|
||||
):
|
||||
with patch.object(mod, "_schedule_next_update", AsyncMock()):
|
||||
with patch.object(mod, "_sync_save_pin") as mock_save:
|
||||
await mod.update_group_pin(context)
|
||||
context.bot.send_message.assert_called_once_with(
|
||||
chat_id=123, text="Current duty", reply_markup=None
|
||||
)
|
||||
context.bot.unpin_chat_message.assert_called_once_with(chat_id=123)
|
||||
context.bot.pin_chat_message.assert_called_once_with(
|
||||
chat_id=123, message_id=999, disable_notification=False
|
||||
)
|
||||
mock_save.assert_called_once_with(123, 999)
|
||||
context.bot.delete_message.assert_called_once_with(chat_id=123, message_id=1)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_group_pin_delete_message_raises_bad_request_still_schedules():
|
||||
"""update_group_pin: delete_message raises BadRequest -> save and schedule still done, log warning."""
|
||||
new_msg = MagicMock()
|
||||
new_msg.message_id = 999
|
||||
context = MagicMock()
|
||||
context.job = MagicMock()
|
||||
context.job.data = {"chat_id": 123}
|
||||
context.bot = MagicMock()
|
||||
context.bot.send_message = AsyncMock(return_value=new_msg)
|
||||
context.bot.unpin_chat_message = AsyncMock()
|
||||
context.bot.pin_chat_message = AsyncMock()
|
||||
context.bot.delete_message = AsyncMock(
|
||||
side_effect=BadRequest("Message to delete not found")
|
||||
)
|
||||
context.application = MagicMock()
|
||||
context.application.job_queue = MagicMock()
|
||||
context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[])
|
||||
context.application.job_queue.run_once = MagicMock()
|
||||
|
||||
with patch.object(config, "DUTY_PIN_NOTIFY", True):
|
||||
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
||||
with patch.object(
|
||||
mod,
|
||||
"_sync_get_pin_refresh_data",
|
||||
return_value=(1, "Current duty", None),
|
||||
):
|
||||
with patch.object(
|
||||
mod, "_schedule_next_update", AsyncMock()
|
||||
) as mock_schedule:
|
||||
with patch.object(mod, "_sync_save_pin") as mock_save:
|
||||
with patch.object(mod, "logger") as mock_logger:
|
||||
await mod.update_group_pin(context)
|
||||
mock_save.assert_called_once_with(123, 999)
|
||||
mock_schedule.assert_called_once_with(context.application, 123, None)
|
||||
mock_logger.warning.assert_called_once()
|
||||
assert "Could not delete old pinned message" in mock_logger.warning.call_args[0][0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -166,10 +262,11 @@ async def test_update_group_pin_no_message_id_skips():
|
||||
context.bot = MagicMock()
|
||||
context.bot.send_message = AsyncMock()
|
||||
|
||||
with patch.object(
|
||||
mod, "_sync_get_pin_refresh_data", return_value=(None, "No duty", None)
|
||||
):
|
||||
await mod.update_group_pin(context)
|
||||
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
||||
with patch.object(
|
||||
mod, "_sync_get_pin_refresh_data", return_value=(None, "No duty", None)
|
||||
):
|
||||
await mod.update_group_pin(context)
|
||||
context.bot.send_message.assert_not_called()
|
||||
|
||||
|
||||
@@ -185,19 +282,22 @@ async def test_update_group_pin_send_raises_no_unpin_pin_schedule_still_called()
|
||||
context.bot.pin_chat_message = AsyncMock()
|
||||
context.application = MagicMock()
|
||||
|
||||
with patch.object(
|
||||
mod, "_sync_get_pin_refresh_data", return_value=(2, "Text", None)
|
||||
):
|
||||
with patch.object(mod, "_schedule_next_update", AsyncMock()) as mock_schedule:
|
||||
await mod.update_group_pin(context)
|
||||
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
||||
with patch.object(
|
||||
mod, "_sync_get_pin_refresh_data", return_value=(2, "Text", None)
|
||||
):
|
||||
with patch.object(
|
||||
mod, "_schedule_next_update", AsyncMock()
|
||||
) as mock_schedule:
|
||||
await mod.update_group_pin(context)
|
||||
context.bot.unpin_chat_message.assert_not_called()
|
||||
context.bot.pin_chat_message.assert_not_called()
|
||||
mock_schedule.assert_called_once_with(context.application, 111, None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_group_pin_repin_raises_still_schedules_next():
|
||||
"""update_group_pin: send_message ok, unpin or pin raises -> no _sync_save_pin, schedule still called, log."""
|
||||
async def test_update_group_pin_unpin_raises_pin_succeeds_saves_and_schedules():
|
||||
"""update_group_pin: send_message ok, unpin raises (e.g. no pinned message), pin succeeds -> save_pin and schedule called."""
|
||||
new_msg = MagicMock()
|
||||
new_msg.message_id = 888
|
||||
context = MagicMock()
|
||||
@@ -206,31 +306,70 @@ async def test_update_group_pin_repin_raises_still_schedules_next():
|
||||
context.bot = MagicMock()
|
||||
context.bot.send_message = AsyncMock(return_value=new_msg)
|
||||
context.bot.unpin_chat_message = AsyncMock(
|
||||
side_effect=Forbidden("Not enough rights")
|
||||
side_effect=BadRequest("Chat has no pinned message")
|
||||
)
|
||||
context.bot.pin_chat_message = AsyncMock()
|
||||
context.bot.delete_message = AsyncMock()
|
||||
context.application = MagicMock()
|
||||
|
||||
with patch.object(config, "DUTY_PIN_NOTIFY", True):
|
||||
with patch.object(
|
||||
mod, "_sync_get_pin_refresh_data", return_value=(3, "Text", None)
|
||||
):
|
||||
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
||||
with patch.object(
|
||||
mod, "_schedule_next_update", AsyncMock()
|
||||
) as mock_schedule:
|
||||
with patch.object(mod, "_sync_save_pin") as mock_save:
|
||||
with patch.object(mod, "logger") as mock_logger:
|
||||
mod, "_sync_get_pin_refresh_data", return_value=(3, "Text", None)
|
||||
):
|
||||
with patch.object(
|
||||
mod, "_schedule_next_update", AsyncMock()
|
||||
) as mock_schedule:
|
||||
with patch.object(mod, "_sync_save_pin") as mock_save:
|
||||
await mod.update_group_pin(context)
|
||||
context.bot.send_message.assert_called_once_with(chat_id=222, text="Text")
|
||||
context.bot.send_message.assert_called_once_with(
|
||||
chat_id=222, text="Text", reply_markup=None
|
||||
)
|
||||
context.bot.unpin_chat_message.assert_called_once_with(chat_id=222)
|
||||
context.bot.pin_chat_message.assert_called_once_with(
|
||||
chat_id=222, message_id=888, disable_notification=False
|
||||
)
|
||||
mock_save.assert_called_once_with(222, 888)
|
||||
mock_schedule.assert_called_once_with(context.application, 222, None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_group_pin_pin_raises_no_save_still_schedules_next():
|
||||
"""update_group_pin: send_message ok, unpin ok, pin raises -> no _sync_save_pin, schedule still called, log."""
|
||||
new_msg = MagicMock()
|
||||
new_msg.message_id = 888
|
||||
context = MagicMock()
|
||||
context.job = MagicMock()
|
||||
context.job.data = {"chat_id": 222}
|
||||
context.bot = MagicMock()
|
||||
context.bot.send_message = AsyncMock(return_value=new_msg)
|
||||
context.bot.unpin_chat_message = AsyncMock()
|
||||
context.bot.pin_chat_message = AsyncMock(side_effect=Forbidden("Not enough rights"))
|
||||
context.application = MagicMock()
|
||||
|
||||
with patch.object(config, "DUTY_PIN_NOTIFY", True):
|
||||
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
||||
with patch.object(
|
||||
mod, "_sync_get_pin_refresh_data", return_value=(3, "Text", None)
|
||||
):
|
||||
with patch.object(
|
||||
mod, "_schedule_next_update", AsyncMock()
|
||||
) as mock_schedule:
|
||||
with patch.object(mod, "_sync_save_pin") as mock_save:
|
||||
with patch.object(mod, "logger") as mock_logger:
|
||||
await mod.update_group_pin(context)
|
||||
context.bot.send_message.assert_called_once_with(
|
||||
chat_id=222, text="Text", reply_markup=None
|
||||
)
|
||||
mock_save.assert_not_called()
|
||||
mock_logger.warning.assert_called_once()
|
||||
assert "Unpin or pin" in mock_logger.warning.call_args[0][0]
|
||||
assert "Pin after refresh failed" in mock_logger.warning.call_args[0][0]
|
||||
mock_schedule.assert_called_once_with(context.application, 222, None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_group_pin_duty_pin_notify_false_pins_silent():
|
||||
"""update_group_pin: DUTY_PIN_NOTIFY False -> send new, unpin, pin new with disable_notification=True, save, schedule."""
|
||||
"""update_group_pin: DUTY_PIN_NOTIFY False -> send new, unpin, pin new with disable_notification=True, save, delete old, schedule."""
|
||||
new_msg = MagicMock()
|
||||
new_msg.message_id = 777
|
||||
context = MagicMock()
|
||||
@@ -240,23 +379,28 @@ async def test_update_group_pin_duty_pin_notify_false_pins_silent():
|
||||
context.bot.send_message = AsyncMock(return_value=new_msg)
|
||||
context.bot.unpin_chat_message = AsyncMock()
|
||||
context.bot.pin_chat_message = AsyncMock()
|
||||
context.bot.delete_message = AsyncMock()
|
||||
context.application = MagicMock()
|
||||
|
||||
with patch.object(config, "DUTY_PIN_NOTIFY", False):
|
||||
with patch.object(
|
||||
mod, "_sync_get_pin_refresh_data", return_value=(4, "Text", None)
|
||||
):
|
||||
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
||||
with patch.object(
|
||||
mod, "_schedule_next_update", AsyncMock()
|
||||
) as mock_schedule:
|
||||
with patch.object(mod, "_sync_save_pin") as mock_save:
|
||||
await mod.update_group_pin(context)
|
||||
context.bot.send_message.assert_called_once_with(chat_id=333, text="Text")
|
||||
mod, "_sync_get_pin_refresh_data", return_value=(4, "Text", None)
|
||||
):
|
||||
with patch.object(
|
||||
mod, "_schedule_next_update", AsyncMock()
|
||||
) as mock_schedule:
|
||||
with patch.object(mod, "_sync_save_pin") as mock_save:
|
||||
await mod.update_group_pin(context)
|
||||
context.bot.send_message.assert_called_once_with(
|
||||
chat_id=333, text="Text", reply_markup=None
|
||||
)
|
||||
context.bot.unpin_chat_message.assert_called_once_with(chat_id=333)
|
||||
context.bot.pin_chat_message.assert_called_once_with(
|
||||
chat_id=333, message_id=777, disable_notification=True
|
||||
)
|
||||
mock_save.assert_called_once_with(333, 777)
|
||||
context.bot.delete_message.assert_called_once_with(chat_id=333, message_id=4)
|
||||
mock_schedule.assert_called_once_with(context.application, 333, None)
|
||||
|
||||
|
||||
@@ -282,7 +426,7 @@ async def test_pin_duty_cmd_group_only_reply():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pin_duty_cmd_group_pins_and_replies_pinned():
|
||||
"""pin_duty_cmd in group with existing pin record -> pin and reply pinned."""
|
||||
"""pin_duty_cmd in group with existing pin record -> pin, schedule next update, reply pinned."""
|
||||
update = MagicMock()
|
||||
update.message = MagicMock()
|
||||
update.message.reply_text = AsyncMock()
|
||||
@@ -293,21 +437,28 @@ async def test_pin_duty_cmd_group_pins_and_replies_pinned():
|
||||
context = MagicMock()
|
||||
context.bot = MagicMock()
|
||||
context.bot.pin_chat_message = AsyncMock()
|
||||
context.application = MagicMock()
|
||||
|
||||
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||
with patch.object(mod, "_sync_get_message_id", return_value=5):
|
||||
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
||||
mock_t.return_value = "Pinned"
|
||||
await mod.pin_duty_cmd(update, context)
|
||||
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
||||
with patch.object(mod, "_sync_get_message_id", return_value=5):
|
||||
with patch.object(mod, "_get_next_shift_end_sync", return_value=None):
|
||||
with patch.object(
|
||||
mod, "_schedule_next_update", AsyncMock()
|
||||
) as mock_schedule:
|
||||
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
||||
mock_t.return_value = "Pinned"
|
||||
await mod.pin_duty_cmd(update, context)
|
||||
context.bot.pin_chat_message.assert_called_once_with(
|
||||
chat_id=100, message_id=5, disable_notification=True
|
||||
)
|
||||
mock_schedule.assert_called_once_with(context.application, 100, None)
|
||||
update.message.reply_text.assert_called_once_with("Pinned")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pin_duty_cmd_no_message_id_replies_no_message():
|
||||
"""pin_duty_cmd: no pin record (_sync_get_message_id -> None) -> reply pin_duty.no_message."""
|
||||
async def test_pin_duty_cmd_untrusted_group_rejects():
|
||||
"""pin_duty_cmd in untrusted group -> reply group.not_trusted, no send/pin."""
|
||||
update = MagicMock()
|
||||
update.message = MagicMock()
|
||||
update.message.reply_text = AsyncMock()
|
||||
@@ -316,14 +467,150 @@ async def test_pin_duty_cmd_no_message_id_replies_no_message():
|
||||
update.effective_chat.id = 100
|
||||
update.effective_user = MagicMock()
|
||||
context = MagicMock()
|
||||
context.bot = MagicMock()
|
||||
context.bot.send_message = AsyncMock()
|
||||
|
||||
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||
with patch.object(mod, "_sync_get_message_id", return_value=None):
|
||||
with patch.object(mod, "_sync_is_trusted", return_value=False):
|
||||
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
||||
mock_t.return_value = "No message to pin"
|
||||
mock_t.return_value = "Not authorized"
|
||||
await mod.pin_duty_cmd(update, context)
|
||||
update.message.reply_text.assert_called_once_with("No message to pin")
|
||||
mock_t.assert_called_with("en", "pin_duty.no_message")
|
||||
update.message.reply_text.assert_called_once_with("Not authorized")
|
||||
mock_t.assert_called_with("en", "group.not_trusted")
|
||||
context.bot.send_message.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pin_duty_cmd_no_message_id_creates_sends_pins_saves_schedules_replies_pinned():
|
||||
"""pin_duty_cmd: no pin record -> send_message, pin, save_pin, schedule, reply pinned."""
|
||||
update = MagicMock()
|
||||
update.message = MagicMock()
|
||||
update.message.reply_text = AsyncMock()
|
||||
update.effective_chat = MagicMock()
|
||||
update.effective_chat.type = "group"
|
||||
update.effective_chat.id = 100
|
||||
update.effective_user = MagicMock()
|
||||
context = MagicMock()
|
||||
context.bot = MagicMock()
|
||||
new_msg = MagicMock()
|
||||
new_msg.message_id = 42
|
||||
context.bot.send_message = AsyncMock(return_value=new_msg)
|
||||
context.bot.pin_chat_message = AsyncMock()
|
||||
context.application = MagicMock()
|
||||
context.application.job_queue = MagicMock()
|
||||
context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[])
|
||||
context.application.job_queue.run_once = MagicMock()
|
||||
|
||||
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
||||
with patch.object(mod, "_sync_get_message_id", return_value=None):
|
||||
with patch.object(
|
||||
mod, "_get_duty_message_text_sync", return_value="Duty text"
|
||||
):
|
||||
with patch.object(mod, "_sync_save_pin") as mock_save:
|
||||
with patch.object(
|
||||
mod, "_get_next_shift_end_sync", return_value=None
|
||||
):
|
||||
with patch.object(
|
||||
mod, "_schedule_next_update", AsyncMock()
|
||||
):
|
||||
with patch(
|
||||
"duty_teller.handlers.group_duty_pin.t"
|
||||
) as mock_t:
|
||||
mock_t.return_value = "Pinned"
|
||||
await mod.pin_duty_cmd(update, context)
|
||||
context.bot.send_message.assert_called_once_with(
|
||||
chat_id=100, text="Duty text", reply_markup=None
|
||||
)
|
||||
context.bot.pin_chat_message.assert_called_once_with(
|
||||
chat_id=100, message_id=42, disable_notification=True
|
||||
)
|
||||
mock_save.assert_called_once_with(100, 42)
|
||||
update.message.reply_text.assert_called_once_with("Pinned")
|
||||
mock_t.assert_called_with("en", "pin_duty.pinned")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pin_duty_cmd_no_message_id_send_message_raises_replies_failed():
|
||||
"""pin_duty_cmd: no pin record, send_message raises BadRequest -> reply pin_duty.failed."""
|
||||
update = MagicMock()
|
||||
update.message = MagicMock()
|
||||
update.message.reply_text = AsyncMock()
|
||||
update.effective_chat = MagicMock()
|
||||
update.effective_chat.type = "group"
|
||||
update.effective_chat.id = 100
|
||||
update.effective_user = MagicMock()
|
||||
context = MagicMock()
|
||||
context.bot = MagicMock()
|
||||
context.bot.send_message = AsyncMock(side_effect=BadRequest("Chat not found"))
|
||||
context.application = MagicMock()
|
||||
|
||||
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
||||
with patch.object(mod, "_sync_get_message_id", return_value=None):
|
||||
with patch.object(
|
||||
mod, "_get_duty_message_text_sync", return_value="Duty"
|
||||
):
|
||||
with patch.object(mod, "_sync_save_pin") as mock_save:
|
||||
with patch.object(
|
||||
mod, "_schedule_next_update", AsyncMock()
|
||||
) as mock_schedule:
|
||||
with patch(
|
||||
"duty_teller.handlers.group_duty_pin.t"
|
||||
) as mock_t:
|
||||
mock_t.return_value = "Failed"
|
||||
await mod.pin_duty_cmd(update, context)
|
||||
update.message.reply_text.assert_called_once_with("Failed")
|
||||
mock_t.assert_called_with("en", "pin_duty.failed")
|
||||
mock_save.assert_not_called()
|
||||
mock_schedule.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pin_duty_cmd_no_message_id_pin_raises_saves_and_replies_could_not_pin():
|
||||
"""pin_duty_cmd: no pin record, pin_chat_message raises -> save pin, reply could_not_pin_make_admin."""
|
||||
update = MagicMock()
|
||||
update.message = MagicMock()
|
||||
update.message.reply_text = AsyncMock()
|
||||
update.effective_chat = MagicMock()
|
||||
update.effective_chat.type = "group"
|
||||
update.effective_chat.id = 100
|
||||
update.effective_user = MagicMock()
|
||||
context = MagicMock()
|
||||
context.bot = MagicMock()
|
||||
new_msg = MagicMock()
|
||||
new_msg.message_id = 43
|
||||
context.bot.send_message = AsyncMock(return_value=new_msg)
|
||||
context.bot.pin_chat_message = AsyncMock(side_effect=Forbidden("Not enough rights"))
|
||||
context.application = MagicMock()
|
||||
context.application.job_queue = MagicMock()
|
||||
context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[])
|
||||
context.application.job_queue.run_once = MagicMock()
|
||||
|
||||
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
||||
with patch.object(mod, "_sync_get_message_id", return_value=None):
|
||||
with patch.object(
|
||||
mod, "_get_duty_message_text_sync", return_value="Duty"
|
||||
):
|
||||
with patch.object(mod, "_sync_save_pin") as mock_save:
|
||||
with patch.object(
|
||||
mod, "_get_next_shift_end_sync", return_value=None
|
||||
):
|
||||
with patch.object(
|
||||
mod, "_schedule_next_update", AsyncMock()
|
||||
):
|
||||
with patch(
|
||||
"duty_teller.handlers.group_duty_pin.t"
|
||||
) as mock_t:
|
||||
mock_t.return_value = "Make me admin to pin"
|
||||
await mod.pin_duty_cmd(update, context)
|
||||
context.bot.send_message.assert_called_once_with(
|
||||
chat_id=100, text="Duty", reply_markup=None
|
||||
)
|
||||
mock_save.assert_called_once_with(100, 43)
|
||||
update.message.reply_text.assert_called_once_with("Make me admin to pin")
|
||||
mock_t.assert_called_with("en", "pin_duty.could_not_pin_make_admin")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -343,10 +630,11 @@ async def test_pin_duty_cmd_pin_raises_replies_failed():
|
||||
)
|
||||
|
||||
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||
with patch.object(mod, "_sync_get_message_id", return_value=5):
|
||||
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
||||
mock_t.return_value = "Failed to pin"
|
||||
await mod.pin_duty_cmd(update, context)
|
||||
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
||||
with patch.object(mod, "_sync_get_message_id", return_value=5):
|
||||
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
||||
mock_t.return_value = "Failed to pin"
|
||||
await mod.pin_duty_cmd(update, context)
|
||||
update.message.reply_text.assert_called_once_with("Failed to pin")
|
||||
mock_t.assert_called_with("en", "pin_duty.failed")
|
||||
|
||||
@@ -387,12 +675,13 @@ async def test_refresh_pin_cmd_group_updated_replies_updated():
|
||||
context = MagicMock()
|
||||
|
||||
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||
with patch.object(
|
||||
mod, "_refresh_pin_for_chat", AsyncMock(return_value="updated")
|
||||
):
|
||||
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
||||
mock_t.return_value = "Updated"
|
||||
await mod.refresh_pin_cmd(update, context)
|
||||
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
||||
with patch.object(
|
||||
mod, "_refresh_pin_for_chat", AsyncMock(return_value="updated")
|
||||
):
|
||||
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
||||
mock_t.return_value = "Updated"
|
||||
await mod.refresh_pin_cmd(update, context)
|
||||
update.message.reply_text.assert_called_once_with("Updated")
|
||||
mock_t.assert_called_with("en", "refresh_pin.updated")
|
||||
|
||||
@@ -410,12 +699,13 @@ async def test_refresh_pin_cmd_group_no_message_replies_no_message():
|
||||
context = MagicMock()
|
||||
|
||||
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||
with patch.object(
|
||||
mod, "_refresh_pin_for_chat", AsyncMock(return_value="no_message")
|
||||
):
|
||||
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
||||
mock_t.return_value = "No message"
|
||||
await mod.refresh_pin_cmd(update, context)
|
||||
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
||||
with patch.object(
|
||||
mod, "_refresh_pin_for_chat", AsyncMock(return_value="no_message")
|
||||
):
|
||||
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
||||
mock_t.return_value = "No message"
|
||||
await mod.refresh_pin_cmd(update, context)
|
||||
update.message.reply_text.assert_called_once_with("No message")
|
||||
mock_t.assert_called_with("en", "refresh_pin.no_message")
|
||||
|
||||
@@ -433,16 +723,42 @@ async def test_refresh_pin_cmd_group_edit_raises_replies_failed():
|
||||
context = MagicMock()
|
||||
|
||||
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||
with patch.object(
|
||||
mod, "_refresh_pin_for_chat", AsyncMock(return_value="failed")
|
||||
):
|
||||
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
||||
mock_t.return_value = "Failed"
|
||||
await mod.refresh_pin_cmd(update, context)
|
||||
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
||||
with patch.object(
|
||||
mod, "_refresh_pin_for_chat", AsyncMock(return_value="failed")
|
||||
):
|
||||
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
||||
mock_t.return_value = "Failed"
|
||||
await mod.refresh_pin_cmd(update, context)
|
||||
update.message.reply_text.assert_called_once_with("Failed")
|
||||
mock_t.assert_called_with("en", "refresh_pin.failed")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_pin_cmd_untrusted_group_rejects():
|
||||
"""refresh_pin_cmd in untrusted group -> reply group.not_trusted, _refresh_pin_for_chat not called."""
|
||||
update = MagicMock()
|
||||
update.message = MagicMock()
|
||||
update.message.reply_text = AsyncMock()
|
||||
update.effective_chat = MagicMock()
|
||||
update.effective_chat.type = "group"
|
||||
update.effective_chat.id = 100
|
||||
update.effective_user = MagicMock()
|
||||
context = MagicMock()
|
||||
|
||||
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||
with patch.object(mod, "_sync_is_trusted", return_value=False):
|
||||
with patch.object(
|
||||
mod, "_refresh_pin_for_chat", AsyncMock()
|
||||
) as mock_refresh:
|
||||
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
||||
mock_t.return_value = "Not authorized"
|
||||
await mod.refresh_pin_cmd(update, context)
|
||||
update.message.reply_text.assert_called_once_with("Not authorized")
|
||||
mock_t.assert_called_with("en", "group.not_trusted")
|
||||
mock_refresh.assert_not_called()
|
||||
|
||||
|
||||
# --- my_chat_member_handler ---
|
||||
|
||||
|
||||
@@ -491,12 +807,80 @@ async def test_my_chat_member_handler_bot_added_sends_pins_and_schedules():
|
||||
context.application.job_queue.run_once = MagicMock()
|
||||
|
||||
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||
with patch.object(mod, "_get_duty_message_text_sync", return_value="Duty text"):
|
||||
with patch.object(mod, "_sync_save_pin"):
|
||||
with patch.object(mod, "_get_next_shift_end_sync", return_value=None):
|
||||
with patch.object(mod, "_schedule_next_update", AsyncMock()):
|
||||
await mod.my_chat_member_handler(update, context)
|
||||
context.bot.send_message.assert_called_once_with(chat_id=200, text="Duty text")
|
||||
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
||||
with patch.object(
|
||||
mod, "_get_duty_message_text_sync", return_value="Duty text"
|
||||
):
|
||||
with patch.object(mod, "_sync_save_pin"):
|
||||
with patch.object(
|
||||
mod, "_get_next_shift_end_sync", return_value=None
|
||||
):
|
||||
with patch.object(mod, "_schedule_next_update", AsyncMock()):
|
||||
await mod.my_chat_member_handler(update, context)
|
||||
context.bot.send_message.assert_called_once_with(
|
||||
chat_id=200, text="Duty text", reply_markup=None
|
||||
)
|
||||
context.bot.pin_chat_message.assert_called_once_with(
|
||||
chat_id=200, message_id=42, disable_notification=True
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_my_chat_member_handler_untrusted_group_does_not_send_duty():
|
||||
"""my_chat_member_handler: bot added to untrusted group -> send group.not_trusted only, no duty message/pin/schedule."""
|
||||
update = _make_my_chat_member_update(
|
||||
old_status=ChatMemberStatus.LEFT,
|
||||
new_status=ChatMemberStatus.ADMINISTRATOR,
|
||||
chat_id=200,
|
||||
bot_id=999,
|
||||
)
|
||||
context = MagicMock()
|
||||
context.bot = MagicMock()
|
||||
context.bot.id = 999
|
||||
context.bot.send_message = AsyncMock()
|
||||
|
||||
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||
with patch.object(mod, "_sync_is_trusted", return_value=False):
|
||||
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
||||
mock_t.return_value = "Not authorized"
|
||||
await mod.my_chat_member_handler(update, context)
|
||||
context.bot.send_message.assert_called_once_with(chat_id=200, text="Not authorized")
|
||||
mock_t.assert_called_with("en", "group.not_trusted")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_my_chat_member_handler_trusted_group_sends_duty():
|
||||
"""my_chat_member_handler: bot added to trusted group -> send duty, pin, schedule (same as test_my_chat_member_handler_bot_added_sends_pins_and_schedules)."""
|
||||
update = _make_my_chat_member_update(
|
||||
old_status=ChatMemberStatus.LEFT,
|
||||
new_status=ChatMemberStatus.ADMINISTRATOR,
|
||||
chat_id=200,
|
||||
bot_id=999,
|
||||
)
|
||||
context = MagicMock()
|
||||
context.bot = MagicMock()
|
||||
context.bot.id = 999
|
||||
context.bot.send_message = AsyncMock(return_value=MagicMock(message_id=42))
|
||||
context.bot.pin_chat_message = AsyncMock()
|
||||
context.application = MagicMock()
|
||||
context.application.job_queue = MagicMock()
|
||||
context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[])
|
||||
context.application.job_queue.run_once = MagicMock()
|
||||
|
||||
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
||||
with patch.object(
|
||||
mod, "_get_duty_message_text_sync", return_value="Duty text"
|
||||
):
|
||||
with patch.object(mod, "_sync_save_pin"):
|
||||
with patch.object(
|
||||
mod, "_get_next_shift_end_sync", return_value=None
|
||||
):
|
||||
with patch.object(mod, "_schedule_next_update", AsyncMock()):
|
||||
await mod.my_chat_member_handler(update, context)
|
||||
context.bot.send_message.assert_called_once_with(
|
||||
chat_id=200, text="Duty text", reply_markup=None
|
||||
)
|
||||
context.bot.pin_chat_message.assert_called_once_with(
|
||||
chat_id=200, message_id=42, disable_notification=True
|
||||
)
|
||||
@@ -522,13 +906,18 @@ async def test_my_chat_member_handler_pin_raises_sends_could_not_pin():
|
||||
context.application.job_queue.run_once = MagicMock()
|
||||
|
||||
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||
with patch.object(mod, "_get_duty_message_text_sync", return_value="Duty"):
|
||||
with patch.object(mod, "_sync_save_pin"):
|
||||
with patch.object(mod, "_get_next_shift_end_sync", return_value=None):
|
||||
with patch.object(mod, "_schedule_next_update", AsyncMock()):
|
||||
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
||||
mock_t.return_value = "Make me admin to pin"
|
||||
await mod.my_chat_member_handler(update, context)
|
||||
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
||||
with patch.object(mod, "_get_duty_message_text_sync", return_value="Duty"):
|
||||
with patch.object(mod, "_sync_save_pin"):
|
||||
with patch.object(
|
||||
mod, "_get_next_shift_end_sync", return_value=None
|
||||
):
|
||||
with patch.object(mod, "_schedule_next_update", AsyncMock()):
|
||||
with patch(
|
||||
"duty_teller.handlers.group_duty_pin.t"
|
||||
) as mock_t:
|
||||
mock_t.return_value = "Make me admin to pin"
|
||||
await mod.my_chat_member_handler(update, context)
|
||||
assert context.bot.send_message.call_count >= 2
|
||||
pin_hint_calls = [
|
||||
c
|
||||
@@ -563,8 +952,8 @@ async def test_my_chat_member_handler_bot_removed_deletes_pin_and_jobs():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_restore_group_pin_jobs_calls_schedule_for_each_chat():
|
||||
"""restore_group_pin_jobs: for each chat_id from _get_all_pin_chat_ids_sync, calls _schedule_next_update."""
|
||||
async def test_restore_group_pin_jobs_calls_schedule_for_each_chat_with_jitter():
|
||||
"""restore_group_pin_jobs: for each chat_id calls _schedule_next_update with jitter_seconds=60."""
|
||||
application = MagicMock()
|
||||
application.job_queue = MagicMock()
|
||||
application.job_queue.get_jobs_by_name = MagicMock(return_value=[])
|
||||
@@ -577,5 +966,198 @@ async def test_restore_group_pin_jobs_calls_schedule_for_each_chat():
|
||||
) as mock_schedule:
|
||||
await mod.restore_group_pin_jobs(application)
|
||||
assert mock_schedule.call_count == 2
|
||||
mock_schedule.assert_any_call(application, 10, None)
|
||||
mock_schedule.assert_any_call(application, 20, None)
|
||||
mock_schedule.assert_any_call(application, 10, None, jitter_seconds=60.0)
|
||||
mock_schedule.assert_any_call(application, 20, None, jitter_seconds=60.0)
|
||||
|
||||
|
||||
# --- _refresh_pin_for_chat untrusted ---
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_pin_for_chat_untrusted_removes_pin():
|
||||
"""_refresh_pin_for_chat: when group not trusted -> delete_pin, remove job, unpin/delete message, return untrusted."""
|
||||
context = MagicMock()
|
||||
context.bot = MagicMock()
|
||||
context.bot.unpin_chat_message = AsyncMock()
|
||||
context.bot.delete_message = AsyncMock()
|
||||
context.application = MagicMock()
|
||||
context.application.job_queue = MagicMock()
|
||||
mock_job = MagicMock()
|
||||
context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[mock_job])
|
||||
|
||||
with patch.object(mod, "_sync_is_trusted", return_value=False):
|
||||
with patch.object(mod, "_sync_get_message_id", return_value=11):
|
||||
with patch.object(mod, "_sync_delete_pin") as mock_delete_pin:
|
||||
result = await mod._refresh_pin_for_chat(context, 100)
|
||||
assert result == "untrusted"
|
||||
mock_delete_pin.assert_called_once_with(100)
|
||||
context.application.job_queue.get_jobs_by_name.assert_called_once_with(
|
||||
"duty_pin_100"
|
||||
)
|
||||
mock_job.schedule_removal.assert_called_once()
|
||||
context.bot.unpin_chat_message.assert_called_once_with(chat_id=100)
|
||||
context.bot.delete_message.assert_called_once_with(chat_id=100, message_id=11)
|
||||
|
||||
|
||||
# --- trust_group_cmd / untrust_group_cmd ---
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trust_group_cmd_non_admin_rejects():
|
||||
"""trust_group_cmd: non-admin -> reply import.admin_only."""
|
||||
update = MagicMock()
|
||||
update.message = MagicMock()
|
||||
update.message.reply_text = AsyncMock()
|
||||
update.effective_chat = MagicMock()
|
||||
update.effective_chat.type = "group"
|
||||
update.effective_chat.id = 100
|
||||
update.effective_user = MagicMock()
|
||||
update.effective_user.id = 111
|
||||
context = MagicMock()
|
||||
|
||||
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||
with patch.object(mod, "is_admin_async", AsyncMock(return_value=False)):
|
||||
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
||||
mock_t.return_value = "Admin only"
|
||||
await mod.trust_group_cmd(update, context)
|
||||
update.message.reply_text.assert_called_once_with("Admin only")
|
||||
mock_t.assert_called_with("en", "import.admin_only")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trust_group_cmd_admin_adds_group():
|
||||
"""trust_group_cmd: admin in group, group not yet trusted -> _sync_trust_group, reply added, then send+pin if no pin."""
|
||||
update = MagicMock()
|
||||
update.message = MagicMock()
|
||||
update.message.reply_text = AsyncMock()
|
||||
update.effective_chat = MagicMock()
|
||||
update.effective_chat.type = "group"
|
||||
update.effective_chat.id = 100
|
||||
update.effective_user = MagicMock()
|
||||
update.effective_user.id = 111
|
||||
context = MagicMock()
|
||||
context.bot = MagicMock()
|
||||
new_msg = MagicMock()
|
||||
new_msg.message_id = 50
|
||||
context.bot.send_message = AsyncMock(return_value=new_msg)
|
||||
context.bot.pin_chat_message = AsyncMock()
|
||||
context.application = MagicMock()
|
||||
context.application.job_queue = MagicMock()
|
||||
context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[])
|
||||
context.application.job_queue.run_once = MagicMock()
|
||||
|
||||
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||
with patch.object(mod, "is_admin_async", AsyncMock(return_value=True)):
|
||||
with patch.object(mod, "_sync_trust_group", return_value=False):
|
||||
with patch.object(mod, "_sync_get_message_id", return_value=None):
|
||||
with patch.object(
|
||||
mod, "_get_duty_message_text_sync", return_value="Duty text"
|
||||
):
|
||||
with patch.object(mod, "_sync_save_pin") as mock_save:
|
||||
with patch.object(
|
||||
mod, "_get_next_shift_end_sync", return_value=None
|
||||
):
|
||||
with patch.object(
|
||||
mod, "_schedule_next_update", AsyncMock()
|
||||
):
|
||||
with patch(
|
||||
"duty_teller.handlers.group_duty_pin.t"
|
||||
) as mock_t:
|
||||
mock_t.return_value = "Added"
|
||||
with patch.object(
|
||||
config, "DUTY_PIN_NOTIFY", False
|
||||
):
|
||||
await mod.trust_group_cmd(update, context)
|
||||
update.message.reply_text.assert_any_call("Added")
|
||||
mock_t.assert_any_call("en", "trust_group.added")
|
||||
context.bot.send_message.assert_called_once_with(
|
||||
chat_id=100, text="Duty text", reply_markup=None
|
||||
)
|
||||
context.bot.pin_chat_message.assert_called_once_with(
|
||||
chat_id=100, message_id=50, disable_notification=True
|
||||
)
|
||||
mock_save.assert_called_once_with(100, 50)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trust_group_cmd_admin_already_trusted_replies_already_trusted():
|
||||
"""trust_group_cmd: admin, group already trusted -> reply already_trusted, no send/pin."""
|
||||
update = MagicMock()
|
||||
update.message = MagicMock()
|
||||
update.message.reply_text = AsyncMock()
|
||||
update.effective_chat = MagicMock()
|
||||
update.effective_chat.type = "group"
|
||||
update.effective_chat.id = 100
|
||||
update.effective_user = MagicMock()
|
||||
update.effective_user.id = 111
|
||||
context = MagicMock()
|
||||
context.bot = MagicMock()
|
||||
context.bot.send_message = AsyncMock()
|
||||
|
||||
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||
with patch.object(mod, "is_admin_async", AsyncMock(return_value=True)):
|
||||
with patch.object(mod, "_sync_trust_group", return_value=True):
|
||||
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
||||
mock_t.return_value = "Already trusted"
|
||||
await mod.trust_group_cmd(update, context)
|
||||
update.message.reply_text.assert_called_once_with("Already trusted")
|
||||
mock_t.assert_called_with("en", "trust_group.already_trusted")
|
||||
context.bot.send_message.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_untrust_group_cmd_removes_group():
|
||||
"""untrust_group_cmd: admin, trusted group with pin -> remove from trusted, delete pin, remove job, unpin/delete message, reply removed."""
|
||||
update = MagicMock()
|
||||
update.message = MagicMock()
|
||||
update.message.reply_text = AsyncMock()
|
||||
update.effective_chat = MagicMock()
|
||||
update.effective_chat.type = "group"
|
||||
update.effective_chat.id = 100
|
||||
update.effective_user = MagicMock()
|
||||
update.effective_user.id = 111
|
||||
context = MagicMock()
|
||||
context.bot = MagicMock()
|
||||
context.bot.unpin_chat_message = AsyncMock()
|
||||
context.bot.delete_message = AsyncMock()
|
||||
context.application = MagicMock()
|
||||
mock_job = MagicMock()
|
||||
context.application.job_queue = MagicMock()
|
||||
context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[mock_job])
|
||||
|
||||
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||
with patch.object(mod, "is_admin_async", AsyncMock(return_value=True)):
|
||||
with patch.object(mod, "_sync_untrust_group", return_value=(True, 99)):
|
||||
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
||||
mock_t.return_value = "Removed"
|
||||
await mod.untrust_group_cmd(update, context)
|
||||
update.message.reply_text.assert_called_once_with("Removed")
|
||||
mock_t.assert_called_with("en", "untrust_group.removed")
|
||||
context.application.job_queue.get_jobs_by_name.assert_called_once_with(
|
||||
"duty_pin_100"
|
||||
)
|
||||
mock_job.schedule_removal.assert_called_once()
|
||||
context.bot.unpin_chat_message.assert_called_once_with(chat_id=100)
|
||||
context.bot.delete_message.assert_called_once_with(chat_id=100, message_id=99)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_untrust_group_cmd_not_trusted_replies_not_trusted():
|
||||
"""untrust_group_cmd: group not in trusted list -> reply not_trusted."""
|
||||
update = MagicMock()
|
||||
update.message = MagicMock()
|
||||
update.message.reply_text = AsyncMock()
|
||||
update.effective_chat = MagicMock()
|
||||
update.effective_chat.type = "group"
|
||||
update.effective_chat.id = 100
|
||||
update.effective_user = MagicMock()
|
||||
context = MagicMock()
|
||||
|
||||
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||
with patch.object(mod, "is_admin_async", AsyncMock(return_value=True)):
|
||||
with patch.object(mod, "_sync_untrust_group", return_value=(False, None)):
|
||||
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
||||
mock_t.return_value = "Not trusted"
|
||||
await mod.untrust_group_cmd(update, context)
|
||||
update.message.reply_text.assert_called_once_with("Not trusted")
|
||||
mock_t.assert_called_with("en", "untrust_group.not_trusted")
|
||||
|
||||
@@ -278,8 +278,8 @@ async def test_handle_duty_schedule_document_non_json_replies_need_json():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_duty_schedule_document_parse_error_replies_and_clears_user_data():
|
||||
"""handle_duty_schedule_document: parse_duty_schedule raises DutyScheduleParseError -> reply, clear user_data."""
|
||||
async def test_handle_duty_schedule_document_parse_error_replies_generic_and_clears_user_data():
|
||||
"""handle_duty_schedule_document: DutyScheduleParseError -> reply generic message (no str(e)), clear user_data."""
|
||||
message = MagicMock()
|
||||
message.document = _make_document()
|
||||
message.reply_text = AsyncMock()
|
||||
@@ -299,17 +299,17 @@ async def test_handle_duty_schedule_document_parse_error_replies_and_clears_user
|
||||
with patch.object(mod, "parse_duty_schedule") as mock_parse:
|
||||
mock_parse.side_effect = DutyScheduleParseError("Bad JSON")
|
||||
with patch.object(mod, "t") as mock_t:
|
||||
mock_t.return_value = "Parse error: Bad JSON"
|
||||
mock_t.return_value = "The file could not be parsed."
|
||||
await mod.handle_duty_schedule_document(update, context)
|
||||
message.reply_text.assert_called_once_with("Parse error: Bad JSON")
|
||||
mock_t.assert_called_with("en", "import.parse_error", error="Bad JSON")
|
||||
message.reply_text.assert_called_once_with("The file could not be parsed.")
|
||||
mock_t.assert_called_with("en", "import.parse_error_generic")
|
||||
assert "awaiting_duty_schedule_file" not in context.user_data
|
||||
assert "handover_utc_time" not in context.user_data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_duty_schedule_document_import_error_replies_and_clears_user_data():
|
||||
"""handle_duty_schedule_document: run_import in executor raises -> reply import_error, clear user_data."""
|
||||
async def test_handle_duty_schedule_document_import_error_replies_generic_and_clears_user_data():
|
||||
"""handle_duty_schedule_document: run_import raises -> reply generic message (no str(e)), clear user_data."""
|
||||
message = MagicMock()
|
||||
message.document = _make_document()
|
||||
message.reply_text = AsyncMock()
|
||||
@@ -340,10 +340,10 @@ async def test_handle_duty_schedule_document_import_error_replies_and_clears_use
|
||||
with patch.object(mod, "run_import") as mock_run:
|
||||
mock_run.side_effect = ValueError("DB error")
|
||||
with patch.object(mod, "t") as mock_t:
|
||||
mock_t.return_value = "Import error: DB error"
|
||||
mock_t.return_value = "Import failed. Please try again."
|
||||
await mod.handle_duty_schedule_document(update, context)
|
||||
message.reply_text.assert_called_once_with("Import error: DB error")
|
||||
mock_t.assert_called_with("en", "import.import_error", error="DB error")
|
||||
message.reply_text.assert_called_once_with("Import failed. Please try again.")
|
||||
mock_t.assert_called_with("en", "import.import_error_generic")
|
||||
assert "awaiting_duty_schedule_file" not in context.user_data
|
||||
assert "handover_utc_time" not in context.user_data
|
||||
|
||||
|
||||
@@ -1,48 +1,46 @@
|
||||
"""Unit tests for duty_teller.i18n: get_lang, t, fallback to en."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import duty_teller.config as config
|
||||
from duty_teller.i18n import get_lang, t
|
||||
|
||||
|
||||
def test_get_lang_none_returns_en():
|
||||
assert get_lang(None) == "en"
|
||||
def test_get_lang_always_returns_default_language():
|
||||
"""get_lang ignores user and always returns config.DEFAULT_LANGUAGE."""
|
||||
assert get_lang(None) == config.DEFAULT_LANGUAGE
|
||||
user_ru = MagicMock()
|
||||
user_ru.language_code = "ru"
|
||||
assert get_lang(user_ru) == config.DEFAULT_LANGUAGE
|
||||
user_en = MagicMock()
|
||||
user_en.language_code = "en"
|
||||
assert get_lang(user_en) == config.DEFAULT_LANGUAGE
|
||||
user_any = MagicMock(spec=[])
|
||||
assert get_lang(user_any) == config.DEFAULT_LANGUAGE
|
||||
|
||||
|
||||
def test_get_lang_ru_returns_ru():
|
||||
user = MagicMock()
|
||||
user.language_code = "ru"
|
||||
assert get_lang(user) == "ru"
|
||||
def test_get_lang_returns_ru_when_default_language_is_ru():
|
||||
"""When DEFAULT_LANGUAGE is ru, get_lang returns 'ru' regardless of user."""
|
||||
with patch("duty_teller.i18n.core.config") as mock_cfg:
|
||||
mock_cfg.DEFAULT_LANGUAGE = "ru"
|
||||
from duty_teller.i18n.core import get_lang as core_get_lang
|
||||
|
||||
assert core_get_lang(None) == "ru"
|
||||
user = MagicMock()
|
||||
user.language_code = "en"
|
||||
assert core_get_lang(user) == "ru"
|
||||
|
||||
|
||||
def test_get_lang_ru_ru_returns_ru():
|
||||
user = MagicMock()
|
||||
user.language_code = "ru-RU"
|
||||
assert get_lang(user) == "ru"
|
||||
def test_get_lang_returns_en_when_default_language_is_en():
|
||||
"""When DEFAULT_LANGUAGE is en, get_lang returns 'en' regardless of user."""
|
||||
with patch("duty_teller.i18n.core.config") as mock_cfg:
|
||||
mock_cfg.DEFAULT_LANGUAGE = "en"
|
||||
from duty_teller.i18n.core import get_lang as core_get_lang
|
||||
|
||||
|
||||
def test_get_lang_en_returns_en():
|
||||
user = MagicMock()
|
||||
user.language_code = "en"
|
||||
assert get_lang(user) == "en"
|
||||
|
||||
|
||||
def test_get_lang_uk_returns_en():
|
||||
user = MagicMock()
|
||||
user.language_code = "uk"
|
||||
assert get_lang(user) == "en"
|
||||
|
||||
|
||||
def test_get_lang_empty_returns_en():
|
||||
user = MagicMock()
|
||||
user.language_code = ""
|
||||
assert get_lang(user) == "en"
|
||||
|
||||
|
||||
def test_get_lang_missing_attr_returns_en():
|
||||
user = MagicMock(spec=[]) # no language_code
|
||||
assert get_lang(user) == "en"
|
||||
assert core_get_lang(None) == "en"
|
||||
user = MagicMock()
|
||||
user.language_code = "ru"
|
||||
assert core_get_lang(user) == "en"
|
||||
|
||||
|
||||
def test_t_en_start_greeting():
|
||||
|
||||
@@ -77,7 +77,7 @@ def test_import_creates_users_and_duties(db_url):
|
||||
assert "2026-02-16T06:00:00Z" in starts
|
||||
assert "2026-02-17T06:00:00Z" in starts
|
||||
assert "2026-02-18T06:00:00Z" in starts
|
||||
for d, _ in duties:
|
||||
for d, *_ in duties:
|
||||
assert d.event_type == "duty"
|
||||
|
||||
|
||||
|
||||
@@ -24,14 +24,23 @@ def test_main_builds_app_and_starts_thread():
|
||||
mock_scope.return_value.__exit__.return_value = None
|
||||
|
||||
with patch("duty_teller.run.require_bot_token"):
|
||||
with patch("duty_teller.run.ApplicationBuilder", return_value=mock_builder):
|
||||
with patch("duty_teller.run.register_handlers") as mock_register:
|
||||
with patch("duty_teller.run.threading.Thread") as mock_thread_class:
|
||||
with patch("duty_teller.db.session.session_scope", mock_scope):
|
||||
mock_thread = MagicMock()
|
||||
mock_thread_class.return_value = mock_thread
|
||||
with pytest.raises(KeyboardInterrupt):
|
||||
main()
|
||||
with patch("duty_teller.run.config") as mock_cfg:
|
||||
mock_cfg.MINI_APP_SKIP_AUTH = False
|
||||
mock_cfg.HTTP_HOST = "127.0.0.1"
|
||||
mock_cfg.HTTP_PORT = 8080
|
||||
with patch("duty_teller.run.ApplicationBuilder", return_value=mock_builder):
|
||||
with patch("duty_teller.run.register_handlers") as mock_register:
|
||||
with patch("duty_teller.run.threading.Thread") as mock_thread_class:
|
||||
with patch(
|
||||
"duty_teller.run._wait_for_http_ready", return_value=True
|
||||
):
|
||||
with patch(
|
||||
"duty_teller.db.session.session_scope", mock_scope
|
||||
):
|
||||
mock_thread = MagicMock()
|
||||
mock_thread_class.return_value = mock_thread
|
||||
with pytest.raises(KeyboardInterrupt):
|
||||
main()
|
||||
mock_register.assert_called_once_with(mock_app)
|
||||
mock_builder.token.assert_called_once()
|
||||
mock_thread.start.assert_called_once()
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
"""Tests for duty_teller.api.telegram_auth.validate_init_data and validate_init_data_with_reason."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import duty_teller.config as config
|
||||
from duty_teller.api.telegram_auth import (
|
||||
validate_init_data,
|
||||
validate_init_data_with_reason,
|
||||
@@ -52,7 +55,7 @@ def test_user_without_username_returns_none_from_validate_init_data():
|
||||
|
||||
|
||||
def test_user_without_username_but_with_id_succeeds_with_reason():
|
||||
"""With validate_init_data_with_reason, valid user.id is enough; username may be None."""
|
||||
"""With validate_init_data_with_reason, valid user.id is enough; lang is DEFAULT_LANGUAGE."""
|
||||
bot_token = "123:ABC"
|
||||
user = {"id": 456, "first_name": "Test", "language_code": "ru"}
|
||||
init_data = make_init_data(user, bot_token)
|
||||
@@ -62,11 +65,11 @@ def test_user_without_username_but_with_id_succeeds_with_reason():
|
||||
assert telegram_user_id == 456
|
||||
assert username is None
|
||||
assert reason == "ok"
|
||||
assert lang == "ru"
|
||||
assert lang == config.DEFAULT_LANGUAGE
|
||||
|
||||
|
||||
def test_user_without_id_returns_no_user_id():
|
||||
"""When user object exists but has no 'id', return no_user_id."""
|
||||
"""When user object exists but has no 'id', return no_user_id; lang is DEFAULT_LANGUAGE."""
|
||||
bot_token = "123:ABC"
|
||||
user = {"first_name": "Test"} # no id
|
||||
init_data = make_init_data(user, bot_token)
|
||||
@@ -76,7 +79,17 @@ def test_user_without_id_returns_no_user_id():
|
||||
assert telegram_user_id is None
|
||||
assert username is None
|
||||
assert reason == "no_user_id"
|
||||
assert lang == "en"
|
||||
assert lang == config.DEFAULT_LANGUAGE
|
||||
|
||||
|
||||
def test_validate_init_data_with_reason_returns_default_language_ignoring_user_lang():
|
||||
"""Returned lang is always config.DEFAULT_LANGUAGE, not user.language_code."""
|
||||
with patch("duty_teller.api.telegram_auth.config.DEFAULT_LANGUAGE", "ru"):
|
||||
user = {"id": 1, "first_name": "U", "language_code": "en"}
|
||||
init_data = make_init_data(user, "123:ABC")
|
||||
_, _, reason, lang = validate_init_data_with_reason(init_data, "123:ABC")
|
||||
assert reason == "ok"
|
||||
assert lang == "ru"
|
||||
|
||||
|
||||
def test_empty_init_data_returns_none():
|
||||
|
||||
84
tests/test_trusted_groups_repository.py
Normal file
84
tests/test_trusted_groups_repository.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""Unit tests for trusted_groups repository functions."""
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from duty_teller.db.models import Base
|
||||
from duty_teller.db.repository import (
|
||||
is_trusted_group,
|
||||
add_trusted_group,
|
||||
remove_trusted_group,
|
||||
get_all_trusted_group_ids,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session():
|
||||
"""In-memory SQLite session with all tables (including trusted_groups)."""
|
||||
engine = create_engine(
|
||||
"sqlite:///:memory:", connect_args={"check_same_thread": False}
|
||||
)
|
||||
Base.metadata.create_all(engine)
|
||||
Session = sessionmaker(bind=engine, autocommit=False, autoflush=False)
|
||||
s = Session()
|
||||
try:
|
||||
yield s
|
||||
finally:
|
||||
s.close()
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_is_trusted_group_empty_returns_false(session):
|
||||
"""is_trusted_group returns False when no record exists."""
|
||||
assert is_trusted_group(session, 100) is False
|
||||
assert is_trusted_group(session, 200) is False
|
||||
|
||||
|
||||
def test_add_trusted_group_creates_record(session):
|
||||
"""add_trusted_group creates a record and returns TrustedGroup."""
|
||||
record = add_trusted_group(session, 100, added_by_user_id=12345)
|
||||
assert record.chat_id == 100
|
||||
assert record.added_by_user_id == 12345
|
||||
assert record.added_at is not None
|
||||
|
||||
|
||||
def test_is_trusted_group_after_add_returns_true(session):
|
||||
"""is_trusted_group returns True after add_trusted_group."""
|
||||
add_trusted_group(session, 100)
|
||||
assert is_trusted_group(session, 100) is True
|
||||
assert is_trusted_group(session, 101) is False
|
||||
|
||||
|
||||
def test_add_trusted_group_without_added_by_user_id(session):
|
||||
"""add_trusted_group accepts added_by_user_id None."""
|
||||
record = add_trusted_group(session, 200, added_by_user_id=None)
|
||||
assert record.chat_id == 200
|
||||
assert record.added_by_user_id is None
|
||||
|
||||
|
||||
def test_remove_trusted_group_removes_record(session):
|
||||
"""remove_trusted_group removes the record."""
|
||||
add_trusted_group(session, 100)
|
||||
assert is_trusted_group(session, 100) is True
|
||||
remove_trusted_group(session, 100)
|
||||
assert is_trusted_group(session, 100) is False
|
||||
|
||||
|
||||
def test_remove_trusted_group_idempotent(session):
|
||||
"""remove_trusted_group on non-existent chat_id does not raise."""
|
||||
remove_trusted_group(session, 999)
|
||||
|
||||
|
||||
def test_get_all_trusted_group_ids_empty(session):
|
||||
"""get_all_trusted_group_ids returns empty list when no trusted groups."""
|
||||
assert get_all_trusted_group_ids(session) == []
|
||||
|
||||
|
||||
def test_get_all_trusted_group_ids_returns_added_chats(session):
|
||||
"""get_all_trusted_group_ids returns all trusted chat_ids."""
|
||||
add_trusted_group(session, 10)
|
||||
add_trusted_group(session, 20)
|
||||
add_trusted_group(session, 30)
|
||||
ids = get_all_trusted_group_ids(session)
|
||||
assert set(ids) == {10, 20, 30}
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Unit tests for utils (dates, user, handover)."""
|
||||
|
||||
from datetime import date
|
||||
from datetime import date, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -62,6 +62,23 @@ def test_validate_date_range_from_after_to():
|
||||
assert exc_info.value.kind == "from_after_to"
|
||||
|
||||
|
||||
def test_validate_date_range_too_large():
|
||||
"""Range longer than MAX_DATE_RANGE_DAYS raises range_too_large."""
|
||||
from duty_teller.utils.dates import MAX_DATE_RANGE_DAYS
|
||||
|
||||
# from 2023-01-01 to 2025-06-01 is more than 731 days
|
||||
with pytest.raises(DateRangeValidationError) as exc_info:
|
||||
validate_date_range("2023-01-01", "2025-06-01")
|
||||
assert exc_info.value.kind == "range_too_large"
|
||||
|
||||
# Exactly MAX_DATE_RANGE_DAYS + 1 day
|
||||
from_d = date(2024, 1, 1)
|
||||
to_d = from_d + timedelta(days=MAX_DATE_RANGE_DAYS + 1)
|
||||
with pytest.raises(DateRangeValidationError) as exc_info:
|
||||
validate_date_range(from_d.isoformat(), to_d.isoformat())
|
||||
assert exc_info.value.kind == "range_too_large"
|
||||
|
||||
|
||||
# --- user ---
|
||||
|
||||
|
||||
|
||||
41
webapp-next/.gitignore
vendored
Normal file
41
webapp-next/.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
36
webapp-next/README.md
Normal file
36
webapp-next/README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
23
webapp-next/components.json
Normal file
23
webapp-next/components.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "zinc",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {}
|
||||
}
|
||||
18
webapp-next/eslint.config.mjs
Normal file
18
webapp-next/eslint.config.mjs
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
10
webapp-next/next.config.ts
Normal file
10
webapp-next/next.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "export",
|
||||
basePath: "/app",
|
||||
trailingSlash: true,
|
||||
images: { unoptimized: true },
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
13495
webapp-next/package-lock.json
generated
Normal file
13495
webapp-next/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
45
webapp-next/package.json
Normal file
45
webapp-next/package.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "webapp-next",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint src/",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@telegram-apps/sdk-react": "^3.3.9",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.576.0",
|
||||
"next": "16.1.6",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@vitejs/plugin-react": "^4.5.2",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"jsdom": "^26.0.0",
|
||||
"shadcn": "^3.8.5",
|
||||
"tailwindcss": "^4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
7
webapp-next/postcss.config.mjs
Normal file
7
webapp-next/postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
BIN
webapp-next/src/app/favicon.ico
Normal file
BIN
webapp-next/src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
52
webapp-next/src/app/global-error.tsx
Normal file
52
webapp-next/src/app/global-error.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Next.js root error boundary. Replaces the root layout when an unhandled error occurs.
|
||||
* Must define its own html/body. For most runtime errors the in-app AppErrorBoundary is used.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import "./globals.css";
|
||||
import { getLang, translate } from "@/i18n/messages";
|
||||
|
||||
export default function GlobalError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
const lang = getLang();
|
||||
return (
|
||||
<html
|
||||
lang={lang === "ru" ? "ru" : "en"}
|
||||
data-theme="dark"
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<head>
|
||||
{/* Same theme detection as layout: hash / Telegram / prefers-color-scheme → data-theme */}
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `(function(){var scheme='dark';var root=document.documentElement;var hash=typeof location!=='undefined'&&location.hash?location.hash.slice(1):'';if(hash){var params={};hash.split('&').forEach(function(p){var i=p.indexOf('=');if(i===-1)return;var k=decodeURIComponent(p.slice(0,i));var v=p.slice(i+1);params[k]=v;});if(params.tgWebAppColorScheme==='light'||params.tgWebAppColorScheme==='dark')scheme=params.tgWebAppColorScheme;if(params.tgWebAppThemeParams){try{var theme=JSON.parse(decodeURIComponent(params.tgWebAppThemeParams));var tgKeys=['bg_color','text_color','hint_color','link_color','button_color','button_text_color','secondary_bg_color','header_bg_color','accent_text_color','destructive_text_color','section_bg_color','section_header_text_color','section_separator_color','subtitle_text_color','bottom_bar_bg_color'];tgKeys.forEach(function(k){var v=theme[k];if(v&&typeof v==='string')root.style.setProperty('--tg-theme-'+k.replace(/_/g,'-'),v);});Object.keys(theme).forEach(function(k){var v=theme[k];if(v&&typeof v==='string')root.style.setProperty('--tg-theme-'+k.replace(/_/g,'-'),v);});}catch(e){}}}var twa=typeof window!=='undefined'&&window.Telegram&&window.Telegram.WebApp;if(scheme!=='light'&&scheme!=='dark'&&twa&&(twa.colorScheme==='light'||twa.colorScheme==='dark'))scheme=twa.colorScheme;if(scheme!=='light'&&scheme!=='dark'){try{var s=getComputedStyle(root).getPropertyValue('--tg-color-scheme').trim();if(s==='light'||s==='dark')scheme=s;}catch(e){} }if(scheme!=='light'&&scheme!=='dark')scheme=window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';root.setAttribute('data-theme',scheme);if(twa){if(twa.setBackgroundColor)twa.setBackgroundColor('bg_color');if(twa.setHeaderColor)twa.setHeaderColor('bg_color');}})();`,
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body className="antialiased">
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-background px-4 text-foreground">
|
||||
<h1 className="text-xl font-semibold">
|
||||
{translate(lang, "error_boundary.message")}
|
||||
</h1>
|
||||
<p className="text-center text-muted-foreground">
|
||||
{translate(lang, "error_boundary.description")}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => reset()}
|
||||
className="rounded-lg bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
{translate(lang, "error_boundary.reload")}
|
||||
</button>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
314
webapp-next/src/app/globals.css
Normal file
314
webapp-next/src/app/globals.css
Normal file
@@ -0,0 +1,314 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
@custom-variant dark (&:is([data-theme="dark"] *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: system-ui, -apple-system, sans-serif;
|
||||
--font-mono: ui-monospace, monospace;
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--radius-2xl: calc(var(--radius) + 8px);
|
||||
--radius-3xl: calc(var(--radius) + 12px);
|
||||
--radius-4xl: calc(var(--radius) + 16px);
|
||||
/* App design tokens (Telegram Mini App) */
|
||||
--color-surface: var(--surface);
|
||||
--color-duty: var(--duty);
|
||||
--color-today: var(--today);
|
||||
--color-unavailable: var(--unavailable);
|
||||
--color-vacation: var(--vacation);
|
||||
--color-error: var(--error);
|
||||
--max-width-app: 420px;
|
||||
}
|
||||
|
||||
/* App design tokens: use Telegram theme vars with dark fallbacks so TG vars apply before data-theme */
|
||||
:root {
|
||||
--bg: var(--tg-theme-bg-color, #17212b);
|
||||
--surface: var(--tg-theme-secondary-bg-color, #232e3c);
|
||||
--text: var(--tg-theme-text-color, #f5f5f5);
|
||||
--muted: var(--tg-theme-hint-color, var(--tg-theme-subtitle-text-color, #708499));
|
||||
--accent: var(--tg-theme-link-color, #6ab3f3);
|
||||
--header-bg: var(--tg-theme-header-bg-color, #232e3c);
|
||||
--card: var(--tg-theme-section-bg-color, var(--surface));
|
||||
--section-header: var(--tg-theme-section-header-text-color, #f5f5f5);
|
||||
--border: var(--tg-theme-section-separator-color, color-mix(in srgb, var(--text) 10%, transparent));
|
||||
--primary: var(--tg-theme-button-color, var(--accent));
|
||||
--primary-foreground: var(--tg-theme-button-text-color, #17212b);
|
||||
--error: var(--tg-theme-destructive-text-color, #e06c75);
|
||||
--accent-text: var(--tg-theme-accent-text-color, #6ab2f2);
|
||||
--duty: #5c9b4a;
|
||||
--today: var(--tg-theme-accent-text-color, var(--tg-theme-link-color, #6ab2f2));
|
||||
--unavailable: #b8860b;
|
||||
--vacation: #5a9bb8;
|
||||
--timeline-date-width: 3.6em;
|
||||
--timeline-track-width: 10px;
|
||||
/* Reusable color-mix tokens (avoid repeating in Tailwind classes). */
|
||||
--surface-hover: color-mix(in srgb, var(--accent) 15%, var(--surface));
|
||||
--surface-hover-10: color-mix(in srgb, var(--accent) 10%, var(--surface));
|
||||
--surface-today-tint: color-mix(in srgb, var(--today) 12%, var(--surface));
|
||||
--surface-muted-tint: color-mix(in srgb, var(--muted) 8%, var(--surface));
|
||||
--today-hover: color-mix(in srgb, var(--bg) 15%, var(--today));
|
||||
--today-border: color-mix(in srgb, var(--today) 35%, transparent);
|
||||
--today-border-selected: color-mix(in srgb, var(--bg) 50%, transparent);
|
||||
--today-gradient-end: color-mix(in srgb, var(--today) 15%, transparent);
|
||||
--muted-fade: color-mix(in srgb, var(--muted) 40%, transparent);
|
||||
--handle-bg: color-mix(in srgb, var(--muted) 80%, var(--text));
|
||||
--indicator-today-duty: color-mix(in srgb, var(--duty) 65%, var(--bg));
|
||||
--indicator-today-unavailable: color-mix(in srgb, var(--unavailable) 65%, var(--bg));
|
||||
--indicator-today-vacation: color-mix(in srgb, var(--vacation) 65%, var(--bg));
|
||||
--indicator-today-events: color-mix(in srgb, var(--accent) 65%, var(--bg));
|
||||
--shadow-card: 0 4px 12px color-mix(in srgb, var(--text) 12%, transparent);
|
||||
--transition-fast: 0.15s;
|
||||
--transition-normal: 0.25s;
|
||||
--ease-out: cubic-bezier(0.32, 0.72, 0, 1);
|
||||
--radius: 0.625rem;
|
||||
--calendar-block-min-height: 260px;
|
||||
/** Minimum height for the 6-row calendar grid so cells stay comfortably large. */
|
||||
--calendar-grid-min-height: 264px;
|
||||
/** Minimum height per calendar row (6 rows × 44px ≈ 264px). */
|
||||
--calendar-row-min-height: 2.75rem;
|
||||
/* Align Tailwind/shadcn semantic tokens with app tokens for Mini App */
|
||||
--background: var(--bg);
|
||||
--foreground: var(--text);
|
||||
--card-foreground: var(--text);
|
||||
--popover: var(--surface);
|
||||
--popover-foreground: var(--text);
|
||||
--secondary: var(--surface);
|
||||
--secondary-foreground: var(--text);
|
||||
--muted-foreground: var(--muted);
|
||||
--accent-foreground: var(--bg);
|
||||
--destructive: var(--error);
|
||||
--input: color-mix(in srgb, var(--text) 15%, transparent);
|
||||
--ring: var(--accent);
|
||||
--chart-1: var(--duty);
|
||||
--chart-2: var(--vacation);
|
||||
--chart-3: var(--unavailable);
|
||||
--chart-4: var(--today);
|
||||
--chart-5: var(--accent);
|
||||
}
|
||||
|
||||
/* Light theme: full Telegram themeParams (14 params) mapping */
|
||||
[data-theme="light"] {
|
||||
--bg: var(--tg-theme-bg-color, #f0f1f3);
|
||||
--surface: var(--tg-theme-secondary-bg-color, #e0e2e6);
|
||||
--text: var(--tg-theme-text-color, #343b58);
|
||||
--muted: var(--tg-theme-hint-color, var(--tg-theme-subtitle-text-color, #6b7089));
|
||||
--accent: var(--tg-theme-link-color, #2e7de0);
|
||||
--header-bg: var(--tg-theme-header-bg-color, #e0e2e6);
|
||||
--card: var(--tg-theme-section-bg-color, #e0e2e6);
|
||||
--section-header: var(--tg-theme-section-header-text-color, #343b58);
|
||||
--border: var(--tg-theme-section-separator-color, color-mix(in srgb, var(--text) 15%, transparent));
|
||||
--primary: var(--tg-theme-button-color, #2e7de0);
|
||||
--primary-foreground: var(--tg-theme-button-text-color, #ffffff);
|
||||
--error: var(--tg-theme-destructive-text-color, #c43b3b);
|
||||
--accent-text: var(--tg-theme-accent-text-color, #2481cc);
|
||||
--duty: #587d0a;
|
||||
--today: var(--tg-theme-accent-text-color, var(--tg-theme-link-color, #2481cc));
|
||||
--unavailable: #b8860b;
|
||||
--vacation: #0d6b9e;
|
||||
}
|
||||
|
||||
/* Dark theme: full Telegram themeParams (14 params) mapping */
|
||||
[data-theme="dark"] {
|
||||
--bg: var(--tg-theme-bg-color, #17212b);
|
||||
--surface: var(--tg-theme-secondary-bg-color, #232e3c);
|
||||
--text: var(--tg-theme-text-color, #f5f5f5);
|
||||
--muted: var(--tg-theme-hint-color, var(--tg-theme-subtitle-text-color, #708499));
|
||||
--accent: var(--tg-theme-link-color, #6ab3f3);
|
||||
--header-bg: var(--tg-theme-header-bg-color, #232e3c);
|
||||
--card: var(--tg-theme-section-bg-color, #232e3c);
|
||||
--section-header: var(--tg-theme-section-header-text-color, #f5f5f5);
|
||||
--border: var(--tg-theme-section-separator-color, color-mix(in srgb, var(--text) 10%, transparent));
|
||||
--primary: var(--tg-theme-button-color, #6ab3f3);
|
||||
--primary-foreground: var(--tg-theme-button-text-color, #17212b);
|
||||
--error: var(--tg-theme-destructive-text-color, #e06c75);
|
||||
--accent-text: var(--tg-theme-accent-text-color, #6ab2f2);
|
||||
--duty: #5c9b4a;
|
||||
--today: var(--tg-theme-accent-text-color, var(--tg-theme-link-color, #6ab2f2));
|
||||
--unavailable: #b8860b;
|
||||
--vacation: #5a9bb8;
|
||||
}
|
||||
|
||||
/* === Layout & base (ported from webapp/css/base.css) */
|
||||
html {
|
||||
scrollbar-gutter: stable;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
html::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Container: max-width, padding, safe area. Use .container for main wrapper if needed. */
|
||||
.container-app {
|
||||
max-width: var(--max-width-app, 420px);
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
padding: 12px;
|
||||
padding-top: 0;
|
||||
padding-bottom: env(safe-area-inset-bottom, 12px);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* Duty list: timeline date cell (non-today) — horizontal line and vertical tick to track */
|
||||
.duty-timeline-date:not(.duty-timeline-date--today)::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 4px;
|
||||
width: calc(100% + var(--timeline-track-width) / 2);
|
||||
height: 2px;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--muted-fade) 0%,
|
||||
var(--muted-fade) 50%,
|
||||
var(--muted) 70%,
|
||||
var(--muted) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.duty-timeline-date:not(.duty-timeline-date--today)::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: calc(100% + (var(--timeline-track-width) / 2) - 1px);
|
||||
bottom: 2px;
|
||||
width: 2px;
|
||||
height: 6px;
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
/* Duty list: timeline date cell (today) — horizontal stripe + vertical tick in today color (same geometry as non-today) */
|
||||
.duty-timeline-date--today::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 5px;
|
||||
width: calc(100% + var(--timeline-track-width) / 2);
|
||||
height: 1px;
|
||||
background: var(--today);
|
||||
}
|
||||
|
||||
.duty-timeline-date--today::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: calc(100% + (var(--timeline-track-width) / 2) - 1px);
|
||||
bottom: 2px;
|
||||
width: 2px;
|
||||
height: 7px;
|
||||
background: var(--today);
|
||||
}
|
||||
|
||||
/* Duty list: current duty card — ensure left stripe uses --today (matches "Today" label and date stripe) */
|
||||
[data-current-duty] .border-l-today {
|
||||
border-left-color: var(--today);
|
||||
border-left-width: 3px;
|
||||
}
|
||||
|
||||
/* Duty list: flip card (front = duty info, back = contacts) */
|
||||
.duty-flip-card {
|
||||
perspective: 600px;
|
||||
}
|
||||
.duty-flip-inner {
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
.duty-flip-front {
|
||||
backface-visibility: hidden;
|
||||
-webkit-backface-visibility: hidden;
|
||||
}
|
||||
.duty-flip-back {
|
||||
backface-visibility: hidden;
|
||||
-webkit-backface-visibility: hidden;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Safe area for Telegram Mini App (notch / status bar). */
|
||||
.pt-safe {
|
||||
padding-top: env(safe-area-inset-top, 0);
|
||||
}
|
||||
|
||||
/* Sticky calendar header: shadow when scrolled (useStickyScroll). */
|
||||
.sticky.is-scrolled {
|
||||
box-shadow: 0 1px 0 0 var(--border);
|
||||
}
|
||||
|
||||
/* Calendar grid: 6 rows with minimum height so cells stay large (restore pre-audit look). */
|
||||
.calendar-grid {
|
||||
grid-template-rows: repeat(6, minmax(var(--calendar-row-min-height), 1fr));
|
||||
}
|
||||
|
||||
/* Current duty card: entrance animation (respects prefers-reduced-motion via global rule). */
|
||||
@keyframes card-appear {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(16px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.current-duty-card {
|
||||
box-shadow: var(--shadow-card);
|
||||
animation: card-appear 0.3s var(--ease-out);
|
||||
}
|
||||
|
||||
.current-duty-card--no-duty {
|
||||
border-top-color: var(--muted);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
}
|
||||
48
webapp-next/src/app/layout.tsx
Normal file
48
webapp-next/src/app/layout.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { TelegramProvider } from "@/components/providers/TelegramProvider";
|
||||
import { AppErrorBoundary } from "@/components/AppErrorBoundary";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Duty Teller",
|
||||
description: "Team duty shift calendar and reminders",
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
viewportFit: "cover",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" data-theme="dark" suppressHydrationWarning>
|
||||
<head>
|
||||
{/* Inline script: theme from hash (tgWebAppColorScheme + all 14 TG themeParams → --tg-theme-*), then data-theme and Mini App colors. */}
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `(function(){var scheme='dark';var root=document.documentElement;var hash=typeof location!=='undefined'&&location.hash?location.hash.slice(1):'';if(hash){var params={};hash.split('&').forEach(function(p){var i=p.indexOf('=');if(i===-1)return;var k=decodeURIComponent(p.slice(0,i));var v=p.slice(i+1);params[k]=v;});if(params.tgWebAppColorScheme==='light'||params.tgWebAppColorScheme==='dark')scheme=params.tgWebAppColorScheme;if(params.tgWebAppThemeParams){try{var theme=JSON.parse(decodeURIComponent(params.tgWebAppThemeParams));var tgKeys=['bg_color','text_color','hint_color','link_color','button_color','button_text_color','secondary_bg_color','header_bg_color','accent_text_color','destructive_text_color','section_bg_color','section_header_text_color','section_separator_color','subtitle_text_color','bottom_bar_bg_color'];tgKeys.forEach(function(k){var v=theme[k];if(v&&typeof v==='string')root.style.setProperty('--tg-theme-'+k.replace(/_/g,'-'),v);});Object.keys(theme).forEach(function(k){var v=theme[k];if(v&&typeof v==='string')root.style.setProperty('--tg-theme-'+k.replace(/_/g,'-'),v);});}catch(e){}}}var twa=typeof window!=='undefined'&&window.Telegram&&window.Telegram.WebApp;if(scheme!=='light'&&scheme!=='dark'&&twa&&(twa.colorScheme==='light'||twa.colorScheme==='dark'))scheme=twa.colorScheme;if(scheme!=='light'&&scheme!=='dark'){try{var s=getComputedStyle(root).getPropertyValue('--tg-color-scheme').trim();if(s==='light'||s==='dark')scheme=s;}catch(e){} }if(scheme!=='light'&&scheme!=='dark')scheme=window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';root.setAttribute('data-theme',scheme);if(twa){if(twa.setBackgroundColor)twa.setBackgroundColor('bg_color');if(twa.setHeaderColor)twa.setHeaderColor('bg_color');}})();`,
|
||||
}}
|
||||
/>
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `(function(){if(typeof window!=='undefined'&&window.__DT_LANG==null)window.__DT_LANG='en';})();`,
|
||||
}}
|
||||
/>
|
||||
<script src="/app/config.js" />
|
||||
</head>
|
||||
<body className="antialiased">
|
||||
<TelegramProvider>
|
||||
<AppErrorBoundary>
|
||||
<TooltipProvider>{children}</TooltipProvider>
|
||||
</AppErrorBoundary>
|
||||
</TelegramProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
25
webapp-next/src/app/not-found.tsx
Normal file
25
webapp-next/src/app/not-found.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Next.js 404 page. Shown when notFound() is called or route is unknown.
|
||||
* For static export with a single route this is rarely hit; added for consistency.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "@/i18n/use-translation";
|
||||
|
||||
export default function NotFound() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-background px-4 text-foreground">
|
||||
<h1 className="text-xl font-semibold">{t("not_found.title")}</h1>
|
||||
<p className="text-muted-foreground">{t("not_found.description")}</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="rounded-lg bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
{t("not_found.open_calendar")}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
webapp-next/src/app/page.test.tsx
Normal file
54
webapp-next/src/app/page.test.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Integration test for main page: calendar and header visible, lang from store.
|
||||
* Ported from webapp/js/main.test.js applyLangToUi.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import Page from "./page";
|
||||
import { resetAppStore } from "@/test/test-utils";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
|
||||
vi.mock("@/hooks/use-telegram-auth", () => ({
|
||||
useTelegramAuth: () => ({
|
||||
initDataRaw: "test-init",
|
||||
startParam: undefined,
|
||||
isLocalhost: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-month-data", () => ({
|
||||
useMonthData: () => ({
|
||||
retry: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("Page", () => {
|
||||
beforeEach(() => {
|
||||
resetAppStore();
|
||||
});
|
||||
|
||||
it("renders calendar and header when store has default state", async () => {
|
||||
render(<Page />);
|
||||
expect(await screen.findByRole("grid", { name: "Calendar" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /previous month/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /next month/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sets document title and lang from store lang", async () => {
|
||||
useAppStore.getState().setLang("en");
|
||||
render(<Page />);
|
||||
await screen.findByRole("grid", { name: "Calendar" });
|
||||
expect(document.title).toBe("Duty Calendar");
|
||||
expect(document.documentElement.lang).toBe("en");
|
||||
});
|
||||
|
||||
it("sets document title for ru when store lang is ru", async () => {
|
||||
(globalThis.window as unknown as { __DT_LANG?: string }).__DT_LANG = "ru";
|
||||
render(<Page />);
|
||||
await screen.findByRole("grid", { name: "Calendar" });
|
||||
await waitFor(() => {
|
||||
expect(document.title).toBe("Календарь дежурств");
|
||||
});
|
||||
});
|
||||
});
|
||||
70
webapp-next/src/app/page.tsx
Normal file
70
webapp-next/src/app/page.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Main Mini App page: current duty deep link or calendar view.
|
||||
* Delegates to CurrentDutyView or CalendarPage; runs theme and app init.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useTelegramTheme } from "@/hooks/use-telegram-theme";
|
||||
import { useTelegramAuth } from "@/hooks/use-telegram-auth";
|
||||
import { useAppInit } from "@/hooks/use-app-init";
|
||||
import { callMiniAppReadyOnce } from "@/lib/telegram-ready";
|
||||
import { CurrentDutyView } from "@/components/current-duty/CurrentDutyView";
|
||||
import { CalendarPage } from "@/components/CalendarPage";
|
||||
|
||||
export default function Home() {
|
||||
useTelegramTheme();
|
||||
|
||||
const { initDataRaw, startParam, isLocalhost } = useTelegramAuth();
|
||||
const isAllowed = isLocalhost || !!initDataRaw;
|
||||
|
||||
useAppInit({ isAllowed, startParam });
|
||||
|
||||
const { currentView, setCurrentView, setSelectedDay, appContentReady } =
|
||||
useAppStore(
|
||||
useShallow((s) => ({
|
||||
currentView: s.currentView,
|
||||
setCurrentView: s.setCurrentView,
|
||||
setSelectedDay: s.setSelectedDay,
|
||||
appContentReady: s.appContentReady,
|
||||
}))
|
||||
);
|
||||
|
||||
// When content is ready, tell Telegram to hide native loading and show our app.
|
||||
useEffect(() => {
|
||||
if (appContentReady) {
|
||||
callMiniAppReadyOnce();
|
||||
}
|
||||
}, [appContentReady]);
|
||||
|
||||
const handleBackFromCurrentDuty = useCallback(() => {
|
||||
setCurrentView("calendar");
|
||||
setSelectedDay(null);
|
||||
}, [setCurrentView, setSelectedDay]);
|
||||
|
||||
const content =
|
||||
currentView === "currentDuty" ? (
|
||||
<div className="mx-auto flex min-h-screen w-full max-w-[var(--max-width-app)] flex-col bg-background px-3 pb-6 pt-safe">
|
||||
<CurrentDutyView
|
||||
onBack={handleBackFromCurrentDuty}
|
||||
openedFromPin={startParam === "duty"}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<CalendarPage isAllowed={isAllowed} initDataRaw={initDataRaw} />
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
visibility: appContentReady ? "visible" : "hidden",
|
||||
minHeight: "100vh",
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
76
webapp-next/src/components/AppErrorBoundary.tsx
Normal file
76
webapp-next/src/components/AppErrorBoundary.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Error boundary that catches render errors in the app tree and shows a fallback
|
||||
* with a reload option. Uses pure i18n (getLang/translate) so it does not depend
|
||||
* on React context that might be broken.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { getLang } from "@/i18n/messages";
|
||||
import { translate } from "@/i18n/messages";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface AppErrorBoundaryProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
interface AppErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Catches JavaScript errors in the child tree and renders a fallback UI
|
||||
* instead of crashing. Provides a Reload button to recover.
|
||||
*/
|
||||
export class AppErrorBoundary extends React.Component<
|
||||
AppErrorBoundaryProps,
|
||||
AppErrorBoundaryState
|
||||
> {
|
||||
constructor(props: AppErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(): AppErrorBoundaryState {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
|
||||
if (typeof console !== "undefined" && console.error) {
|
||||
console.error("AppErrorBoundary caught an error:", error, errorInfo);
|
||||
}
|
||||
}
|
||||
|
||||
handleReload = (): void => {
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.reload();
|
||||
}
|
||||
};
|
||||
|
||||
render(): React.ReactNode {
|
||||
if (this.state.hasError) {
|
||||
const lang = getLang();
|
||||
const message = translate(lang, "error_boundary.message");
|
||||
const reloadLabel = translate(lang, "error_boundary.reload");
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-[200px] flex-col items-center justify-center gap-4 rounded-xl bg-surface py-8 px-4 text-center"
|
||||
role="alert"
|
||||
>
|
||||
<p className="m-0 text-sm font-medium text-foreground">{message}</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={this.handleReload}
|
||||
className="bg-primary text-primary-foreground hover:opacity-90"
|
||||
>
|
||||
{reloadLabel}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
179
webapp-next/src/components/CalendarPage.tsx
Normal file
179
webapp-next/src/components/CalendarPage.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Calendar view layout: header, grid, duty list, day detail.
|
||||
* Composes calendar UI and owns sticky scroll, swipe, month data, and day-detail ref.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useEffect, useCallback } from "react";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useMonthData } from "@/hooks/use-month-data";
|
||||
import { useSwipe } from "@/hooks/use-swipe";
|
||||
import { useStickyScroll } from "@/hooks/use-sticky-scroll";
|
||||
import { useAutoRefresh } from "@/hooks/use-auto-refresh";
|
||||
import { CalendarHeader } from "@/components/calendar/CalendarHeader";
|
||||
import { CalendarGrid } from "@/components/calendar/CalendarGrid";
|
||||
import { DutyList } from "@/components/duty/DutyList";
|
||||
import { DayDetail, type DayDetailHandle } from "@/components/day-detail";
|
||||
import { ErrorState } from "@/components/states/ErrorState";
|
||||
import { AccessDenied } from "@/components/states/AccessDenied";
|
||||
|
||||
/** Fallback height (px) until ResizeObserver reports the sticky block size. Matches --calendar-block-min-height + pb-2 (260 + 8). */
|
||||
const STICKY_HEIGHT_FALLBACK_PX = 268;
|
||||
|
||||
export interface CalendarPageProps {
|
||||
/** Whether the user is allowed (for data loading). */
|
||||
isAllowed: boolean;
|
||||
/** Raw initData string for API auth. */
|
||||
initDataRaw: string | undefined;
|
||||
}
|
||||
|
||||
export function CalendarPage({ isAllowed, initDataRaw }: CalendarPageProps) {
|
||||
const dayDetailRef = useRef<DayDetailHandle>(null);
|
||||
const calendarStickyRef = useRef<HTMLDivElement>(null);
|
||||
const [stickyBlockHeight, setStickyBlockHeight] = useState(STICKY_HEIGHT_FALLBACK_PX);
|
||||
|
||||
useEffect(() => {
|
||||
const el = calendarStickyRef.current;
|
||||
if (!el) return;
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry) setStickyBlockHeight(entry.contentRect.height);
|
||||
});
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const {
|
||||
currentMonth,
|
||||
pendingMonth,
|
||||
loading,
|
||||
error,
|
||||
accessDenied,
|
||||
accessDeniedDetail,
|
||||
duties,
|
||||
calendarEvents,
|
||||
selectedDay,
|
||||
nextMonth,
|
||||
prevMonth,
|
||||
setCurrentMonth,
|
||||
setSelectedDay,
|
||||
setAppContentReady,
|
||||
} = useAppStore(
|
||||
useShallow((s) => ({
|
||||
currentMonth: s.currentMonth,
|
||||
pendingMonth: s.pendingMonth,
|
||||
loading: s.loading,
|
||||
error: s.error,
|
||||
accessDenied: s.accessDenied,
|
||||
accessDeniedDetail: s.accessDeniedDetail,
|
||||
duties: s.duties,
|
||||
calendarEvents: s.calendarEvents,
|
||||
selectedDay: s.selectedDay,
|
||||
nextMonth: s.nextMonth,
|
||||
prevMonth: s.prevMonth,
|
||||
setCurrentMonth: s.setCurrentMonth,
|
||||
setSelectedDay: s.setSelectedDay,
|
||||
setAppContentReady: s.setAppContentReady,
|
||||
}))
|
||||
);
|
||||
|
||||
const { retry } = useMonthData({
|
||||
initDataRaw,
|
||||
enabled: isAllowed,
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
const isCurrentMonth =
|
||||
currentMonth.getFullYear() === now.getFullYear() &&
|
||||
currentMonth.getMonth() === now.getMonth();
|
||||
useAutoRefresh(retry, isCurrentMonth);
|
||||
|
||||
const navDisabled = loading || accessDenied || selectedDay !== null;
|
||||
const handlePrevMonth = useCallback(() => {
|
||||
if (navDisabled) return;
|
||||
prevMonth();
|
||||
}, [navDisabled, prevMonth]);
|
||||
const handleNextMonth = useCallback(() => {
|
||||
if (navDisabled) return;
|
||||
nextMonth();
|
||||
}, [navDisabled, nextMonth]);
|
||||
|
||||
useSwipe(
|
||||
calendarStickyRef,
|
||||
handleNextMonth,
|
||||
handlePrevMonth,
|
||||
{ threshold: 50, disabled: navDisabled }
|
||||
);
|
||||
useStickyScroll(calendarStickyRef);
|
||||
|
||||
const handleDayClick = useCallback(
|
||||
(dateKey: string, anchorRect: DOMRect) => {
|
||||
const [y, m] = dateKey.split("-").map(Number);
|
||||
if (
|
||||
y !== currentMonth.getFullYear() ||
|
||||
m !== currentMonth.getMonth() + 1
|
||||
) {
|
||||
return;
|
||||
}
|
||||
dayDetailRef.current?.openWithRect(dateKey, anchorRect);
|
||||
},
|
||||
[currentMonth]
|
||||
);
|
||||
|
||||
const handleCloseDayDetail = useCallback(() => {
|
||||
setSelectedDay(null);
|
||||
}, [setSelectedDay]);
|
||||
|
||||
const readyCalledRef = useRef(false);
|
||||
// Mark content ready when first load finishes or access denied, so page can call ready() and show content.
|
||||
useEffect(() => {
|
||||
if ((!loading || accessDenied) && !readyCalledRef.current) {
|
||||
readyCalledRef.current = true;
|
||||
setAppContentReady(true);
|
||||
}
|
||||
}, [loading, accessDenied, setAppContentReady]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-screen w-full max-w-[var(--max-width-app)] flex-col bg-background px-3 pb-6 pt-safe">
|
||||
<div
|
||||
ref={calendarStickyRef}
|
||||
className="sticky top-0 z-10 min-h-[var(--calendar-block-min-height)] bg-background pb-2"
|
||||
>
|
||||
<CalendarHeader
|
||||
month={currentMonth}
|
||||
disabled={navDisabled}
|
||||
onPrevMonth={handlePrevMonth}
|
||||
onNextMonth={handleNextMonth}
|
||||
/>
|
||||
<CalendarGrid
|
||||
currentMonth={currentMonth}
|
||||
duties={duties}
|
||||
calendarEvents={calendarEvents}
|
||||
onDayClick={handleDayClick}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{accessDenied && (
|
||||
<AccessDenied serverDetail={accessDeniedDetail} className="my-3" />
|
||||
)}
|
||||
{!accessDenied && error && (
|
||||
<ErrorState message={error} onRetry={retry} className="my-3" />
|
||||
)}
|
||||
{!accessDenied && !error && (
|
||||
<DutyList
|
||||
scrollMarginTop={stickyBlockHeight}
|
||||
className="mt-2"
|
||||
/>
|
||||
)}
|
||||
|
||||
<DayDetail
|
||||
ref={dayDetailRef}
|
||||
duties={duties}
|
||||
calendarEvents={calendarEvents}
|
||||
onClose={handleCloseDayDetail}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
79
webapp-next/src/components/calendar/CalendarDay.test.tsx
Normal file
79
webapp-next/src/components/calendar/CalendarDay.test.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Unit tests for CalendarDay: click opens day detail only for current month;
|
||||
* other-month cells do not call onDayClick and are non-interactive (aria-disabled).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { CalendarDay } from "./CalendarDay";
|
||||
import { resetAppStore } from "@/test/test-utils";
|
||||
|
||||
describe("CalendarDay", () => {
|
||||
const defaultProps = {
|
||||
dateKey: "2025-02-15",
|
||||
dayOfMonth: 15,
|
||||
isToday: false,
|
||||
duties: [],
|
||||
eventSummaries: [],
|
||||
onDayClick: () => {},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
resetAppStore();
|
||||
});
|
||||
|
||||
it("calls onDayClick with dateKey and rect when clicked and isOtherMonth is false", () => {
|
||||
const onDayClick = vi.fn();
|
||||
render(
|
||||
<CalendarDay
|
||||
{...defaultProps}
|
||||
isOtherMonth={false}
|
||||
onDayClick={onDayClick}
|
||||
/>
|
||||
);
|
||||
const button = screen.getByRole("button", { name: /15/ });
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(onDayClick).toHaveBeenCalledTimes(1);
|
||||
expect(onDayClick).toHaveBeenCalledWith(
|
||||
"2025-02-15",
|
||||
expect.objectContaining({
|
||||
width: expect.any(Number),
|
||||
height: expect.any(Number),
|
||||
top: expect.any(Number),
|
||||
left: expect.any(Number),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("does not call onDayClick when clicked and isOtherMonth is true", () => {
|
||||
const onDayClick = vi.fn();
|
||||
render(
|
||||
<CalendarDay
|
||||
{...defaultProps}
|
||||
isOtherMonth={true}
|
||||
onDayClick={onDayClick}
|
||||
/>
|
||||
);
|
||||
const button = screen.getByRole("button", { name: /15/ });
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(onDayClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sets aria-disabled on the button when isOtherMonth is true", () => {
|
||||
render(
|
||||
<CalendarDay {...defaultProps} isOtherMonth={true} onDayClick={() => {}} />
|
||||
);
|
||||
const button = screen.getByRole("button", { name: /15/ });
|
||||
expect(button).toHaveAttribute("aria-disabled", "true");
|
||||
});
|
||||
|
||||
it("is not disabled for interaction when isOtherMonth is false", () => {
|
||||
render(
|
||||
<CalendarDay {...defaultProps} isOtherMonth={false} onDayClick={() => {}} />
|
||||
);
|
||||
const button = screen.getByRole("button", { name: /15/ });
|
||||
expect(button.getAttribute("aria-disabled")).not.toBe("true");
|
||||
});
|
||||
});
|
||||
123
webapp-next/src/components/calendar/CalendarDay.tsx
Normal file
123
webapp-next/src/components/calendar/CalendarDay.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Single calendar day cell: date number and day indicators. Click opens day detail.
|
||||
* Ported from webapp/js/calendar.js day cell rendering.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import { useTranslation } from "@/i18n/use-translation";
|
||||
import { dateKeyToDDMM } from "@/lib/date-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { DutyWithUser } from "@/types";
|
||||
import { DayIndicators } from "./DayIndicators";
|
||||
|
||||
export interface CalendarDayProps {
|
||||
/** YYYY-MM-DD key for this day. */
|
||||
dateKey: string;
|
||||
/** Day of month (1–31) for display. */
|
||||
dayOfMonth: number;
|
||||
isToday: boolean;
|
||||
isOtherMonth: boolean;
|
||||
/** Duties overlapping this day (for indicators and tooltip). */
|
||||
duties: DutyWithUser[];
|
||||
/** External calendar event summaries for this day. */
|
||||
eventSummaries: string[];
|
||||
onDayClick: (dateKey: string, anchorRect: DOMRect) => void;
|
||||
}
|
||||
|
||||
function CalendarDayInner({
|
||||
dateKey,
|
||||
dayOfMonth,
|
||||
isToday,
|
||||
isOtherMonth,
|
||||
duties,
|
||||
eventSummaries,
|
||||
onDayClick,
|
||||
}: CalendarDayProps) {
|
||||
const { t } = useTranslation();
|
||||
const { dutyList, unavailableList, vacationList } = useMemo(
|
||||
() => ({
|
||||
dutyList: duties.filter((d) => d.event_type === "duty"),
|
||||
unavailableList: duties.filter((d) => d.event_type === "unavailable"),
|
||||
vacationList: duties.filter((d) => d.event_type === "vacation"),
|
||||
}),
|
||||
[duties]
|
||||
);
|
||||
const hasEvent = eventSummaries.length > 0;
|
||||
const showIndicator = !isOtherMonth;
|
||||
const hasAny = duties.length > 0 || hasEvent;
|
||||
|
||||
const ariaParts: string[] = [dateKeyToDDMM(dateKey)];
|
||||
if (hasAny && showIndicator) {
|
||||
const counts: string[] = [];
|
||||
if (dutyList.length) counts.push(`${dutyList.length} ${t("event_type.duty")}`);
|
||||
if (unavailableList.length)
|
||||
counts.push(`${unavailableList.length} ${t("event_type.unavailable")}`);
|
||||
if (vacationList.length)
|
||||
counts.push(`${vacationList.length} ${t("event_type.vacation")}`);
|
||||
if (hasEvent) counts.push(t("hint.events"));
|
||||
ariaParts.push(counts.join(", "));
|
||||
} else {
|
||||
ariaParts.push(t("aria.day_info"));
|
||||
}
|
||||
const ariaLabel = ariaParts.join("; ");
|
||||
|
||||
const content = (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={ariaLabel}
|
||||
aria-disabled={isOtherMonth}
|
||||
data-date={dateKey}
|
||||
className={cn(
|
||||
"relative flex w-full aspect-square min-h-8 min-w-0 flex-col items-center justify-start rounded-lg p-1 text-[0.85rem] transition-[background-color,transform] overflow-hidden",
|
||||
"focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent",
|
||||
isOtherMonth &&
|
||||
"pointer-events-none opacity-40 bg-[var(--surface-muted-tint)] cursor-default",
|
||||
!isOtherMonth && [
|
||||
"bg-surface hover:bg-[var(--surface-hover-10)]",
|
||||
"active:scale-[0.98] cursor-pointer",
|
||||
isToday && "bg-today text-[var(--bg)] hover:bg-[var(--today-hover)]",
|
||||
],
|
||||
showIndicator && hasAny && "font-bold",
|
||||
showIndicator &&
|
||||
hasEvent &&
|
||||
"bg-[linear-gradient(135deg,var(--surface)_0%,var(--today-gradient-end)_100%)] border border-[var(--today-border)]",
|
||||
isToday &&
|
||||
hasEvent &&
|
||||
"bg-today text-[var(--bg)] border border-[var(--today-border-selected)]"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (isOtherMonth) return;
|
||||
onDayClick(dateKey, e.currentTarget.getBoundingClientRect());
|
||||
}}
|
||||
>
|
||||
<span className="num">{dayOfMonth}</span>
|
||||
{showIndicator && (duties.length > 0 || hasEvent) && (
|
||||
<DayIndicators
|
||||
dutyCount={dutyList.length}
|
||||
unavailableCount={unavailableList.length}
|
||||
vacationCount={vacationList.length}
|
||||
hasEvents={hasEvent}
|
||||
isToday={isToday}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
function arePropsEqual(prev: CalendarDayProps, next: CalendarDayProps): boolean {
|
||||
return (
|
||||
prev.dateKey === next.dateKey &&
|
||||
prev.dayOfMonth === next.dayOfMonth &&
|
||||
prev.isToday === next.isToday &&
|
||||
prev.isOtherMonth === next.isOtherMonth &&
|
||||
prev.duties === next.duties &&
|
||||
prev.eventSummaries === next.eventSummaries &&
|
||||
prev.onDayClick === next.onDayClick
|
||||
);
|
||||
}
|
||||
|
||||
export const CalendarDay = React.memo(CalendarDayInner, arePropsEqual);
|
||||
88
webapp-next/src/components/calendar/CalendarGrid.test.tsx
Normal file
88
webapp-next/src/components/calendar/CalendarGrid.test.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Unit tests for CalendarGrid: 42 cells, data-date, today class, month title in header.
|
||||
* Ported from webapp/js/calendar.test.js renderCalendar.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { CalendarGrid } from "./CalendarGrid";
|
||||
import { CalendarHeader } from "./CalendarHeader";
|
||||
import { resetAppStore } from "@/test/test-utils";
|
||||
|
||||
describe("CalendarGrid", () => {
|
||||
beforeEach(() => {
|
||||
resetAppStore();
|
||||
});
|
||||
|
||||
it("renders 42 cells (6 weeks)", () => {
|
||||
const currentMonth = new Date(2025, 0, 1); // January 2025
|
||||
render(
|
||||
<CalendarGrid
|
||||
currentMonth={currentMonth}
|
||||
duties={[]}
|
||||
calendarEvents={[]}
|
||||
onDayClick={() => {}}
|
||||
/>
|
||||
);
|
||||
const cells = screen.getAllByRole("button", { name: /;/ });
|
||||
expect(cells.length).toBe(42);
|
||||
});
|
||||
|
||||
it("sets data-date on each cell to YYYY-MM-DD", () => {
|
||||
const currentMonth = new Date(2025, 0, 1);
|
||||
render(
|
||||
<CalendarGrid
|
||||
currentMonth={currentMonth}
|
||||
duties={[]}
|
||||
calendarEvents={[]}
|
||||
onDayClick={() => {}}
|
||||
/>
|
||||
);
|
||||
const grid = screen.getByRole("grid", { name: "Calendar" });
|
||||
const buttons = grid.querySelectorAll('button[data-date]');
|
||||
expect(buttons.length).toBe(42);
|
||||
buttons.forEach((el) => {
|
||||
const date = el.getAttribute("data-date");
|
||||
expect(date).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
});
|
||||
});
|
||||
|
||||
it("adds today styling to cell matching today", () => {
|
||||
const today = new Date();
|
||||
const currentMonth = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
render(
|
||||
<CalendarGrid
|
||||
currentMonth={currentMonth}
|
||||
duties={[]}
|
||||
calendarEvents={[]}
|
||||
onDayClick={() => {}}
|
||||
/>
|
||||
);
|
||||
const year = today.getFullYear();
|
||||
const month = String(today.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(today.getDate()).padStart(2, "0");
|
||||
const todayKey = `${year}-${month}-${day}`;
|
||||
const todayCell = document.querySelector(`button[data-date="${todayKey}"]`);
|
||||
expect(todayCell).toBeTruthy();
|
||||
expect(todayCell?.className).toMatch(/today|bg-today/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CalendarHeader", () => {
|
||||
beforeEach(() => {
|
||||
resetAppStore();
|
||||
});
|
||||
|
||||
it("sets month title from lang and given year/month", () => {
|
||||
render(
|
||||
<CalendarHeader
|
||||
month={new Date(2025, 1, 1)}
|
||||
onPrevMonth={() => {}}
|
||||
onNextMonth={() => {}}
|
||||
/>
|
||||
);
|
||||
const heading = screen.getByRole("heading", { level: 1 });
|
||||
expect(heading).toHaveTextContent("February");
|
||||
expect(heading).toHaveTextContent("2025");
|
||||
});
|
||||
});
|
||||
93
webapp-next/src/components/calendar/CalendarGrid.tsx
Normal file
93
webapp-next/src/components/calendar/CalendarGrid.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* 6-week (42-cell) calendar grid starting from Monday. Composes CalendarDay cells.
|
||||
* Ported from webapp/js/calendar.js renderCalendar.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
firstDayOfMonth,
|
||||
getMonday,
|
||||
localDateString,
|
||||
} from "@/lib/date-utils";
|
||||
import type { CalendarEvent, DutyWithUser } from "@/types";
|
||||
import { dutiesByDate, calendarEventsByDate } from "@/lib/calendar-data";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CalendarDay } from "./CalendarDay";
|
||||
|
||||
export interface CalendarGridProps {
|
||||
/** Currently displayed month. */
|
||||
currentMonth: Date;
|
||||
/** All duties for the visible range (will be grouped by date). */
|
||||
duties: DutyWithUser[];
|
||||
/** All calendar events for the visible range. */
|
||||
calendarEvents: CalendarEvent[];
|
||||
/** Called when a day cell is clicked (opens day detail). Receives date key and cell rect for popover. */
|
||||
onDayClick: (dateKey: string, anchorRect: DOMRect) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const CELLS = 42;
|
||||
|
||||
export function CalendarGrid({
|
||||
currentMonth,
|
||||
duties,
|
||||
calendarEvents,
|
||||
onDayClick,
|
||||
className,
|
||||
}: CalendarGridProps) {
|
||||
const dutiesByDateMap = useMemo(
|
||||
() => dutiesByDate(duties),
|
||||
[duties]
|
||||
);
|
||||
const calendarEventsByDateMap = useMemo(
|
||||
() => calendarEventsByDate(calendarEvents),
|
||||
[calendarEvents]
|
||||
);
|
||||
const todayKey = localDateString(new Date());
|
||||
|
||||
const cells = useMemo(() => {
|
||||
const first = firstDayOfMonth(currentMonth);
|
||||
const start = getMonday(first);
|
||||
const result: { date: Date; key: string; month: number }[] = [];
|
||||
const d = new Date(start);
|
||||
for (let i = 0; i < CELLS; i++) {
|
||||
const key = localDateString(d);
|
||||
result.push({ date: new Date(d), key, month: d.getMonth() });
|
||||
d.setDate(d.getDate() + 1);
|
||||
}
|
||||
return result;
|
||||
}, [currentMonth]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"calendar-grid grid grid-cols-7 gap-1 mb-4 min-h-[var(--calendar-grid-min-height)]",
|
||||
className
|
||||
)}
|
||||
role="grid"
|
||||
aria-label="Calendar"
|
||||
>
|
||||
{cells.map(({ date, key, month }, i) => {
|
||||
const isOtherMonth = month !== currentMonth.getMonth();
|
||||
const dayDuties = dutiesByDateMap[key] ?? [];
|
||||
const eventSummaries = calendarEventsByDateMap[key] ?? [];
|
||||
|
||||
return (
|
||||
<div key={`cell-${i}`} role="gridcell" className="min-h-0">
|
||||
<CalendarDay
|
||||
dateKey={key}
|
||||
dayOfMonth={date.getDate()}
|
||||
isToday={key === todayKey}
|
||||
isOtherMonth={isOtherMonth}
|
||||
duties={dayDuties}
|
||||
eventSummaries={eventSummaries}
|
||||
onDayClick={onDayClick}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
82
webapp-next/src/components/calendar/CalendarHeader.tsx
Normal file
82
webapp-next/src/components/calendar/CalendarHeader.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Calendar header: month title, prev/next navigation, weekday labels.
|
||||
* Replaces the header from webapp index.html and calendar.js month title.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslation } from "@/i18n/use-translation";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
ChevronLeft as ChevronLeftIcon,
|
||||
ChevronRight as ChevronRightIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
export interface CalendarHeaderProps {
|
||||
/** Currently displayed month (used for title). */
|
||||
month: Date;
|
||||
/** Whether month navigation is disabled (e.g. during loading). */
|
||||
disabled?: boolean;
|
||||
onPrevMonth: () => void;
|
||||
onNextMonth: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CalendarHeader({
|
||||
month,
|
||||
disabled = false,
|
||||
onPrevMonth,
|
||||
onNextMonth,
|
||||
className,
|
||||
}: CalendarHeaderProps) {
|
||||
const { t, monthName, weekdayLabels } = useTranslation();
|
||||
const year = month.getFullYear();
|
||||
const monthIndex = month.getMonth();
|
||||
const labels = weekdayLabels();
|
||||
|
||||
return (
|
||||
<header className={cn("flex flex-col", className)}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="size-10 rounded-[10px] bg-surface text-accent hover:bg-[var(--surface-hover)] focus-visible:outline-accent active:scale-95 disabled:opacity-50"
|
||||
aria-label={t("nav.prev_month")}
|
||||
disabled={disabled}
|
||||
onClick={onPrevMonth}
|
||||
>
|
||||
<ChevronLeftIcon className="size-5" aria-hidden />
|
||||
</Button>
|
||||
<div className="flex flex-col items-center gap-0.5">
|
||||
<h1
|
||||
className="m-0 flex items-center justify-center gap-2 text-[1.1rem] font-semibold sm:text-[1.25rem]"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
>
|
||||
{monthName(monthIndex)} {year}
|
||||
</h1>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="size-10 rounded-[10px] bg-surface text-accent hover:bg-[var(--surface-hover)] focus-visible:outline-accent active:scale-95 disabled:opacity-50"
|
||||
aria-label={t("nav.next_month")}
|
||||
disabled={disabled}
|
||||
onClick={onNextMonth}
|
||||
>
|
||||
<ChevronRightIcon className="size-5" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-0.5 mb-1.5 text-center text-[0.75rem] text-muted">
|
||||
{labels.map((label, i) => (
|
||||
<span key={i} aria-hidden>
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
71
webapp-next/src/components/calendar/DayIndicators.test.tsx
Normal file
71
webapp-next/src/components/calendar/DayIndicators.test.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Unit tests for DayIndicators: rounding is position-based (first / last segment),
|
||||
* not by indicator type, so one or multiple segments form one pill.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { DayIndicators } from "./DayIndicators";
|
||||
|
||||
const baseProps = {
|
||||
dutyCount: 0,
|
||||
unavailableCount: 0,
|
||||
vacationCount: 0,
|
||||
hasEvents: false,
|
||||
};
|
||||
|
||||
describe("DayIndicators", () => {
|
||||
it("renders nothing when no indicators", () => {
|
||||
const { container } = render(<DayIndicators {...baseProps} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("renders one segment as pill (e.g. vacation only)", () => {
|
||||
const { container } = render(
|
||||
<DayIndicators {...baseProps} vacationCount={1} />
|
||||
);
|
||||
const wrapper = container.querySelector("[aria-hidden]");
|
||||
expect(wrapper).toBeInTheDocument();
|
||||
expect(wrapper?.className).toContain("[&>:first-child]:rounded-l-[3px]");
|
||||
expect(wrapper?.className).toContain("[&>:last-child]:rounded-r-[3px]");
|
||||
const spans = wrapper?.querySelectorAll("span");
|
||||
expect(spans).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("renders two segments with positional rounding (duty + vacation)", () => {
|
||||
const { container } = render(
|
||||
<DayIndicators {...baseProps} dutyCount={1} vacationCount={1} />
|
||||
);
|
||||
const wrapper = container.querySelector("[aria-hidden]");
|
||||
expect(wrapper).toBeInTheDocument();
|
||||
expect(wrapper?.className).toContain(
|
||||
"[&>:first-child]:rounded-l-[3px]"
|
||||
);
|
||||
expect(wrapper?.className).toContain(
|
||||
"[&>:last-child]:rounded-r-[3px]"
|
||||
);
|
||||
const spans = wrapper?.querySelectorAll("span");
|
||||
expect(spans).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("renders three segments with first left-rounded, last right-rounded (duty + unavailable + vacation)", () => {
|
||||
const { container } = render(
|
||||
<DayIndicators
|
||||
{...baseProps}
|
||||
dutyCount={1}
|
||||
unavailableCount={1}
|
||||
vacationCount={1}
|
||||
/>
|
||||
);
|
||||
const wrapper = container.querySelector("[aria-hidden]");
|
||||
expect(wrapper).toBeInTheDocument();
|
||||
expect(wrapper?.className).toContain(
|
||||
"[&>:first-child]:rounded-l-[3px]"
|
||||
);
|
||||
expect(wrapper?.className).toContain(
|
||||
"[&>:last-child]:rounded-r-[3px]"
|
||||
);
|
||||
const spans = wrapper?.querySelectorAll("span");
|
||||
expect(spans).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
65
webapp-next/src/components/calendar/DayIndicators.tsx
Normal file
65
webapp-next/src/components/calendar/DayIndicators.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Colored segments (pill bar) for calendar day: duty (green), unavailable (amber), vacation (blue), events (accent).
|
||||
* Ported from webapp calendar day-indicator markup and markers.css.
|
||||
*
|
||||
* Rounding is position-based (first / last segment), so one or multiple segments always form a pill:
|
||||
* first segment gets left rounding, last segment gets right rounding (single segment gets both).
|
||||
*/
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface DayIndicatorsProps {
|
||||
/** Number of duty slots this day. */
|
||||
dutyCount: number;
|
||||
/** Number of unavailable slots. */
|
||||
unavailableCount: number;
|
||||
/** Number of vacation slots. */
|
||||
vacationCount: number;
|
||||
/** Whether the day has external calendar events (e.g. holiday). */
|
||||
hasEvents: boolean;
|
||||
/** When true (e.g. today cell), use darker segments for contrast. */
|
||||
isToday?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function DayIndicators({
|
||||
dutyCount,
|
||||
unavailableCount,
|
||||
vacationCount,
|
||||
hasEvents,
|
||||
isToday = false,
|
||||
className,
|
||||
}: DayIndicatorsProps) {
|
||||
const hasAny = dutyCount > 0 || unavailableCount > 0 || vacationCount > 0 || hasEvents;
|
||||
if (!hasAny) return null;
|
||||
|
||||
const dotClass = (variant: "duty" | "unavailable" | "vacation" | "events") =>
|
||||
cn(
|
||||
"min-w-0 flex-1 h-1 max-h-1.5",
|
||||
variant === "duty" && "bg-duty",
|
||||
variant === "unavailable" && "bg-unavailable",
|
||||
variant === "vacation" && "bg-vacation",
|
||||
variant === "events" && "bg-accent",
|
||||
isToday && variant === "duty" && "bg-[var(--indicator-today-duty)]",
|
||||
isToday && variant === "unavailable" && "bg-[var(--indicator-today-unavailable)]",
|
||||
isToday && variant === "vacation" && "bg-[var(--indicator-today-vacation)]",
|
||||
isToday && variant === "events" && "bg-[var(--indicator-today-events)]"
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-[65%] justify-center gap-0.5 mt-1.5",
|
||||
"[&>:first-child]:rounded-l-[3px]",
|
||||
"[&>:last-child]:rounded-r-[3px]",
|
||||
className
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
{dutyCount > 0 && <span className={dotClass("duty")} />}
|
||||
{unavailableCount > 0 && <span className={dotClass("unavailable")} />}
|
||||
{vacationCount > 0 && <span className={dotClass("vacation")} />}
|
||||
{hasEvents && <span className={dotClass("events")} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
webapp-next/src/components/calendar/index.ts
Normal file
12
webapp-next/src/components/calendar/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Calendar components and data helpers.
|
||||
*/
|
||||
|
||||
export { CalendarGrid } from "./CalendarGrid";
|
||||
export { CalendarHeader } from "./CalendarHeader";
|
||||
export { CalendarDay } from "./CalendarDay";
|
||||
export { DayIndicators } from "./DayIndicators";
|
||||
export type { DayIndicatorsProps } from "./DayIndicators";
|
||||
export type { CalendarGridProps } from "./CalendarGrid";
|
||||
export type { CalendarHeaderProps } from "./CalendarHeader";
|
||||
export type { CalendarDayProps } from "./CalendarDay";
|
||||
60
webapp-next/src/components/contact/ContactLinks.test.tsx
Normal file
60
webapp-next/src/components/contact/ContactLinks.test.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Unit tests for ContactLinks: phone/Telegram display, labels, layout.
|
||||
* Ported from webapp/js/contactHtml.test.js buildContactLinksHtml.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { ContactLinks } from "./ContactLinks";
|
||||
import { resetAppStore } from "@/test/test-utils";
|
||||
|
||||
describe("ContactLinks", () => {
|
||||
beforeEach(() => {
|
||||
resetAppStore();
|
||||
});
|
||||
|
||||
it("returns null when phone and username are missing", () => {
|
||||
const { container } = render(
|
||||
<ContactLinks phone={null} username={null} />
|
||||
);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("renders phone only with label and tel: link", () => {
|
||||
render(<ContactLinks phone="+79991234567" username={null} showLabels />);
|
||||
expect(document.querySelector('a[href^="tel:"]')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Phone/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays phone formatted for Russian numbers", () => {
|
||||
render(<ContactLinks phone="79146522209" username={null} />);
|
||||
expect(screen.getByText(/\+7 914 652-22-09/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders username only with label and t.me link", () => {
|
||||
render(<ContactLinks phone={null} username="alice_dev" showLabels />);
|
||||
expect(document.querySelector('a[href*="t.me"]')).toBeInTheDocument();
|
||||
expect(screen.getByText(/alice_dev/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders both phone and username with labels", () => {
|
||||
render(
|
||||
<ContactLinks
|
||||
phone="+79001112233"
|
||||
username="bob"
|
||||
showLabels
|
||||
/>
|
||||
);
|
||||
expect(document.querySelector('a[href^="tel:"]')).toBeInTheDocument();
|
||||
expect(document.querySelector('a[href*="t.me"]')).toBeInTheDocument();
|
||||
expect(screen.getByText(/\+7 900 111-22-33/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/@bob/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("strips leading @ from username and displays with @", () => {
|
||||
render(<ContactLinks phone={null} username="@alice" />);
|
||||
const link = document.querySelector('a[href*="t.me/alice"]');
|
||||
expect(link).toBeInTheDocument();
|
||||
expect(link?.textContent).toContain("@alice");
|
||||
});
|
||||
});
|
||||
145
webapp-next/src/components/contact/ContactLinks.tsx
Normal file
145
webapp-next/src/components/contact/ContactLinks.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Contact links (phone, Telegram) for duty cards and day detail.
|
||||
* Ported from webapp/js/contactHtml.js buildContactLinksHtml.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "@/i18n/use-translation";
|
||||
import { formatPhoneDisplay } from "@/lib/phone-format";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Phone as PhoneIcon, Send as TelegramIcon } from "lucide-react";
|
||||
|
||||
export interface ContactLinksProps {
|
||||
phone?: string | null;
|
||||
username?: string | null;
|
||||
layout?: "inline" | "block";
|
||||
showLabels?: boolean;
|
||||
/** Optional label for aria-label on links (e.g. duty holder name for "Call …", "Message … on Telegram"). */
|
||||
contextLabel?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const linkClass =
|
||||
"text-accent hover:underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-offset-2";
|
||||
|
||||
/**
|
||||
* Renders phone (tel:) and Telegram (t.me) links. Used on flip card back and day detail.
|
||||
*/
|
||||
export function ContactLinks({
|
||||
phone,
|
||||
username,
|
||||
layout = "inline",
|
||||
showLabels = true,
|
||||
contextLabel,
|
||||
className,
|
||||
}: ContactLinksProps) {
|
||||
const { t } = useTranslation();
|
||||
const hasPhone = Boolean(phone && String(phone).trim());
|
||||
const rawUsername = username && String(username).trim();
|
||||
const cleanUsername = rawUsername ? rawUsername.replace(/^@+/, "") : "";
|
||||
const hasUsername = Boolean(cleanUsername);
|
||||
|
||||
if (!hasPhone && !hasUsername) return null;
|
||||
|
||||
const ariaCall = contextLabel
|
||||
? t("contact.aria_call", { name: contextLabel })
|
||||
: t("contact.phone");
|
||||
const ariaTelegram = contextLabel
|
||||
? t("contact.aria_telegram", { name: contextLabel })
|
||||
: t("contact.telegram");
|
||||
|
||||
if (layout === "block") {
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-2", className)}>
|
||||
{hasPhone && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-12 justify-start gap-3 px-4 text-accent hover:bg-accent/10 hover:text-accent"
|
||||
asChild
|
||||
>
|
||||
<a href={`tel:${String(phone).trim()}`} aria-label={ariaCall}>
|
||||
<PhoneIcon className="size-5" aria-hidden />
|
||||
<span>{formatPhoneDisplay(phone!)}</span>
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
{hasUsername && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-12 justify-start gap-3 px-4 text-accent hover:bg-accent/10 hover:text-accent"
|
||||
asChild
|
||||
>
|
||||
<a
|
||||
href={`https://t.me/${encodeURIComponent(cleanUsername)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={ariaTelegram}
|
||||
>
|
||||
<TelegramIcon className="size-5" aria-hidden />
|
||||
<span>@{cleanUsername}</span>
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const parts: React.ReactNode[] = [];
|
||||
if (hasPhone) {
|
||||
const displayPhone = formatPhoneDisplay(phone!);
|
||||
parts.push(
|
||||
showLabels ? (
|
||||
<span key="phone">
|
||||
{t("contact.phone")}:{" "}
|
||||
<a
|
||||
href={`tel:${String(phone).trim()}`}
|
||||
className={linkClass}
|
||||
aria-label={ariaCall}
|
||||
>
|
||||
{displayPhone}
|
||||
</a>
|
||||
</span>
|
||||
) : (
|
||||
<a
|
||||
key="phone"
|
||||
href={`tel:${String(phone).trim()}`}
|
||||
className={linkClass}
|
||||
aria-label={ariaCall}
|
||||
>
|
||||
{displayPhone}
|
||||
</a>
|
||||
)
|
||||
);
|
||||
}
|
||||
if (hasUsername) {
|
||||
const href = `https://t.me/${encodeURIComponent(cleanUsername)}`;
|
||||
const link = (
|
||||
<a
|
||||
key="tg"
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={linkClass}
|
||||
aria-label={ariaTelegram}
|
||||
>
|
||||
@{cleanUsername}
|
||||
</a>
|
||||
);
|
||||
parts.push(showLabels ? <span key="tg">{t("contact.telegram")}: {link}</span> : link);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("text-sm text-muted-foreground flex flex-wrap items-center gap-x-1", className)}>
|
||||
{parts.map((p, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-x-1">
|
||||
{i > 0 && <span aria-hidden className="text-muted-foreground">·</span>}
|
||||
{p}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
6
webapp-next/src/components/contact/index.ts
Normal file
6
webapp-next/src/components/contact/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Contact links component.
|
||||
*/
|
||||
|
||||
export { ContactLinks } from "./ContactLinks";
|
||||
export type { ContactLinksProps } from "./ContactLinks";
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Unit tests for CurrentDutyView: no-duty message, duty card with contacts.
|
||||
* Ported from webapp/js/currentDuty.test.js renderCurrentDutyContent / showCurrentDutyView.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { CurrentDutyView } from "./CurrentDutyView";
|
||||
import { resetAppStore } from "@/test/test-utils";
|
||||
|
||||
vi.mock("@/hooks/use-telegram-auth", () => ({
|
||||
useTelegramAuth: () => ({
|
||||
initDataRaw: "test-init",
|
||||
startParam: undefined,
|
||||
isLocalhost: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
fetchDuties: vi.fn().mockResolvedValue([]),
|
||||
AccessDeniedError: class AccessDeniedError extends Error {
|
||||
serverDetail?: string;
|
||||
constructor(m: string, d?: string) {
|
||||
super(m);
|
||||
this.serverDetail = d;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
describe("CurrentDutyView", () => {
|
||||
beforeEach(() => {
|
||||
resetAppStore();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("shows loading then no-duty message when no active duty", async () => {
|
||||
const onBack = vi.fn();
|
||||
render(<CurrentDutyView onBack={onBack} />);
|
||||
await screen.findByText(/No one is on duty|Сейчас никто не дежурит/i, {}, { timeout: 3000 });
|
||||
expect(screen.getByText(/Back to calendar|Назад к календарю/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("back button calls onBack when clicked", async () => {
|
||||
const onBack = vi.fn();
|
||||
render(<CurrentDutyView onBack={onBack} />);
|
||||
await screen.findByText(/No one is on duty|Сейчас никто не дежурит/i, {}, { timeout: 3000 });
|
||||
const buttons = screen.getAllByRole("button", { name: /Back to calendar|Назад к календарю/i });
|
||||
fireEvent.click(buttons[buttons.length - 1]);
|
||||
expect(onBack).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows Close button when openedFromPin is true", async () => {
|
||||
const onBack = vi.fn();
|
||||
render(<CurrentDutyView onBack={onBack} openedFromPin={true} />);
|
||||
await screen.findByText(/No one is on duty|Сейчас никто не дежурит/i, {}, { timeout: 3000 });
|
||||
expect(screen.getByRole("button", { name: /Close|Закрыть/i })).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Back to calendar|Назад к календарю/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows contact info not set when duty has no phone or username", async () => {
|
||||
const { fetchDuties } = await import("@/lib/api");
|
||||
const now = new Date();
|
||||
const start = new Date(now.getTime() - 60 * 60 * 1000); // 1 hour ago
|
||||
const end = new Date(now.getTime() + 60 * 60 * 1000); // 1 hour from now
|
||||
const dutyNoContacts = {
|
||||
id: 1,
|
||||
user_id: 1,
|
||||
start_at: start.toISOString(),
|
||||
end_at: end.toISOString(),
|
||||
event_type: "duty" as const,
|
||||
full_name: "Test User",
|
||||
phone: null,
|
||||
username: null,
|
||||
};
|
||||
vi.mocked(fetchDuties).mockResolvedValue([dutyNoContacts]);
|
||||
const onBack = vi.fn();
|
||||
render(<CurrentDutyView onBack={onBack} />);
|
||||
await screen.findByText("Test User", {}, { timeout: 3000 });
|
||||
expect(
|
||||
screen.getByText(/Contact info not set|Контактные данные не указаны/i)
|
||||
).toBeInTheDocument();
|
||||
vi.mocked(fetchDuties).mockResolvedValue([]);
|
||||
});
|
||||
});
|
||||
323
webapp-next/src/components/current-duty/CurrentDutyView.tsx
Normal file
323
webapp-next/src/components/current-duty/CurrentDutyView.tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* Current duty view: full-screen card when opened via Mini App deep link (startapp=duty).
|
||||
* Fetches today's duties, finds the active one, shows name, shift, auto-updating remaining time,
|
||||
* and contact links. Integrates with Telegram BackButton.
|
||||
* Ported from webapp/js/currentDuty.js.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { backButton, closeMiniApp } from "@telegram-apps/sdk-react";
|
||||
import { Calendar } from "lucide-react";
|
||||
import { useTranslation } from "@/i18n/use-translation";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { useTelegramAuth } from "@/hooks/use-telegram-auth";
|
||||
import { fetchDuties, AccessDeniedError } from "@/lib/api";
|
||||
import {
|
||||
localDateString,
|
||||
dateKeyToDDMM,
|
||||
formatHHMM,
|
||||
} from "@/lib/date-utils";
|
||||
import { getRemainingTime, findCurrentDuty } from "@/lib/current-duty";
|
||||
import { ContactLinks } from "@/components/contact/ContactLinks";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import type { DutyWithUser } from "@/types";
|
||||
|
||||
export interface CurrentDutyViewProps {
|
||||
/** Called when user taps Back (in-app button or Telegram BackButton). */
|
||||
onBack: () => void;
|
||||
/** True when opened via pin button (startParam=duty). Shows Close instead of Back to calendar. */
|
||||
openedFromPin?: boolean;
|
||||
}
|
||||
|
||||
type ViewState = "loading" | "error" | "ready";
|
||||
|
||||
/**
|
||||
* Full-screen current duty view with Telegram BackButton and auto-updating remaining time.
|
||||
*/
|
||||
export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const lang = useAppStore((s) => s.lang);
|
||||
const setAppContentReady = useAppStore((s) => s.setAppContentReady);
|
||||
const { initDataRaw } = useTelegramAuth();
|
||||
|
||||
const [state, setState] = useState<ViewState>("loading");
|
||||
const [duty, setDuty] = useState<DutyWithUser | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [remaining, setRemaining] = useState<{ hours: number; minutes: number } | null>(null);
|
||||
|
||||
const loadTodayDuties = useCallback(
|
||||
async (signal?: AbortSignal | null) => {
|
||||
const today = new Date();
|
||||
const from = localDateString(today);
|
||||
const to = from;
|
||||
const initData = initDataRaw ?? "";
|
||||
try {
|
||||
const duties = await fetchDuties(from, to, initData, lang, signal);
|
||||
if (signal?.aborted) return;
|
||||
const active = findCurrentDuty(duties);
|
||||
setDuty(active);
|
||||
setState("ready");
|
||||
if (active) {
|
||||
setRemaining(getRemainingTime(active.end_at));
|
||||
} else {
|
||||
setRemaining(null);
|
||||
}
|
||||
} catch (e) {
|
||||
if (signal?.aborted) return;
|
||||
setState("error");
|
||||
const msg =
|
||||
e instanceof AccessDeniedError && e.serverDetail
|
||||
? e.serverDetail
|
||||
: t("error_generic");
|
||||
setErrorMessage(msg);
|
||||
setDuty(null);
|
||||
setRemaining(null);
|
||||
}
|
||||
},
|
||||
[initDataRaw, lang, t]
|
||||
);
|
||||
|
||||
// Fetch today's duties on mount; abort on unmount to avoid setState after unmount.
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
loadTodayDuties(controller.signal);
|
||||
return () => controller.abort();
|
||||
}, [loadTodayDuties]);
|
||||
|
||||
// Mark content ready when data is loaded or error, so page can call ready() and show content.
|
||||
useEffect(() => {
|
||||
if (state !== "loading") {
|
||||
setAppContentReady(true);
|
||||
}
|
||||
}, [state, setAppContentReady]);
|
||||
|
||||
// Auto-update remaining time every second when there is an active duty.
|
||||
useEffect(() => {
|
||||
if (!duty) return;
|
||||
const interval = setInterval(() => {
|
||||
setRemaining(getRemainingTime(duty.end_at));
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [duty]);
|
||||
|
||||
// Telegram BackButton: show on mount, hide on unmount, handle click.
|
||||
useEffect(() => {
|
||||
let offClick: (() => void) | undefined;
|
||||
try {
|
||||
if (backButton.mount.isAvailable()) {
|
||||
backButton.mount();
|
||||
}
|
||||
if (backButton.show.isAvailable()) {
|
||||
backButton.show();
|
||||
}
|
||||
if (backButton.onClick.isAvailable()) {
|
||||
offClick = backButton.onClick(onBack);
|
||||
}
|
||||
} catch {
|
||||
// Non-Telegram environment; BackButton not available.
|
||||
}
|
||||
|
||||
return () => {
|
||||
try {
|
||||
if (typeof offClick === "function") offClick();
|
||||
if (backButton.hide.isAvailable()) {
|
||||
backButton.hide();
|
||||
}
|
||||
} catch {
|
||||
// Ignore cleanup errors in non-Telegram environment.
|
||||
}
|
||||
};
|
||||
}, [onBack]);
|
||||
|
||||
const handleBack = () => {
|
||||
onBack();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (closeMiniApp.isAvailable()) {
|
||||
closeMiniApp();
|
||||
} else {
|
||||
onBack();
|
||||
}
|
||||
};
|
||||
|
||||
const primaryButtonLabel = openedFromPin ? t("current_duty.close") : t("current_duty.back");
|
||||
const primaryButtonAriaLabel = openedFromPin
|
||||
? t("current_duty.close")
|
||||
: t("current_duty.back");
|
||||
const handlePrimaryAction = openedFromPin ? handleClose : handleBack;
|
||||
|
||||
if (state === "loading") {
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-[50vh] flex-col items-center justify-center gap-4 px-4"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label={t("loading")}
|
||||
>
|
||||
<span
|
||||
className="block size-8 shrink-0 rounded-full border-2 border-transparent border-t-accent motion-reduce:animate-none animate-spin"
|
||||
aria-hidden
|
||||
/>
|
||||
<p className="text-muted-foreground m-0">{t("loading")}</p>
|
||||
<Button variant="outline" onClick={handlePrimaryAction} aria-label={primaryButtonAriaLabel}>
|
||||
{primaryButtonLabel}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === "error") {
|
||||
const handleRetry = () => {
|
||||
setState("loading");
|
||||
loadTodayDuties();
|
||||
};
|
||||
return (
|
||||
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4 px-4">
|
||||
<Card className="w-full max-w-[var(--max-width-app)]">
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-error">{errorMessage}</p>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col gap-2 sm:flex-row sm:justify-end">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={handleRetry}
|
||||
aria-label={t("error.retry")}
|
||||
>
|
||||
{t("error.retry")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handlePrimaryAction}
|
||||
aria-label={primaryButtonAriaLabel}
|
||||
>
|
||||
{primaryButtonLabel}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!duty) {
|
||||
return (
|
||||
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4 px-4">
|
||||
<Card className="current-duty-card--no-duty w-full max-w-[var(--max-width-app)] border-t-4 border-t-muted">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("current_duty.title")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col items-center gap-4">
|
||||
<span
|
||||
className="flex items-center justify-center text-muted-foreground"
|
||||
aria-hidden
|
||||
>
|
||||
<Calendar className="size-12" strokeWidth={1.5} />
|
||||
</span>
|
||||
<p className="text-center text-muted-foreground">
|
||||
{t("current_duty.no_duty")}
|
||||
</p>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button onClick={handlePrimaryAction} aria-label={primaryButtonAriaLabel}>
|
||||
{primaryButtonLabel}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const startLocal = localDateString(new Date(duty.start_at));
|
||||
const endLocal = localDateString(new Date(duty.end_at));
|
||||
const startDDMM = dateKeyToDDMM(startLocal);
|
||||
const endDDMM = dateKeyToDDMM(endLocal);
|
||||
const startTime = formatHHMM(duty.start_at);
|
||||
const endTime = formatHHMM(duty.end_at);
|
||||
const shiftStr = `${startDDMM} ${startTime} — ${endDDMM} ${endTime}`;
|
||||
const rem = remaining ?? getRemainingTime(duty.end_at);
|
||||
const remainingStr = t("current_duty.remaining", {
|
||||
hours: String(rem.hours),
|
||||
minutes: String(rem.minutes),
|
||||
});
|
||||
const endsAtStr = t("current_duty.ends_at", { time: endTime });
|
||||
|
||||
const displayTz =
|
||||
typeof window !== "undefined" &&
|
||||
(window as unknown as { __DT_TZ?: string }).__DT_TZ;
|
||||
const shiftLabel = displayTz
|
||||
? t("current_duty.shift_tz", { tz: displayTz })
|
||||
: t("current_duty.shift_local");
|
||||
|
||||
const hasContacts =
|
||||
Boolean(duty.phone && String(duty.phone).trim()) ||
|
||||
Boolean(duty.username && String(duty.username).trim());
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4 px-4">
|
||||
<Card
|
||||
className="current-duty-card w-full max-w-[var(--max-width-app)] border-t-4 border-t-duty animate-in fade-in-0 slide-in-from-bottom-4 duration-300 motion-reduce:animate-none motion-reduce:duration-0"
|
||||
role="article"
|
||||
aria-labelledby="current-duty-title"
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle
|
||||
id="current-duty-title"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<span
|
||||
className="inline-block size-2.5 shrink-0 rounded-full bg-duty animate-pulse motion-reduce:animate-none"
|
||||
aria-hidden
|
||||
/>
|
||||
{t("current_duty.title")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<p className="font-medium text-foreground" id="current-duty-name">
|
||||
{duty.full_name}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{shiftLabel} {shiftStr}
|
||||
</p>
|
||||
<div
|
||||
className="rounded-lg bg-duty/10 px-3 py-2 text-sm font-medium text-foreground"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
>
|
||||
{remainingStr}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{endsAtStr}</p>
|
||||
{hasContacts ? (
|
||||
<ContactLinks
|
||||
phone={duty.phone}
|
||||
username={duty.username}
|
||||
layout="block"
|
||||
showLabels={true}
|
||||
contextLabel={duty.full_name ?? undefined}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("current_duty.contact_info_not_set")}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button
|
||||
onClick={handlePrimaryAction}
|
||||
aria-label={primaryButtonAriaLabel}
|
||||
>
|
||||
{primaryButtonLabel}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
2
webapp-next/src/components/current-duty/index.ts
Normal file
2
webapp-next/src/components/current-duty/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { CurrentDutyView } from "./CurrentDutyView";
|
||||
export type { CurrentDutyViewProps } from "./CurrentDutyView";
|
||||
77
webapp-next/src/components/day-detail/DayDetail.test.tsx
Normal file
77
webapp-next/src/components/day-detail/DayDetail.test.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Unit tests for DayDetailContent: sorts duties by start_at; duty entries show time and name only (no contact links).
|
||||
* Ported from webapp/js/dayDetail.test.js buildDayDetailContent.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { DayDetailContent } from "./DayDetailContent";
|
||||
import { resetAppStore } from "@/test/test-utils";
|
||||
import type { DutyWithUser } from "@/types";
|
||||
|
||||
function duty(
|
||||
full_name: string,
|
||||
start_at: string,
|
||||
end_at: string,
|
||||
extra: Partial<DutyWithUser> = {}
|
||||
): DutyWithUser {
|
||||
return {
|
||||
id: 1,
|
||||
user_id: 1,
|
||||
full_name,
|
||||
start_at,
|
||||
end_at,
|
||||
event_type: "duty",
|
||||
phone: null,
|
||||
username: null,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
describe("DayDetailContent", () => {
|
||||
beforeEach(() => {
|
||||
resetAppStore();
|
||||
});
|
||||
|
||||
it("sorts duty list by start_at when input order is wrong", () => {
|
||||
const dateKey = "2025-02-25";
|
||||
const duties = [
|
||||
duty("Петров", "2025-02-25T14:00:00Z", "2025-02-25T18:00:00Z", { id: 2 }),
|
||||
duty("Иванов", "2025-02-25T09:00:00Z", "2025-02-25T14:00:00Z", { id: 1 }),
|
||||
];
|
||||
render(
|
||||
<DayDetailContent
|
||||
dateKey={dateKey}
|
||||
duties={duties}
|
||||
eventSummaries={[]}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("Иванов")).toBeInTheDocument();
|
||||
expect(screen.getByText("Петров")).toBeInTheDocument();
|
||||
const body = document.body.innerHTML;
|
||||
const ivanovPos = body.indexOf("Иванов");
|
||||
const petrovPos = body.indexOf("Петров");
|
||||
expect(ivanovPos).toBeLessThan(petrovPos);
|
||||
});
|
||||
|
||||
it("shows duty time and name on one line and does not show contact links", () => {
|
||||
const dateKey = "2025-03-01";
|
||||
const duties = [
|
||||
duty("Alice", "2025-03-01T09:00:00Z", "2025-03-01T17:00:00Z", {
|
||||
phone: "+79991234567",
|
||||
username: "alice_dev",
|
||||
}),
|
||||
];
|
||||
render(
|
||||
<DayDetailContent
|
||||
dateKey={dateKey}
|
||||
duties={duties}
|
||||
eventSummaries={[]}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("Alice")).toBeInTheDocument();
|
||||
expect(document.querySelector('a[href^="tel:"]')).toBeNull();
|
||||
expect(document.querySelector('a[href*="t.me"]')).toBeNull();
|
||||
expect(screen.queryByText(/alice_dev/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
255
webapp-next/src/components/day-detail/DayDetail.tsx
Normal file
255
webapp-next/src/components/day-detail/DayDetail.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* Day detail panel: shadcn Popover on desktop (>=640px), Sheet (bottom) on mobile.
|
||||
* Renders DayDetailContent; anchor for popover is a virtual element at the clicked cell rect.
|
||||
* Owns anchor rect state; parent opens via ref.current.openWithRect(dateKey, rect).
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useIsDesktop } from "@/hooks/use-media-query";
|
||||
import { useTranslation } from "@/i18n/use-translation";
|
||||
import { dutiesByDate, calendarEventsByDate } from "@/lib/calendar-data";
|
||||
import { localDateString, dateKeyToDDMM } from "@/lib/date-utils";
|
||||
import { DayDetailContent } from "./DayDetailContent";
|
||||
import type { CalendarEvent, DutyWithUser } from "@/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
|
||||
/** Empty state for day detail: date and "no duties or events" message. */
|
||||
function DayDetailEmpty({ dateKey }: { dateKey: string }) {
|
||||
const { t } = useTranslation();
|
||||
const todayKey = localDateString(new Date());
|
||||
const ddmm = dateKeyToDDMM(dateKey);
|
||||
const title =
|
||||
dateKey === todayKey ? t("duty.today") + ", " + ddmm : ddmm;
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h2
|
||||
id="day-detail-title"
|
||||
className="text-[1.1rem] font-semibold leading-tight m-0"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground m-0">
|
||||
{t("day_detail.no_events")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface DayDetailHandle {
|
||||
/** Open the panel for the given day with popover anchored at the given rect. */
|
||||
openWithRect: (dateKey: string, anchorRect: DOMRect) => void;
|
||||
}
|
||||
|
||||
export interface DayDetailProps {
|
||||
/** All duties for the visible range (will be filtered by selectedDay). */
|
||||
duties: DutyWithUser[];
|
||||
/** All calendar events for the visible range. */
|
||||
calendarEvents: CalendarEvent[];
|
||||
/** Called when the panel should close. */
|
||||
onClose: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Virtual anchor: invisible div at the given rect so Popover positions relative to it.
|
||||
*/
|
||||
function VirtualAnchor({
|
||||
rect,
|
||||
className,
|
||||
}: {
|
||||
rect: DOMRect;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn("pointer-events-none fixed z-0", className)}
|
||||
style={{
|
||||
left: rect.left,
|
||||
top: rect.bottom,
|
||||
width: rect.width,
|
||||
height: 1,
|
||||
}}
|
||||
aria-hidden
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const DayDetail = React.forwardRef<DayDetailHandle, DayDetailProps>(
|
||||
function DayDetail({ duties, calendarEvents, onClose, className }, ref) {
|
||||
const isDesktop = useIsDesktop();
|
||||
const { t } = useTranslation();
|
||||
const selectedDay = useAppStore((s) => s.selectedDay);
|
||||
const setSelectedDay = useAppStore((s) => s.setSelectedDay);
|
||||
const [anchorRect, setAnchorRect] = React.useState<DOMRect | null>(null);
|
||||
const [exiting, setExiting] = React.useState(false);
|
||||
|
||||
const open = selectedDay !== null;
|
||||
|
||||
React.useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
openWithRect(dateKey: string, rect: DOMRect) {
|
||||
setSelectedDay(dateKey);
|
||||
setAnchorRect(rect);
|
||||
},
|
||||
}),
|
||||
[setSelectedDay]
|
||||
);
|
||||
|
||||
const handleClose = React.useCallback(() => {
|
||||
setSelectedDay(null);
|
||||
setAnchorRect(null);
|
||||
setExiting(false);
|
||||
onClose();
|
||||
}, [setSelectedDay, onClose]);
|
||||
|
||||
/** Start close animation; actual unmount happens in onCloseAnimationEnd (or fallback timeout). */
|
||||
const requestClose = React.useCallback(() => {
|
||||
setExiting(true);
|
||||
}, []);
|
||||
|
||||
// Fallback: if onAnimationEnd never fires (e.g. reduced motion), close after animation duration
|
||||
React.useEffect(() => {
|
||||
if (!exiting) return;
|
||||
const fallback = window.setTimeout(() => {
|
||||
handleClose();
|
||||
}, 320);
|
||||
return () => window.clearTimeout(fallback);
|
||||
}, [exiting, handleClose]);
|
||||
|
||||
const dutiesByDateMap = React.useMemo(
|
||||
() => dutiesByDate(duties),
|
||||
[duties]
|
||||
);
|
||||
const eventsByDateMap = React.useMemo(
|
||||
() => calendarEventsByDate(calendarEvents),
|
||||
[calendarEvents]
|
||||
);
|
||||
|
||||
const dayDuties = selectedDay ? dutiesByDateMap[selectedDay] ?? [] : [];
|
||||
const dayEvents = selectedDay ? eventsByDateMap[selectedDay] ?? [] : [];
|
||||
const hasContent = dayDuties.length > 0 || dayEvents.length > 0;
|
||||
|
||||
// Close popover/sheet on window resize so anchor position does not become stale.
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
const onResize = () => handleClose();
|
||||
window.addEventListener("resize", onResize);
|
||||
return () => window.removeEventListener("resize", onResize);
|
||||
}, [open, handleClose]);
|
||||
|
||||
const content =
|
||||
selectedDay &&
|
||||
(hasContent ? (
|
||||
<DayDetailContent
|
||||
dateKey={selectedDay}
|
||||
duties={dayDuties}
|
||||
eventSummaries={dayEvents}
|
||||
/>
|
||||
) : (
|
||||
<DayDetailEmpty dateKey={selectedDay} />
|
||||
));
|
||||
|
||||
if (!open || !selectedDay) return null;
|
||||
|
||||
const panelClassName =
|
||||
"max-w-[min(360px,calc(100vw-24px))] max-h-[70vh] overflow-auto bg-surface text-[var(--text)] rounded-xl shadow-lg p-4 pt-9";
|
||||
const closeButton = (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute top-2 right-2 h-8 w-8 text-muted hover:text-[var(--text)] rounded-lg"
|
||||
onClick={requestClose}
|
||||
aria-label={t("day_detail.close")}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</Button>
|
||||
);
|
||||
|
||||
const renderSheet = (withHandle: boolean) => (
|
||||
<Sheet
|
||||
open={!exiting && open}
|
||||
onOpenChange={(o) => !o && requestClose()}
|
||||
>
|
||||
<SheetContent
|
||||
side="bottom"
|
||||
className={cn(
|
||||
"rounded-t-2xl pt-3 pb-[calc(24px+env(safe-area-inset-bottom,0px))] max-h-[70vh] bg-[var(--surface)]",
|
||||
className
|
||||
)}
|
||||
showCloseButton={false}
|
||||
onCloseAnimationEnd={handleClose}
|
||||
>
|
||||
<div className="relative px-4">
|
||||
{closeButton}
|
||||
{withHandle && (
|
||||
<div
|
||||
className="w-10 h-1 rounded-full bg-[var(--handle-bg)] mx-auto mb-2"
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
<SheetHeader className="p-0">
|
||||
<SheetTitle id="day-detail-sheet-title" className="sr-only">
|
||||
{selectedDay}
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
{content}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
|
||||
if (isDesktop === true && anchorRect != null) {
|
||||
return (
|
||||
<Popover open={open} onOpenChange={(o) => !o && handleClose()}>
|
||||
<PopoverAnchor asChild>
|
||||
<VirtualAnchor rect={anchorRect} />
|
||||
</PopoverAnchor>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
sideOffset={8}
|
||||
align="center"
|
||||
className={cn(panelClassName, "relative", className)}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={handleClose}
|
||||
>
|
||||
{closeButton}
|
||||
{content}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
return renderSheet(true);
|
||||
}
|
||||
);
|
||||
187
webapp-next/src/components/day-detail/DayDetailContent.tsx
Normal file
187
webapp-next/src/components/day-detail/DayDetailContent.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Shared content for day detail: title and sections (duty, unavailable, vacation, events).
|
||||
* Ported from webapp/js/dayDetail.js buildDayDetailContent.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "@/i18n/use-translation";
|
||||
import { localDateString, dateKeyToDDMM } from "@/lib/date-utils";
|
||||
import { getDutyMarkerRows } from "@/lib/duty-marker-rows";
|
||||
import type { DutyWithUser } from "@/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const NBSP = "\u00a0";
|
||||
|
||||
export interface DayDetailContentProps {
|
||||
/** YYYY-MM-DD key for the day. */
|
||||
dateKey: string;
|
||||
/** Duties overlapping this day. */
|
||||
duties: DutyWithUser[];
|
||||
/** Calendar event summary strings for this day. */
|
||||
eventSummaries: string[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function DayDetailContent({
|
||||
dateKey,
|
||||
duties,
|
||||
eventSummaries,
|
||||
className,
|
||||
}: DayDetailContentProps) {
|
||||
const { t } = useTranslation();
|
||||
const todayKey = localDateString(new Date());
|
||||
const ddmm = dateKeyToDDMM(dateKey);
|
||||
const title =
|
||||
dateKey === todayKey ? t("duty.today") + ", " + ddmm : ddmm;
|
||||
|
||||
const fromLabel = t("hint.from");
|
||||
const toLabel = t("hint.to");
|
||||
|
||||
const dutyList = useMemo(
|
||||
() =>
|
||||
duties
|
||||
.filter((d) => d.event_type === "duty")
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(a.start_at || 0).getTime() -
|
||||
new Date(b.start_at || 0).getTime()
|
||||
),
|
||||
[duties]
|
||||
);
|
||||
|
||||
const unavailableList = useMemo(
|
||||
() => duties.filter((d) => d.event_type === "unavailable"),
|
||||
[duties]
|
||||
);
|
||||
|
||||
const vacationList = useMemo(
|
||||
() => duties.filter((d) => d.event_type === "vacation"),
|
||||
[duties]
|
||||
);
|
||||
|
||||
const dutyRows = useMemo(() => {
|
||||
const hasTimes = dutyList.some((it) => it.start_at || it.end_at);
|
||||
return hasTimes
|
||||
? getDutyMarkerRows(dutyList, dateKey, NBSP, fromLabel, toLabel)
|
||||
: dutyList.map((d) => ({
|
||||
id: d.id,
|
||||
timePrefix: "",
|
||||
fullName: d.full_name ?? "",
|
||||
phone: d.phone,
|
||||
username: d.username,
|
||||
}));
|
||||
}, [dutyList, dateKey, fromLabel, toLabel]);
|
||||
|
||||
const uniqueUnavailable = useMemo(
|
||||
() => [
|
||||
...new Set(
|
||||
unavailableList.map((d) => d.full_name ?? "").filter(Boolean)
|
||||
),
|
||||
],
|
||||
[unavailableList]
|
||||
);
|
||||
|
||||
const uniqueVacation = useMemo(
|
||||
() => [
|
||||
...new Set(vacationList.map((d) => d.full_name ?? "").filter(Boolean)),
|
||||
],
|
||||
[vacationList]
|
||||
);
|
||||
|
||||
const summaries = eventSummaries ?? [];
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-4", className)}>
|
||||
<h2
|
||||
id="day-detail-title"
|
||||
className="text-[1.1rem] font-semibold leading-tight m-0"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
<div className="flex flex-col gap-4">
|
||||
{dutyList.length > 0 && (
|
||||
<section
|
||||
className="day-detail-section-duty"
|
||||
aria-labelledby="day-detail-duty-heading"
|
||||
>
|
||||
<h3
|
||||
id="day-detail-duty-heading"
|
||||
className="text-[0.8rem] font-semibold text-duty m-0 mb-1"
|
||||
>
|
||||
{t("event_type.duty")}
|
||||
</h3>
|
||||
<ul className="list-[disc] pl-5 m-0 text-[0.9rem] leading-snug space-y-1 [&_li]:flex [&_li]:items-baseline [&_li]:gap-1">
|
||||
{dutyRows.map((r) => (
|
||||
<li key={r.id}>
|
||||
{r.timePrefix && (
|
||||
<span className="text-muted-foreground">{r.timePrefix} — </span>
|
||||
)}
|
||||
<span className="font-semibold">{r.fullName}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{uniqueUnavailable.length > 0 && (
|
||||
<section
|
||||
className="day-detail-section-unavailable"
|
||||
aria-labelledby="day-detail-unavailable-heading"
|
||||
>
|
||||
<h3
|
||||
id="day-detail-unavailable-heading"
|
||||
className="text-[0.8rem] font-semibold text-unavailable m-0 mb-1"
|
||||
>
|
||||
{t("event_type.unavailable")}
|
||||
</h3>
|
||||
<ul className="list-[disc] pl-5 m-0 text-[0.9rem] leading-snug space-y-1">
|
||||
{uniqueUnavailable.map((name) => (
|
||||
<li key={name}>{name}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{uniqueVacation.length > 0 && (
|
||||
<section
|
||||
className="day-detail-section-vacation"
|
||||
aria-labelledby="day-detail-vacation-heading"
|
||||
>
|
||||
<h3
|
||||
id="day-detail-vacation-heading"
|
||||
className="text-[0.8rem] font-semibold text-vacation m-0 mb-1"
|
||||
>
|
||||
{t("event_type.vacation")}
|
||||
</h3>
|
||||
<ul className="list-[disc] pl-5 m-0 text-[0.9rem] leading-snug space-y-1">
|
||||
{uniqueVacation.map((name) => (
|
||||
<li key={name}>{name}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{summaries.length > 0 && (
|
||||
<section
|
||||
className="day-detail-section-events"
|
||||
aria-labelledby="day-detail-events-heading"
|
||||
>
|
||||
<h3
|
||||
id="day-detail-events-heading"
|
||||
className="text-[0.8rem] font-semibold text-accent m-0 mb-1"
|
||||
>
|
||||
{t("hint.events")}
|
||||
</h3>
|
||||
<ul className="list-[disc] pl-5 m-0 text-[0.9rem] leading-snug space-y-1">
|
||||
{summaries.map((s) => (
|
||||
<li key={String(s)}>{String(s)}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
4
webapp-next/src/components/day-detail/index.ts
Normal file
4
webapp-next/src/components/day-detail/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { DayDetail } from "./DayDetail";
|
||||
export { DayDetailContent } from "./DayDetailContent";
|
||||
export type { DayDetailContentProps } from "./DayDetailContent";
|
||||
export type { DayDetailHandle, DayDetailProps } from "./DayDetail";
|
||||
80
webapp-next/src/components/duty/DutyItem.tsx
Normal file
80
webapp-next/src/components/duty/DutyItem.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Single duty row: event type label, name, time range.
|
||||
* Used inside timeline cards and day detail. Ported from webapp/js/dutyList.js dutyItemHtml.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "@/i18n/use-translation";
|
||||
import { formatHHMM, formatDateKey } from "@/lib/date-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { DutyWithUser } from "@/types";
|
||||
|
||||
export interface DutyItemProps {
|
||||
duty: DutyWithUser;
|
||||
/** Override type label (e.g. "On duty now"). */
|
||||
typeLabelOverride?: string;
|
||||
/** Show "until HH:MM" instead of full range (for current duty). */
|
||||
showUntilEnd?: boolean;
|
||||
/** Extra class, e.g. for current duty highlight. */
|
||||
isCurrent?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const borderByType = {
|
||||
duty: "border-l-duty",
|
||||
unavailable: "border-l-unavailable",
|
||||
vacation: "border-l-vacation",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Renders type badge, name, and time. Timeline cards use event_type for border color.
|
||||
*/
|
||||
export function DutyItem({
|
||||
duty,
|
||||
typeLabelOverride,
|
||||
showUntilEnd = false,
|
||||
isCurrent = false,
|
||||
className,
|
||||
}: DutyItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const typeLabel =
|
||||
typeLabelOverride ?? t(`event_type.${duty.event_type || "duty"}`);
|
||||
const eventType = (duty.event_type || "duty") as keyof typeof borderByType;
|
||||
const borderClass = isCurrent ? "border-l-today" : borderByType[eventType] ?? "border-l-duty";
|
||||
|
||||
let timeOrRange: string;
|
||||
if (showUntilEnd && duty.event_type === "duty") {
|
||||
timeOrRange = t("duty.until", { time: formatHHMM(duty.end_at) });
|
||||
} else if (duty.event_type === "vacation" || duty.event_type === "unavailable") {
|
||||
const startStr = formatDateKey(duty.start_at);
|
||||
const endStr = formatDateKey(duty.end_at);
|
||||
timeOrRange = startStr === endStr ? startStr : `${startStr} – ${endStr}`;
|
||||
} else {
|
||||
timeOrRange = `${formatHHMM(duty.start_at)} – ${formatHHMM(duty.end_at)}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid grid-cols-1 gap-y-0.5 items-baseline rounded-lg bg-surface px-2.5 py-2",
|
||||
"border-l-[3px] shadow-sm",
|
||||
"min-h-0",
|
||||
borderClass,
|
||||
isCurrent && "bg-[var(--surface-today-tint)]",
|
||||
className
|
||||
)}
|
||||
data-slot="duty-item"
|
||||
>
|
||||
<span className="text-xs text-muted col-span-1 row-start-1">
|
||||
{typeLabel}
|
||||
</span>
|
||||
<span className="font-semibold min-w-0 col-span-1 row-start-2 col-start-1">
|
||||
{duty.full_name}
|
||||
</span>
|
||||
<span className="text-[0.8rem] text-muted col-span-1 row-start-3 col-start-1">
|
||||
{timeOrRange}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
78
webapp-next/src/components/duty/DutyList.test.tsx
Normal file
78
webapp-next/src/components/duty/DutyList.test.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Unit tests for DutyList: renders timeline, flip card with contacts, duty items.
|
||||
* Ported from webapp/js/dutyList.test.js.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { DutyList } from "./DutyList";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { resetAppStore } from "@/test/test-utils";
|
||||
import type { DutyWithUser } from "@/types";
|
||||
|
||||
function duty(
|
||||
full_name: string,
|
||||
start_at: string,
|
||||
end_at: string,
|
||||
extra: Partial<DutyWithUser> = {}
|
||||
): DutyWithUser {
|
||||
return {
|
||||
id: 1,
|
||||
user_id: 1,
|
||||
full_name,
|
||||
start_at,
|
||||
end_at,
|
||||
event_type: "duty",
|
||||
phone: null,
|
||||
username: null,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
describe("DutyList", () => {
|
||||
beforeEach(() => {
|
||||
resetAppStore();
|
||||
useAppStore.getState().setCurrentMonth(new Date(2025, 1, 1)); // Feb 2025
|
||||
});
|
||||
|
||||
it("renders no duties message when duties empty and data loaded for month", () => {
|
||||
useAppStore.getState().setDuties([]);
|
||||
useAppStore.getState().batchUpdate({ dataForMonthKey: "2025-02" });
|
||||
render(<DutyList />);
|
||||
expect(screen.getByText(/No duties this month/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders compact loading placeholder when data not yet loaded for month (no skeleton)", () => {
|
||||
useAppStore.getState().setDuties([]);
|
||||
useAppStore.getState().batchUpdate({ dataForMonthKey: null });
|
||||
render(<DutyList />);
|
||||
expect(screen.queryByText(/No duties this month/i)).not.toBeInTheDocument();
|
||||
expect(document.querySelector('[aria-busy="true"]')).toBeInTheDocument();
|
||||
expect(document.querySelector('[data-slot="skeleton"]')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders duty with full_name and time range", () => {
|
||||
useAppStore.getState().setDuties([
|
||||
duty("Иванов", "2025-02-25T09:00:00Z", "2025-02-25T18:00:00Z"),
|
||||
]);
|
||||
useAppStore.getState().setCurrentMonth(new Date(2025, 1, 1));
|
||||
useAppStore.getState().batchUpdate({ dataForMonthKey: "2025-02" });
|
||||
render(<DutyList />);
|
||||
expect(screen.getByText("Иванов")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders flip card with contact links when phone or username present", () => {
|
||||
useAppStore.getState().setDuties([
|
||||
duty("Alice", "2025-03-01T09:00:00Z", "2025-03-01T17:00:00Z", {
|
||||
phone: "+79991234567",
|
||||
username: "alice_dev",
|
||||
}),
|
||||
]);
|
||||
useAppStore.getState().setCurrentMonth(new Date(2025, 2, 1));
|
||||
useAppStore.getState().batchUpdate({ dataForMonthKey: "2025-03" });
|
||||
render(<DutyList />);
|
||||
expect(screen.getAllByText("Alice").length).toBeGreaterThanOrEqual(1);
|
||||
expect(document.querySelector('a[href^="tel:"]')).toBeInTheDocument();
|
||||
expect(document.querySelector('a[href*="t.me"]')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
278
webapp-next/src/components/duty/DutyList.tsx
Normal file
278
webapp-next/src/components/duty/DutyList.tsx
Normal file
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* Duty timeline list for the current month: dates, track line, flip cards.
|
||||
* Scrolls to current duty or today on mount. Ported from webapp/js/dutyList.js renderDutyList.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useTranslation } from "@/i18n/use-translation";
|
||||
import {
|
||||
localDateString,
|
||||
firstDayOfMonth,
|
||||
lastDayOfMonth,
|
||||
dateKeyToDDMM,
|
||||
} from "@/lib/date-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { LoadingState } from "@/components/states/LoadingState";
|
||||
import { DutyTimelineCard } from "./DutyTimelineCard";
|
||||
|
||||
/** Extra offset so the sticky calendar slightly overlaps the target card (card sits a bit under the calendar). */
|
||||
const SCROLL_OVERLAP_PX = 14;
|
||||
|
||||
/**
|
||||
* Skeleton placeholder for duty list (e.g. when loading a new month).
|
||||
* Shows 4 card-shaped placeholders in timeline layout.
|
||||
*/
|
||||
export function DutyListSkeleton({ className }: { className?: string }) {
|
||||
return (
|
||||
<div className={cn("duty-timeline relative text-[0.9rem]", className)}>
|
||||
<div
|
||||
className="absolute left-[calc(var(--timeline-date-width)+var(--timeline-track-width)/2-1px)] top-0 bottom-0 w-0.5 pointer-events-none bg-gradient-to-b from-muted from-0% to-[85%] to-[var(--muted-fade)]"
|
||||
aria-hidden
|
||||
/>
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="grid gap-x-1 items-start mb-2 min-h-0"
|
||||
style={{
|
||||
gridTemplateColumns:
|
||||
"var(--timeline-date-width) var(--timeline-track-width) 1fr",
|
||||
}}
|
||||
>
|
||||
<Skeleton className="h-14 w-12 rounded" />
|
||||
<span className="min-w-0" aria-hidden />
|
||||
<Skeleton className="h-20 w-full min-w-0 rounded-lg" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface DutyListProps {
|
||||
/** Offset from viewport top for scroll target (sticky calendar height + its padding, e.g. 268px). */
|
||||
scrollMarginTop?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders duty timeline (duty type only), grouped by date. Shows "Today" label and
|
||||
* auto-scrolls to current duty or today block. Uses CSS variables --timeline-date-width, --timeline-track-width.
|
||||
*/
|
||||
export function DutyList({ scrollMarginTop = 268, className }: DutyListProps) {
|
||||
const { t } = useTranslation();
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const { currentMonth, duties, dataForMonthKey } = useAppStore(
|
||||
useShallow((s) => ({
|
||||
currentMonth: s.currentMonth,
|
||||
duties: s.duties,
|
||||
dataForMonthKey: s.dataForMonthKey,
|
||||
}))
|
||||
);
|
||||
const monthKey = `${currentMonth.getFullYear()}-${String(currentMonth.getMonth() + 1).padStart(2, "0")}`;
|
||||
const hasDataForMonth = dataForMonthKey === monthKey;
|
||||
|
||||
const { filtered, dates, dutiesByDateKey } = useMemo(() => {
|
||||
const filteredList = duties.filter((d) => d.event_type === "duty");
|
||||
const todayKey = localDateString(new Date());
|
||||
const firstKey = localDateString(firstDayOfMonth(currentMonth));
|
||||
const lastKey = localDateString(lastDayOfMonth(currentMonth));
|
||||
const showTodayInMonth = todayKey >= firstKey && todayKey <= lastKey;
|
||||
|
||||
const dateSet = new Set<string>();
|
||||
filteredList.forEach((d) =>
|
||||
dateSet.add(localDateString(new Date(d.start_at)))
|
||||
);
|
||||
if (showTodayInMonth) dateSet.add(todayKey);
|
||||
const datesList = Array.from(dateSet).sort();
|
||||
|
||||
const byDate: Record<string, typeof filteredList> = {};
|
||||
datesList.forEach((date) => {
|
||||
byDate[date] = filteredList
|
||||
.filter((d) => localDateString(new Date(d.start_at)) === date)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(a.start_at).getTime() - new Date(b.start_at).getTime()
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
filtered: filteredList,
|
||||
dates: datesList,
|
||||
dutiesByDateKey: byDate,
|
||||
};
|
||||
}, [currentMonth, duties]);
|
||||
|
||||
const todayKey = localDateString(new Date());
|
||||
const [now, setNow] = useState(() => new Date());
|
||||
const [flippedDutyId, setFlippedDutyId] = useState<number | null>(null);
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(new Date()), 60_000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
const scrolledForMonthRef = useRef<string | null>(null);
|
||||
const prevScrollMarginTopRef = useRef<number>(scrollMarginTop);
|
||||
const prevMonthKeyRef = useRef<string>(monthKey);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollMarginTop !== prevScrollMarginTopRef.current) {
|
||||
scrolledForMonthRef.current = null;
|
||||
prevScrollMarginTopRef.current = scrollMarginTop;
|
||||
}
|
||||
if (prevMonthKeyRef.current !== monthKey) {
|
||||
scrolledForMonthRef.current = null;
|
||||
prevMonthKeyRef.current = monthKey;
|
||||
}
|
||||
|
||||
const el = listRef.current;
|
||||
if (!el) return;
|
||||
const currentCard = el.querySelector<HTMLElement>("[data-current-duty]");
|
||||
const todayBlock = el.querySelector<HTMLElement>("[data-today-block]");
|
||||
const target = currentCard ?? todayBlock;
|
||||
if (!target || scrolledForMonthRef.current === monthKey) return;
|
||||
|
||||
const effectiveMargin = Math.max(0, scrollMarginTop + SCROLL_OVERLAP_PX);
|
||||
const scrollTo = () => {
|
||||
const rect = target.getBoundingClientRect();
|
||||
const scrollTop = window.scrollY + rect.top - effectiveMargin;
|
||||
window.scrollTo({ top: scrollTop, behavior: "smooth" });
|
||||
scrolledForMonthRef.current = monthKey;
|
||||
};
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(scrollTo);
|
||||
});
|
||||
}, [filtered, dates.length, scrollMarginTop, monthKey]);
|
||||
|
||||
if (!hasDataForMonth) {
|
||||
return (
|
||||
<div aria-busy="true" className={className}>
|
||||
<LoadingState asPlaceholder />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (filtered.length === 0) {
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-1", className)}>
|
||||
<p className="text-sm text-muted m-0">{t("duty.none_this_month")}</p>
|
||||
<p className="text-xs text-muted m-0">{t("duty.none_this_month_hint")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={listRef} className={className}>
|
||||
<div className="duty-timeline relative text-[0.9rem]">
|
||||
{/* Vertical track line */}
|
||||
<div
|
||||
className="absolute left-[calc(var(--timeline-date-width)+var(--timeline-track-width)/2-1px)] top-0 bottom-0 w-0.5 pointer-events-none bg-gradient-to-b from-muted from-0% to-[85%] to-[var(--muted-fade)]"
|
||||
aria-hidden
|
||||
/>
|
||||
{dates.map((date) => {
|
||||
const isToday = date === todayKey;
|
||||
const dateLabel = dateKeyToDDMM(date);
|
||||
const dayDuties = dutiesByDateKey[date] ?? [];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={date}
|
||||
className="mb-0"
|
||||
style={isToday ? { scrollMarginTop: `${scrollMarginTop}px` } : undefined}
|
||||
data-date={date}
|
||||
data-today-block={isToday ? true : undefined}
|
||||
>
|
||||
{dayDuties.length > 0 ? (
|
||||
dayDuties.map((duty) => {
|
||||
const start = new Date(duty.start_at);
|
||||
const end = new Date(duty.end_at);
|
||||
const isCurrent = start <= now && now < end;
|
||||
return (
|
||||
<div
|
||||
key={duty.id}
|
||||
className="grid gap-x-1 items-start mb-2 min-h-0"
|
||||
style={{
|
||||
gridTemplateColumns:
|
||||
"var(--timeline-date-width) var(--timeline-track-width) 1fr",
|
||||
}}
|
||||
>
|
||||
<TimelineDateCell
|
||||
dateLabel={dateLabel}
|
||||
isToday={isToday}
|
||||
/>
|
||||
<span
|
||||
className="min-w-0"
|
||||
aria-hidden
|
||||
/>
|
||||
<div
|
||||
className="min-w-0 overflow-hidden"
|
||||
{...(isCurrent ? { "data-current-duty": true } : {})}
|
||||
>
|
||||
<DutyTimelineCard
|
||||
duty={duty}
|
||||
isCurrent={isCurrent}
|
||||
isFlipped={flippedDutyId === duty.id}
|
||||
onFlipChange={(flip) =>
|
||||
setFlippedDutyId(flip ? duty.id : null)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div
|
||||
className="grid gap-x-1 items-start mb-2 min-h-0"
|
||||
style={{
|
||||
gridTemplateColumns:
|
||||
"var(--timeline-date-width) var(--timeline-track-width) 1fr",
|
||||
}}
|
||||
>
|
||||
<TimelineDateCell
|
||||
dateLabel={dateLabel}
|
||||
isToday={isToday}
|
||||
/>
|
||||
<span className="min-w-0" aria-hidden />
|
||||
<div className="min-w-0" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineDateCell({
|
||||
dateLabel,
|
||||
isToday,
|
||||
}: {
|
||||
dateLabel: string;
|
||||
isToday: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"duty-timeline-date relative text-[0.8rem] text-muted pt-2.5 pb-2.5 flex-shrink-0 overflow-visible",
|
||||
isToday && "duty-timeline-date--today flex flex-col items-start pt-1 text-today font-semibold"
|
||||
)}
|
||||
>
|
||||
{isToday ? (
|
||||
<>
|
||||
<span className="duty-timeline-date-label text-today block leading-tight">
|
||||
{t("duty.today")}
|
||||
</span>
|
||||
<span className="duty-timeline-date-day text-muted font-normal text-[0.75rem] block self-start text-left">
|
||||
{dateLabel}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
dateLabel
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
194
webapp-next/src/components/duty/DutyTimelineCard.tsx
Normal file
194
webapp-next/src/components/duty/DutyTimelineCard.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Timeline duty card: front = duty info + flip button; back = name + contacts + back button.
|
||||
* Flip card only when duty has phone or username. Ported from webapp/js/dutyList.js dutyTimelineCardHtml.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useRef } from "react";
|
||||
import { useTranslation } from "@/i18n/use-translation";
|
||||
import {
|
||||
localDateString,
|
||||
dateKeyToDDMM,
|
||||
formatHHMM,
|
||||
} from "@/lib/date-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ContactLinks } from "@/components/contact/ContactLinks";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { DutyWithUser } from "@/types";
|
||||
import { Phone, ArrowLeft } from "lucide-react";
|
||||
|
||||
export interface DutyTimelineCardProps {
|
||||
duty: DutyWithUser;
|
||||
isCurrent: boolean;
|
||||
/** When provided, card is controlled: only one card can be flipped at a time (managed by parent). */
|
||||
isFlipped?: boolean;
|
||||
/** Called when user flips to contacts (true) or back (false). Used with isFlipped for controlled mode. */
|
||||
onFlipChange?: (flipped: boolean) => void;
|
||||
}
|
||||
|
||||
function buildTimeStr(duty: DutyWithUser): string {
|
||||
const startLocal = localDateString(new Date(duty.start_at));
|
||||
const endLocal = localDateString(new Date(duty.end_at));
|
||||
const startDDMM = dateKeyToDDMM(startLocal);
|
||||
const endDDMM = dateKeyToDDMM(endLocal);
|
||||
const startTime = formatHHMM(duty.start_at);
|
||||
const endTime = formatHHMM(duty.end_at);
|
||||
if (startLocal === endLocal) {
|
||||
return `${startDDMM}, ${startTime} – ${endTime}`;
|
||||
}
|
||||
return `${startDDMM} ${startTime} – ${endDDMM} ${endTime}`;
|
||||
}
|
||||
|
||||
const cardBase =
|
||||
"grid grid-cols-1 gap-y-0.5 items-baseline rounded-lg bg-surface px-2.5 py-2 border-l-[3px] shadow-sm min-h-0 pr-12 relative";
|
||||
const borderByType = {
|
||||
duty: "border-l-duty",
|
||||
unavailable: "border-l-unavailable",
|
||||
vacation: "border-l-vacation",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Renders a single duty card. If duty has phone or username, wraps in a flip card
|
||||
* (front: type, name, time + "Contacts" button; back: name, ContactLinks, "Back" button).
|
||||
*/
|
||||
export function DutyTimelineCard({
|
||||
duty,
|
||||
isCurrent,
|
||||
isFlipped,
|
||||
onFlipChange,
|
||||
}: DutyTimelineCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [localFlipped, setLocalFlipped] = useState(false);
|
||||
const frontBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const backBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const hasContacts = Boolean(
|
||||
(duty.phone && String(duty.phone).trim()) ||
|
||||
(duty.username && String(duty.username).trim())
|
||||
);
|
||||
const typeLabel = isCurrent
|
||||
? t("duty.now_on_duty")
|
||||
: t(`event_type.${duty.event_type || "duty"}`);
|
||||
const timeStr = useMemo(() => buildTimeStr(duty), [duty]);
|
||||
const eventType = (duty.event_type || "duty") as keyof typeof borderByType;
|
||||
const borderClass = isCurrent ? "border-l-today" : borderByType[eventType] ?? "border-l-duty";
|
||||
|
||||
const isControlled = onFlipChange != null;
|
||||
const flipped = isControlled ? (isFlipped ?? false) : localFlipped;
|
||||
|
||||
const handleFlipToBack = () => {
|
||||
if (isControlled) {
|
||||
onFlipChange?.(true);
|
||||
} else {
|
||||
setLocalFlipped(true);
|
||||
}
|
||||
setTimeout(() => backBtnRef.current?.focus(), 310);
|
||||
};
|
||||
|
||||
const handleFlipToFront = () => {
|
||||
if (isControlled) {
|
||||
onFlipChange?.(false);
|
||||
} else {
|
||||
setLocalFlipped(false);
|
||||
}
|
||||
setTimeout(() => frontBtnRef.current?.focus(), 310);
|
||||
};
|
||||
|
||||
if (!hasContacts) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
cardBase,
|
||||
borderClass,
|
||||
isCurrent && "bg-[var(--surface-today-tint)]"
|
||||
)}
|
||||
>
|
||||
<span className="text-xs text-muted row-start-1">{typeLabel}</span>
|
||||
<span
|
||||
className="font-semibold min-w-0 row-start-2 whitespace-nowrap overflow-hidden text-ellipsis"
|
||||
title={duty.full_name ?? undefined}
|
||||
>
|
||||
{duty.full_name}
|
||||
</span>
|
||||
<span className="text-[0.8rem] text-muted row-start-3">{timeStr}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="duty-flip-card relative min-h-0 overflow-hidden rounded-lg">
|
||||
<div
|
||||
className="duty-flip-inner relative min-h-0 transition-transform duration-300 motion-reduce:duration-[0.01ms]"
|
||||
style={{
|
||||
transformStyle: "preserve-3d",
|
||||
transform: flipped ? "rotateY(180deg)" : "none",
|
||||
}}
|
||||
>
|
||||
{/* Front */}
|
||||
<div
|
||||
className={cn(
|
||||
cardBase,
|
||||
borderClass,
|
||||
isCurrent && "bg-[var(--surface-today-tint)]",
|
||||
"duty-flip-front"
|
||||
)}
|
||||
>
|
||||
<span className="text-xs text-muted row-start-1">{typeLabel}</span>
|
||||
<span
|
||||
className="font-semibold min-w-0 row-start-2 whitespace-nowrap overflow-hidden text-ellipsis"
|
||||
title={duty.full_name ?? undefined}
|
||||
>
|
||||
{duty.full_name}
|
||||
</span>
|
||||
<span className="text-[0.8rem] text-muted row-start-3">{timeStr}</span>
|
||||
<Button
|
||||
ref={frontBtnRef}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 size-9 rounded-full bg-surface text-accent hover:bg-accent/20"
|
||||
aria-label={t("contact.show")}
|
||||
onClick={handleFlipToBack}
|
||||
>
|
||||
<Phone className="size-[18px]" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
{/* Back */}
|
||||
<div
|
||||
className={cn(
|
||||
cardBase,
|
||||
borderClass,
|
||||
isCurrent && "bg-[var(--surface-today-tint)]",
|
||||
"duty-flip-back"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className="font-semibold min-w-0 row-start-1 whitespace-nowrap overflow-hidden text-ellipsis"
|
||||
title={duty.full_name ?? undefined}
|
||||
>
|
||||
{duty.full_name}
|
||||
</span>
|
||||
<div className="row-start-2 mt-1">
|
||||
<ContactLinks
|
||||
phone={duty.phone}
|
||||
username={duty.username}
|
||||
layout="inline"
|
||||
showLabels={false}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
ref={backBtnRef}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 size-9 rounded-full bg-surface text-accent hover:bg-accent/20"
|
||||
aria-label={t("contact.back")}
|
||||
onClick={handleFlipToFront}
|
||||
>
|
||||
<ArrowLeft className="size-[18px]" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
webapp-next/src/components/duty/index.ts
Normal file
10
webapp-next/src/components/duty/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Duty list and timeline components.
|
||||
*/
|
||||
|
||||
export { DutyList } from "./DutyList";
|
||||
export { DutyTimelineCard } from "./DutyTimelineCard";
|
||||
export { DutyItem } from "./DutyItem";
|
||||
export type { DutyListProps } from "./DutyList";
|
||||
export type { DutyTimelineCardProps } from "./DutyTimelineCard";
|
||||
export type { DutyItemProps } from "./DutyItem";
|
||||
46
webapp-next/src/components/providers/TelegramProvider.tsx
Normal file
46
webapp-next/src/components/providers/TelegramProvider.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import {
|
||||
init,
|
||||
mountMiniAppSync,
|
||||
mountThemeParamsSync,
|
||||
bindThemeParamsCssVars,
|
||||
} from "@telegram-apps/sdk-react";
|
||||
import { fixSurfaceContrast } from "@/hooks/use-telegram-theme";
|
||||
|
||||
/**
|
||||
* Wraps the app with Telegram Mini App SDK initialization.
|
||||
* Calls init(acceptCustomStyles), mounts theme params (binds --tg-theme-* CSS vars),
|
||||
* and mounts the mini app. Does not call ready() here — the app calls
|
||||
* callMiniAppReadyOnce() from lib/telegram-ready when the first visible screen
|
||||
* has finished loading, so Telegram keeps its native loading animation until then.
|
||||
* Theme is set before first paint by the inline script in layout.tsx (URL hash);
|
||||
* useTelegramTheme() in the app handles ongoing theme changes.
|
||||
*/
|
||||
export function TelegramProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const cleanup = init({ acceptCustomStyles: true });
|
||||
|
||||
if (mountThemeParamsSync.isAvailable()) {
|
||||
mountThemeParamsSync();
|
||||
}
|
||||
if (bindThemeParamsCssVars.isAvailable()) {
|
||||
bindThemeParamsCssVars();
|
||||
}
|
||||
fixSurfaceContrast();
|
||||
void document.documentElement.offsetHeight;
|
||||
|
||||
if (mountMiniAppSync.isAvailable()) {
|
||||
mountMiniAppSync();
|
||||
}
|
||||
|
||||
return cleanup;
|
||||
}, []);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
24
webapp-next/src/components/states/AccessDenied.test.tsx
Normal file
24
webapp-next/src/components/states/AccessDenied.test.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Unit tests for AccessDenied. Ported from webapp/js/ui.test.js showAccessDenied.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { AccessDenied } from "./AccessDenied";
|
||||
import { resetAppStore } from "@/test/test-utils";
|
||||
|
||||
describe("AccessDenied", () => {
|
||||
beforeEach(() => {
|
||||
resetAppStore();
|
||||
});
|
||||
|
||||
it("renders translated access denied message", () => {
|
||||
render(<AccessDenied serverDetail={null} />);
|
||||
expect(screen.getByText(/Access denied|Доступ запрещён/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("appends serverDetail when provided", () => {
|
||||
render(<AccessDenied serverDetail="Custom 403 message" />);
|
||||
expect(screen.getByText("Custom 403 message")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
46
webapp-next/src/components/states/AccessDenied.tsx
Normal file
46
webapp-next/src/components/states/AccessDenied.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Access denied state: message and optional server detail.
|
||||
* Ported from webapp/js/ui.js showAccessDenied and states.css .access-denied.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "@/i18n/use-translation";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface AccessDeniedProps {
|
||||
/** Optional detail from API 403 response, shown below the main message. */
|
||||
serverDetail?: string | null;
|
||||
/** Optional class for the container. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays access denied message; optional second paragraph for server detail.
|
||||
*/
|
||||
export function AccessDenied({ serverDetail, className }: AccessDeniedProps) {
|
||||
const { t } = useTranslation();
|
||||
const hasDetail = Boolean(serverDetail && String(serverDetail).trim());
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-xl bg-surface py-6 px-4 my-3 text-center text-muted-foreground shadow-sm transition-opacity duration-200",
|
||||
className
|
||||
)}
|
||||
role="alert"
|
||||
>
|
||||
<p className="m-0 mb-2 font-semibold text-error">
|
||||
{t("access_denied")}
|
||||
</p>
|
||||
{hasDetail && (
|
||||
<p className="mt-2 m-0 text-sm text-muted">
|
||||
{serverDetail}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2 m-0 text-sm text-muted">
|
||||
{t("access_denied.hint")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
webapp-next/src/components/states/ErrorState.test.tsx
Normal file
26
webapp-next/src/components/states/ErrorState.test.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Unit tests for ErrorState. Ported from webapp/js/ui.test.js showError.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { ErrorState } from "./ErrorState";
|
||||
import { resetAppStore } from "@/test/test-utils";
|
||||
|
||||
describe("ErrorState", () => {
|
||||
beforeEach(() => resetAppStore());
|
||||
|
||||
it("renders error message", () => {
|
||||
render(<ErrorState message="Network error" onRetry={undefined} />);
|
||||
expect(screen.getByText("Network error")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Retry button when onRetry provided", () => {
|
||||
const onRetry = vi.fn();
|
||||
render(<ErrorState message="Fail" onRetry={onRetry} />);
|
||||
const retry = screen.getByRole("button", { name: /retry|повторить/i });
|
||||
expect(retry).toBeInTheDocument();
|
||||
retry.click();
|
||||
expect(onRetry).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
75
webapp-next/src/components/states/ErrorState.tsx
Normal file
75
webapp-next/src/components/states/ErrorState.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Error state: warning icon, message, and optional Retry button.
|
||||
* Ported from webapp/js/ui.js showError and states.css .error.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "@/i18n/use-translation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface ErrorStateProps {
|
||||
/** Error message to display. If not provided, uses generic i18n message. */
|
||||
message?: string | null;
|
||||
/** Optional retry callback; when provided, a Retry button is shown. */
|
||||
onRetry?: (() => void) | null;
|
||||
/** Optional class for the container. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Warning triangle icon 24×24 for error state. */
|
||||
function ErrorIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={cn("shrink-0 text-error", className)}
|
||||
aria-hidden
|
||||
>
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
||||
<line x1="12" y1="9" x2="12" y2="13" />
|
||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays an error message with optional Retry button.
|
||||
*/
|
||||
export function ErrorState({ message, onRetry, className }: ErrorStateProps) {
|
||||
const { t } = useTranslation();
|
||||
const displayMessage =
|
||||
message && String(message).trim() ? message : t("error_generic");
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-3 rounded-xl bg-surface py-5 px-4 my-3 text-center text-error transition-opacity duration-200",
|
||||
className
|
||||
)}
|
||||
role="alert"
|
||||
>
|
||||
<ErrorIcon />
|
||||
<p className="m-0 text-sm font-medium">{displayMessage}</p>
|
||||
{typeof onRetry === "function" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="mt-1 bg-primary text-primary-foreground hover:opacity-90 focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-offset-2"
|
||||
onClick={onRetry}
|
||||
>
|
||||
{t("error.retry")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user