Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions packages/core/openexecutive/api/routes/contacts.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
- ``POST /contacts/changes/{id}/accept`` / ``/dismiss`` — Add / Not now on a
suggestion.
- ``POST /contacts/changes/seen`` — Got it: clears the card.
- ``GET /contacts/{person_id}/note`` — what the Executive remembers about one
person, contact or teammate (``people.contact_notes``): 404 when it has
nothing yet. The principal's alone, whoever it is about.
Contacts are the principal's alone, so every route answers only a caller
that resolves to the principal (``people.caller_is_principal``): 404 for
Expand All @@ -19,6 +22,7 @@

from openexecutive.api.routes.people import caller_is_principal
from openexecutive.people import contact_changes as changes
from openexecutive.people import contact_notes

router = APIRouter()

Expand Down Expand Up @@ -89,3 +93,15 @@ def post_contact_change_accept(change_id: int, request: Request) -> changes.Cont
@router.post("/contacts/changes/{change_id}/dismiss", response_model=changes.ContactChange)
def post_contact_change_dismiss(change_id: int, request: Request) -> changes.ContactChange:
return _decide(change_id, request, "dismiss")


@router.get("/contacts/{person_id}/note", response_model=contact_notes.ContactNote)
def get_contact_note(person_id: int, request: Request) -> contact_notes.ContactNote:
Comment thread
johnrufusone marked this conversation as resolved.
_require_principal(request)
from openexecutive.people import store as people_store

person = people_store.get_person(person_id)
note = contact_notes.get(person_id) if person is not None and not person.is_principal else None
if note is None or person is None or person.archived:
raise HTTPException(status_code=404, detail="Not found")
Comment thread
johnrufusone marked this conversation as resolved.
return note
64 changes: 59 additions & 5 deletions packages/core/openexecutive/api/routes/people.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,13 @@
from fastapi import APIRouter, HTTPException, Request, Response, status
from pydantic import BaseModel, Field

from openexecutive.people import contact_info
from openexecutive.people import registry as people_registry
from openexecutive.people import store as people_store
from openexecutive.people.models import (
AuthorityScope,
AvailabilityWindow,
ContactDetail,
Person,
PersonKind,
)
Expand Down Expand Up @@ -59,6 +61,8 @@ class PersonCreate(BaseModel):
slack_user_id: str | None = None
telegram_chat_id: str | None = None
discord_user_id: str | None = None
# Phone, website, profiles, postal address (``Person.details``).
details: list[ContactDetail] = Field(default_factory=list, max_length=20)
Comment thread
johnrufusone marked this conversation as resolved.
Comment thread
johnrufusone marked this conversation as resolved.
preferred_channel: str = "any"
response_sla_hours: int = Field(default=24, ge=1, le=8760)
on_leave_until: date | None = None
Expand All @@ -77,6 +81,8 @@ class PersonPatch(BaseModel):
slack_user_id: str | None = None
telegram_chat_id: str | None = None
discord_user_id: str | None = None
# The full list; replaces the current one. Omit to leave it unchanged.
details: list[ContactDetail] | None = Field(default=None, max_length=20)
preferred_channel: str | None = None
response_sla_hours: int | None = Field(default=None, ge=1, le=8760)
on_leave_until: date | None = None
Expand All @@ -91,6 +97,28 @@ class PersonPatch(BaseModel):
# Who is asking (contacts are the principal's alone)
# --------------------------------------------------------------------------- #

_DETAIL_NAMES = {
"phone": "a phone number", "mobile": "a mobile number", "website": "a website",
"linkedin": "a LinkedIn profile", "social": "a profile link", "address": "an address",
}


def _checked_details(details: list[ContactDetail]) -> list[ContactDetail]:
"""Each detail as stored, blanks dropped; 422 naming the first that
isn't plainly its kind."""
out: list[ContactDetail] = []
for detail in details:
if not detail.value.strip():
continue
value = contact_info.clean_detail(detail.kind, detail.value)
if not value:
raise HTTPException(
status_code=422, detail=f"\"{detail.value[:60]}\" doesn't look like {_DETAIL_NAMES[detail.kind]}.",
)
out.append(ContactDetail(kind=detail.kind, value=value))
return out


