refactor: rename package fabledassistant -> scribe (code-only)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 54s
CI & Build / Build & push image (push) Successful in 1m14s

Renames src/fabledassistant -> src/scribe and all imports, plus the
default DB name and DB user/password (fabled -> scribe) in config +
compose. 952 refs / 154 files. Reverses the old 'internal name stays
fabledassistant' convention.

Code-only: live databases are still physically named 'fabledassistant'.
Deployed environments must set POSTGRES_DB / POSTGRES_USER (or rename the
DB) since the defaults now resolve to 'scribe'. Repo (FabledScribe), git
host (fabledsword), MCP (fabled-git) and the image name (fabledscribe)
are intentionally unchanged.

ruff check src/ clean locally; CI (typecheck + pytest) is the gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 15:48:35 -04:00
co-authored by Claude Opus 4.8
parent 1d4c206563
commit b255a0f90e
167 changed files with 1183 additions and 2368 deletions
View File
+163
View File
@@ -0,0 +1,163 @@
"""Access control service.
Single source of truth for permission resolution on projects and notes.
All human-facing routes that should honour shares call these functions.
LLM tool routes continue to use owner-scoped service functions directly.
Permission rank: owner > admin > editor > viewer
"""
import logging
from sqlalchemy import select
from scribe.models import async_session
from scribe.models.group import GroupMembership
from scribe.models.note import Note
from scribe.models.project import Project
from scribe.models.share import NoteShare, ProjectShare
logger = logging.getLogger(__name__)
PERMISSION_RANK: dict[str, int] = {
"viewer": 1,
"editor": 2,
"admin": 3,
"owner": 4,
}
def _higher(a: str | None, b: str | None) -> str | None:
"""Return the higher-ranked permission, or None if both are None."""
if a is None:
return b
if b is None:
return a
return a if PERMISSION_RANK[a] >= PERMISSION_RANK[b] else b
async def _user_group_ids(session, user_id: int) -> list[int]:
rows = (
await session.execute(
select(GroupMembership.group_id).where(GroupMembership.user_id == user_id)
)
).scalars().all()
return list(rows)
# ---------------------------------------------------------------------------
# Project permissions
# ---------------------------------------------------------------------------
async def get_project_permission(user_id: int, project_id: int) -> str | None:
"""Return the effective permission string for user on project, or None."""
async with async_session() as session:
project = await session.get(Project, project_id)
if project is None:
return None
if project.user_id == user_id:
return "owner"
# Direct share
direct = (
await session.execute(
select(ProjectShare).where(
ProjectShare.project_id == project_id,
ProjectShare.shared_with_user_id == user_id,
)
)
).scalar_one_or_none()
# Group shares
group_ids = await _user_group_ids(session, user_id)
group_perm: str | None = None
if group_ids:
group_shares = (
await session.execute(
select(ProjectShare).where(
ProjectShare.project_id == project_id,
ProjectShare.shared_with_group_id.in_(group_ids),
)
)
).scalars().all()
for gs in group_shares:
group_perm = _higher(group_perm, gs.permission)
return _higher(direct.permission if direct else None, group_perm)
async def can_read_project(user_id: int, project_id: int) -> bool:
return (await get_project_permission(user_id, project_id)) is not None
async def can_write_project(user_id: int, project_id: int) -> bool:
perm = await get_project_permission(user_id, project_id)
return perm in ("editor", "admin", "owner")
async def can_admin_project(user_id: int, project_id: int) -> bool:
perm = await get_project_permission(user_id, project_id)
return perm in ("admin", "owner")
# ---------------------------------------------------------------------------
# Note / task permissions
# ---------------------------------------------------------------------------
async def get_note_permission(user_id: int, note_id: int) -> str | None:
"""Return the effective permission for user on a note/task, or None.
Resolution order:
1. Ownership
2. Direct note share
3. Group-based note share
4. Inherited from project share (if note belongs to a project)
Highest rank wins.
"""
async with async_session() as session:
note = await session.get(Note, note_id)
if note is None:
return None
if note.user_id == user_id:
return "owner"
direct = (
await session.execute(
select(NoteShare).where(
NoteShare.note_id == note_id,
NoteShare.shared_with_user_id == user_id,
)
)
).scalar_one_or_none()
group_ids = await _user_group_ids(session, user_id)
group_perm: str | None = None
if group_ids:
group_shares = (
await session.execute(
select(NoteShare).where(
NoteShare.note_id == note_id,
NoteShare.shared_with_group_id.in_(group_ids),
)
)
).scalars().all()
for gs in group_shares:
group_perm = _higher(group_perm, gs.permission)
note_perm = _higher(direct.permission if direct else None, group_perm)
# Inherit from project if note belongs to one
if note.project_id is not None:
project_perm = await get_project_permission(user_id, note.project_id)
note_perm = _higher(note_perm, project_perm)
return note_perm
async def can_read_note(user_id: int, note_id: int) -> bool:
return (await get_note_permission(user_id, note_id)) is not None
async def can_write_note(user_id: int, note_id: int) -> bool:
perm = await get_note_permission(user_id, note_id)
return perm in ("editor", "admin", "owner")
+87
View File
@@ -0,0 +1,87 @@
import hashlib
import secrets
from datetime import datetime, timezone
from sqlalchemy import select
from scribe.models import async_session
from scribe.models.api_key import ApiKey
def generate_key() -> str:
"""Generate a new full API key. Never stored — caller must hash it."""
return "fmcp_" + secrets.token_urlsafe(32)
def _hash_key(key: str) -> str:
return hashlib.sha256(key.encode()).hexdigest()
def _key_prefix(key: str) -> str:
"""Return first 12 chars of the key for display (e.g. 'fmcp_abcdefg')."""
return key[:12]
async def create_api_key(
user_id: int, name: str, scope: str
) -> tuple[str, dict]:
"""Create a new API key. Returns (full_key, key_dict). full_key is never stored."""
if scope not in ("read", "write"):
raise ValueError("scope must be 'read' or 'write'")
full_key = generate_key()
key = ApiKey(
user_id=user_id,
name=name,
key_hash=_hash_key(full_key),
key_prefix=_key_prefix(full_key),
scope=scope,
)
async with async_session() as session:
session.add(key)
await session.commit()
await session.refresh(key)
return full_key, key.to_dict()
async def list_api_keys(user_id: int) -> list[dict]:
"""List all non-revoked API keys for the user."""
async with async_session() as session:
result = await session.execute(
select(ApiKey)
.where(ApiKey.user_id == user_id, ApiKey.revoked_at.is_(None))
.order_by(ApiKey.created_at.desc())
)
return [k.to_dict() for k in result.scalars().all()]
async def revoke_api_key(user_id: int, key_id: int) -> bool:
"""Soft-delete a key by setting revoked_at. Returns True if found."""
async with async_session() as session:
result = await session.execute(
select(ApiKey).where(ApiKey.id == key_id, ApiKey.user_id == user_id)
)
key = result.scalars().first()
if key is None:
return False
key.revoked_at = datetime.now(timezone.utc)
await session.commit()
return True
async def lookup_key(raw_key: str) -> ApiKey | None:
"""Look up a non-revoked ApiKey by raw token value. Updates last_used_at."""
key_hash = _hash_key(raw_key)
async with async_session() as session:
result = await session.execute(
select(ApiKey).where(
ApiKey.key_hash == key_hash,
ApiKey.revoked_at.is_(None),
)
)
key = result.scalars().first()
if key is None:
return None
key.last_used_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(key)
return key
+417
View File
@@ -0,0 +1,417 @@
import hashlib
import logging
import secrets
from datetime import datetime, timedelta, timezone
import bcrypt
from sqlalchemy import func, select, update
from scribe.models import async_session
from scribe.models.note import Note
from scribe.models.invitation import InvitationToken
from scribe.models.password_reset import PasswordResetToken
from scribe.models.setting import Setting
from scribe.models.user import User
logger = logging.getLogger(__name__)
def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
def verify_password(password: str, password_hash: str) -> bool:
return bcrypt.checkpw(password.encode(), password_hash.encode())
async def get_user_count() -> int:
async with async_session() as session:
return await session.scalar(select(func.count(User.id))) or 0
async def create_user(
username: str,
password: str | None,
email: str | None = None,
oauth_sub: str | None = None,
) -> User:
user_count = await get_user_count()
role = "admin" if user_count == 0 else "user"
async with async_session() as session:
user = User(
username=username,
email=email,
password_hash=hash_password(password) if password is not None else None,
oauth_sub=oauth_sub,
role=role,
)
session.add(user)
await session.commit()
await session.refresh(user)
# First user claims all pre-existing data (migration assigns user_id=1,
# which matches SERIAL first insert; also handles any NULLs)
if role == "admin":
await session.execute(
update(Note)
.where((Note.user_id.is_(None)) | (Note.user_id == user.id))
.values(user_id=user.id)
)
await session.execute(
update(Setting)
.where(Setting.user_id.is_(None))
.values(user_id=user.id)
)
# Auto-close registration after first user setup
session.add(Setting(user_id=user.id, key="registration_open", value="false"))
await session.commit()
logger.info("First user '%s' created as admin, claimed orphaned data", username)
return user
async def authenticate(username: str, password: str) -> User | None:
async with async_session() as session:
result = await session.execute(
select(User).where(User.username == username)
)
user = result.scalars().first()
if user is None:
return None
if user.password_hash is None:
# OAuth-only user — cannot use password login
return None
if verify_password(password, user.password_hash):
return user
return None
async def get_user_by_id(user_id: int) -> User | None:
async with async_session() as session:
return await session.get(User, user_id)
async def get_user_by_username(username: str) -> User | None:
async with async_session() as session:
result = await session.execute(
select(User).where(User.username == username)
)
return result.scalars().first()
async def change_password(user_id: int, current_password: str, new_password: str) -> int | None:
"""Change a user's password. Returns new session_version on success, None if current password is wrong."""
async with async_session() as session:
user = await session.get(User, user_id)
if not user:
return None
# OAuth-only accounts have no local password hash — verify_password
# would crash on None. Treat as "no local credential to change" (the
# route turns None into a clean 4xx, not a 500).
if user.password_hash is None:
return None
if not verify_password(current_password, user.password_hash):
return None
user.password_hash = hash_password(new_password)
user.session_version += 1
await session.commit()
logger.info("Password changed for user %d (%s)", user_id, user.username)
return user.session_version
async def invalidate_other_sessions(user_id: int) -> int:
"""Bump session_version so all sessions except the current one are invalidated.
Returns the new session_version so the caller can update the active session cookie.
"""
async with async_session() as session:
user = await session.get(User, user_id)
user.session_version += 1
await session.commit()
logger.info("Sessions invalidated for user %d (%s)", user_id, user.username)
return user.session_version
async def is_registration_open() -> bool:
"""Check if new user registration is allowed.
Always open when no users exist (first-user setup).
Otherwise reads the admin's 'registration_open' setting (default closed).
"""
user_count = await get_user_count()
if user_count == 0:
return True
async with async_session() as session:
# Find the admin user's registration_open setting
result = await session.execute(
select(Setting)
.join(User, Setting.user_id == User.id)
.where(User.role == "admin", Setting.key == "registration_open")
)
setting = result.scalar_one_or_none()
return setting.value == "true" if setting else False
async def list_users() -> list[User]:
async with async_session() as session:
result = await session.execute(select(User).order_by(User.created_at))
return list(result.scalars().all())
async def delete_user(user_id: int) -> bool:
async with async_session() as session:
user = await session.get(User, user_id)
if not user:
return False
await session.delete(user)
await session.commit()
logger.info("Deleted user %d (%s)", user_id, user.username)
return True
async def set_registration_open(admin_user_id: int, open: bool) -> None:
from scribe.services.settings import set_setting
await set_setting(admin_user_id, "registration_open", "true" if open else "false")
async def update_user_email(user_id: int, email: str | None) -> None:
async with async_session() as session:
user = await session.get(User, user_id)
if user:
user.email = email
await session.commit()
async def get_user_by_oauth_sub(sub: str) -> User | None:
async with async_session() as session:
result = await session.execute(
select(User).where(User.oauth_sub == sub)
)
return result.scalars().first()
async def link_oauth_sub(user_id: int, sub: str) -> None:
async with async_session() as session:
await session.execute(
update(User).where(User.id == user_id).values(oauth_sub=sub)
)
await session.commit()
async def get_user_by_email(email: str) -> User | None:
async with async_session() as session:
result = await session.execute(
select(User).where(func.lower(User.email) == email.lower())
)
return result.scalars().first()
async def create_password_reset_token(user_id: int) -> str:
"""Generate a password reset token. Returns the raw token (for the email link)."""
raw_token = secrets.token_urlsafe(32)
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
expires_at = datetime.now(timezone.utc) + timedelta(hours=1)
async with async_session() as session:
# Invalidate any existing unused tokens for this user
result = await session.execute(
select(PasswordResetToken).where(
PasswordResetToken.user_id == user_id,
PasswordResetToken.used.is_(False),
)
)
for old_token in result.scalars().all():
old_token.used = True
token = PasswordResetToken(
user_id=user_id,
token_hash=token_hash,
expires_at=expires_at,
)
session.add(token)
await session.commit()
logger.info("Password reset token created for user %d", user_id)
return raw_token
async def reset_password_with_token(raw_token: str, new_password: str) -> int | None:
"""Validate a reset token and update the user's password. Returns user_id on success."""
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
async with async_session() as session:
result = await session.execute(
select(PasswordResetToken).where(PasswordResetToken.token_hash == token_hash)
)
reset_token = result.scalars().first()
if not reset_token:
return None
if reset_token.used:
return None
if reset_token.expires_at < datetime.now(timezone.utc):
return None
user = await session.get(User, reset_token.user_id)
if not user:
return None
user.password_hash = hash_password(new_password)
user.session_version += 1
reset_token.used = True
await session.commit()
logger.info("Password reset via token for user %d (%s)", user.id, user.username)
return user.id
async def create_invitation(email: str, invited_by: int) -> str:
"""Generate an invitation token. Returns the raw token (for the email link)."""
raw_token = secrets.token_urlsafe(32)
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
expires_at = datetime.now(timezone.utc) + timedelta(days=7)
async with async_session() as session:
# Invalidate previous unused invitations for same email
result = await session.execute(
select(InvitationToken).where(
func.lower(InvitationToken.email) == email.lower(),
InvitationToken.used.is_(False),
)
)
for old_token in result.scalars().all():
old_token.used = True
token = InvitationToken(
email=email.lower(),
token_hash=token_hash,
invited_by=invited_by,
expires_at=expires_at,
)
session.add(token)
await session.commit()
logger.info("Invitation created for %s by user %d", email, invited_by)
return raw_token
async def validate_invitation_token(raw_token: str) -> InvitationToken | None:
"""Look up by hash, check not used/expired. Returns the token record with email."""
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
async with async_session() as session:
result = await session.execute(
select(InvitationToken).where(InvitationToken.token_hash == token_hash)
)
invitation = result.scalars().first()
if not invitation:
return None
if invitation.used:
return None
if invitation.expires_at < datetime.now(timezone.utc):
return None
return invitation
async def register_with_invitation(raw_token: str, username: str, password: str) -> User | None:
"""Validate token, create user with the invitation's email, mark token used."""
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
async with async_session() as session:
result = await session.execute(
select(InvitationToken).where(InvitationToken.token_hash == token_hash)
)
invitation = result.scalars().first()
if not invitation or invitation.used or invitation.expires_at < datetime.now(timezone.utc):
return None
invitation_id = invitation.id
invite_email = invitation.email
# Create the user FIRST, then consume the token. create_user can fail (e.g.
# a username collision raises and the route returns 409); marking the token
# used before that would burn this single-use invite and lock the invitee
# out of retrying. Ordering it after means a failed creation leaves the
# token valid.
user = await create_user(username, password, invite_email)
async with async_session() as session:
invitation = await session.get(InvitationToken, invitation_id)
if invitation is not None:
invitation.used = True
await session.commit()
logger.info("User '%s' registered via invitation for %s", username, invite_email)
return user
async def list_pending_invitations() -> list[InvitationToken]:
"""List unused, non-expired invitations."""
async with async_session() as session:
result = await session.execute(
select(InvitationToken).where(
InvitationToken.used.is_(False),
InvitationToken.expires_at > datetime.now(timezone.utc),
).order_by(InvitationToken.created_at.desc())
)
return list(result.scalars().all())
async def revoke_invitation(invitation_id: int) -> bool:
"""Mark an invitation as used (revoked)."""
async with async_session() as session:
invitation = await session.get(InvitationToken, invitation_id)
if not invitation or invitation.used:
return False
invitation.used = True
await session.commit()
logger.info("Invitation %d revoked", invitation_id)
return True
# ── Token retention ─────────────────────────────────────────────────────
# Password-reset and invitation tokens are only ever flipped used=True and are
# never pruned, so on a long-lived instance both tables grow without bound.
# A daily sweep deletes any token whose validity window ended over grace_days
# ago (covers both used and naturally-expired rows once they're cold).
_auth_retention_task = None
async def purge_expired_auth_tokens(grace_days: int = 7) -> int:
"""Delete password-reset / invitation tokens that expired > grace_days ago."""
from sqlalchemy import delete
cutoff = datetime.now(timezone.utc) - timedelta(days=grace_days)
removed = 0
async with async_session() as session:
for model in (PasswordResetToken, InvitationToken):
result = await session.execute(
delete(model).where(model.expires_at < cutoff)
)
removed += result.rowcount or 0
await session.commit()
return removed
async def _auth_token_retention_loop() -> None:
import asyncio
while True:
await asyncio.sleep(86400) # daily
try:
removed = await purge_expired_auth_tokens()
if removed:
logger.info("Auth token retention: deleted %d expired token(s)", removed)
except Exception:
logger.exception("Error in auth token retention cleanup")
def start_auth_token_retention_loop() -> None:
global _auth_retention_task
import asyncio
if _auth_retention_task is None or _auth_retention_task.done():
_auth_retention_task = asyncio.create_task(_auth_token_retention_loop())
+545
View File
@@ -0,0 +1,545 @@
import logging
from datetime import date, datetime, timezone
from sqlalchemy import select
from scribe.models import async_session
from scribe.models.milestone import Milestone
from scribe.models.note import Note
from scribe.models.note_draft import NoteDraft
from scribe.models.note_version import NoteVersion
from scribe.models.project import Project
from scribe.models.setting import Setting
from scribe.models.task_log import TaskLog
from scribe.models.user import User
logger = logging.getLogger(__name__)
def _dt(val: str | None) -> datetime:
return datetime.fromisoformat(val) if val else datetime.now(timezone.utc)
def _d(val: str | None) -> date | None:
return date.fromisoformat(val) if val else None
# ---------------------------------------------------------------------------
# Export
# ---------------------------------------------------------------------------
async def export_full_backup() -> dict:
"""Export all data as version-2 JSON backup."""
async with async_session() as session:
users = (await session.execute(select(User))).scalars().all()
projects = (await session.execute(select(Project))).scalars().all()
milestones = (await session.execute(select(Milestone))).scalars().all()
notes = (await session.execute(select(Note))).scalars().all()
task_logs = (await session.execute(select(TaskLog))).scalars().all()
note_drafts = (await session.execute(select(NoteDraft))).scalars().all()
note_versions = (await session.execute(
select(NoteVersion).order_by(NoteVersion.note_id, NoteVersion.id)
)).scalars().all()
settings = (await session.execute(select(Setting))).scalars().all()
return {
"version": 2,
"scope": "full",
"exported_at": datetime.now(timezone.utc).isoformat(),
"_security_notice": (
"This backup contains hashed passwords. "
"Store it securely and restrict access."
),
"users": [
{
"id": u.id,
"username": u.username,
"email": u.email,
"password_hash": u.password_hash,
"oauth_sub": u.oauth_sub,
"role": u.role,
"session_version": u.session_version,
"created_at": u.created_at.isoformat(),
}
for u in users
],
"projects": [
{
"id": p.id,
"user_id": p.user_id,
"title": p.title,
"description": p.description,
"goal": p.goal,
"status": p.status,
"color": p.color,
"created_at": p.created_at.isoformat(),
"updated_at": p.updated_at.isoformat(),
}
for p in projects
],
"milestones": [
{
"id": m.id,
"user_id": m.user_id,
"project_id": m.project_id,
"title": m.title,
"description": m.description,
"status": m.status,
"order_index": m.order_index,
"created_at": m.created_at.isoformat(),
"updated_at": m.updated_at.isoformat(),
}
for m in milestones
],
"notes": [
{
"id": n.id,
"user_id": n.user_id,
"title": n.title,
"body": n.body,
"tags": n.tags or [],
"parent_id": n.parent_id,
"project_id": n.project_id,
"milestone_id": n.milestone_id,
"status": n.status,
"priority": n.priority,
"due_date": n.due_date.isoformat() if n.due_date else None,
"created_at": n.created_at.isoformat(),
"updated_at": n.updated_at.isoformat(),
}
for n in notes
],
"task_logs": [
{
"id": tl.id,
"user_id": tl.user_id,
"task_id": tl.task_id,
"content": tl.content,
"duration_minutes": tl.duration_minutes,
"created_at": tl.created_at.isoformat(),
"updated_at": tl.updated_at.isoformat(),
}
for tl in task_logs
],
"note_drafts": [
{
"id": nd.id,
"user_id": nd.user_id,
"note_id": nd.note_id,
"proposed_body": nd.proposed_body,
"original_body": nd.original_body,
"instruction": nd.instruction,
"scope": nd.scope,
"created_at": nd.created_at.isoformat(),
"updated_at": nd.updated_at.isoformat(),
}
for nd in note_drafts
],
"note_versions": [
{
"id": nv.id,
"user_id": nv.user_id,
"note_id": nv.note_id,
"title": nv.title,
"body": nv.body,
"tags": nv.tags or [],
"pin_kind": nv.pin_kind,
"pin_label": nv.pin_label,
"created_at": nv.created_at.isoformat(),
}
for nv in note_versions
],
"settings": [
{"user_id": s.user_id, "key": s.key, "value": s.value}
for s in settings
],
}
async def export_user_backup(user_id: int) -> dict:
"""Export a single user's data as version-2 JSON backup."""
async with async_session() as session:
user = await session.get(User, user_id)
projects = (await session.execute(
select(Project).where(Project.user_id == user_id)
)).scalars().all()
milestones = (await session.execute(
select(Milestone).where(Milestone.user_id == user_id)
)).scalars().all()
notes = (await session.execute(
select(Note).where(Note.user_id == user_id)
)).scalars().all()
task_logs = (await session.execute(
select(TaskLog).where(TaskLog.user_id == user_id)
)).scalars().all()
note_drafts = (await session.execute(
select(NoteDraft).where(NoteDraft.user_id == user_id)
)).scalars().all()
note_versions = (await session.execute(
select(NoteVersion).where(NoteVersion.user_id == user_id)
.order_by(NoteVersion.note_id, NoteVersion.id)
)).scalars().all()
settings = (await session.execute(
select(Setting).where(Setting.user_id == user_id)
)).scalars().all()
return {
"version": 2,
"scope": "user",
"exported_at": datetime.now(timezone.utc).isoformat(),
"user": {
"id": user.id,
"username": user.username,
"email": user.email,
"role": user.role,
"created_at": user.created_at.isoformat(),
} if user else None,
"projects": [
{
"id": p.id,
"user_id": p.user_id,
"title": p.title,
"description": p.description,
"goal": p.goal,
"status": p.status,
"color": p.color,
"created_at": p.created_at.isoformat(),
"updated_at": p.updated_at.isoformat(),
}
for p in projects
],
"milestones": [
{
"id": m.id,
"user_id": m.user_id,
"project_id": m.project_id,
"title": m.title,
"description": m.description,
"status": m.status,
"order_index": m.order_index,
"created_at": m.created_at.isoformat(),
"updated_at": m.updated_at.isoformat(),
}
for m in milestones
],
"notes": [
{
"id": n.id,
"title": n.title,
"body": n.body,
"tags": n.tags or [],
"parent_id": n.parent_id,
"project_id": n.project_id,
"milestone_id": n.milestone_id,
"status": n.status,
"priority": n.priority,
"due_date": n.due_date.isoformat() if n.due_date else None,
"created_at": n.created_at.isoformat(),
"updated_at": n.updated_at.isoformat(),
}
for n in notes
],
"task_logs": [
{
"id": tl.id,
"task_id": tl.task_id,
"content": tl.content,
"duration_minutes": tl.duration_minutes,
"created_at": tl.created_at.isoformat(),
"updated_at": tl.updated_at.isoformat(),
}
for tl in task_logs
],
"note_drafts": [
{
"id": nd.id,
"note_id": nd.note_id,
"proposed_body": nd.proposed_body,
"original_body": nd.original_body,
"instruction": nd.instruction,
"scope": nd.scope,
"created_at": nd.created_at.isoformat(),
"updated_at": nd.updated_at.isoformat(),
}
for nd in note_drafts
],
"note_versions": [
{
"id": nv.id,
"note_id": nv.note_id,
"title": nv.title,
"body": nv.body,
"tags": nv.tags or [],
"pin_kind": nv.pin_kind,
"pin_label": nv.pin_label,
"created_at": nv.created_at.isoformat(),
}
for nv in note_versions
],
"settings": [
{"key": s.key, "value": s.value}
for s in settings
],
}
# ---------------------------------------------------------------------------
# Restore
# ---------------------------------------------------------------------------
async def restore_full_backup(data: dict) -> dict:
"""Restore from backup JSON. Dispatches by version."""
version = data.get("version", 1)
if version == 1:
return await _restore_v1(data)
return await _restore_v2(data)
async def _restore_v1(data: dict) -> dict:
"""Restore legacy v1 backup (original format).
Pre-pivot v1 backups included conversations + messages; those are
skipped during restore now that the chat subsystem is gone.
"""
stats = {"users": 0, "notes": 0, "settings": 0}
async with async_session() as session:
user_id_map: dict[int, int] = {}
for u_data in data.get("users", []):
old_id = u_data["id"]
user = User(
username=u_data["username"],
email=u_data.get("email"),
password_hash=u_data["password_hash"],
role=u_data.get("role", "user"),
created_at=_dt(u_data.get("created_at")),
)
session.add(user)
await session.flush()
user_id_map[old_id] = user.id
stats["users"] += 1
note_id_map: dict[int, int] = {}
for n_data in data.get("notes", []):
old_id = n_data.get("id")
mapped_user_id = user_id_map.get(n_data.get("user_id", 0))
if mapped_user_id is None:
continue
note = Note(
user_id=mapped_user_id,
title=n_data.get("title", ""),
body=n_data.get("body", ""),
tags=n_data.get("tags", []),
parent_id=None, # patched below
status=n_data.get("status"),
priority=n_data.get("priority"),
due_date=_d(n_data.get("due_date")),
created_at=_dt(n_data.get("created_at")),
updated_at=_dt(n_data.get("updated_at")),
)
session.add(note)
await session.flush()
if old_id is not None:
note_id_map[old_id] = note.id
stats["notes"] += 1
# Patch parent_id now that all notes have new IDs
for n_data in data.get("notes", []):
old_id = n_data.get("id")
old_parent = n_data.get("parent_id")
if old_id and old_parent and old_id in note_id_map and old_parent in note_id_map:
note_row = await session.get(Note, note_id_map[old_id])
if note_row:
note_row.parent_id = note_id_map[old_parent]
for s_data in data.get("settings", []):
mapped_user_id = user_id_map.get(s_data.get("user_id", 0))
if mapped_user_id is None:
continue
session.add(Setting(user_id=mapped_user_id, key=s_data["key"], value=s_data.get("value", "")))
stats["settings"] += 1
await session.commit()
logger.info("Restored v1 backup: %s", stats)
return stats
async def _restore_v2(data: dict) -> dict:
"""Restore v2 backup with full FK re-mapping.
Conversations + push subscriptions in pre-pivot backups are silently
skipped — those subsystems were removed in the MCP-first pivot.
"""
stats: dict[str, int] = {
"users": 0, "projects": 0, "milestones": 0, "notes": 0,
"task_logs": 0, "note_drafts": 0, "note_versions": 0,
"settings": 0,
}
async with async_session() as session:
user_id_map: dict[int, int] = {}
project_id_map: dict[int, int] = {}
milestone_id_map: dict[int, int] = {}
note_id_map: dict[int, int] = {}
# 1. Users
for u_data in data.get("users", []):
old_id = u_data["id"]
user = User(
username=u_data["username"],
email=u_data.get("email"),
password_hash=u_data.get("password_hash"),
oauth_sub=u_data.get("oauth_sub"),
role=u_data.get("role", "user"),
session_version=u_data.get("session_version", 1),
created_at=_dt(u_data.get("created_at")),
)
session.add(user)
await session.flush()
user_id_map[old_id] = user.id
stats["users"] += 1
# 2. Projects
for p_data in data.get("projects", []):
mapped_uid = user_id_map.get(p_data.get("user_id", 0))
if mapped_uid is None:
continue
proj = Project(
user_id=mapped_uid,
title=p_data.get("title", ""),
description=p_data.get("description", ""),
goal=p_data.get("goal", ""),
status=p_data.get("status", "active"),
color=p_data.get("color"),
created_at=_dt(p_data.get("created_at")),
updated_at=_dt(p_data.get("updated_at")),
)
session.add(proj)
await session.flush()
project_id_map[p_data["id"]] = proj.id
stats["projects"] += 1
# 3. Milestones
for m_data in data.get("milestones", []):
mapped_uid = user_id_map.get(m_data.get("user_id", 0))
mapped_pid = project_id_map.get(m_data.get("project_id", 0))
if mapped_uid is None or mapped_pid is None:
continue
ms = Milestone(
user_id=mapped_uid,
project_id=mapped_pid,
title=m_data.get("title", ""),
description=m_data.get("description"),
status=m_data.get("status", "active"),
order_index=m_data.get("order_index", 0),
created_at=_dt(m_data.get("created_at")),
updated_at=_dt(m_data.get("updated_at")),
)
session.add(ms)
await session.flush()
milestone_id_map[m_data["id"]] = ms.id
stats["milestones"] += 1
# 4a. Notes — first pass (no parent_id yet)
notes_with_parents: list[tuple[int, int]] = [] # (new_note_id, old_parent_id)
for n_data in data.get("notes", []):
mapped_uid = user_id_map.get(n_data.get("user_id", 0))
if mapped_uid is None:
continue
note = Note(
user_id=mapped_uid,
title=n_data.get("title", ""),
body=n_data.get("body", ""),
tags=n_data.get("tags", []),
parent_id=None,
project_id=project_id_map.get(n_data["project_id"]) if n_data.get("project_id") else None,
milestone_id=milestone_id_map.get(n_data["milestone_id"]) if n_data.get("milestone_id") else None,
status=n_data.get("status"),
priority=n_data.get("priority"),
due_date=_d(n_data.get("due_date")),
created_at=_dt(n_data.get("created_at")),
updated_at=_dt(n_data.get("updated_at")),
)
session.add(note)
await session.flush()
note_id_map[n_data["id"]] = note.id
if n_data.get("parent_id"):
notes_with_parents.append((note.id, n_data["parent_id"]))
stats["notes"] += 1
# 4b. Patch parent_id
for new_note_id, old_parent_id in notes_with_parents:
new_parent_id = note_id_map.get(old_parent_id)
if new_parent_id:
note_row = await session.get(Note, new_note_id)
if note_row:
note_row.parent_id = new_parent_id
# 5. TaskLogs
for tl_data in data.get("task_logs", []):
mapped_uid = user_id_map.get(tl_data.get("user_id", 0))
mapped_tid = note_id_map.get(tl_data.get("task_id", 0))
if mapped_uid is None or mapped_tid is None:
continue
tl = TaskLog(
user_id=mapped_uid,
task_id=mapped_tid,
content=tl_data.get("content", ""),
duration_minutes=tl_data.get("duration_minutes"),
created_at=_dt(tl_data.get("created_at")),
updated_at=_dt(tl_data.get("updated_at")),
)
session.add(tl)
stats["task_logs"] += 1
# 6. NoteDrafts
for nd_data in data.get("note_drafts", []):
mapped_uid = user_id_map.get(nd_data.get("user_id", 0))
mapped_nid = note_id_map.get(nd_data.get("note_id", 0))
if mapped_uid is None or mapped_nid is None:
continue
nd = NoteDraft(
user_id=mapped_uid,
note_id=mapped_nid,
proposed_body=nd_data.get("proposed_body", ""),
original_body=nd_data.get("original_body", ""),
instruction=nd_data.get("instruction", ""),
scope=nd_data.get("scope", "document"),
created_at=_dt(nd_data.get("created_at")),
updated_at=_dt(nd_data.get("updated_at")),
)
session.add(nd)
stats["note_drafts"] += 1
# 7. NoteVersions
for nv_data in data.get("note_versions", []):
mapped_uid = user_id_map.get(nv_data.get("user_id", 0))
mapped_nid = note_id_map.get(nv_data.get("note_id", 0))
if mapped_uid is None or mapped_nid is None:
continue
nv = NoteVersion(
user_id=mapped_uid,
note_id=mapped_nid,
title=nv_data.get("title", ""),
body=nv_data.get("body", ""),
tags=nv_data.get("tags", []),
pin_kind=nv_data.get("pin_kind"),
pin_label=nv_data.get("pin_label"),
created_at=_dt(nv_data.get("created_at")),
)
session.add(nv)
stats["note_versions"] += 1
# 8. Settings
for s_data in data.get("settings", []):
mapped_uid = user_id_map.get(s_data.get("user_id", 0))
if mapped_uid is None:
continue
session.add(Setting(user_id=mapped_uid, key=s_data["key"], value=s_data.get("value", "")))
stats["settings"] += 1
await session.commit()
logger.info("Restored v2 backup: %s", stats)
return stats
+770
View File
@@ -0,0 +1,770 @@
"""CalDAV calendar integration service."""
import asyncio
import logging
from datetime import date as date_type, datetime, timedelta
from zoneinfo import ZoneInfo
import caldav
import icalendar
from scribe.services.settings import get_all_settings
logger = logging.getLogger(__name__)
CALDAV_SETTING_KEYS = ["caldav_url", "caldav_username", "caldav_password", "caldav_calendar_name", "caldav_timezone"]
# Sentinel: distinguishes "leave the RRULE untouched" from "clear it" (None/"")
# in update_event, since None is a meaningful value for recurrence.
_RECURRENCE_UNSET = object()
async def get_caldav_config(user_id: int) -> dict[str, str]:
"""Return the user's CalDAV config from their settings."""
all_settings = await get_all_settings(user_id)
return {k: all_settings.get(k, "") for k in CALDAV_SETTING_KEYS}
async def is_caldav_configured(user_id: int) -> bool:
"""Check if the user has configured an external CalDAV server."""
config = await get_caldav_config(user_id)
return bool(config.get("caldav_url") and config.get("caldav_username") and config.get("caldav_password"))
def _get_calendar(client: caldav.DAVClient, calendar_name: str) -> caldav.Calendar:
"""Get a named calendar or the first available one (synchronous)."""
principal = client.principal()
calendars = principal.calendars()
if not calendars:
raise ValueError("No calendars found on the CalDAV server.")
if calendar_name:
for cal in calendars:
if cal.name == calendar_name:
return cal
names = [c.name for c in calendars]
raise ValueError(f"Calendar '{calendar_name}' not found. Available: {', '.join(names)}")
return calendars[0]
def _get_all_calendars(client: caldav.DAVClient) -> list[caldav.Calendar]:
"""Get all calendars for the user (synchronous)."""
principal = client.principal()
calendars = principal.calendars()
if not calendars:
raise ValueError("No calendars found on the CalDAV server.")
return calendars
def _make_client(config: dict[str, str]) -> caldav.DAVClient:
"""Create a CalDAV client from config dict."""
return caldav.DAVClient(
url=config["caldav_url"],
username=config.get("caldav_username") or None,
password=config.get("caldav_password") or None,
)
def _parse_vevent(component) -> dict | None:
"""Extract event data from a VEVENT component."""
if component.name != "VEVENT":
return None
title = str(component.get("SUMMARY", ""))
dtstart = component.get("DTSTART")
dtend = component.get("DTEND")
location = str(component.get("LOCATION", ""))
description = str(component.get("DESCRIPTION", ""))
uid = str(component.get("UID", ""))
start_str = dtstart.dt.isoformat() if dtstart else ""
end_str = dtend.dt.isoformat() if dtend else ""
result = {
"uid": uid,
"title": title,
"start": start_str,
"end": end_str,
"location": location,
"description": description,
}
# Extract recurrence rule
rrule = component.get("RRULE")
if rrule:
result["recurrence"] = rrule.to_ical().decode("utf-8")
# Extract alarms
alarms = []
for sub in component.subcomponents:
if sub.name == "VALARM":
trigger = sub.get("TRIGGER")
if trigger and trigger.dt:
minutes = abs(int(trigger.dt.total_seconds() // 60))
alarms.append({"minutes_before": minutes})
if alarms:
result["alarms"] = alarms
# Extract attendees
attendees = component.get("ATTENDEE")
if attendees:
if not isinstance(attendees, list):
attendees = [attendees]
result["attendees"] = [str(a).replace("mailto:", "") for a in attendees]
return result
def _parse_vtodo(component) -> dict | None:
"""Extract todo data from a VTODO component."""
if component.name != "VTODO":
return None
uid = str(component.get("UID", ""))
summary = str(component.get("SUMMARY", ""))
description = str(component.get("DESCRIPTION", ""))
status = str(component.get("STATUS", ""))
due = component.get("DUE")
due_str = due.dt.isoformat() if due else ""
priority = component.get("PRIORITY")
priority_val = int(priority) if priority else None
return {
"uid": uid,
"summary": summary,
"description": description,
"due": due_str,
"status": status,
"priority": priority_val,
}
def _apply_timezone(dt: datetime, timezone: str | None) -> datetime:
"""Apply a timezone to a naive datetime. Returns dt unchanged if already aware."""
if dt.tzinfo is not None:
return dt
if timezone:
return dt.replace(tzinfo=ZoneInfo(timezone))
return dt
def _build_valarm(minutes_before: int) -> icalendar.Alarm:
"""Create a DISPLAY alarm component triggered N minutes before the event."""
alarm = icalendar.Alarm()
alarm.add("action", "DISPLAY")
alarm.add("description", "Reminder")
alarm.add("trigger", timedelta(minutes=-minutes_before))
return alarm
def _add_attendees(event: icalendar.Event, attendees: list[str]) -> None:
"""Add mailto: attendees to an iCalendar event."""
for email in attendees:
attendee = icalendar.vCalAddress(f"mailto:{email}")
event.add("attendee", attendee)
def _check_config(config: dict[str, str]) -> None:
"""Raise if CalDAV is not configured."""
if not config.get("caldav_url"):
raise ValueError("CalDAV is not configured. Go to Settings → Calendar to enter your server URL.")
async def create_event(
user_id: int,
title: str,
start: str,
end: str | None = None,
duration: int | None = None,
description: str | None = None,
location: str | None = None,
all_day: bool = False,
recurrence: str | None = None,
timezone: str | None = None,
reminder_minutes: int | None = None,
attendees: list[str] | None = None,
calendar_name: str | None = None,
uid: str | None = None,
) -> dict:
"""Create a calendar event.
start/end are ISO date (YYYY-MM-DD) or datetime strings.
If all_day is True, DTSTART/DTEND use DATE values.
recurrence is an iCalendar RRULE string (e.g. "FREQ=YEARLY").
"""
config = await get_caldav_config(user_id)
_check_config(config)
tz = timezone or config.get("caldav_timezone") or None
cal = icalendar.Calendar()
cal.add("prodid", "-//Scribe//EN")
cal.add("version", "2.0")
event = icalendar.Event()
if uid:
# Remove auto-generated UID if the library added one, then inject ours
if "UID" in event:
del event["UID"]
event.add("uid", uid)
event.add("summary", title)
if all_day:
# All-day events use DATE values (no time component)
d_start = datetime.fromisoformat(start).date() if "T" in start else date_type.fromisoformat(start)
if end:
d_end = datetime.fromisoformat(end).date() if "T" in end else date_type.fromisoformat(end)
else:
d_end = d_start + timedelta(days=1)
event.add("dtstart", d_start)
event.add("dtend", d_end)
result_start = d_start.isoformat()
result_end = d_end.isoformat()
else:
dt_start = _apply_timezone(datetime.fromisoformat(start), tz)
event.add("dtstart", dt_start)
result_start = dt_start.isoformat()
if end:
dt_end = _apply_timezone(datetime.fromisoformat(end), tz)
elif duration:
dt_end = dt_start + timedelta(minutes=duration)
else:
dt_end = None
if dt_end is not None:
event.add("dtend", dt_end)
result_end = dt_end.isoformat()
else:
# Point event (no end, no duration): emit DTSTART only. Fabricating
# a 60-min DTEND here would round-trip back on the next pull as
# duration_minutes=60, silently lengthening a point event.
result_end = None
if description:
event.add("description", description)
if location:
event.add("location", location)
if recurrence:
# Parse RRULE string like "FREQ=YEARLY" into a vRecur dict
rrule_parts = {}
for part in recurrence.split(";"):
if "=" in part:
key, value = part.split("=", 1)
rrule_parts[key.strip().lower()] = value.strip()
event.add("rrule", rrule_parts)
if reminder_minutes is not None:
event.add_component(_build_valarm(reminder_minutes))
if attendees:
_add_attendees(event, attendees)
cal.add_component(event)
ical_str = cal.to_ical().decode("utf-8")
def _save():
client = _make_client(config)
cal_name = calendar_name or config.get("caldav_calendar_name", "")
calendar = _get_calendar(client, cal_name)
calendar.save_event(ical_str)
await asyncio.to_thread(_save)
result = {
"title": title,
"start": result_start,
"end": result_end,
"all_day": all_day,
}
if recurrence:
result["recurrence"] = recurrence
return result
async def list_events(user_id: int, date_from: str, date_to: str) -> list[dict]:
"""List calendar events in a date range. Dates are ISO datetime strings.
Searches all calendars unless caldav_calendar_name is configured.
"""
config = await get_caldav_config(user_id)
_check_config(config)
dt_from = datetime.fromisoformat(date_from)
dt_to = datetime.fromisoformat(date_to)
def _search():
client = _make_client(config)
cal_name = config.get("caldav_calendar_name", "")
if cal_name:
calendars = [_get_calendar(client, cal_name)]
else:
calendars = _get_all_calendars(client)
all_results = []
for calendar in calendars:
try:
all_results.extend(calendar.date_search(dt_from, dt_to))
except Exception:
logger.warning("Failed to search calendar '%s'", getattr(calendar, 'name', '?'))
return all_results
results = await asyncio.to_thread(_search)
events = []
for result in results:
cal = icalendar.Calendar.from_ical(result.data)
for component in cal.walk():
parsed = _parse_vevent(component)
if parsed:
events.append(parsed)
return events
async def search_events(user_id: int, query: str, days_ahead: int = 90) -> list[dict]:
"""Search events by keyword in the next N days."""
now = datetime.now()
date_from = now.isoformat()
date_to = (now + timedelta(days=days_ahead)).isoformat()
all_events = await list_events(user_id, date_from, date_to)
q = query.lower()
return [
e for e in all_events
if q in e["title"].lower() or q in e.get("location", "").lower() or q in e.get("description", "").lower()
]
async def update_event(
user_id: int,
query: str,
title: str | None = None,
start: str | None = None,
end: str | None = None,
description: str | None = None,
location: str | None = None,
timezone: str | None = None,
calendar_name: str | None = None,
recurrence: str | None | object = _RECURRENCE_UNSET,
) -> dict:
"""Update a calendar event matching the query.
``recurrence``: leave at the sentinel to keep the existing RRULE; pass an
RRULE string to set it, or None/"" to remove it. The push path passes the
local event's recurrence so RRULE edits propagate to the server.
"""
config = await get_caldav_config(user_id)
_check_config(config)
tz = timezone or config.get("caldav_timezone") or None
def _do_update():
client = _make_client(config)
cal_name = calendar_name or config.get("caldav_calendar_name", "")
now = datetime.now()
if cal_name:
calendars = [_get_calendar(client, cal_name)]
else:
calendars = _get_all_calendars(client)
results = []
for cal in calendars:
try:
results.extend(cal.date_search(now - timedelta(days=30), now + timedelta(days=365)))
except Exception:
logger.warning("Failed to search calendar '%s'", getattr(cal, 'name', '?'))
q = query.lower()
matches = []
for r in results:
cal_obj = icalendar.Calendar.from_ical(r.data)
for component in cal_obj.walk():
if component.name == "VEVENT":
event_title = str(component.get("SUMMARY", ""))
if q in event_title.lower():
matches.append((r, component))
if not matches:
raise ValueError(f"No event found matching '{query}'.")
if len(matches) > 3:
titles = [str(m[1].get("SUMMARY", "")) for m in matches]
raise ValueError(f"Too many matches ({len(matches)}) for '{query}'. Be more specific. Found: {', '.join(titles[:10])}")
event_obj, component = matches[0]
if title:
component["SUMMARY"] = title
if start:
dt_start = _apply_timezone(datetime.fromisoformat(start), tz)
del component["DTSTART"]
component.add("dtstart", dt_start)
if end:
dt_end = _apply_timezone(datetime.fromisoformat(end), tz)
if "DTEND" in component:
del component["DTEND"]
component.add("dtend", dt_end)
if description is not None:
if "DESCRIPTION" in component:
del component["DESCRIPTION"]
component.add("description", description)
if location is not None:
if "LOCATION" in component:
del component["LOCATION"]
component.add("location", location)
if recurrence is not _RECURRENCE_UNSET:
# Authoritatively sync the RRULE to the local event: drop the old
# rule, then re-add if a non-empty rule was provided (else clear it).
if "RRULE" in component:
del component["RRULE"]
if recurrence:
rrule_parts = {}
for part in str(recurrence).split(";"):
if "=" in part:
key, value = part.split("=", 1)
rrule_parts[key.strip().lower()] = value.strip()
component.add("rrule", rrule_parts)
# Rebuild ical data and save
cal_data = icalendar.Calendar()
cal_data.add("prodid", "-//Scribe//EN")
cal_data.add("version", "2.0")
cal_data.add_component(component)
event_obj.data = cal_data.to_ical().decode("utf-8")
event_obj.save()
return _parse_vevent(component)
return await asyncio.to_thread(_do_update)
async def delete_event(
user_id: int,
query: str,
calendar_name: str | None = None,
) -> dict:
"""Delete a calendar event matching the query."""
config = await get_caldav_config(user_id)
_check_config(config)
def _do_delete():
client = _make_client(config)
cal_name = calendar_name or config.get("caldav_calendar_name", "")
now = datetime.now()
if cal_name:
calendars = [_get_calendar(client, cal_name)]
else:
calendars = _get_all_calendars(client)
results = []
for cal in calendars:
try:
results.extend(cal.date_search(now - timedelta(days=30), now + timedelta(days=365)))
except Exception:
logger.warning("Failed to search calendar '%s'", getattr(cal, 'name', '?'))
q = query.lower()
matches = []
for r in results:
cal_obj = icalendar.Calendar.from_ical(r.data)
for component in cal_obj.walk():
if component.name == "VEVENT":
event_title = str(component.get("SUMMARY", ""))
if q in event_title.lower():
matches.append((r, component))
if not matches:
raise ValueError(f"No event found matching '{query}'.")
if len(matches) > 3:
titles = [str(m[1].get("SUMMARY", "")) for m in matches]
raise ValueError(f"Too many matches ({len(matches)}) for '{query}'. Be more specific. Found: {', '.join(titles[:10])}")
event_obj, component = matches[0]
parsed = _parse_vevent(component)
event_obj.delete()
return parsed
return await asyncio.to_thread(_do_delete)
async def list_calendars(user_id: int) -> list[dict]:
"""List all calendars for the user."""
config = await get_caldav_config(user_id)
_check_config(config)
def _list():
client = _make_client(config)
principal = client.principal()
calendars = principal.calendars()
return [{"name": c.name, "url": str(c.url)} for c in calendars]
return await asyncio.to_thread(_list)
async def create_todo(
user_id: int,
summary: str,
due: str | None = None,
description: str | None = None,
priority: int | None = None,
reminder_minutes: int | None = None,
timezone: str | None = None,
calendar_name: str | None = None,
) -> dict:
"""Create a CalDAV todo (VTODO)."""
config = await get_caldav_config(user_id)
_check_config(config)
tz = timezone or config.get("caldav_timezone") or None
def _create():
client = _make_client(config)
cal_name = calendar_name or config.get("caldav_calendar_name", "")
calendar = _get_calendar(client, cal_name)
kwargs = {"summary": summary}
if due:
dt_due = datetime.fromisoformat(due)
dt_due = _apply_timezone(dt_due, tz)
kwargs["due"] = dt_due
todo = calendar.save_todo(**kwargs)
# Modify component for extra fields
cal_obj = icalendar.Calendar.from_ical(todo.data)
modified = False
for component in cal_obj.walk():
if component.name == "VTODO":
if description:
component.add("description", description)
modified = True
if priority is not None:
component.add("priority", priority)
modified = True
if reminder_minutes is not None:
component.add_component(_build_valarm(reminder_minutes))
modified = True
if modified:
todo.data = cal_obj.to_ical().decode("utf-8")
todo.save()
return _parse_vtodo(component)
return {"summary": summary}
return await asyncio.to_thread(_create)
async def list_todos(
user_id: int,
include_completed: bool = False,
calendar_name: str | None = None,
) -> list[dict]:
"""List CalDAV todos."""
config = await get_caldav_config(user_id)
_check_config(config)
def _list():
client = _make_client(config)
cal_name = calendar_name or config.get("caldav_calendar_name", "")
calendar = _get_calendar(client, cal_name)
todos = calendar.todos(include_completed=include_completed)
results = []
for t in todos:
cal_obj = icalendar.Calendar.from_ical(t.data)
for component in cal_obj.walk():
parsed = _parse_vtodo(component)
if parsed:
results.append(parsed)
return results
return await asyncio.to_thread(_list)
async def search_todos(
user_id: int,
query: str,
include_completed: bool = False,
calendar_name: str | None = None,
) -> list[dict]:
"""Search CalDAV todos by keyword in summary or description."""
todos = await list_todos(user_id, include_completed=include_completed, calendar_name=calendar_name)
q = query.lower()
return [
t for t in todos
if q in t.get("summary", "").lower() or q in (t.get("description") or "").lower()
]
async def complete_todo(
user_id: int,
query: str,
calendar_name: str | None = None,
) -> dict:
"""Complete a CalDAV todo matching the query."""
config = await get_caldav_config(user_id)
_check_config(config)
def _complete():
client = _make_client(config)
cal_name = calendar_name or config.get("caldav_calendar_name", "")
calendar = _get_calendar(client, cal_name)
todos = calendar.todos(include_completed=False)
q = query.lower()
matches = []
for t in todos:
cal_obj = icalendar.Calendar.from_ical(t.data)
for component in cal_obj.walk():
if component.name == "VTODO":
s = str(component.get("SUMMARY", ""))
if q in s.lower():
matches.append((t, component))
if not matches:
raise ValueError(f"No todo found matching '{query}'.")
if len(matches) > 3:
titles = [str(m[1].get("SUMMARY", "")) for m in matches]
raise ValueError(f"Too many matches ({len(matches)}) for '{query}'. Be more specific. Found: {', '.join(titles[:10])}")
todo_obj, component = matches[0]
todo_obj.complete()
# Re-parse after completing
cal_obj = icalendar.Calendar.from_ical(todo_obj.data)
for comp in cal_obj.walk():
parsed = _parse_vtodo(comp)
if parsed:
return parsed
return {"summary": str(component.get("SUMMARY", "")), "status": "COMPLETED"}
return await asyncio.to_thread(_complete)
async def update_todo(
user_id: int,
query: str,
summary: str | None = None,
due: str | None = None,
description: str | None = None,
priority: int | None = None,
timezone: str | None = None,
calendar_name: str | None = None,
) -> dict:
"""Update a CalDAV todo matching the query."""
config = await get_caldav_config(user_id)
_check_config(config)
tz = timezone or config.get("caldav_timezone") or None
def _do_update():
client = _make_client(config)
cal_name = calendar_name or config.get("caldav_calendar_name", "")
calendar = _get_calendar(client, cal_name)
todos = calendar.todos(include_completed=True)
q = query.lower()
matches = []
for t in todos:
cal_obj = icalendar.Calendar.from_ical(t.data)
for component in cal_obj.walk():
if component.name == "VTODO":
s = str(component.get("SUMMARY", ""))
if q in s.lower():
matches.append((t, component))
if not matches:
raise ValueError(f"No todo found matching '{query}'.")
if len(matches) > 3:
titles = [str(m[1].get("SUMMARY", "")) for m in matches]
raise ValueError(
f"Too many matches ({len(matches)}) for '{query}'. "
f"Be more specific. Found: {', '.join(titles[:10])}"
)
todo_obj, component = matches[0]
if summary:
component["SUMMARY"] = summary
if description is not None:
if "DESCRIPTION" in component:
del component["DESCRIPTION"]
component.add("description", description)
if priority is not None:
if "PRIORITY" in component:
del component["PRIORITY"]
component.add("priority", priority)
if due:
if "DUE" in component:
del component["DUE"]
try:
dt = datetime.fromisoformat(due)
dt = _apply_timezone(dt, tz)
component.add("due", dt)
except ValueError:
component.add("due", date_type.fromisoformat(due))
# Rebuild ical data and save
cal_data = icalendar.Calendar()
cal_data.add("prodid", "-//Scribe//EN")
cal_data.add("version", "2.0")
cal_data.add_component(component)
todo_obj.data = cal_data.to_ical().decode("utf-8")
todo_obj.save()
return _parse_vtodo(component)
return await asyncio.to_thread(_do_update)
async def delete_todo(
user_id: int,
query: str,
calendar_name: str | None = None,
) -> dict:
"""Delete a CalDAV todo matching the query."""
config = await get_caldav_config(user_id)
_check_config(config)
def _delete():
client = _make_client(config)
cal_name = calendar_name or config.get("caldav_calendar_name", "")
calendar = _get_calendar(client, cal_name)
todos = calendar.todos(include_completed=True)
q = query.lower()
matches = []
for t in todos:
cal_obj = icalendar.Calendar.from_ical(t.data)
for component in cal_obj.walk():
if component.name == "VTODO":
s = str(component.get("SUMMARY", ""))
if q in s.lower():
matches.append((t, component))
if not matches:
raise ValueError(f"No todo found matching '{query}'.")
if len(matches) > 3:
titles = [str(m[1].get("SUMMARY", "")) for m in matches]
raise ValueError(f"Too many matches ({len(matches)}) for '{query}'. Be more specific. Found: {', '.join(titles[:10])}")
todo_obj, component = matches[0]
parsed = _parse_vtodo(component)
todo_obj.delete()
return parsed
return await asyncio.to_thread(_delete)
async def test_connection(user_id: int) -> dict:
"""Test the CalDAV connection and return status."""
config = await get_caldav_config(user_id)
if not config.get("caldav_url"):
return {"success": False, "error": "CalDAV is not configured."}
def _test():
client = _make_client(config)
principal = client.principal()
calendars = principal.calendars()
return [c.name for c in calendars]
try:
calendar_names = await asyncio.to_thread(_test)
return {
"success": True,
"calendars": calendar_names,
"message": f"Connected successfully. Found {len(calendar_names)} calendar(s).",
}
except Exception as e:
error_msg = str(e)
if "401" in error_msg or "403" in error_msg or "Unauthorized" in error_msg:
error_msg = "Authentication failed. Check your username and password."
elif "404" in error_msg or "Not Found" in error_msg:
error_msg = "CalDAV endpoint not found. Check your URL."
elif "Connection" in error_msg or "resolve" in error_msg:
error_msg = f"Connection failed: {error_msg}"
return {"success": False, "error": error_msg}
+247
View File
@@ -0,0 +1,247 @@
"""CalDAV pull sync — imports remote events into the internal event store.
Runs as a scheduled job (hourly) and is also callable via the API.
Only syncs events in a rolling 30-day-past / 180-day-future window.
"""
from __future__ import annotations
import asyncio
import logging
import uuid
from datetime import datetime, timedelta, timezone
from typing import Any
from sqlalchemy import select, update
from scribe.models import async_session
from scribe.models.event import Event
logger = logging.getLogger(__name__)
_SYNC_PAST_DAYS = 30
_SYNC_FUTURE_DAYS = 180
# Wall-clock cap on the blocking CalDAV fetch so a hung/slow server can't
# wedge the hourly sweep indefinitely.
_SYNC_TIMEOUT_SECONDS = 120
def _parse_dt(val: Any) -> datetime | None:
"""Convert a date or datetime from an iCal component to a UTC-aware datetime."""
if val is None:
return None
import datetime as _dt_mod
if isinstance(val, _dt_mod.datetime):
if val.tzinfo is None:
return val.replace(tzinfo=timezone.utc)
return val.astimezone(timezone.utc)
if isinstance(val, _dt_mod.date):
# All-day date: treat as midnight UTC
return datetime(val.year, val.month, val.day, tzinfo=timezone.utc)
return None
def _sync_one_user(config: dict[str, str], user_id: int) -> list[dict]:
"""Synchronous CalDAV fetch — runs in a thread executor."""
import caldav # noqa: PLC0415
now = datetime.now(timezone.utc)
range_start = now - timedelta(days=_SYNC_PAST_DAYS)
range_end = now + timedelta(days=_SYNC_FUTURE_DAYS)
client = caldav.DAVClient(
url=config["caldav_url"],
username=config.get("caldav_username") or None,
password=config.get("caldav_password") or None,
)
principal = client.principal()
calendars = principal.calendars()
if not calendars:
return []
cal_name = config.get("caldav_calendar_name", "")
if cal_name:
calendars = [c for c in calendars if c.name == cal_name] or calendars
events: list[dict] = []
for calendar in calendars:
try:
results = calendar.date_search(start=range_start, end=range_end, expand=False)
except Exception:
logger.warning("CalDAV date_search failed for calendar %s", getattr(calendar, "name", "?"), exc_info=True)
continue
for vevent_obj in results:
try:
ical = vevent_obj.icalendar_instance
for component in ical.walk():
if component.name != "VEVENT":
continue
dtstart = component.get("DTSTART")
dtend = component.get("DTEND")
uid = str(component.get("UID", ""))
if not uid:
continue
start_dt = _parse_dt(dtstart.dt if dtstart else None)
end_dt = _parse_dt(dtend.dt if dtend else None)
if start_dt is None:
continue
import datetime as _dt_mod
all_day = dtstart and isinstance(dtstart.dt, _dt_mod.date) and not isinstance(dtstart.dt, _dt_mod.datetime)
rrule = component.get("RRULE")
recurrence = rrule.to_ical().decode("utf-8") if rrule else None
events.append({
"caldav_uid": uid,
"title": str(component.get("SUMMARY", "")),
"start_dt": start_dt,
"end_dt": end_dt,
"all_day": bool(all_day),
"description": str(component.get("DESCRIPTION", "")),
"location": str(component.get("LOCATION", "")),
"recurrence": recurrence,
})
except Exception:
logger.debug("Failed to parse CalDAV event", exc_info=True)
return events
async def sync_user_events(user_id: int) -> dict:
"""Pull CalDAV events for one user and upsert into the DB.
Returns a summary dict: {created, updated, unchanged}.
"""
from scribe.services.caldav import get_caldav_config, is_caldav_configured # noqa: PLC0415
if not await is_caldav_configured(user_id):
return {"skipped": True, "reason": "CalDAV not configured"}
config = await get_caldav_config(user_id)
started = datetime.now(timezone.utc)
range_start = started - timedelta(days=_SYNC_PAST_DAYS)
range_end = started + timedelta(days=_SYNC_FUTURE_DAYS)
loop = asyncio.get_running_loop()
try:
remote_events: list[dict] = await asyncio.wait_for(
loop.run_in_executor(None, _sync_one_user, config, user_id),
timeout=_SYNC_TIMEOUT_SECONDS,
)
except asyncio.TimeoutError:
logger.warning("CalDAV pull sync timed out for user %d after %ds", user_id, _SYNC_TIMEOUT_SECONDS)
return {"error": "CalDAV fetch timed out"}
except Exception:
logger.warning("CalDAV pull sync failed for user %d", user_id, exc_info=True)
return {"error": "CalDAV fetch failed"}
created = updated = unchanged = skipped = deleted = 0
async with async_session() as session:
for ev in remote_events:
caldav_uid = ev["caldav_uid"]
# Storage uses duration, not end_dt. Convert here so the
# rest of this function can compare/upsert in one shape.
ev_start = ev["start_dt"]
ev_end = ev["end_dt"]
ev_duration = (
int((ev_end - ev_start).total_seconds() // 60)
if ev_end is not None and ev_start is not None and ev_end > ev_start
else None
)
ev["duration_minutes"] = ev_duration
result = await session.execute(
select(Event).where(
Event.user_id == user_id,
Event.caldav_uid == caldav_uid,
)
)
existing = result.scalar_one_or_none()
if existing is not None and existing.deleted_at is not None:
# The user trashed this event locally. Don't resurrect it by
# updating, and don't create a duplicate live copy — leave it
# in the trash. (Propagating the delete to the remote server is
# tracked separately.)
skipped += 1
continue
if existing is None:
# Create new event
new_ev = Event(
user_id=user_id,
uid=str(uuid.uuid4()),
caldav_uid=caldav_uid,
title=ev["title"],
start_dt=ev_start,
duration_minutes=ev_duration,
all_day=ev["all_day"],
description=ev["description"],
location=ev["location"],
recurrence=ev["recurrence"],
)
session.add(new_ev)
created += 1
else:
# Update if anything changed
changed = False
for field in ("title", "start_dt", "duration_minutes", "all_day", "description", "location", "recurrence"):
if getattr(existing, field) != ev[field]:
setattr(existing, field, ev[field])
changed = True
if changed:
updated += 1
else:
unchanged += 1
# Reconcile deletions: a previously-synced event (has a caldav_uid)
# that no longer appears remotely within the synced window is
# soft-deleted, so a delete on the remote propagates locally instead
# of orphaning forever. Guarded on a non-empty fetch so a spurious
# empty result can't wipe every local copy.
if remote_events:
remote_uids = {e["caldav_uid"] for e in remote_events}
orphan_batch = str(uuid.uuid4())
orphan_res = await session.execute(
update(Event)
.where(
Event.user_id == user_id,
Event.caldav_uid.isnot(None),
Event.caldav_uid.notin_(remote_uids),
Event.deleted_at.is_(None),
Event.start_dt >= range_start,
Event.start_dt <= range_end,
)
.values(deleted_at=datetime.now(timezone.utc), deleted_batch_id=orphan_batch)
)
deleted = orphan_res.rowcount or 0
await session.commit()
elapsed = (datetime.now(timezone.utc) - started).total_seconds()
logger.info(
"CalDAV sync user %d: %d created, %d updated, %d unchanged, %d skipped (trashed), "
"%d deleted (orphaned) in %.1fs",
user_id, created, updated, unchanged, skipped, deleted, elapsed,
)
return {"created": created, "updated": updated, "unchanged": unchanged,
"skipped": skipped, "deleted": deleted}
async def sync_all_users() -> None:
"""Pull CalDAV events for all users with CalDAV configured."""
from sqlalchemy import select as sa_select # noqa: PLC0415
from scribe.models.user import User # noqa: PLC0415
async with async_session() as session:
result = await session.execute(sa_select(User.id))
user_ids = [row[0] for row in result.all()]
for user_id in user_ids:
try:
await sync_user_events(user_id)
except Exception:
logger.warning("CalDAV sync failed for user %d", user_id, exc_info=True)
+171
View File
@@ -0,0 +1,171 @@
"""Dashboard aggregation — assembles the /dashboard landing payload.
One call: most-recently-active projects (each -> active milestones -> open
tasks), recently-completed tasks, upcoming events, week stats. Owner-scoped,
trashed rows excluded. Each section is independent — a failure returns its
empty value rather than blanking the page.
"""
from __future__ import annotations
import logging
from datetime import datetime, timedelta, timezone
from sqlalchemy import case, func, select
from scribe.models import async_session
from scribe.models.note import Note
from scribe.models.project import Project
from scribe.models.milestone import Milestone
from scribe.services import milestones as milestones_svc
logger = logging.getLogger(__name__)
N_PROJECTS = 3 # most-recently-active projects shown
TASKS_PER_GROUP = 5 # open-task cap per milestone / no-milestone group
RECENT_DONE_LIMIT = 8 # recently-completed tasks shown
WINDOW_DAYS = 7 # look-back (done) / look-ahead (events) window
_OPEN = ["todo", "in_progress"]
def _open_order():
"""in-progress first -> priority high..none -> most-recently-updated."""
status_rank = case((Note.status == "in_progress", 0), else_=1)
priority_rank = case(
(Note.priority == "high", 0), (Note.priority == "medium", 1),
(Note.priority == "low", 2), else_=3,
)
return status_rank, priority_rank, Note.updated_at.desc()
def _task_row(n: Note) -> dict:
return {"id": n.id, "title": n.title, "status": n.status,
"priority": n.priority or "none"}
async def _safe(coro, empty):
try:
return await coro
except Exception:
logger.warning("dashboard section failed", exc_info=True)
return empty
async def build_dashboard(user_id: int) -> dict:
return {
"active_projects": await _safe(_active_projects(user_id), []),
"recently_completed": await _safe(_recently_completed(user_id), []),
"upcoming_events": await _safe(_upcoming_events(user_id), []),
"week_stats": await _safe(_week_stats(user_id), {}),
}
async def _active_projects(user_id: int) -> list[dict]:
so, po, ro = _open_order()
async with async_session() as session:
recency = (
select(Note.project_id, func.max(Note.updated_at).label("last"))
.where(Note.user_id == user_id, Note.deleted_at.is_(None),
Note.project_id.isnot(None))
.group_by(Note.project_id).subquery()
)
prows = (await session.execute(
select(Project, recency.c.last)
.outerjoin(recency, Project.id == recency.c.project_id)
.where(Project.user_id == user_id, Project.status == "active",
Project.deleted_at.is_(None))
.order_by(func.coalesce(recency.c.last, Project.updated_at).desc())
.limit(N_PROJECTS)
)).all()
out = []
for project, last in prows:
counts = (await session.execute(
select(
func.count(Note.id).filter(Note.status.in_(_OPEN)),
func.count(Note.id).filter(Note.status == "done"),
func.count(Note.id).filter(Note.status.in_(_OPEN + ["done"])),
).where(Note.user_id == user_id, Note.project_id == project.id,
Note.deleted_at.is_(None))
)).one()
open_count, done_count, resolved_total = counts
progress_pct = round(done_count / resolved_total * 100, 1) if resolved_total else 0.0
mrows = (await session.execute(
select(Milestone).where(
Milestone.user_id == user_id, Milestone.project_id == project.id,
Milestone.status == "active", Milestone.deleted_at.is_(None))
.order_by(Milestone.order_index.asc())
)).scalars().all()
milestones = []
for m in mrows:
tasks = (await session.execute(
select(Note).where(
Note.user_id == user_id, Note.milestone_id == m.id,
Note.status.in_(_OPEN), Note.deleted_at.is_(None))
.order_by(so, po, ro).limit(TASKS_PER_GROUP)
)).scalars().all()
if not tasks:
continue
prog = await milestones_svc.get_milestone_progress(m.id)
milestones.append({
"id": m.id, "title": m.title, "progress_pct": prog["pct"],
"open_tasks": [_task_row(t) for t in tasks],
})
no_ms = (await session.execute(
select(Note).where(
Note.user_id == user_id, Note.project_id == project.id,
Note.milestone_id.is_(None), Note.status.in_(_OPEN),
Note.deleted_at.is_(None))
.order_by(so, po, ro).limit(TASKS_PER_GROUP)
)).scalars().all()
out.append({
"id": project.id, "title": project.title, "color": project.color,
"last_activity": (last or project.updated_at).isoformat(),
"open_count": open_count, "progress_pct": progress_pct,
"milestones": milestones,
"no_milestone": [_task_row(t) for t in no_ms],
})
return out
async def _recently_completed(user_id: int) -> list[dict]:
cutoff = datetime.now(timezone.utc) - timedelta(days=WINDOW_DAYS)
async with async_session() as session:
rows = (await session.execute(
select(Note, Project.title)
.outerjoin(Project, Note.project_id == Project.id)
.where(Note.user_id == user_id, Note.status == "done",
Note.deleted_at.is_(None), Note.completed_at.isnot(None),
Note.completed_at >= cutoff)
.order_by(Note.completed_at.desc()).limit(RECENT_DONE_LIMIT)
)).all()
return [{"id": n.id, "title": n.title, "project_title": ptitle,
"completed_at": n.completed_at.isoformat()} for n, ptitle in rows]
async def _upcoming_events(user_id: int) -> list[dict]:
from scribe.services import events as events_svc
now = datetime.now(timezone.utc)
rows = await events_svc.list_events(user_id, now, now + timedelta(days=WINDOW_DAYS))
out = []
for e in rows:
d = e if isinstance(e, dict) else e.to_dict()
out.append({"id": d["id"], "title": d["title"],
"start_dt": d.get("start_dt"), "all_day": d.get("all_day", False)})
return out
async def _week_stats(user_id: int) -> dict:
cutoff = datetime.now(timezone.utc) - timedelta(days=WINDOW_DAYS)
async with async_session() as session:
row = (await session.execute(
select(
func.count(Note.id).filter(Note.status == "done", Note.completed_at >= cutoff),
func.count(Note.id).filter(Note.status.in_(_OPEN)),
func.count(Note.id).filter(Note.status == "in_progress"),
func.count(Note.id).filter(Note.task_kind == "plan", Note.status.in_(_OPEN)),
).where(Note.user_id == user_id, Note.deleted_at.is_(None))
)).one()
return {"completed_this_week": row[0], "open_total": row[1],
"in_progress": row[2], "active_plans": row[3]}
+443
View File
@@ -0,0 +1,443 @@
"""Lightweight diagnostic instrumentation for crash investigation.
The Scribe app + its Postgres have been crashing recurrently with no
clear cause in the logs. This module adds three things designed to make
the crash class identifiable from logs alone:
1. **Heartbeat** — once per minute, log a snapshot of process resources
(RSS memory, asyncio task count, DB pool checked-in/out, curator
busy state). A sudden silence in heartbeats lets you bound the
crash time to within a minute, and the last snapshot before silence
usually rules in or out memory growth / pool exhaustion / hung
curator pass.
2. **Signal handler** — catches SIGTERM and SIGINT, logs them with the
sender's intent ("docker stop", "swarm restart", "manual ctrl-C")
then lets the normal shutdown proceed. Distinguishes orderly
shutdown from kill-9 / OOM-kill (which can't be caught and will
show as a silent log gap followed by container exit code 137).
3. **Asyncio exception hook** — every Task that raises an uncaught
exception logs a full traceback. Without this, `asyncio.create_task`
exceptions are swallowed silently — the chat crash that locked us
into 409 forever was exactly this pattern.
All three are read-only / log-only — no behavior changes. Safe to
leave running in production indefinitely; the cost is one log line
per minute and ~0.1ms of work per heartbeat.
"""
from __future__ import annotations
import asyncio
import datetime
import json
import logging
import os
import signal
import time
from logging.handlers import RotatingFileHandler
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
# Heartbeat cadence. 60s is the sweet spot: short enough that a crash
# window is bounded to a useful interval, long enough that the log
# noise is negligible. If we ever need more resolution during active
# debugging, drop it temporarily to 15s.
_HEARTBEAT_INTERVAL_SECS = 60
# Persistent diagnostic state — written to /data so it survives
# container restart, OOM-kill, and Docker log rotation. The point of
# the persistence: the operator may not notice a crash for hours, and
# by then the Docker logs are gone. /data/diagnostics gives them a
# durable place to look post-mortem.
_DIAG_DIR = Path("/data/diagnostics")
_CURRENT_STATE_PATH = _DIAG_DIR / "current.json"
_LAST_SHUTDOWN_PATH = _DIAG_DIR / "last_shutdown.json"
_LAST_EXCEPTION_PATH = _DIAG_DIR / "last_exception.json"
_PREVIOUS_RUN_PATH = _DIAG_DIR / "previous_run.json"
_DIAG_LOG_PATH = _DIAG_DIR / "diag.log"
# Dedicated file logger for the heartbeat/exception/signal stream.
# Separate from the stdout logger so it can't be lost by Docker log
# rotation. Rotates at 10 MB, keeps 5 backups → 50 MB max footprint.
_file_logger: logging.Logger | None = None
_heartbeat_task: asyncio.Task | None = None
_shutdown_logged = False # don't double-log shutdown if multiple signals arrive
_started_at: float | None = None
def _process_rss_mb() -> float | None:
"""Resident-set memory in MB. Read from /proc/self/status — no deps."""
try:
with open("/proc/self/status") as fh:
for line in fh:
if line.startswith("VmRSS:"):
# Format: 'VmRSS: 123456 kB'
kb = int(line.split()[1])
return round(kb / 1024, 1)
except Exception:
pass
return None
def _db_pool_stats() -> dict[str, Any]:
"""Pool checked-in / checked-out / overflow. Direct from SQLAlchemy."""
try:
from scribe.models import engine
pool = engine.pool
# Async engines wrap a sync pool; .checkedin() / .checkedout() exist
# on the underlying QueuePool. Attribute access is documented but
# version-fragile, so wrap in try.
return {
"size": getattr(pool, "size", lambda: None)(),
"checked_in": getattr(pool, "checkedin", lambda: None)(),
"checked_out": getattr(pool, "checkedout", lambda: None)(),
"overflow": getattr(pool, "overflow", lambda: None)(),
}
except Exception:
return {}
def _asyncio_task_count() -> int | None:
try:
return len([t for t in asyncio.all_tasks() if not t.done()])
except Exception:
return None
def _curator_busy() -> bool | None:
# Curator was removed in Phase 8 of the MCP-first pivot. Always False.
return False
def _uptime_secs() -> float | None:
if _started_at is None:
return None
return round(time.monotonic() - _started_at, 1)
def _snapshot_dict(reason: str = "heartbeat") -> dict[str, Any]:
"""Bundle the current resource snapshot into a JSON-safe dict.
This is the canonical 'what was the app doing right now' record —
written to current.json every heartbeat, to last_shutdown.json on
signal, to last_exception.json on uncaught task exception. The
`reason` field tells you which event captured this snapshot.
"""
return {
"reason": reason,
"wall_time_utc": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"uptime_secs": _uptime_secs(),
"rss_mb": _process_rss_mb(),
"asyncio_tasks": _asyncio_task_count(),
"db_pool": _db_pool_stats(),
"curator_busy": _curator_busy(),
"pid": os.getpid(),
}
def _write_state_atomic(path: Path, data: dict[str, Any]) -> None:
"""Write JSON to `path` via a tmp+rename so a crash mid-write can't
leave a half-written / unparseable file. The tmp file is on the same
filesystem as the target so the rename is atomic.
Silent on failure — diagnostic writes must never raise into the
caller. If /data isn't writable, the in-memory + stdout logging
still happens.
"""
try:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(data, indent=2, default=str))
tmp.replace(path)
except Exception:
# Log to stdout — if this fires, /data is bad and the operator
# needs to know. Don't propagate; diagnostics must not crash
# the heartbeat that depends on them.
logger.exception("Failed to write diagnostic state to %s", path)
def _setup_file_logger() -> logging.Logger:
"""Create or return the dedicated rotating file logger for diagnostics.
Separate from the app's stdout logger so an aggressive log rotator
or Docker log cleanup can't take it out. Writes to /data/diagnostics/diag.log
with size-based rotation.
"""
global _file_logger
if _file_logger is not None:
return _file_logger
try:
_DIAG_DIR.mkdir(parents=True, exist_ok=True)
flog = logging.getLogger("scribe.diagnostics.persistent")
flog.setLevel(logging.INFO)
flog.propagate = False # don't double-log into the root stdout stream
# Don't re-add handlers across module reloads / re-runs.
if not flog.handlers:
handler = RotatingFileHandler(
_DIAG_LOG_PATH,
maxBytes=10 * 1024 * 1024,
backupCount=5,
)
handler.setFormatter(
logging.Formatter(
"%(asctime)s %(levelname)s %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
)
flog.addHandler(handler)
_file_logger = flog
except Exception:
logger.exception("Failed to set up persistent diagnostic logger")
# Fall back to the regular stdout logger so callers don't need
# to null-check the return value.
_file_logger = logger
return _file_logger
def _persistent_log(level: int, msg: str, *args: Any) -> None:
"""Emit one line to BOTH the stdout logger AND the persistent file."""
logger.log(level, msg, *args)
try:
_setup_file_logger().log(level, msg, *args)
except Exception:
pass
def _post_mortem_previous_run() -> None:
"""On startup, check the persistent state and tell the operator if
the previous run died abruptly.
Logic: if current.json exists and is newer than last_shutdown.json,
then either there was never a clean shutdown (first ever run, or
SIGKILL/OOM/abrupt termination of the previous run). If current.json
exists AND last_shutdown.json doesn't, OR current.json's timestamp
is newer than last_shutdown.json's → the previous run didn't get
to write its shutdown record. Log the last-known state prominently
so the operator notices on next visit.
"""
try:
if not _CURRENT_STATE_PATH.exists():
return # first ever run
current_mtime = _CURRENT_STATE_PATH.stat().st_mtime
shutdown_mtime = (
_LAST_SHUTDOWN_PATH.stat().st_mtime
if _LAST_SHUTDOWN_PATH.exists() else 0
)
if shutdown_mtime >= current_mtime:
return # previous run shut down cleanly after its last heartbeat
# The previous run had a heartbeat without a subsequent clean
# shutdown — that's a crash, OOM, or otherwise-killed run.
try:
last_snapshot = json.loads(_CURRENT_STATE_PATH.read_text())
except Exception:
last_snapshot = {"_parse_error": "current.json unreadable"}
seconds_since = round(time.time() - current_mtime, 1)
_persistent_log(
logging.WARNING,
"diag post-mortem: PREVIOUS RUN DIED ABRUPTLY. Last heartbeat "
"was %ss before this startup. Last-known state: %s",
seconds_since, json.dumps(last_snapshot),
)
# Preserve the offending snapshot for retrospection. This file
# accumulates one rename per abrupt termination so the user can
# diff sequences over time if it's a recurring pattern.
try:
_PREVIOUS_RUN_PATH.write_text(json.dumps({
"noticed_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"seconds_since_last_heartbeat": seconds_since,
"had_shutdown_record": _LAST_SHUTDOWN_PATH.exists(),
"had_exception_record": _LAST_EXCEPTION_PATH.exists(),
"last_known_state": last_snapshot,
}, indent=2, default=str))
except Exception:
logger.exception("Failed to preserve previous_run.json")
except Exception:
logger.exception("Post-mortem check itself failed")
async def _heartbeat_loop() -> None:
"""Forever-running task that emits one snapshot per interval.
Each heartbeat does three things:
1. Logs the snapshot to stdout (Docker logs — short-lived).
2. Appends the snapshot to /data/diagnostics/diag.log (rotating;
survives container restart).
3. Atomically rewrites /data/diagnostics/current.json with the
latest snapshot — so post-crash you can `cat` one file and see
the last known good state instead of scanning the rolling log.
Exceptions inside the loop are caught and logged so the loop itself
can't die silently — the whole point is that this thing keeps
talking even when other things crash around it.
"""
while True:
try:
snap = _snapshot_dict(reason="heartbeat")
_persistent_log(
logging.INFO,
"diag heartbeat: uptime=%ss rss=%sMB asyncio_tasks=%s "
"db_pool=%s curator_busy=%s",
snap["uptime_secs"], snap["rss_mb"], snap["asyncio_tasks"],
snap["db_pool"], snap["curator_busy"],
)
_write_state_atomic(_CURRENT_STATE_PATH, snap)
except Exception:
logger.exception("Heartbeat snapshot crashed (continuing)")
try:
await asyncio.sleep(_HEARTBEAT_INTERVAL_SECS)
except asyncio.CancelledError:
_persistent_log(
logging.INFO, "diag heartbeat: shutting down (CancelledError)"
)
raise
def _asyncio_exception_handler(loop: asyncio.AbstractEventLoop, context: dict) -> None:
"""Log unhandled task exceptions instead of letting them disappear.
Default asyncio behaviour: if a fire-and-forget task raises and
nothing awaits the result, the exception is logged at ERROR but the
surrounding context (which task, what coroutine) can be sparse.
This handler enriches the log line so we can tell WHICH task crashed.
"""
msg = context.get("message", "")
exc = context.get("exception")
task = context.get("task") or context.get("future")
task_name = getattr(task, "get_name", lambda: "?")() if task else "?"
coro = getattr(task, "get_coro", lambda: None)() if task else None
coro_name = getattr(coro, "__qualname__", str(coro)) if coro else "?"
if exc is not None:
logger.error(
"asyncio unhandled exception in task %r (coro=%s): %s",
task_name, coro_name, msg,
exc_info=(type(exc), exc, exc.__traceback__),
)
# Also emit to the persistent file logger so the traceback
# survives even if Docker logs are flushed.
try:
_setup_file_logger().error(
"asyncio unhandled exception in task %r (coro=%s): %s",
task_name, coro_name, msg,
exc_info=(type(exc), exc, exc.__traceback__),
)
except Exception:
pass
# Persist the snapshot at exception time so post-mortem can see
# what the app's state was when it crashed.
snap = _snapshot_dict(reason="asyncio_exception")
snap["task_name"] = task_name
snap["coro_name"] = coro_name
snap["exception_type"] = type(exc).__name__
snap["exception_message"] = str(exc)
_write_state_atomic(_LAST_EXCEPTION_PATH, snap)
else:
logger.error(
"asyncio unhandled event in task %r (coro=%s): %s — context=%r",
task_name, coro_name, msg, context,
)
def _signal_handler(loop: asyncio.AbstractEventLoop, signame: str) -> None:
"""Catch SIGTERM/SIGINT and log them.
Doesn't try to interfere with shutdown — Hypercorn handles its own
graceful exit. We just want a log line so we can tell "orderly
shutdown via signal X" apart from "silent gap then container exit"
(which means kill-9 or OOM, neither of which is catchable).
"""
global _shutdown_logged
if _shutdown_logged:
return
_shutdown_logged = True
snap = _snapshot_dict(reason="signal_shutdown")
snap["signal"] = signame
_persistent_log(
logging.WARNING,
"diag shutdown: received %s, expecting graceful exit. snapshot=%s",
signame, json.dumps(snap),
)
# Write before-exit snapshot so the next startup's post-mortem can
# see this run shut down cleanly via signal (rather than crashed).
_write_state_atomic(_LAST_SHUTDOWN_PATH, snap)
def start_diagnostics(loop: asyncio.AbstractEventLoop) -> None:
"""Install all three diagnostic surfaces. Idempotent."""
global _heartbeat_task, _started_at
if _started_at is None:
_started_at = time.monotonic()
# 0. Set up the persistent file logger FIRST, then run the
# post-mortem check. The post-mortem reads /data state from the
# previous run and surfaces "previous run died abruptly" if
# current.json is newer than last_shutdown.json. Operator catches
# this on next visit even if they missed the crash window.
_setup_file_logger()
_post_mortem_previous_run()
# 1. Asyncio exception hook — install once.
if loop.get_exception_handler() is None:
loop.set_exception_handler(_asyncio_exception_handler)
# 2. Signal handlers. add_signal_handler is Unix-only; skip on
# Windows so dev on a non-Linux machine doesn't blow up.
if os.name == "posix":
for sig in (signal.SIGTERM, signal.SIGINT):
try:
loop.add_signal_handler(
sig,
_signal_handler,
loop,
sig.name,
)
except (RuntimeError, NotImplementedError, ValueError):
# add_signal_handler can fail when not running in the main
# thread or when the loop is already managing the signal.
# Either case is fine — heartbeat + exception hook still work.
pass
# 3. Heartbeat loop. Start if not already running (idempotent on
# restart-style reloads).
if _heartbeat_task is None or _heartbeat_task.done():
_heartbeat_task = asyncio.create_task(
_heartbeat_loop(), name="diag-heartbeat",
)
logger.info(
"diag started: heartbeat every %ds, signal-aware shutdown logging on, "
"asyncio exception hook installed",
_HEARTBEAT_INTERVAL_SECS,
)
def stop_diagnostics() -> None:
"""Cancel the heartbeat. Called from after_serving.
Lets the loop emit one last 'shutting down' log line so the silence
that follows is intentional (not a crash). The exception hook stays
installed but won't fire after the loop closes.
"""
global _heartbeat_task
if _heartbeat_task is not None and not _heartbeat_task.done():
_heartbeat_task.cancel()
_heartbeat_task = None
snap = _snapshot_dict(reason="after_serving_shutdown")
_persistent_log(
logging.INFO,
"diag stopped: heartbeat cancelled. Final snapshot=%s",
json.dumps(snap),
)
# If we got here without the signal handler firing (e.g., Hypercorn
# decided to shut down on its own), still record a clean exit so
# the next startup's post-mortem sees a fresh last_shutdown.json
# and doesn't flag this as an abrupt death.
_write_state_atomic(_LAST_SHUTDOWN_PATH, snap)
+181
View File
@@ -0,0 +1,181 @@
"""Email service for sending notifications via SMTP."""
import logging
from email.message import EmailMessage
import aiosmtplib
from sqlalchemy import select
from scribe.config import Config
from scribe.models import async_session
from scribe.models.setting import Setting
from scribe.models.user import User
logger = logging.getLogger(__name__)
# Inline SVG logo for email (white palette, renders on indigo header)
_EMAIL_LOGO_SVG = (
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32"'
' style="display:inline-block;vertical-align:middle;margin-right:8px;">'
'<path d="M4 7 C4 7 8 5 16 6 C24 5 28 7 28 7 L28 26 C28 26 24 24 16 25 C8 24 4 26 4 26 Z"'
' fill="#ffffff" stroke="#c4b5fd" stroke-width="0.5"/>'
'<line x1="16" y1="6" x2="16" y2="25" stroke="#c4b5fd" stroke-width="0.8"/>'
'<line x1="7" y1="11" x2="14" y2="10.5" stroke="#ddd6fe" stroke-width="0.6" stroke-linecap="round"/>'
'<line x1="7" y1="14" x2="14" y2="13.5" stroke="#ddd6fe" stroke-width="0.6" stroke-linecap="round"/>'
'<line x1="7" y1="17" x2="14" y2="16.5" stroke="#ddd6fe" stroke-width="0.6" stroke-linecap="round"/>'
'<line x1="7" y1="20" x2="12" y2="19.5" stroke="#ddd6fe" stroke-width="0.6" stroke-linecap="round"/>'
'<line x1="18" y1="10.5" x2="25" y2="11" stroke="#ddd6fe" stroke-width="0.6" stroke-linecap="round"/>'
'<line x1="18" y1="13.5" x2="25" y2="14" stroke="#ddd6fe" stroke-width="0.6" stroke-linecap="round"/>'
'<line x1="18" y1="16.5" x2="25" y2="17" stroke="#ddd6fe" stroke-width="0.6" stroke-linecap="round"/>'
'<g transform="translate(24, 5)">'
'<path d="M0 -4 L1 -1 L4 0 L1 1 L0 4 L-1 1 L-4 0 L-1 -1 Z" fill="#f6ad55"/>'
'<path d="M0 -2.5 L0.6 -0.6 L2.5 0 L0.6 0.6 L0 2.5 L-0.6 0.6 L-2.5 0 L-0.6 -0.6 Z" fill="#fbd38d"/>'
'</g>'
'<circle cx="20" cy="3" r="0.7" fill="#f6ad55" opacity="0.7"/>'
'<circle cx="27" cy="8" r="0.5" fill="#fbd38d" opacity="0.6"/>'
'</svg>'
)
def _email_html(title: str, body: str) -> str:
"""Wrap email body content in the standard Fabled Scribe template."""
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="light">
</head>
<body style="margin:0;padding:0;background:#f5f3ff;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;">
<div style="padding:36px 16px 48px;">
<div style="max-width:520px;margin:0 auto;">
<!-- Card -->
<div style="background:#ffffff;border:1px solid #ddd6fe;border-radius:14px;overflow:hidden;box-shadow:0 4px 24px rgba(109,40,217,0.08);">
<!-- Header -->
<div style="background:linear-gradient(135deg,#7c3aed 0%,#6d28d9 100%);padding:24px 28px;text-align:center;">
<div style="margin-bottom:8px;">
{_EMAIL_LOGO_SVG}<span style="display:inline-block;vertical-align:middle;color:#ffffff;font-size:18px;font-weight:700;letter-spacing:0.01em;">Fabled Scribe</span>
</div>
<p style="margin:0;color:#ede9fe;font-size:13px;letter-spacing:0.03em;">{title}</p>
</div>
<!-- Body -->
<div style="padding:32px 28px;color:#1e1b4b;">
{body}
</div>
<!-- Footer -->
<div style="border-top:1px solid #ede9fe;padding:16px 28px;text-align:center;background:#faf5ff;">
<p style="margin:0;color:#a78bfa;font-size:12px;">Sent by your Fabled Scribe instance.</p>
</div>
</div>
</div>
</div>
</body>
</html>"""
SMTP_SETTING_KEYS = [
"smtp_host",
"smtp_port",
"smtp_username",
"smtp_password",
"smtp_from_address",
"smtp_from_name",
"smtp_use_tls",
]
async def get_smtp_config() -> dict[str, str]:
"""Get SMTP config from admin's settings, falling back to env vars."""
config: dict[str, str] = {
"smtp_host": Config.SMTP_HOST,
"smtp_port": str(Config.SMTP_PORT),
"smtp_username": Config.SMTP_USERNAME,
"smtp_password": Config.SMTP_PASSWORD,
"smtp_from_address": Config.SMTP_FROM_ADDRESS,
"smtp_from_name": Config.SMTP_FROM_NAME,
"smtp_use_tls": "true" if Config.SMTP_USE_TLS else "false",
}
# Override with DB settings from admin user
async with async_session() as session:
result = await session.execute(
select(Setting)
.join(User, Setting.user_id == User.id)
.where(User.role == "admin", Setting.key.in_(SMTP_SETTING_KEYS))
)
for setting in result.scalars().all():
config[setting.key] = setting.value
return config
async def is_smtp_configured() -> bool:
"""Check if SMTP is configured (has a host set)."""
config = await get_smtp_config()
return bool(config.get("smtp_host"))
async def get_base_url() -> str:
"""Get the application base URL from admin settings, falling back to Config.BASE_URL."""
async with async_session() as session:
result = await session.execute(
select(Setting)
.join(User, Setting.user_id == User.id)
.where(User.role == "admin", Setting.key == "base_url")
)
setting = result.scalars().first()
if setting and setting.value:
return setting.value.rstrip("/")
return Config.BASE_URL.rstrip("/")
async def send_email(to: str, subject: str, html_body: str) -> None:
"""Send an email via SMTP."""
config = await get_smtp_config()
host = config.get("smtp_host")
if not host:
logger.debug("SMTP not configured, skipping email to %s", to)
return
port = int(config.get("smtp_port", "587"))
username = config.get("smtp_username", "")
password = config.get("smtp_password", "")
from_address = config.get("smtp_from_address", "")
from_name = config.get("smtp_from_name", "Fabled Scribe")
use_tls = config.get("smtp_use_tls", "true") == "true"
msg = EmailMessage()
msg["Subject"] = subject
msg["From"] = f"{from_name} <{from_address}>" if from_name else from_address
msg["To"] = to
msg.set_content(subject) # plain text fallback
msg.add_alternative(html_body, subtype="html")
try:
await aiosmtplib.send(
msg,
hostname=host,
port=port,
username=username or None,
password=password or None,
start_tls=use_tls and port != 465,
use_tls=port == 465,
)
logger.info("Email sent to %s: %s", to, subject)
except Exception:
logger.exception("Failed to send email to %s: %s", to, subject)
raise
async def send_test_email(to: str) -> None:
"""Send a branded test email."""
body = """
<p style="margin:0 0 12px;color:#1e1b4b;font-size:15px;font-weight:600;">SMTP is configured correctly</p>
<p style="margin:0;color:#6b7280;font-size:14px;">Your Fabled Scribe instance can send email notifications.</p>
"""
await send_email(to, "Fabled Scribe - Test Email", _email_html("Test Email", body))
+212
View File
@@ -0,0 +1,212 @@
"""Semantic note search via fastembed (in-process ONNX, no external service).
Embeddings are stored as JSONB lists in the note_embeddings table (one row per
note). All search operations degrade gracefully — if the embedder fails to
initialize the callers fall back to keyword search.
Model: BAAI/bge-small-en-v1.5 (384-dim). The first call downloads the model
into `FASTEMBED_CACHE_DIR` (defaults to /data/fastembed-cache, a mounted
volume so subsequent boots are instant).
"""
import asyncio
import logging
import math
import os
from sqlalchemy import delete, select
from scribe.models import async_session
from scribe.models.embedding import NoteEmbedding
from scribe.models.note import Note
logger = logging.getLogger(__name__)
# Minimum cosine similarity to include a note in context results.
# bge-small-en-v1.5 produces unit-normalized vectors, so range is [-1, 1].
# 0.45 keeps only genuinely relevant notes; lower values like 0.30 let in
# loosely-related results that pad the sidebar without adding real value.
_SIMILARITY_THRESHOLD = 0.45
_MODEL_NAME = "BAAI/bge-small-en-v1.5"
_CACHE_DIR = os.environ.get("FASTEMBED_CACHE_DIR", "/data/fastembed-cache")
_model = None # lazy singleton; first call downloads model files
_model_lock = asyncio.Lock()
async def _get_model():
"""Return the singleton fastembed.TextEmbedding instance, loading on first call."""
global _model
if _model is None:
async with _model_lock:
if _model is None:
# Defer the import so module import doesn't pull in onnxruntime
# for non-embedding code paths (cheaper cold-start for tests etc.)
from fastembed import TextEmbedding
_model = await asyncio.to_thread(
TextEmbedding,
model_name=_MODEL_NAME,
cache_dir=_CACHE_DIR,
)
logger.info("Loaded fastembed model %s (cache: %s)", _MODEL_NAME, _CACHE_DIR)
return _model
async def get_embedding(text: str, model: str | None = None) -> list[float]:
"""Get an embedding vector for the given text.
The ``model`` parameter is preserved for backward compatibility with the
Ollama era but is now ignored — fastembed uses a single fixed model.
Raises if the fastembed model fails to load. Callers should catch and
degrade to keyword search.
"""
embedder = await _get_model()
# embed() is synchronous CPU work; offload so we don't block the event loop.
vecs = await asyncio.to_thread(lambda: list(embedder.embed([text])))
return vecs[0].tolist()
def _cosine_similarity(a: list[float], b: list[float]) -> float:
"""Cosine similarity between two vectors. Returns 0 for zero-length or
mismatched-length inputs (defensive — mixed-dim vectors can sneak in
across the migration boundary)."""
if not a or not b or len(a) != len(b):
return 0.0
dot = sum(x * y for x, y in zip(a, b))
mag_a = math.sqrt(sum(x * x for x in a))
mag_b = math.sqrt(sum(x * x for x in b))
if mag_a == 0.0 or mag_b == 0.0:
return 0.0
return dot / (mag_a * mag_b)
async def upsert_note_embedding(note_id: int, user_id: int, text: str) -> None:
"""Generate and persist an embedding for a note. Safe to fire-and-forget."""
if not text or not text.strip():
return
try:
embedding = await get_embedding(text)
except Exception:
logger.debug("Skipping embedding for note %d — embedder unavailable", note_id)
return
try:
async with async_session() as session:
await session.execute(
delete(NoteEmbedding).where(NoteEmbedding.note_id == note_id)
)
session.add(NoteEmbedding(note_id=note_id, user_id=user_id, embedding=embedding))
await session.commit()
logger.debug("Upserted embedding for note %d", note_id)
except Exception:
logger.warning("Failed to persist embedding for note %d", note_id, exc_info=True)
async def semantic_search_notes(
user_id: int,
query: str,
exclude_ids: set[int] | None = None,
limit: int = 8,
threshold: float = _SIMILARITY_THRESHOLD,
project_id: int | None = None,
is_task: bool | None = None,
orphan_only: bool = False,
) -> list[tuple[float, Note]]:
"""Return up to *limit* (score, note) pairs most relevant to *query*.
Scores are cosine similarities in [-1, 1]; only notes at or above
*threshold* are returned, sorted highest-first.
Returns an empty list if the embedder is unavailable or on any error.
"""
if not query or not query.strip():
return []
try:
query_vec = await get_embedding(query)
except Exception:
logger.debug("Semantic search skipped — embedder unavailable")
return []
try:
async with async_session() as session:
stmt = (
select(NoteEmbedding, Note)
.join(Note, NoteEmbedding.note_id == Note.id)
.where(NoteEmbedding.user_id == user_id, Note.deleted_at.is_(None))
)
if orphan_only:
stmt = stmt.where(Note.project_id.is_(None))
elif project_id is not None:
stmt = stmt.where(Note.project_id == project_id)
if is_task is True:
stmt = stmt.where(Note.status.isnot(None))
elif is_task is False:
stmt = stmt.where(Note.status.is_(None))
if exclude_ids:
stmt = stmt.where(NoteEmbedding.note_id.notin_(exclude_ids))
rows = list((await session.execute(stmt)).all())
except Exception:
logger.warning("Failed to query note embeddings", exc_info=True)
return []
if not rows:
return []
def _score() -> list[tuple[float, Note]]:
out: list[tuple[float, Note]] = []
for ne, note in rows:
try:
sim = _cosine_similarity(query_vec, ne.embedding)
except Exception:
continue
if sim >= threshold:
out.append((sim, note))
out.sort(key=lambda x: x[0], reverse=True)
return out[:limit]
# Offload the O(rows) cosine scoring off the event loop so a large corpus
# doesn't stall other requests while ranking. Results are unchanged; the
# real scaling fix (ORDER BY / LIMIT in pgvector) is a separate effort.
return await asyncio.to_thread(_score)
async def backfill_note_embeddings() -> None:
"""Generate embeddings for all notes that don't have one yet.
Runs as a background task at startup. Adds a small sleep between notes
so a large backfill doesn't peg CPU.
"""
try:
async with async_session() as session:
existing = {
row[0]
for row in (
await session.execute(select(NoteEmbedding.note_id))
).fetchall()
}
result = await session.execute(
select(Note.id, Note.user_id, Note.title, Note.body)
)
notes_to_embed = [
row for row in result.fetchall() if row[0] not in existing
]
except Exception:
logger.warning("Embedding backfill: failed to query notes", exc_info=True)
return
if not notes_to_embed:
logger.info("Embedding backfill: all notes already have embeddings")
return
logger.info("Embedding backfill: generating embeddings for %d notes", len(notes_to_embed))
success = 0
for note_id, user_id, title, body in notes_to_embed:
text = f"{title}\n{body}".strip() if body else (title or "")
if not text:
continue
await upsert_note_embedding(note_id, user_id, text)
success += 1
await asyncio.sleep(0.05) # gentle pacing
logger.info("Embedding backfill complete: %d/%d notes embedded", success, len(notes_to_embed))
+198
View File
@@ -0,0 +1,198 @@
"""Scheduler jobs for background maintenance tasks.
- Reminder notifications: checks every 5 minutes for due event reminders and
delivers them to the in-app notification feed.
- CalDAV pull sync: runs every hour for all users with CalDAV configured.
- Recurring-task spawn: every 15 minutes, creates the next occurrence of any
recurring task whose spawn time has arrived.
Uses the BackgroundScheduler pattern shared with the other *_scheduler modules.
"""
from __future__ import annotations
import asyncio
import logging
from datetime import datetime, timedelta, timezone
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.interval import IntervalTrigger
from dateutil.rrule import rrulestr
from sqlalchemy import and_, or_, select
from scribe.models import async_session
from scribe.models.event import Event
logger = logging.getLogger(__name__)
_scheduler: BackgroundScheduler | None = None
_loop: asyncio.AbstractEventLoop | None = None
# ---------------------------------------------------------------------------
# Reminder job
# ---------------------------------------------------------------------------
async def _fire_reminders() -> None:
"""Fire in-app reminders for events whose reminder time has arrived.
One-shot events fire once (gated on reminder_sent_at IS NULL). Recurring
events fire once PER OCCURRENCE: reminder_sent_at stores the start of the
occurrence we last reminded about, so each new occurrence re-arms the
reminder instead of the whole series firing only once.
"""
now = datetime.now(timezone.utc)
window_end = now + timedelta(minutes=5)
async with async_session() as session:
result = await session.execute(
select(Event).where(
Event.reminder_minutes.isnot(None),
Event.deleted_at.is_(None),
or_(
# Recurring events are evaluated every sweep against their
# next occurrence (the base start_dt is long past).
Event.recurrence.isnot(None),
# One-shot events: classic gate.
and_(Event.reminder_sent_at.is_(None), Event.start_dt > now),
),
)
)
candidates = list(result.scalars().all())
# (event_id, occurrence_start) — occurrence_start is also the dedup marker
# written to reminder_sent_at, so a given occurrence reminds exactly once.
to_notify: list[tuple[int, datetime]] = []
for event in candidates:
if event.recurrence:
try:
rule = rrulestr(event.recurrence, dtstart=event.start_dt, ignoretz=False)
occ = rule.after(now, inc=True)
except Exception:
logger.warning("Failed to expand RRULE for event %d reminder", event.id, exc_info=True)
continue
if occ is None:
continue
reminder_dt = occ - timedelta(minutes=event.reminder_minutes)
if reminder_dt <= window_end and event.reminder_sent_at != occ:
to_notify.append((event.id, occ))
else:
reminder_dt = event.start_dt - timedelta(minutes=event.reminder_minutes)
if reminder_dt <= window_end:
to_notify.append((event.id, event.start_dt))
if not to_notify:
return
# Deliver via the in-app notification feed (push was removed in Phase 8).
from scribe.services.notifications import create_in_app_notification
async with async_session() as session:
for event_id, occurrence_start in to_notify:
ev = (await session.execute(
select(Event).where(Event.id == event_id)
)).scalar_one_or_none()
# Skip if this exact occurrence was already reminded (covers a
# concurrent sweep and the one-shot already-sent case).
if ev is None or ev.reminder_sent_at == occurrence_start:
continue
await create_in_app_notification(ev.user_id, "event_reminder", {
"event_id": ev.id,
"title": ev.title,
"start_dt": occurrence_start.isoformat(),
"url": "/calendar",
})
# Stamp the occurrence marker only after the notification is
# created, so a delivery failure leaves it eligible to retry.
ev.reminder_sent_at = occurrence_start
await session.commit()
def _run_reminders(loop: asyncio.AbstractEventLoop) -> None:
asyncio.run_coroutine_threadsafe(_fire_reminders(), loop)
# ---------------------------------------------------------------------------
# CalDAV pull sync job
# ---------------------------------------------------------------------------
async def _run_caldav_sync() -> None:
from scribe.services.caldav_sync import sync_all_users # noqa: PLC0415
try:
await sync_all_users()
except Exception:
logger.warning("CalDAV pull sync job failed", exc_info=True)
def _run_caldav_sync_threadsafe(loop: asyncio.AbstractEventLoop) -> None:
asyncio.run_coroutine_threadsafe(_run_caldav_sync(), loop)
# ---------------------------------------------------------------------------
# Recurring-task spawn job
# ---------------------------------------------------------------------------
async def _run_recurrence_spawn() -> None:
from scribe.services.recurrence import spawn_recurring_tasks # noqa: PLC0415
try:
await spawn_recurring_tasks()
except Exception:
logger.warning("Recurring-task spawn job failed", exc_info=True)
def _run_recurrence_spawn_threadsafe(loop: asyncio.AbstractEventLoop) -> None:
asyncio.run_coroutine_threadsafe(_run_recurrence_spawn(), loop)
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
def start_event_scheduler(loop: asyncio.AbstractEventLoop) -> None:
global _scheduler, _loop
if _scheduler is not None:
return
_loop = loop
_scheduler = BackgroundScheduler()
# Check reminders every 5 minutes
_scheduler.add_job(
_run_reminders,
trigger=IntervalTrigger(minutes=5),
args=[loop],
id="event_reminders",
replace_existing=True,
)
# CalDAV pull sync every hour
_scheduler.add_job(
_run_caldav_sync_threadsafe,
trigger=IntervalTrigger(hours=1),
args=[loop],
id="caldav_pull_sync",
replace_existing=True,
)
# Spawn the next occurrence of due recurring tasks every 15 minutes.
# Without this job, recurrence_next_spawn_at is armed on completion but
# never drained, so recurring tasks never recur.
_scheduler.add_job(
_run_recurrence_spawn_threadsafe,
trigger=IntervalTrigger(minutes=15),
args=[loop],
id="recurrence_spawn",
replace_existing=True,
)
_scheduler.start()
logger.info(
"Event scheduler started (reminders every 5m, CalDAV sync every 1h, "
"recurring-task spawn every 15m)"
)
def stop_event_scheduler() -> None:
global _scheduler
if _scheduler is not None:
_scheduler.shutdown(wait=False)
_scheduler = None
logger.info("Event scheduler stopped")
+477
View File
@@ -0,0 +1,477 @@
"""Internal event store service with CalDAV push sync.
Storage model: an event is anchored at ``start_dt`` and has an optional
``duration_minutes``. The end of the event is *derived* via
``Event.end_dt`` (a Python property), never stored. Callers may still
pass ``end_dt`` on writes for ergonomic compatibility — the service
converts to ``duration_minutes`` internally. This rules out the entire
"end before start" bug class structurally (Fable #160 / migration
0043). Open-ended events use ``duration_minutes = None``.
"""
from __future__ import annotations
import asyncio
import logging
import uuid
from datetime import datetime, timedelta, timezone
from dateutil.rrule import rrulestr
from sqlalchemy import or_, select
from scribe.models import async_session
from scribe.models.event import Event
logger = logging.getLogger(__name__)
def _normalize_duration(
*,
start_dt: datetime,
end_dt: datetime | None,
duration_minutes: int | None,
) -> int | None:
"""Reduce (end_dt, duration_minutes) inputs to a single canonical
``duration_minutes`` value.
Resolution order:
1. If ``duration_minutes`` is explicit, use it (validate >= 0).
If ``end_dt`` is also given, validate the two agree.
2. Otherwise, derive from ``end_dt - start_dt``.
3. Otherwise None (point event with no end).
Raises ``ValueError`` for any invalid combination — duration < 0,
end_dt < start_dt, or end_dt and duration_minutes inconsistent.
"""
if duration_minutes is not None:
if duration_minutes < 0:
raise ValueError(
f"duration_minutes must be >= 0, got {duration_minutes}"
)
if end_dt is not None:
expected = int((end_dt - start_dt).total_seconds() // 60)
if expected != duration_minutes:
raise ValueError(
f"end_dt ({end_dt.isoformat()}) implies "
f"{expected} minutes but duration_minutes={duration_minutes} "
f"was passed; pass only one or make them agree."
)
return duration_minutes
if end_dt is not None:
delta_seconds = (end_dt - start_dt).total_seconds()
if delta_seconds < 0:
raise ValueError(
f"end_dt ({end_dt.isoformat()}) must be at or after "
f"start_dt ({start_dt.isoformat()}); pass end_dt=None "
f"or omit it for point events."
)
return int(delta_seconds // 60)
return None
async def _localize_naive(user_id: int, dt: datetime | None) -> datetime | None:
"""Anchor a naive datetime in the user's timezone; pass tz-aware through.
Naive datetimes are the user's local wall-clock time (the MCP create/update
tools combine date+time without a zone). Attaching the user's tzinfo lets
asyncpg store the correct UTC instant, matching the REST/UI path.
"""
if dt is not None and dt.tzinfo is None:
from scribe.services.tz import get_user_tz # noqa: PLC0415
return dt.replace(tzinfo=await get_user_tz(user_id))
return dt
async def create_event(
user_id: int,
title: str,
start_dt: datetime,
end_dt: datetime | None = None,
duration_minutes: int | None = None,
all_day: bool = False,
description: str = "",
location: str = "",
color: str = "",
recurrence: str | None = None,
project_id: int | None = None,
reminder_minutes: int | None = None,
# ``duration`` is a legacy alias kept for the calendar tool layer
# and CalDAV pass-through callers; promotes to duration_minutes
# when duration_minutes isn't otherwise specified.
duration: int | None = None,
attendees: list[str] | None = None,
calendar_name: str | None = None,
) -> Event:
"""Create an event in the DB, then fire a CalDAV push task.
Either ``end_dt`` or ``duration_minutes`` may be supplied; the
service converts to ``duration_minutes`` internally. Raises
``ValueError`` on invalid combinations (negative duration, end
before start, end/duration disagreement).
"""
if duration is not None and duration_minutes is None:
duration_minutes = duration
# Canonical localization point: a naive datetime (e.g. from the MCP tool's
# date+time split) is the user's wall-clock time, so anchor it in their
# timezone before storage. tz-aware inputs (REST, CalDAV pass-through) are
# left untouched. Without this, MCP-created events landed at the same
# wall-clock numerals in UTC and drifted from UI-created ones by the offset.
start_dt = await _localize_naive(user_id, start_dt)
end_dt = await _localize_naive(user_id, end_dt)
duration_minutes = _normalize_duration(
start_dt=start_dt, end_dt=end_dt, duration_minutes=duration_minutes,
)
uid = str(uuid.uuid4())
async with async_session() as session:
event = Event(
user_id=user_id,
uid=uid,
title=title,
start_dt=start_dt,
duration_minutes=duration_minutes,
all_day=all_day,
description=description,
location=location,
color=color,
recurrence=recurrence,
project_id=project_id,
reminder_minutes=reminder_minutes,
)
session.add(event)
await session.commit()
await session.refresh(event)
extra_fields = {
"duration": duration_minutes,
"reminder_minutes": reminder_minutes,
"attendees": attendees,
"calendar_name": calendar_name,
}
asyncio.create_task(_push_create(event, user_id, extra_fields))
return event
async def get_event(user_id: int, event_id: int) -> Event | None:
"""Return event owned by user_id, or None."""
async with async_session() as session:
result = await session.execute(
select(Event).where(
Event.id == event_id, Event.user_id == user_id, Event.deleted_at.is_(None)
)
)
return result.scalar_one_or_none()
async def list_events(
user_id: int,
date_from: datetime,
date_to: datetime,
) -> list[dict]:
"""List events for user_id that overlap [date_from, date_to].
Recurring events (with an RRULE recurrence string) are expanded into
individual occurrences within the range. Non-recurring events are
returned as-is. All results are sorted by start time and returned as
dicts (same shape as ``Event.to_dict()``).
Filtering strategy: a coarse SQL prefilter (events that start on or
before ``date_to``), then refine in Python using the event's derived
end (``start_dt + duration_minutes``). Doing the end-of-event math
in SQL would require Postgres-specific interval arithmetic; the
Python-side refinement is a few row-loops over a small per-user
result set, which is fine for personal-scale data and avoids
coupling the query to a specific dialect.
"""
async with async_session() as session:
result = await session.execute(
select(Event)
.where(
Event.user_id == user_id,
Event.deleted_at.is_(None),
or_(
Event.recurrence.isnot(None),
Event.start_dt <= date_to,
),
)
.order_by(Event.start_dt)
)
events = list(result.scalars().all())
items: list[dict] = []
for event in events:
if event.recurrence:
duration = (
timedelta(minutes=event.duration_minutes)
if event.duration_minutes is not None
else None
)
try:
rule = rrulestr(event.recurrence, dtstart=event.start_dt, ignoretz=False)
occurrences = rule.between(date_from, date_to, inc=True)
except Exception:
logger.warning("Failed to expand RRULE for event %d: %r", event.id, event.recurrence)
# Fall back to canonical event row; still apply the
# window check so a far-future canonical row doesn't
# leak into today's list.
if date_from <= event.start_dt <= date_to:
items.append(event.to_dict())
continue
base = event.to_dict()
for occ in occurrences:
if occ.tzinfo is None:
occ = occ.replace(tzinfo=timezone.utc)
occurrence_dict = dict(base)
occurrence_dict["start_dt"] = occ.isoformat()
if duration is not None:
occurrence_dict["end_dt"] = (occ + duration).isoformat()
items.append(occurrence_dict)
continue
# Non-recurring: refine the coarse prefilter in Python using the
# derived end_dt. A point event (duration None) is included when
# its start is at or after date_from. A timed event is included
# when its end is at or after date_from.
derived_end = event.end_dt
if derived_end is None:
if event.start_dt >= date_from:
items.append(event.to_dict())
else:
if derived_end >= date_from:
items.append(event.to_dict())
items.sort(key=lambda x: x["start_dt"])
return items
async def search_events(
user_id: int,
query: str,
days_ahead: int = 90,
include_past: bool = False,
) -> list[Event]:
"""Search events by keyword in title, description, or location."""
now = datetime.now(timezone.utc)
q = f"%{query}%"
async with async_session() as session:
where = [
Event.user_id == user_id,
Event.deleted_at.is_(None),
or_(
Event.title.ilike(q),
Event.description.ilike(q),
Event.location.ilike(q),
),
]
if not include_past:
date_to = now + timedelta(days=days_ahead)
where.extend([Event.start_dt >= now, Event.start_dt <= date_to])
result = await session.execute(
select(Event).where(*where).order_by(Event.start_dt)
)
return result.scalars().all()
async def update_event(user_id: int, event_id: int, **fields) -> Event | None:
"""Partial update. Returns updated event or None if not found.
Accepts ``end_dt`` or ``duration_minutes`` (or both, validated for
agreement). The service converts to ``duration_minutes`` before
persisting; ``end_dt`` is never stored. Raises ``ValueError`` for
invalid combinations against the post-update state.
"""
async with async_session() as session:
result = await session.execute(
select(Event).where(
Event.id == event_id, Event.user_id == user_id,
Event.deleted_at.is_(None),
)
)
event = result.scalar_one_or_none()
if event is None:
return None
old_title = event.title # capture before mutation for CalDAV lookup
# Localize a naive start_dt patch to the user's timezone (same canonical
# rule as create_event) before it's used or persisted.
if fields.get("start_dt") is not None:
fields["start_dt"] = await _localize_naive(user_id, fields["start_dt"])
# Resolve any end_dt/duration_minutes inputs against the
# post-update start_dt. If neither is in the patch, leave the
# existing duration_minutes alone.
post_update_start = (
fields["start_dt"]
if fields.get("start_dt") is not None
else event.start_dt
)
if "end_dt" in fields or "duration_minutes" in fields:
new_end = fields.pop("end_dt", None)
new_duration = fields.pop("duration_minutes", None)
# If end_dt is in the patch but explicitly None, that's a
# clear → duration_minutes = None. Same shape duration_minutes=None.
if new_end is None and new_duration is None:
fields["duration_minutes"] = None
else:
fields["duration_minutes"] = _normalize_duration(
start_dt=post_update_start,
end_dt=new_end,
duration_minutes=new_duration,
)
allowed = {
"title", "start_dt", "duration_minutes", "all_day",
"description", "location", "color", "recurrence",
"project_id", "reminder_minutes",
}
# Nullable fields callers can explicitly clear by passing None
nullable = {
"duration_minutes", "recurrence", "project_id",
"reminder_minutes",
}
for key, value in fields.items():
if key in allowed and (value is not None or key in nullable):
setattr(event, key, value)
# Re-arm the reminder when the timing changes, so an event moved to a
# new (future) time — or given a new lead time — fires again instead of
# being permanently suppressed by a stale reminder_sent_at.
if "start_dt" in fields or "reminder_minutes" in fields:
event.reminder_sent_at = None
await session.commit()
await session.refresh(event)
asyncio.create_task(_push_update(event, user_id, old_title=old_title))
return event
async def delete_event(user_id: int, event_id: int) -> None:
"""Delete event. Fires CalDAV delete push if caldav_uid is set."""
async with async_session() as session:
result = await session.execute(
select(Event).where(Event.id == event_id, Event.user_id == user_id)
)
event = result.scalar_one_or_none()
if event is None:
return
caldav_uid = event.caldav_uid
event_title = event.title # needed to find the event on CalDAV by title
await session.delete(event)
await session.commit()
if caldav_uid:
asyncio.create_task(_push_delete(caldav_uid, event_title, user_id))
async def find_events_by_query(user_id: int, query: str) -> list[Event]:
"""ILIKE search on title — used by AI update/delete tools.
Returns upcoming events first (start_dt >= now), falling back to
past events so the AI operates on the most relevant match.
"""
q = f"%{query}%"
now = datetime.now(timezone.utc)
async with async_session() as session:
# Prefer events at or after now; fall back to past events
upcoming = (await session.execute(
select(Event).where(
Event.user_id == user_id,
Event.deleted_at.is_(None),
Event.title.ilike(q),
Event.start_dt >= now,
).order_by(Event.start_dt)
)).scalars().all()
if upcoming:
return list(upcoming)
past = (await session.execute(
select(Event).where(
Event.user_id == user_id,
Event.deleted_at.is_(None),
Event.title.ilike(q),
Event.start_dt < now,
).order_by(Event.start_dt.desc())
)).scalars().all()
return list(past)
# ---------------------------------------------------------------------------
# CalDAV push helpers (fire-and-forget)
# ---------------------------------------------------------------------------
async def _push_create(event: Event, user_id: int, extra: dict) -> None:
try:
from scribe.services.caldav import (
create_event as caldav_create,
is_caldav_configured,
)
if not await is_caldav_configured(user_id):
return
derived_end = event.end_dt # property: start + duration_minutes
await caldav_create(
user_id=user_id,
title=event.title,
start=event.start_dt.isoformat(),
end=derived_end.isoformat() if derived_end else None,
description=event.description or None,
location=event.location or None,
all_day=event.all_day,
recurrence=event.recurrence,
uid=event.uid,
duration=extra.get("duration"),
reminder_minutes=extra.get("reminder_minutes"),
attendees=extra.get("attendees"),
calendar_name=extra.get("calendar_name"),
)
# Mark as synced
async with async_session() as session:
result = await session.execute(
select(Event).where(Event.id == event.id)
)
ev = result.scalar_one_or_none()
if ev:
ev.caldav_uid = event.uid
await session.commit()
except Exception:
logger.warning("CalDAV push (create) failed for event %d", event.id, exc_info=True)
async def _push_update(event: Event, user_id: int, old_title: str = "") -> None:
"""Push an update to CalDAV. Uses old_title to locate the event by its pre-rename SUMMARY."""
if not event.caldav_uid:
return
try:
from scribe.services.caldav import (
update_event as caldav_update,
is_caldav_configured,
)
if not await is_caldav_configured(user_id):
return
# Use old_title so CalDAV can find the event even if the title was changed
query_title = old_title or event.title
derived_end = event.end_dt
await caldav_update(
user_id=user_id,
query=query_title,
title=event.title,
start=event.start_dt.isoformat(),
end=derived_end.isoformat() if derived_end else None,
description=event.description or None,
location=event.location or None,
# Propagate the (possibly cleared) RRULE so a local recurrence edit
# isn't overwritten by the stale remote rule on the next pull.
recurrence=event.recurrence,
)
except Exception:
logger.warning("CalDAV push (update) failed for event %d", event.id, exc_info=True)
async def _push_delete(caldav_uid: str, event_title: str, user_id: int) -> None:
"""Push a delete to CalDAV. Uses event_title to locate the event by SUMMARY."""
try:
from scribe.services.caldav import (
delete_event as caldav_delete,
is_caldav_configured,
)
if not await is_caldav_configured(user_id):
return
await caldav_delete(user_id=user_id, query=event_title)
except Exception:
logger.warning("CalDAV push (delete) failed for uid %s", caldav_uid, exc_info=True)
+186
View File
@@ -0,0 +1,186 @@
"""Group management service."""
import logging
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from scribe.models import async_session
from scribe.models.group import Group, GroupMembership
from scribe.models.user import User
logger = logging.getLogger(__name__)
async def create_group(
user_id: int, name: str, description: str | None = None
) -> Group:
async with async_session() as session:
group = Group(name=name, description=description, created_by=user_id)
session.add(group)
await session.flush()
session.add(GroupMembership(group_id=group.id, user_id=user_id, role="owner"))
await session.commit()
await session.refresh(group)
return group
async def list_groups(user_id: int) -> list[dict]:
"""All users see all groups with member count and their own membership status."""
async with async_session() as session:
groups = (await session.execute(select(Group).order_by(Group.name))).scalars().all()
user_group_ids = set(
(await session.execute(
select(GroupMembership.group_id).where(GroupMembership.user_id == user_id)
)).scalars().all()
)
result = []
for g in groups:
count = len(
(await session.execute(
select(GroupMembership).where(GroupMembership.group_id == g.id)
)).scalars().all()
)
d = g.to_dict()
d["member_count"] = count
d["is_member"] = g.id in user_group_ids
result.append(d)
return result
async def get_group(group_id: int) -> Group | None:
async with async_session() as session:
return await session.get(Group, group_id)
async def _is_group_owner(session, acting_user_id: int, group_id: int) -> bool:
m = (await session.execute(
select(GroupMembership).where(
GroupMembership.group_id == group_id,
GroupMembership.user_id == acting_user_id,
GroupMembership.role == "owner",
)
)).scalar_one_or_none()
return m is not None
async def update_group(
acting_user_id: int, group_id: int, is_site_admin: bool, **fields
) -> Group | None:
async with async_session() as session:
group = await session.get(Group, group_id)
if not group:
return None
if not is_site_admin and not await _is_group_owner(session, acting_user_id, group_id):
return None
for k, v in fields.items():
if hasattr(group, k):
setattr(group, k, v)
await session.commit()
await session.refresh(group)
return group
async def delete_group(acting_user_id: int, group_id: int, is_site_admin: bool) -> bool:
async with async_session() as session:
group = await session.get(Group, group_id)
if not group:
return False
if not is_site_admin and group.created_by != acting_user_id:
return False
await session.delete(group)
await session.commit()
return True
async def list_members(group_id: int) -> list[dict]:
async with async_session() as session:
rows = (await session.execute(
select(GroupMembership, User)
.join(User, User.id == GroupMembership.user_id)
.where(GroupMembership.group_id == group_id)
)).all()
return [
{**m.to_dict(), "username": u.username, "email": u.email}
for m, u in rows
]
async def add_member(
acting_user_id: int,
group_id: int,
target_user_id: int,
role: str,
is_site_admin: bool,
) -> GroupMembership | None:
async with async_session() as session:
if not is_site_admin and not await _is_group_owner(session, acting_user_id, group_id):
return None
membership = GroupMembership(group_id=group_id, user_id=target_user_id, role=role)
session.add(membership)
try:
await session.commit()
except IntegrityError:
await session.rollback()
return None
await session.refresh(membership)
return membership
async def update_member_role(
acting_user_id: int,
group_id: int,
target_user_id: int,
role: str,
is_site_admin: bool,
) -> GroupMembership | None:
async with async_session() as session:
if not is_site_admin and not await _is_group_owner(session, acting_user_id, group_id):
return None
m = (await session.execute(
select(GroupMembership).where(
GroupMembership.group_id == group_id,
GroupMembership.user_id == target_user_id,
)
)).scalar_one_or_none()
if not m:
return None
m.role = role
await session.commit()
await session.refresh(m)
return m
async def remove_member(
acting_user_id: int,
group_id: int,
target_user_id: int,
is_site_admin: bool,
) -> bool:
"""Group owner, site admin, or self-removal are all permitted."""
async with async_session() as session:
is_self = acting_user_id == target_user_id
if not is_site_admin and not is_self:
if not await _is_group_owner(session, acting_user_id, group_id):
return False
m = (await session.execute(
select(GroupMembership).where(
GroupMembership.group_id == group_id,
GroupMembership.user_id == target_user_id,
)
)).scalar_one_or_none()
if not m:
return False
await session.delete(m)
await session.commit()
return True
async def get_user_groups(user_id: int) -> list[Group]:
async with async_session() as session:
rows = (await session.execute(
select(Group)
.join(GroupMembership, GroupMembership.group_id == Group.id)
.where(GroupMembership.user_id == user_id)
)).scalars().all()
return list(rows)
+376
View File
@@ -0,0 +1,376 @@
"""Knowledge service — unified query across notes, people, places, and lists."""
import logging
from sqlalchemy import func, select
from scribe.models import async_session
from scribe.models.note import Note
logger = logging.getLogger(__name__)
_SNIPPET_LEN = 200
def _note_to_item(note: Note) -> dict:
meta = note.entity_meta or {}
item: dict = {
"id": note.id,
"note_type": note.entity_type,
"title": note.title,
"snippet": (note.body or "")[:_SNIPPET_LEN],
"tags": note.tags or [],
"project_id": note.project_id,
"metadata": meta,
"created_at": note.created_at.isoformat(),
"updated_at": note.updated_at.isoformat(),
}
# Type-specific convenience fields
if note.entity_type == "person":
item["relationship"] = meta.get("relationship", "")
item["email"] = meta.get("email", "")
item["phone"] = meta.get("phone", "")
item["birthday"] = meta.get("birthday", "")
item["organization"] = meta.get("organization", "")
item["address"] = meta.get("address", "")
elif note.entity_type == "place":
item["address"] = meta.get("address", "")
item["phone"] = meta.get("phone", "")
item["hours"] = meta.get("hours", "")
item["website"] = meta.get("website", "")
item["category"] = meta.get("category", "")
elif note.entity_type == "list":
# Parse markdown task list syntax into structured items
body = note.body or ""
list_items = []
for line in body.split("\n"):
stripped = line.strip()
if stripped.startswith("- [ ] ") or stripped.startswith("- [x] ") or stripped.startswith("- [X] "):
checked_item = not stripped.startswith("- [ ] ")
list_items.append({"text": stripped[6:], "checked": checked_item})
item["list_items"] = list_items
item["item_count"] = len(list_items)
item["checked_count"] = sum(1 for i in list_items if i["checked"])
item["body"] = body
# Task fields — override note_type and add status/priority/due_date
if note.is_task:
item["note_type"] = "task"
item["task_kind"] = note.task_kind
item["status"] = note.status
item["priority"] = note.priority
item["due_date"] = note.due_date.isoformat() if note.due_date else None
return item
def _apply_type_filter(stmt, note_type: str | None):
"""Apply the type facet to a Note select.
'task' = any task (status not null); 'plan' = a task with task_kind='plan';
any other non-empty type = a non-task note of that note_type; None = all.
Trashed rows (deleted_at set) are always excluded.
"""
stmt = stmt.where(Note.deleted_at.is_(None))
if note_type == "task":
return stmt.where(Note.status.isnot(None))
if note_type == "plan":
return stmt.where(Note.status.isnot(None)).where(Note.task_kind == "plan")
if note_type:
return stmt.where(Note.note_type == note_type).where(Note.status.is_(None))
return stmt
async def query_knowledge(
user_id: int,
note_type: str | None,
tags: list[str],
sort: str,
q: str | None,
limit: int,
offset: int,
) -> tuple[list[dict], int]:
"""Query knowledge objects (non-task notes) with filters.
Returns (items, total_count).
"""
# Semantic search path — scores take priority over sort
if q:
return await _semantic_knowledge_search(
user_id, q, note_type=note_type, tags=tags, limit=limit, offset=offset
)
async with async_session() as session:
base = select(Note).where(Note.user_id == user_id)
base = _apply_type_filter(base, note_type)
for tag in tags:
base = base.where(Note.tags.contains([tag]))
# Count before pagination
count_stmt = select(func.count()).select_from(base.subquery())
total: int = (await session.execute(count_stmt)).scalar_one()
# Apply sort
if sort == "created":
base = base.order_by(Note.created_at.desc())
elif sort == "alpha":
base = base.order_by(Note.title.asc())
elif sort == "type":
base = base.order_by(Note.note_type.asc(), Note.updated_at.desc())
else: # modified (default)
base = base.order_by(Note.updated_at.desc())
rows = list((await session.execute(base.limit(limit).offset(offset))).scalars().all())
return [_note_to_item(n) for n in rows], total
async def _semantic_knowledge_search(
user_id: int,
q: str,
note_type: str | None,
tags: list[str],
limit: int,
offset: int,
) -> tuple[list[dict], int]:
"""Hybrid search: keyword matches first (title/body ILIKE), then semantic results.
Exact keyword matches always rank above semantic-only matches so that
searching for a name like "Weston" surfaces the note with that title
before conceptually related notes.
BEST-EFFORT TOP-N, not exhaustive pagination: the ranked candidate set is
capped (keyword limit*2 + up to ~200 semantic), so `total` is the size of
that window, NOT the true match count, and matches beyond the cap are not
reachable by paging. Each page also recomputes the full merge (O(corpus)
per page). Acceptable for an interactive "best results" feed; a cached
ranked-id list or pgvector ORDER BY/LIMIT is the fix if exhaustive,
cheap pagination is ever needed.
"""
# 1. Keyword search — title and body ILIKE
keyword_notes: list[Note] = []
try:
async with async_session() as session:
pattern = f"%{q}%"
base = (
select(Note)
.where(Note.user_id == user_id)
.where(Note.title.ilike(pattern) | Note.body.ilike(pattern))
)
base = _apply_type_filter(base, note_type)
for tag in tags:
base = base.where(Note.tags.contains([tag]))
# Title matches first, then body-only matches, newest first within each
base = base.order_by(
Note.title.ilike(pattern).desc(),
Note.updated_at.desc(),
).limit(limit * 2)
keyword_notes = list((await session.execute(base)).scalars().all())
except Exception:
logger.warning("Keyword search failed", exc_info=True)
# 2. Semantic search — conceptual similarity
semantic_notes: list[Note] = []
try:
from scribe.services.embeddings import semantic_search_notes
is_task_filter = True if note_type in ("task", "plan") else (False if note_type else None)
candidates = await semantic_search_notes(
user_id=user_id,
query=q,
limit=min(200, limit * 4),
threshold=0.3,
is_task=is_task_filter,
)
for _score, note in candidates:
if note.deleted_at is not None:
continue
if note_type == "task" and not note.is_task:
continue
elif note_type == "plan" and (not note.is_task or note.task_kind != "plan"):
continue
elif note_type and note_type not in ("task", "plan") and note.entity_type != note_type:
continue
if tags and not all(t in (note.tags or []) for t in tags):
continue
semantic_notes.append(note)
except Exception:
logger.warning("Semantic search unavailable, using keyword results only", exc_info=True)
# 3. Merge — keyword matches first, then semantic (deduplicated)
seen_ids: set[int] = set()
merged: list[Note] = []
for note in keyword_notes:
if note.id not in seen_ids:
seen_ids.add(note.id)
merged.append(note)
for note in semantic_notes:
if note.id not in seen_ids:
seen_ids.add(note.id)
merged.append(note)
total = len(merged)
page_items = merged[offset: offset + limit]
return [_note_to_item(n) for n in page_items], total
async def get_knowledge_tags(user_id: int, note_type: str | None = None) -> list[str]:
"""Return all distinct tags used across knowledge objects for this user."""
async with async_session() as session:
base = (
select(func.unnest(Note.tags).label("tag"))
.where(Note.user_id == user_id)
)
base = _apply_type_filter(base, note_type)
stmt = base.distinct().order_by("tag")
rows = list((await session.execute(stmt)).scalars().all())
return [r for r in rows if r]
async def get_knowledge_counts(user_id: int, tags: list[str] | None = None) -> dict[str, int]:
"""Return per-type count of knowledge objects for the sidebar display."""
async with async_session() as session:
# Count non-task types
stmt = (
select(Note.note_type, func.count(Note.id))
.where(Note.user_id == user_id)
.where(Note.status.is_(None))
.where(Note.deleted_at.is_(None))
.where(Note.note_type.in_(["note", "person", "place", "list", "process"]))
.group_by(Note.note_type)
)
if tags:
for tag in tags:
stmt = stmt.where(Note.tags.contains([tag]))
rows = list((await session.execute(stmt)).all())
counts = {row[0]: row[1] for row in rows}
# Count tasks separately (is_task = status IS NOT NULL)
task_stmt = (
select(func.count(Note.id))
.where(Note.user_id == user_id)
.where(Note.status.isnot(None))
.where(Note.deleted_at.is_(None))
)
if tags:
for tag in tags:
task_stmt = task_stmt.where(Note.tags.contains([tag]))
task_count: int = (await session.execute(task_stmt)).scalar_one()
counts["task"] = task_count
# Plans are a subset of tasks (task_kind='plan'); counted for the facet
# but NOT added to total to avoid double-counting against "task".
plan_stmt = (
select(func.count(Note.id))
.where(Note.user_id == user_id)
.where(Note.status.isnot(None))
.where(Note.task_kind == "plan")
.where(Note.deleted_at.is_(None))
)
if tags:
for tag in tags:
plan_stmt = plan_stmt.where(Note.tags.contains([tag]))
counts["plan"] = (await session.execute(plan_stmt)).scalar_one()
for t in ("note", "person", "place", "list", "task", "plan", "process"):
counts.setdefault(t, 0)
counts["total"] = sum(counts[t] for t in ("note", "person", "place", "list", "task", "process"))
return counts
async def query_knowledge_ids(
user_id: int,
note_type: str | None,
tags: list[str],
sort: str,
q: str | None,
limit: int = 100,
offset: int = 0,
) -> tuple[list[int], int]:
"""Return note IDs only — cheap query for the two-tier pagination feed."""
if q:
# Re-use semantic search, extract IDs in rank order
items, total = await _semantic_knowledge_search(
user_id, q, note_type=note_type, tags=tags,
limit=limit, offset=offset,
)
return [item["id"] for item in items], total
async with async_session() as session:
base = select(Note.id).where(Note.user_id == user_id)
base = _apply_type_filter(base, note_type)
for tag in tags:
base = base.where(Note.tags.contains([tag]))
count_stmt = select(func.count()).select_from(base.subquery())
total: int = (await session.execute(count_stmt)).scalar_one()
if sort == "created":
base = base.order_by(Note.created_at.desc())
elif sort == "alpha":
base = base.order_by(Note.title.asc())
elif sort == "type":
base = base.order_by(Note.note_type.asc(), Note.updated_at.desc())
else:
base = base.order_by(Note.updated_at.desc())
ids = list((await session.execute(base.limit(limit).offset(offset))).scalars().all())
return ids, total
async def get_knowledge_by_ids(user_id: int, ids: list[int]) -> list[dict]:
"""Fetch full items for the given IDs, preserving the requested order."""
if not ids:
return []
async with async_session() as session:
stmt = (
select(Note)
.where(Note.user_id == user_id)
.where(Note.id.in_(ids))
.where(Note.deleted_at.is_(None))
)
rows = list((await session.execute(stmt)).scalars().all())
by_id = {n.id: n for n in rows}
return [_note_to_item(by_id[i]) for i in ids if i in by_id]
async def get_people_and_places_context(user_id: int) -> str:
"""Return a compact summary of known people and places for LLM system prompt injection."""
async with async_session() as session:
stmt = (
select(Note)
.where(Note.user_id == user_id)
.where(Note.note_type.in_(["person", "place"]))
.where(Note.status.is_(None))
.where(Note.deleted_at.is_(None))
.order_by(Note.title.asc())
.limit(50)
)
rows = list((await session.execute(stmt)).scalars().all())
if not rows:
return ""
people = [n for n in rows if n.entity_type == "person"]
places = [n for n in rows if n.entity_type == "place"]
lines = []
if people:
parts = []
for p in people:
meta = p.entity_meta or {}
rel = meta.get("relationship", "")
parts.append(f"{p.title}" + (f" ({rel})" if rel else ""))
lines.append("Known people: " + ", ".join(parts))
if places:
parts = []
for p in places:
meta = p.entity_meta or {}
addr = meta.get("address", "")
parts.append(f"{p.title}" + (f" {addr}" if addr else ""))
lines.append("Known places: " + "; ".join(parts))
return "\n".join(lines)
+177
View File
@@ -0,0 +1,177 @@
"""Application logging service for audit, usage, and error events."""
import asyncio
import json
import logging
import traceback as tb_module
from datetime import datetime, timedelta, timezone
from sqlalchemy import delete, func, select, text
from scribe.config import Config
from scribe.models import async_session
from scribe.models.app_log import AppLog
logger = logging.getLogger(__name__)
_retention_task: asyncio.Task | None = None
async def log_audit(
action: str,
user_id: int | None = None,
username: str | None = None,
ip_address: str | None = None,
details: dict | None = None,
) -> None:
async with async_session() as session:
log = AppLog(
category="audit",
user_id=user_id,
username=username,
action=action,
ip_address=ip_address,
details=json.dumps(details) if details else None,
)
session.add(log)
await session.commit()
async def log_usage(
user_id: int | None = None,
username: str | None = None,
endpoint: str | None = None,
method: str | None = None,
status_code: int | None = None,
duration_ms: float | None = None,
) -> None:
async with async_session() as session:
log = AppLog(
category="usage",
user_id=user_id,
username=username,
endpoint=endpoint,
method=method,
status_code=status_code,
duration_ms=duration_ms,
)
session.add(log)
await session.commit()
async def log_error(
user_id: int | None = None,
username: str | None = None,
endpoint: str | None = None,
method: str | None = None,
error_type: str | None = None,
error_message: str | None = None,
traceback: str | None = None,
) -> None:
details = {}
if error_type:
details["error_type"] = error_type
if error_message:
details["error_message"] = error_message
if traceback:
details["traceback"] = traceback
async with async_session() as session:
log = AppLog(
category="error",
user_id=user_id,
username=username,
endpoint=endpoint,
method=method,
details=json.dumps(details) if details else None,
)
session.add(log)
await session.commit()
async def get_logs(
category: str | None = None,
user_id: int | None = None,
search: str | None = None,
date_from: str | None = None,
date_to: str | None = None,
limit: int = 50,
offset: int = 0,
) -> tuple[list[dict], int]:
async with async_session() as session:
query = select(AppLog)
count_query = select(func.count(AppLog.id))
if category:
query = query.where(AppLog.category == category)
count_query = count_query.where(AppLog.category == category)
if user_id is not None:
query = query.where(AppLog.user_id == user_id)
count_query = count_query.where(AppLog.user_id == user_id)
if search:
pattern = f"%{search}%"
search_filter = (
AppLog.action.ilike(pattern)
| AppLog.endpoint.ilike(pattern)
| AppLog.username.ilike(pattern)
| AppLog.details.ilike(pattern)
)
query = query.where(search_filter)
count_query = count_query.where(search_filter)
if date_from:
query = query.where(AppLog.created_at >= date_from)
count_query = count_query.where(AppLog.created_at >= date_from)
if date_to:
query = query.where(AppLog.created_at <= date_to)
count_query = count_query.where(AppLog.created_at <= date_to)
total = (await session.execute(count_query)).scalar() or 0
query = query.order_by(AppLog.created_at.desc()).limit(limit).offset(offset)
result = await session.execute(query)
logs = [row.to_dict() for row in result.scalars().all()]
return logs, total
async def get_log_stats() -> dict:
async with async_session() as session:
result = await session.execute(
text(
"SELECT category, COUNT(*) as count FROM app_logs GROUP BY category"
)
)
stats = {row.category: row.count for row in result}
return {
"audit": stats.get("audit", 0),
"usage": stats.get("usage", 0),
"error": stats.get("error", 0),
"total": sum(stats.values()),
}
async def delete_old_logs(retention_days: int) -> int:
cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days)
async with async_session() as session:
result = await session.execute(
delete(AppLog).where(AppLog.created_at < cutoff)
)
await session.commit()
return result.rowcount
async def _retention_loop() -> None:
while True:
await asyncio.sleep(3600) # hourly
try:
deleted = await delete_old_logs(Config.LOG_RETENTION_DAYS)
if deleted:
logger.info("Log retention: deleted %d old log entries", deleted)
except Exception:
logger.exception("Error in log retention cleanup")
def start_log_retention_loop() -> None:
global _retention_task
if _retention_task is None or _retention_task.done():
_retention_task = asyncio.create_task(_retention_loop())
+188
View File
@@ -0,0 +1,188 @@
"""Milestone management service."""
import logging
from datetime import datetime, timezone
from sqlalchemy import func, select
from scribe.models import async_session
from scribe.models.milestone import Milestone
from scribe.models.note import Note
logger = logging.getLogger(__name__)
async def create_milestone(
user_id: int,
project_id: int,
title: str,
description: str | None = None,
order_index: int = 0,
status: str = "active",
) -> Milestone:
async with async_session() as session:
milestone = Milestone(
user_id=user_id,
project_id=project_id,
title=title,
description=description,
status=status,
order_index=order_index,
)
session.add(milestone)
await session.commit()
await session.refresh(milestone)
return milestone
async def get_milestone(user_id: int, milestone_id: int) -> Milestone | None:
async with async_session() as session:
result = await session.execute(
select(Milestone).where(
Milestone.id == milestone_id, Milestone.user_id == user_id,
Milestone.deleted_at.is_(None),
)
)
return result.scalars().first()
async def get_milestone_in_project(project_id: int, milestone_id: int) -> Milestone | None:
"""Fetch a milestone by id within a project, without a user_id ownership check.
Callers must verify project access separately before using this."""
async with async_session() as session:
result = await session.execute(
select(Milestone).where(
Milestone.id == milestone_id,
Milestone.project_id == project_id,
Milestone.deleted_at.is_(None),
)
)
return result.scalars().first()
async def get_milestone_by_title(user_id: int, project_id: int, title: str) -> Milestone | None:
async with async_session() as session:
result = await session.execute(
select(Milestone).where(
Milestone.user_id == user_id,
Milestone.project_id == project_id,
func.lower(Milestone.title) == func.lower(title.strip()),
).limit(1)
)
return result.scalars().first()
async def find_milestone_by_title(user_id: int, title: str) -> Milestone | None:
"""Find a milestone by title across ALL projects for this user (case-insensitive)."""
async with async_session() as session:
result = await session.execute(
select(Milestone).where(
Milestone.user_id == user_id,
func.lower(Milestone.title) == func.lower(title.strip()),
).order_by(Milestone.id).limit(1)
)
return result.scalars().first()
async def get_or_create_milestone(user_id: int, project_id: int, title: str) -> Milestone:
milestone = await get_milestone_by_title(user_id, project_id, title)
if milestone:
return milestone
return await create_milestone(user_id, project_id, title=title)
async def list_milestones(
user_id: int, project_id: int, status: str | None = None
) -> list[Milestone]:
async with async_session() as session:
query = select(Milestone).where(
Milestone.user_id == user_id,
Milestone.project_id == project_id,
Milestone.deleted_at.is_(None),
)
if status:
query = query.where(Milestone.status == status)
query = query.order_by(Milestone.order_index.asc(), Milestone.created_at.asc())
result = await session.execute(query)
return list(result.scalars().all())
async def update_milestone(user_id: int, milestone_id: int, **fields: object) -> Milestone | None:
async with async_session() as session:
result = await session.execute(
select(Milestone).where(
Milestone.id == milestone_id, Milestone.user_id == user_id,
Milestone.deleted_at.is_(None),
)
)
milestone = result.scalars().first()
if milestone is None:
return None
for key, value in fields.items():
if hasattr(milestone, key):
setattr(milestone, key, value)
milestone.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(milestone)
return milestone
async def delete_milestone(user_id: int, milestone_id: int) -> bool:
async with async_session() as session:
result = await session.execute(
select(Milestone).where(Milestone.id == milestone_id, Milestone.user_id == user_id)
)
milestone = result.scalars().first()
if milestone is None:
return False
await session.delete(milestone)
await session.commit()
return True
async def get_milestone_progress(milestone_id: int) -> dict:
"""Return task completion stats for a milestone."""
async with async_session() as session:
rows = await session.execute(
select(Note.status, func.count(Note.id))
.where(
Note.milestone_id == milestone_id, Note.status.isnot(None),
Note.deleted_at.is_(None),
)
.group_by(Note.status)
)
status_counts: dict[str, int] = {}
for status, count in rows.fetchall():
status_counts[status] = count
total = sum(status_counts.values())
cancelled = status_counts.get("cancelled", 0)
completed = status_counts.get("done", 0)
# Cancelled tasks are resolved work, not pending — exclude them from the
# percent-complete denominator so a milestone whose only open task was
# cancelled still reaches 100% (and auto-collapses) instead of stalling.
active_total = total - cancelled
pct = round(completed / active_total * 100, 1) if active_total > 0 else 0.0
return {
"total": total,
"completed": completed,
"pct": pct,
"status_counts": {
"todo": status_counts.get("todo", 0),
"in_progress": status_counts.get("in_progress", 0),
"done": status_counts.get("done", 0),
"cancelled": cancelled,
},
}
async def get_project_milestone_summary(user_id: int, project_id: int) -> list[dict]:
"""Return ordered list of milestones with their progress stats."""
milestones = await list_milestones(user_id, project_id)
result = []
for m in milestones:
progress = await get_milestone_progress(m.id)
entry = m.to_dict()
entry.update(progress)
result.append(entry)
return result
+66
View File
@@ -0,0 +1,66 @@
from sqlalchemy import select, text
from scribe.models import async_session
from scribe.models.note_draft import NoteDraft
async def upsert_draft(
user_id: int,
note_id: int,
proposed_body: str,
original_body: str,
instruction: str,
scope: str,
) -> NoteDraft:
async with async_session() as session:
await session.execute(
text("""
INSERT INTO note_drafts (note_id, user_id, proposed_body, original_body, instruction, scope)
VALUES (:note_id, :user_id, :proposed_body, :original_body, :instruction, :scope)
ON CONFLICT (note_id, user_id) DO UPDATE SET
proposed_body = EXCLUDED.proposed_body,
original_body = EXCLUDED.original_body,
instruction = EXCLUDED.instruction,
scope = EXCLUDED.scope,
updated_at = NOW()
""").bindparams(
note_id=note_id,
user_id=user_id,
proposed_body=proposed_body,
original_body=original_body,
instruction=instruction,
scope=scope,
)
)
await session.commit()
result = await session.execute(
select(NoteDraft).where(
NoteDraft.note_id == note_id, NoteDraft.user_id == user_id
)
)
return result.scalars().first()
async def get_draft(user_id: int, note_id: int) -> NoteDraft | None:
async with async_session() as session:
result = await session.execute(
select(NoteDraft).where(
NoteDraft.note_id == note_id, NoteDraft.user_id == user_id
)
)
return result.scalars().first()
async def delete_draft(user_id: int, note_id: int) -> bool:
async with async_session() as session:
result = await session.execute(
select(NoteDraft).where(
NoteDraft.note_id == note_id, NoteDraft.user_id == user_id
)
)
draft = result.scalars().first()
if draft is None:
return False
await session.delete(draft)
await session.commit()
return True
+90
View File
@@ -0,0 +1,90 @@
from datetime import datetime, timezone
from sqlalchemy import select, text
from scribe.models import async_session
from scribe.models.note_version import NoteVersion
# Maximum snapshots retained per note.
MAX_VERSIONS = 50
# Minimum seconds between snapshots. Prevents autosave from consuming all
# slots — with autosave at 60 s intervals, a 5-minute gate means at most
# one snapshot per editing session segment rather than one per save tick.
MIN_VERSION_INTERVAL_SECONDS = 300
async def create_version(
user_id: int, note_id: int, body: str, title: str, tags: list[str] | None = None
) -> NoteVersion | None:
async with async_session() as session:
# Fetch the most recent snapshot once for both checks.
recent = await session.execute(
select(NoteVersion)
.where(NoteVersion.note_id == note_id, NoteVersion.user_id == user_id)
.order_by(NoteVersion.created_at.desc())
.limit(1)
)
last = recent.scalars().first()
if last is not None:
# Skip if content is identical — no change, no snapshot (git semantics).
if (
last.body == body
and last.title == title
and sorted(last.tags or []) == sorted(tags or [])
):
return None
# Skip if within the minimum interval to prevent rapid-fire autosave snapshots.
last_at = last.created_at
if last_at.tzinfo is None:
last_at = last_at.replace(tzinfo=timezone.utc)
age = (datetime.now(timezone.utc) - last_at).total_seconds()
if age < MIN_VERSION_INTERVAL_SECONDS:
return None
version = NoteVersion(note_id=note_id, user_id=user_id, body=body, title=title, tags=tags or [])
session.add(version)
await session.commit()
await session.refresh(version)
# Prune rolling versions beyond MAX_VERSIONS. Pinned rows
# (pin_kind IS NOT NULL) are excluded from both the counted
# bucket and the deletion candidate set, so they survive
# indefinitely regardless of rolling autosave volume.
await session.execute(
text("""
DELETE FROM note_versions
WHERE id IN (
SELECT id FROM note_versions
WHERE note_id = :note_id
AND user_id = :user_id
AND pin_kind IS NULL
ORDER BY created_at DESC
OFFSET :max_versions
)
""").bindparams(note_id=note_id, user_id=user_id, max_versions=MAX_VERSIONS)
)
await session.commit()
return version
async def list_versions(user_id: int, note_id: int) -> list[NoteVersion]:
async with async_session() as session:
result = await session.execute(
select(NoteVersion)
.where(NoteVersion.note_id == note_id, NoteVersion.user_id == user_id)
.order_by(NoteVersion.created_at.desc())
)
return list(result.scalars().all())
async def get_version(user_id: int, note_id: int, version_id: int) -> NoteVersion | None:
async with async_session() as session:
result = await session.execute(
select(NoteVersion).where(
NoteVersion.id == version_id,
NoteVersion.note_id == note_id,
NoteVersion.user_id == user_id,
)
)
return result.scalars().first()
+581
View File
@@ -0,0 +1,581 @@
import logging
import re
from datetime import date, datetime, timezone
from sqlalchemy import func, or_, select, text
from scribe.models import async_session
from scribe.models.note import Note, TaskPriority, TaskStatus
logger = logging.getLogger(__name__)
def _normalize_tags(tags: list[str]) -> list[str]:
"""Lowercase, strip, deduplicate, and drop empty tags."""
seen: set[str] = set()
out: list[str] = []
for t in tags:
normalized = t.strip().lower()
if normalized and normalized not in seen:
seen.add(normalized)
out.append(normalized)
return out
# Type-nouns the LLM tends to include in search queries. Treating them as
# required ILIKE terms drops literal-title matches; we strip them server-side
# and let the `type` / `project` parameters scope results instead.
_SEARCH_TYPE_NOUNS = {"task", "tasks", "note", "notes", "project", "projects"}
def _strip_type_nouns(q: str) -> list[str]:
"""Return q's tokens with type-nouns removed (case-insensitive)."""
return [t for t in q.split() if t.lower() not in _SEARCH_TYPE_NOUNS]
async def _maybe_reactivate_project(project_id: int) -> None:
"""If a project is paused, reactivate it — activity indicates resumed work."""
from scribe.models.project import Project
try:
async with async_session() as session:
project = (await session.execute(
select(Project).where(Project.id == project_id)
)).scalars().first()
if project and project.status == "paused":
project.status = "active"
await session.commit()
logger.info("Auto-reactivated paused project %d (%s)", project_id, project.title)
except Exception:
logger.debug("_maybe_reactivate_project failed for project %d", project_id, exc_info=True)
async def create_note(
user_id: int,
title: str = "",
body: str = "",
description: str | None = None,
tags: list[str] | None = None,
parent_id: int | None = None,
project_id: int | None = None,
milestone_id: int | None = None,
status: str | None = None,
priority: str | None = None,
due_date: date | None = None,
recurrence_rule: dict | None = None,
note_type: str = "note",
entity_meta: dict | None = None,
task_kind: str = "work",
) -> Note:
# Validate status/priority here so the MCP create_task path (which passes
# them straight through) can't persist an out-of-enum value that the REST
# route would have rejected — there's no DB CHECK on notes.status.
if isinstance(status, str):
try:
status = TaskStatus(status).value
except ValueError:
raise ValueError(f"Invalid status: {status!r}. Must be one of: {[s.value for s in TaskStatus]}")
if isinstance(priority, str):
try:
priority = TaskPriority(priority).value
except ValueError:
raise ValueError(f"Invalid priority: {priority!r}. Must be one of: {[p.value for p in TaskPriority]}")
# Auto-populate project_id from milestone when not explicitly provided
if milestone_id is not None and project_id is None:
from scribe.models.milestone import Milestone
async with async_session() as lookup:
result = await lookup.execute(
select(Milestone).where(Milestone.id == milestone_id, Milestone.user_id == user_id)
)
ms = result.scalars().first()
if ms is not None:
project_id = ms.project_id
async with async_session() as session:
note = Note(
user_id=user_id,
title=title,
body=body,
description=description,
tags=_normalize_tags(tags or []),
parent_id=parent_id,
project_id=project_id,
milestone_id=milestone_id,
status=status,
priority=priority,
due_date=due_date,
recurrence_rule=recurrence_rule,
note_type=note_type,
entity_meta=entity_meta,
task_kind=task_kind,
)
session.add(note)
await session.commit()
await session.refresh(note)
if project_id is not None:
await _maybe_reactivate_project(project_id)
return note
async def get_note(user_id: int, note_id: int) -> Note | None:
async with async_session() as session:
result = await session.execute(
select(Note).where(
Note.id == note_id, Note.user_id == user_id, Note.deleted_at.is_(None)
)
)
return result.scalars().first()
async def list_notes(
user_id: int,
q: str | None = None,
tags: list[str] | None = None,
is_task: bool | None = None,
status: str | list[str] | None = None,
priority: str | list[str] | None = None,
due_before: date | None = None,
due_after: date | None = None,
project_id: int | None = None,
milestone_id: int | None = None,
milestone_ids: list[int] | None = None,
parent_id: int | None = None,
task_kind: str | None = None,
no_project: bool = False,
exclude_paused_projects: bool = False,
sort: str = "updated_at",
order: str = "desc",
limit: int = 50,
offset: int = 0,
) -> tuple[list[Note], int]:
async with async_session() as session:
query = select(Note).where(Note.user_id == user_id, Note.deleted_at.is_(None))
count_query = select(func.count(Note.id)).where(
Note.user_id == user_id, Note.deleted_at.is_(None)
)
# Filter by task vs note
if is_task is True:
query = query.where(Note.status.isnot(None))
count_query = count_query.where(Note.status.isnot(None))
elif is_task is False:
query = query.where(Note.status.is_(None))
count_query = count_query.where(Note.status.is_(None))
if q:
terms = _strip_type_nouns(q)
for term in terms:
escaped_term = term.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
pattern = f"%{escaped_term}%"
term_filter = or_(Note.title.ilike(pattern), Note.body.ilike(pattern))
query = query.where(term_filter)
count_query = count_query.where(term_filter)
if tags:
for i, tag in enumerate(tags):
param_tag = f"tag_{i}"
param_prefix = f"tag_prefix_{i}"
tag_filter = text(
f"EXISTS (SELECT 1 FROM unnest(notes.tags) AS t"
f" WHERE t = :{param_tag} OR t LIKE :{param_prefix})"
).bindparams(**{param_tag: tag, param_prefix: tag + "/%"})
query = query.where(tag_filter)
count_query = count_query.where(tag_filter)
if status:
statuses = [status] if isinstance(status, str) else status
query = query.where(Note.status.in_(statuses))
count_query = count_query.where(Note.status.in_(statuses))
if priority:
priorities = [priority] if isinstance(priority, str) else priority
query = query.where(Note.priority.in_(priorities))
count_query = count_query.where(Note.priority.in_(priorities))
if due_before is not None:
query = query.where(Note.due_date < due_before)
count_query = count_query.where(Note.due_date < due_before)
if due_after is not None:
query = query.where(Note.due_date >= due_after)
count_query = count_query.where(Note.due_date >= due_after)
if project_id is not None:
if milestone_ids:
# OR: directly assigned to project, OR assigned to one of the project's milestones
project_filter = or_(Note.project_id == project_id, Note.milestone_id.in_(milestone_ids))
else:
project_filter = Note.project_id == project_id
query = query.where(project_filter)
count_query = count_query.where(project_filter)
if milestone_id is not None:
query = query.where(Note.milestone_id == milestone_id)
count_query = count_query.where(Note.milestone_id == milestone_id)
if parent_id is not None:
query = query.where(Note.parent_id == parent_id)
count_query = count_query.where(Note.parent_id == parent_id)
if task_kind is not None:
query = query.where(Note.task_kind == task_kind)
count_query = count_query.where(Note.task_kind == task_kind)
if no_project:
query = query.where(Note.project_id.is_(None))
count_query = count_query.where(Note.project_id.is_(None))
if exclude_paused_projects:
from scribe.models.project import Project
paused_ids = (
select(Project.id)
.where(Project.user_id == user_id, Project.status == "paused")
.scalar_subquery()
)
paused_filter = or_(Note.project_id.is_(None), Note.project_id.not_in(paused_ids))
query = query.where(paused_filter)
count_query = count_query.where(paused_filter)
sort_col = getattr(Note, sort, Note.updated_at)
if order == "asc":
query = query.order_by(sort_col.asc())
else:
query = query.order_by(sort_col.desc())
query = query.limit(limit).offset(offset)
total = await session.scalar(count_query) or 0
result = await session.execute(query)
notes = list(result.scalars().all())
return notes, total
async def get_note_by_title(user_id: int, title: str) -> Note | None:
async with async_session() as session:
result = await session.execute(
select(Note).where(
Note.user_id == user_id,
func.lower(Note.title) == func.lower(title.strip()),
Note.deleted_at.is_(None),
).limit(1)
)
return result.scalars().first()
async def get_or_create_note_by_title(user_id: int, title: str) -> Note:
title = title.strip()
note = await get_note_by_title(user_id, title)
if note:
return note
return await create_note(user_id, title=title)
async def update_note(user_id: int, note_id: int, **fields: object) -> Note | None:
async with async_session() as session:
result = await session.execute(
select(Note).where(Note.id == note_id, Note.user_id == user_id)
)
note = result.scalars().first()
if note is None:
return None
# Snapshot before changes for version creation
old_body = note.body
old_title = note.title
old_tags = list(note.tags or [])
for key, value in fields.items():
if not hasattr(note, key):
continue
if key == "status" and isinstance(value, str):
try:
value = TaskStatus(value).value
except ValueError:
raise ValueError(f"Invalid status: {value!r}. Must be one of: {[s.value for s in TaskStatus]}")
elif key == "priority" and isinstance(value, str):
try:
value = TaskPriority(value).value
except ValueError:
raise ValueError(f"Invalid priority: {value!r}. Must be one of: {[p.value for p in TaskPriority]}")
elif key == "tags" and isinstance(value, list):
value = _normalize_tags(value)
setattr(note, key, value)
# Auto-set lifecycle timestamps on status transitions
if "status" in fields:
_now = datetime.now(timezone.utc)
if note.status == TaskStatus.in_progress.value:
if note.started_at is None:
note.started_at = _now
elif note.status in (TaskStatus.done.value, TaskStatus.cancelled.value):
note.completed_at = _now
if note.recurrence_rule:
from scribe.services.recurrence import calculate_next_due
base = note.due_date or _now.date()
next_due = calculate_next_due(note.recurrence_rule, base)
note.recurrence_next_spawn_at = datetime(
next_due.year, next_due.month, next_due.day, tzinfo=timezone.utc
)
elif note.status == TaskStatus.todo.value:
note.started_at = None
note.completed_at = None
note.recurrence_next_spawn_at = None
note.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(note)
# Create a version snapshot when body actually changes
if "body" in fields and fields["body"] != old_body:
from scribe.services.note_versions import create_version
await create_version(user_id, note_id, old_body, old_title, old_tags)
if note.project_id is not None:
await _maybe_reactivate_project(note.project_id)
return note
async def delete_note(user_id: int, note_id: int) -> bool:
async with async_session() as session:
result = await session.execute(
select(Note).where(Note.id == note_id, Note.user_id == user_id)
)
note = result.scalars().first()
if note is None:
return False
await session.delete(note)
await session.commit()
return True
async def get_all_tags(user_id: int, q: str | None = None) -> list[str]:
async with async_session() as session:
if q:
result = await session.execute(
text(
"SELECT DISTINCT tag FROM"
" (SELECT unnest(tags) AS tag FROM notes"
" WHERE tags != '{}' AND user_id = :user_id) t"
" WHERE tag ILIKE :q_pattern ORDER BY tag LIMIT 100"
).bindparams(user_id=user_id, q_pattern=f"%{q}%")
)
else:
result = await session.execute(
text(
"SELECT DISTINCT unnest(tags) AS tag FROM notes"
" WHERE tags != '{}' AND user_id = :user_id"
" ORDER BY tag LIMIT 100"
).bindparams(user_id=user_id)
)
return [row[0] for row in result.fetchall()]
async def convert_note_to_task(user_id: int, note_id: int) -> Note:
async with async_session() as session:
result = await session.execute(
select(Note).where(Note.id == note_id, Note.user_id == user_id)
)
note = result.scalars().first()
if note is None:
logger.warning("convert_note_to_task: note %d not found", note_id)
raise ValueError("Note not found")
note.status = TaskStatus.todo.value
note.priority = TaskPriority.none.value
note.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(note)
logger.info("Converted note %d to task", note_id)
return note
async def convert_task_to_note(user_id: int, note_id: int) -> Note:
async with async_session() as session:
result = await session.execute(
select(Note).where(Note.id == note_id, Note.user_id == user_id)
)
note = result.scalars().first()
if note is None:
logger.warning("convert_task_to_note: note %d not found", note_id)
raise ValueError("Note not found")
note.status = None
note.priority = None
note.due_date = None
# A plain note is not a task and must not recur — clear the rule and
# any armed spawn timestamp so the recurrence sweep never picks it up.
note.recurrence_rule = None
note.recurrence_next_spawn_at = None
note.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(note)
logger.info("Converted task %d to note", note_id)
return note
async def resolve_process(user_id: int, name_or_id) -> tuple[Note | None, list[dict]]:
"""Resolve a stored process by id or name.
Owner-scoped, note_type='process', non-trashed. Precedence: numeric id →
exact case-insensitive title → substring. Returns (note, other_candidates);
on a substring tie with no exact hit, `note` is the most-recently-updated
match and `other_candidates` lists the rest as [{id, title}] so the caller
can disambiguate. Returns (None, []) when nothing matches.
"""
async with async_session() as session:
base = select(Note).where(
Note.user_id == user_id,
Note.note_type == "process",
Note.deleted_at.is_(None),
)
s = str(name_or_id).strip()
if s.isdigit():
row = (await session.execute(base.where(Note.id == int(s)))).scalars().first()
if row is not None:
return row, []
exact = (await session.execute(
base.where(func.lower(Note.title) == s.lower()).order_by(Note.updated_at.desc())
)).scalars().first()
if exact is not None:
return exact, []
matches = (await session.execute(
base.where(Note.title.ilike(f"%{s}%")).order_by(Note.updated_at.desc())
)).scalars().all()
if not matches:
return None, []
return matches[0], [{"id": n.id, "title": n.title} for n in matches[1:]]
async def get_notes_by_ids(user_id: int, note_ids: list[int]) -> dict[int, Note]:
"""Batch fetch notes by ID list. Returns {note_id: Note}."""
if not note_ids:
return {}
async with async_session() as session:
result = await session.execute(
select(Note).where(
Note.user_id == user_id, Note.id.in_(note_ids), Note.deleted_at.is_(None)
)
)
return {n.id: n for n in result.scalars().all()}
async def get_backlinks(user_id: int, note_id: int) -> list[dict]:
note = await get_note(user_id, note_id)
if note is None:
return []
title = note.title
if not title:
return []
async with async_session() as session:
escaped = title.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
pattern = f"%[[{escaped}]]%"
pattern_alias = f"%[[{escaped}|%"
results = await session.execute(
select(Note.id, Note.title, Note.status).where(
Note.user_id == user_id,
Note.id != note_id,
Note.deleted_at.is_(None),
or_(Note.body.like(pattern), Note.body.like(pattern_alias)),
)
)
backlinks: list[dict] = []
for row in results.fetchall():
link_type = "task" if row[2] is not None else "note"
backlinks.append({"type": link_type, "id": row[0], "title": row[1]})
return backlinks
_WIKILINK_RE = re.compile(r'\[\[([^\]|]+)(?:\|[^\]]+)?\]\]')
async def build_note_graph(
user_id: int,
project_id: int | None = None,
include_shared_tags: bool = False,
) -> dict:
notes, _ = await list_notes(user_id, project_id=project_id, limit=1000)
if not notes:
return {"nodes": [], "edges": []}
# Fetch project colours
project_ids = {n.project_id for n in notes if n.project_id is not None}
project_colors: dict[int, str] = {}
if project_ids:
from scribe.models.project import Project
async with async_session() as session:
result = await session.execute(
select(Project.id, Project.color).where(Project.id.in_(project_ids))
)
for pid, color in result.fetchall():
project_colors[pid] = color or "#888888"
# Build title lookup (lowercase → id)
title_map: dict[str, int] = {}
for n in notes:
if n.title:
title_map[n.title.lower()] = n.id
# Build nodes
nodes = []
for n in notes:
nodes.append({
"id": n.id,
"title": n.title or "(untitled)",
"type": "task" if n.is_task else "note",
"tags": n.tags or [],
"project_id": n.project_id,
"project_color": project_colors.get(n.project_id) if n.project_id else None,
})
# Build wikilink edges
edge_set: set[tuple[int, int]] = set()
edges = []
for n in notes:
if not n.body:
continue
for match in _WIKILINK_RE.findall(n.body):
target_id = title_map.get(match.strip().lower())
if target_id is not None and target_id != n.id:
pair = (n.id, target_id)
if pair not in edge_set:
edge_set.add(pair)
edges.append({"source": n.id, "target": target_id, "type": "wikilink"})
# Build tag nodes + note→tag edges
if include_shared_tags:
tag_to_ids: dict[str, list[int]] = {}
for n in notes:
for tag in (n.tags or []):
tag_to_ids.setdefault(tag, []).append(n.id)
for tag, note_ids in tag_to_ids.items():
tag_node_id = f"tag:{tag}"
nodes.append({
"id": tag_node_id,
"title": tag,
"type": "tag",
"tags": [],
"project_id": None,
"project_color": None,
})
for nid in note_ids:
edges.append({"source": nid, "target": tag_node_id, "type": "tag"})
return {"nodes": nodes, "edges": edges}
# ---------------------------------------------------------------------------
# Shared-access variant
# ---------------------------------------------------------------------------
async def get_note_for_user(
accessing_user_id: int, note_id: int
) -> tuple["Note", str] | None:
"""Returns (note, permission) if user has any access, else None."""
from scribe.services.access import get_note_permission
perm = await get_note_permission(accessing_user_id, note_id)
if perm is None:
return None
async with async_session() as session:
note = await session.get(Note, note_id)
return (note, perm) if note else None
+464
View File
@@ -0,0 +1,464 @@
"""Notification service for security alerts and task reminders."""
import asyncio
import json
import logging
from datetime import date, datetime, timezone
from sqlalchemy import func, select, text
from scribe.models import async_session
from scribe.models.app_log import AppLog
from scribe.models.note import Note
from scribe.models.setting import Setting
from scribe.models.user import User
from scribe.services.email import _email_html, is_smtp_configured, send_email
from scribe.services.logging import log_audit
logger = logging.getLogger(__name__)
_notification_task: asyncio.Task | None = None
SECURITY_EVENT_LABELS = {
"login": "New Login",
"login_failed": "Failed Login Attempt",
"logout": "Logout",
"password_change": "Password Changed",
}
async def _get_user_notification_pref(user_id: int, key: str) -> bool:
"""Check if a user has a notification preference enabled (default True)."""
async with async_session() as session:
result = await session.execute(
select(Setting).where(Setting.user_id == user_id, Setting.key == key)
)
setting = result.scalar_one_or_none()
# Default to enabled
return setting.value != "false" if setting else True
async def _get_user_email(user_id: int) -> str | None:
"""Get a user's email address."""
async with async_session() as session:
user = await session.get(User, user_id)
return user.email if user else None
async def notify_security_event(
user_id: int, event_type: str, details: dict | None = None
) -> None:
"""Send a security alert email to a user if they have alerts enabled and an email set."""
try:
if not await is_smtp_configured():
return
if not await _get_user_notification_pref(user_id, "notify_security_alerts"):
return
email = await _get_user_email(user_id)
if not email:
return
label = SECURITY_EVENT_LABELS.get(event_type, event_type)
ip = details.get("ip_address", "Unknown") if details else "Unknown"
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
detail_rows = ""
if details:
for k, v in details.items():
if k == "ip_address":
continue
detail_rows += (
f'<tr>'
f'<td style="padding:6px 0;color:#6b7280;font-size:13px;width:120px;">{k}</td>'
f'<td style="padding:6px 0;color:#111827;font-size:13px;">{v}</td>'
f'</tr>'
)
body = f"""
<p style="margin:0 0 16px;color:#111827;font-size:15px;font-weight:600;">{label}</p>
<table style="width:100%;border-collapse:collapse;background:#f9fafb;border:1px solid #e5e7eb;border-radius:6px;">
<tr>
<td style="padding:8px 12px;color:#6b7280;font-size:13px;width:120px;border-bottom:1px solid #e5e7eb;">Time</td>
<td style="padding:8px 12px;color:#111827;font-size:13px;border-bottom:1px solid #e5e7eb;">{timestamp}</td>
</tr>
<tr>
<td style="padding:8px 12px;color:#6b7280;font-size:13px;width:120px;{';border-bottom:1px solid #e5e7eb' if detail_rows else ''}">IP Address</td>
<td style="padding:8px 12px;color:#111827;font-size:13px;{';border-bottom:1px solid #e5e7eb' if detail_rows else ''}">{ip}</td>
</tr>
{detail_rows}
</table>
<p style="margin:16px 0 0;color:#9ca3af;font-size:12px;">If this wasn't you, change your password immediately.</p>
"""
await send_email(email, f"Fabled Scribe - {label}", _email_html("Security Alert", body))
except Exception:
logger.exception("Failed to send security notification for user %d", user_id)
async def send_password_reset_email(email: str, reset_url: str) -> None:
"""Send a password reset email with a link to reset the user's password."""
body = f"""
<p style="margin:0 0 16px;color:#374151;font-size:15px;">
We received a request to reset your password. Click the button below to choose a new password.
</p>
<div style="text-align:center;margin:24px 0;">
<a href="{reset_url}" style="display:inline-block;background:#7c3aed;color:#ffffff;padding:12px 32px;border-radius:6px;text-decoration:none;font-weight:600;font-size:15px;">
Reset Password
</a>
</div>
<p style="margin:0 0 6px;color:#6b7280;font-size:13px;">This link expires in 1 hour.</p>
<p style="margin:0 0 16px;color:#6b7280;font-size:13px;">If you didn't request this, you can safely ignore this email.</p>
<hr style="border:none;border-top:1px solid #e5e7eb;margin:16px 0;">
<p style="margin:0;color:#9ca3af;font-size:12px;">If the button doesn't work, copy and paste this link:</p>
<p style="margin:4px 0 0;color:#9ca3af;font-size:12px;word-break:break-all;">{reset_url}</p>
"""
await send_email(email, "Fabled Scribe - Password Reset", _email_html("Password Reset", body))
async def send_password_reset_success_email(email: str) -> None:
"""Send a notification that the password was successfully reset."""
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
body = f"""
<p style="margin:0 0 12px;color:#374151;font-size:15px;">
Your password was successfully changed at <strong>{timestamp}</strong>.
</p>
<p style="margin:0;color:#6b7280;font-size:13px;">If you didn't make this change, please contact your administrator immediately.</p>
"""
await send_email(email, "Fabled Scribe - Password Changed", _email_html("Password Changed", body))
async def send_invitation_email(email: str, invite_url: str, invited_by_username: str) -> None:
"""Send a branded invitation email with a registration link."""
body = f"""
<p style="margin:0 0 16px;color:#374151;font-size:15px;">
<strong>{invited_by_username}</strong> has invited you to join Fabled Scribe.
Click the button below to create your account.
</p>
<div style="text-align:center;margin:24px 0;">
<a href="{invite_url}" style="display:inline-block;background:#7c3aed;color:#ffffff;padding:12px 32px;border-radius:6px;text-decoration:none;font-weight:600;font-size:15px;">
Accept Invitation
</a>
</div>
<p style="margin:0 0 6px;color:#6b7280;font-size:13px;">This invitation expires in 7 days.</p>
<p style="margin:0 0 16px;color:#6b7280;font-size:13px;">If you weren't expecting this, you can safely ignore this email.</p>
<hr style="border:none;border-top:1px solid #e5e7eb;margin:16px 0;">
<p style="margin:0;color:#9ca3af;font-size:12px;">If the button doesn't work, copy and paste this link:</p>
<p style="margin:4px 0 0;color:#9ca3af;font-size:12px;word-break:break-all;">{invite_url}</p>
"""
await send_email(email, "Fabled Scribe - You're Invited!", _email_html("You're Invited!", body))
async def check_due_tasks() -> None:
"""Check for tasks due today and send reminder emails."""
if not await is_smtp_configured():
return
today = date.today()
today_str = today.isoformat()
async with async_session() as session:
# Find tasks due today or overdue, not done
result = await session.execute(
select(Note)
.where(
Note.status.isnot(None),
Note.status != "done",
Note.due_date <= today,
)
)
tasks = result.scalars().all()
if not tasks:
return
# Group by user
tasks_by_user: dict[int, list[Note]] = {}
for task in tasks:
if task.user_id:
tasks_by_user.setdefault(task.user_id, []).append(task)
for user_id, user_tasks in tasks_by_user.items():
try:
# Check notification pref
if not await _get_user_notification_pref(user_id, "notify_task_reminders"):
continue
email = await _get_user_email(user_id)
if not email:
continue
# Dedup: check if we already sent a reminder today
dedup_result = await session.execute(
select(func.count(AppLog.id)).where(
AppLog.action == "task_reminder",
AppLog.user_id == user_id,
AppLog.created_at >= today_str,
)
)
if (dedup_result.scalar() or 0) > 0:
continue
# Build task list HTML
task_rows = ""
for task in user_tasks:
overdue = task.due_date < today if task.due_date else False
date_color = "#ef4444" if overdue else "#374151"
date_label = f'<span style="color: {date_color};">{task.due_date.isoformat()}</span>' if task.due_date else ""
overdue_badge = ' <span style="color:#ef4444;font-weight:600;font-size:11px;">(overdue)</span>' if overdue else ""
task_rows += (
f'<tr>'
f'<td style="padding:8px 12px;border-bottom:1px solid #e5e7eb;color:#111827;font-size:14px;">{task.title}</td>'
f'<td style="padding:8px 12px;border-bottom:1px solid #e5e7eb;font-size:14px;">{date_label}{overdue_badge}</td>'
f'<td style="padding:8px 12px;border-bottom:1px solid #e5e7eb;color:#6b7280;font-size:13px;">{task.status}</td>'
f'</tr>'
)
body = f"""
<p style="margin:0 0 16px;color:#374151;font-size:15px;">You have <strong>{len(user_tasks)}</strong> task(s) due:</p>
<table style="width:100%;border-collapse:collapse;border:1px solid #e5e7eb;border-radius:6px;overflow:hidden;">
<tr style="background:#f9fafb;">
<th style="padding:8px 12px;text-align:left;font-size:12px;color:#6b7280;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;border-bottom:1px solid #e5e7eb;">Task</th>
<th style="padding:8px 12px;text-align:left;font-size:12px;color:#6b7280;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;border-bottom:1px solid #e5e7eb;">Due</th>
<th style="padding:8px 12px;text-align:left;font-size:12px;color:#6b7280;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;border-bottom:1px solid #e5e7eb;">Status</th>
</tr>
{task_rows}
</table>
"""
await send_email(email, f"Fabled Scribe - {len(user_tasks)} Task(s) Due", _email_html("Task Reminders", body))
await log_audit(
"task_reminder",
user_id=user_id,
details={"task_count": len(user_tasks), "task_ids": [t.id for t in user_tasks]},
)
logger.info("Sent task reminder to user %d (%d tasks)", user_id, len(user_tasks))
except Exception:
logger.exception("Failed to send task reminder for user %d", user_id)
# Read notifications are kept this long before the hourly sweep deletes them;
# unread are kept regardless. Without a sweep the table grows without bound.
_NOTIFICATION_RETENTION_DAYS = 30
async def purge_old_read_notifications(retention_days: int = _NOTIFICATION_RETENTION_DAYS) -> int:
"""Delete already-read in-app notifications older than retention_days."""
from datetime import timedelta
from sqlalchemy import delete
from scribe.models.notification import Notification
cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days)
async with async_session() as session:
result = await session.execute(
delete(Notification).where(
Notification.read_at.isnot(None),
Notification.read_at < cutoff,
)
)
await session.commit()
return result.rowcount or 0
async def _notification_loop() -> None:
while True:
await asyncio.sleep(3600) # hourly
try:
await check_due_tasks()
except Exception:
logger.exception("Error in notification loop")
try:
removed = await purge_old_read_notifications()
if removed:
logger.info("Notification retention: deleted %d read notification(s)", removed)
except Exception:
logger.exception("Error in notification retention cleanup")
def start_notification_loop() -> None:
global _notification_task
if _notification_task is None or _notification_task.done():
_notification_task = asyncio.create_task(_notification_loop())
# ---------------------------------------------------------------------------
# In-app notifications (Notification model — shares, group events)
# ---------------------------------------------------------------------------
async def create_in_app_notification(user_id: int, notif_type: str, payload: dict):
"""Create an in-app Notification record."""
from scribe.models.notification import Notification
async with async_session() as session:
n = Notification(user_id=user_id, type=notif_type, payload=payload)
session.add(n)
await session.commit()
await session.refresh(n)
return n
async def _fire_share_email(user_id: int, subject: str, body_text: str) -> None:
try:
if not await is_smtp_configured():
return
async with async_session() as session:
user = await session.get(User, user_id)
if user and user.email:
html = _email_html(subject, f"<p>{body_text.replace(chr(10), '<br>')}</p>")
await send_email(user.email, subject, html)
except Exception:
logger.exception("Share email notification failed for user %d", user_id)
async def _group_member_ids(group_id: int) -> list[int]:
from scribe.models.group import GroupMembership
async with async_session() as session:
rows = (await session.execute(
select(GroupMembership.user_id).where(GroupMembership.group_id == group_id)
)).scalars().all()
return list(rows)
async def notify_project_shared(
project_id: int,
permission: str,
invited_by_user_id: int,
target_user_id: int | None,
target_group_id: int | None,
) -> None:
from scribe.models.project import Project
async with async_session() as session:
project = await session.get(Project, project_id)
inviter = await session.get(User, invited_by_user_id)
if not project or not inviter:
return
recipients: list[int] = []
if target_user_id:
recipients = [target_user_id]
elif target_group_id:
recipients = [uid for uid in await _group_member_ids(target_group_id) if uid != invited_by_user_id]
url = f"/projects/{project_id}"
msg = f"{inviter.username} shared \"{project.title}\" with you as {permission}"
for uid in recipients:
await create_in_app_notification(uid, "project_shared", {
"project_id": project_id,
"project_title": project.title,
"permission": permission,
"invited_by": inviter.username,
"url": url,
})
asyncio.create_task(_fire_share_email(uid, f"[Fabled] {inviter.username} shared a project with you", msg))
async def notify_note_shared(
note_id: int,
permission: str,
invited_by_user_id: int,
target_user_id: int | None,
target_group_id: int | None,
) -> None:
from scribe.models.note import Note
async with async_session() as session:
note = await session.get(Note, note_id)
inviter = await session.get(User, invited_by_user_id)
if not note or not inviter:
return
recipients: list[int] = []
if target_user_id:
recipients = [target_user_id]
elif target_group_id:
recipients = [uid for uid in await _group_member_ids(target_group_id) if uid != invited_by_user_id]
route = "tasks" if note.is_task else "notes"
url = f"/{route}/{note_id}"
msg = f"{inviter.username} shared \"{note.title}\" with you as {permission}"
for uid in recipients:
await create_in_app_notification(uid, "note_shared", {
"note_id": note_id,
"note_title": note.title,
"permission": permission,
"invited_by": inviter.username,
"url": url,
})
asyncio.create_task(_fire_share_email(uid, f"[Fabled] {inviter.username} shared a note with you", msg))
async def notify_group_added(
group_id: int, role: str, invited_by_user_id: int, target_user_id: int
) -> None:
from scribe.models.group import Group
async with async_session() as session:
group = await session.get(Group, group_id)
inviter = await session.get(User, invited_by_user_id)
if not group or not inviter:
return
url = f"/groups/{group_id}"
msg = f"{inviter.username} added you to group \"{group.name}\" as {role}"
await create_in_app_notification(target_user_id, "group_added", {
"group_id": group_id,
"group_name": group.name,
"role": role,
"invited_by": inviter.username,
"url": url,
})
asyncio.create_task(_fire_share_email(target_user_id, "[Fabled] You've been added to a group", msg))
async def list_in_app_notifications(user_id: int, unread_only: bool = True) -> list[dict]:
from scribe.models.notification import Notification
async with async_session() as session:
q = select(Notification).where(Notification.user_id == user_id)
if unread_only:
q = q.where(Notification.read_at.is_(None))
q = q.order_by(Notification.created_at.desc()).limit(50)
rows = (await session.execute(q)).scalars().all()
return [n.to_dict() for n in rows]
async def unread_notification_count(user_id: int) -> int:
from scribe.models.notification import Notification
async with async_session() as session:
result = await session.execute(
select(func.count()).where(
Notification.user_id == user_id,
Notification.read_at.is_(None),
)
)
return result.scalar() or 0
async def mark_notification_read(user_id: int, notification_id: int) -> bool:
from scribe.models.notification import Notification
from datetime import timezone as tz
async with async_session() as session:
n = (await session.execute(
select(Notification).where(
Notification.id == notification_id,
Notification.user_id == user_id,
)
)).scalar_one_or_none()
if not n:
return False
from datetime import datetime
n.read_at = datetime.now(tz.utc)
await session.commit()
return True
async def mark_all_notifications_read(user_id: int) -> int:
from scribe.models.notification import Notification
from datetime import datetime, timezone as tz
from sqlalchemy import update as sa_update
async with async_session() as session:
result = await session.execute(
sa_update(Notification)
.where(Notification.user_id == user_id, Notification.read_at.is_(None))
.values(read_at=datetime.now(tz.utc))
.returning(Notification.id)
)
await session.commit()
return len(result.fetchall())
+124
View File
@@ -0,0 +1,124 @@
import logging
import urllib.parse
import httpx
from scribe.config import Config
from scribe.models import async_session
from scribe.models.user import User
from sqlalchemy import select
logger = logging.getLogger(__name__)
_oidc_config: dict | None = None
async def get_oidc_config() -> dict:
global _oidc_config
if _oidc_config is not None:
return _oidc_config
discovery_url = Config.OIDC_ISSUER.rstrip("/") + "/.well-known/openid-configuration"
async with httpx.AsyncClient() as client:
resp = await client.get(discovery_url, follow_redirects=True)
resp.raise_for_status()
_oidc_config = resp.json()
logger.info("OIDC config loaded from %s", discovery_url)
return _oidc_config
async def build_auth_url(state: str, code_challenge: str) -> str:
oidc = await get_oidc_config()
redirect_uri = Config.BASE_URL.rstrip("/") + "/api/auth/oauth/callback"
params = {
"response_type": "code",
"client_id": Config.OIDC_CLIENT_ID,
"redirect_uri": redirect_uri,
"scope": Config.OIDC_SCOPES,
"state": state,
"code_challenge": code_challenge,
"code_challenge_method": "S256",
}
return oidc["authorization_endpoint"] + "?" + urllib.parse.urlencode(params)
async def exchange_code(code: str, redirect_uri: str, code_verifier: str) -> dict:
oidc = await get_oidc_config()
async with httpx.AsyncClient() as client:
resp = await client.post(
oidc["token_endpoint"],
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
"client_id": Config.OIDC_CLIENT_ID,
"client_secret": Config.OIDC_CLIENT_SECRET,
"code_verifier": code_verifier,
},
)
resp.raise_for_status()
return resp.json()
async def get_userinfo(access_token: str) -> dict:
oidc = await get_oidc_config()
async with httpx.AsyncClient() as client:
resp = await client.get(
oidc["userinfo_endpoint"],
headers={"Authorization": f"Bearer {access_token}"},
)
resp.raise_for_status()
return resp.json()
async def find_or_create_oauth_user(
sub: str, email: str, preferred_username: str
) -> User:
async with async_session() as session:
# 1. Look up by oauth_sub
result = await session.execute(select(User).where(User.oauth_sub == sub))
user = result.scalars().first()
if user:
return user
# 2. Look up by email — auto-link
if email:
from sqlalchemy import func
result = await session.execute(
select(User).where(func.lower(User.email) == email.lower())
)
user = result.scalars().first()
if user:
user.oauth_sub = sub
await session.commit()
await session.refresh(user)
logger.info(
"Linked OAuth sub %s to existing user %d (%s) via email",
sub, user.id, user.username,
)
return user
# 3. Create new user — pick a non-colliding username
base_username = preferred_username or (email.split("@")[0] if email else "user")
candidate = base_username
suffix = 2
while True:
result = await session.execute(
select(User).where(User.username == candidate)
)
if not result.scalars().first():
break
candidate = f"{base_username}_{suffix}"
suffix += 1
user = User(
username=candidate,
email=email or None,
password_hash=None,
oauth_sub=sub,
)
session.add(user)
await session.commit()
await session.refresh(user)
logger.info("Auto-provisioned OAuth user %d (%s) for sub %s", user.id, user.username, sub)
return user
+66
View File
@@ -0,0 +1,66 @@
"""Planning service — start_planning aggregates plan-task creation with the
project's applicable Rulebook rules and a little context, so planning happens
in Scribe and rules surface at the planning moment.
"""
from __future__ import annotations
from scribe.services import notes as notes_svc
from scribe.services import projects as projects_svc
from scribe.services import rulebooks as rulebooks_svc
PLAN_TEMPLATE = """## Goal
## Approach
## Steps
- [ ]
## Verification
"""
async def start_planning(user_id: int, project_id: int, title: str) -> dict:
"""Create a plan-task seeded with the plan template and return it with the
project's applicable rules + brief context.
Returns:
{
"task": <task dict>,
"applicable_rules": [...],
"subscribed_rulebooks": [...],
"applicable_rules_truncated": bool,
"project_goal": str,
"open_task_count": int,
}
"""
project = await projects_svc.get_project(user_id, project_id)
if project is None:
raise ValueError(f"project {project_id} not found")
note = await notes_svc.create_note(
user_id,
title=title,
body=PLAN_TEMPLATE,
status="todo",
task_kind="plan",
project_id=project_id,
)
applicable = await rulebooks_svc.get_applicable_rules(
project_id=project_id, user_id=user_id,
)
_, open_count = await notes_svc.list_notes(
user_id, is_task=True, status="todo", project_id=project_id, limit=1,
)
return {
"task": note.to_dict(),
"applicable_rules": applicable["rules"],
"subscribed_rulebooks": applicable["subscribed_rulebooks"],
"applicable_rules_truncated": applicable["truncated"],
"project_rules": applicable.get("project_rules", []),
"suppressed_rules": applicable.get("suppressed_rules", []),
"suppressed_topics": applicable.get("suppressed_topics", []),
"project_goal": getattr(project, "goal", "") or "",
"open_task_count": open_count,
}
+253
View File
@@ -0,0 +1,253 @@
"""Project management service."""
import logging
from datetime import datetime, timezone
from sqlalchemy import func, select
from scribe.models import async_session
from scribe.models.note import Note
from scribe.models.project import Project, ProjectStatus
logger = logging.getLogger(__name__)
def _validate_status(status: str) -> str:
"""Coerce/validate a project status against ProjectStatus.
Canonical gate so the MCP create/update_project path (which passes status
straight through) can't persist an out-of-enum value — there's no DB CHECK.
"""
try:
return ProjectStatus(status).value
except ValueError:
raise ValueError(
f"Invalid status: {status!r}. Must be one of: {[s.value for s in ProjectStatus]}"
)
async def create_project(
user_id: int,
title: str,
description: str = "",
goal: str = "",
color: str | None = None,
status: str = "active",
) -> Project:
status = _validate_status(status)
async with async_session() as session:
project = Project(
user_id=user_id,
title=title,
description=description,
goal=goal,
color=color,
status=status,
)
session.add(project)
await session.commit()
await session.refresh(project)
return project
async def get_project(user_id: int, project_id: int) -> Project | None:
async with async_session() as session:
result = await session.execute(
select(Project).where(
Project.id == project_id, Project.user_id == user_id,
Project.deleted_at.is_(None),
)
)
return result.scalars().first()
async def get_project_by_title(user_id: int, title: str) -> Project | None:
async with async_session() as session:
result = await session.execute(
select(Project).where(
Project.user_id == user_id,
func.lower(Project.title) == func.lower(title.strip()),
Project.deleted_at.is_(None),
).limit(1)
)
return result.scalars().first()
async def get_or_create_project(user_id: int, title: str) -> Project:
project = await get_project_by_title(user_id, title)
if project:
return project
return await create_project(user_id, title=title)
async def list_projects(user_id: int, status: str | None = None) -> list[Project]:
async with async_session() as session:
query = select(Project).where(
Project.user_id == user_id, Project.deleted_at.is_(None)
)
if status:
query = query.where(Project.status == status)
query = query.order_by(Project.updated_at.desc())
result = await session.execute(query)
return list(result.scalars().all())
async def update_project(user_id: int, project_id: int, **fields: object) -> Project | None:
async with async_session() as session:
result = await session.execute(
select(Project).where(
Project.id == project_id, Project.user_id == user_id,
Project.deleted_at.is_(None),
)
)
project = result.scalars().first()
if project is None:
return None
if "status" in fields and fields["status"] is not None:
fields["status"] = _validate_status(fields["status"])
for key, value in fields.items():
if hasattr(project, key):
setattr(project, key, value)
project.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(project)
return project
async def delete_project(user_id: int, project_id: int) -> bool:
async with async_session() as session:
result = await session.execute(
select(Project).where(Project.id == project_id, Project.user_id == user_id)
)
project = result.scalars().first()
if project is None:
return False
# Unlink notes (cascade handled by DB ON DELETE SET NULL, but we delete project here)
await session.delete(project)
await session.commit()
return True
async def get_project_summary(user_id: int, project_id: int) -> dict:
"""Return task counts by status, note count, and last activity."""
async with async_session() as session:
# Task counts by status
task_rows = await session.execute(
select(Note.status, func.count(Note.id))
.where(
Note.user_id == user_id,
Note.project_id == project_id,
Note.status.isnot(None),
Note.deleted_at.is_(None),
)
.group_by(Note.status)
)
# Initialise all three lifecycle keys to 0 so consumers can sum them
# safely without `?? 0` guards. Frontend interface declares all three
# as required; rendering `undefined + N` yields NaN.
task_counts: dict[str, int] = {"todo": 0, "in_progress": 0, "done": 0}
for status, count in task_rows.fetchall():
task_counts[status] = count
# Note count (non-tasks)
note_count_result = await session.scalar(
select(func.count(Note.id)).where(
Note.user_id == user_id,
Note.project_id == project_id,
Note.status.is_(None),
Note.deleted_at.is_(None),
)
)
note_count = note_count_result or 0
# Last activity
last_activity_result = await session.scalar(
select(func.max(Note.updated_at)).where(
Note.user_id == user_id,
Note.project_id == project_id,
)
)
from scribe.services.milestones import get_project_milestone_summary
milestone_summary = await get_project_milestone_summary(user_id, project_id)
return {
"task_counts": task_counts,
"note_count": note_count,
"last_activity": last_activity_result.isoformat() if last_activity_result else None,
"milestone_summary": milestone_summary,
}
# ---------------------------------------------------------------------------
# Shared-access variants (honour ProjectShare in addition to ownership)
# ---------------------------------------------------------------------------
async def get_project_for_user(
accessing_user_id: int, project_id: int
) -> tuple[Project, str] | None:
"""Returns (project, permission) if user has any access, else None."""
from scribe.services.access import get_project_permission
perm = await get_project_permission(accessing_user_id, project_id)
if perm is None:
return None
async with async_session() as session:
project = await session.get(Project, project_id)
return (project, perm) if project else None
async def list_projects_for_user(user_id: int, status: str | None = None) -> list[dict]:
"""Owned projects + shared projects, each dict has 'permission' field."""
from scribe.models.group import GroupMembership
from scribe.models.share import ProjectShare
from scribe.services.access import PERMISSION_RANK
owned = await list_projects(user_id, status)
owned_ids = {p.id for p in owned}
result = []
for p in owned:
d = p.to_dict() if hasattr(p, "to_dict") else {"id": p.id, "title": p.title}
d["permission"] = "owner"
result.append(d)
async with async_session() as session:
user_group_ids = (await session.execute(
select(GroupMembership.group_id).where(GroupMembership.user_id == user_id)
)).scalars().all()
shared_direct = (await session.execute(
select(ProjectShare).where(ProjectShare.shared_with_user_id == user_id)
)).scalars().all()
shared_group: list[ProjectShare] = []
if user_group_ids:
shared_group = (await session.execute(
select(ProjectShare).where(
ProjectShare.shared_with_group_id.in_(user_group_ids)
)
)).scalars().all()
seen: dict[int, str] = {}
for share in list(shared_direct) + list(shared_group):
if share.project_id in owned_ids:
continue
prev = seen.get(share.project_id)
if prev is None or PERMISSION_RANK[share.permission] > PERMISSION_RANK[prev]:
seen[share.project_id] = share.permission
for pid, perm in seen.items():
if status:
async with async_session() as session:
proj = await session.get(Project, pid)
if not proj or proj.status != status:
continue
else:
async with async_session() as session:
proj = await session.get(Project, pid)
if proj:
d = proj.to_dict() if hasattr(proj, "to_dict") else {"id": proj.id, "title": proj.title}
d["permission"] = perm
d["is_shared"] = True
result.append(d)
return result
+158
View File
@@ -0,0 +1,158 @@
"""Recurring task rule validation, date calculation, and scheduled spawning."""
import asyncio
import calendar
import logging
from datetime import date, datetime, timedelta, timezone
from sqlalchemy import and_, select
from scribe.models import async_session
from scribe.models.note import Note, TaskStatus
logger = logging.getLogger(__name__)
_VALID_UNITS = {"day", "week", "month", "year"}
def validate_recurrence_rule(rule: dict) -> None:
"""Raise ValueError if rule is structurally invalid."""
if not isinstance(rule, dict):
raise ValueError("recurrence_rule must be an object")
rule_type = rule.get("type")
if rule_type not in ("interval", "calendar"):
raise ValueError("recurrence_rule.type must be 'interval' or 'calendar'")
unit = rule.get("unit")
if unit not in _VALID_UNITS:
raise ValueError(f"recurrence_rule.unit must be one of {_VALID_UNITS}")
if rule_type == "interval":
every = rule.get("every")
if not isinstance(every, int) or every < 1:
raise ValueError("recurrence_rule.every must be a positive integer")
elif rule_type == "calendar":
day = rule.get("day_of_month")
if not isinstance(day, int) or not 1 <= day <= 31:
raise ValueError("recurrence_rule.day_of_month must be an integer between 1 and 31")
if unit == "year":
month = rule.get("month")
if not isinstance(month, int) or not 1 <= month <= 12:
raise ValueError("recurrence_rule.month must be an integer between 1 and 12")
elif unit != "month":
raise ValueError("calendar recurrence only supports unit 'month' or 'year'")
def _add_months(d: date, months: int) -> date:
month = d.month - 1 + months
year = d.year + month // 12
month = month % 12 + 1
day = min(d.day, calendar.monthrange(year, month)[1])
return d.replace(year=year, month=month, day=day)
def _next_day_of_month(from_date: date, day: int) -> date:
"""Return the next occurrence of day-of-month strictly after from_date."""
year, month = from_date.year, from_date.month
max_day = calendar.monthrange(year, month)[1]
clamped = min(day, max_day)
if clamped > from_date.day:
return from_date.replace(day=clamped)
# Advance one month
if month == 12:
year, month = year + 1, 1
else:
month += 1
return date(year, month, min(day, calendar.monthrange(year, month)[1]))
def _next_annual_date(from_date: date, month: int, day: int) -> date:
"""Return the next occurrence of month/day after from_date."""
max_day = calendar.monthrange(from_date.year, month)[1]
candidate = date(from_date.year, month, min(day, max_day))
if candidate > from_date:
return candidate
return date(
from_date.year + 1,
month,
min(day, calendar.monthrange(from_date.year + 1, month)[1]),
)
def calculate_next_due(rule: dict, from_date: date) -> date:
"""Return the next due date after from_date according to rule."""
rule_type = rule["type"]
unit = rule["unit"]
if rule_type == "interval":
every = rule["every"]
if unit == "day":
return from_date + timedelta(days=every)
if unit == "week":
return from_date + timedelta(weeks=every)
if unit == "month":
return _add_months(from_date, every)
if unit == "year":
return _add_months(from_date, every * 12)
elif rule_type == "calendar":
if unit == "month":
return _next_day_of_month(from_date, rule["day_of_month"])
if unit == "year":
return _next_annual_date(from_date, rule["month"], rule["day_of_month"])
raise ValueError(f"Unhandled recurrence rule: {rule!r}")
async def spawn_recurring_tasks() -> int:
"""Create child tasks for all recurring tasks whose spawn date has arrived.
Returns the number of tasks spawned.
"""
from scribe.services.embeddings import upsert_note_embedding
from scribe.services.notes import create_note
now = datetime.now(timezone.utc)
spawned = 0
async with async_session() as session:
result = await session.execute(
select(Note).where(
and_(
Note.recurrence_rule.isnot(None),
Note.recurrence_next_spawn_at <= now,
# Never spawn children off a trashed parent — that would
# resurrect work the user explicitly deleted.
Note.deleted_at.is_(None),
)
)
)
tasks = result.scalars().all()
for task in tasks:
base_date = task.due_date or now.date()
next_due = calculate_next_due(task.recurrence_rule, base_date)
try:
child = await create_note(
task.user_id,
title=task.title,
body=task.body or "",
status=TaskStatus.todo.value,
priority=task.priority or "none",
due_date=next_due,
tags=list(task.tags or []),
project_id=task.project_id,
milestone_id=task.milestone_id,
recurrence_rule=task.recurrence_rule,
)
text = f"{child.title}\n{child.body}".strip() if child.body else (child.title or "")
if text:
asyncio.create_task(upsert_note_embedding(child.id, task.user_id, text))
except Exception:
logger.exception("Failed to spawn recurring task %d", task.id)
continue
async with async_session() as session:
parent = await session.get(Note, task.id)
if parent:
parent.recurrence_next_spawn_at = None
await session.commit()
spawned += 1
logger.info("Spawned recurring task %d → child due %s", task.id, next_due)
return spawned
+781
View File
@@ -0,0 +1,781 @@
"""Rulebook / topic / rule service layer — single source of truth used by
both routes/rulebooks.py and mcp/tools/rulebooks.py.
Ownership enforcement: every function takes user_id and scopes through
rulebooks.owner_user_id. Functions return models or model.to_dict() output
depending on the caller's needs (mirroring services/events.py pattern).
"""
from __future__ import annotations
import logging
from typing import Optional
from sqlalchemy import select
from scribe.models import async_session
from scribe.models.rulebook import Rulebook
logger = logging.getLogger(__name__)
# ── Rulebook CRUD ────────────────────────────────────────────────────────
async def create_rulebook(
user_id: int, title: str, description: str = "",
) -> Rulebook:
"""Create a new rulebook owned by user_id. Returns the persisted model."""
async with async_session() as session:
rb = Rulebook(
owner_user_id=user_id, title=title, description=description or None,
)
session.add(rb)
await session.commit()
await session.refresh(rb)
return rb
async def list_rulebooks(user_id: int) -> list[Rulebook]:
"""List rulebooks owned by user_id, ordered by title."""
async with async_session() as session:
result = await session.execute(
select(Rulebook)
.where(Rulebook.owner_user_id == user_id, Rulebook.deleted_at.is_(None))
.order_by(Rulebook.title)
)
return list(result.scalars().all())
async def get_rulebook(rulebook_id: int, user_id: int) -> Optional[Rulebook]:
"""Get a rulebook by id, scoped to user_id. None if not owned or not found."""
async with async_session() as session:
result = await session.execute(
select(Rulebook).where(
Rulebook.id == rulebook_id,
Rulebook.owner_user_id == user_id,
Rulebook.deleted_at.is_(None),
)
)
return result.scalar_one_or_none()
async def update_rulebook(
rulebook_id: int, user_id: int, **fields,
) -> Optional[Rulebook]:
"""Partial update. Returns updated rulebook or None if not found."""
async with async_session() as session:
result = await session.execute(
select(Rulebook).where(
Rulebook.id == rulebook_id,
Rulebook.owner_user_id == user_id,
)
)
rb = result.scalar_one_or_none()
if rb is None:
return None
allowed = {"title", "description", "always_on"}
for key, value in fields.items():
if key in allowed and value is not None:
setattr(rb, key, value)
await session.commit()
await session.refresh(rb)
return rb
async def delete_rulebook(rulebook_id: int, user_id: int) -> None:
"""Delete a rulebook. Cascade-deletes topics, rules, subscriptions."""
async with async_session() as session:
result = await session.execute(
select(Rulebook).where(
Rulebook.id == rulebook_id,
Rulebook.owner_user_id == user_id,
)
)
rb = result.scalar_one_or_none()
if rb is None:
return
await session.delete(rb)
await session.commit()
async def find_rulebook_by_title(
user_id: int, title: str,
) -> Optional[Rulebook]:
"""Used by the port script for the dupe-guard. None if not found."""
async with async_session() as session:
result = await session.execute(
select(Rulebook).where(
Rulebook.owner_user_id == user_id,
Rulebook.title == title,
Rulebook.deleted_at.is_(None),
)
)
return result.scalar_one_or_none()
# ── Topic CRUD ──────────────────────────────────────────────────────────
from scribe.models.rulebook import RulebookTopic
async def _assert_rulebook_owned(session, rulebook_id: int, user_id: int) -> None:
"""Raise ValueError if rulebook doesn't exist or isn't owned by user.
Centralizes ownership check used by all topic/rule operations.
"""
result = await session.execute(
select(Rulebook).where(
Rulebook.id == rulebook_id,
Rulebook.owner_user_id == user_id,
Rulebook.deleted_at.is_(None),
)
)
if result.scalar_one_or_none() is None:
raise ValueError(f"rulebook {rulebook_id} not found")
async def create_topic(
rulebook_id: int, user_id: int, title: str,
description: str = "", order_index: int = 0,
) -> RulebookTopic:
async with async_session() as session:
await _assert_rulebook_owned(session, rulebook_id, user_id)
topic = RulebookTopic(
rulebook_id=rulebook_id,
title=title,
description=description or None,
order_index=order_index,
)
session.add(topic)
await session.commit()
await session.refresh(topic)
return topic
async def list_topics(rulebook_id: int, user_id: int) -> list[RulebookTopic]:
async with async_session() as session:
await _assert_rulebook_owned(session, rulebook_id, user_id)
result = await session.execute(
select(RulebookTopic)
.where(
RulebookTopic.rulebook_id == rulebook_id,
RulebookTopic.deleted_at.is_(None),
)
.order_by(RulebookTopic.order_index, RulebookTopic.title)
)
return list(result.scalars().all())
async def get_topic(topic_id: int, user_id: int) -> Optional[RulebookTopic]:
"""Get a topic, scoped via the rulebook owner."""
async with async_session() as session:
result = await session.execute(
select(RulebookTopic)
.join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
.where(
RulebookTopic.id == topic_id,
Rulebook.owner_user_id == user_id,
RulebookTopic.deleted_at.is_(None),
Rulebook.deleted_at.is_(None),
)
)
return result.scalar_one_or_none()
async def update_topic(
topic_id: int, user_id: int, **fields,
) -> Optional[RulebookTopic]:
async with async_session() as session:
result = await session.execute(
select(RulebookTopic)
.join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
.where(
RulebookTopic.id == topic_id,
Rulebook.owner_user_id == user_id,
)
)
topic = result.scalar_one_or_none()
if topic is None:
return None
allowed = {"title", "description", "order_index"}
for key, value in fields.items():
if key in allowed and value is not None:
setattr(topic, key, value)
await session.commit()
await session.refresh(topic)
return topic
async def delete_topic(topic_id: int, user_id: int) -> None:
async with async_session() as session:
result = await session.execute(
select(RulebookTopic)
.join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
.where(
RulebookTopic.id == topic_id,
Rulebook.owner_user_id == user_id,
)
)
topic = result.scalar_one_or_none()
if topic is None:
return
await session.delete(topic)
await session.commit()
# ── Rule CRUD ──────────────────────────────────────────────────────────
from scribe.models.rulebook import Rule
async def _assert_topic_owned(session, topic_id: int, user_id: int) -> None:
"""Raise ValueError if topic doesn't exist or isn't in user's rulebook."""
result = await session.execute(
select(RulebookTopic)
.join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
.where(
RulebookTopic.id == topic_id,
Rulebook.owner_user_id == user_id,
RulebookTopic.deleted_at.is_(None),
Rulebook.deleted_at.is_(None),
)
)
if result.scalar_one_or_none() is None:
raise ValueError(f"topic {topic_id} not found")
async def _assert_project_owned(session, project_id: int, user_id: int) -> None:
"""Raise ValueError if project doesn't exist or isn't owned by user."""
from scribe.models.project import Project
result = await session.execute(
select(Project).where(
Project.id == project_id,
Project.user_id == user_id,
Project.deleted_at.is_(None),
)
)
if result.scalar_one_or_none() is None:
raise ValueError(f"project {project_id} not found")
async def _assert_rulebook_rule_owned(session, rule_id: int, user_id: int) -> None:
"""Raise ValueError if rule isn't a rulebook rule the user owns.
Project-scoped rules (Rule.project_id set, topic_id NULL) are NOT
suppressible — they belong to the project; delete them instead. This
helper deliberately excludes them.
"""
from scribe.models.rulebook import Rule
result = await session.execute(
select(Rule)
.join(RulebookTopic, Rule.topic_id == RulebookTopic.id)
.join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
.where(
Rule.id == rule_id,
Rulebook.owner_user_id == user_id,
Rule.deleted_at.is_(None),
RulebookTopic.deleted_at.is_(None),
Rulebook.deleted_at.is_(None),
)
)
if result.scalar_one_or_none() is None:
raise ValueError(f"rule {rule_id} not found or not a rulebook rule")
async def create_rule(
topic_id: int, user_id: int, title: str, statement: str,
why: str = "", how_to_apply: str = "", order_index: int = 0,
) -> Rule:
async with async_session() as session:
await _assert_topic_owned(session, topic_id, user_id)
rule = Rule(
topic_id=topic_id,
title=title,
statement=statement,
why=why or None,
how_to_apply=how_to_apply or None,
order_index=order_index,
)
session.add(rule)
await session.commit()
await session.refresh(rule)
return rule
async def create_project_rule(
project_id: int, user_id: int, title: str, statement: str,
why: str = "", how_to_apply: str = "", order_index: int = 0,
) -> Rule:
"""Create a rule scoped to a single project (no rulebook ceremony).
Project-scoped rules apply only to the named project; they don't
propagate via rulebook subscriptions. Topic_id is left NULL — the
CHECK constraint enforces exactly-one of (topic_id, project_id).
"""
async with async_session() as session:
await _assert_project_owned(session, project_id, user_id)
rule = Rule(
project_id=project_id,
title=title,
statement=statement,
why=why or None,
how_to_apply=how_to_apply or None,
order_index=order_index,
)
session.add(rule)
await session.commit()
await session.refresh(rule)
return rule
async def list_rules(
user_id: int,
rulebook_id: int | None = None,
topic_id: int | None = None,
project_id: int | None = None,
) -> list[Rule]:
"""List rules filtered by any of the three IDs. All filters are ownership-scoped.
When project_id is set, the result includes both rulebook rules reached via
project_rulebook_subscriptions AND project-scoped rules (Rule.project_id).
When rulebook_id or topic_id is set, project-scoped rules are excluded by
construction (they have neither). With no filter, only rulebook rules are
returned — adding all of a user's project-scoped rules unprompted would
surprise existing callers.
"""
from scribe.models.rulebook import project_rulebook_subscriptions
async with async_session() as session:
stmt = (
select(Rule)
.join(RulebookTopic, Rule.topic_id == RulebookTopic.id)
.join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
.where(
Rulebook.owner_user_id == user_id,
Rule.deleted_at.is_(None),
RulebookTopic.deleted_at.is_(None),
Rulebook.deleted_at.is_(None),
)
)
if topic_id:
stmt = stmt.where(Rule.topic_id == topic_id)
if rulebook_id:
stmt = stmt.where(RulebookTopic.rulebook_id == rulebook_id)
if project_id:
stmt = (
stmt.join(
project_rulebook_subscriptions,
project_rulebook_subscriptions.c.rulebook_id == Rulebook.id,
)
.where(project_rulebook_subscriptions.c.project_id == project_id)
)
stmt = stmt.order_by(
Rulebook.id, RulebookTopic.order_index, Rule.order_index, Rule.title,
)
result = await session.execute(stmt)
rulebook_rules = list(result.scalars().all())
if not project_id:
return rulebook_rules
# Project-scoped rules (topic_id IS NULL, project_id matches).
# Verifies ownership by joining Project on user_id.
from scribe.models.project import Project
proj_stmt = (
select(Rule)
.join(Project, Rule.project_id == Project.id)
.where(
Project.user_id == user_id,
Rule.project_id == project_id,
Rule.deleted_at.is_(None),
Project.deleted_at.is_(None),
)
.order_by(Rule.order_index, Rule.title)
)
proj_result = await session.execute(proj_stmt)
return rulebook_rules + list(proj_result.scalars().all())
async def list_always_on_rules(user_id: int, limit: int = 100) -> list[Rule]:
"""Return all rules from rulebooks flagged always_on for the user.
Called by the MCP tool of the same name at session start to load the
standing rules that apply regardless of which project (if any) is in
scope. Ordering matches list_rules so results are stable across calls.
"""
async with async_session() as session:
result = await session.execute(
select(Rule)
.join(RulebookTopic, Rule.topic_id == RulebookTopic.id)
.join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
.where(
Rulebook.owner_user_id == user_id,
Rulebook.always_on.is_(True),
Rule.deleted_at.is_(None),
RulebookTopic.deleted_at.is_(None),
Rulebook.deleted_at.is_(None),
)
.order_by(
Rulebook.id, RulebookTopic.order_index, Rule.order_index, Rule.title,
)
.limit(limit)
)
return list(result.scalars().all())
async def _fetch_owned_rule(session, rule_id: int, user_id: int) -> Optional[Rule]:
"""Fetch a rule by id, scoped to user owning either its rulebook
(via topic) or its project (via project_id). Honors soft-delete.
Returns None when not found or not owned.
"""
from scribe.models.project import Project
# Path A — rulebook rule.
rulebook_rule = (await session.execute(
select(Rule)
.join(RulebookTopic, Rule.topic_id == RulebookTopic.id)
.join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
.where(
Rule.id == rule_id,
Rulebook.owner_user_id == user_id,
Rule.deleted_at.is_(None),
RulebookTopic.deleted_at.is_(None),
Rulebook.deleted_at.is_(None),
)
)).scalar_one_or_none()
if rulebook_rule is not None:
return rulebook_rule
# Path B — project-scoped rule.
project_rule = (await session.execute(
select(Rule)
.join(Project, Rule.project_id == Project.id)
.where(
Rule.id == rule_id,
Project.user_id == user_id,
Rule.deleted_at.is_(None),
Project.deleted_at.is_(None),
)
)).scalar_one_or_none()
return project_rule
async def get_rule(rule_id: int, user_id: int) -> Optional[Rule]:
async with async_session() as session:
return await _fetch_owned_rule(session, rule_id, user_id)
async def update_rule(rule_id: int, user_id: int, **fields) -> Optional[Rule]:
async with async_session() as session:
rule = await _fetch_owned_rule(session, rule_id, user_id)
if rule is None:
return None
allowed = {"title", "statement", "why", "how_to_apply", "order_index"}
for key, value in fields.items():
if key in allowed and value is not None:
setattr(rule, key, value)
await session.commit()
await session.refresh(rule)
return rule
async def delete_rule(rule_id: int, user_id: int) -> None:
async with async_session() as session:
rule = await _fetch_owned_rule(session, rule_id, user_id)
if rule is None:
return
await session.delete(rule)
await session.commit()
# ── Subscriptions + get_applicable_rules ───────────────────────────────
from sqlalchemy import insert, delete as sql_delete
async def subscribe_project(
project_id: int, rulebook_id: int, user_id: int,
) -> None:
"""Add a subscription. Idempotent — duplicates raise; we swallow."""
from scribe.models.rulebook import project_rulebook_subscriptions
async with async_session() as session:
await _assert_project_owned(session, project_id, user_id)
await _assert_rulebook_owned(session, rulebook_id, user_id)
# ON CONFLICT DO NOTHING via try/except to keep dialect-agnostic.
try:
await session.execute(
insert(project_rulebook_subscriptions).values(
project_id=project_id, rulebook_id=rulebook_id,
)
)
await session.commit()
except Exception:
await session.rollback() # PK collision = already subscribed; fine.
async def unsubscribe_project(
project_id: int, rulebook_id: int, user_id: int,
) -> None:
from scribe.models.rulebook import project_rulebook_subscriptions
async with async_session() as session:
await _assert_project_owned(session, project_id, user_id)
await _assert_rulebook_owned(session, rulebook_id, user_id)
await session.execute(
sql_delete(project_rulebook_subscriptions).where(
project_rulebook_subscriptions.c.project_id == project_id,
project_rulebook_subscriptions.c.rulebook_id == rulebook_id,
)
)
await session.commit()
# ── Suppressions — project-level mute of rulebook rules / topics ────────
async def suppress_rule_for_project(
project_id: int, rule_id: int, user_id: int,
) -> None:
"""Mute one rulebook rule for one project. Idempotent."""
from scribe.models.rulebook import project_rule_suppressions
async with async_session() as session:
await _assert_project_owned(session, project_id, user_id)
await _assert_rulebook_rule_owned(session, rule_id, user_id)
try:
await session.execute(
insert(project_rule_suppressions).values(
project_id=project_id, rule_id=rule_id,
)
)
await session.commit()
except Exception:
await session.rollback() # PK collision = already suppressed; fine.
async def unsuppress_rule_for_project(
project_id: int, rule_id: int, user_id: int,
) -> None:
"""Unmute one rulebook rule for one project. Idempotent."""
from scribe.models.rulebook import project_rule_suppressions
async with async_session() as session:
await _assert_project_owned(session, project_id, user_id)
await session.execute(
sql_delete(project_rule_suppressions).where(
project_rule_suppressions.c.project_id == project_id,
project_rule_suppressions.c.rule_id == rule_id,
)
)
await session.commit()
async def suppress_topic_for_project(
project_id: int, topic_id: int, user_id: int,
) -> None:
"""Mute every rule under one topic for one project. Idempotent."""
from scribe.models.rulebook import project_topic_suppressions
async with async_session() as session:
await _assert_project_owned(session, project_id, user_id)
await _assert_topic_owned(session, topic_id, user_id)
try:
await session.execute(
insert(project_topic_suppressions).values(
project_id=project_id, topic_id=topic_id,
)
)
await session.commit()
except Exception:
await session.rollback()
async def unsuppress_topic_for_project(
project_id: int, topic_id: int, user_id: int,
) -> None:
"""Unmute a topic for one project. Idempotent."""
from scribe.models.rulebook import project_topic_suppressions
async with async_session() as session:
await _assert_project_owned(session, project_id, user_id)
await session.execute(
sql_delete(project_topic_suppressions).where(
project_topic_suppressions.c.project_id == project_id,
project_topic_suppressions.c.topic_id == topic_id,
)
)
await session.commit()
async def get_applicable_rules(
project_id: int, user_id: int, limit: int = 50,
) -> dict:
"""Return rules applicable to a project — both via rulebook subscriptions
and project-scoped rules (Rule.project_id matches), with suppressed rules
and suppressed topics filtered out.
Shape:
{
"rules": [{id, title, statement,
topic_id, topic_title,
rulebook_id, rulebook_title}, ...],
"project_rules": [{id, title, statement}, ...],
"suppressed_rules": [{id, title,
topic_id, topic_title,
rulebook_id, rulebook_title}, ...],
"suppressed_topics": [{id, title,
rulebook_id, rulebook_title}, ...],
"truncated": bool,
"subscribed_rulebooks": [{id, title}, ...]
}
`rules` is the subscription-derived set with project-level suppressions
applied. `project_rules` is the project-scoped set (never suppressed —
delete instead). `suppressed_rules` / `suppressed_topics` carry the
titles + rulebook context callers need to display what was filtered
without round-tripping for names.
"""
from scribe.models.rulebook import (
project_rulebook_subscriptions,
project_rule_suppressions,
project_topic_suppressions,
)
async with async_session() as session:
# Subscribed rulebooks for the project (ownership-scoped).
sub_q = (
select(Rulebook.id, Rulebook.title)
.join(
project_rulebook_subscriptions,
project_rulebook_subscriptions.c.rulebook_id == Rulebook.id,
)
.where(
project_rulebook_subscriptions.c.project_id == project_id,
Rulebook.owner_user_id == user_id,
Rulebook.deleted_at.is_(None),
)
.order_by(Rulebook.title)
)
sub_rows = (await session.execute(sub_q)).all()
subscribed_rulebooks = [
{"id": rb_id, "title": rb_title} for rb_id, rb_title in sub_rows
]
# Suppressed rules — joined to topic + rulebook so callers can render
# context without a follow-up lookup. Ownership-scoped via rulebook.
suppressed_rules_q = (
select(
Rule.id, Rule.title,
RulebookTopic.id.label("topic_id"),
RulebookTopic.title.label("topic_title"),
Rulebook.id.label("rulebook_id"),
Rulebook.title.label("rulebook_title"),
)
.join(project_rule_suppressions, project_rule_suppressions.c.rule_id == Rule.id)
.join(RulebookTopic, Rule.topic_id == RulebookTopic.id)
.join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
.where(
project_rule_suppressions.c.project_id == project_id,
Rulebook.owner_user_id == user_id,
)
.order_by(Rulebook.title, RulebookTopic.title, Rule.title)
)
suppressed_rule_rows = (await session.execute(suppressed_rules_q)).all()
suppressed_rules = [
{"id": rid, "title": rt, "topic_id": ti, "topic_title": tt,
"rulebook_id": rbi, "rulebook_title": rbt}
for rid, rt, ti, tt, rbi, rbt in suppressed_rule_rows
]
suppressed_rule_ids = [r["id"] for r in suppressed_rules]
# Suppressed topics — joined to rulebook for context.
suppressed_topics_q = (
select(
RulebookTopic.id, RulebookTopic.title,
Rulebook.id.label("rulebook_id"),
Rulebook.title.label("rulebook_title"),
)
.join(project_topic_suppressions, project_topic_suppressions.c.topic_id == RulebookTopic.id)
.join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
.where(
project_topic_suppressions.c.project_id == project_id,
Rulebook.owner_user_id == user_id,
)
.order_by(Rulebook.title, RulebookTopic.title)
)
suppressed_topic_rows = (await session.execute(suppressed_topics_q)).all()
suppressed_topics = [
{"id": tid, "title": tt, "rulebook_id": rbi, "rulebook_title": rbt}
for tid, tt, rbi, rbt in suppressed_topic_rows
]
suppressed_topic_ids = [t["id"] for t in suppressed_topics]
# Applicable rules (limit + 1 so we can detect truncation). Filter
# in SQL so truncation reflects the post-suppression count, not the
# raw subscription count.
rules_q = (
select(
Rule.id, Rule.title, Rule.statement,
RulebookTopic.id.label("topic_id"),
RulebookTopic.title.label("topic_title"),
Rulebook.id.label("rulebook_id"),
Rulebook.title.label("rulebook_title"),
)
.join(RulebookTopic, Rule.topic_id == RulebookTopic.id)
.join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
.join(
project_rulebook_subscriptions,
project_rulebook_subscriptions.c.rulebook_id == Rulebook.id,
)
.where(
project_rulebook_subscriptions.c.project_id == project_id,
Rulebook.owner_user_id == user_id,
Rule.deleted_at.is_(None),
RulebookTopic.deleted_at.is_(None),
Rulebook.deleted_at.is_(None),
)
.order_by(
Rulebook.id, RulebookTopic.order_index, Rule.order_index, Rule.title,
)
.limit(limit + 1)
)
if suppressed_rule_ids:
rules_q = rules_q.where(Rule.id.notin_(suppressed_rule_ids))
if suppressed_topic_ids:
rules_q = rules_q.where(Rule.topic_id.notin_(suppressed_topic_ids))
rule_rows = (await session.execute(rules_q)).all()
truncated = len(rule_rows) > limit
rules = [
{
"id": rid, "title": rtitle, "statement": stmt,
"topic_id": ti, "topic_title": tt,
"rulebook_id": rbi, "rulebook_title": rbt,
}
for rid, rtitle, stmt, ti, tt, rbi, rbt in rule_rows[:limit]
]
# Project-scoped rules — verifies ownership via Project.user_id.
from scribe.models.project import Project
proj_rules_q = (
select(Rule.id, Rule.title, Rule.statement)
.join(Project, Rule.project_id == Project.id)
.where(
Project.user_id == user_id,
Rule.project_id == project_id,
Rule.deleted_at.is_(None),
Project.deleted_at.is_(None),
)
.order_by(Rule.order_index, Rule.title)
)
proj_rule_rows = (await session.execute(proj_rules_q)).all()
project_rules = [
{"id": rid, "title": rtitle, "statement": stmt}
for rid, rtitle, stmt in proj_rule_rows
]
return {
"rules": rules,
"project_rules": project_rules,
"suppressed_rules": suppressed_rules,
"suppressed_topics": suppressed_topics,
"truncated": truncated,
"subscribed_rulebooks": subscribed_rulebooks,
}
+63
View File
@@ -0,0 +1,63 @@
import logging
from sqlalchemy import delete as sa_delete, select
from scribe.models import async_session
from scribe.models.setting import Setting
logger = logging.getLogger(__name__)
async def get_setting(user_id: int, key: str, default: str = "") -> str:
async with async_session() as session:
result = await session.execute(
select(Setting).where(Setting.user_id == user_id, Setting.key == key)
)
setting = result.scalar_one_or_none()
return setting.value if setting else default
async def set_setting(user_id: int, key: str, value: str) -> None:
async with async_session() as session:
result = await session.execute(
select(Setting).where(Setting.user_id == user_id, Setting.key == key)
)
setting = result.scalar_one_or_none()
if setting:
setting.value = value
else:
session.add(Setting(user_id=user_id, key=key, value=value))
await session.commit()
async def set_settings_batch(user_id: int, settings: dict[str, str]) -> None:
"""Update multiple settings in a single transaction."""
async with async_session() as session:
for key, value in settings.items():
result = await session.execute(
select(Setting).where(Setting.user_id == user_id, Setting.key == key)
)
setting = result.scalar_one_or_none()
if setting:
setting.value = value
else:
session.add(Setting(user_id=user_id, key=key, value=value))
await session.commit()
logger.info("Batch-updated %d settings for user %d", len(settings), user_id)
async def delete_setting(user_id: int, key: str) -> None:
"""Remove a setting row so get_setting() returns its hardcoded default instead."""
async with async_session() as session:
await session.execute(
sa_delete(Setting).where(Setting.user_id == user_id, Setting.key == key)
)
await session.commit()
async def get_all_settings(user_id: int) -> dict[str, str]:
async with async_session() as session:
result = await session.execute(
select(Setting).where(Setting.user_id == user_id)
)
return {s.key: s.value for s in result.scalars().all()}
+261
View File
@@ -0,0 +1,261 @@
"""Sharing service — create/manage project and note shares."""
import logging
from sqlalchemy import select
from scribe.models import async_session
from scribe.models.group import Group, GroupMembership
from scribe.models.note import Note
from scribe.models.project import Project
from scribe.models.share import NoteShare, ProjectShare
from scribe.models.user import User
logger = logging.getLogger(__name__)
VALID_PERMISSIONS = {"viewer", "editor", "admin"}
async def _enrich_shares(session, shares) -> list[dict]:
"""Attach username / group_name to a list of share rows (ProjectShare or NoteShare)."""
result = []
for s in shares:
d = s.to_dict()
if s.shared_with_user_id:
user = await session.get(User, s.shared_with_user_id)
d["username"] = user.username if user else None
if s.shared_with_group_id:
group = await session.get(Group, s.shared_with_group_id)
d["group_name"] = group.name if group else None
result.append(d)
return result
def _deduplicate_by_permission(shares, id_attr: str) -> dict[int, str]:
"""Return {resource_id: best_permission} keeping the highest-ranked permission per resource."""
from scribe.services.access import PERMISSION_RANK
seen: dict[int, str] = {}
for share in shares:
rid = getattr(share, id_attr)
prev = seen.get(rid)
if prev is None or PERMISSION_RANK[share.permission] > PERMISSION_RANK[prev]:
seen[rid] = share.permission
return seen
# ---------------------------------------------------------------------------
# Project shares
# ---------------------------------------------------------------------------
async def share_project(
acting_user_id: int,
project_id: int,
*,
target_user_id: int | None = None,
target_group_id: int | None = None,
permission: str = "viewer",
) -> ProjectShare | None:
if permission not in VALID_PERMISSIONS:
return None
from scribe.services.access import can_admin_project
if not await can_admin_project(acting_user_id, project_id):
return None
async with async_session() as session:
share = ProjectShare(
project_id=project_id,
shared_with_user_id=target_user_id,
shared_with_group_id=target_group_id,
permission=permission,
invited_by=acting_user_id,
)
session.add(share)
await session.commit()
await session.refresh(share)
return share
async def update_project_share(
acting_user_id: int, share_id: int, permission: str
) -> ProjectShare | None:
if permission not in VALID_PERMISSIONS:
return None
async with async_session() as session:
share = await session.get(ProjectShare, share_id)
if not share:
return None
from scribe.services.access import can_admin_project
if not await can_admin_project(acting_user_id, share.project_id):
return None
share.permission = permission
await session.commit()
await session.refresh(share)
return share
async def remove_project_share(acting_user_id: int, share_id: int) -> bool:
async with async_session() as session:
share = await session.get(ProjectShare, share_id)
if not share:
return False
from scribe.services.access import can_admin_project
if not await can_admin_project(acting_user_id, share.project_id):
return False
await session.delete(share)
await session.commit()
return True
async def list_project_shares(project_id: int) -> list[dict]:
async with async_session() as session:
shares = (await session.execute(
select(ProjectShare).where(ProjectShare.project_id == project_id)
)).scalars().all()
return await _enrich_shares(session, shares)
# ---------------------------------------------------------------------------
# Note shares
# ---------------------------------------------------------------------------
async def share_note(
acting_user_id: int,
note_id: int,
*,
target_user_id: int | None = None,
target_group_id: int | None = None,
permission: str = "viewer",
) -> NoteShare | None:
if permission not in VALID_PERMISSIONS:
return None
from scribe.services.access import get_note_permission
perm = await get_note_permission(acting_user_id, note_id)
if perm not in ("admin", "owner"):
return None
async with async_session() as session:
share = NoteShare(
note_id=note_id,
shared_with_user_id=target_user_id,
shared_with_group_id=target_group_id,
permission=permission,
invited_by=acting_user_id,
)
session.add(share)
await session.commit()
await session.refresh(share)
return share
async def update_note_share(
acting_user_id: int, share_id: int, permission: str
) -> NoteShare | None:
if permission not in VALID_PERMISSIONS:
return None
async with async_session() as session:
share = await session.get(NoteShare, share_id)
if not share:
return None
from scribe.services.access import get_note_permission
perm = await get_note_permission(acting_user_id, share.note_id)
if perm not in ("admin", "owner"):
return None
share.permission = permission
await session.commit()
await session.refresh(share)
return share
async def remove_note_share(acting_user_id: int, share_id: int) -> bool:
async with async_session() as session:
share = await session.get(NoteShare, share_id)
if not share:
return False
from scribe.services.access import get_note_permission
perm = await get_note_permission(acting_user_id, share.note_id)
if perm not in ("admin", "owner"):
return False
await session.delete(share)
await session.commit()
return True
async def list_note_shares(note_id: int) -> list[dict]:
async with async_session() as session:
shares = (await session.execute(
select(NoteShare).where(NoteShare.note_id == note_id)
)).scalars().all()
return await _enrich_shares(session, shares)
# ---------------------------------------------------------------------------
# Shared-with-me
# ---------------------------------------------------------------------------
async def list_shared_with_me(user_id: int) -> dict:
"""All projects and notes shared with the user (directly or via group)."""
async with async_session() as session:
user_group_ids = (await session.execute(
select(GroupMembership.group_id).where(GroupMembership.user_id == user_id)
)).scalars().all()
# --- Projects ---
proj_direct = (await session.execute(
select(ProjectShare).where(ProjectShare.shared_with_user_id == user_id)
)).scalars().all()
proj_group: list[ProjectShare] = []
if user_group_ids:
proj_group = (await session.execute(
select(ProjectShare).where(
ProjectShare.shared_with_group_id.in_(user_group_ids)
)
)).scalars().all()
seen_projects = _deduplicate_by_permission(list(proj_direct) + list(proj_group), "project_id")
projects = []
for pid, perm in seen_projects.items():
proj = await session.get(Project, pid)
if proj:
owner = await session.get(User, proj.user_id)
projects.append({
"id": proj.id,
"title": proj.title,
"description": proj.description,
"status": proj.status,
"color": proj.color,
"updated_at": proj.updated_at.isoformat(),
"owner_username": owner.username if owner else None,
"permission": perm,
})
# --- Notes / Tasks ---
note_direct = (await session.execute(
select(NoteShare).where(NoteShare.shared_with_user_id == user_id)
)).scalars().all()
note_group: list[NoteShare] = []
if user_group_ids:
note_group = (await session.execute(
select(NoteShare).where(
NoteShare.shared_with_group_id.in_(user_group_ids)
)
)).scalars().all()
seen_notes = _deduplicate_by_permission(list(note_direct) + list(note_group), "note_id")
notes = []
for nid, perm in seen_notes.items():
note = await session.get(Note, nid)
if note:
owner = await session.get(User, note.user_id)
notes.append({
"id": note.id,
"title": note.title,
"is_task": note.is_task,
"project_id": note.project_id,
"updated_at": note.updated_at.isoformat(),
"owner_username": owner.username if owner else None,
"permission": perm,
})
return {"projects": projects, "notes": notes}
+84
View File
@@ -0,0 +1,84 @@
"""Task work log service."""
import logging
from datetime import datetime, timezone
from sqlalchemy import select
from scribe.models import async_session
from scribe.models.task_log import TaskLog
from scribe.models.note import Note
logger = logging.getLogger(__name__)
_UNSET = object()
async def create_log(
user_id: int,
task_id: int,
content: str,
duration_minutes: int | None = None,
) -> TaskLog:
async with async_session() as session:
# Verify task exists and belongs to user
result = await session.execute(
select(Note).where(Note.id == task_id, Note.user_id == user_id)
)
if result.scalars().first() is None:
raise ValueError(f"Task {task_id} not found")
log = TaskLog(
task_id=task_id,
user_id=user_id,
content=content,
duration_minutes=duration_minutes,
)
session.add(log)
await session.commit()
await session.refresh(log)
return log
async def list_logs(user_id: int, task_id: int) -> list[TaskLog]:
async with async_session() as session:
result = await session.execute(
select(TaskLog)
.where(TaskLog.task_id == task_id, TaskLog.user_id == user_id)
.order_by(TaskLog.created_at.asc())
)
return list(result.scalars().all())
async def update_log(
user_id: int,
log_id: int,
content: str | None = None,
duration_minutes: object = _UNSET,
) -> TaskLog | None:
async with async_session() as session:
result = await session.execute(
select(TaskLog).where(TaskLog.id == log_id, TaskLog.user_id == user_id)
)
log = result.scalars().first()
if log is None:
return None
if content is not None:
log.content = content
if duration_minutes is not _UNSET:
log.duration_minutes = duration_minutes # type: ignore[assignment]
log.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(log)
return log
async def delete_log(user_id: int, log_id: int) -> bool:
async with async_session() as session:
result = await session.execute(
select(TaskLog).where(TaskLog.id == log_id, TaskLog.user_id == user_id)
)
log = result.scalars().first()
if log is None:
return False
await session.delete(log)
await session.commit()
return True
+276
View File
@@ -0,0 +1,276 @@
"""Soft-delete / trash service — single source of truth for recoverable deletes.
A delete stamps `deleted_at` + a shared `deleted_batch_id` on the target row and
its descendants (cascade). Restore clears the batch; purge hard-deletes it; the
retention cron purges rows older than the configured window. Live reads exclude
trashed rows via `alive()`.
"""
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from sqlalchemy import or_, select, update
from scribe.models import async_session
from scribe.models.note import Note
from scribe.models.event import Event
from scribe.models.project import Project
from scribe.models.milestone import Milestone
from scribe.models.rulebook import Rulebook, RulebookTopic, Rule
# entity_type -> Model. Used to resolve which table a trash op targets.
_MODEL_FOR = {
"note": Note,
"task": Note,
"event": Event,
"project": Project,
"milestone": Milestone,
"rulebook": Rulebook,
"topic": RulebookTopic,
"rule": Rule,
}
def _owner_clause(model, user_id: int):
"""Boolean expr scoping `model` rows to the ones `user_id` owns.
EVERY trash query (exists-check, restore, purge, list, retention sweep)
must carry this — a batch_id is a bearer token, so without an owner
predicate a leaked/guessed id lets one tenant read, restore, or
permanently destroy another's content. Topics and rules carry no
user_id of their own; ownership is derived through the parent rulebook
(or, for project-scoped rules, the owning project).
"""
if model is Rulebook:
return Rulebook.owner_user_id == user_id
if model is RulebookTopic:
return RulebookTopic.rulebook_id.in_(
select(Rulebook.id).where(Rulebook.owner_user_id == user_id)
)
if model is Rule:
return or_(
Rule.topic_id.in_(
select(RulebookTopic.id)
.join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
.where(Rulebook.owner_user_id == user_id)
),
Rule.project_id.in_(
select(Project.id).where(Project.user_id == user_id)
),
)
# Note, Event, Project, Milestone all carry user_id directly.
return model.user_id == user_id
async def _set(session, model, where, batch, now) -> None:
"""Stamp deleted_at + batch on live rows matching `where`."""
await session.execute(
update(model)
.where(*where, model.deleted_at.is_(None))
.values(deleted_at=now, deleted_batch_id=batch)
)
async def _exists_alive(session, user_id: int, etype: str, eid: int) -> bool:
model = _MODEL_FOR[etype]
where = [model.id == eid, model.deleted_at.is_(None), _owner_clause(model, user_id)]
return (await session.execute(select(model.id).where(*where))).first() is not None
async def _cascade(session, user_id: int, etype: str, eid: int, batch: str, now) -> None:
if etype == "project":
await _set(session, Note, [Note.user_id == user_id, Note.project_id == eid], batch, now)
await _set(session, Milestone, [Milestone.user_id == user_id, Milestone.project_id == eid], batch, now)
# Project-scoped rules cascade with the project they're attached to.
await _set(session, Rule, [Rule.project_id == eid], batch, now)
# Suppressions are pure associations (no deleted_at) — hard-delete
# them here so restoring the project doesn't bring stale mutes back.
# FK CASCADE would handle a full DELETE on the project row, but the
# soft-delete path keeps the project row alive; this guarantees the
# rows are gone whether or not the project ever gets purged.
from sqlalchemy import delete as _sql_delete
from scribe.models.rulebook import (
project_rule_suppressions, project_topic_suppressions,
)
await session.execute(
_sql_delete(project_rule_suppressions)
.where(project_rule_suppressions.c.project_id == eid)
)
await session.execute(
_sql_delete(project_topic_suppressions)
.where(project_topic_suppressions.c.project_id == eid)
)
await _set(session, Project, [Project.user_id == user_id, Project.id == eid], batch, now)
elif etype == "milestone":
await _set(session, Note, [Note.user_id == user_id, Note.milestone_id == eid], batch, now)
await _set(session, Milestone, [Milestone.user_id == user_id, Milestone.id == eid], batch, now)
elif etype in ("note", "task"):
# Stamp the entire sub-task subtree (not just direct children) so a
# deeply nested task and all its descendants trash/restore as one batch.
ids = [eid]
frontier = [eid]
while frontier:
children = (await session.execute(
select(Note.id).where(
Note.user_id == user_id,
Note.parent_id.in_(frontier),
Note.deleted_at.is_(None),
)
)).scalars().all()
frontier = [c for c in children if c not in ids]
ids.extend(frontier)
await _set(session, Note, [Note.user_id == user_id, Note.id.in_(ids)], batch, now)
elif etype == "event":
await _set(session, Event, [Event.user_id == user_id, Event.id == eid], batch, now)
elif etype == "rulebook":
topic_ids = (await session.execute(
select(RulebookTopic.id)
.join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
.where(Rulebook.id == eid, Rulebook.owner_user_id == user_id)
)).scalars().all()
if topic_ids:
await _set(session, Rule, [Rule.topic_id.in_(topic_ids)], batch, now)
await _set(session, RulebookTopic, [RulebookTopic.rulebook_id == eid], batch, now)
await _set(session, Rulebook, [Rulebook.id == eid, Rulebook.owner_user_id == user_id], batch, now)
elif etype == "topic":
await _set(session, Rule, [Rule.topic_id == eid], batch, now)
await _set(session, RulebookTopic, [RulebookTopic.id == eid], batch, now)
elif etype == "rule":
await _set(session, Rule, [Rule.id == eid], batch, now)
else:
raise ValueError(f"unknown entity_type: {etype!r}")
async def delete(user_id: int, entity_type: str, entity_id: int) -> str | None:
"""Soft-delete an entity + its descendants under one batch_id.
Returns the batch_id, or None if the entity wasn't found / not owned.
"""
batch = str(uuid.uuid4())
now = datetime.now(timezone.utc)
caldav_event: tuple[str, str] | None = None
async with async_session() as session:
if not await _exists_alive(session, user_id, entity_type, entity_id):
return None
# Capture CalDAV linkage before soft-deleting so we can propagate the
# deletion to the external server (the row stays present locally).
if entity_type == "event":
row = (await session.execute(
select(Event.caldav_uid, Event.title).where(
Event.id == entity_id, Event.user_id == user_id
)
)).first()
if row and row[0]:
caldav_event = (row[0], row[1])
await _cascade(session, user_id, entity_type, entity_id, batch, now)
await session.commit()
# Without this the soft-delete only hides the event locally and the remote
# copy lingers forever (and re-appears on any client syncing that server).
if caldav_event:
import asyncio
from scribe.services.events import _push_delete
asyncio.create_task(_push_delete(caldav_event[0], caldav_event[1], user_id))
return batch
# All soft-deletable models, and their trash-listing type label.
_ALL = [Note, Event, Project, Milestone, Rulebook, RulebookTopic, Rule]
_TYPE = {
Note: "note", Event: "event", Project: "project", Milestone: "milestone",
Rulebook: "rulebook", RulebookTopic: "topic", Rule: "rule",
}
def alive(stmt, model):
"""Append a 'not trashed' filter to a select. Use in every live read."""
return stmt.where(model.deleted_at.is_(None))
async def restore(user_id: int, batch_id: str) -> int:
"""Clear deleted_at for every row in the batch. Returns rows restored."""
n = 0
async with async_session() as session:
for model in _ALL:
res = await session.execute(
update(model)
.where(model.deleted_batch_id == batch_id, _owner_clause(model, user_id))
.values(deleted_at=None, deleted_batch_id=None)
)
n += res.rowcount or 0
await session.commit()
return n
async def purge(user_id: int, batch_id: str) -> int:
"""Hard-delete every row in the batch. Irreversible."""
from sqlalchemy import delete as sql_delete
n = 0
async with async_session() as session:
for model in _ALL:
res = await session.execute(
sql_delete(model).where(
model.deleted_batch_id == batch_id, _owner_clause(model, user_id)
)
)
n += res.rowcount or 0
await session.commit()
return n
async def list_trash(user_id: int) -> list[dict]:
"""Trashed rows across all soft-deletable tables, grouped by batch_id."""
batches: dict[str, dict] = {}
async with async_session() as session:
for model in _ALL:
rows = (await session.execute(
select(model).where(
model.deleted_at.isnot(None), _owner_clause(model, user_id)
)
)).scalars().all()
for r in rows:
grp = batches.setdefault(
r.deleted_batch_id,
{"batch_id": r.deleted_batch_id,
"deleted_at": r.deleted_at.isoformat() if r.deleted_at else None,
"items": []},
)
grp["items"].append({
"type": _TYPE[model], "id": r.id,
"title": getattr(r, "title", "") or "",
})
out = list(batches.values())
for g in out:
lead = g["items"][0]
g["summary"] = lead["title"] or f"{lead['type']} {lead['id']}"
g["count"] = len(g["items"])
out.sort(key=lambda g: g["deleted_at"] or "", reverse=True)
return out
async def purge_expired(user_id: int, retention_days: int) -> int:
"""Cron entry: hard-delete THIS user's rows trashed more than retention_days ago.
Scoped to one owner so the scheduler can apply each user's own
`trash_retention_days` window — a single global sweep would let one
user's short window prematurely destroy another's data.
retention_days <= 0 disables auto-purge (returns 0 without touching anything).
"""
from datetime import timedelta
from sqlalchemy import delete as sql_delete
if retention_days <= 0:
return 0
cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days)
n = 0
async with async_session() as session:
for model in _ALL:
res = await session.execute(
sql_delete(model).where(
model.deleted_at.isnot(None),
model.deleted_at < cutoff,
_owner_clause(model, user_id),
)
)
n += res.rowcount or 0
await session.commit()
return n
+80
View File
@@ -0,0 +1,80 @@
"""Daily APScheduler cron that purges expired trash.
Mirrors version_pinning_scheduler.py: a single global BackgroundScheduler job
at 03:30 UTC bridges into the asyncio loop to run the async purge. Iterates
every user and applies that user's own `trash_retention_days` setting; 0
disables auto-purge for that user.
"""
from __future__ import annotations
import asyncio
import logging
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from scribe.services import trash as trash_svc
from scribe.services.settings import get_setting
logger = logging.getLogger(__name__)
_scheduler: BackgroundScheduler | None = None
_loop: asyncio.AbstractEventLoop | None = None
def _run_purge_threadsafe() -> None:
"""APScheduler invokes this from a worker thread; bridge into the loop."""
if _loop is None:
logger.warning("trash scheduler: no loop registered")
return
async def _runner():
try:
from sqlalchemy import select
from scribe.models import async_session
from scribe.models.user import User
async with async_session() as session:
user_ids = (await session.execute(select(User.id))).scalars().all()
purged = 0
for uid in user_ids:
raw = await get_setting(uid, "trash_retention_days", "90")
try:
days = int(raw)
except (TypeError, ValueError):
days = 90
purged += await trash_svc.purge_expired(uid, days)
if purged:
logger.info("trash purge: removed %d expired row(s)", purged)
else:
logger.debug("trash purge: nothing expired")
except Exception:
logger.exception("trash purge run failed")
asyncio.run_coroutine_threadsafe(_runner(), _loop)
def start_trash_scheduler(loop: asyncio.AbstractEventLoop) -> None:
global _scheduler, _loop
if _scheduler is not None:
return
_loop = loop
_scheduler = BackgroundScheduler()
_scheduler.add_job(
_run_purge_threadsafe,
trigger=CronTrigger(hour=3, minute=30, timezone="UTC"),
id="trash_retention_purge",
replace_existing=True,
)
_scheduler.start()
logger.info("Trash retention scheduler started (daily 03:30 UTC)")
def stop_trash_scheduler() -> None:
global _scheduler
if _scheduler is not None:
_scheduler.shutdown(wait=False)
_scheduler = None
logger.info("Trash retention scheduler stopped")
+52
View File
@@ -0,0 +1,52 @@
"""User-timezone helpers.
All datetimes in the DB are stored as UTC. The helpers here bridge between
that UTC storage and the user's configured local timezone (IANA string in
the ``user_timezone`` setting). Use these anywhere the model or UI talks
in terms of "today", "tomorrow", or a bare calendar date — never
``date.today()`` or ``datetime.now(timezone.utc)`` inside a per-user flow.
"""
from __future__ import annotations
from datetime import date, datetime, timedelta
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from scribe.services.settings import get_setting
# Day-rollover boundary for journal/day-anchored views. The "day" flips at
# this local hour (not midnight) so the 00:0004:00 local window still
# shows yesterday's content until the user "starts" the new day.
DAY_ROLLOVER_HOUR = 4
# Backwards-compat alias for code paths still referencing the old name.
BRIEFING_DAY_START_HOUR = DAY_ROLLOVER_HOUR
async def get_user_tz(user_id: int) -> ZoneInfo:
"""Return the user's IANA ``ZoneInfo``, falling back to UTC."""
tz_str = await get_setting(user_id, "user_timezone") or "UTC"
try:
return ZoneInfo(tz_str)
except (ZoneInfoNotFoundError, KeyError):
return ZoneInfo("UTC")
async def user_today(user_id: int) -> date:
"""Return today's calendar date in the user's local timezone."""
tz = await get_user_tz(user_id)
return datetime.now(tz).date()
async def user_day_date(user_id: int) -> date:
"""Return the current "day" in the user's local timezone.
The day flips at ``DAY_ROLLOVER_HOUR`` (4am local) rather than midnight,
so the 00:0004:00 local window still returns *yesterday* — the user's
journal stays anchored to yesterday until they cross the rollover.
"""
tz = await get_user_tz(user_id)
return (datetime.now(tz) - timedelta(hours=DAY_ROLLOVER_HOUR)).date()
# Backwards-compat alias used by briefing-only modules being torn down in Stage B.
user_briefing_date = user_day_date
+56
View File
@@ -0,0 +1,56 @@
"""User profile service — structured per-user preferences.
Post-pivot the LLM-driven observation/consolidation surface is gone (curator
deleted in Phase 8). The profile model is preserved as user-managed metadata
(name, job, expertise, style, tone, interests, work schedule) — Claude can
read it via the upcoming MCP profile tools.
"""
import logging
from sqlalchemy import select
from scribe.models import async_session
from scribe.models.user_profile import UserProfile
logger = logging.getLogger(__name__)
VALID_EXPERTISE = {"novice", "intermediate", "expert"}
VALID_STYLES = {"concise", "balanced", "detailed"}
VALID_TONES = {"casual", "professional", "technical"}
async def get_profile(user_id: int) -> UserProfile:
"""Get or create the profile row for a user."""
async with async_session() as session:
result = await session.execute(
select(UserProfile).where(UserProfile.user_id == user_id)
)
profile = result.scalar_one_or_none()
if profile is None:
profile = UserProfile(user_id=user_id)
session.add(profile)
await session.commit()
await session.refresh(profile)
return profile
async def update_profile(user_id: int, data: dict) -> UserProfile:
"""Upsert structured profile fields from a validated dict."""
allowed = {
"display_name", "job_title", "industry", "expertise_level",
"response_style", "tone", "interests", "work_schedule",
}
async with async_session() as session:
result = await session.execute(
select(UserProfile).where(UserProfile.user_id == user_id)
)
profile = result.scalar_one_or_none()
if profile is None:
profile = UserProfile(user_id=user_id)
session.add(profile)
for key, value in data.items():
if key in allowed:
setattr(profile, key, value)
await session.commit()
await session.refresh(profile)
return profile
+243
View File
@@ -0,0 +1,243 @@
"""Pin/unpin and auto-pin operations on NoteVersion.
The autosave-rolling system in `services/note_versions.py` continues to
own the existing rolling-cap behavior; this module adds the manual-pin
API and the daily auto-pin scan.
Tiers (pin_kind values):
None → rolling autosave; capped at MAX_VERSIONS, FIFO.
"auto" → system-declared via the stability scan; capped at MAX_AUTO_PINS, FIFO.
"manual" → user-declared; unlimited, never pruned.
Design: docs/superpowers/specs/2026-05-13-note-version-pinning-design.md
"""
from __future__ import annotations
import datetime
import logging
from datetime import timezone
from sqlalchemy import select, text
from scribe.models import async_session
from scribe.models.note_version import NoteVersion
logger = logging.getLogger(__name__)
MAX_AUTO_PINS = 25
AUTO_PIN_STABILITY_DAYS = 2
PIN_LABEL_MAX_LEN = 500
async def pin_version(
user_id: int, note_id: int, version_id: int, *, label: str | None,
) -> NoteVersion | None:
"""Mark a version as manually pinned. Returns the updated row or None
if not found (or wrong user/note scope).
Acceptable on already-pinned rows (manual or auto) — promotes to
manual and updates the label. Labels are capped at PIN_LABEL_MAX_LEN
chars; longer values raise ValueError.
"""
if label is not None and len(label) > PIN_LABEL_MAX_LEN:
raise ValueError(
f"pin_label too long ({len(label)} > {PIN_LABEL_MAX_LEN} chars)"
)
async with async_session() as session:
version = (
await session.execute(
select(NoteVersion).where(
NoteVersion.id == version_id,
NoteVersion.note_id == note_id,
NoteVersion.user_id == user_id,
)
)
).scalars().first()
if version is None:
return None
version.pin_kind = "manual"
version.pin_label = label
await session.commit()
await session.refresh(version)
return version
async def unpin_version(
user_id: int, note_id: int, version_id: int,
) -> NoteVersion | None:
"""Clear pin_kind and pin_label, downgrading the row to rolling.
Does NOT delete the row. If the row is older than the rolling cap
depth, the next create_version call will prune it via the rolling
FIFO. Returns the updated row or None if not found.
"""
async with async_session() as session:
version = (
await session.execute(
select(NoteVersion).where(
NoteVersion.id == version_id,
NoteVersion.note_id == note_id,
NoteVersion.user_id == user_id,
)
)
).scalars().first()
if version is None:
return None
version.pin_kind = None
version.pin_label = None
await session.commit()
await session.refresh(version)
return version
def _format_auto_pin_label(
start: datetime.datetime, end: datetime.datetime | None,
) -> str:
"""Auto-generated label describing the stability window.
end=None → version is the latest with no successor; render as
"stable since {start_iso}". Otherwise render as
"stable {start_iso}{end_iso}".
"""
s = start.date().isoformat()
if end is None:
return f"stable since {s}"
return f"stable {s}{end.date().isoformat()}"
def _promote_stable_versions_for_note(versions_chrono: list) -> list:
"""Mutate the input list: set pin_kind='auto' + auto-label on any
unpinned version whose gap to its successor (or to now, for the
latest) is >= AUTO_PIN_STABILITY_DAYS.
Returns the list of versions that were newly pinned (caller uses
this for logging / counts).
"""
now = datetime.datetime.now(timezone.utc)
newly_pinned: list = []
for i, v in enumerate(versions_chrono):
if v.pin_kind is not None:
continue
if i + 1 < len(versions_chrono):
next_ts = versions_chrono[i + 1].created_at
is_latest = False
else:
next_ts = now
is_latest = True
v_ts = v.created_at
if v_ts.tzinfo is None:
v_ts = v_ts.replace(tzinfo=timezone.utc)
if next_ts.tzinfo is None:
next_ts = next_ts.replace(tzinfo=timezone.utc)
gap_days = (next_ts - v_ts).total_seconds() / 86400
if gap_days >= AUTO_PIN_STABILITY_DAYS:
v.pin_kind = "auto"
v.pin_label = _format_auto_pin_label(
v_ts, None if is_latest else next_ts,
)
newly_pinned.append(v)
return newly_pinned
async def _list_user_note_ids_with_versions(user_id: int) -> list[int]:
"""Return note_ids belonging to user_id that have at least one row in
note_versions. Notes with no version history are ignored."""
async with async_session() as session:
rows = (
await session.execute(
text(
"""
SELECT DISTINCT note_id FROM note_versions
WHERE user_id = :user_id
"""
).bindparams(user_id=user_id)
)
).all()
return [r[0] for r in rows]
async def _scan_one_note(user_id: int, note_id: int) -> int:
"""Run the promote+prune flow for one note. Returns count of newly-
pinned versions."""
async with async_session() as session:
versions = (
await session.execute(
select(NoteVersion)
.where(
NoteVersion.note_id == note_id,
NoteVersion.user_id == user_id,
)
.order_by(NoteVersion.created_at.asc())
)
).scalars().all()
if not versions:
return 0
newly_pinned = _promote_stable_versions_for_note(list(versions))
if newly_pinned:
await session.commit()
if newly_pinned:
await prune_auto_pins(user_id=user_id, note_id=note_id)
return len(newly_pinned)
async def scan_user_for_auto_pins(user_id: int) -> int:
"""Run the scan across every versioned note for one user. Returns
total newly-pinned-this-pass."""
total = 0
note_ids = await _list_user_note_ids_with_versions(user_id)
for note_id in note_ids:
try:
total += await _scan_one_note(user_id, note_id)
except Exception:
logger.exception(
"auto-pin scan failed for user=%d note=%d", user_id, note_id,
)
return total
async def scan_all_users_for_auto_pins() -> dict[int, int]:
"""Top-level scan entrypoint. Iterates over all users and runs the
per-user scan. Returns {user_id: newly_pinned_count}. Per-user errors
are caught and logged so one user's failure doesn't stop the scan."""
from scribe.models import User
async with async_session() as session:
users = (await session.execute(select(User.id))).scalars().all()
out: dict[int, int] = {}
for uid in users:
try:
out[uid] = await scan_user_for_auto_pins(uid)
except Exception:
logger.exception("auto-pin scan failed for user=%d", uid)
out[uid] = 0
return out
async def prune_auto_pins(user_id: int, note_id: int) -> None:
"""FIFO-prune the auto-pinned bucket for one note past MAX_AUTO_PINS.
Manual pins and rolling rows are untouched. Called by the scan job
after each note's auto-pin promotions finish.
"""
async with async_session() as session:
await session.execute(
text(
"""
DELETE FROM note_versions
WHERE id IN (
SELECT id FROM note_versions
WHERE note_id = :note_id
AND user_id = :user_id
AND pin_kind = 'auto'
ORDER BY created_at DESC
OFFSET :max_auto_pins
)
"""
).bindparams(
note_id=note_id,
user_id=user_id,
max_auto_pins=MAX_AUTO_PINS,
)
)
await session.commit()
@@ -0,0 +1,72 @@
"""Daily APScheduler cron for the auto-pin scan.
Single global job at 03:00 UTC. Runs scan_all_users_for_auto_pins so the
system promotes stable note versions before they get aged out of the
rolling cap. Off-hours by design — the scan is cheap but not time-
critical and doesn't need to interrupt regular activity.
Mirrors the BackgroundScheduler + threadsafe-async-call pattern used by
journal_scheduler.py.
"""
from __future__ import annotations
import asyncio
import logging
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from scribe.services.version_pinning import scan_all_users_for_auto_pins
logger = logging.getLogger(__name__)
_scheduler: BackgroundScheduler | None = None
_loop: asyncio.AbstractEventLoop | None = None
def _run_scan_threadsafe() -> None:
"""APScheduler invokes this from a worker thread; bridge into the
asyncio loop so the scan can await its DB operations."""
if _loop is None:
logger.warning("version_pinning scheduler: no loop registered")
return
async def _runner():
try:
results = await scan_all_users_for_auto_pins()
total = sum(results.values())
if total > 0:
logger.info(
"auto-pin scan: pinned %d version(s) across %d user(s)",
total, len(results),
)
else:
logger.debug("auto-pin scan: no new pins")
except Exception:
logger.exception("auto-pin scan run failed")
asyncio.run_coroutine_threadsafe(_runner(), _loop)
def start_version_pinning_scheduler(loop: asyncio.AbstractEventLoop) -> None:
global _scheduler, _loop
if _scheduler is not None:
return
_loop = loop
_scheduler = BackgroundScheduler()
_scheduler.add_job(
_run_scan_threadsafe,
trigger=CronTrigger(hour=3, minute=0, timezone="UTC"),
id="version_pinning_auto_scan",
replace_existing=True,
)
_scheduler.start()
logger.info("Version pinning scheduler started (daily 03:00 UTC)")
def stop_version_pinning_scheduler() -> None:
global _scheduler
if _scheduler is not None:
_scheduler.shutdown(wait=False)
_scheduler = None
logger.info("Version pinning scheduler stopped")