agent

package
v0.6.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 19, 2026 License: MIT Imports: 19 Imported by: 0

Documentation

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type CommentHandler added in v0.3.0

type CommentHandler struct {
	// contains filtered or unexported fields
}

CommentHandler exposes the Bearer-authenticated agent comment endpoints. It reuses the existing content domain service's comment methods (which already validate comment text and enforce the AllowComments gate) — it never duplicates that logic. The comment surface is nested under the agent's content-keyed namespace (/api/v1/content/{id}/comments) so it is collision-free vs the browser realm's /api/v1/content_items/.../comments and /api/v1/comments routes, and consistent with the agent surface that keys everything by content id.

func NewCommentHandler added in v0.3.0

func NewCommentHandler(s CommentService, logger *util.Logger) *CommentHandler

NewCommentHandler constructs a CommentHandler backed by the given content service. A nil logger degrades to a discard sink so the handler is safe to construct in any context (mirrors NewContentHandler/NewMediaHandler).

func (*CommentHandler) Create added in v0.3.0

func (h *CommentHandler) Create(w http.ResponseWriter, r *http.Request)

Create handles POST /api/v1/content/{id}/comments. It resolves the content by id (404 NOT_FOUND when missing, and 404 — never 403 — when the caller is neither owner nor Admin and the content is a draft, so existence is not disclosed), rejects commenting when the content has comments disabled (403 FORBIDDEN), then delegates to SubmitComment which validates the text (1–2000 chars, no HTML → ErrInvalidCommentText → 400 VALIDATION_ERROR) and persists the comment in the "pending" moderation status. Like the rest of the agent surface, success is 200 (the browser realm returns 201; the agent realm standardizes on 200 via response.Success).

func (*CommentHandler) Delete added in v0.3.0

func (h *CommentHandler) Delete(w http.ResponseWriter, r *http.Request)

Delete handles DELETE /api/v1/content/{id}/comments/{commentId}. The path content id is bound to the comment (a mismatch — the comment belongs to other content — is rejected with a clean 404, no disclosure), then an Admin may delete any comment (DeleteComment) while any other caller may delete only their own (DeleteOwnComment, which returns ErrCommentNotFound — a 404 with no ownership disclosure — when the comment is missing or belongs to someone else). Success is 204 No Content.

func (*CommentHandler) List added in v0.3.0

