diff --git a/GAMIFICATION_PLAN.md b/GAMIFICATION_PLAN.md new file mode 100644 index 0000000..3886c83 --- /dev/null +++ b/GAMIFICATION_PLAN.md @@ -0,0 +1,389 @@ +# CarPass Gamification Plan + +## 1. Goals + +CarPass gamification is a trust and data-quality system, not a game layer. The product goal is to turn a casual vehicle diary into a premium digital vehicle passport that keeps owners returning because the data becomes more useful over time. + +Primary goals: + +- Increase retention by showing owners how each verified record improves the vehicle passport. +- Improve data quality for VIN, license plate, mileage, oil specs, service intervals, fuel consumption, and service history. +- Motivate owner confirmation of service visits and sensitive vehicle data changes. +- Encourage structured interaction between owners and verified service centers. +- Make the vehicle more resale-ready by building a reliable, private maintenance history. +- Give service centers a trust signal based on confirmed work, low dispute rates, and good operational hygiene. + +Non-goals: + +- No public leaderboards. +- No speed, harsh driving, racing, or mileage competition. +- No coins, gambling, loot boxes, streak pressure, or daily spam loops. +- No public comparison of users, vehicles, or service centers. + +## 2. UX Philosophy + +The experience should feel like a premium automotive SaaS product: calm, precise, and useful. The visual language should resemble a digital inspection report, ownership dashboard, and service-grade vehicle passport. + +Principles: + +- Use "quality", "verification", "health", and "trust" language instead of "points" or "levels". +- Show progress as professional indicators: rings, bars, badges, timelines, and status chips. +- Explain why each missing item matters: VIN improves identity confidence, full-tank fuel records improve consumption accuracy, confirmed service visits improve resale trust. +- Avoid shame. Use neutral states like "Needs data", "Partially verified", "Attention needed". +- Keep notifications sparse and high-signal. +- Make the owner feel in control. Service centers can propose, but owner confirmation decides trust. + +## 3. Reward Types + +Rewards are evidence markers, not currency. + +Vehicle rewards: + +- Profile Quality: percentage of important fields completed. +- Verified History Status: self-reported, partially verified, verified maintenance history. +- Maintenance Health: green, yellow, red based on overdue service and reminders. +- Passport Badges: durable achievements such as Full Vehicle Profile or First Verified Service. +- Timeline Milestones: service, fuel, verification, OBD, and ownership events. + +Owner rewards: + +- Garage Quality: average completion across active vehicles. +- Complete Garage: every active vehicle has identity, mileage, and maintenance baseline. +- Maintenance Consistency: regular data capture without aggressive streak language. + +Service center rewards: + +- Verified Service Center badge. +- Trust Level: new, verified, reliable, high-confidence. +- Confirmation Quality: owner confirmation rate, low dispute rate, completed visit quality. + +## 4. Progression System + +Progression is based on increasing confidence in the digital passport. + +Vehicle Profile Completion: + +- 0-30: Basic profile. +- 31-60: Useful ownership profile. +- 61-85: Strong maintenance profile. +- 86-100: High-confidence vehicle passport. + +Verified History: + +- Self-reported: owner-only records. +- Partially verified: at least one confirmed service-center visit or a mixed history. +- Verified maintenance history: most service history is confirmed by verified service centers. + +Maintenance Health: + +- Green: no overdue critical service reminders. +- Yellow: service due soon or incomplete maintenance baseline. +- Red: overdue service by date or mileage. + +Service Trust: + +- New service: verified account but low confirmed work volume. +- Verified service: documents checked. +- Reliable service: good confirmation rate and low dispute rate. +- High-confidence service: consistent confirmed work and low unresolved disputes. + +## 5. User Engagement Loops + +Owner loop: + +1. Add vehicle. +2. See passport quality and missing items. +3. Add VIN/license plate/oil/mileage/fuel/service data. +4. Receive a clearer score and timeline. +5. Confirm service-center visits. +6. Build a trustworthy maintenance history. + +Service loop: + +1. Register service center. +2. Pass verification. +3. Invite employees. +4. Create service visits. +5. Owner confirms work. +6. Trust score improves through confirmed, low-dispute work. + +OCR loop: + +1. Scan VIN, plate, or document. +2. System extracts candidates. +3. User or employee confirms suggested values. +4. Sensitive changes become pending requests until owner approval. + +Notification loop: + +- Notify only for due maintenance, owner confirmation, important data quality milestones, and access requests. +- Never send daily streak pressure. + +## 6. Vehicle Profile Completion + +Each vehicle gets `completeness_score` from 0 to 100. + +Suggested scoring weights: + +- Vehicle exists and has owner access: 5. +- Make/model/year/name baseline: 15. +- VIN: 15. +- License plate with country: 10. +- Current odometer: 10. +- Fuel type and consumption baseline: 10. +- Engine oil type and volume: 10. +- Transmission/coolant/brake fluid fields: 5. +- At least one fuel entry: 5. +- At least one service entry or service visit: 5. +- At least one confirmed service-center visit: 10. +- Future OBD connection: 10. + +The score is capped at 100. The API should also return `missing_items` with practical next actions. + +## 7. Verified Maintenance History + +Verified history score measures how trustworthy the service history is. + +Inputs: + +- Total service records. +- Confirmed service-center visits. +- Whether the service center is verified. +- Disputed/rejected/cancelled visits. +- Owner-created self-reported records. + +Statuses: + +- `self_reported`: no confirmed service-center visits. +- `partially_verified`: at least one confirmed service visit or 30-79% trusted records. +- `verified`: 80%+ trusted records and no unresolved disputes. + +Sensitive rule: + +- OCR or service-center input can suggest values, but it cannot automatically make history trusted without owner confirmation. + +## 8. Service Center Trust Mechanics + +Trust score is operational quality, not a public star rating. + +Inputs: + +- Verification status. +- Confirmed visit count. +- Owner confirmation rate. +- Dispute rate. +- Rejected/cancelled visit rate. +- Employee hygiene: disabled employees, unresolved disputes, suspicious lookup patterns. + +Display: + +- New service. +- Verified service. +- Reliable service. +- High-confidence service. + +Avoid: + +- Public numeric ranking. +- Punitive public labels. +- One-click toxic ratings. + +## 9. Anti-Abuse Rules + +Achievements: + +- Unlock once per user or once per vehicle depending on scope. +- Use unique constraints to prevent duplicate unlocks. +- Do not unlock from deleted or disputed records. + +Scoring: + +- Ignore duplicate low-quality records created in a short time window. +- Require valid owner access for vehicle score APIs. +- Require confirmed visits for verified-history credit. +- Require verified service-center status for high trust credit. + +Search and OCR: + +- Rate-limit VIN/license plate search and OCR later. +- Return masked/minimal search results. +- Log lookups in `AuditLog` or `EngagementEvent`. + +Deletion: + +- Owner cannot erase service-center history without trace. +- Use dispute, rejected, hidden, or cancelled status plus audit trail. + +## 10. Notification Strategy + +Notification types: + +- Maintenance due soon. +- Maintenance overdue. +- Service visit pending owner confirmation. +- Vehicle data change request pending. +- Profile completion milestone: 50%, 80%, 100%. +- First verified service confirmed. + +Rules: + +- No daily streak reminders. +- No more than one non-critical nudge per vehicle per week. +- Critical maintenance alerts can repeat with conservative cooldown. +- Owner controls notification preferences. +- Notifications must be actionable and quiet. + +## 11. Privacy Considerations + +- No public leaderboard. +- No sharing of Telegram ID, phone, VIN, full plate, or service history without consent. +- Service-center search returns masked VIN/plate and minimal vehicle identity. +- Owner controls access duration and revocation. +- Scores are private by default. +- Service trust shown to owners should be aggregate and non-invasive. + +## 12. Database Changes + +New tables: + +- `achievements` + - `id` + - `code` + - `scope`: user / vehicle / service_center + - `title` + - `description` + - `icon` + - `category` + - `is_active` + - `created_at` + +- `user_achievements` + - `id` + - `user_id` + - `achievement_id` + - `vehicle_id` + - `service_center_id` + - `unlocked_at` + - `metadata_json` + +- `vehicle_scores` + - `id` + - `vehicle_id` + - `completeness_score` + - `verified_history_score` + - `maintenance_health_score` + - `maintenance_status` + - `profile_quality` + - `verified_history_status` + - `missing_items` + - `computed_at` + +- `service_center_scores` + - `id` + - `service_center_id` + - `trust_score` + - `trust_level` + - `confirmed_visits_count` + - `confirmation_rate` + - `dispute_rate` + - `computed_at` + +- `engagement_events` + - `id` + - `user_id` + - `vehicle_id` + - `service_center_id` + - `event_type` + - `metadata_json` + - `created_at` + +Indexes: + +- `achievements.code` unique. +- `user_achievements(user_id, achievement_id, vehicle_id, service_center_id)` unique. +- `vehicle_scores.vehicle_id` unique. +- `service_center_scores.service_center_id` unique. +- `engagement_events(user_id, event_type, created_at)`. +- `engagement_events(vehicle_id, event_type, created_at)`. + +## 13. API Changes + +Owner APIs: + +- `GET /api/me/achievements` +- `GET /api/my/vehicles/{vehicle_id}/score` +- `GET /api/my/vehicles/{vehicle_id}/timeline` + +Service APIs: + +- `GET /api/service-centers/{service_center_id}/trust-score` + +Internal scoring APIs, optional later: + +- `POST /api/internal/scoring/recompute` +- `POST /api/internal/achievements/evaluate` + +Response principles: + +- Return computed scores from backend. +- Return missing items and human-readable next actions. +- Never trust `user_id` from request body. +- Every vehicle endpoint uses `current_user` and vehicle access checks. + +## 14. Frontend UI Changes + +Replace the simple "Профиль учета" strip with a premium passport dashboard: + +- Vehicle Passport Quality card with progress ring. +- Verified History card with status and trusted record ratio. +- Maintenance Health widget with green/yellow/red status. +- "Что улучшить" list with 2-4 next actions. +- Achievement cards in calm SaaS style. +- Vehicle timeline with fuel, service, confirmation, OCR, OBD, and achievements. +- Service trust badge in service-center screens. +- Garage overview with average completion and active alerts. + +Style: + +- Dark premium automotive UI. +- Calm contrast, thin borders, glass/dark panels used sparingly. +- No arcade language, no coin counters, no animated confetti. + +## 15. Future Extensibility + +Future modules should plug into the scoring engine without rewriting frontend: + +- OBD2: connected vehicle badge, automatic mileage, battery monitoring. +- Insurance: verified maintenance packet for insurance offers. +- Resale: exportable CarPass report. +- Predictive maintenance: service recommendations based on mileage/time/fuel/OBD. +- AI assistant: explain score changes and suggest next best action. +- Service recommendations: based on vehicle type, overdue work, and trusted service access. + +## Implementation Phases + +Phase 1: + +- Add backend models and migration. +- Add scoring engine. +- Add achievement engine. +- Add score, achievements, timeline, and trust-score APIs. +- Add focused tests. + +Phase 2: + +- Replace frontend progress strip with backend-driven widgets. +- Add achievements and timeline UI. +- Keep old MVP data flows intact. + +Phase 3: + +- Add smart notification events and cooldown-ready schema. +- Expand trust score for service centers. +- Add service-center-facing gamification signals. + +Phase 4: + +- Add engagement analytics and anti-abuse refinements. +- Optimize queries and add recomputation jobs. +- Prepare production deployment checklist. diff --git a/alembic/env.py b/alembic/env.py index eb3640a..be6392f 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import async_engine_from_config from app.core.config import settings from app.db.base import Base -from app.models import car, expense, push, user # noqa: F401 +from app.models import car, expense, gamification, push, user # noqa: F401 config = context.config config.set_main_option("sqlalchemy.url", settings.database_url) diff --git a/alembic/versions/202605120006_gamification_scores.py b/alembic/versions/202605120006_gamification_scores.py new file mode 100644 index 0000000..3d07bc8 --- /dev/null +++ b/alembic/versions/202605120006_gamification_scores.py @@ -0,0 +1,157 @@ +"""gamification scores + +Revision ID: 202605120006 +Revises: 202605120005 +Create Date: 2026-05-12 20:10:00.000000 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "202605120006" +down_revision: str | None = "202605120005" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "achievements", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("code", sa.String(length=80), nullable=False), + sa.Column("scope", sa.String(length=24), server_default="user", nullable=False), + sa.Column("title", sa.String(length=120), nullable=False), + sa.Column("description", sa.String(length=260), nullable=False), + sa.Column("icon", sa.String(length=40), nullable=True), + sa.Column("category", sa.String(length=40), nullable=True), + sa.Column("is_active", sa.Boolean(), server_default=sa.text("true"), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_achievements_category", "achievements", ["category"]) + op.create_index("ix_achievements_code", "achievements", ["code"], unique=True) + op.create_index("ix_achievements_is_active", "achievements", ["is_active"]) + op.create_index("ix_achievements_scope", "achievements", ["scope"]) + + op.create_table( + "vehicle_scores", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("vehicle_id", sa.Integer(), nullable=False), + sa.Column("completeness_score", sa.Integer(), server_default="0", nullable=False), + sa.Column("verified_history_score", sa.Integer(), server_default="0", nullable=False), + sa.Column("maintenance_health_score", sa.Integer(), server_default="100", nullable=False), + sa.Column("maintenance_status", sa.String(length=24), server_default="unknown", nullable=False), + sa.Column("profile_quality", sa.String(length=40), server_default="basic", nullable=False), + sa.Column("verified_history_status", sa.String(length=40), server_default="self_reported", nullable=False), + sa.Column("missing_items", postgresql.JSON(astext_type=sa.Text()), nullable=True), + sa.Column("computed_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.ForeignKeyConstraint(["vehicle_id"], ["cars.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_vehicle_scores_maintenance_status", "vehicle_scores", ["maintenance_status"]) + op.create_index("ix_vehicle_scores_profile_quality", "vehicle_scores", ["profile_quality"]) + op.create_index("ix_vehicle_scores_vehicle_id", "vehicle_scores", ["vehicle_id"], unique=True) + op.create_index("ix_vehicle_scores_verified_history_status", "vehicle_scores", ["verified_history_status"]) + + op.create_table( + "service_center_scores", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("service_center_id", sa.Integer(), nullable=False), + sa.Column("trust_score", sa.Integer(), server_default="0", nullable=False), + sa.Column("trust_level", sa.String(length=40), server_default="new_service", nullable=False), + sa.Column("confirmed_visits_count", sa.Integer(), server_default="0", nullable=False), + sa.Column("confirmation_rate", sa.Numeric(5, 2), server_default="0", nullable=False), + sa.Column("dispute_rate", sa.Numeric(5, 2), server_default="0", nullable=False), + sa.Column("computed_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.ForeignKeyConstraint(["service_center_id"], ["service_centers.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_service_center_scores_service_center_id", "service_center_scores", ["service_center_id"], unique=True) + op.create_index("ix_service_center_scores_trust_level", "service_center_scores", ["trust_level"]) + + op.create_table( + "user_achievements", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("achievement_id", sa.Integer(), nullable=False), + sa.Column("vehicle_id", sa.Integer(), nullable=True), + sa.Column("service_center_id", sa.Integer(), nullable=True), + sa.Column("unlocked_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("metadata_json", postgresql.JSON(astext_type=sa.Text()), nullable=True), + sa.ForeignKeyConstraint(["achievement_id"], ["achievements.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["service_center_id"], ["service_centers.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["vehicle_id"], ["cars.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "user_id", + "achievement_id", + "vehicle_id", + "service_center_id", + name="uq_user_achievement_scope", + ), + ) + op.create_index("ix_user_achievements_achievement_id", "user_achievements", ["achievement_id"]) + op.create_index("ix_user_achievements_service_center_id", "user_achievements", ["service_center_id"]) + op.create_index("ix_user_achievements_unlocked_at", "user_achievements", ["unlocked_at"]) + op.create_index("ix_user_achievements_user_id", "user_achievements", ["user_id"]) + op.create_index("ix_user_achievements_vehicle_id", "user_achievements", ["vehicle_id"]) + + op.create_table( + "engagement_events", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=True), + sa.Column("vehicle_id", sa.Integer(), nullable=True), + sa.Column("service_center_id", sa.Integer(), nullable=True), + sa.Column("event_type", sa.String(length=80), nullable=False), + sa.Column("metadata_json", postgresql.JSON(astext_type=sa.Text()), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.ForeignKeyConstraint(["service_center_id"], ["service_centers.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["vehicle_id"], ["cars.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_engagement_events_created_at", "engagement_events", ["created_at"]) + op.create_index("ix_engagement_events_event_type", "engagement_events", ["event_type"]) + op.create_index("ix_engagement_events_service_center_id", "engagement_events", ["service_center_id"]) + op.create_index("ix_engagement_events_user_id", "engagement_events", ["user_id"]) + op.create_index("ix_engagement_events_vehicle_id", "engagement_events", ["vehicle_id"]) + op.create_index("ix_engagement_events_user_type_created", "engagement_events", ["user_id", "event_type", "created_at"]) + op.create_index("ix_engagement_events_vehicle_type_created", "engagement_events", ["vehicle_id", "event_type", "created_at"]) + + +def downgrade() -> None: + op.drop_index("ix_engagement_events_vehicle_type_created", table_name="engagement_events") + op.drop_index("ix_engagement_events_user_type_created", table_name="engagement_events") + op.drop_index("ix_engagement_events_vehicle_id", table_name="engagement_events") + op.drop_index("ix_engagement_events_user_id", table_name="engagement_events") + op.drop_index("ix_engagement_events_service_center_id", table_name="engagement_events") + op.drop_index("ix_engagement_events_event_type", table_name="engagement_events") + op.drop_index("ix_engagement_events_created_at", table_name="engagement_events") + op.drop_table("engagement_events") + + op.drop_index("ix_user_achievements_vehicle_id", table_name="user_achievements") + op.drop_index("ix_user_achievements_user_id", table_name="user_achievements") + op.drop_index("ix_user_achievements_unlocked_at", table_name="user_achievements") + op.drop_index("ix_user_achievements_service_center_id", table_name="user_achievements") + op.drop_index("ix_user_achievements_achievement_id", table_name="user_achievements") + op.drop_table("user_achievements") + + op.drop_index("ix_service_center_scores_trust_level", table_name="service_center_scores") + op.drop_index("ix_service_center_scores_service_center_id", table_name="service_center_scores") + op.drop_table("service_center_scores") + + op.drop_index("ix_vehicle_scores_verified_history_status", table_name="vehicle_scores") + op.drop_index("ix_vehicle_scores_vehicle_id", table_name="vehicle_scores") + op.drop_index("ix_vehicle_scores_profile_quality", table_name="vehicle_scores") + op.drop_index("ix_vehicle_scores_maintenance_status", table_name="vehicle_scores") + op.drop_table("vehicle_scores") + + op.drop_index("ix_achievements_scope", table_name="achievements") + op.drop_index("ix_achievements_is_active", table_name="achievements") + op.drop_index("ix_achievements_code", table_name="achievements") + op.drop_index("ix_achievements_category", table_name="achievements") + op.drop_table("achievements") diff --git a/app/api/gamification.py b/app/api/gamification.py new file mode 100644 index 0000000..57acacc --- /dev/null +++ b/app/api/gamification.py @@ -0,0 +1,184 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import ( + ensure_service_employee, + ensure_vehicle_owner_or_access, + get_current_telegram_user, +) +from app.db.session import get_session +from app.models.car import ServiceCenter, ServiceVisit +from app.models.expense import FuelEntry, ServiceEntry +from app.models.gamification import Achievement, EngagementEvent, UserAchievement +from app.models.user import User +from app.schemas.gamification import ( + AchievementRead, + ServiceCenterScoreRead, + TimelineItem, + VehicleScoreRead, +) +from app.services.scoring import ( + compute_service_center_score, + compute_vehicle_score, + evaluate_garage_achievements, +) + +router = APIRouter(tags=["gamification"]) + + +@router.get("/me/achievements", response_model=list[AchievementRead]) +async def my_achievements( + session: AsyncSession = Depends(get_session), + current_user: User = Depends(get_current_telegram_user), +) -> list[AchievementRead]: + await evaluate_garage_achievements(session, current_user.id) + result = await session.execute( + select(UserAchievement, Achievement) + .join(Achievement, Achievement.id == UserAchievement.achievement_id) + .where(UserAchievement.user_id == current_user.id) + .order_by(UserAchievement.unlocked_at.desc(), UserAchievement.id.desc()) + ) + await session.commit() + return [ + AchievementRead( + code=achievement.code, + scope=achievement.scope, + title=achievement.title, + description=achievement.description, + icon=achievement.icon, + category=achievement.category, + unlocked_at=user_achievement.unlocked_at, + vehicle_id=user_achievement.vehicle_id, + service_center_id=user_achievement.service_center_id, + ) + for user_achievement, achievement in result.all() + ] + + +@router.get("/my/vehicles/{vehicle_id}/score", response_model=VehicleScoreRead) +async def vehicle_score( + vehicle_id: int, + session: AsyncSession = Depends(get_session), + current_user: User = Depends(get_current_telegram_user), +): + car = await ensure_vehicle_owner_or_access(session, vehicle_id, current_user) + score = await compute_vehicle_score(session, car) + await session.commit() + await session.refresh(score) + return score + + +@router.get("/my/vehicles/{vehicle_id}/timeline", response_model=list[TimelineItem]) +async def vehicle_timeline( + vehicle_id: int, + limit: int = 80, + offset: int = 0, + session: AsyncSession = Depends(get_session), + current_user: User = Depends(get_current_telegram_user), +) -> list[TimelineItem]: + if limit < 1: + limit = 1 + limit = min(limit, 200) + offset = max(offset, 0) + car = await ensure_vehicle_owner_or_access(session, vehicle_id, current_user) + fuel_entries = list((await session.execute(select(FuelEntry).where(FuelEntry.car_id == car.id))).scalars()) + service_entries = list((await session.execute(select(ServiceEntry).where(ServiceEntry.car_id == car.id))).scalars()) + visits = list((await session.execute(select(ServiceVisit).where(ServiceVisit.vehicle_id == car.id))).scalars()) + achievements = list( + ( + await session.execute( + select(UserAchievement, Achievement) + .join(Achievement, Achievement.id == UserAchievement.achievement_id) + .where(UserAchievement.user_id == current_user.id, UserAchievement.vehicle_id == car.id) + ) + ).all() + ) + events = list( + ( + await session.execute( + select(EngagementEvent).where(EngagementEvent.vehicle_id == car.id).order_by(EngagementEvent.created_at.desc()) + ) + ).scalars() + ) + + items: list[TimelineItem] = [] + for entry in fuel_entries: + items.append( + TimelineItem( + id=f"fuel:{entry.id}", + date=entry.entry_date.isoformat(), + type="fuel", + title=f"Fuel refill {float(entry.liters):.1f} L", + description=entry.station, + amount=entry.total_cost, + metadata={"odometer": entry.odometer, "full_tank": entry.is_full_tank}, + ) + ) + for entry in service_entries: + items.append( + TimelineItem( + id=f"service:{entry.id}", + date=entry.entry_date.isoformat(), + type="service", + title=entry.title, + status="self_reported", + description=entry.vendor, + amount=entry.total_cost, + metadata={"odometer": entry.odometer, "service_type": entry.service_type.value}, + ) + ) + for visit in visits: + items.append( + TimelineItem( + id=f"visit:{visit.id}", + date=visit.visit_date.isoformat(), + type="service_visit", + title="Service-center visit", + status=visit.status, + description=visit.notes, + amount=visit.total_cost, + metadata={"odometer": visit.odometer, "service_center_id": visit.service_center_id}, + ) + ) + for user_achievement, achievement in achievements: + items.append( + TimelineItem( + id=f"achievement:{user_achievement.id}", + date=user_achievement.unlocked_at, + type="achievement", + title=achievement.title, + status="unlocked", + description=achievement.description, + metadata={"code": achievement.code, "category": achievement.category}, + ) + ) + for event in events: + items.append( + TimelineItem( + id=f"event:{event.id}", + date=event.created_at, + type="event", + title=event.event_type.replace("_", " ").title(), + metadata=event.metadata_json, + ) + ) + + items.sort(key=lambda item: str(item.date), reverse=True) + return items[offset : offset + limit] + + +@router.get("/service-centers/{service_center_id}/trust-score", response_model=ServiceCenterScoreRead) +async def service_center_trust_score( + service_center_id: int, + session: AsyncSession = Depends(get_session), + current_user: User = Depends(get_current_telegram_user), +): + center = await session.get(ServiceCenter, service_center_id) + if center is None: + raise HTTPException(status_code=404, detail="Service center not found") + await ensure_service_employee(session, service_center_id, current_user) + score = await compute_service_center_score(session, center) + await session.commit() + await session.refresh(score) + return score diff --git a/app/main.py b/app/main.py index cd658b9..b80abfa 100644 --- a/app/main.py +++ b/app/main.py @@ -8,6 +8,7 @@ from app.api import ( catalog, change_requests, entries, + gamification, my, ocr, service_centers, @@ -34,6 +35,7 @@ app.include_router(my.router, prefix="/api") app.include_router(catalog.router, prefix="/api") app.include_router(cars.router, prefix="/api") app.include_router(entries.router, prefix="/api") +app.include_router(gamification.router, prefix="/api") app.include_router(ocr.router, prefix="/api") app.include_router(service_centers.router, prefix="/api") app.include_router(service_visits.router, prefix="/api") diff --git a/app/models/gamification.py b/app/models/gamification.py new file mode 100644 index 0000000..1130981 --- /dev/null +++ b/app/models/gamification.py @@ -0,0 +1,94 @@ +from datetime import datetime +from decimal import Decimal + +from sqlalchemy import ( + JSON, + Boolean, + DateTime, + ForeignKey, + Integer, + Numeric, + String, + UniqueConstraint, + func, +) +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class Achievement(Base): + __tablename__ = "achievements" + + id: Mapped[int] = mapped_column(primary_key=True) + code: Mapped[str] = mapped_column(String(80), unique=True, index=True) + scope: Mapped[str] = mapped_column(String(24), default="user", server_default="user", index=True) + title: Mapped[str] = mapped_column(String(120)) + description: Mapped[str] = mapped_column(String(260)) + icon: Mapped[str | None] = mapped_column(String(40)) + category: Mapped[str | None] = mapped_column(String(40), index=True) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true", index=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + +class UserAchievement(Base): + __tablename__ = "user_achievements" + __table_args__ = ( + UniqueConstraint( + "user_id", + "achievement_id", + "vehicle_id", + "service_center_id", + name="uq_user_achievement_scope", + ), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) + achievement_id: Mapped[int] = mapped_column(ForeignKey("achievements.id", ondelete="CASCADE"), index=True) + vehicle_id: Mapped[int | None] = mapped_column(ForeignKey("cars.id", ondelete="CASCADE"), index=True) + service_center_id: Mapped[int | None] = mapped_column(ForeignKey("service_centers.id", ondelete="CASCADE"), index=True) + unlocked_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON) + + +class VehicleScore(Base): + __tablename__ = "vehicle_scores" + + id: Mapped[int] = mapped_column(primary_key=True) + vehicle_id: Mapped[int] = mapped_column(ForeignKey("cars.id", ondelete="CASCADE"), unique=True, index=True) + completeness_score: Mapped[int] = mapped_column(Integer, default=0, server_default="0") + verified_history_score: Mapped[int] = mapped_column(Integer, default=0, server_default="0") + maintenance_health_score: Mapped[int] = mapped_column(Integer, default=100, server_default="100") + maintenance_status: Mapped[str] = mapped_column(String(24), default="unknown", server_default="unknown", index=True) + profile_quality: Mapped[str] = mapped_column(String(40), default="basic", server_default="basic", index=True) + verified_history_status: Mapped[str] = mapped_column(String(40), default="self_reported", server_default="self_reported", index=True) + missing_items: Mapped[list | None] = mapped_column(JSON) + computed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + +class ServiceCenterScore(Base): + __tablename__ = "service_center_scores" + + id: Mapped[int] = mapped_column(primary_key=True) + service_center_id: Mapped[int] = mapped_column( + ForeignKey("service_centers.id", ondelete="CASCADE"), unique=True, index=True + ) + trust_score: Mapped[int] = mapped_column(Integer, default=0, server_default="0") + trust_level: Mapped[str] = mapped_column(String(40), default="new_service", server_default="new_service", index=True) + confirmed_visits_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0") + confirmation_rate: Mapped[Decimal] = mapped_column(Numeric(5, 2), default=0, server_default="0") + dispute_rate: Mapped[Decimal] = mapped_column(Numeric(5, 2), default=0, server_default="0") + computed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + +class EngagementEvent(Base): + __tablename__ = "engagement_events" + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), index=True) + vehicle_id: Mapped[int | None] = mapped_column(ForeignKey("cars.id", ondelete="CASCADE"), index=True) + service_center_id: Mapped[int | None] = mapped_column(ForeignKey("service_centers.id", ondelete="CASCADE"), index=True) + event_type: Mapped[str] = mapped_column(String(80), index=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True) diff --git a/app/schemas/gamification.py b/app/schemas/gamification.py new file mode 100644 index 0000000..0a79fa8 --- /dev/null +++ b/app/schemas/gamification.py @@ -0,0 +1,60 @@ +from datetime import datetime +from decimal import Decimal + +from pydantic import BaseModel, ConfigDict + + +class MissingItem(BaseModel): + code: str + title: str + description: str + weight: int + + +class VehicleScoreRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + vehicle_id: int + completeness_score: int + verified_history_score: int + maintenance_health_score: int + maintenance_status: str + profile_quality: str + verified_history_status: str + missing_items: list[MissingItem] = [] + computed_at: datetime + + +class AchievementRead(BaseModel): + code: str + scope: str + title: str + description: str + icon: str | None = None + category: str | None = None + unlocked_at: datetime | None = None + vehicle_id: int | None = None + service_center_id: int | None = None + + +class TimelineItem(BaseModel): + id: str + date: datetime | str + type: str + title: str + status: str | None = None + description: str | None = None + amount: Decimal | float | int | None = None + metadata: dict | None = None + + +class ServiceCenterScoreRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + service_center_id: int + trust_score: int + trust_level: str + confirmed_visits_count: int + confirmation_rate: Decimal | float + dispute_rate: Decimal | float + computed_at: datetime diff --git a/app/services/scoring.py b/app/services/scoring.py new file mode 100644 index 0000000..3ca4aad --- /dev/null +++ b/app/services/scoring.py @@ -0,0 +1,456 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date +from decimal import Decimal + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.models.car import Car, ServiceCenter, ServiceVisit +from app.models.expense import FuelEntry, ServiceEntry +from app.models.gamification import ( + Achievement, + EngagementEvent, + ServiceCenterScore, + UserAchievement, + VehicleScore, +) + + +@dataclass(frozen=True) +class MissingItem: + code: str + title: str + description: str + weight: int + + def as_dict(self) -> dict: + return { + "code": self.code, + "title": self.title, + "description": self.description, + "weight": self.weight, + } + + +DEFAULT_ACHIEVEMENTS = [ + { + "code": "first_service_record", + "scope": "vehicle", + "title": "First Service Record", + "description": "The vehicle passport has its first service record.", + "icon": "service", + "category": "maintenance", + }, + { + "code": "first_verified_service", + "scope": "vehicle", + "title": "First Verified Service", + "description": "A service-center visit was confirmed by the owner.", + "icon": "verified", + "category": "trust", + }, + { + "code": "full_vehicle_profile", + "scope": "vehicle", + "title": "Full Vehicle Profile", + "description": "The digital vehicle passport has high-quality identity and maintenance data.", + "icon": "passport", + "category": "profile", + }, + { + "code": "fuel_tracking_active", + "scope": "vehicle", + "title": "Fuel Tracking Active", + "description": "Fuel records are consistent enough to improve running-cost analytics.", + "icon": "fuel", + "category": "tracking", + }, + { + "code": "trusted_history", + "scope": "vehicle", + "title": "Trusted History", + "description": "Most maintenance records are confirmed and suitable for a trusted vehicle history.", + "icon": "shield", + "category": "trust", + }, + { + "code": "complete_garage", + "scope": "user", + "title": "Complete Garage", + "description": "Every active vehicle in the garage has a strong digital passport.", + "icon": "garage", + "category": "garage", + }, +] + + +def profile_quality(score: int) -> str: + if score >= 86: + return "high_confidence" + if score >= 61: + return "strong" + if score >= 31: + return "useful" + return "basic" + + +def verified_history_status(score: int, confirmed_visits: int) -> str: + if score >= 80 and confirmed_visits > 0: + return "verified" + if score >= 30 or confirmed_visits > 0: + return "partially_verified" + return "self_reported" + + +def score_maintenance(car: Car, service_entries: list[ServiceEntry], visits: list[ServiceVisit]) -> tuple[int, str]: + today = date.today() + current_odometer = car.current_odometer + red = False + yellow = False + has_baseline = bool(service_entries or visits) + + for entry in service_entries: + if entry.next_due_date and entry.next_due_date < today: + red = True + elif entry.next_due_date and (entry.next_due_date - today).days <= 30: + yellow = True + if current_odometer and entry.next_due_odometer: + overdue_km = current_odometer - entry.next_due_odometer + if overdue_km > 0: + red = True + elif overdue_km >= -1000: + yellow = True + + for visit in visits: + for item in getattr(visit, "work_items", []) or []: + if item.next_due_date and item.next_due_date < today: + red = True + elif item.next_due_date and (item.next_due_date - today).days <= 30: + yellow = True + if current_odometer and item.next_due_odometer: + overdue_km = current_odometer - item.next_due_odometer + if overdue_km > 0: + red = True + elif overdue_km >= -1000: + yellow = True + + if red: + return 30, "red" + if yellow: + return 70, "yellow" + if has_baseline: + return 100, "green" + return 55, "unknown" + + +async def compute_vehicle_score(session: AsyncSession, car: Car) -> VehicleScore: + fuel_entries = list( + ( + await session.execute( + select(FuelEntry).where(FuelEntry.car_id == car.id).order_by(FuelEntry.entry_date.desc()) + ) + ).scalars() + ) + service_entries = list( + ( + await session.execute( + select(ServiceEntry).where(ServiceEntry.car_id == car.id).order_by(ServiceEntry.entry_date.desc()) + ) + ).scalars() + ) + visits = list( + ( + await session.execute( + select(ServiceVisit) + .options(selectinload(ServiceVisit.work_items)) + .where(ServiceVisit.vehicle_id == car.id) + .order_by(ServiceVisit.visit_date.desc()) + ) + ).scalars() + ) + + score = 5 + missing: list[MissingItem] = [] + + baseline_fields = [car.make, car.model, car.year, car.name] + baseline_points = int(round(15 * sum(1 for value in baseline_fields if value) / len(baseline_fields))) + score += baseline_points + if baseline_points < 15: + missing.append( + MissingItem( + "vehicle_baseline", + "Complete make, model and year", + "These fields make the passport easier to identify and export.", + 15 - baseline_points, + ) + ) + + if car.vin_normalized: + score += 15 + else: + missing.append(MissingItem("vin", "Add VIN", "VIN raises identity confidence for the vehicle passport.", 15)) + + if car.license_plate_normalized and car.license_plate_country: + score += 10 + else: + missing.append( + MissingItem("license_plate", "Add license plate", "Plate and country help service centers request access safely.", 10) + ) + + if car.current_odometer: + score += 10 + else: + missing.append(MissingItem("odometer", "Add current mileage", "Mileage improves reminders and maintenance health.", 10)) + + if car.fuel_type and (fuel_entries or car.target_consumption_l_per_100km): + score += 10 + else: + missing.append( + MissingItem("fuel_baseline", "Add fuel baseline", "Fuel type and records improve consumption analytics.", 10) + ) + + if car.engine_oil_type and car.engine_oil_volume_l: + score += 10 + else: + missing.append(MissingItem("engine_oil", "Add oil specification", "Oil type and volume make service reminders useful.", 10)) + + fluid_fields = [car.transmission_fluid_type, car.coolant_type, car.brake_fluid_type] + fluid_points = min(5, sum(1 for value in fluid_fields if value) * 2) + score += fluid_points + if fluid_points < 5: + missing.append(MissingItem("fluids", "Add fluid details", "Transmission, coolant and brake fluid data improve service readiness.", 5 - fluid_points)) + + if fuel_entries: + score += 5 + else: + missing.append(MissingItem("fuel_entry", "Add first fuel record", "Fuel records unlock more accurate ownership cost analytics.", 5)) + + if service_entries or visits: + score += 5 + else: + missing.append(MissingItem("service_entry", "Add service history", "Service records build the maintenance timeline.", 5)) + + confirmed_visits = [visit for visit in visits if visit.status == "confirmed"] + if confirmed_visits: + score += 10 + else: + missing.append( + MissingItem( + "confirmed_service", + "Confirm a service-center visit", + "Owner-confirmed service visits make the history more trustworthy.", + 10, + ) + ) + + total_service_records = len(service_entries) + len([visit for visit in visits if visit.status != "draft"]) + trusted_records = len(confirmed_visits) + history_score = int(round((trusted_records / total_service_records) * 100)) if total_service_records else 0 + maintenance_score, maintenance_status = score_maintenance(car, service_entries, visits) + + score = min(100, score) + result = await session.execute(select(VehicleScore).where(VehicleScore.vehicle_id == car.id)) + vehicle_score = result.scalar_one_or_none() + payload = { + "completeness_score": score, + "verified_history_score": history_score, + "maintenance_health_score": maintenance_score, + "maintenance_status": maintenance_status, + "profile_quality": profile_quality(score), + "verified_history_status": verified_history_status(history_score, trusted_records), + "missing_items": [item.as_dict() for item in missing], + } + if vehicle_score is None: + vehicle_score = VehicleScore(vehicle_id=car.id, **payload) + session.add(vehicle_score) + else: + for key, value in payload.items(): + setattr(vehicle_score, key, value) + await session.flush() + await evaluate_vehicle_achievements(session, car, vehicle_score, fuel_entries, service_entries, visits) + return vehicle_score + + +async def ensure_default_achievements(session: AsyncSession) -> dict[str, Achievement]: + result = await session.execute(select(Achievement)) + existing = {achievement.code: achievement for achievement in result.scalars()} + for item in DEFAULT_ACHIEVEMENTS: + if item["code"] not in existing: + achievement = Achievement(**item) + session.add(achievement) + existing[item["code"]] = achievement + await session.flush() + return existing + + +async def unlock_achievement( + session: AsyncSession, + *, + user_id: int, + achievement: Achievement, + vehicle_id: int | None = None, + service_center_id: int | None = None, + metadata: dict | None = None, +) -> None: + result = await session.execute( + select(UserAchievement).where( + UserAchievement.user_id == user_id, + UserAchievement.achievement_id == achievement.id, + UserAchievement.vehicle_id.is_(None) if vehicle_id is None else UserAchievement.vehicle_id == vehicle_id, + UserAchievement.service_center_id.is_(None) + if service_center_id is None + else UserAchievement.service_center_id == service_center_id, + ) + ) + if result.scalar_one_or_none() is not None: + return + session.add( + UserAchievement( + user_id=user_id, + achievement_id=achievement.id, + vehicle_id=vehicle_id, + service_center_id=service_center_id, + metadata_json=metadata, + ) + ) + session.add( + EngagementEvent( + user_id=user_id, + vehicle_id=vehicle_id, + service_center_id=service_center_id, + event_type="achievement_unlocked", + metadata_json={"code": achievement.code}, + ) + ) + + +async def evaluate_vehicle_achievements( + session: AsyncSession, + car: Car, + vehicle_score: VehicleScore, + fuel_entries: list[FuelEntry], + service_entries: list[ServiceEntry], + visits: list[ServiceVisit], +) -> None: + achievements = await ensure_default_achievements(session) + if service_entries or visits: + await unlock_achievement( + session, + user_id=car.owner_id, + vehicle_id=car.id, + achievement=achievements["first_service_record"], + ) + if any(visit.status == "confirmed" for visit in visits): + await unlock_achievement( + session, + user_id=car.owner_id, + vehicle_id=car.id, + achievement=achievements["first_verified_service"], + ) + if vehicle_score.completeness_score >= 90: + await unlock_achievement( + session, + user_id=car.owner_id, + vehicle_id=car.id, + achievement=achievements["full_vehicle_profile"], + ) + if len(fuel_entries) >= 3: + await unlock_achievement( + session, + user_id=car.owner_id, + vehicle_id=car.id, + achievement=achievements["fuel_tracking_active"], + ) + if vehicle_score.verified_history_status == "verified": + await unlock_achievement( + session, + user_id=car.owner_id, + vehicle_id=car.id, + achievement=achievements["trusted_history"], + ) + + +async def evaluate_garage_achievements(session: AsyncSession, user_id: int) -> None: + result = await session.execute(select(Car).where(Car.owner_id == user_id)) + cars = list(result.scalars()) + if not cars: + return + scores = [] + for car in cars: + scores.append(await compute_vehicle_score(session, car)) + if all(score.completeness_score >= 80 for score in scores): + achievements = await ensure_default_achievements(session) + await unlock_achievement(session, user_id=user_id, achievement=achievements["complete_garage"]) + + +async def compute_service_center_score(session: AsyncSession, center: ServiceCenter) -> ServiceCenterScore: + visits = list( + ( + await session.execute(select(ServiceVisit).where(ServiceVisit.service_center_id == center.id)) + ).scalars() + ) + relevant = [visit for visit in visits if visit.status not in {"draft", "cancelled"}] + confirmed = [visit for visit in relevant if visit.status == "confirmed"] + disputed = [visit for visit in relevant if visit.status == "disputed"] + confirmation_rate = Decimal("0") + dispute_rate = Decimal("0") + if relevant: + confirmation_rate = Decimal(len(confirmed) * 100) / Decimal(len(relevant)) + dispute_rate = Decimal(len(disputed) * 100) / Decimal(len(relevant)) + + score = 20 if center.verification_status == "verified" else 5 + score += min(30, len(confirmed) * 5) + score += int(min(30, confirmation_rate * Decimal("0.3"))) + score -= int(min(25, dispute_rate * Decimal("0.5"))) + score = max(0, min(100, score)) + + if center.verification_status != "verified": + level = "new_service" + elif score >= 85: + level = "high_confidence_service" + elif score >= 65: + level = "reliable_service" + else: + level = "verified_service" + + result = await session.execute(select(ServiceCenterScore).where(ServiceCenterScore.service_center_id == center.id)) + center_score = result.scalar_one_or_none() + payload = { + "trust_score": score, + "trust_level": level, + "confirmed_visits_count": len(confirmed), + "confirmation_rate": confirmation_rate.quantize(Decimal("0.01")), + "dispute_rate": dispute_rate.quantize(Decimal("0.01")), + } + if center_score is None: + center_score = ServiceCenterScore(service_center_id=center.id, **payload) + session.add(center_score) + else: + for key, value in payload.items(): + setattr(center_score, key, value) + await session.flush() + return center_score + + +async def garage_quality(session: AsyncSession, user_id: int) -> dict: + result = await session.execute(select(Car).where(Car.owner_id == user_id)) + cars = list(result.scalars()) + if not cars: + return {"vehicles_count": 0, "average_completeness": 0, "verified_records": 0} + scores = [await compute_vehicle_score(session, car) for car in cars] + confirmed_count = ( + await session.execute( + select(func.count(ServiceVisit.id)).where( + ServiceVisit.vehicle_id.in_([car.id for car in cars]), + ServiceVisit.status == "confirmed", + ) + ) + ).scalar_one() + return { + "vehicles_count": len(cars), + "average_completeness": int(round(sum(score.completeness_score for score in scores) / len(scores))), + "verified_records": confirmed_count, + } diff --git a/tests/conftest.py b/tests/conftest.py index 7947f57..417550a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,7 +14,7 @@ from app.core.config import settings from app.db.base import Base from app.db.session import get_session from app.main import app -from app.models import car, expense, push, user # noqa: F401 +from app.models import car, expense, gamification, push, user # noqa: F401 TEST_BOT_TOKEN = "123456:test-token" TEST_INTERNAL_TOKEN = "internal-test-token" diff --git a/tests/test_gamification.py b/tests/test_gamification.py new file mode 100644 index 0000000..04bdde9 --- /dev/null +++ b/tests/test_gamification.py @@ -0,0 +1,86 @@ +import pytest + + +@pytest.mark.asyncio +async def test_vehicle_score_is_backend_driven(client, auth_headers) -> None: + vehicle = ( + await client.post( + "/api/my/vehicles", + headers=auth_headers, + json={ + "name": "Passport car", + "make": "Hyundai", + "model": "Avante", + "year": 2020, + "license_plate": "12 가 3456", + "license_plate_country": "KR", + "vin": "KMHCT41BAHU123456", + "fuel_type": "gasoline", + "current_odometer": 12000, + "engine_oil_type": "5W-30", + "engine_oil_volume_l": 4.2, + }, + ) + ).json() + await client.post( + "/api/fuel", + headers=auth_headers, + json={ + "car_id": vehicle["id"], + "entry_date": "2026-05-12", + "odometer": 12000, + "liters": 35, + "price_per_liter": 2, + }, + ) + + response = await client.get(f"/api/my/vehicles/{vehicle['id']}/score", headers=auth_headers) + + assert response.status_code == 200 + payload = response.json() + assert payload["vehicle_id"] == vehicle["id"] + assert payload["completeness_score"] >= 70 + assert payload["profile_quality"] in {"strong", "high_confidence"} + assert isinstance(payload["missing_items"], list) + + +@pytest.mark.asyncio +async def test_timeline_contains_fuel_service_and_achievements(client, auth_headers) -> None: + vehicle = (await client.post("/api/my/vehicles", headers=auth_headers, json={"name": "Timeline car"})).json() + await client.post( + "/api/service", + headers=auth_headers, + json={ + "car_id": vehicle["id"], + "entry_date": "2026-05-12", + "service_type": "maintenance", + "title": "Oil service", + "total_cost": 100, + }, + ) + await client.get(f"/api/my/vehicles/{vehicle['id']}/score", headers=auth_headers) + + response = await client.get(f"/api/my/vehicles/{vehicle['id']}/timeline", headers=auth_headers) + + assert response.status_code == 200 + item_types = {item["type"] for item in response.json()} + assert "service" in item_types + assert "achievement" in item_types + + +@pytest.mark.asyncio +async def test_service_center_trust_score_requires_employee_access(client, auth_headers, other_auth_headers) -> None: + center = ( + await client.post( + "/api/service-centers", + headers=auth_headers, + json={"display_name": "Trust Service", "country": "KR", "city": "Seoul"}, + ) + ).json() + + forbidden = await client.get(f"/api/service-centers/{center['id']}/trust-score", headers=other_auth_headers) + allowed = await client.get(f"/api/service-centers/{center['id']}/trust-score", headers=auth_headers) + + assert forbidden.status_code == 403 + assert allowed.status_code == 200 + assert allowed.json()["trust_level"] == "new_service"