feat(@projects/@cocottetech): ✨ add cockpit and content asset endpoints
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
5b324d3b0c
commit
432670469d
6 changed files with 177 additions and 2 deletions
|
|
@ -0,0 +1,15 @@
|
|||
import Foundation
|
||||
|
||||
/// Read-only cockpit surfaces backed by platform.api:
|
||||
/// - `/specialists` — fleet roster derived from agent_actions
|
||||
/// - `/surface-metrics` — per-surface metric rollup (empty until adapters write rows)
|
||||
extension Endpoint {
|
||||
|
||||
public static func specialists() -> Endpoint {
|
||||
.crudList("specialists")
|
||||
}
|
||||
|
||||
public static func surfaceMetrics() -> Endpoint {
|
||||
.crudList("surface-metrics")
|
||||
}
|
||||
}
|
||||
|
|
@ -7,11 +7,23 @@ import CocottePlatformModels
|
|||
/// query filters — add one here only when the backend supports it.)
|
||||
extension Endpoint {
|
||||
|
||||
public static func contentAssets() -> Endpoint {
|
||||
.crudList("content-assets")
|
||||
/// List assets, newest first. `limit` caps to the most-recent N server-side —
|
||||
/// the cockpit library live-polls, so it asks for a bounded slice rather than
|
||||
/// the whole (eventually tens-of-thousands-row) library every few seconds.
|
||||
public static func contentAssets(limit: Int? = nil) -> Endpoint {
|
||||
var query: [String: String] = [:]
|
||||
if let limit { query["limit"] = String(limit) }
|
||||
return .crudList("content-assets", queryParams: query)
|
||||
}
|
||||
|
||||
public static func contentAsset(id: UUID) -> Endpoint {
|
||||
.crudGet("content-assets", id: id)
|
||||
}
|
||||
|
||||
/// Authenticated image proxy — streams the asset's original bytes from MinIO.
|
||||
/// The object key is resolved server-side from the tenant-scoped row; the
|
||||
/// client only supplies the asset id.
|
||||
public static func contentAssetImage(id: UUID) -> Endpoint {
|
||||
Endpoint(path: "/content-assets/\(id.uuidString)/image", method: .get)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,4 +14,74 @@ extension Endpoint {
|
|||
public static func contentDrop(id: UUID) -> Endpoint {
|
||||
.crudGet("content-drops", id: id)
|
||||
}
|
||||
|
||||
/// Create a drop. (platform-api `POST content-drops`, via `CrudControllerBase`.)
|
||||
/// Legs are not part of the create payload — they live in `content_drop_legs`
|
||||
/// and are fanned out separately; assets link via `addDropAsset`.
|
||||
public static func createContentDrop(_ body: CreateContentDropBody) -> Endpoint {
|
||||
.crudCreate("content-drops", body: body)
|
||||
}
|
||||
|
||||
/// Link an existing content asset to a drop.
|
||||
/// (platform-api `POST content-drops/:id/assets`.)
|
||||
public static func addDropAsset(dropId: UUID, _ body: AddDropAssetBody) -> Endpoint {
|
||||
Endpoint(path: "/content-drops/\(dropId.uuidString)/assets", method: .post, body: body)
|
||||
}
|
||||
}
|
||||
|
||||
public struct CreateContentDropBody: Encodable, Sendable {
|
||||
public let userId: UUID
|
||||
public let orgId: UUID?
|
||||
public let title: String
|
||||
public let arc: String
|
||||
public let state: ContentDropState
|
||||
public let clusterSource: String
|
||||
public let dropAt: Date
|
||||
public let createdBySpecialist: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case userId = "user_id"
|
||||
case orgId = "org_id"
|
||||
case title
|
||||
case arc
|
||||
case state
|
||||
case clusterSource = "cluster_source"
|
||||
case dropAt = "drop_at"
|
||||
case createdBySpecialist = "created_by_specialist"
|
||||
}
|
||||
|
||||
public init(
|
||||
userId: UUID,
|
||||
orgId: UUID?,
|
||||
title: String,
|
||||
arc: String,
|
||||
state: ContentDropState,
|
||||
clusterSource: String,
|
||||
dropAt: Date,
|
||||
createdBySpecialist: String
|
||||
) {
|
||||
self.userId = userId
|
||||
self.orgId = orgId
|
||||
self.title = title
|
||||
self.arc = arc
|
||||
self.state = state
|
||||
self.clusterSource = clusterSource
|
||||
self.dropAt = dropAt
|
||||
self.createdBySpecialist = createdBySpecialist
|
||||
}
|
||||
}
|
||||
|
||||
public struct AddDropAssetBody: Encodable, Sendable {
|
||||
public let assetId: UUID
|
||||
public let sortOrder: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case assetId = "asset_id"
|
||||
case sortOrder = "sort_order"
|
||||
}
|
||||
|
||||
public init(assetId: UUID, sortOrder: Int) {
|
||||
self.assetId = assetId
|
||||
self.sortOrder = sortOrder
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,6 +63,14 @@ public final class PlatformAPIClient: Sendable {
|
|||
_ = try await perform(endpoint, scope: scope)
|
||||
}
|
||||
|
||||
/// Fetch a raw (non-JSON) response body with full auth + tenancy headers —
|
||||
/// e.g. the content-assets image proxy, which streams image bytes, not JSON.
|
||||
/// `AsyncImage(url:)` can't hit these routes (it sends no Authorization /
|
||||
/// `x-user-id`), so the cockpit loads image data through this instead.
|
||||
public func sendData(_ endpoint: Endpoint, scope: TenantScope) async throws -> Data {
|
||||
try await perform(endpoint, scope: scope)
|
||||
}
|
||||
|
||||
// MARK: - transport
|
||||
|
||||
private func perform(_ endpoint: Endpoint, scope: TenantScope) async throws -> Data {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
import Foundation
|
||||
|
||||
/// Fleet-roster row, DERIVED from `agent_actions` (there is no specialist registry
|
||||
/// table). `trust` is mean action confidence (nil if the specialist has no actions);
|
||||
/// `mode` is derived from `auto_executed` over the last week. Operational states with
|
||||
/// no signal (degraded/retired) are never reported.
|
||||
public struct SpecialistSummary: Codable, Identifiable, Hashable, Sendable {
|
||||
public var id: String { specialistId }
|
||||
public let specialistId: String
|
||||
public let countToday: Int
|
||||
public let trust: Double?
|
||||
public let mode: SpecialistMode
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case specialistId = "specialist_id"
|
||||
case countToday = "count_today"
|
||||
case trust
|
||||
case mode
|
||||
}
|
||||
|
||||
public init(specialistId: String, countToday: Int, trust: Double?, mode: SpecialistMode) {
|
||||
self.specialistId = specialistId
|
||||
self.countToday = countToday
|
||||
self.trust = trust
|
||||
self.mode = mode
|
||||
}
|
||||
}
|
||||
|
||||
/// Derived operating mode. `auto` = acted autonomously this week; `draftOnly` = acted
|
||||
/// but only via approval; `idle` = no recent actions. Decodes leniently (unknown →
|
||||
/// `.idle`) so a new server-side value never breaks the cockpit.
|
||||
public enum SpecialistMode: String, Codable, Sendable {
|
||||
case auto
|
||||
case draftOnly = "draft_only"
|
||||
case idle
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let raw = try decoder.singleValueContainer().decode(String.self)
|
||||
self = SpecialistMode(rawValue: raw) ?? .idle
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import Foundation
|
||||
|
||||
/// Per-surface metric rollup for the Insights tab. Sourced from `surface_metrics`
|
||||
/// (empty until surface adapters write rows). The kind→field mapping server-side is
|
||||
/// provisional; `posts` has no metric source yet and arrives as 0.
|
||||
public struct SurfaceMetricSummary: Codable, Identifiable, Hashable, Sendable {
|
||||
public var id: String { surface }
|
||||
public let surface: String
|
||||
public let posts: Int
|
||||
public let impressions: Int
|
||||
public let engagementPct: Double
|
||||
public let conversions: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case surface
|
||||
case posts
|
||||
case impressions
|
||||
case engagementPct = "engagement_pct"
|
||||
case conversions
|
||||
}
|
||||
|
||||
public init(surface: String, posts: Int, impressions: Int, engagementPct: Double, conversions: Int) {
|
||||
self.surface = surface
|
||||
self.posts = posts
|
||||
self.impressions = impressions
|
||||
self.engagementPct = engagementPct
|
||||
self.conversions = conversions
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue