Documentation
¶
Overview ¶
Package person is the read model of people: every human and every byline is an entity of the builtin `person` content type.
A person is a normal entity (name, slug, attributes) so it gets a URL, a sitemap entry and revisions like any other content. Entities credit one through hatamoto_entities.byline_person_id (or byline_org for the site organisation), and a user account owns at most one person through hatamoto_user_people. Nothing here reads or exposes a user's email: only the person's own `public_email` attribute is ever public.
hatamoto_authors is kept only so a rollback still renders bylines; it is not the source of bylines any more (see internal/author).
Index ¶
- Constants
- Variables
- func Backfill(ctx context.Context, db SQLDB) error
- func Complete(p *Person) bool
- func Duplicates(people []*Person) [][]*Person
- type Byline
- func (b *Byline) Check(ctx context.Context, id uuid.UUID) error
- func (b *Byline) Mirror(ctx context.Context, personID uuid.UUID) (*uuid.UUID, error)
- func (b *Byline) Name(ctx context.Context, personID *uuid.UUID) (string, error)
- func (b *Byline) PersonChanged(ctx context.Context, personID uuid.UUID) error
- func (b *Byline) PersonDeleting(ctx context.Context, personID uuid.UUID) error
- func (b *Byline) Resolve(ctx context.Context, name string) (entity.Byline, error)
- type Credit
- type DB
- type EntityWriter
- type Link
- type MergeResult
- type PathMover
- type Person
- type ProfileInput
- type ProfileStore
- type Profiles
- func (p *Profiles) Create(ctx context.Context, by *uuid.UUID, name string) (*Person, error)
- func (p *Profiles) MergePeople(ctx context.Context, keepID, dropID uuid.UUID) (*MergeResult, error)
- func (p *Profiles) Save(ctx context.Context, userID uuid.UUID, in ProfileInput) (*Person, error)
- func (p *Profiles) WithRedirects(pathOf func(ctx context.Context, slug string) string, mover PathMover) *Profiles
- type SQLDB
- type Store
- func (s *Store) ByIDs(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]*Person, error)
- func (s *Store) Credits(ctx context.Context, entityIDs []uuid.UUID) (map[uuid.UUID]Credit, error)
- func (s *Store) ForUser(ctx context.Context, userID uuid.UUID) (*Person, error)
- func (s *Store) Get(ctx context.Context, id uuid.UUID) (*Person, error)
- func (s *Store) GetBySlug(ctx context.Context, slug string) (*Person, error)
- func (s *Store) Link(ctx context.Context, userID, personID uuid.UUID) error
- func (s *Store) LinkedUser(ctx context.Context, personID uuid.UUID) (uuid.UUID, error)
- func (s *Store) LinkedUsers(ctx context.Context) (map[uuid.UUID]uuid.UUID, error)
- func (s *Store) List(ctx context.Context) ([]*Person, error)
- func (s *Store) Merge(ctx context.Context, keepID, dropID uuid.UUID, ...) (*MergeResult, error)
- func (s *Store) PostCounts(ctx context.Context) (map[uuid.UUID]int, error)
- func (s *Store) SchemaID(ctx context.Context) (uuid.UUID, error)
- type ValidationError
Constants ¶
const SchemaSlug = "person"
SchemaSlug is the slug of the builtin person content type.
Variables ¶
var ( // ErrMergeSame means keep and drop are the same person. ErrMergeSame = errors.New("a person cannot be merged into themselves") // ErrMergeBothLogins means both people belong to a user account: merging // would leave one of the accounts without a profile. ErrMergeBothLogins = errors.New("both people have a login") )
Merge errors.
var ( ErrNotFound = errors.New("person not found") // ErrAlreadyLinked means the user already owns a person, or the person // already belongs to another user. ErrAlreadyLinked = errors.New("user or person already linked") )
Errors.
var CoreFields = entity.PersonCoreFields
CoreFields are the person type's own properties: a site can add fields next to them but never remove or retype them.
Functions ¶
func Backfill ¶
Backfill makes every author a person and points the author's entities at it. It is idempotent: a second run finds everything done and changes nothing, so it also serves after importing an archive that predates people.
- Each author whose name is not the company name gets one person entity (slug = author slug so /author/{slug} keeps working, bio, photo from the avatar, public when the author has published entities). The person carries metadata.import_key "author:<author id>" (the idempotency key) and metadata.legacy_author_id (the mirror row for rollback).
- An author named like the company (exact, case-insensitive, trimmed) gets no person: its entities become byline_org instead. Nothing else is read to decide that.
- Entities that had author_id and no byline yet get byline_person_id (or byline_org). author_id is left untouched.
- Users are linked to a person by equal name; failing that, a site with exactly one user and one person-author links the two. Everyone else stays unlinked and creates their profile at sign-in.
- People never carry page_sections: the key is stripped from every person entity, so the Page Builder is never the editor of a person.
It fails when a non-builtin schema already uses the slug `person`.
func Complete ¶
Complete reports whether a profile has what a signed-in user must provide: a name and a photo. The admin sends users with an incomplete profile to the "Complete your profile" step.
func Duplicates ¶
Duplicates groups the people who look like the same human, for the Merge screen. Two people are alike when their names are equal ignoring case and spacing, or when their slugs share a first name and one slug starts with the other (alex-c and alex-carter). A group holds at least two people, in name order; groups are in name order of their first member. Nothing is merged here: a person decides.
Types ¶
type Byline ¶
type Byline struct {
// contains filtered or unexported fields
}
Byline resolves byline text to people and keeps the hatamoto_authors mirror of each person. It implements entity.BylineResolver.
The mirror exists only for rollback: the previous binary renders bylines from author_id, so every person has an author row with the same slug and name (its id is kept in the person's metadata.legacy_author_id) and the plain-text attributes.author of their posts carries the display name.
func (*Byline) Mirror ¶
Mirror implements entity.BylineResolver: it makes the mirror row when the person has none and brings its name up to date.
func (*Byline) PersonChanged ¶
PersonChanged implements entity.BylineResolver: the mirror follows the person, and the plain-text byline of their posts follows the name.
func (*Byline) PersonDeleting ¶
PersonDeleting implements entity.BylineResolver. The mirror author row is kept (rollback); the posts keep their plain-text byline.
type Credit ¶
Credit is who an entity credits: a person, the site organisation, or (both zero) nobody.
type DB ¶
type DB interface {
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
}
DB is the slice of pgx the store needs; *pgxpool.Pool and pgx.Tx satisfy it, so the same store works inside a migration or import transaction.
type EntityWriter ¶
type EntityWriter interface {
GetEntity(ctx context.Context, id uuid.UUID) (*entity.Entity, error)
CreateEntity(ctx context.Context, userID *uuid.UUID, in *entity.CreateEntityInput) (*entity.Entity, error)
UpdateEntity(ctx context.Context, id uuid.UUID, userID *uuid.UUID, in *entity.UpdateEntityInput) (*entity.Entity, error)
DeleteEntity(ctx context.Context, id uuid.UUID) error
}
EntityWriter is the part of entity.Service that writes people. Going through it keeps revisions, validation and the author mirror (PersonChanged) working.
type MergeResult ¶
type MergeResult struct {
Keep, Drop *Person
// Posts is how many entities were credited to Drop and now credit Keep.
Posts int
}
MergeResult is what a merge changed, for the caller's message.
type Person ¶
type Person struct {
ID uuid.UUID
Slug string
Name string
JobTitle string
ShortBio string
Bio string
Photo string // URL of the photo, "" without one
PublicEmail string
// SlugFixed is true once the person has been public: the address then stays
// when the name changes.
SlugFixed bool
// Public is the opt-in: only a public person has a page, a sitemap entry
// and a linked byline.
Public bool
// OnTeam marks people the site lists as its team (the on_team attribute).
OnTeam bool
// Links holds the profile links by key: website, linkedin, x, github.
Links map[string]string
// Expertise are free-form topic labels.
Expertise []string
}
Person is one profile.
func (*Person) LabelledLinks ¶
LabelledLinks returns the profile links with a visible label, in the order of SameAs.
type ProfileInput ¶
type ProfileInput struct {
Name string
Photo string
JobTitle string
ShortBio string
LinkedIn string
Public bool
}
ProfileInput is what the profile forms edit. The person's other attributes (bio, public email, links other than LinkedIn, expertise) are left alone.
func (*ProfileInput) Validate ¶
func (in *ProfileInput) Validate() *ValidationError
Validate trims the input and checks it. Name and photo are required; the rest is optional. It returns nil when the input is good.
type ProfileStore ¶
type ProfileStore interface {
SchemaID(ctx context.Context) (uuid.UUID, error)
Get(ctx context.Context, id uuid.UUID) (*Person, error)
ForUser(ctx context.Context, userID uuid.UUID) (*Person, error)
Link(ctx context.Context, userID, personID uuid.UUID) error
}
ProfileStore is the part of Store the profile service reads and links with.
type Profiles ¶
type Profiles struct {
*Store
// contains filtered or unexported fields
}
Profiles creates and edits the person a user account owns. The embedded Store gives it the read methods too.
func NewProfiles ¶
func NewProfiles(store *Store, entities EntityWriter) *Profiles
NewProfiles returns the profile service over a store and the entity service.
func (*Profiles) Create ¶
Create makes a draft person that is not public and has no login (People, Add person). The name must be 1 to 120 characters; a bad one is a *ValidationError on the "name" field.
func (*Profiles) MergePeople ¶
MergePeople is Store.Merge with the redirect settings of WithRedirects.
type SQLDB ¶
type SQLDB interface {
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
}
SQLDB is the slice of database/sql Backfill needs; *sql.Tx and *sql.DB satisfy it, so it runs inside a goose migration and after an import.
type Store ¶
type Store struct {
// contains filtered or unexported fields
}
Store reads people and their user links.
func NewPoolStore ¶
NewPoolStore is NewStore for a pool.
func (*Store) ByIDs ¶
ByIDs returns the given people in one query, keyed by id. Unknown ids are absent. Use it to resolve the bylines of a whole listing.
func (*Store) Credits ¶
Credits returns the byline of each entity in one query. Entities without a person or organisation byline are absent.
func (*Store) ForUser ¶
ForUser returns the person a user owns, or ErrNotFound when the user has none yet.
func (*Store) Link ¶
Link makes personID the person of userID. It returns ErrAlreadyLinked when the user already owns a person or the person already has a login, and ErrNotFound when personID is not a person entity.
func (*Store) LinkedUser ¶
LinkedUser returns the user that owns a person, or ErrNotFound for a person without a login.
func (*Store) LinkedUsers ¶
LinkedUsers returns the user id that owns each person; people without a login are absent.
func (*Store) Merge ¶
func (s *Store) Merge(ctx context.Context, keepID, dropID uuid.UUID, pathOf func(ctx context.Context, slug string) string, redirects PathMover) (*MergeResult, error)
Merge folds the person drop into keep, in one transaction: every entity credited to drop is credited to keep (including the author mirror and the plain-text byline), drop's login moves to keep when keep has none, keep's empty profile fields are filled from drop, and drop is deleted. Two people that both have a login are refused.
Afterwards, when drop had a public page and keep has one too, drop's old path answers 301 to keep's. pathOf gives the public path of a slug; with redirects or pathOf nil no redirect is written. The redirect is written after the commit, so a failure there is returned with the result: the merge itself stands.
func (*Store) PostCounts ¶
PostCounts returns how many published entities each person is credited on. People without any are absent.
type ValidationError ¶
ValidationError lists the problems of a ProfileInput by form field name ("name", "photo", "job_title", "short_bio", "linkedin").
func (*ValidationError) Error ¶
func (e *ValidationError) Error() string