Alex Spies
    • Create new note
    • Create a note from template
      • Sharing URL Link copied
      • /edit
      • View mode
        • Edit mode
        • View mode
        • Book mode
        • Slide mode
        Edit mode View mode Book mode Slide mode
      • Customize slides
      • Note Permission
      • Read
        • Only me
        • Signed-in users
        • Everyone
        Only me Signed-in users Everyone
      • Write
        • Only me
        • Signed-in users
        • Everyone
        Only me Signed-in users Everyone
      • Engagement control Commenting, Suggest edit, Emoji Reply
    • Invite by email
      Invitee

      This note has no invitees

    • Publish Note

      Share your work with the world Congratulations! 🎉 Your note is out in the world Publish Note

      Your note will be visible on your profile and discoverable by anyone.
      Your note is now live.
      This note is visible on your profile and discoverable online.
      Everyone on the web can find and read all notes of this public team.
      See published notes
      Unpublish note
      Please check the box to agree to the Community Guidelines.
      View profile
    • Commenting
      Permission
      Disabled Forbidden Owners Signed-in users Everyone
    • Enable
    • Permission
      • Forbidden
      • Owners
      • Signed-in users
      • Everyone
    • Suggest edit
      Permission
      Disabled Forbidden Owners Signed-in users Everyone
    • Enable
    • Permission
      • Forbidden
      • Owners
      • Signed-in users
    • Emoji Reply
    • Enable
    • Versions and GitHub Sync
    • Note settings
    • Note Insights New
    • Engagement control
    • Make a copy
    • Transfer ownership
    • Delete this note
    • Save as template
    • Insert from template
    • Import from
      • Dropbox
      • Google Drive
      • Gist
      • Clipboard
    • Export to
      • Dropbox
      • Google Drive
      • Gist
    • Download
      • Markdown
      • HTML
      • Raw HTML
Menu Note settings Note Insights Versions and GitHub Sync Sharing URL Create Help
Create Create new note Create a note from template
Menu
Options
Engagement control Make a copy Transfer ownership Delete this note
Import from
Dropbox Google Drive Gist Clipboard
Export to
Dropbox Google Drive Gist
Download
Markdown HTML Raw HTML
Back
Sharing URL Link copied
/edit
View mode
  • Edit mode
  • View mode
  • Book mode
  • Slide mode
Edit mode View mode Book mode Slide mode
Customize slides
Note Permission
Read
Only me
  • Only me
  • Signed-in users
  • Everyone
Only me Signed-in users Everyone
Write
Only me
  • Only me
  • Signed-in users
  • Everyone
Only me Signed-in users Everyone
Engagement control Commenting, Suggest edit, Emoji Reply
  • Invite by email
    Invitee

    This note has no invitees

  • Publish Note

    Share your work with the world Congratulations! 🎉 Your note is out in the world Publish Note

    Your note will be visible on your profile and discoverable by anyone.
    Your note is now live.
    This note is visible on your profile and discoverable online.
    Everyone on the web can find and read all notes of this public team.
    See published notes
    Unpublish note
    Please check the box to agree to the Community Guidelines.
    View profile
    Engagement control
    Commenting
    Permission
    Disabled Forbidden Owners Signed-in users Everyone
    Enable
    Permission
    • Forbidden
    • Owners
    • Signed-in users
    • Everyone
    Suggest edit
    Permission
    Disabled Forbidden Owners Signed-in users Everyone
    Enable
    Permission
    • Forbidden
    • Owners
    • Signed-in users
    Emoji Reply
    Enable
    Import from Dropbox Google Drive Gist Clipboard
       Owned this note    Owned this note      
    Published Linked with GitHub
    • Any changes
      Be notified of any changes
    • Mention me
      Be notified of mention me
    • Unsubscribe
    # Social Habit Pledge Tracker — Technical Specification v1.1 ## 0. One-Liner A Telegram Mini App + bot that helps friends make playful "money-on-the-line" habit pacts. Users check in daily via WebApp; missed days add to a running ledger. Settlement happens off-platform. --- ## 1. Goals & Non-Goals ### Goals (MVP) - **1:1 contracts**: One person pledges to pay the other on misses. - **WebApp-first UX**: Primary interaction through Telegram Mini App. - **Telegram bot as notifier**: Reminders, alerts, and quick-action buttons that deeplink to WebApp. - **Ledger-only**: Track "owed" amounts; no real money movement. - **Dispute flow**: Partner can challenge check-ins within a time window. - **API-first architecture**: Clean separation enabling future standalone PWA. ### Non-Goals (MVP) - Payment processing, escrow, KYC. - Proof systems (photos, Strava integration). - Group challenges (data model supports it; UI deferred). - Mutual match pacts (users can create two one-way pacts manually). --- ## 2. Architecture Overview ``` ┌─────────────────────────────────────────────────────────────────┐ │ Clients │ ├──────────────────┬──────────────────┬───────────────────────────┤ │ Telegram Mini │ Telegram Bot │ Future: Standalone PWA │ │ App (React) │ (aiogram v3) │ │ │ ── Primary UI │ ── Notifier │ │ └────────┬─────────┴────────┬─────────┴───────────────────────────┘ │ │ │ HTTPS + JWT │ Webhook ▼ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ FastAPI Backend │ │ ┌──────────────┐ ┌──────────────┐ ┌────────────────────────┐ │ │ │ REST API │ │ Bot Webhook │ │ Background Jobs (arq) │ │ │ │ /api/v1/* │ │ /webhook/tg │ │ reminders, deadlines │ │ │ └──────────────┘ └──────────────┘ └────────────────────────┘ │ └────────────────────────────┬────────────────────────────────────┘ │ ┌──────────────┴──────────────┐ ▼ ▼ ┌─────────────────┐ ┌─────────────────┐ │ Neon Postgres │ │ Redis │ │ (primary data) │ │ (job queue) │ └─────────────────┘ └─────────────────┘ ``` ### Design Principles 1. **API owns all state** — Telegram and WebApp are pure UI layers. 2. **Ledger is append-only** — Never mutate; add adjustment entries. 3. **Idempotent operations** — All check-ins and state transitions handle retries. 4. **Timezone-aware** — All date logic uses contract timezone. --- ## 3. Tech Stack >> we don't need redis / caching. for queue purposes use postgres for now. | Layer | Technology | Rationale | |-------|------------|-----------| | **Backend** | Python 3.12 + FastAPI | Async-native, excellent OpenAPI generation | | **ORM** | SQLAlchemy 2.0 + Alembic | Type-safe, mature migration tooling | | **Bot** | aiogram v3 | Modern async Telegram library | | **Job Queue** | arq (Redis-backed) | Lightweight, async-native | | **Database** | Neon Postgres | Serverless, branching for dev environments | | **Cache/Queue** | Redis (Upstash or Railway) | For arq job queue | | **WebApp** | Vite + React 18 + TypeScript | Fast builds, type safety | | **Styling** | TailwindCSS + shadcn/ui | Utility-first, accessible components | | **Mini App SDK** | @telegram-apps/sdk-react | Official Telegram integration | | **Deployment** | Railway (monorepo) | Simple multi-service deployment | | **Auth** | Telegram initData + optional JWT | Stateless validation or session tokens | --- ## 4. Core Concepts & Terminology ### Users & Roles | Term | Definition | |------|------------| | **Initiator** | Creates the pact; commits to the habit | | **Partner** | Accepts the pact; receives payment on initiator's misses; can dispute | ### Pact (Contract) An agreement between two users with: - Habit name and description - Stake amount and currency - Weekly schedule (which days) - Daily deadline time - Start and optional end date - Dispute window duration ### Pact Day A single scheduled day within a pact. States: | Status | Description | |--------|-------------| | `PENDING` | Awaiting check-in; deadline not yet passed | | `DONE` | Initiator checked in; dispute window may be active | | `MISSED` | Deadline passed without check-in; forfeit applied | | `DISPUTED` | Partner challenged the check-in | | `SKIPPED` | Pre-deadline: day exempted (e.g., sick day) — no forfeit | | `WAIVED` | Post-miss: partner forgave the miss — creates WAIVER ledger entry to reverse forfeit | ### Ledger Entry Append-only record of balance changes: | Type | Description | |------|-------------| | `FORFEIT` | Stake added to balance (initiator owes partner) | | `SETTLEMENT` | Balance reduced (off-platform payment confirmed) | | `ADJUSTMENT` | Manual correction (admin use) | | `WAIVER` | Forfeit reversed (day was waived) | ### Balance Derived value: `SUM(ledger_entries.amount)` where positive = initiator owes partner. --- ## 5. Data Model ### Entity Relationship Diagram ``` ┌──────────┐ ┌───────────────────┐ ┌─────────────┐ │ users │──────<│ pact_participants │>──────│ pacts │ └──────────┘ └───────────────────┘ └─────────────┘ │ ┌───────────────────────────┼───────────────────────────┐ │ │ │ ▼ ▼ ▼ ┌─────────────┐ ┌───────────────┐ ┌─────────────────┐ │ pact_days │ │ ledger_entries│ │ settlements │ └─────────────┘ └───────────────┘ └─────────────────┘ ``` ### Table Definitions #### `users` ```sql CREATE TABLE users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), telegram_user_id BIGINT UNIQUE NOT NULL, username VARCHAR(255), -- @username, nullable display_name VARCHAR(255) NOT NULL, timezone VARCHAR(64) DEFAULT 'UTC', -- IANA timezone notify_reminders BOOLEAN DEFAULT TRUE, notify_disputes BOOLEAN DEFAULT TRUE, reminder_minutes_before INT DEFAULT 60, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() ); -- Auto-update updated_at on all tables that have it CREATE OR REPLACE FUNCTION update_updated_at() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = NOW(); RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER users_updated_at BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION update_updated_at(); ``` #### `pacts` ```sql CREATE TABLE pacts ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), title VARCHAR(255) NOT NULL, -- "Gym Pact 🏋️" description TEXT, currency VARCHAR(3) DEFAULT 'GBP', -- ISO 4217 stake_amount INT NOT NULL, -- Minor units (pence) schedule_days BOOLEAN[7] NOT NULL, -- [Mon, Tue, Wed, Thu, Fri, Sat, Sun] (ISO weekday order) deadline_time TIME NOT NULL, -- Local time in pact timezone timezone VARCHAR(64) NOT NULL, -- IANA; set from initiator start_date DATE NOT NULL, end_date DATE, -- NULL = indefinite dispute_window_hours INT DEFAULT 12, status VARCHAR(20) DEFAULT 'PENDING_ACCEPT', -- PENDING_ACCEPT, ACTIVE, ENDED invite_code VARCHAR(32) UNIQUE, -- For invite deeplinks created_by_user_id UUID REFERENCES users(id), created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() ); CREATE INDEX idx_pacts_invite_code ON pacts(invite_code); CREATE INDEX idx_pacts_status ON pacts(status); CREATE TRIGGER pacts_updated_at BEFORE UPDATE ON pacts FOR EACH ROW EXECUTE FUNCTION update_updated_at(); ``` #### `pact_participants` ```sql CREATE TABLE pact_participants ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), pact_id UUID REFERENCES pacts(id) ON DELETE CASCADE, user_id UUID REFERENCES users(id), role VARCHAR(20) NOT NULL, -- INITIATOR, PARTNER status VARCHAR(20) DEFAULT 'PENDING', -- PENDING, ACCEPTED, DECLINED accepted_at TIMESTAMPTZ, created_at TIMESTAMPTZ DEFAULT NOW(), UNIQUE(pact_id, user_id), UNIQUE(pact_id, role) -- One initiator, one partner per pact ); ``` #### `pact_days` ```sql CREATE TABLE pact_days ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), pact_id UUID REFERENCES pacts(id) ON DELETE CASCADE, date DATE NOT NULL, -- In pact timezone status VARCHAR(20) DEFAULT 'PENDING', -- PENDING, DONE, MISSED, DISPUTED, SKIPPED, WAIVED deadline_at TIMESTAMPTZ NOT NULL, -- Precomputed: date + deadline_time in pact timezone (handles DST) -- Check-in data checked_in_at TIMESTAMPTZ, checked_in_by_user_id UUID REFERENCES users(id), checkin_note TEXT, checkin_idempotency_key VARCHAR(64), -- Prevent duplicate check-ins -- Dispute data dispute_open_until TIMESTAMPTZ, -- Partner can open dispute until this time (checked_in_at + window) disputed_at TIMESTAMPTZ, disputed_by_user_id UUID REFERENCES users(id), dispute_reason TEXT, dispute_resolve_until TIMESTAMPTZ, -- Set when disputed: disputed_at + resolution_hours (default 12h) -- Resolution data resolution VARCHAR(20), -- DONE_STANDS (auto-expire), CONCEDED, WITHDRAWN, REJECTED resolution_at TIMESTAMPTZ, resolution_by_user_id UUID REFERENCES users(id), created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), UNIQUE(pact_id, date) ); CREATE INDEX idx_pact_days_status ON pact_days(pact_id, status); CREATE INDEX idx_pact_days_date ON pact_days(date); CREATE INDEX idx_pact_days_deadline ON pact_days(deadline_at) WHERE status = 'PENDING'; -- For deadline batch job CREATE INDEX idx_pact_days_dispute ON pact_days(dispute_resolve_until) WHERE status = 'DISPUTED'; -- For dispute resolution job ``` #### `ledger_entries` ```sql CREATE TABLE ledger_entries ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), pact_id UUID REFERENCES pacts(id) ON DELETE CASCADE, pact_day_id UUID REFERENCES pact_days(id), -- NULL for settlements type VARCHAR(20) NOT NULL, -- FORFEIT, SETTLEMENT, ADJUSTMENT, WAIVER amount INT NOT NULL, -- Signed; positive = initiator owes more from_user_id UUID REFERENCES users(id), -- Who owes to_user_id UUID REFERENCES users(id), -- Who is owed note TEXT, created_at TIMESTAMPTZ DEFAULT NOW() -- Append-only: no updated_at column ); -- Enforce append-only: reject UPDATE and DELETE CREATE OR REPLACE FUNCTION ledger_immutable() RETURNS TRIGGER AS $$ BEGIN RAISE EXCEPTION 'ledger_entries is append-only: UPDATE and DELETE are forbidden'; END; $$ LANGUAGE plpgsql; CREATE TRIGGER ledger_no_modify BEFORE UPDATE OR DELETE ON ledger_entries FOR EACH ROW EXECUTE FUNCTION ledger_immutable(); CREATE INDEX idx_ledger_pact ON ledger_entries(pact_id); ``` ``` #### `settlements` ```sql CREATE TABLE settlements ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), pact_id UUID REFERENCES pacts(id) ON DELETE CASCADE, amount INT NOT NULL, -- Amount being settled proposed_by_user_id UUID REFERENCES users(id), from_user_confirmed_at TIMESTAMPTZ, to_user_confirmed_at TIMESTAMPTZ, status VARCHAR(20) DEFAULT 'PENDING', -- PENDING, CONFIRMED, CANCELLED ledger_entry_id UUID REFERENCES ledger_entries(id), -- Created on confirmation created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() ); ``` ### Derived Views ```sql -- Current balance per pact CREATE VIEW pact_balances AS SELECT pact_id, SUM(amount) AS balance, -- Positive = initiator owes partner COUNT(*) FILTER (WHERE type = 'FORFEIT') AS total_forfeits, COUNT(*) FILTER (WHERE type = 'SETTLEMENT') AS total_settlements FROM ledger_entries GROUP BY pact_id; ``` --- ## 6. Authentication ### Telegram Mini App Auth Flow ``` ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ Mini App │ │ Backend │ │ Telegram │ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ │ │ │ 1. App loads │ │ │──────────────────────────────────────── │ │ │ │ 2. Get initData │ │ │<──────────────────────────────────────│ │ │ │ │ 3. POST /auth │ │ │ {initData} │ │ │──────────────────>│ │ │ │ │ │ │ 4. Validate HMAC │ │ │ using bot token│ │ │ │ │ 5. Return user │ │ │ + JWT token │ │ │<──────────────────│ │ │ │ │ │ 6. Subsequent │ │ │ requests with │ │ │ Authorization │ │ │ header │ │ │──────────────────>│ │ ``` ### initData Validation (Backend) ```python import hashlib import hmac import time from urllib.parse import parse_qs def validate_init_data(init_data: str, bot_token: str, max_age_seconds: int = 86400) -> dict | None: """Validate Telegram Mini App initData and return parsed data. Args: init_data: The initData string from Telegram bot_token: Your bot's token max_age_seconds: Maximum age of auth_date (default 24 hours for replay protection) """ parsed = parse_qs(init_data) # Extract hash received_hash = parsed.pop('hash', [None])[0] if not received_hash: return None # Validate auth_date is recent (replay protection) auth_date = parsed.get('auth_date', [None])[0] if auth_date: if int(time.time()) - int(auth_date) > max_age_seconds: return None # initData too old # Build check string (sorted key=value pairs) check_pairs = sorted( f"{k}={v[0]}" for k, v in parsed.items() ) check_string = "\n".join(check_pairs) # Compute HMAC secret_key = hmac.new( b"WebAppData", bot_token.encode(), hashlib.sha256 ).digest() computed_hash = hmac.new( secret_key, check_string.encode(), hashlib.sha256 ).hexdigest() if not hmac.compare_digest(computed_hash, received_hash): return None return {k: v[0] for k, v in parsed.items()} ``` ### JWT Token Strategy - Issue JWT on successful initData validation - Token contains: `user_id`, `telegram_user_id`, `exp` - Expiry: 7 days (Mini App sessions are persistent) - Refresh: Re-validate initData to issue new token --- ## 7. API Specification ### Base URL ``` Production: https://api.habitpact.app/api/v1 Development: http://localhost:8000/api/v1 ``` ### Authentication Header ``` Authorization: Bearer <jwt_token> ``` ### Endpoints #### Auth ##### `POST /auth/telegram` Validate Telegram initData and return user + token. **Request:** ```json { "init_data": "query_string_from_telegram" } ``` **Response:** ```json { "user": { "id": "uuid", "telegram_user_id": 123456789, "display_name": "Alex", "username": "alexcode", "timezone": "Europe/London" }, "token": "jwt_token", "is_new_user": false } ``` --- #### Users ##### `GET /users/me` Get current user profile. ##### `PATCH /users/me` Update user settings. **Request:** ```json { "timezone": "America/Los_Angeles", "notify_reminders": true, "reminder_minutes_before": 30 } ``` --- #### Pacts ##### `GET /pacts` List user's pacts. **Query params:** - `status`: Filter by status (PENDING_ACCEPT, ACTIVE, ENDED) - `role`: Filter by role (INITIATOR, PARTNER) **Response:** ```json { "pacts": [ { "id": "uuid", "title": "Gym Pact 🏋️", "status": "ACTIVE", "my_role": "INITIATOR", "partner": { "id": "uuid", "display_name": "Sam" }, "stake_amount": 500, "currency": "GBP", "balance": 1500, "today": { "date": "2025-01-20", "status": "PENDING", "deadline": "2025-01-20T21:00:00Z" } } ] } ``` ##### `POST /pacts` Create a new pact. > **Note:** Telegram bots cannot resolve @username → user_id. Partner binding happens when they open the invite link and we get their `telegram_user_id` via initData. The `partner_username` field is optional display metadata only. **Request:** ```json { "title": "Gym Pact 🏋️", "description": "Hit the gym at least once", "stake_amount": 500, "currency": "GBP", "schedule_days": [false, true, true, true, true, true, false], "deadline_time": "21:00", "start_date": "2025-01-20", "end_date": null, "dispute_window_hours": 12, "partner_username": "samsmith" } ``` **Response:** ```json { "pact": { ... }, "invite_link": "https://t.me/HabitPactBot?startapp=invite_abc123" } ``` ##### `GET /pacts/{pact_id}` Get pact details including today's status and balance. **Response:** ```json { "pact": { "id": "uuid", "title": "Gym Pact 🏋️", "status": "ACTIVE", "stake_amount": 500, "currency": "GBP", "schedule_days": [false, true, true, true, true, true, false], "deadline_time": "21:00", "timezone": "Europe/London", "dispute_window_hours": 12, "start_date": "2025-01-20", "end_date": null, "created_at": "2025-01-19T10:00:00Z" }, "participants": [ {"user": {...}, "role": "INITIATOR", "status": "ACCEPTED"}, {"user": {...}, "role": "PARTNER", "status": "ACCEPTED"} ], "balance": 1500, "stats": { "total_days": 30, "done_days": 25, "missed_days": 3, "disputed_days": 2, "current_streak": 5 }, "today": { "id": "uuid", "date": "2025-01-20", "status": "PENDING", "deadline": "2025-01-20T21:00:00Z", "can_check_in": true, "can_dispute": false } } ``` ##### `POST /pacts/{pact_id}/accept` Accept a pact invitation (partner only). ##### `POST /pacts/{pact_id}/decline` Decline a pact invitation (partner only). ##### `POST /pacts/{pact_id}/end` End an active pact (either participant). **Request:** ```json { "reason": "Completed our 30-day challenge!" } ``` --- #### Pact Days ##### `GET /pacts/{pact_id}/days` List pact days with pagination. **Query params:** - `limit`: Max results (default 30) - `offset`: Pagination offset - `status`: Filter by status ##### `GET /pacts/{pact_id}/days/{date}` Get specific day details. ##### `POST /pacts/{pact_id}/days/{date}/checkin` Check in for a day (initiator only). **Request:** ```json { "note": "Did 30 mins cardio", "idempotency_key": "uuid-client-generated" } ``` **Response:** ```json { "day": { "id": "uuid", "date": "2025-01-20", "status": "DONE", "checked_in_at": "2025-01-20T18:30:00Z", "dispute_open_until": "2025-01-21T06:30:00Z" } } ``` ##### `POST /pacts/{pact_id}/days/{date}/miss` Manually mark day as missed (initiator only; before deadline). ##### `POST /pacts/{pact_id}/days/{date}/dispute` Dispute a check-in (partner only). **Request:** ```json { "reason": "Didn't see you at the gym today 🤔" } ``` **Conditions:** - Day status must be `DONE` - Current time must be before `dispute_open_until` ##### `POST /pacts/{pact_id}/days/{date}/resolve` Resolve a dispute. **Request:** ```json { "resolution": "CONCEDE" // or "WITHDRAW" or "REJECT" } ``` **Rules:** - `CONCEDE`: Initiator admits miss → status becomes `MISSED`, forfeit applied - `WITHDRAW`: Partner withdraws dispute → status becomes `DONE` - `REJECT`: Initiator stands by check-in → status becomes `DONE`, dispute closed (partner can re-dispute if still in window) ##### `POST /pacts/{pact_id}/days/{date}/waive` Waive a missed day (partner only; forgive the miss and reverse the forfeit). **Conditions:** - Day status must be `MISSED` - Creates a `WAIVER` ledger entry to reverse the forfeit ##### `POST /pacts/{pact_id}/days/{date}/skip` Skip a day before deadline (either participant; exempt from check-in requirement). **Conditions:** - Day status must be `PENDING` - Current time must be before deadline - No forfeit is applied --- #### Ledger ##### `GET /pacts/{pact_id}/ledger` Get ledger entries. **Query params:** - `limit`: Max results (default 20) - `offset`: Pagination offset **Response:** ```json { "entries": [ { "id": "uuid", "type": "FORFEIT", "amount": 500, "date": "2025-01-18", "note": "Missed check-in", "created_at": "2025-01-18T21:02:00Z" } ], "balance": 1500, "total_entries": 5 } ``` --- #### Settlements ##### `POST /pacts/{pact_id}/settlements` Propose a settlement. **Request:** ```json { "amount": 1500 // Optional; defaults to current balance } ``` ##### `GET /pacts/{pact_id}/settlements` List settlement history. ##### `POST /settlements/{settlement_id}/confirm` Confirm a settlement (called by each party). ##### `POST /settlements/{settlement_id}/cancel` Cancel a pending settlement. --- #### Invites ##### `GET /invites/{code}` Get pact info from invite code (for preview before accepting). **Response:** ```json { "pact": { "title": "Gym Pact 🏋️", "stake_amount": 500, "currency": "GBP", "schedule_days": [false, true, true, true, true, true, false], "deadline_time": "21:00" }, "initiator": { "display_name": "Alex", "username": "alexcode" }, "status": "PENDING_ACCEPT" // or "ALREADY_ACCEPTED", "EXPIRED" } ``` ##### `POST /invites/{code}/accept` Accept invite (alternative to `/pacts/{id}/accept`). --- ### Error Responses ```json { "error": { "code": "DISPUTE_WINDOW_CLOSED", "message": "The dispute window for this day has expired", "details": { "window_end": "2025-01-20T06:30:00Z" } } } ``` **Standard error codes:** - `UNAUTHORIZED` — Invalid or expired token - `FORBIDDEN` — User doesn't have permission - `NOT_FOUND` — Resource doesn't exist - `CONFLICT` — Invalid state transition - `VALIDATION_ERROR` — Invalid request body - `IDEMPOTENCY_CONFLICT` — Duplicate idempotency key with different payload --- ## 8. Telegram Bot Specification ### Bot Role The bot serves three purposes: 1. **Entry point**: `/start` command and deeplinks open Mini App 2. **Notifications**: Reminders, dispute alerts, settlement confirmations 3. **Quick actions**: Inline buttons for simple confirm/deny flows ### Commands | Command | Description | Action | |---------|-------------|--------| | `/start` | Welcome + onboarding | Open Mini App home | | `/start invite_{code}` | Accept invite deeplink | Open Mini App to invite screen | | `/help` | How it works | Send help message with Mini App button | ### Notification Messages #### Daily Reminder **Trigger:** `reminder_minutes_before` deadline, if day is `PENDING` ``` 🕒 Habit check! Time for **Gym**. Hit Done before **9:00 PM** or the Tab Goblin adds **£5** to your bill. 👺 [Open Pact →] [Done ✅] ``` **Buttons:** - `Open Pact →` — Opens Mini App to pact detail - `Done ✅` — Quick check-in (hits API directly) #### Check-in Confirmation **Trigger:** After successful check-in ``` ✅ Logged **DONE** for Gym today! Sam can dispute within 12 hours (but only if they're feeling spicy 🌶️). ``` #### Missed Deadline **Trigger:** Deadline passes with `PENDING` status ``` 😬 Deadline passed for **Gym**! **+£5** added to the tab. Running total: Alex owes Sam **£15**. [View Details →] ``` #### Dispute Opened (to Initiator) **Trigger:** Partner disputes a check-in ``` 🤨 **Sam** disputed your check-in for **Gym** (Jan 20). Reason: "Didn't see you at the gym today 🤔" Was it legit? [View Dispute →] [Concede 😅] [Stand By It 💪] ``` #### Dispute Opened (to Partner) **Trigger:** After dispute is created ``` ⚖️ You disputed Alex's check-in for **Gym** (Jan 20). Waiting for their response. The dispute window closes in 11 hours. ``` #### Dispute Resolved **Trigger:** Resolution submitted ``` ✅ Dispute resolved for **Gym** (Jan 20). Result: Alex conceded — day marked as **MISSED**. **+£5** added to the tab. ``` #### Dispute Window Expired **Trigger:** Dispute window closes with no resolution ``` ⏰ Dispute window closed for **Gym** (Jan 20). No resolution submitted — check-in stands as **DONE**. ``` #### Settlement Proposed (to other party) **Trigger:** Settlement created ``` 💸 **Alex** wants to settle up! Amount: **£15** (Settle however you like — bank transfer, cash, or interpretive dance) [Confirm Payment Received ✅] [Not Yet ⏳] ``` #### Settlement Confirmed **Trigger:** Both parties confirm ``` 🎉 Settlement complete! **£15** marked as settled between Alex and Sam. New balance: **£0** ``` #### Pact Ended **Trigger:** Pact ended by either party ``` 🏁 **Gym Pact** has ended! Final stats: • Duration: 30 days • Completed: 25 days • Missed: 3 days • Final balance: Alex owes Sam £15 Thanks for playing! [Start New Pact →] ``` ### Inline Keyboard Callbacks | Callback Data | Action | |---------------|--------| | `checkin:{pact_id}:{date}` | Quick check-in | | `concede:{pact_id}:{date}` | Concede dispute | | `reject:{pact_id}:{date}` | Reject dispute (stand by check-in) | | `confirm_settlement:{settlement_id}` | Confirm settlement | | `open_app:{path}` | Open Mini App to specific path | ### Deeplink Format ``` https://t.me/HabitPactBot?startapp={path} Examples: - https://t.me/HabitPactBot?startapp=home - https://t.me/HabitPactBot?startapp=invite_abc123 - https://t.me/HabitPactBot?startapp=pact_uuid123 ``` --- ## 9. WebApp Specification ### Tech Stack - **Framework:** React 18 + TypeScript - **Build:** Vite - **Styling:** TailwindCSS + shadcn/ui - **State:** TanStack Query (React Query) - **Routing:** React Router v6 - **Telegram SDK:** @telegram-apps/sdk-react ### Route Structure | Route | Screen | Description | |-------|--------|-------------| | `/` | Home | List of active pacts + create CTA | | `/pacts/new` | Create Pact | Multi-step pact creation form | | `/pacts/:id` | Pact Detail | Today's status, check-in, quick stats | | `/pacts/:id/history` | Pact History | Calendar view + day list | | `/pacts/:id/ledger` | Ledger | Full ledger with balance | | `/pacts/:id/settings` | Pact Settings | Edit pact (limited), end pact | | `/pacts/:id/days/:date` | Day Detail | Day status, dispute flow | | `/invite/:code` | Accept Invite | Preview + accept/decline | | `/settings` | User Settings | Timezone, notifications | ### Screen Wireframes #### Home Screen ``` ┌─────────────────────────────────────────┐ │ ≡ Habit Pact ⚙️ │ ├─────────────────────────────────────────┤ │ │ │ Good evening, Alex! 👋 │ │ │ │ ┌─────────────────────────────────┐ │ │ │ 🏋️ Gym Pact PENDING │ │ │ │ with Sam │ │ │ │ │ │ │ │ Due today by 9:00 PM │ │ │ │ Tab: £15 owed │ │ │ │ │ │ │ │ [────────────── Check In ✅] │ │ │ └─────────────────────────────────┘ │ │ │ │ ┌─────────────────────────────────┐ │ │ │ 📚 Reading Pact DONE │ │ │ │ with Jamie ✓ │ │ │ │ │ │ │ │ Completed today at 6:30 PM │ │ │ │ Tab: £0 │ │ │ └─────────────────────────────────┘ │ │ │ │ │ │ ┌─────────────────────────────────┐ │ │ │ + Create New Pact │ │ │ └─────────────────────────────────┘ │ │ │ └─────────────────────────────────────────┘ ``` #### Pact Detail Screen ``` ┌─────────────────────────────────────────┐ │ ← Gym Pact 🏋️ ⋮ │ ├─────────────────────────────────────────┤ │ │ │ Today: Monday, Jan 20 │ │ ┌─────────────────────────────────┐ │ │ │ │ │ │ │ ⏰ PENDING │ │ │ │ │ │ │ │ Check in by 9:00 PM │ │ │ │ (2 hours 30 mins left) │ │ │ │ │ │ │ │ [────────── Done ✅ ──────────] │ │ │ │ [Skip Today 😬] │ │ │ │ │ │ │ └─────────────────────────────────┘ │ │ │ │ ────────────────────────────────── │ │ │ │ 📊 Stats │ │ ┌────────┬────────┬────────┐ │ │ │ 25 │ 3 │ 5 │ │ │ │ Done │ Missed │ Streak │ │ │ └────────┴────────┴────────┘ │ │ │ │ 💰 Balance │ │ Alex owes Sam: £15 │ │ [View Ledger →] [Settle Up 💸] │ │ │ │ 📅 This Week │ │ M T W T F S S │ │ ● ○ ○ ○ ○ ○ · │ │ ✓ ? ? ? ? ? │ │ │ └─────────────────────────────────────────┘ ``` #### Create Pact Flow ``` Step 1: Basics ┌─────────────────────────────────────────┐ │ ← New Pact 1/4 │ ├─────────────────────────────────────────┤ │ │ │ What habit are you committing to? │ │ │ │ ┌─────────────────────────────────┐ │ │ │ Go to the gym │ │ │ └─────────────────────────────────┘ │ │ │ │ Pick an emoji: │ │ [🏋️] [📚] [🧘] [🏃] [💻] [🎸] [+] │ │ │ │ │ │ ┌─────────────────────────────────┐ │ │ │ Next → │ │ │ └─────────────────────────────────┘ │ │ │ └─────────────────────────────────────────┘ Step 2: Schedule ┌─────────────────────────────────────────┐ │ ← New Pact 2/4 │ ├─────────────────────────────────────────┤ │ │ │ Which days? │ │ │ │ ┌───┬───┬───┬───┬───┬───┬───┐ │ │ │ M │ T │ W │ T │ F │ S │ S │ │ │ │[●]│[●]│[●]│[●]│[●]│[ ]│[ ]│ │ │ └───┴───┴───┴───┴───┴───┴───┘ │ │ │ │ Daily deadline: │ │ ┌─────────────────────────────────┐ │ │ │ 9:00 PM ⌄ │ │ │ └─────────────────────────────────┘ │ │ │ │ Start date: │ │ ┌─────────────────────────────────┐ │ │ │ Today (Jan 20) ⌄ │ │ │ └─────────────────────────────────┘ │ │ │ │ End date: (optional) │ │ ┌─────────────────────────────────┐ │ │ │ No end date ⌄ │ │ │ └─────────────────────────────────┘ │ │ │ │ ┌─────────────────────────────────┐ │ │ │ Next → │ │ │ └─────────────────────────────────┘ │ └─────────────────────────────────────────┘ Step 3: Stakes ┌─────────────────────────────────────────┐ │ ← New Pact 3/4 │ ├─────────────────────────────────────────┤ │ │ │ What's at stake? │ │ │ │ Amount per missed day: │ │ ┌──────┬──────────────────────────┐ │ │ │ GBP ⌄│ 5.00 │ │ │ └──────┴──────────────────────────┘ │ │ │ │ Quick pick: │ │ [£1] [£5] [£10] [£20] │ │ │ │ ────────────────────────────────── │ │ │ │ Dispute window: │ │ How long can your partner challenge │ │ a check-in? │ │ │ │ ┌─────────────────────────────────┐ │ │ │ 12 hours ⌄ │ │ │ └─────────────────────────────────┘ │ │ │ │ ┌─────────────────────────────────┐ │ │ │ Next → │ │ │ └─────────────────────────────────┘ │ └─────────────────────────────────────────┘ Step 4: Partner ┌─────────────────────────────────────────┐ │ ← New Pact 4/4 │ ├─────────────────────────────────────────┤ │ │ │ Who's holding you accountable? │ │ │ │ Partner's Telegram username: │ │ ┌─────────────────────────────────┐ │ │ │ @ │ │ │ └─────────────────────────────────┘ │ │ │ │ ─── or ─── │ │ │ │ ┌─────────────────────────────────┐ │ │ │ 📤 Send Invite Link │ │ │ └─────────────────────────────────┘ │ │ │ │ ────────────────────────────────── │ │ │ │ Summary: │ │ 🏋️ Gym • Mon-Fri by 9PM • £5/miss │ │ │ │ ┌─────────────────────────────────┐ │ │ │ Create Pact 🚀 │ │ │ └─────────────────────────────────┘ │ │ │ └─────────────────────────────────────────┘ ``` #### Dispute Screen ``` ┌─────────────────────────────────────────┐ │ ← Dispute │ ├─────────────────────────────────────────┤ │ │ │ ⚖️ Disputed Check-in │ │ │ │ Gym Pact • Monday, Jan 20 │ │ │ │ ┌─────────────────────────────────┐ │ │ │ Alex checked in at 6:30 PM │ │ │ │ Note: "Did 30 mins cardio" │ │ │ └─────────────────────────────────┘ │ │ │ │ ┌─────────────────────────────────┐ │ │ │ Sam disputed at 8:45 PM │ │ │ │ "Didn't see you at the gym │ │ │ │ today 🤔" │ │ │ └─────────────────────────────────┘ │ │ │ │ Dispute window closes in: │ │ ┌─────────────────────────────────┐ │ │ │ 9h 45m remaining │ │ │ └─────────────────────────────────┘ │ │ │ │ If no action, check-in stands. │ │ │ │ ─── Alex's Options ─── │ │ │ │ ┌─────────────────────────────────┐ │ │ │ Concede — I missed it 😅 │ │ │ └─────────────────────────────────┘ │ │ ┌─────────────────────────────────┐ │ │ │ Stand By It — I did it 💪 │ │ │ └─────────────────────────────────┘ │ │ │ └─────────────────────────────────────────┘ ``` ### Component Library Use shadcn/ui components: - `Button`, `Card`, `Badge` — Core UI - `Dialog`, `Sheet` — Modals and drawers - `Select`, `Input`, `Checkbox` — Form elements - `Tabs`, `Calendar` — Navigation and date picking - `Toast` — Notifications ### State Management ```typescript // React Query for server state const { data: pacts } = useQuery({ queryKey: ['pacts'], queryFn: () => api.getPacts() }); const checkIn = useMutation({ mutationFn: (data: CheckInRequest) => api.checkIn(pactId, date, data), onSuccess: () => { queryClient.invalidateQueries(['pacts', pactId]); hapticFeedback.notificationOccurred('success'); } }); ``` ### Telegram Integration ```typescript import { useLaunchParams, useHapticFeedback } from '@telegram-apps/sdk-react'; function App() { const launchParams = useLaunchParams(); const haptic = useHapticFeedback(); // Extract startapp param for deeplinks const startParam = launchParams.startParam; // e.g., "invite_abc123" // Navigate based on deeplink useEffect(() => { if (startParam?.startsWith('invite_')) { navigate(`/invite/${startParam.slice(7)}`); } else if (startParam?.startsWith('pact_')) { navigate(`/pacts/${startParam.slice(5)}`); } }, [startParam]); // Use haptics on actions const handleCheckIn = () => { haptic.impactOccurred('medium'); checkIn.mutate(); }; } ``` --- ## 10. Background Jobs ### Job Queue: arq ```python # worker.py from arq import cron class WorkerSettings: functions = [ send_reminder, process_deadline, resolve_expired_disputes, send_weekly_summary, ] cron_jobs = [ cron(process_deadlines_batch, minute={0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 26, 28, 30, 32, 34, 36, 38, 40, 42, 44, 46, 48, 50, 52, 54, 56, 58}), # Every 2 mins cron(resolve_expired_disputes, minute={1, 31}), # Every 30 mins (offset) cron(send_weekly_summary, weekday=6, hour=18), # Sunday 6 PM ] redis_settings = RedisSettings.from_dsn(REDIS_URL) ``` ### Job: Send Reminder **Trigger:** Scheduled per pact, `reminder_minutes_before` deadline **Logic:** ```python async def send_reminder(ctx, pact_id: str, date: str): pact = await get_pact(pact_id) day = await get_or_create_pact_day(pact_id, date) if day.status != 'PENDING': return # Already checked in or missed if pact.status != 'ACTIVE': return # Pact ended initiator = await get_initiator(pact_id) if not initiator.notify_reminders: return await bot.send_message( chat_id=initiator.telegram_user_id, text=format_reminder(pact, day), reply_markup=reminder_keyboard(pact_id, date) ) ``` ### Job: Process Deadline **Trigger:** Runs every 2 minutes (batch) **Logic:** ```python async def process_deadlines_batch(ctx): """Process all pact_days where deadline has passed and status is PENDING.""" now = datetime.now(UTC) # Query uses the precomputed deadline_at with partial index overdue_days = await db.fetch(""" SELECT pd.*, p.stake_amount, p.id as pact_id FROM pact_days pd JOIN pacts p ON pd.pact_id = p.id WHERE pd.status = 'PENDING' AND pd.deadline_at < $1 AND p.status = 'ACTIVE' FOR UPDATE OF pd SKIP LOCKED """, now) for day in overdue_days: await process_single_deadline(day) async def process_single_deadline(day): async with db.transaction(): # Mark missed await db.execute(""" UPDATE pact_days SET status = 'MISSED', updated_at = NOW() WHERE id = $1 """, day.id) # Get participant IDs initiator_id, partner_id = await get_pact_participants(day.pact_id) # Create ledger entry await db.execute(""" INSERT INTO ledger_entries (pact_id, pact_day_id, type, amount, from_user_id, to_user_id, note) VALUES ($1, $2, 'FORFEIT', $3, $4, $5, 'Missed check-in') """, day.pact_id, day.id, day.stake_amount, initiator_id, partner_id) # Notify both parties await notify_missed(day) ``` ### Job: Resolve Expired Disputes **Trigger:** Every 30 minutes **Logic:** ```python async def resolve_expired_disputes(ctx): """Auto-resolve disputes where the resolution window has expired.""" now = datetime.now(UTC) expired = await db.fetch(""" SELECT * FROM pact_days WHERE status = 'DISPUTED' AND dispute_resolve_until < $1 FOR UPDATE SKIP LOCKED """, now) for day in expired: await db.execute(""" UPDATE pact_days SET status = 'DONE', resolution = 'DONE_STANDS', resolution_at = NOW() WHERE id = $1 """, day.id) await notify_dispute_expired(day) ``` ### Job: Weekly Summary **Trigger:** Sunday 6 PM local time **Logic:** ```python async def send_weekly_summary(ctx): active_pacts = await get_active_pacts() for pact in active_pacts: stats = await get_weekly_stats(pact.id) balance = await get_balance(pact.id) for participant in pact.participants: await bot.send_message( chat_id=participant.telegram_user_id, text=format_weekly_summary(pact, stats, balance), reply_markup=summary_keyboard(pact.id) ) ``` --- ## 11. State Machines ### Pact Status ``` ┌──────────────┐ │ DRAFT │ (Future: for saved incomplete pacts) └──────┬───────┘ │ create ▼ ┌──────────────┐ ┌───────────│PENDING_ACCEPT│───────────┐ │ decline └──────┬───────┘ expire │ │ │ accept │ ▼ ▼ ▼ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ DECLINED │ │ ACTIVE │ │ EXPIRED │ └──────────────┘ └──────┬───────┘ └──────────────┘ │ end ▼ ┌──────────────┐ │ ENDED │ └──────────────┘ ``` ### Pact Day Status ``` ┌─────────────┐ │ PENDING │ └──────┬──────┘ │ ┌──────────────────────┼──────────────────────┐ │ check_in │ deadline_pass │ skip (either) ▼ ▼ ▼ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ DONE │ │ MISSED │ │ SKIPPED │ └──────┬──────┘ └──────┬──────┘ └─────────────┘ │ │ │ dispute (partner) │ waive (partner) ▼ ▼ ┌─────────────┐ ┌─────────────┐ │ DISPUTED │ │ WAIVED │ └──────┬──────┘ └─────────────┘ │ ├─── concede (initiator) ────► MISSED + forfeit │ ├─── reject (initiator) ─────► DONE │ ├─── withdraw (partner) ─────► DONE │ └─── window expires ─────────► DONE (auto: DONE_STANDS) ``` ### Settlement Status ``` ┌─────────────┐ │ PENDING │ └──────┬──────┘ │ ┌─────────┼─────────┐ │ cancel │ confirm │ ▼ │ (both) ▼ ┌─────────┐ │ ┌───────────┐ │CANCELLED│ └──>│ CONFIRMED │ └─────────┘ └───────────┘ ``` --- ## 12. Error Handling ### API Error Codes | Code | HTTP | Description | |------|------|-------------| | `AUTH_INVALID_INIT_DATA` | 401 | initData validation failed | | `AUTH_EXPIRED_TOKEN` | 401 | JWT expired | | `FORBIDDEN_NOT_PARTICIPANT` | 403 | User not in pact | | `FORBIDDEN_WRONG_ROLE` | 403 | Action requires different role | | `PACT_NOT_FOUND` | 404 | Pact doesn't exist | | `DAY_NOT_FOUND` | 404 | Day doesn't exist | | `INVALID_STATE_TRANSITION` | 409 | e.g., disputing a PENDING day | | `DISPUTE_WINDOW_CLOSED` | 409 | Too late to open dispute | | `RESOLUTION_WINDOW_CLOSED` | 409 | Too late to resolve dispute | | `ALREADY_CHECKED_IN` | 409 | Day already DONE | | `IDEMPOTENCY_MISMATCH` | 409 | Same key, different payload | | `PACT_NOT_ACTIVE` | 409 | Pact is ended/pending | | `VALIDATION_ERROR` | 422 | Invalid request body | | `RATE_LIMITED` | 429 | Too many requests | ### WebApp Error Handling ```typescript // Global error boundary function ErrorFallback({ error, resetErrorBoundary }) { const haptic = useHapticFeedback(); useEffect(() => { haptic.notificationOccurred('error'); }, []); return ( <div className="error-screen"> <h2>Something went wrong</h2> <p>{error.message}</p> <Button onClick={resetErrorBoundary}>Try again</Button> </div> ); } // API error handling const api = { async request(url, options) { const res = await fetch(url, options); if (!res.ok) { const error = await res.json(); if (error.code === 'AUTH_EXPIRED_TOKEN') { // Re-authenticate with Telegram await reauthenticate(); return this.request(url, options); } throw new ApiError(error.code, error.message); } return res.json(); } }; ``` ### Bot Error Handling ```python @router.error() async def error_handler(event: ErrorEvent): logger.error(f"Bot error: {event.exception}", exc_info=event.exception) if isinstance(event.exception, TelegramAPIError): # Don't retry Telegram errors return # Notify user of generic error if event.update.message: await event.update.message.answer( "😅 Something went wrong. Please try again." ) ``` --- ## 13. Rate Limiting ### API Rate Limits | Endpoint Pattern | Limit | Window | |-----------------|-------|--------| | `POST /auth/*` | 10 | 1 min | | `POST /pacts/*/days/*/checkin` | 5 | 1 min | | `POST /pacts/*/days/*/dispute` | 3 | 1 min | | `GET /*` | 100 | 1 min | | `POST /*` (other) | 30 | 1 min | ### Implementation ```python from slowapi import Limiter from slowapi.util import get_remote_address limiter = Limiter(key_func=get_user_id_from_token) @app.post("/pacts/{pact_id}/days/{date}/checkin") @limiter.limit("5/minute") async def checkin(pact_id: str, date: str, request: Request): ... ``` --- ## 14. Observability ### Logging ```python # Structured logging with structlog import structlog logger = structlog.get_logger() @app.post("/pacts/{pact_id}/days/{date}/checkin") async def checkin(pact_id: str, date: str, user: User = Depends(get_current_user)): logger.info( "checkin_attempt", pact_id=pact_id, date=date, user_id=str(user.id) ) # ... do checkin ... logger.info( "checkin_success", pact_id=pact_id, date=date, user_id=str(user.id), new_status="DONE" ) ``` ### Metrics Track: - `pact_created_total` — Counter of pacts created - `checkin_total{status}` — Counter by result (done, missed, disputed) - `dispute_resolution_total{resolution}` — Counter by resolution type - `api_request_duration_seconds` — Histogram of request latency - `active_pacts_gauge` — Current active pacts ### Alerts - High error rate (>5% 5xx in 5 min) - Job queue backup (>100 pending jobs) - Deadline job failures - Database connection pool exhaustion --- ## 15. Security Considerations ### Input Validation - All inputs validated with Pydantic - Telegram usernames validated against pattern: `^[a-zA-Z][a-zA-Z0-9_]{4,31}$` - Stake amounts capped (e.g., max £100 per miss) - Schedule must have at least one day selected ### Authorization - Every endpoint checks user is participant in pact - Role-specific actions validated (e.g., only partner can dispute) - Idempotency keys scoped to user ### Data Protection - No sensitive data stored (no payment info) - Telegram user IDs are pseudonymous - Consider GDPR: provide data export/deletion endpoints --- ## 16. Deployment ### Railway Setup ```yaml # railway.toml [build] builder = "dockerfile" [deploy] healthcheckPath = "/health" healthcheckTimeout = 30 [[services]] name = "api" dockerfile = "backend/Dockerfile" [[services]] name = "webapp" dockerfile = "webapp/Dockerfile" [[services]] name = "worker" dockerfile = "backend/Dockerfile" command = "arq app.jobs.worker.WorkerSettings" ``` ### Environment Variables ```bash # Backend DATABASE_URL=postgresql://... REDIS_URL=redis://... TELEGRAM_BOT_TOKEN=... JWT_SECRET=... WEBAPP_URL=https://habitpact.app # WebApp VITE_API_URL=https://api.habitpact.app VITE_BOT_USERNAME=HabitPactBot ``` ### Database Migrations ```bash # Initial setup alembic revision --autogenerate -m "initial" alembic upgrade head # CI/CD: run migrations before deploy alembic upgrade head ``` --- ## 17. Testing Strategy ### Backend ```python # pytest + httpx for API tests @pytest.fixture async def client(): async with AsyncClient(app=app, base_url="http://test") as client: yield client async def test_checkin_success(client, auth_headers, pact_with_pending_day): response = await client.post( f"/api/v1/pacts/{pact_with_pending_day.id}/days/2025-01-20/checkin", headers=auth_headers, json={"idempotency_key": "test-123"} ) assert response.status_code == 200 assert response.json()["day"]["status"] == "DONE" ``` ### WebApp ```typescript // Vitest + React Testing Library describe('CheckInButton', () => { it('shows loading state during checkin', async () => { render(<CheckInButton pactId="123" date="2025-01-20" />); await userEvent.click(screen.getByRole('button', { name: /done/i })); expect(screen.getByRole('button')).toBeDisabled(); expect(screen.getByText(/checking in/i)).toBeInTheDocument(); }); }); ``` ### E2E ```typescript // Playwright test('complete checkin flow', async ({ page }) => { await page.goto('/pacts/test-pact-id'); await page.click('button:has-text("Done")'); await expect(page.locator('.status-badge')).toHaveText('DONE'); await expect(page.locator('.toast')).toContainText('Checked in'); }); ``` --- ## 18. MVP Acceptance Criteria ### Must Have - [ ] User can authenticate via Telegram Mini App - [ ] User can create a pact with another user - [ ] Partner can accept/decline pact via deeplink - [ ] Daily reminders sent via Telegram bot - [ ] User can check in via WebApp or quick button - [ ] Missed deadlines auto-apply forfeit to ledger - [ ] Partner can dispute check-in within window - [ ] Dispute can be resolved (concede/withdraw) - [ ] Users can view running balance and ledger - [ ] Users can propose and confirm settlements - [ ] Users can end a pact ### Should Have - [ ] Weekly summary notifications - [ ] Streak tracking - [ ] Timezone settings - [ ] Notification preferences ### Nice to Have - [ ] Calendar view of history - [ ] Pact statistics (completion rate, etc.) - [ ] Custom reminder times --- ## 19. Repository Structure ``` habit-pact/ ├── backend/ │ ├── app/ │ │ ├── __init__.py │ │ ├── main.py # FastAPI app + lifespan │ │ ├── config.py # Settings from env │ │ ├── database.py # SQLAlchemy setup │ │ │ │ │ ├── api/ │ │ │ ├── __init__.py │ │ │ ├── deps.py # Dependencies (auth, db session) │ │ │ └── routes/ │ │ │ ├── __init__.py │ │ │ ├── auth.py │ │ │ ├── users.py │ │ │ ├── pacts.py │ │ │ ├── days.py │ │ │ ├── ledger.py │ │ │ ├── settlements.py │ │ │ └── invites.py │ │ │ │ │ ├── bot/ │ │ │ ├── __init__.py │ │ │ ├── main.py # aiogram bot setup │ │ │ ├── handlers/ │ │ │ │ ├── __init__.py │ │ │ │ ├── commands.py # /start, /help │ │ │ │ └── callbacks.py # Inline button handlers │ │ │ ├── keyboards.py # Inline keyboard builders │ │ │ └── notifications.py # Send notifications │ │ │ │ │ ├── core/ │ │ │ ├── __init__.py │ │ │ ├── models.py # SQLAlchemy models │ │ │ ├── schemas.py # Pydantic schemas │ │ │ └── services/ │ │ │ ├── __init__.py │ │ │ ├── auth.py # initData validation, JWT │ │ │ ├── pacts.py # Pact CRUD + logic │ │ │ ├── days.py # Check-in, dispute logic │ │ │ ├── ledger.py # Balance calculations │ │ │ └── settlements.py │ │ │ │ │ └── jobs/ │ │ ├── __init__.py │ │ ├── worker.py # arq worker settings │ │ ├── reminders.py │ │ ├── deadlines.py │ │ └── summaries.py │ │ │ ├── alembic/ │ │ ├── env.py │ │ └── versions/ │ │ │ ├── tests/ │ │ ├── conftest.py │ │ ├── test_auth.py │ │ ├── test_pacts.py │ │ └── test_days.py │ │ │ ├── alembic.ini │ ├── pyproject.toml │ ├── Dockerfile │ └── .env.example │ ├── webapp/ │ ├── src/ │ │ ├── main.tsx │ │ ├── App.tsx │ │ ├── vite-env.d.ts │ │ │ │ │ ├── routes/ │ │ │ ├── index.tsx # Home │ │ │ ├── pacts/ │ │ │ │ ├── index.tsx # Pact list (redirect to home) │ │ │ │ ├── new.tsx # Create pact │ │ │ │ ├── [id]/ │ │ │ │ │ ├── index.tsx # Pact detail │ │ │ │ │ ├── history.tsx │ │ │ │ │ ├── ledger.tsx │ │ │ │ │ └── settings.tsx │ │ │ │ └── [id]/days/[date].tsx # Day detail / dispute │ │ │ ├── invite/ │ │ │ │ └── [code].tsx │ │ │ └── settings.tsx │ │ │ │ │ ├── components/ │ │ │ ├── ui/ # shadcn components │ │ │ ├── pact-card.tsx │ │ │ ├── checkin-button.tsx │ │ │ ├── dispute-dialog.tsx │ │ │ ├── ledger-table.tsx │ │ │ ├── week-calendar.tsx │ │ │ └── settlement-sheet.tsx │ │ │ │ │ ├── lib/ │ │ │ ├── api.ts # API client │ │ │ ├── telegram.ts # TG SDK helpers │ │ │ ├── utils.ts │ │ │ └── types.ts # Shared types │ │ │ │ │ └── hooks/ │ │ ├── use-pacts.ts │ │ ├── use-auth.ts │ │ └── use-telegram.ts │ │ │ ├── public/ │ ├── index.html │ ├── package.json │ ├── tsconfig.json │ ├── vite.config.ts │ ├── tailwind.config.js │ ├── Dockerfile │ └── .env.example │ ├── docker-compose.yml # Local dev ├── railway.toml # Deployment config ├── .gitignore └── README.md ``` --- ## 20. Implementation Phases ### Phase 1: Foundation (Days 1-2) - [ ] Project scaffolding (backend + webapp) - [ ] Database models + migrations - [ ] Auth flow (initData validation + JWT) - [ ] Basic CRUD for pacts and participants ### Phase 2: Core Flow (Days 3-4) - [ ] Check-in endpoint + state machine - [ ] Deadline processing job - [ ] Ledger entries on miss - [ ] Telegram bot notifications (reminders, missed) ### Phase 3: WebApp UI (Days 5-6) - [ ] Home screen with pact list - [ ] Pact detail with check-in - [ ] Create pact flow - [ ] Accept invite flow ### Phase 4: Disputes & Settlements (Days 7-8) - [ ] Dispute flow (open, resolve) - [ ] Dispute window expiry job - [ ] Settlement proposal + confirmation - [ ] Dispute/settlement notifications ### Phase 5: Polish (Days 9-10) - [ ] Weekly summaries - [ ] Error handling + edge cases - [ ] Testing - [ ] Deployment + monitoring --- ## Appendix A: Copy Templates ### Bot Messages See Section 8 for all notification templates. ### WebApp Microcopy | Context | Copy | |---------|------| | Empty pacts list | "No pacts yet. Ready to commit to a habit?" | | Pending check-in | "Check in by {time} or the Tab Goblin strikes! 👺" | | Checked in | "Nice one! ✅" | | Missed | "Deadline passed. +{amount} to the tab." | | Dispute opened | "Your partner isn't convinced. Time to defend yourself!" | | Settlement confirmed | "All squared up! 🎉" | | Pact ended | "Journey complete. You showed up {n} times!" | --- ## Appendix B: Future Extensions Designed into the data model but not shipped in MVP: 1. **Group pacts** — `pact_participants` supports N users; add pot rules 2. **Charity mode** — `to_user_id = NULL` + `charity_id`; generate donation links 3. **Proof attachments** — Add `media_file_id` to `pact_days` 4. **Recurring templates** — Save pact config for quick re-creation 5. **Integrations** — Strava, Apple Health webhooks for auto-check-in 6. **Payments** — Stripe integration; `payment_status` on settlements --- *End of specification.* --- ## Changelog ### v1.1 (Review Feedback) **Breaking changes:** - `dispute_window_end` split into `dispute_open_until` and `dispute_resolve_until` for fairer timing - Added `deadline_at` (precomputed TIMESTAMPTZ) to `pact_days` — must be set when creating days **New features:** - `SKIPPED` status for pre-deadline exemptions (distinct from `WAIVED` which is post-miss) - `REJECT` resolution option for initiator to stand by check-in - `POST /pacts/{id}/days/{date}/skip` endpoint **Fixes:** - Partner username lookup clarified as metadata-only (Telegram API limitation) - Deadline processing now runs every 2 minutes (was hourly) - initData validation now checks `auth_date` for replay protection - Ledger immutability enforced via database trigger - `updated_at` columns now have auto-update triggers - `schedule_days` ordering explicitly documented as ISO weekday order [Mon..Sun] - Fixed `standbychrome` callback typo → `reject` **Schema changes:** - Added `deadline_at TIMESTAMPTZ` to `pact_days` - Renamed `dispute_window_end` → `dispute_open_until` - Added `dispute_resolve_until` to `pact_days` - Added partial indexes for deadline and dispute resolution queries - Added `update_updated_at()` trigger function - Added `ledger_immutable()` trigger to enforce append-only

    Import from clipboard

    Paste your markdown or webpage here...

    Advanced permission required

    Your current role can only read. Ask the system administrator to acquire write and comment permission.

    This team is disabled

    Sorry, this team is disabled. You can't edit this note.

    This note is locked

    Sorry, only owner can edit this note.

    Reach the limit

    Sorry, you've reached the max length this note can be.
    Please reduce the content or divide it to more notes, thank you!

    Import from Gist

    Import from Snippet

    or

    Export to Snippet

    Are you sure?

    Do you really want to delete this note?
    All users will lose their connection.

    Create a note from template

    Create a note from template

    Oops...
    This template has been removed or transferred.
    Upgrade
    All
    • All
    • Team
    No template.

    Create a template

    Upgrade

    Delete template

    Do you really want to delete this template?
    Turn this template into a regular note and keep its content, versions, and comments.

    This page need refresh

    You have an incompatible client version.
    Refresh to update.
    New version available!
    See releases notes here
    Refresh to enjoy new features.
    Your user state has changed.
    Refresh to load new user state.

    Sign in

    Forgot password

    or

    By clicking below, you agree to our terms of service.

    Sign in via Facebook Sign in via Twitter Sign in via GitHub Sign in via Dropbox Sign in with Wallet
    Wallet ( )
    Connect another wallet

    New to HackMD? Sign up

    Help

    • English
    • 中文
    • Français
    • Deutsch
    • 日本語
    • Español
    • Català
    • Ελληνικά
    • Português
    • italiano
    • Türkçe
    • Русский
    • Nederlands
    • hrvatski jezik
    • język polski
    • Українська
    • हिन्दी
    • svenska
    • Esperanto
    • dansk

    Documents

    Help & Tutorial

    How to use Book mode

    Slide Example

    API Docs

    Edit in VSCode

    Install browser extension

    Contacts

    Feedback

    Discord

    Send us email

    Resources

    Releases

    Pricing

    Blog

    Policy

    Terms

    Privacy

    Cheatsheet

    Syntax Example Reference
    # Header Header 基本排版
    - Unordered List
    • Unordered List
    1. Ordered List
    1. Ordered List
    - [ ] Todo List
    • Todo List
    > Blockquote
    Blockquote
    **Bold font** Bold font
    *Italics font* Italics font
    ~~Strikethrough~~ Strikethrough
    19^th^ 19th
    H~2~O H2O
    ++Inserted text++ Inserted text
    ==Marked text== Marked text
    [link text](https:// "title") Link
    ![image alt](https:// "title") Image
    `Code` Code 在筆記中貼入程式碼
    ```javascript
    var i = 0;
    ```
    var i = 0;
    :smile: :smile: Emoji list
    {%youtube youtube_id %} Externals
    $L^aT_eX$ LaTeX
    :::info
    This is a alert area.
    :::

    This is a alert area.

    Versions and GitHub Sync
    Get Full History Access

    • Edit version name
    • Delete

    revision author avatar     named on  

    More Less

    Note content is identical to the latest version.
    Compare
      Choose a version
      No search result
      Version not found
    Sign in to link this note to GitHub
    Learn more
    This note is not linked with GitHub
     

    Feedback

    Submission failed, please try again

    Thanks for your support.

    On a scale of 0-10, how likely is it that you would recommend HackMD to your friends, family or business associates?

    Please give us some advice and help us improve HackMD.

     

    Thanks for your feedback

    Remove version name

    Do you want to remove this version name and description?

    Transfer ownership

    Transfer to
      Warning: is a public team. If you transfer note to this team, everyone on the web can find and read this note.

        Link with GitHub

        Please authorize HackMD on GitHub
        • Please sign in to GitHub and install the HackMD app on your GitHub repo.
        • HackMD links with GitHub through a GitHub App. You can choose which repo to install our App.
        Learn more  Sign in to GitHub

        Push the note to GitHub Push to GitHub Pull a file from GitHub

          Authorize again
         

        Choose which file to push to

        Select repo
        Refresh Authorize more repos
        Select branch
        Select file
        Select branch
        Choose version(s) to push
        • Save a new version and push
        • Choose from existing versions
        Include title and tags
        Available push count

        Pull from GitHub

         
        File from GitHub
        File from HackMD

        GitHub Link Settings

        File linked

        Linked by
        File path
        Last synced branch
        Available push count

        Danger Zone

        Unlink
        You will no longer receive notification when GitHub file changes after unlink.

        Syncing

        Push failed

        Push successfully