def caller_is_principal(request: Request) -> bool:
"""Whether the caller resolves to the principal: their signed-in email is
the principal's, or there is no ``x-caller-email`` (the CLI, direct curl,
Expand All @@ -112,7 +140,25 @@ def _visible_person(person_id: int, request: Request) -> Person:
person = people_store.get_person(person_id)
if person is None or (person.kind != "team" and not caller_is_principal(request)):
raise HTTPException(status_code=404, detail="Person not found")
return person
return _shown([person], request)[0]


def _shown(people: list[Person], request: Request) -> list[Person]:
Comment thread
johnrufusone marked this conversation as resolved.
"""``people`` as the caller may see them: a person's phone, website and
other details (some read from their email signature) go only to the
principal and to that person. Fails closed."""
from openexecutive.api.routes.chat import _resolve_caller_person_id

try:
caller = _resolve_caller_person_id(request)
principal = people_store.is_principal_or_self(caller, None)
except Exception:
logger.exception("people: caller check failed — details stay hidden")
caller, principal = None, False
if principal:
return people
return [p if not p.details or (caller is not None and p.id == caller) else p.model_copy(update={"details": []})
for p in people]


class PeopleViewer(BaseModel):
Expand All @@ -128,11 +174,11 @@ class PeopleViewer(BaseModel):
def list_people(
request: Request, include_archived: bool = False, include_contacts: bool = False
) -> list[Person]:
return people_store.list_people(
return _shown(people_store.list_people(
include_archived=include_archived,
# Anyone but the principal gets the team, as if no contact existed.
include_contacts=include_contacts and caller_is_principal(request),
)
), request)


@router.get("/people/me", response_model=PeopleViewer)
Expand All @@ -148,7 +194,7 @@ def people_viewer(request: Request) -> PeopleViewer:


@router.get("/people/by-scope/{token}", response_model=list[Person])
def people_by_scope(token: str) -> list[Person]:
def people_by_scope(token: str, request: Request) -> list[Person]:
"""Return non-archived people who can approve the given scope token."""
try:
scope = AuthorityScope(token)
Expand All @@ -157,7 +203,7 @@ def people_by_scope(token: str) -> list[Person]:
status_code=400,
detail=f"Unknown scope token: {token!r}. Valid tokens: {[s.value for s in AuthorityScope]}",
) from exc
return people_store.find_approvers(scope)
return _shown(people_store.find_approvers(scope), request)


# --------------------------------------------------------------------------- #
Expand Down Expand Up @@ -347,6 +393,8 @@ def _require_roster_owner(request: Request) -> None:
@router.post("/people", response_model=Person, status_code=status.HTTP_201_CREATED)
def create_person(body: PersonCreate, request: Request) -> Person:
_require_roster_owner(request)
# Checked before anything is written, so a bad one changes nothing.
details = _checked_details(body.details)
if body.is_principal and body.kind != "team":
raise HTTPException(status_code=422, detail=_PRINCIPAL_CONTACT_DETAIL)
if body.kind != "team" and not caller_is_principal(request):
Expand All @@ -371,6 +419,8 @@ def create_person(body: PersonCreate, request: Request) -> Person:
reports_to_person_id=body.reports_to_person_id,
kind=body.kind,
)
if details:
people_store.set_person_details(pid, details)
if body.authority_scope:
people_store.set_authority_scope(pid, body.authority_scope)
if body.availability:
Expand Down Expand Up @@ -423,6 +473,8 @@ def _after_roster_write() -> None:
@router.patch("/people/{person_id}", response_model=Person)
def patch_person(person_id: int, body: PersonPatch, request: Request) -> Person:
_require_roster_owner(request)
# Checked before anything is written, so a bad one changes nothing.
details = _checked_details(body.details) if body.details is not None else None
existing = _visible_person(person_id, request)
if body.kind is not None and body.kind != "team" and existing.is_principal:
raise HTTPException(status_code=422, detail=_PRINCIPAL_CONTACT_DETAIL)
Expand Down Expand Up @@ -455,6 +507,8 @@ def patch_person(person_id: int, body: PersonPatch, request: Request) -> Person:
department_slugs=body.department_slugs,
kind=body.kind,
)
if details is not None:
people_store.set_person_details(person_id, details)
if "authority_scope" in raw:
people_store.set_authority_scope(
person_id, body.authority_scope or []
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4370,6 +4370,48 @@ people:
recorded with reason asked so it has an Undo. Routes /contacts/* answer
caller_is_principal only (404 otherwise). /today/activity gives the
principal one contacts_kept row per day with changes.
people.contact_notes keeps what the Executive remembers about each
contact (contact_notes, one row per person): last emailed, last heard
from (newest authenticated mail),
last met, next meeting and its title, counts, and a note of at most 400
characters with up to 6 topics. Why the principal's own words only: the
note is written by one forced-tool call (ROUTING_MODEL) from their own
sent mail to the contact (sender_new_text, no quoted mail, no
ghostwritten sends) and the titles of meetings they organised, so a
contact's mail cannot write it, and it can be read in chat like the
principal's own sent mail. Rewritten only when the set of those
messages and meetings changes (activity_key), capped 5 per pass and 20
model calls a day (contact_note_tries counts every call, failed or
rejected too); a note with a link, address or tag is rejected and the
old note kept.
A pass whose calendar read fails keeps the stored next meeting and
meeting count (put_activity meetings_known=False).
last_heard_at is read for up to HEARD_READS_PER_RUN (20) people a pass,
independent of the note budget. Read on the principal's own turn by list_people and
ask_about_person (contacts_reachable_now), and by GET
/contacts/{person_id}/note (principal only). archive_person deletes it.
Team members get the same note and signature read (role when empty and
contact details, never name or addresses: TEAM_FIELDS); a teammate's
note is still the principal's alone, from the principal's own mailbox,
never teammate_mail or their peer memory.
Contact details (person_details; Person.details: phone, mobile,
website, linkedin, social, address) come from the same signature call.
Why checked in code: each is kept only when the mail writes it (phone
digits as one number, every word of an address, a link's host+path), a
website only on the sender's own non-freemail domain or its subdomains
(never a parent such as co.uk), a profile only on
SOCIAL_HOSTS / linkedin.com (people.contact_info.detail_from_text). One
per kind is the keeper's (last_applied); a newer value replaces it,
a kind someone typed a value for is left alone. Each is a contact_changes row (field = kind) with Undo
that removes it and restores the one it replaced. Details are for the
reader only: never an egress channel.
Who sees them: the people routes (GET /people, /people/{id},
/people/by-scope) return details only to the principal and to that
person (api.routes.people._shown); anyone else gets details empty, so a
number read from a teammate's signature isn't handed to the whole team.
A refused mailbox read (GmailAuthError / GmailRateLimited) stops that
run's signature and last-heard reads but not its notes; the run is then
still recorded as a mailbox failure (raised after the notes).
aliases: |
A Person has one PRIMARY address (`Person.email`, the one they sign in
with) and up to 10 ALIASES (`Person.email_aliases`, table
Expand Down

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions packages/core/openexecutive/architecture/prebuilt/api.json

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions packages/core/openexecutive/architecture/prebuilt/org.json

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Loading
Loading