List handles GET /api/v1/content/{id}/comments. An Admin sees the moderation view (every comment, any status — mirroring the browser realm's admin-gated moderation queue); any other caller sees only approved comments, mirroring the public GET /api/v1/public/content_items/{slug}/comments. Both are scoped by the same visibility model as Create (published, or owned by the caller, or Admin; else 404). Comments-disabled content yields an empty list for non-admins (admins still moderate); the data array is always present, even when empty.

func (*CommentHandler) UpdateStatus added in v0.3.0

func (h *CommentHandler) UpdateStatus(w http.ResponseWriter, r *http.Request)

UpdateStatus handles PUT /api/v1/content/{id}/comments/{commentId}/status. It is admin-only (a non-admin API key gets 403 FORBIDDEN). The path content id is bound to the comment (a mismatch yields a clean 404), then it delegates to UpdateCommentStatus, which validates the status (a valid contentdomain.CommentStatus — pending/approved/rejected/spam; an unknown value returns ErrInvalidCommentStatus → 400 VALIDATION_ERROR). The updated comment is returned so the caller sees the new status.

type CommentProjection added in v0.3.0

type CommentProjection struct {
	ID        int       `json:"id"`
	Comment   string    `json:"comment"`
	Author    string    `json:"author,omitempty"`
	Username  string    `json:"username,omitempty"`
	Role      string    `json:"role,omitempty"`
	Status    string    `json:"status,omitempty"`
	CreatedAt time.Time `json:"createdAt"`
}

CommentProjection is the public, whitelisted view of a comment returned by the agent comment endpoints. It mirrors the browser comment handler's response shape (id, comment, author, username, role, status, createdAt) so the wire contract is consistent across the two realms, and excludes the numeric owner / content ids the raw contentdomain.Comment carries.

type CommentResponse added in v0.3.0

type CommentResponse struct {
	Comment CommentProjection `json:"comment"`
}

CommentResponse wraps a projected comment for the agent comment responses. Envelope body: {"data":{"comment":{...}}}.

func NewCommentResponse added in v0.3.0

func NewCommentResponse(c *contentdomain.Comment) CommentResponse

NewCommentResponse builds a CommentResponse projecting the given comment entity to its public fields. A nil entity yields an empty projection (defensive — the service contract returns a non-nil comment on success).

type CommentService added in v0.3.0

type CommentService interface {
	GetByID(ctx context.Context, id int) (*contentdomain.Content, error)
	SubmitComment(ctx context.Context, contentID int, userID int, req contentdomain.CreateCommentRequest) (*contentdomain.Comment, error)
	GetCommentsForContent(ctx context.Context, contentID int) ([]*contentdomain.Comment, error)
	GetCommentsForModeration(ctx context.Context, contentID int) ([]*contentdomain.Comment, error)
	GetComment(ctx context.Context, commentID int) (*contentdomain.Comment, error)
	UpdateCommentStatus(ctx context.Context, commentID int, status contentdomain.CommentStatus) error
	DeleteComment(ctx context.Context, commentID int) error
	DeleteOwnComment(ctx context.Context, commentID int, userID int) error
}

CommentService is the narrow slice of the content domain service the agent comment handlers depend on. *contentdomain.Service satisfies it. Declaring it locally — instead of depending on the wider contentdomain.ServiceInterface — keeps the agent surface independent and makes the dependency explicit. It is exported so mockery can generate an exported constructor callable from the package's *_test files (CLAUDE.md mandates *_test packages, which cannot reach an unexported mock).

type ContentHandler

type ContentHandler struct {
	// contains filtered or unexported fields
}

ContentHandler exposes the Bearer-authenticated agent content endpoints. It reuses the existing ContentService (which already validates custom fields and fires plugin hooks) — it never duplicates that logic.

func NewContentHandler

func NewContentHandler(s ContentService, systemFields SystemFieldResolver, logger *util.Logger) *ContentHandler

NewContentHandler constructs a ContentHandler backed by the given content service. systemFields resolves admin-managed system fields per post type so the authoring endpoints can reject (not silently drop) agent-supplied system-field keys; a nil resolver disables that check. A nil logger degrades to a discard sink so the handler is safe to construct in any context (mirrors NewAPIKeyHandler/NewAPIKeyAuthMiddleware).

func (*ContentHandler) Create

func (h *ContentHandler) Create(w http.ResponseWriter, r *http.Request)

Create handles POST /api/v1/content. It maps the streamlined agent payload onto a contentdomain.CreateContentRequest (reusing the same validation + plugin hook path as the admin surface) and returns the created content in the envelope.

func (*ContentHandler) Delete

func (h *ContentHandler) Delete(w http.ResponseWriter, r *http.Request)

Delete handles DELETE /api/v1/content/{id}. It pre-fetches for the ownership check (404 on not-found or not-owned-per-role) then delegates to the existing ContentService.DeleteContent. A successful delete returns 204 No Content; a subsequent GET returns 404 (the service hard-deletes for Admins and scoped-deletes otherwise).

func (*ContentHandler) Get

Get handles GET /api/v1/content/{id}. It returns the content in the envelope, or 404 NOT_FOUND when it does not exist OR the caller is not allowed to see it.

Visibility scoping (review fix for the GET IDOR): the underlying GetByID returns content regardless of owner/status, so the handler enforces "published OR owned by the requesting user" and otherwise returns NOT_FOUND (never FORBIDDEN, so existence is not disclosed to unauthorized callers). This mirrors the ownership pattern the admin Update/DeleteContent paths use (existing.UserID != userID).

func (*ContentHandler) List

List handles GET /api/v1/content. It returns the caller's own content (any status — drafts + published) in newest-first order using opaque keyset (cursor) pagination, each item projected via the ContentProjection whitelist. hasMore/nextCursor are computed by requesting limit+1 rows; nextCursor is the opaque token of the last item on the current page and is only present when there is another page.

Optional filters: ?tag=foo&tag=bar (AND-of-tags), ?language=en, ?status=draft|published, ?post_type=post, ?author=alice (admin only — non-admins get 403), ?search=foo (min 2 chars). All filters AND with the cursor pagination. The agent v1 surface scopes to the caller's own content; the author filter is meaningful only for admins because the agent handler never expands the userID scope (admin still sees their own content here — use the admin surface for cross-user listings).

func (*ContentHandler) Publish

func (h *ContentHandler) Publish(w http.ResponseWriter, r *http.Request)

Publish handles POST /api/v1/content/{id}/publish. It delegates to ContentService.Publish so ownership, the draft→published transition, SEO auto-generation, and the AfterPublish hook fire through the same domain path the Update endpoint uses. The endpoint accepts an empty body and is idempotent: publishing an already-published post returns 200 with the current projection, fires no hook, runs no SEO regen.

A non-admin caller must be the owner; otherwise 404 (never 403, so existence is not disclosed). An admin can publish another user's content. Validation and persistence errors map to the same error envelope the other endpoints use.

func (*ContentHandler) SetSystemFields added in v0.2.0

func (h *ContentHandler) SetSystemFields(w http.ResponseWriter, r *http.Request)

SetSystemFields handles PUT /api/v1/content/{id}/system-fields. System fields are admin-managed metadata (e.g. editorial_status, internal_notes), so this endpoint is admin-only: a non-admin caller gets 403. It mirrors the browser admin's system-fields endpoint but lives in the Bearer/agent realm so the CLI can set them with an admin API key. It delegates to ContentService.SetSystemFields, which validates each key against the post type's system-field schema and each value's type — an unknown key returns ErrUnknownSystemFieldKey and a bad value returns ErrSystemFieldValidation (both → 400 VALIDATION_ERROR via handleError).

func (*ContentHandler) Unpublish

func (h *ContentHandler) Unpublish(w http.ResponseWriter, r *http.Request)

Unpublish handles POST /api/v1/content/{id}/unpublish. It delegates to ContentService.Unpublish so the status flip persists through the same domain path. The endpoint accepts an empty body and is idempotent: unpublishing an already-draft post returns 200 with the current projection, fires no hook, runs no SEO regen. Unpublish never fires the AfterPublish hook (the hook is wired to the draft→published edge only).

Same ownership/role contract as Publish: 404 (not 403) for non-admin non-owners; admins can unpublish anyone's content.

func (*ContentHandler) Update

func (h *ContentHandler) Update(w http.ResponseWriter, r *http.Request)

Update handles PUT /api/v1/content/{id}. It pre-fetches the existing content for an ownership check (404 NOT_FOUND when the caller is neither owner nor Admin — existence is never disclosed) and then delegates to the existing ContentService.Update so hooks + custom-field validation run through the same domain path the admin uses. format=tiptap stores the body unchanged; markdown is rejected (Story 2.4).

v1 settable on update: title, body, format, postType, customFields, isPublished, tags, language. Server-managed (preserved from the existing item): SEO metadata (metaDescription, ogTitle, ogDescription), allowComments, translationGroupId.

type ContentProjection

type ContentProjection struct {
	ID           int            `json:"id"`
	Title        string         `json:"title"`
	Slug         string         `json:"slug"`
	Body         string         `json:"body"`
	Status       string         `json:"status"`
	PostType     string         `json:"postType,omitempty"`
	Language     string         `json:"language,omitempty"`
	Tags         []string       `json:"tags,omitempty"`
	CustomFields map[string]any `json:"customFields,omitempty"`
	Author       string         `json:"author,omitempty"`
	CreatedAt    time.Time      `json:"createdAt,omitempty"`
	UpdatedAt    time.Time      `json:"updatedAt,omitempty"`
}

ContentProjection is the public, whitelisted view of a content item returned by the agent create/get endpoints. It deliberately excludes internal/admin-only fields the raw contentdomain.Content carries (the numeric owner id, updatedBy/updatedByUsername, translationGroupId, SEO metadata, allowComments) so the programmatic API surface does not over-disclose to integrators or — via IDOR — to other tenants.

type ContentRequest

type ContentRequest struct {
	Title              string         `json:"title"`
	Body               string         `json:"body"`
	Format             string         `json:"format,omitempty"`
	PostType           string         `json:"postType,omitempty"`
	Slug               string         `json:"slug,omitempty"`
	CustomFields       map[string]any `json:"customFields,omitempty"`
	IsPublished        bool           `json:"isPublished,omitempty"`
	Tags               []string       `json:"tags,omitempty"`
	Language           string         `json:"language,omitempty"`
	TranslationGroupID *int           `json:"translationGroupId,omitempty"`
}

ContentRequest is the agent authoring payload for POST /api/v1/content. It maps onto the existing contentdomain.CreateContentRequest (see the mapping table in the story Dev Notes). It is intentionally separate from the admin handler's CreateContentRequest so the agent contract can evolve independently.

Field notes:

  • Body is the canonical content. For Format "tiptap" (default) it is the Tiptap JSON string, stored unchanged. The service validates/sanitizes it.
  • Format defaults to "tiptap" when omitted. "markdown" is reserved for Story 2.4 (Markdown→Tiptap conversion) and is rejected with VALIDATION_ERROR here.
  • Slug is accepted for API stability but NOT honored in Story 2.1: the content service auto-generates the slug from the title. See Completion Notes.
  • IsPublished true maps to StatusPublished; false/omitted maps to StatusDraft.
  • Tags are normalized via contentdomain.ValidateTags; an invalid tag returns VALIDATION_ERROR from the server.
  • Language must be in the server's configured languages (config.toml [languages] list); an unknown code returns VALIDATION_ERROR (ErrInvalidLanguage). The CLI cannot pre-validate this and forwards the value as-given so the server is the single source of truth.
  • TranslationGroupID (the CLI's --translation-of <id>) links the new item to an existing one's translation group. The server validates the id exists (ErrTranslationGroupNotFound → VALIDATION_ERROR) and stores it; a non-nil value is what makes the public language switcher appear on the page.

type ContentResponse

type ContentResponse struct {
	Content ContentProjection `json:"content"`
}

ContentResponse wraps a projected content item for the agent create/get responses. Envelope body: {"data":{"content":{...}}}.

func NewContentResponse

func NewContentResponse(c *contentdomain.Content) ContentResponse

NewContentResponse builds a ContentResponse projecting the given content entity to its public fields. A nil entity yields an empty projection (defensive — the service contract returns a non-nil content on success).

type ContentService

type ContentService interface {
	Create(ctx context.Context, userID int, req contentdomain.CreateContentRequest) (*contentdomain.Content, error)
	GetByID(ctx context.Context, id int) (*contentdomain.Content, error)
	ListByCursor(ctx context.Context, userID int, limit int, beforeID int, filters contentdomain.ContentFilters) ([]*contentdomain.Content, error)
	Update(ctx context.Context, id int, userID int, role string, req contentdomain.UpdateContentRequest) (*contentdomain.Content, error)
	DeleteContent(ctx context.Context, id int, userID int, role string) error
	Publish(ctx context.Context, id int, userID int, role string) (*contentdomain.Content, error)
	Unpublish(ctx context.Context, id int, userID int, role string) (*contentdomain.Content, error)
	SetSystemFields(ctx context.Context, contentID int, systemFields map[string]any) (*contentdomain.Content, error)
}

ContentService is the narrow slice of the content domain service the agent handlers depend on. *contentdomain.Service satisfies it. Declaring it locally — instead of depending on the admin handler's wider ContentServiceInterface — keeps the agent surface independent and makes the dependency explicit. It is exported so mockery can generate an exported constructor callable from the package's *_test files (CLAUDE.md mandates *_test packages, which cannot reach an unexported mock).

type MediaHandler

type MediaHandler struct {
	// contains filtered or unexported fields
}

MediaHandler exposes the Bearer-authenticated agent media endpoints. It reuses the existing MediaService (which already validates files, hashes, dedups, converts to WebP, and builds thumbnail variants) — it never duplicates that logic.

func NewMediaHandler

func NewMediaHandler(s MediaService, logger *util.Logger) *MediaHandler

NewMediaHandler constructs a MediaHandler backed by the given media service. A nil logger degrades to a discard sink so the handler is safe to construct in any context (mirrors NewContentHandler).

func (*MediaHandler) Get

Get handles GET /api/v1/media/{id}. It returns the media in the envelope, or 404 NOT_FOUND when it does not exist OR the caller is not allowed to see it (ownership scoping: only the owner or an Admin may read a given item — existence is never disclosed, consistent with the content visibility model).

func (*MediaHandler) List

func (h *MediaHandler) List(w http.ResponseWriter, r *http.Request)

List handles GET /api/v1/media. It returns the caller's own media in newest-first order using opaque keyset (cursor) pagination — the SAME contract as the content list. It reuses the agent package's cursor helpers and the response package's SuccessList/ Pagination/ListMeta types (introduced for this reuse in Story 2.2).

func (*MediaHandler) Upload

func (h *MediaHandler) Upload(w http.ResponseWriter, r *http.Request)

Upload handles POST /api/v1/media. It parses a required `file` part and an optional JSON `metadata` part (carrying altText) from multipart/form-data, maps them onto a mediadomain.UploadRequest, and delegates to the existing MediaService.Upload. A missing `file` part returns 400 VALIDATION_ERROR; the service's own validation (mime/size/alt text) and dedup flow through handleMediaError.

type MediaMetadata

type MediaMetadata struct {
	AltText string `json:"altText,omitempty"`
}

MediaMetadata is the optional JSON `metadata` part of a media upload. altText is the only field today; it is REQUIRED by the media service (ValidateAltText) for accessibility, so agents should always send it.

type MediaProjection

type MediaProjection struct {
	ID               int                                 `json:"id"`
	Filename         string                              `json:"filename"`
	OriginalFilename string                              `json:"originalFilename"`
	MimeType         mediadomain.MimeType                `json:"mimeType"`
	FileSize         int64                               `json:"fileSize"`
	Width            int                                 `json:"width"`
	Height           int                                 `json:"height"`
	AltText          string                              `json:"altText"`
	IsWebP           bool                                `json:"isWebp"`
	Hash             string                              `json:"hash"`
	URL              string                              `json:"url"`
	Variants         map[string]mediadomain.MediaVariant `json:"variants,omitempty"`
	CreatedAt        time.Time                           `json:"createdAt,omitempty"`
	UpdatedAt        time.Time                           `json:"updatedAt,omitempty"`
}

MediaProjection is the public, whitelisted view of a media item returned by the agent media endpoints. It deliberately excludes internal/admin-only fields the raw mediadomain.Media carries (the numeric owner id, filePath, uploadedBy) so the programmatic API surface does not over-disclose — mirroring ContentProjection.

type MediaResponse

type MediaResponse struct {
	Media MediaProjection `json:"media"`
}

MediaResponse wraps a projected media item for the agent media responses. Envelope body: {"data":{"media":{...}}}.

func NewMediaResponse

func NewMediaResponse(m *mediadomain.Media) MediaResponse

NewMediaResponse builds a MediaResponse projecting the given media entity to its public fields. A nil entity yields an empty projection (defensive — the service contract returns a non-nil media on success).

type MediaService

type MediaService interface {
	Upload(ctx context.Context, req mediadomain.UploadRequest) (*mediadomain.Media, error)
	GetByID(ctx context.Context, id int) (*mediadomain.Media, error)
	ListByCursor(ctx context.Context, userID int, limit int, beforeID int) ([]*mediadomain.Media, error)
}

MediaService is the narrow slice of the media domain service the agent handlers depend on. *mediadomain.Service satisfies it. It is exported so mockery can generate an exported constructor callable from the package's *_test files (CLAUDE.md mandates *_test packages, which cannot reach an unexported mock).

type SystemFieldResolver added in v0.2.0

type SystemFieldResolver interface {
	GetSystemFieldsByPostType(slug string) ([]customfield.FieldSchema, error)
}

SystemFieldResolver resolves the admin-managed system field schemas for a post type. *posttype.Service satisfies it. It is exported so mockery can generate a mock reachable from the *_test package (CLAUDE.md mandates *_test packages). It is optional: a nil resolver disables the system-field-rejection check on create/update (the service still strips such keys afterwards).

Directories

Path Synopsis

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL