Initial commit: CI Agent competitive-intelligence monitoring app
FastAPI + Celery + Next.js + Postgres/Redis app with company monitoring, source collection, LLM-based change analysis, enrichment, and account security (Turnstile, escalating lockout, email verification).
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
"""SQLAlchemy ORM models.
|
||||
|
||||
Every model module is imported here so that `Base.metadata` (used by Alembic
|
||||
autogenerate) sees the full schema. Add new model modules to this list as
|
||||
they're created.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.models.alert import Alert # noqa: F401
|
||||
from app.models.company import Company, CompanyAlias, Competitor # noqa: F401
|
||||
from app.models.company_enrichment import CompanyEnrichment # noqa: F401
|
||||
from app.models.detected_change import DetectedChange # noqa: F401
|
||||
from app.models.email_code import EmailCode # noqa: F401
|
||||
from app.models.ip_ban import IpBan # noqa: F401
|
||||
from app.models.ip_throttle_state import IpThrottleState # noqa: F401
|
||||
from app.models.monitor_configuration import MonitorConfiguration # noqa: F401
|
||||
from app.models.monitoring_run import MonitoringRun # noqa: F401
|
||||
from app.models.notification_delivery import NotificationDelivery # noqa: F401
|
||||
from app.models.notification_destination import ( # noqa: F401
|
||||
NotificationDestination,
|
||||
NotificationDestinationCompany,
|
||||
)
|
||||
from app.models.password_history import PasswordHistoryEntry # noqa: F401
|
||||
from app.models.refresh_token import RefreshToken # noqa: F401
|
||||
from app.models.report import Report # noqa: F401
|
||||
from app.models.snapshot import Snapshot # noqa: F401
|
||||
from app.models.source import Source # noqa: F401
|
||||
from app.models.source_document import SourceDocument # noqa: F401
|
||||
from app.models.system_secret import SystemSecret # noqa: F401
|
||||
from app.models.unban_request import UnbanRequest # noqa: F401
|
||||
from app.models.user import User # noqa: F401
|
||||
from app.models.user_api_key import UserApiKey # noqa: F401
|
||||
from app.models.user_known_ip import UserKnownIp # noqa: F401
|
||||
from app.models.user_security_event import UserSecurityEvent # noqa: F401
|
||||
@@ -0,0 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import Boolean, Enum, Float, ForeignKey, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
from app.models.enums import SeverityLevel
|
||||
|
||||
|
||||
class Alert(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||
__tablename__ = "alerts"
|
||||
|
||||
company_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("companies.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
detected_change_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("detected_changes.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(200))
|
||||
summary: Mapped[str] = mapped_column(Text)
|
||||
why_it_matters: Mapped[str] = mapped_column(Text)
|
||||
severity: Mapped[SeverityLevel] = mapped_column(
|
||||
Enum(SeverityLevel, native_enum=False, length=20)
|
||||
)
|
||||
confidence: Mapped[float] = mapped_column(Float)
|
||||
read: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
resolved: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
@@ -0,0 +1,76 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import JSON, Enum, ForeignKey, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
from app.models.enums import CompanyStatus
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.company_enrichment import CompanyEnrichment
|
||||
from app.models.monitor_configuration import MonitorConfiguration
|
||||
from app.models.notification_destination import NotificationDestinationCompany
|
||||
|
||||
|
||||
class Company(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||
__tablename__ = "companies"
|
||||
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(200))
|
||||
slug: Mapped[str] = mapped_column(String(220), index=True)
|
||||
official_website: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
monitoring_focus: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
industry: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
country: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
region: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
headquarters: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
# Best-effort discovered identifiers, e.g. {"ticker": "ACME", "linkedin_url": "..."}
|
||||
# - see app/prompts/company_profile.py. Empty dict, never fabricated.
|
||||
public_identifiers: Mapped[dict[str, str]] = mapped_column(JSON, default=dict)
|
||||
status: Mapped[CompanyStatus] = mapped_column(
|
||||
Enum(CompanyStatus, native_enum=False, length=20), default=CompanyStatus.ACTIVE
|
||||
)
|
||||
|
||||
aliases: Mapped[list[CompanyAlias]] = relationship(
|
||||
back_populates="company", cascade="all, delete-orphan"
|
||||
)
|
||||
competitors: Mapped[list[Competitor]] = relationship(
|
||||
back_populates="company", cascade="all, delete-orphan"
|
||||
)
|
||||
monitor_configuration: Mapped[MonitorConfiguration | None] = relationship(
|
||||
back_populates="company", cascade="all, delete-orphan", uselist=False
|
||||
)
|
||||
enrichment: Mapped[CompanyEnrichment | None] = relationship(
|
||||
back_populates="company", cascade="all, delete-orphan", uselist=False
|
||||
)
|
||||
notification_links: Mapped[list[NotificationDestinationCompany]] = relationship(
|
||||
cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class CompanyAlias(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||
__tablename__ = "company_aliases"
|
||||
|
||||
company_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("companies.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
alias: Mapped[str] = mapped_column(String(200))
|
||||
|
||||
company: Mapped[Company] = relationship(back_populates="aliases")
|
||||
|
||||
|
||||
class Competitor(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||
__tablename__ = "competitors"
|
||||
|
||||
company_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("companies.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(200))
|
||||
|
||||
company: Mapped[Company] = relationship(back_populates="competitors")
|
||||
@@ -0,0 +1,42 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from sqlalchemy import JSON, DateTime, Enum, ForeignKey, Integer
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
from app.models.enums import EnrichmentStatus
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.company import Company
|
||||
|
||||
|
||||
class CompanyEnrichment(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||
"""One-shot, onboarding-time company enrichment from a paid third-party
|
||||
provider (NinjaPear/nubela.co) - never re-fetched on a schedule, see
|
||||
app/services/enrichment_service.py. `data` holds a documented (not
|
||||
DB-enforced) shape: employee_count, description, specialties,
|
||||
leadership_team (each optionally carrying work_email/profile_url/bio),
|
||||
funding (total_raised + rounds), competitors (name+reason), products,
|
||||
recent_updates, customers. `errors` maps section name -> error message
|
||||
for whichever calls failed, so a partial result is never silently
|
||||
presented as complete - same transparency principle as every other
|
||||
source/collector in this app."""
|
||||
|
||||
__tablename__ = "company_enrichments"
|
||||
|
||||
company_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("companies.id", ondelete="CASCADE"), unique=True, index=True
|
||||
)
|
||||
status: Mapped[EnrichmentStatus] = mapped_column(
|
||||
Enum(EnrichmentStatus, native_enum=False, length=20), default=EnrichmentStatus.PENDING
|
||||
)
|
||||
data: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||
errors: Mapped[dict[str, str]] = mapped_column(JSON, default=dict)
|
||||
credits_spent: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
fetched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
company: Mapped[Company] = relationship(back_populates="enrichment")
|
||||
@@ -0,0 +1,44 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import JSON, Enum, Float, ForeignKey, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
from app.models.enums import ChangeStatus, ChangeType, SeverityLevel
|
||||
|
||||
|
||||
class DetectedChange(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||
__tablename__ = "detected_changes"
|
||||
|
||||
company_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("companies.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
source_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("sources.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
monitoring_run_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("monitoring_runs.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
previous_snapshot_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
ForeignKey("snapshots.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
current_snapshot_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("snapshots.id", ondelete="CASCADE")
|
||||
)
|
||||
change_type: Mapped[ChangeType] = mapped_column(Enum(ChangeType, native_enum=False, length=30))
|
||||
raw_diff: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||
significance_score: Mapped[float] = mapped_column(Float)
|
||||
confidence_score: Mapped[float] = mapped_column(Float)
|
||||
severity: Mapped[SeverityLevel] = mapped_column(
|
||||
Enum(SeverityLevel, native_enum=False, length=20)
|
||||
)
|
||||
status: Mapped[ChangeStatus] = mapped_column(
|
||||
Enum(ChangeStatus, native_enum=False, length=20), default=ChangeStatus.NEW
|
||||
)
|
||||
# Short human-readable label, e.g. "3 new job postings detected" -
|
||||
# populated deterministically here; Phase 7's LLM may later add a
|
||||
# richer "why it matters" narrative on top without replacing this.
|
||||
summary: Mapped[str] = mapped_column(String(500))
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Email verification / password-reset codes.
|
||||
|
||||
Only a SHA-256 hash of the 6-digit code is stored, never the raw value -
|
||||
same "never store the raw secret" precedent as RefreshToken.token_hash. A
|
||||
short numeric code doesn't need Argon2's cost; it needs short expiry plus
|
||||
the IP throttle system (app/services/ip_throttle_service.py) guarding how
|
||||
often it can be guessed or resent.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import DateTime, Enum, ForeignKey, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
from app.models.enums import EmailCodePurpose
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
class EmailCode(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||
__tablename__ = "email_codes"
|
||||
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||
purpose: Mapped[EmailCodePurpose] = mapped_column(
|
||||
Enum(EmailCodePurpose, native_enum=False, length=20)
|
||||
)
|
||||
code_hash: Mapped[str] = mapped_column(String(64), index=True)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
user: Mapped[User] = relationship()
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Shared string enums for ORM models. Mirrored in apps/web/lib/types.ts and
|
||||
packages/shared/src/index.ts - keep those in sync by hand when changing this."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class CompanyStatus(StrEnum):
|
||||
ACTIVE = "active"
|
||||
PAUSED = "paused"
|
||||
|
||||
|
||||
class MonitoringFrequency(StrEnum):
|
||||
HOURLY = "hourly"
|
||||
EVERY_6_HOURS = "every_6_hours"
|
||||
EVERY_12_HOURS = "every_12_hours"
|
||||
DAILY = "daily"
|
||||
EVERY_2_DAYS = "every_2_days"
|
||||
WEEKLY = "weekly"
|
||||
EVERY_2_WEEKS = "every_2_weeks"
|
||||
MONTHLY = "monthly"
|
||||
CUSTOM = "custom"
|
||||
|
||||
|
||||
# Minimum minutes represented by each non-custom frequency, used both to
|
||||
# compute next_run and to enforce MINIMUM_MONITORING_INTERVAL_MINUTES.
|
||||
FREQUENCY_MINUTES: dict[MonitoringFrequency, int] = {
|
||||
MonitoringFrequency.HOURLY: 60,
|
||||
MonitoringFrequency.EVERY_6_HOURS: 6 * 60,
|
||||
MonitoringFrequency.EVERY_12_HOURS: 12 * 60,
|
||||
MonitoringFrequency.DAILY: 24 * 60,
|
||||
MonitoringFrequency.EVERY_2_DAYS: 2 * 24 * 60,
|
||||
MonitoringFrequency.WEEKLY: 7 * 24 * 60,
|
||||
MonitoringFrequency.EVERY_2_WEEKS: 14 * 24 * 60,
|
||||
MonitoringFrequency.MONTHLY: 30 * 24 * 60,
|
||||
}
|
||||
|
||||
|
||||
class SeverityLevel(StrEnum):
|
||||
CRITICAL = "critical"
|
||||
HIGH = "high"
|
||||
MEDIUM = "medium"
|
||||
LOW = "low"
|
||||
|
||||
|
||||
# Ordering for threshold comparisons (index 0 = most severe).
|
||||
SEVERITY_ORDER: list[SeverityLevel] = [
|
||||
SeverityLevel.CRITICAL,
|
||||
SeverityLevel.HIGH,
|
||||
SeverityLevel.MEDIUM,
|
||||
SeverityLevel.LOW,
|
||||
]
|
||||
|
||||
|
||||
class NotificationType(StrEnum):
|
||||
EMAIL = "email"
|
||||
SMS = "sms"
|
||||
CONSOLE = "console"
|
||||
|
||||
|
||||
class SourceType(StrEnum):
|
||||
WEBSITE = "website"
|
||||
RSS = "rss"
|
||||
CUSTOM_URL = "custom_url"
|
||||
SEC_EDGAR = "sec_edgar"
|
||||
GITHUB = "github"
|
||||
JOB_POSTING = "job_posting"
|
||||
PATENT = "patent"
|
||||
REVIEW = "review"
|
||||
GOV_CONTRACT = "gov_contract"
|
||||
|
||||
|
||||
class SourceStatus(StrEnum):
|
||||
ACTIVE = "active"
|
||||
DISABLED = "disabled"
|
||||
RATE_LIMITED = "rate_limited"
|
||||
AUTH_REQUIRED = "auth_required"
|
||||
BLOCKED_BY_POLICY = "blocked_by_policy"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class MonitoringRunTrigger(StrEnum):
|
||||
SCHEDULED = "scheduled"
|
||||
MANUAL = "manual"
|
||||
INITIAL = "initial"
|
||||
RETRY = "retry"
|
||||
|
||||
|
||||
class MonitoringRunStatus(StrEnum):
|
||||
QUEUED = "queued"
|
||||
RUNNING = "running"
|
||||
SUCCESSFUL = "successful"
|
||||
PARTIAL = "partial"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class ChangeType(StrEnum):
|
||||
NEW_DOCUMENT = "new_document"
|
||||
REMOVED_DOCUMENT = "removed_document"
|
||||
CONTENT_MODIFIED = "content_modified"
|
||||
PRICE_CHANGE = "price_change"
|
||||
LEADERSHIP_CHANGE = "leadership_change"
|
||||
FILING_NEW = "filing_new"
|
||||
|
||||
|
||||
class ChangeStatus(StrEnum):
|
||||
NEW = "new"
|
||||
ACKNOWLEDGED = "acknowledged"
|
||||
DISMISSED = "dismissed"
|
||||
|
||||
|
||||
class ReportType(StrEnum):
|
||||
BASELINE = "baseline"
|
||||
UPDATE = "update"
|
||||
MONTHLY = "monthly"
|
||||
MANUAL = "manual"
|
||||
|
||||
|
||||
class NotificationDeliveryStatus(StrEnum):
|
||||
PENDING = "pending"
|
||||
SENT = "sent"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class EnrichmentStatus(StrEnum):
|
||||
PENDING = "pending"
|
||||
PARTIAL = "partial"
|
||||
COMPLETE = "complete"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class EmailCodePurpose(StrEnum):
|
||||
VERIFY_EMAIL = "verify_email"
|
||||
PASSWORD_RESET = "password_reset"
|
||||
|
||||
|
||||
class ThrottleAction(StrEnum):
|
||||
RESEND_VERIFICATION = "resend_verification"
|
||||
RESEND_RESET = "resend_reset"
|
||||
FAILED_LOGIN = "failed_login"
|
||||
VERIFY_EMAIL_CODE = "verify_email_code"
|
||||
CONFIRM_RESET_CODE = "confirm_reset_code"
|
||||
|
||||
|
||||
class SecurityEventType(StrEnum):
|
||||
LOGIN_SUCCESS = "login_success"
|
||||
LOGIN_FAILED = "login_failed"
|
||||
ACCOUNT_LOCKED = "account_locked"
|
||||
PASSWORD_RESET_REQUESTED = "password_reset_requested"
|
||||
PASSWORD_RESET_COMPLETED = "password_reset_completed"
|
||||
EMAIL_VERIFICATION_SENT = "email_verification_sent"
|
||||
EMAIL_VERIFIED = "email_verified"
|
||||
SERVER_SECRET_UPDATED = "server_secret_updated"
|
||||
API_KEY_UPDATED = "api_key_updated"
|
||||
|
||||
|
||||
class ApiKeyProvider(StrEnum):
|
||||
"""Third-party providers a user can supply their own key for - see
|
||||
app/services/user_api_key_service.py's PROVIDER_META for the matching
|
||||
Settings field, display label, and credits/notes shown in Settings."""
|
||||
|
||||
ANTHROPIC = "anthropic"
|
||||
BRAVE_SEARCH = "brave_search"
|
||||
NINJAPEAR = "ninjapear"
|
||||
USPTO = "uspto"
|
||||
|
||||
|
||||
class SystemSecretKey(StrEnum):
|
||||
"""Server-wide (not per-user) secrets an admin can configure from the
|
||||
Settings page instead of only via .env - see
|
||||
app/services/system_secret_service.py's META for the matching Settings
|
||||
field and display label."""
|
||||
|
||||
TURNSTILE_SITE_KEY = "turnstile_site_key"
|
||||
TURNSTILE_SECRET = "turnstile_secret"
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Global per-IP bans. Deliberately separate from IpThrottleState - once any
|
||||
action type escalates an IP to permanent, that IP is blocked from every
|
||||
sensitive endpoint (register/login/resend/reset), not just the one action
|
||||
that triggered it."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
|
||||
|
||||
class IpBan(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||
__tablename__ = "ip_bans"
|
||||
|
||||
ip_address: Mapped[str] = mapped_column(String(45), unique=True, index=True)
|
||||
banned_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
reason: Mapped[str] = mapped_column(String(255))
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Per-IP, per-action escalation state for the throttle/ban engine
|
||||
(app/services/ip_throttle_service.py). `offense_count` is the "memory" that
|
||||
survives a completed timeout cycle - only a manual admin unban resets it."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Enum, Integer, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
from app.models.enums import ThrottleAction
|
||||
|
||||
|
||||
class IpThrottleState(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||
__tablename__ = "ip_throttle_state"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("ip_address", "action", name="uq_ip_throttle_state_ip_action"),
|
||||
)
|
||||
|
||||
ip_address: Mapped[str] = mapped_column(String(45), index=True)
|
||||
action: Mapped[ThrottleAction] = mapped_column(
|
||||
Enum(ThrottleAction, native_enum=False, length=24)
|
||||
)
|
||||
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
next_allowed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
timeout_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
offense_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
@@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from sqlalchemy import JSON, Boolean, DateTime, Enum, ForeignKey, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
from app.models.enums import MonitoringFrequency, SeverityLevel
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.company import Company
|
||||
|
||||
|
||||
class MonitorConfiguration(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||
__tablename__ = "monitor_configurations"
|
||||
|
||||
company_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("companies.id", ondelete="CASCADE"), unique=True, index=True
|
||||
)
|
||||
frequency_type: Mapped[MonitoringFrequency] = mapped_column(
|
||||
Enum(MonitoringFrequency, native_enum=False, length=30),
|
||||
default=MonitoringFrequency.WEEKLY,
|
||||
)
|
||||
# Only meaningful when frequency_type == CUSTOM: interval_minutes takes
|
||||
# precedence if set, otherwise cron_expression is used.
|
||||
interval_minutes: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
cron_expression: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
timezone: Mapped[str] = mapped_column(String(64), default="America/New_York")
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
next_run: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
last_run: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
severity_threshold: Mapped[SeverityLevel] = mapped_column(
|
||||
Enum(SeverityLevel, native_enum=False, length=20), default=SeverityLevel.MEDIUM
|
||||
)
|
||||
source_configuration: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||
|
||||
company: Mapped[Company] = relationship(back_populates="monitor_configuration")
|
||||
@@ -0,0 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Enum, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
from app.models.enums import MonitoringRunStatus, MonitoringRunTrigger
|
||||
|
||||
|
||||
class MonitoringRun(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||
__tablename__ = "monitoring_runs"
|
||||
|
||||
company_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("companies.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
trigger_type: Mapped[MonitoringRunTrigger] = mapped_column(
|
||||
Enum(MonitoringRunTrigger, native_enum=False, length=20)
|
||||
)
|
||||
status: Mapped[MonitoringRunStatus] = mapped_column(
|
||||
Enum(MonitoringRunStatus, native_enum=False, length=20),
|
||||
default=MonitoringRunStatus.QUEUED,
|
||||
)
|
||||
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
sources_attempted: Mapped[int] = mapped_column(Integer, default=0)
|
||||
sources_successful: Mapped[int] = mapped_column(Integer, default=0)
|
||||
sources_failed: Mapped[int] = mapped_column(Integer, default=0)
|
||||
items_collected: Mapped[int] = mapped_column(Integer, default=0)
|
||||
changes_detected: Mapped[int] = mapped_column(Integer, default=0)
|
||||
error_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
worker_task_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Enum, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
from app.models.enums import NotificationDeliveryStatus
|
||||
|
||||
|
||||
class NotificationDelivery(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||
__tablename__ = "notification_deliveries"
|
||||
|
||||
alert_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("alerts.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
destination_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("notification_destinations.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
provider: Mapped[str] = mapped_column(String(50))
|
||||
status: Mapped[NotificationDeliveryStatus] = mapped_column(
|
||||
Enum(NotificationDeliveryStatus, native_enum=False, length=20),
|
||||
default=NotificationDeliveryStatus.PENDING,
|
||||
)
|
||||
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
last_attempt: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
external_message_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
@@ -0,0 +1,57 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import Boolean, Enum, ForeignKey, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
from app.models.enums import NotificationType, SeverityLevel
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.company import Company
|
||||
|
||||
|
||||
class NotificationDestination(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||
__tablename__ = "notification_destinations"
|
||||
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
type: Mapped[NotificationType] = mapped_column(
|
||||
Enum(NotificationType, native_enum=False, length=20)
|
||||
)
|
||||
# Email address, phone number, or a label for the console provider.
|
||||
# Not a secret, but still PII - see SECURITY.md.
|
||||
destination_value: Mapped[str] = mapped_column(String(320))
|
||||
verified: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
minimum_severity: Mapped[SeverityLevel] = mapped_column(
|
||||
Enum(SeverityLevel, native_enum=False, length=20), default=SeverityLevel.MEDIUM
|
||||
)
|
||||
|
||||
company_links: Mapped[list[NotificationDestinationCompany]] = relationship(
|
||||
back_populates="destination", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class NotificationDestinationCompany(Base, TimestampMixin):
|
||||
"""Which companies a destination receives alerts for - a destination
|
||||
with zero links is orphaned and gets garbage-collected (see
|
||||
notification_destination_service.py) rather than left dangling."""
|
||||
|
||||
__tablename__ = "notification_destination_companies"
|
||||
|
||||
destination_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("notification_destinations.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
company_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("companies.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
|
||||
destination: Mapped[NotificationDestination] = relationship(back_populates="company_links")
|
||||
# Read-only path to the company's name for display; Company.notification_links
|
||||
# (used only for cascade-delete) writes the same FK from the other direction,
|
||||
# hence overlaps= to tell SQLAlchemy that's intentional, not a conflict.
|
||||
company: Mapped[Company] = relationship(overlaps="notification_links")
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Every password hash a user has ever had active - checked on password
|
||||
reset so a user can't "reset" back to a password they (or an attacker who
|
||||
learned it) has used before. Never used for anything except that
|
||||
membership check; nothing reads these hashes back out for display."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import ForeignKey, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
class PasswordHistoryEntry(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||
__tablename__ = "password_history_entries"
|
||||
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
password_hash: Mapped[str] = mapped_column(String(255))
|
||||
|
||||
user: Mapped[User] = relationship()
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Refresh token records.
|
||||
|
||||
Only a hash of the token's `jti` is stored - never the JWT itself - so a
|
||||
database read can't be replayed as a valid refresh token. Rotation on use
|
||||
(one row per issuance, `revoked_at` set when superseded) limits the blast
|
||||
radius of a leaked refresh token to its remaining lifetime.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
class RefreshToken(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||
__tablename__ = "refresh_tokens"
|
||||
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||
token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
user: Mapped[User] = relationship(back_populates="refresh_tokens")
|
||||
@@ -0,0 +1,29 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import JSON, Enum, ForeignKey, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
from app.models.enums import ReportType
|
||||
|
||||
|
||||
class Report(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||
__tablename__ = "reports"
|
||||
|
||||
company_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("companies.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
monitoring_run_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
ForeignKey("monitoring_runs.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
report_type: Mapped[ReportType] = mapped_column(Enum(ReportType, native_enum=False, length=20))
|
||||
title: Mapped[str] = mapped_column(String(300))
|
||||
executive_summary: Mapped[str] = mapped_column(Text)
|
||||
structured_report: Mapped[dict[str, Any]] = mapped_column(JSON)
|
||||
markdown_content: Mapped[str] = mapped_column(Text)
|
||||
model_provider: Mapped[str] = mapped_column(String(50))
|
||||
model_name: Mapped[str] = mapped_column(String(100))
|
||||
prompt_version: Mapped[str] = mapped_column(String(20), default="v1")
|
||||
@@ -0,0 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import JSON, ForeignKey, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
|
||||
|
||||
class Snapshot(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||
"""A structured, comparable summary of a source's state at a point in
|
||||
time - what change detection (Phase 6) diffs against the prior snapshot
|
||||
for the same source."""
|
||||
|
||||
__tablename__ = "snapshots"
|
||||
|
||||
company_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("companies.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
source_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("sources.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
snapshot_type: Mapped[str] = mapped_column(String(50))
|
||||
hash: Mapped[str] = mapped_column(String(64), index=True)
|
||||
structured_summary: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||
text_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
monitoring_run_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
ForeignKey("monitoring_runs.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
@@ -0,0 +1,45 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import JSON, Boolean, DateTime, Enum, Float, ForeignKey, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
from app.models.enums import MonitoringFrequency, SourceStatus, SourceType
|
||||
|
||||
|
||||
class Source(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||
__tablename__ = "sources"
|
||||
|
||||
company_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("companies.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
source_type: Mapped[SourceType] = mapped_column(Enum(SourceType, native_enum=False, length=20))
|
||||
name: Mapped[str] = mapped_column(String(200))
|
||||
base_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
status: Mapped[SourceStatus] = mapped_column(
|
||||
Enum(SourceStatus, native_enum=False, length=20), default=SourceStatus.ACTIVE
|
||||
)
|
||||
trust_score: Mapped[float] = mapped_column(Float, default=0.7)
|
||||
last_checked: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
last_successful_check: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
failure_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
configuration_metadata: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||
|
||||
# Per-source check cadence override - NULL frequency_type means "inherit
|
||||
# the company's default MonitorConfiguration cadence" (the behavior for
|
||||
# every source before this feature existed, and still the default for
|
||||
# any source that never sets an override). See app/tasks/scheduler.py
|
||||
# and app/services/scheduling.py for how these combine into due-ness.
|
||||
frequency_type: Mapped[MonitoringFrequency | None] = mapped_column(
|
||||
Enum(MonitoringFrequency, native_enum=False, length=20), nullable=True
|
||||
)
|
||||
interval_minutes: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
cron_expression: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
next_check: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
@@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import JSON, DateTime, Float, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
|
||||
|
||||
class SourceDocument(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||
"""A single piece of collected content. Kept lean on purpose - raw HTML
|
||||
is not stored, only extracted text - per the "avoid saving unnecessary
|
||||
full HTML indefinitely" rule in the spec."""
|
||||
|
||||
__tablename__ = "source_documents"
|
||||
|
||||
source_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("sources.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
company_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("companies.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
url: Mapped[str] = mapped_column(String(1000))
|
||||
canonical_url: Mapped[str] = mapped_column(String(1000), index=True)
|
||||
title: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
author: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
publication_date: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
retrieved_date: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
content_text: Mapped[str] = mapped_column(Text)
|
||||
content_hash: Mapped[str] = mapped_column(String(64), index=True)
|
||||
metadata_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||
language: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||
http_status: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
extraction_method: Mapped[str] = mapped_column(String(50))
|
||||
trust_score: Mapped[float] = mapped_column(Float, default=0.7)
|
||||
@@ -0,0 +1,24 @@
|
||||
"""A server-wide secret (e.g. Turnstile site key/secret), encrypted at rest
|
||||
(app/core/crypto.py). Unlike UserApiKey, this isn't scoped to a user - it's
|
||||
one value shared by the whole app, admin-editable from the Settings page
|
||||
instead of only via .env. When set,
|
||||
app/services/system_secret_service.py's get_effective_settings substitutes
|
||||
it in place of the server's global .env-configured value - see that module
|
||||
for the full fallback logic."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import Enum, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
from app.models.enums import SystemSecretKey
|
||||
|
||||
|
||||
class SystemSecret(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||
__tablename__ = "system_secrets"
|
||||
|
||||
key: Mapped[SystemSecretKey] = mapped_column(
|
||||
Enum(SystemSecretKey, native_enum=False, length=32), unique=True
|
||||
)
|
||||
encrypted_value: Mapped[str] = mapped_column(Text)
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Manual unban requests from banned visitors - one per IP per 24h, enforced
|
||||
in the service layer at insert time. Purely a queue for admin review; no
|
||||
automated unban happens from this table."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
|
||||
|
||||
class UnbanRequest(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||
__tablename__ = "unban_requests"
|
||||
|
||||
ip_address: Mapped[str] = mapped_column(String(45), index=True)
|
||||
message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
@@ -0,0 +1,47 @@
|
||||
"""User account model.
|
||||
|
||||
`password_hash` is nullable because `AUTH_MODE=local` provisions a single
|
||||
fixed user with no password at all - that mode never routes through
|
||||
password verification, so there's nothing to hash.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.refresh_token import RefreshToken
|
||||
|
||||
# Fixed, deterministic user id used for AUTH_MODE=local so the same row is
|
||||
# reused across restarts rather than multiplying "local dev user" rows.
|
||||
LOCAL_DEV_USER_ID = uuid.UUID("00000000-0000-0000-0000-000000000001")
|
||||
LOCAL_DEV_USER_EMAIL = "[email protected]"
|
||||
|
||||
|
||||
class User(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||
__tablename__ = "users"
|
||||
|
||||
email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
|
||||
password_hash: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
display_name: Mapped[str] = mapped_column(String(120))
|
||||
timezone: Mapped[str] = mapped_column(String(64), default="America/New_York")
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
# Phase 19: email verification + escalating failed-login lockout. The
|
||||
# fixed AUTH_MODE=local user is seeded as already-verified (it never
|
||||
# goes through this flow - see auth_service.get_or_create_local_user).
|
||||
email_verified: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
failed_login_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
locked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
refresh_tokens: Mapped[list[RefreshToken]] = relationship(
|
||||
back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
@@ -0,0 +1,36 @@
|
||||
"""A user's own API key for a given third-party provider, encrypted at
|
||||
rest (app/core/crypto.py). When set, app/services/user_api_key_service.py's
|
||||
get_effective_settings substitutes it in place of the server's global
|
||||
.env-configured key for that user's own requests - see that module for the
|
||||
full fallback logic."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import Enum, ForeignKey, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
from app.models.enums import ApiKeyProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
class UserApiKey(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||
__tablename__ = "user_api_keys"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "provider", name="uq_user_api_keys_user_provider"),
|
||||
)
|
||||
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
provider: Mapped[ApiKeyProvider] = mapped_column(
|
||||
Enum(ApiKeyProvider, native_enum=False, length=16)
|
||||
)
|
||||
encrypted_key: Mapped[str] = mapped_column(Text)
|
||||
|
||||
user: Mapped[User] = relationship()
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Every distinct IP an account has ever signed in from - one row per
|
||||
(user, ip) pair, first_seen_at set once and last_seen_at touched on every
|
||||
subsequent sign-in from that same IP. Pure data capture for now (see
|
||||
app/services/auth_service.py's sign-in paths, both real login and the
|
||||
local-dev bypass) - nothing currently reads this table, but it's the
|
||||
foundation a later "new device/location" security feature would query
|
||||
against without needing to scan/dedupe the much larger, append-only
|
||||
user_security_events log."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
class UserKnownIp(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||
__tablename__ = "user_known_ips"
|
||||
__table_args__ = (UniqueConstraint("user_id", "ip_address", name="uq_user_known_ips_user_ip"),)
|
||||
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
ip_address: Mapped[str] = mapped_column(String(45))
|
||||
first_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
user: Mapped[User] = relationship()
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Per-user security activity log - the user-facing counterpart to the
|
||||
admin-only, app-wide Redis log feed (app/core/logging.py). Visible only to
|
||||
the owning user via GET /auth/security-events."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import Enum, ForeignKey, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
from app.models.enums import SecurityEventType
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
class UserSecurityEvent(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||
__tablename__ = "user_security_events"
|
||||
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
event_type: Mapped[SecurityEventType] = mapped_column(
|
||||
Enum(SecurityEventType, native_enum=False, length=32)
|
||||
)
|
||||
ip_address: Mapped[str] = mapped_column(String(45))
|
||||
|
||||
user: Mapped[User] = relationship()
|
||||
Reference in New Issue
Block a user