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:
2026-08-05 10:48:20 -04:00
commit 1a4c80958f
365 changed files with 43541 additions and 0 deletions
+35
View File
@@ -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
+32
View File
@@ -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)
+76
View File
@@ -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")
+42
View File
@@ -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")
+44
View File
@@ -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))
+37
View File
@@ -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()
+176
View File
@@ -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"
+21
View File
@@ -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))
+29
View File
@@ -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")
+34
View File
@@ -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")
+28
View File
@@ -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()
+32
View File
@@ -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")
+29
View File
@@ -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")
+31
View File
@@ -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
)
+45
View File
@@ -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)
+40
View File
@@ -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)
+24
View File
@@ -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)
+17
View File
@@ -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)
+47
View File
@@ -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"
)
+36
View File
@@ -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()
+36
View File
@@ -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()