Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ long failed = client.posts().stream(PostListParams.create().workspaceId(workspac
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `posts()` | `list`, `autoPaginate`, `stream`, `get`, `create`, `update`, `delete`, `duplicate`, `publish`, `retry`, `cancel`, `preflight`, `deliveries`, `publishRuns`, `analytics`, `bulkShift`, `bulkLabel`, `bulkDelete`, `validateImport`, `commitImport`, `rollbackImport` |
| `workspaces()` | `list`, `get`, `create`, `update`, `delete`, `analytics` |
| `accounts()` | `list`, `get`, `create`, `rename`, `move`, `delete`, `healthSummary`, `health`, `togglePrimary`, `validate`, `refreshToken`, `analytics`, `createTelegramConnectCode`, `getTelegramConnectStatus`, `getTelegramBotCommands`, `setTelegramBotCommands`, `deleteTelegramBotCommands`, `listSlackChannels`, `listSlackMembers`, `getSlackIdentity`, `updateSlackIdentity`, `getIceBreakers`, `setIceBreakers`, `deleteIceBreakers`, `getPersistentMenu`, `setPersistentMenu`, `deletePersistentMenu`, `getGreeting`, `setGreeting`, `deleteGreeting`, `getWebhookSubscription`, `resubscribeWebhook`, `communities()`, `listDiscordChannels`, `switchDiscordChannel`, `getDiscordIdentity`, `updateDiscordIdentity`, `listDiscordPins`, `deleteDiscordMessage`, `pinDiscordMessage`, `unpinDiscordMessage`, `crosspostDiscordMessage`, `createDiscordThread`, `sendDiscordDm`, `listDiscordEvents`, `getDiscordEvent`, `createDiscordEvent`, `updateDiscordEvent`, `deleteDiscordEvent`, `listDiscordMembers`, `getDiscordMember`, `listDiscordRoles`, `createDiscordRole`, `updateDiscordRole`, `deleteDiscordRole`, `addDiscordMemberRole`, `removeDiscordMemberRole` |
| `accounts()` | `list`, `get`, `create`, `rename`, `move`, `delete`, `healthSummary`, `health`, `togglePrimary`, `validate`, `refreshToken`, `analytics`, `createTelegramConnectCode`, `getTelegramConnectStatus`, `getTelegramBotCommands`, `setTelegramBotCommands`, `deleteTelegramBotCommands`, `listSlackChannels`, `listSlackMembers`, `getSlackIdentity`, `updateSlackIdentity`, `getIceBreakers`, `setIceBreakers`, `deleteIceBreakers`, `getPersistentMenu`, `setPersistentMenu`, `deletePersistentMenu`, `getGreeting`, `setGreeting`, `deleteGreeting`, `getWebhookSubscription`, `resubscribeWebhook`, `communities()`, `listDiscordChannels`, `switchDiscordChannel`, `getDiscordIdentity`, `updateDiscordIdentity`, `listDiscordPins`, `deleteDiscordMessage`, `pinDiscordMessage`, `unpinDiscordMessage`, `crosspostDiscordMessage`, `createDiscordThread`, `sendDiscordDm`, `listDiscordEvents`, `getDiscordEvent`, `createDiscordEvent`, `updateDiscordEvent`, `deleteDiscordEvent`, `listDiscordMembers`, `getDiscordMember`, `listDiscordRoles`, `createDiscordRole`, `updateDiscordRole`, `deleteDiscordRole`, `addDiscordMemberRole`, `removeDiscordMemberRole`, `platformMetrics` |
| `accountGroups()` | `list`, `get`, `create`, `update`, `delete`, `setMembers` |
| `labels()` | `list`, `get`, `create`, `update`, `delete` |
| `webhooks()` | `list`, `create`, `update`, `delete`, `test` |
Expand Down
32 changes: 32 additions & 0 deletions src/main/java/com/fopost/sdk/model/AccountPlatformMetrics.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.fopost.sdk.model;

import java.util.List;

/**
* What only this network reports, in its own vocabulary: ad-break earnings, story taps, a
* retention curve, the search terms behind a listing.
*/
public record AccountPlatformMetrics(String platform, Block account, Block post) {

/**
* One side of the set: the account itself, or its newest measured post.
* {@code externalPostId} is null on the account side.
*/
public record Block(String fetchedAt, String externalPostId, List<Row> metrics) {}

/**
* One metric a network reports under its own name. {@code key} is the platform's own name
* and is stable; {@code label} is ours and may be reworded, so match on {@code key}.
* {@code kind} is one of count, duration_ms, currency_usd, ratio, series.
*
* <p>{@code value} is a {@link Number} for every kind but series, which is a list of points,
* so it is declared as {@link Object} and {@link #number()} decodes the common case.
*/
public record Row(String key, String label, String kind, Object value) {

/** The value as a number, or null for a series or a non-numeric answer. */
public Double number() {
return value instanceof Number n ? n.doubleValue() : null;
}
}
}
16 changes: 16 additions & 0 deletions src/main/java/com/fopost/sdk/resource/AccountsResource.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import com.fopost.sdk.model.Account;
import com.fopost.sdk.model.AccountAnalyticsHistory;
import com.fopost.sdk.model.AccountHealth;
import com.fopost.sdk.model.AccountPlatformMetrics;
import com.fopost.sdk.model.AccountValidation;
import com.fopost.sdk.model.AccountsHealthSummary;
import com.fopost.sdk.model.DiscordAck;
Expand Down Expand Up @@ -159,6 +160,21 @@ public AccountHealth health(String accountId, boolean refresh) {
ApiClient.unwrap(http.get("/v1/accounts/" + accountId + "/health", params)), AccountHealth.class);
}

/**
* The numbers only this account's network reports, in its own vocabulary: ad-break
* earnings, story taps, a retention curve, the search terms behind a listing. Keyed by
* the platform's own metric names, read from the newest collected snapshot rather than
* fetched live. Needs the {@code analytics} scope.
*
* <p>A network whose metric access has not been granted yet answers 503
* ({@code platform_metrics_unavailable}) rather than an empty set.
*/
public AccountPlatformMetrics platformMetrics(String accountId) {
return http.convert(
ApiClient.unwrap(http.get("/v1/accounts/" + accountId + "/insights", query("raw", "true"))),
AccountPlatformMetrics.class);
}

/** Make this the account a post targets by default, or clear the flag. Returns the new state. */
public boolean togglePrimary(String accountId) {
JsonNode data = ApiClient.unwrap(http.post("/v1/accounts/" + accountId + "/primary", null));
Expand Down
74 changes: 74 additions & 0 deletions src/test/java/com/fopost/sdk/PlatformMetricsTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package com.fopost.sdk;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

import com.fopost.sdk.model.AccountPlatformMetrics;
import java.util.List;
import org.junit.jupiter.api.Test;

class PlatformMetricsTest {

@Test
void platformMetricsAsksForRawAndDecodesTheSet() {
FakeTransport transport = new FakeTransport()
.enqueue(200, """
{"data":{"platform":"facebook",
"account":{"fetched_at":"2026-09-20T02:00:00.000Z","metrics":[
{"key":"page_daily_video_ad_break_earnings","label":"Ad Break Earnings",
"kind":"currency_usd","value":42.15},
{"key":"page_impressions_paid","label":"Paid Impressions",
"kind":"count","value":1500}]},
"post":{"external_post_id":"123_456","fetched_at":"2026-09-20T02:00:00.000Z",
"metrics":[]}}}""");

AccountPlatformMetrics metrics = TestSupport.client(transport).accounts().platformMetrics("a1");

assertEquals("GET", transport.last().method());
assertEquals("https://api.fopost.test/v1/accounts/a1/insights?raw=true", transport.last().url());
assertEquals("facebook", metrics.platform());
assertEquals("2026-09-20T02:00:00.000Z", metrics.account().fetchedAt());
assertEquals(
List.of("page_daily_video_ad_break_earnings", "page_impressions_paid"),
metrics.account().metrics().stream().map(AccountPlatformMetrics.Row::key).toList());
assertEquals(42.15, metrics.account().metrics().get(0).number());
assertEquals("123_456", metrics.post().externalPostId());
assertTrue(metrics.post().metrics().isEmpty());
}

@Test
void aSeriesValueSurvivesAsAList() {
FakeTransport transport = new FakeTransport()
.enqueue(200, """
{"data":{"platform":"youtube",
"account":{"fetched_at":null,"metrics":[
{"key":"daily_views","label":"Views by Day","kind":"series",
"value":[{"day":"2026-09-19","views":600}]}]},
"post":{"external_post_id":null,"fetched_at":null,"metrics":[]}}}""");

AccountPlatformMetrics metrics = TestSupport.client(transport).accounts().platformMetrics("a1");
AccountPlatformMetrics.Row row = metrics.account().metrics().get(0);

assertNull(row.number());
assertTrue(row.value() instanceof List<?>);
assertNull(metrics.account().fetchedAt());
}

@Test
void aPendingMetricGrantThrows() {
FakeTransport transport = new FakeTransport()
.enqueue(503, """
{"error":"platform_metrics_unavailable",
"message":"google-business metrics are not available on this deployment yet."}""");

FoPostException error = assertThrows(FoPostException.class,
() -> TestSupport.client(transport, 1, new java.util.ArrayList<>())
.accounts()
.platformMetrics("a1"));

assertEquals(503, error.status());
assertEquals("platform_metrics_unavailable", error.code());
}
}
Loading