diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d08096..aed99cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,34 @@ All notable changes to this project are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses [Semantic Versioning](https://semver.org/). +## [0.4.0] - 2026-10-18 + +### Added + +- `queue()` for the posting queue: `listSlots`, `createSlot`, `updateSlot`, `deleteSlot`, `next` and + `preview`, with the `QueueSlot`, `QueueSlotUpdate`, `QueueSlotDeleted` and `QueueSlotMove` records + and the `QueueSlotParams` builder. `CreatePostParams.queue()` and `UpdatePostParams.queue()` send + `schedule: queue`, which puts the post in the next free slot. Needs `posts`. +- `posts().unpublish` takes a live post down on every network and answers an `UnpublishResult` + with one `Removal` per network. Needs `publish`. +- `connectSessions()`: `create` mints a hosted connect link for one of your own users and `get` + reads the session, with the `ConnectSession` record and the `ConnectSessionParams` builder. + Needs `accounts`. +- `webhooks().deliveries`, `delivery` and `redeliver`: the delivery log of an endpoint, one + attempt, and sending an event again, with the `WebhookDelivery` record. Needs `webhooks`. +- `automations().versions`, `version` and `restoreVersion`: the saved graph history and a restore + that writes a new version on top of it, with `AutomationVersion`, `AutomationVersionPage`, + `AutomationGraph` and `AutomationRestore`. Needs `automations`. +- `logs().list` reads the request log, newest first, with `LogParams` filters and a `LogPage` + carrying the cursor and the window summary. Needs `workspaces`. +- `analytics().inbox` answers the inbox aggregates as an `InboxAnalytics` record. Needs + `analytics`. + +### Changed + +- The javadoc of every method that builds its path from a helper now names the API operation it + wraps, so the operation list and the SDK can be checked against each other. + ## [0.3.0] - 2026-10-18 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 4b7ea7e..8d33209 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,7 +13,7 @@ exception per error status. sources and javadoc. - Runtime dependencies: `jackson-databind` and `jackson-datatype-jsr310` (2.18.2). HTTP is the JDK's own `java.net.http.HttpClient` — no HTTP library dependency. -- Version `0.3.0` in `pom.xml`, mirrored by the constant in +- Version `0.4.0` in `pom.xml`, mirrored by the constant in `src/main/java/com/fopost/sdk/internal/Version.java` (the release workflow enforces the match). ## Downstream Packages @@ -162,7 +162,7 @@ anything unwrapped. mvn -B verify # compile, test, sources jar, javadoc jar — what CI runs mvn -B test # tests only mvn -B test -Dtest=RetryTest # one test class -mvn -q install -DskipTests # install 0.3.0 locally, so ../fopost-spring can resolve it +mvn -q install -DskipTests # install 0.4.0 locally, so ../fopost-spring can resolve it mvn -B -Prelease deploy -DskipTests # GPG-sign and publish to Central (release workflow only) ``` diff --git a/README.md b/README.md index 1869ada..9ed51d3 100644 --- a/README.md +++ b/README.md @@ -9,12 +9,12 @@ Official Java SDK for the [FoPost](https://fopost.com) API. Schedule and publish com.fopost fopost-java - 0.3.0 + 0.4.0 ``` ```kotlin -implementation("com.fopost:fopost-java:0.3.0") +implementation("com.fopost:fopost-java:0.4.0") ``` Requires Java 17 or newer. HTTP goes through the JDK's own client; the only dependency is Jackson. @@ -97,15 +97,15 @@ long failed = client.posts().stream(PostListParams.create().workspaceId(workspac | Namespace | Methods | | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `posts()` | `list`, `autoPaginate`, `stream`, `get`, `create`, `update`, `delete`, `duplicate`, `publish`, `retry`, `cancel`, `preflight`, `deliveries`, `publishRuns`, `analytics`, `bulkShift`, `bulkLabel`, `bulkDelete`, `validateImport`, `commitImport`, `rollbackImport` | +| `posts()` | `list`, `autoPaginate`, `stream`, `get`, `create`, `update`, `delete`, `duplicate`, `publish`, `retry`, `cancel`, `preflight`, `deliveries`, `publishRuns`, `analytics`, `bulkShift`, `bulkLabel`, `bulkDelete`, `validateImport`, `commitImport`, `rollbackImport`, `unpublish` | | `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`, `redditSubreddits`, `redditSubredditRules`, `redditFlairs`, `setRedditDefaultSubreddit`, `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`, `verify` | +| `webhooks()` | `list`, `create`, `update`, `delete`, `test`, `deliveries`, `delivery`, `redeliver`, `verify` | | `blog()` | `getSettings`, `updateSettings`, `rotateWebsiteSecret`, `testWebsite`, `listKeywords`, `suggestSeeds`, `planKeywords`, `addKeyword`, `updateKeyword`, `deleteKeyword`, `listArticles`, `requestArticle`, `getArticle`, `updateArticle`, `regenerateArticle`, `submitArticle`, `publishArticle`, `rejectArticle`, `deleteArticle` | -| `analytics()` | `overview`, `timeSeries`, `topPosts`, `labels`, `postsTable`, `postingStreak`, `demographics`, `collect`, `decay`, `frequency`, `timeline`, `changes`, `collectPost`, `nativePosts` | -| `automations()` | `list`, `get`, `create`, `update`, `delete`, `toggle`, `runs`, `run`, `trigger`, `stats` | +| `analytics()` | `overview`, `timeSeries`, `topPosts`, `labels`, `postsTable`, `postingStreak`, `demographics`, `collect`, `decay`, `frequency`, `timeline`, `changes`, `collectPost`, `nativePosts`, `inbox` | +| `automations()` | `list`, `get`, `create`, `update`, `delete`, `toggle`, `runs`, `run`, `trigger`, `stats`, `versions`, `version`, `restoreVersion` | | `media()` | `list`, `limits`, `upload`, `presign`, `complete`, `uploadDirect`, `delete` | | `ai()` | `credits`, `generateCaption`, `rewrite`, `repurposeUrl` | | `inbox()` | `list`, `threads`, `conversations`, `unreadCount`, `accounts`, `platforms`, `markThreadRead`, `markConversationRead`, `refresh`, `update`, `editComment`, `reply`, `hide`, `unhide`, `delete`, `like`, `unlike`, `vote`, `pin`, `unpin`, `react`, `startConversation`, `setTyping`, `passThreadControl`, `takeThreadControl`, `handover`, `listApprovals`, `approveReply`, `rejectReply` | @@ -122,6 +122,9 @@ long failed = client.posts().stream(PostListParams.create().workspaceId(workspac | `googleBusiness()` | `getLocation`, `updateLocation`, `getAttributes`, `updateAttributes`, `getMenus`, `replaceMenus`, `getServices`, `replaceServices`, `listMedia`, `addMedia`, `deleteMedia`, `listPlaceActions`, `createPlaceAction`, `updatePlaceAction`, `deletePlaceAction`, `getVerificationOptions`, `startVerification`, `completeVerification`, `getPerformance`, `getSearchKeywords`, `assign` | | `blogs()` | `listBlogs`, `listArticles`, `getArticle`, `createArticle`, `updateArticle`, `deleteArticle`, `listProducts`, `updateProduct` | | `validate()` | `post`, `length`, `media`, `subreddit` | +| `queue()` | `listSlots`, `createSlot`, `updateSlot`, `deleteSlot`, `next`, `preview` | +| `connectSessions()` | `create`, `get` | +| `logs()` | `list` | | `activity()` | `list` | `accounts().communities()` covers the X communities an account can post into: `list`, `sync`, diff --git a/pom.xml b/pom.xml index 2c098b1..aa33256 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.fopost fopost-java - 0.3.0 + 0.4.0 jar fopost-java diff --git a/src/main/java/com/fopost/sdk/FoPost.java b/src/main/java/com/fopost/sdk/FoPost.java index afea03f..05b3b3a 100644 --- a/src/main/java/com/fopost/sdk/FoPost.java +++ b/src/main/java/com/fopost/sdk/FoPost.java @@ -15,6 +15,7 @@ import com.fopost.sdk.resource.AutomationsResource; import com.fopost.sdk.resource.BlogResource; import com.fopost.sdk.resource.BroadcastsResource; +import com.fopost.sdk.resource.ConnectSessionsResource; import com.fopost.sdk.resource.ContactsResource; import com.fopost.sdk.resource.BlogsResource; import com.fopost.sdk.resource.InboxResource; @@ -22,8 +23,10 @@ import com.fopost.sdk.resource.GoogleAdsResource; import com.fopost.sdk.resource.GoogleBusinessResource; import com.fopost.sdk.resource.LabelsResource; +import com.fopost.sdk.resource.LogsResource; import com.fopost.sdk.resource.MediaResource; import com.fopost.sdk.resource.PostsResource; +import com.fopost.sdk.resource.QueueResource; import com.fopost.sdk.resource.SequencesResource; import com.fopost.sdk.resource.ValidateResource; import com.fopost.sdk.resource.WebhooksResource; @@ -67,8 +70,10 @@ public final class FoPost { private final ApiClient http; private final PostsResource posts; + private final QueueResource queue; private final AccountsResource accounts; private final AccountGroupsResource accountGroups; + private final ConnectSessionsResource connectSessions; private final WorkspacesResource workspaces; private final KnowledgeResource knowledge; private final LabelsResource labels; @@ -81,6 +86,7 @@ public final class FoPost { private final BroadcastsResource broadcasts; private final SequencesResource sequences; private final ActivityResource activity; + private final LogsResource logs; private final GoogleBusinessResource googleBusiness; private final WhatsappResource whatsapp; private final InboxResource inbox; @@ -93,13 +99,16 @@ public final class FoPost { private FoPost(ApiClient http) { this.http = http; this.posts = new PostsResource(http); + this.queue = new QueueResource(http); this.googleBusiness = new GoogleBusinessResource(http); this.accounts = new AccountsResource(http); this.accountGroups = new AccountGroupsResource(http); + this.connectSessions = new ConnectSessionsResource(http); this.workspaces = new WorkspacesResource(http); this.knowledge = new KnowledgeResource(http); this.labels = new LabelsResource(http); this.activity = new ActivityResource(http); + this.logs = new LogsResource(http); this.webhooks = new WebhooksResource(http); this.analytics = new AnalyticsResource(http); this.automations = new AutomationsResource(http); @@ -134,6 +143,11 @@ public PostsResource posts() { return posts; } + /** The posting queue: weekly slots and the times a queued post would get. */ + public QueueResource queue() { + return queue; + } + public AccountsResource accounts() { return accounts; } @@ -142,6 +156,11 @@ public AccountGroupsResource accountGroups() { return accountGroups; } + /** Hosted links that let your own users connect an account without a FoPost login. */ + public ConnectSessionsResource connectSessions() { + return connectSessions; + } + public WorkspacesResource workspaces() { return workspaces; } @@ -180,6 +199,11 @@ public ActivityResource activity() { return activity; } + /** The request log: every API call made against a workspace, kept for 30 days. */ + public LogsResource logs() { + return logs; + } + public AiResource ai() { return ai; } diff --git a/src/main/java/com/fopost/sdk/internal/Version.java b/src/main/java/com/fopost/sdk/internal/Version.java index d8cd231..4701a3a 100644 --- a/src/main/java/com/fopost/sdk/internal/Version.java +++ b/src/main/java/com/fopost/sdk/internal/Version.java @@ -4,5 +4,5 @@ public final class Version { private Version() {} - public static final String VALUE = "0.3.0"; + public static final String VALUE = "0.4.0"; } diff --git a/src/main/java/com/fopost/sdk/model/AutomationGraph.java b/src/main/java/com/fopost/sdk/model/AutomationGraph.java new file mode 100644 index 0000000..c92bafe --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/AutomationGraph.java @@ -0,0 +1,15 @@ +package com.fopost.sdk.model; + +import java.util.List; +import java.util.Map; + +/** The nodes and edges of a saved automation graph. */ +public record AutomationGraph(List nodes, List edges) { + + /** {@code config} is echoed back exactly as authored. */ + public record Node(String id, String kind, String label, Map config, Integer position, Layout layout) {} + + public record Layout(Double x, Double y) {} + + public record Edge(String id, String source, String sourceHandle, String target) {} +} diff --git a/src/main/java/com/fopost/sdk/model/AutomationRestore.java b/src/main/java/com/fopost/sdk/model/AutomationRestore.java new file mode 100644 index 0000000..01ee07c --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/AutomationRestore.java @@ -0,0 +1,10 @@ +package com.fopost.sdk.model; + +import java.util.List; +import java.util.Map; + +/** The answer to a restore: the new version number, its graph, and the steps compiled from it. */ +public record AutomationRestore(Integer version, AutomationGraph graph, List steps) { + + public record Step(Integer position, String nodeId, String actionType, Map actionConfig) {} +} diff --git a/src/main/java/com/fopost/sdk/model/AutomationVersion.java b/src/main/java/com/fopost/sdk/model/AutomationVersion.java new file mode 100644 index 0000000..88386e2 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/AutomationVersion.java @@ -0,0 +1,23 @@ +package com.fopost.sdk.model; + +import java.time.Instant; +import java.util.List; + +/** + * One saved version of an automation's graph. The list carries {@code nodeCount}, + * {@code nodesAdded} and {@code nodesRemoved}; the single-version call carries {@code graph}. + */ +public record AutomationVersion( + Integer version, + String note, + Author createdBy, + Instant createdAt, + Integer nodeCount, + List nodesAdded, + List nodesRemoved, + AutomationGraph graph) { + + public record Author(String name) {} + + public record NodeRef(String id, String kind, String label) {} +} diff --git a/src/main/java/com/fopost/sdk/model/AutomationVersionPage.java b/src/main/java/com/fopost/sdk/model/AutomationVersionPage.java new file mode 100644 index 0000000..5d0b833 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/AutomationVersionPage.java @@ -0,0 +1,28 @@ +package com.fopost.sdk.model; + +import java.util.Iterator; +import java.util.List; + +/** One page of an automation's version history, newest first. */ +public record AutomationVersionPage(List data, Meta meta) implements Iterable { + + public AutomationVersionPage { + data = data == null ? List.of() : List.copyOf(data); + } + + /** {@code totalPages} is at least 1, so an empty history is one empty page. */ + public record Meta(Integer page, Integer perPage, Integer total, Integer totalPages) {} + + @Override + public Iterator iterator() { + return data.iterator(); + } + + public int size() { + return data.size(); + } + + public boolean isEmpty() { + return data.isEmpty(); + } +} diff --git a/src/main/java/com/fopost/sdk/model/ConnectSession.java b/src/main/java/com/fopost/sdk/model/ConnectSession.java new file mode 100644 index 0000000..29a6b55 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/ConnectSession.java @@ -0,0 +1,25 @@ +package com.fopost.sdk.model; + +import java.time.Instant; +import java.util.List; + +/** + * A hosted connect link. {@code url} is present on creation only; {@code accountId} is set once + * the session is used. {@code status} is {@code pending}, {@code used} or {@code expired}. + */ +public record ConnectSession( + String id, + String status, + String externalUserId, + List platforms, + String redirectUrl, + Branding branding, + String accountId, + String url, + Instant expiresAt, + Instant usedAt, + Instant createdAt) { + + /** What the hosted page shows as yours. */ + public record Branding(String name, String logoUrl) {} +} diff --git a/src/main/java/com/fopost/sdk/model/InboxAnalytics.java b/src/main/java/com/fopost/sdk/model/InboxAnalytics.java new file mode 100644 index 0000000..c0ed448 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/InboxAnalytics.java @@ -0,0 +1,54 @@ +package com.fopost.sdk.model; + +import java.time.LocalDate; +import java.util.List; + +/** + * How the social inbox performed over the period. Aggregates only. {@code deltas} are percentage + * changes against the previous period of the same length; {@code heatmap} is bucketed in + * {@code timeZone}. + */ +public record InboxAnalytics( + Totals totals, + Deltas deltas, + List series, + List byPlatform, + List byType, + List byAccount, + List responseTime, + List heatmap, + String timeZone) { + + /** {@code medianResponseMinutes} is null when nothing was replied to. */ + public record Totals( + Integer received, + Integer sent, + Integer answered, + Integer open, + Integer conversations, + Double medianResponseMinutes) {} + + public record Deltas(Double received, Double sent, Double answered) {} + + public record Point(LocalDate date, Integer received, Integer sent, Integer answered) {} + + public record PlatformRow(String platform, Integer received, Integer sent) {} + + /** {@code type} is comment, mention, reply, dm or review. */ + public record TypeRow(String type, Integer count) {} + + public record AccountRow( + String accountId, + String platform, + String username, + String name, + String avatar, + Integer received, + Integer sent) {} + + /** {@code bucket} is under_1h, 1h_6h, 6h_24h, 1d_3d or over_3d. */ + public record ResponseTimeRow(String bucket, Integer count) {} + + /** {@code weekday} 0 is Sunday; {@code hour} is 0 to 23. */ + public record HeatmapCell(Integer weekday, Integer hour, Integer count) {} +} diff --git a/src/main/java/com/fopost/sdk/model/LogEntry.java b/src/main/java/com/fopost/sdk/model/LogEntry.java new file mode 100644 index 0000000..ed90047 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/LogEntry.java @@ -0,0 +1,20 @@ +package com.fopost.sdk.model; + +import java.time.Instant; + +/** + * One API request made against a workspace. {@code route} is the pattern, such as + * {@code /v1/posts/{id}}; {@code message} is the error text of a failed request. + */ +public record LogEntry( + Long id, + String workspaceId, + Instant time, + String authMethod, + String method, + String route, + String path, + Integer statusCode, + Long durationMs, + String platform, + String message) {} diff --git a/src/main/java/com/fopost/sdk/model/LogPage.java b/src/main/java/com/fopost/sdk/model/LogPage.java new file mode 100644 index 0000000..46ac57d --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/LogPage.java @@ -0,0 +1,29 @@ +package com.fopost.sdk.model; + +import java.util.Iterator; +import java.util.List; + +/** + * One page of the request log, newest first. Pass {@code nextCursor} back as + * {@link com.fopost.sdk.param.LogParams#before} for the next page; it is {@code null} at the end. + */ +public record LogPage(List data, String nextCursor, Boolean hasMore, LogSummary summary) + implements Iterable { + + public LogPage { + data = data == null ? List.of() : List.copyOf(data); + } + + @Override + public Iterator iterator() { + return data.iterator(); + } + + public int size() { + return data.size(); + } + + public boolean isEmpty() { + return data.isEmpty(); + } +} diff --git a/src/main/java/com/fopost/sdk/model/LogSummary.java b/src/main/java/com/fopost/sdk/model/LogSummary.java new file mode 100644 index 0000000..0b405c1 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/LogSummary.java @@ -0,0 +1,10 @@ +package com.fopost.sdk.model; + +import java.time.Instant; +import java.util.List; + +/** Totals and hourly buckets for the whole filtered window, not just the page. */ +public record LogSummary(Instant since, Integer total, Integer errors, List buckets) { + + public record Bucket(Instant at, Integer total, Integer errors) {} +} diff --git a/src/main/java/com/fopost/sdk/model/QueueSlot.java b/src/main/java/com/fopost/sdk/model/QueueSlot.java new file mode 100644 index 0000000..648b6ca --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/QueueSlot.java @@ -0,0 +1,17 @@ +package com.fopost.sdk.model; + +import java.time.Instant; + +/** + * One recurring weekly posting slot. {@code accountId} is null when the slot applies to every + * account; {@code weekday} runs 0 (Sunday) to 6 (Saturday) and {@code time} is {@code HH:MM} in + * {@code timezone}. + */ +public record QueueSlot( + String id, + String workspaceId, + String accountId, + Integer weekday, + String time, + String timezone, + Instant createdAt) {} diff --git a/src/main/java/com/fopost/sdk/model/QueueSlotDeleted.java b/src/main/java/com/fopost/sdk/model/QueueSlotDeleted.java new file mode 100644 index 0000000..4c3c7b5 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/QueueSlotDeleted.java @@ -0,0 +1,6 @@ +package com.fopost.sdk.model; + +import java.util.List; + +/** The answer to deleting a slot: the queued posts that were moved to the next free slot. */ +public record QueueSlotDeleted(Boolean deleted, List moved) {} diff --git a/src/main/java/com/fopost/sdk/model/QueueSlotMove.java b/src/main/java/com/fopost/sdk/model/QueueSlotMove.java new file mode 100644 index 0000000..8d09917 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/QueueSlotMove.java @@ -0,0 +1,6 @@ +package com.fopost.sdk.model; + +import java.time.Instant; + +/** A queued post moved to a new time because its slot changed. {@code id} is the post id. */ +public record QueueSlotMove(String id, Instant from, Instant to) {} diff --git a/src/main/java/com/fopost/sdk/model/QueueSlotUpdate.java b/src/main/java/com/fopost/sdk/model/QueueSlotUpdate.java new file mode 100644 index 0000000..46b0e5e --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/QueueSlotUpdate.java @@ -0,0 +1,15 @@ +package com.fopost.sdk.model; + +import java.time.Instant; +import java.util.List; + +/** The slot after an update, plus the queued posts the change moved. */ +public record QueueSlotUpdate( + String id, + String workspaceId, + String accountId, + Integer weekday, + String time, + String timezone, + Instant createdAt, + List moved) {} diff --git a/src/main/java/com/fopost/sdk/model/UnpublishResult.java b/src/main/java/com/fopost/sdk/model/UnpublishResult.java new file mode 100644 index 0000000..5a956b5 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/UnpublishResult.java @@ -0,0 +1,12 @@ +package com.fopost.sdk.model; + +import java.util.List; + +/** + * The answer to an unpublish: the post's new status and one entry per network it was live on. + * {@code removed} is false where the network refused to take the post down. + */ +public record UnpublishResult(String postStatus, List deliveries) { + + public record Removal(String accountId, String platform, Boolean removed) {} +} diff --git a/src/main/java/com/fopost/sdk/model/WebhookDelivery.java b/src/main/java/com/fopost/sdk/model/WebhookDelivery.java new file mode 100644 index 0000000..8327060 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/WebhookDelivery.java @@ -0,0 +1,23 @@ +package com.fopost.sdk.model; + +import java.time.Instant; + +/** + * One attempt to deliver an event to a webhook. Every attempt of one event shares + * {@code deliveryKey} (the {@code X-FoPost-Delivery} id); {@code attempt} is 1 for the first send + * and counts up through retries and redeliveries. {@code status} is {@code success} or + * {@code failed}. + */ +public record WebhookDelivery( + String id, + String webhookId, + String event, + String deliveryKey, + Integer attempt, + String status, + Integer responseStatus, + String responseSnippet, + Long durationMs, + String payloadHash, + Instant nextRetryAt, + Instant createdAt) {} diff --git a/src/main/java/com/fopost/sdk/param/ConnectSessionParams.java b/src/main/java/com/fopost/sdk/param/ConnectSessionParams.java new file mode 100644 index 0000000..d6c4a1e --- /dev/null +++ b/src/main/java/com/fopost/sdk/param/ConnectSessionParams.java @@ -0,0 +1,54 @@ +package com.fopost.sdk.param; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * A hosted connect link to mint. {@code externalUserId} and {@code platforms} are required; + * {@code workspaceId} may be left out for a key bound to one workspace. + * + *
{@code
+ * ConnectSessionParams.of("user_42", List.of("linkedin", "threads"))
+ *     .workspaceId(workspaceId)
+ *     .redirectUrl("https://yourbrand.com/connected")
+ *     .branding("Your Brand", "https://yourbrand.com/logo.png");
+ * }
+ */ +public final class ConnectSessionParams { + + private final Map body = new LinkedHashMap<>(); + + private ConnectSessionParams(String externalUserId, List platforms) { + body.put("externalUserId", externalUserId); + body.put("platforms", platforms); + } + + public static ConnectSessionParams of(String externalUserId, List platforms) { + return new ConnectSessionParams(externalUserId, platforms); + } + + public ConnectSessionParams workspaceId(String workspaceId) { + body.put("workspaceId", workspaceId); + return this; + } + + /** Where the user lands once connected. Its domain has to be on the workspace redirect allowlist. */ + public ConnectSessionParams redirectUrl(String redirectUrl) { + body.put("redirectUrl", redirectUrl); + return this; + } + + /** The name and logo the hosted page carries. Either may be null. */ + public ConnectSessionParams branding(String name, String logoUrl) { + Map branding = new LinkedHashMap<>(); + Params.put(branding, "name", name); + Params.put(branding, "logoUrl", logoUrl); + body.put("branding", branding); + return this; + } + + public Map toMap() { + return new LinkedHashMap<>(body); + } +} diff --git a/src/main/java/com/fopost/sdk/param/CreatePostParams.java b/src/main/java/com/fopost/sdk/param/CreatePostParams.java index 8c63895..e2edcb7 100644 --- a/src/main/java/com/fopost/sdk/param/CreatePostParams.java +++ b/src/main/java/com/fopost/sdk/param/CreatePostParams.java @@ -31,6 +31,7 @@ public final class CreatePostParams { private String contentType; private String artifactType; private Instant scheduleAt; + private String schedule; private Boolean repeatable; private Integer repeatableTimes; private Integer repeatableGap; @@ -110,6 +111,12 @@ public CreatePostParams scheduleAt(Instant scheduleAt) { return this; } + /** Put the post in the next free posting-queue slot instead of pinning a time. */ + public CreatePostParams queue() { + this.schedule = "queue"; + return this; + } + /** {@code post}, {@code thread} or {@code reel}. */ public CreatePostParams contentType(String contentType) { this.contentType = contentType; @@ -198,6 +205,7 @@ public Map toMap() { Params.put(body, "content_type", contentType); Params.put(body, "artifact_type", artifactType); Params.put(body, "schedule_at", Params.iso(scheduleAt)); + Params.put(body, "schedule", schedule); Params.put(body, "repeatable", repeatable); Params.put(body, "repeatable_times", repeatableTimes); Params.put(body, "repeatable_gap", repeatableGap); diff --git a/src/main/java/com/fopost/sdk/param/LogParams.java b/src/main/java/com/fopost/sdk/param/LogParams.java new file mode 100644 index 0000000..26c081a --- /dev/null +++ b/src/main/java/com/fopost/sdk/param/LogParams.java @@ -0,0 +1,76 @@ +package com.fopost.sdk.param; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Filters for the request log. Every field is optional; leaving {@code workspaceId} unset reads + * every workspace the key can reach plus its own account-level requests. + */ +public final class LogParams { + + private final Map query = new LinkedHashMap<>(); + + public static LogParams create() { + return new LogParams(); + } + + public LogParams workspaceId(String workspaceId) { + query.put("workspace_id", workspaceId); + return this; + } + + /** How far back to read, 1 to 30. */ + public LogParams days(int days) { + query.put("days", days); + return this; + } + + /** {@code success} or {@code failed}. */ + public LogParams status(String status) { + query.put("status", status); + return this; + } + + public LogParams method(String method) { + query.put("method", method); + return this; + } + + public LogParams statusCode(int statusCode) { + query.put("status_code", statusCode); + return this; + } + + public LogParams platform(String platform) { + query.put("platform", platform); + return this; + } + + /** Exact route pattern, such as {@code /v1/posts/{id}}. */ + public LogParams route(String route) { + query.put("route", route); + return this; + } + + /** Substring match against path and message. */ + public LogParams q(String q) { + query.put("q", q); + return this; + } + + public LogParams limit(int limit) { + query.put("limit", limit); + return this; + } + + /** The {@code nextCursor} of the previous page: rows older than this id. */ + public LogParams before(String before) { + query.put("before", before); + return this; + } + + public Map toQuery() { + return new LinkedHashMap<>(query); + } +} diff --git a/src/main/java/com/fopost/sdk/param/QueueSlotParams.java b/src/main/java/com/fopost/sdk/param/QueueSlotParams.java new file mode 100644 index 0000000..b199953 --- /dev/null +++ b/src/main/java/com/fopost/sdk/param/QueueSlotParams.java @@ -0,0 +1,49 @@ +package com.fopost.sdk.param; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * A posting slot to create or change. Creating needs {@code weekday}, {@code time} and + * {@code timezone}; an update sends only what was set. + * + *
{@code
+ * QueueSlotParams.create().weekday(1).time("09:30").timezone("Europe/Berlin");
+ * }
+ */ +public final class QueueSlotParams { + + private final Map body = new LinkedHashMap<>(); + + public static QueueSlotParams create() { + return new QueueSlotParams(); + } + + /** Limit the slot to one account; null applies it to every account in the workspace. */ + public QueueSlotParams accountId(String accountId) { + body.put("account_id", accountId); + return this; + } + + /** 0 is Sunday, 6 is Saturday. */ + public QueueSlotParams weekday(int weekday) { + body.put("weekday", weekday); + return this; + } + + /** {@code HH:MM}, 24-hour, in the slot's time zone. */ + public QueueSlotParams time(String time) { + body.put("time", time); + return this; + } + + /** An IANA zone name. */ + public QueueSlotParams timezone(String timezone) { + body.put("timezone", timezone); + return this; + } + + public Map toMap() { + return new LinkedHashMap<>(body); + } +} diff --git a/src/main/java/com/fopost/sdk/param/UpdatePostParams.java b/src/main/java/com/fopost/sdk/param/UpdatePostParams.java index bfdc736..6941a67 100644 --- a/src/main/java/com/fopost/sdk/param/UpdatePostParams.java +++ b/src/main/java/com/fopost/sdk/param/UpdatePostParams.java @@ -59,6 +59,12 @@ public UpdatePostParams scheduleAt(Instant scheduleAt) { return this; } + /** Move the post to the next free posting-queue slot. */ + public UpdatePostParams queue() { + body.put("schedule", "queue"); + return this; + } + public UpdatePostParams contentType(String contentType) { body.put("content_type", contentType); return this; diff --git a/src/main/java/com/fopost/sdk/resource/AccountsResource.java b/src/main/java/com/fopost/sdk/resource/AccountsResource.java index dbb7728..d05364c 100644 --- a/src/main/java/com/fopost/sdk/resource/AccountsResource.java +++ b/src/main/java/com/fopost/sdk/resource/AccountsResource.java @@ -382,12 +382,18 @@ public WebhookSubscription resubscribeWebhook(String accountId) { * *

A 409 {@code webhook_connection} means the account posts through a webhook; upgrade it to * the bot first. The same applies to every other Discord call here. + * + *

Wraps the API's {@code listDiscordChannels} operation. */ public List listDiscordChannels(String accountId) { return http.convertList(ApiClient.unwrap(http.get(discord(accountId, "/channels"), null)), DiscordChannel.class); } - /** Move the account to another channel in the same server. */ + /** + * Move the account to another channel in the same server. + * + *

Wraps the API's {@code switchDiscordChannel} operation. + */ public DiscordChannel switchDiscordChannel(String accountId, String channelId) { ObjectNode body = Json.MAPPER.createObjectNode().put("channel_id", channelId); return http.convert( @@ -395,25 +401,41 @@ public DiscordChannel switchDiscordChannel(String accountId, String channelId) { DiscordChannel.class); } - /** The nickname and avatar the bot wears in the server. */ + /** + * The nickname and avatar the bot wears in the server. + * + *

Wraps the API's {@code getDiscordIdentity} operation. + */ public DiscordIdentity getDiscordIdentity(String accountId) { return http.convert(ApiClient.unwrap(http.get(discord(accountId, "/identity"), null)), DiscordIdentity.class); } - /** Change the nickname or avatar the bot wears in the server. */ + /** + * Change the nickname or avatar the bot wears in the server. + * + *

Wraps the API's {@code updateDiscordIdentity} operation. + */ public DiscordIdentity updateDiscordIdentity(String accountId, UpdateDiscordIdentityParams params) { return http.convert( ApiClient.unwrap(http.request("PATCH", discord(accountId, "/identity"), params.toJson(), null)), DiscordIdentity.class); } - /** Pinned messages in the account's channel. */ + /** + * Pinned messages in the account's channel. + * + *

Wraps the API's {@code listDiscordPins} operation. + */ public List listDiscordPins(String accountId) { return http.convertList( ApiClient.unwrap(http.get(discord(accountId, "/messages/pinned"), null)), DiscordMessage.class); } - /** Remove a message from the account's channel. */ + /** + * Remove a message from the account's channel. + * + *

Wraps the API's {@code deleteDiscordMessage} operation. + */ public DiscordAck deleteDiscordMessage(String accountId, String messageId) { return ack(http.delete(discord(accountId, "/messages/" + messageId))); } @@ -452,7 +474,11 @@ public DiscordThread createDiscordThread( DiscordThread.class); } - /** Send one message to a member of the server. */ + /** + * Send one message to a member of the server. + * + *

Wraps the API's {@code sendDiscordDm} operation. + */ public DiscordMessageRef sendDiscordDm(String accountId, String memberId, String content) { ObjectNode body = Json.MAPPER.createObjectNode(); body.put("member_id", memberId); @@ -461,39 +487,63 @@ public DiscordMessageRef sendDiscordDm(String accountId, String memberId, String ApiClient.unwrap(http.post(discord(accountId, "/dm"), body)), DiscordMessageRef.class); } - /** The server's scheduled events. */ + /** + * The server's scheduled events. + * + *

Wraps the API's {@code listDiscordEvents} operation. + */ public List listDiscordEvents(String accountId) { return http.convertList( ApiClient.unwrap(http.get(discord(accountId, "/events"), null)), DiscordScheduledEvent.class); } - /** One scheduled event. */ + /** + * One scheduled event. + * + *

Wraps the API's {@code getDiscordEvent} operation. + */ public DiscordScheduledEvent getDiscordEvent(String accountId, String eventId) { return http.convert( ApiClient.unwrap(http.get(discord(accountId, "/events/" + eventId), null)), DiscordScheduledEvent.class); } - /** Add an event to the server's calendar. */ + /** + * Add an event to the server's calendar. + * + *

Wraps the API's {@code createDiscordEvent} operation. + */ public DiscordScheduledEvent createDiscordEvent(String accountId, DiscordEventParams params) { return http.convert( ApiClient.unwrap(http.post(discord(accountId, "/events"), params.toJson())), DiscordScheduledEvent.class); } - /** Change a scheduled event. */ + /** + * Change a scheduled event. + * + *

Wraps the API's {@code updateDiscordEvent} operation. + */ public DiscordScheduledEvent updateDiscordEvent(String accountId, String eventId, DiscordEventParams params) { return http.convert( ApiClient.unwrap(http.request("PATCH", discord(accountId, "/events/" + eventId), params.toJson(), null)), DiscordScheduledEvent.class); } - /** Remove a scheduled event. */ + /** + * Remove a scheduled event. + * + *

Wraps the API's {@code deleteDiscordEvent} operation. + */ public DiscordAck deleteDiscordEvent(String accountId, String eventId) { return ack(http.delete(discord(accountId, "/events/" + eventId))); } - /** The server's roster. */ + /** + * The server's roster. + * + *

Wraps the API's {@code listDiscordMembers} operation. + */ public List listDiscordMembers(String accountId) { return listDiscordMembers(accountId, null, null); } @@ -512,30 +562,50 @@ public List listDiscordMembers(String accountId, String query, In DiscordMember.class); } - /** One member of the server. */ + /** + * One member of the server. + * + *

Wraps the API's {@code getDiscordMember} operation. + */ public DiscordMember getDiscordMember(String accountId, String memberId) { return http.convert( ApiClient.unwrap(http.get(discord(accountId, "/members/" + memberId), null)), DiscordMember.class); } - /** The server's roles, highest first. */ + /** + * The server's roles, highest first. + * + *

Wraps the API's {@code listDiscordRoles} operation. + */ public List listDiscordRoles(String accountId) { return http.convertList(ApiClient.unwrap(http.get(discord(accountId, "/roles"), null)), DiscordRole.class); } - /** Add a role to the server. */ + /** + * Add a role to the server. + * + *

Wraps the API's {@code createDiscordRole} operation. + */ public DiscordRole createDiscordRole(String accountId, DiscordRoleParams params) { return http.convert(ApiClient.unwrap(http.post(discord(accountId, "/roles"), params.toJson())), DiscordRole.class); } - /** Change a role on the server. */ + /** + * Change a role on the server. + * + *

Wraps the API's {@code updateDiscordRole} operation. + */ public DiscordRole updateDiscordRole(String accountId, String roleId, DiscordRoleParams params) { return http.convert( ApiClient.unwrap(http.request("PATCH", discord(accountId, "/roles/" + roleId), params.toJson(), null)), DiscordRole.class); } - /** Remove a role from the server. */ + /** + * Remove a role from the server. + * + *

Wraps the API's {@code deleteDiscordRole} operation. + */ public DiscordAck deleteDiscordRole(String accountId, String roleId) { return ack(http.delete(discord(accountId, "/roles/" + roleId))); } diff --git a/src/main/java/com/fopost/sdk/resource/AdsResource.java b/src/main/java/com/fopost/sdk/resource/AdsResource.java index cc1b2c9..4461c1e 100644 --- a/src/main/java/com/fopost/sdk/resource/AdsResource.java +++ b/src/main/java/com/fopost/sdk/resource/AdsResource.java @@ -1082,13 +1082,21 @@ public ReachFrequencyPrediction reachFrequencyPrediction( ReachFrequencyPrediction.class); } - /** Holds the inventory the prediction priced. Also needs the {@code publish} scope. */ + /** + * Holds the inventory the prediction priced. Also needs the {@code publish} scope. + * + *

Wraps the API's {@code reserveReachFrequency} operation. + */ public ReachFrequencyPrediction reserveReachFrequency( String predictionId, ReachFrequencyActionParams params) { return reachFrequencyAction(predictionId, "reserve", params); } - /** Also needs the {@code publish} scope. */ + /** + * Also needs the {@code publish} scope. + * + *

Wraps the API's {@code cancelReachFrequency} operation. + */ public ReachFrequencyPrediction cancelReachFrequency( String predictionId, ReachFrequencyActionParams params) { return reachFrequencyAction(predictionId, "cancel", params); diff --git a/src/main/java/com/fopost/sdk/resource/AnalyticsResource.java b/src/main/java/com/fopost/sdk/resource/AnalyticsResource.java index dcfa2a5..045ce7b 100644 --- a/src/main/java/com/fopost/sdk/resource/AnalyticsResource.java +++ b/src/main/java/com/fopost/sdk/resource/AnalyticsResource.java @@ -6,6 +6,7 @@ import com.fopost.sdk.model.CollectSummary; import com.fopost.sdk.model.ContentDecay; import com.fopost.sdk.model.Demographics; +import com.fopost.sdk.model.InboxAnalytics; import com.fopost.sdk.model.LabelAnalytics; import com.fopost.sdk.model.MetricChangePage; import com.fopost.sdk.model.NativePost; @@ -78,6 +79,28 @@ public Demographics demographics(AnalyticsParams params) { ApiClient.unwrap(http.get("/v1/analytics/demographics", params.toQuery())), Demographics.class); } + /** + * How the inbox performed: volume received and sent, reply coverage, median first-reply time, + * per-platform and per-account rows, response-time buckets and a weekday-by-hour heatmap. + * Honours {@code accountId}, {@code workspaceId} and {@code days}; {@code tz} buckets the + * heatmap and defaults to UTC. + */ + public InboxAnalytics inbox(AnalyticsParams params, String tz) { + Map query = new LinkedHashMap<>(); + if (params != null) { + // This endpoint reads the account under its snake_case name only. + params.toQuery().forEach((key, value) -> query.put("accountId".equals(key) ? "account_id" : key, value)); + } + if (tz != null) { + query.put("tz", tz); + } + return http.convert(ApiClient.unwrap(http.get("/v1/analytics/inbox", query)), InboxAnalytics.class); + } + + public InboxAnalytics inbox(AnalyticsParams params) { + return inbox(params, null); + } + /** Fetch fresh figures from the platforms now, rather than waiting for the next scheduled pass. */ public CollectSummary collect() { return collect(null); diff --git a/src/main/java/com/fopost/sdk/resource/AutomationsResource.java b/src/main/java/com/fopost/sdk/resource/AutomationsResource.java index 127a396..a013ff9 100644 --- a/src/main/java/com/fopost/sdk/resource/AutomationsResource.java +++ b/src/main/java/com/fopost/sdk/resource/AutomationsResource.java @@ -3,9 +3,12 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fopost.sdk.internal.ApiClient; import com.fopost.sdk.model.Automation; +import com.fopost.sdk.model.AutomationRestore; import com.fopost.sdk.model.AutomationRun; import com.fopost.sdk.model.AutomationStats; import com.fopost.sdk.model.AutomationTrigger; +import com.fopost.sdk.model.AutomationVersion; +import com.fopost.sdk.model.AutomationVersionPage; import com.fopost.sdk.model.Page; import com.fopost.sdk.model.PageMeta; import com.fopost.sdk.param.AutomationParams; @@ -87,6 +90,43 @@ public AutomationTrigger trigger(String automationId, Map payloa AutomationTrigger.class); } + /** Every saved graph, newest first, with the nodes each save added and removed. */ + public AutomationVersionPage versions(String automationId) { + return versions(automationId, null, null); + } + + public AutomationVersionPage versions(String automationId, Integer page, Integer perPage) { + Map query = new LinkedHashMap<>(); + if (page != null) { + query.put("page", page); + } + if (perPage != null) { + query.put("per_page", perPage); + } + JsonNode body = http.get("/v1/automations/" + automationId + "/versions", query); + return new AutomationVersionPage( + http.convertList(body.path("data"), AutomationVersion.class), + http.convert(body.path("meta"), AutomationVersionPage.Meta.class)); + } + + /** One version, with its full graph. */ + public AutomationVersion version(String automationId, int version) { + return http.convert( + ApiClient.unwrap(http.get("/v1/automations/" + automationId + "/versions/" + version, null)), + AutomationVersion.class); + } + + /** + * Save that version's graph as a new version on top of the history; nothing is rewritten. The + * old graph must still validate under the automation's current trigger and workspace. + */ + public AutomationRestore restoreVersion(String automationId, int version) { + return http.convert( + ApiClient.unwrap(http.post( + "/v1/automations/" + automationId + "/versions/" + version + "/restore", null)), + AutomationRestore.class); + } + public AutomationStats stats() { return http.convert(ApiClient.unwrap(http.get("/v1/automations/stats", null)), AutomationStats.class); } diff --git a/src/main/java/com/fopost/sdk/resource/BlogResource.java b/src/main/java/com/fopost/sdk/resource/BlogResource.java index c6583b4..6af80a0 100644 --- a/src/main/java/com/fopost/sdk/resource/BlogResource.java +++ b/src/main/java/com/fopost/sdk/resource/BlogResource.java @@ -204,7 +204,11 @@ public BlogArticleFull updateArticle(String workspaceId, String articleId, BlogP BlogArticleFull.class); } - /** Write the article again from its instructions. Spends AI credits. */ + /** + * Write the article again from its instructions. Spends AI credits. + * + *

Wraps the API's {@code regenerateBlogArticle} operation. + */ public BlogArticle regenerateArticle(String workspaceId, String articleId) { return action(workspaceId, articleId, "regenerate"); } @@ -218,12 +222,20 @@ public BlogArticle publishArticle(String workspaceId, String articleId) { return action(workspaceId, articleId, "publish"); } - /** Move a draft, failed or rejected article to {@code in_review}. Any other state answers 409. */ + /** + * Move a draft, failed or rejected article to {@code in_review}. Any other state answers 409. + * + *

Wraps the API's {@code submitBlogArticle} operation. + */ public BlogArticle submitArticle(String workspaceId, String articleId) { return action(workspaceId, articleId, "submit"); } - /** Take an article out of review without publishing it. */ + /** + * Take an article out of review without publishing it. + * + *

Wraps the API's {@code rejectBlogArticle} operation. + */ public BlogArticle rejectArticle(String workspaceId, String articleId) { return action(workspaceId, articleId, "reject"); } diff --git a/src/main/java/com/fopost/sdk/resource/ConnectSessionsResource.java b/src/main/java/com/fopost/sdk/resource/ConnectSessionsResource.java new file mode 100644 index 0000000..652d66c --- /dev/null +++ b/src/main/java/com/fopost/sdk/resource/ConnectSessionsResource.java @@ -0,0 +1,37 @@ +package com.fopost.sdk.resource; + +import com.fopost.sdk.internal.ApiClient; +import com.fopost.sdk.model.ConnectSession; +import com.fopost.sdk.param.ConnectSessionParams; + +/** + * Hosted connect links: a single-use page, open for one hour, where one of your end users connects + * a social account into your workspace with no FoPost login. The account is tagged with the + * {@code externalUserId} you pass. Needs the {@code accounts} scope. + * + *

{@code
+ * ConnectSession session = client.connectSessions()
+ *         .create(ConnectSessionParams.of("user_42", List.of("linkedin")).workspaceId(workspaceId));
+ * // hand session.url() to the user, then poll:
+ * ConnectSession later = client.connectSessions().get(session.id());
+ * }
+ */ +public final class ConnectSessionsResource { + + private final ApiClient http; + + public ConnectSessionsResource(ApiClient http) { + this.http = http; + } + + /** Mint a link. {@code url} is on the returned session and is never shown again. */ + public ConnectSession create(ConnectSessionParams params) { + return http.convert(ApiClient.unwrap(http.post("/v1/connect-sessions", params.toMap())), ConnectSession.class); + } + + /** The session's state, and the connected account once it has been used. */ + public ConnectSession get(String sessionId) { + return http.convert( + ApiClient.unwrap(http.get("/v1/connect-sessions/" + sessionId, null)), ConnectSession.class); + } +} diff --git a/src/main/java/com/fopost/sdk/resource/GoogleBusinessResource.java b/src/main/java/com/fopost/sdk/resource/GoogleBusinessResource.java index 40f10b8..24f0001 100644 --- a/src/main/java/com/fopost/sdk/resource/GoogleBusinessResource.java +++ b/src/main/java/com/fopost/sdk/resource/GoogleBusinessResource.java @@ -36,7 +36,11 @@ public GoogleBusinessResource(ApiClient http) { this.http = http; } - /** The connected location, in the Business Information shape. */ + /** + * The connected location, in the Business Information shape. + * + *

Wraps the API's {@code getGoogleBusinessLocation} operation. + */ public JsonNode getLocation(String accountId) { return ApiClient.unwrap(http.get(path(accountId, "/location"), null)); } @@ -46,12 +50,18 @@ public JsonNode getLocation(String accountId) { * clears that field. Keys are the API's own snake_case names, such as * {@code title}, {@code description}, {@code website_uri} and * {@code primary_phone}. + * + *

Wraps the API's {@code updateGoogleBusinessLocation} operation. */ public JsonNode updateLocation(String accountId, Map fields) { return ApiClient.unwrap(http.request("PATCH", path(accountId, "/location"), body(fields), null)); } - /** The attribute values set on the location. */ + /** + * The attribute values set on the location. + * + *

Wraps the API's {@code getGoogleBusinessAttributes} operation. + */ public JsonNode getAttributes(String accountId) { return getAttributes(accountId, false, null, null, null); } @@ -72,33 +82,57 @@ public JsonNode getAttributes( return ApiClient.unwrap(http.get(path(accountId, "/attributes"), query.isEmpty() ? null : query)); } - /** Only the named attributes change; every other one is left alone. */ + /** + * Only the named attributes change; every other one is left alone. + * + *

Wraps the API's {@code updateGoogleBusinessAttributes} operation. + */ public JsonNode updateAttributes(String accountId, List> attributes) { return ApiClient.unwrap(http.request( "PATCH", path(accountId, "/attributes"), Map.of("attributes", attributes), null)); } - /** The location's food menus. */ + /** + * The location's food menus. + * + *

Wraps the API's {@code getGoogleBusinessMenus} operation. + */ public JsonNode getMenus(String accountId) { return ApiClient.unwrap(http.get(path(accountId, "/menus"), null)); } - /** Google has no per-section patch, so the whole menu set is replaced. */ + /** + * Google has no per-section patch, so the whole menu set is replaced. + * + *

Wraps the API's {@code replaceGoogleBusinessMenus} operation. + */ public JsonNode replaceMenus(String accountId, List> menus) { return ApiClient.unwrap(http.put(path(accountId, "/menus"), Map.of("menus", menus))); } - /** The location's service list. */ + /** + * The location's service list. + * + *

Wraps the API's {@code getGoogleBusinessServices} operation. + */ public JsonNode getServices(String accountId) { return ApiClient.unwrap(http.get(path(accountId, "/services"), null)); } - /** Replace the whole service list. */ + /** + * Replace the whole service list. + * + *

Wraps the API's {@code replaceGoogleBusinessServices} operation. + */ public JsonNode replaceServices(String accountId, List> serviceItems) { return ApiClient.unwrap(http.put(path(accountId, "/services"), Map.of("service_items", serviceItems))); } - /** The photos on the location. */ + /** + * The photos on the location. + * + *

Wraps the API's {@code listGoogleBusinessMedia} operation. + */ public JsonNode listMedia(String accountId) { return listMedia(accountId, 0, null); } @@ -115,6 +149,8 @@ public JsonNode listMedia(String accountId, int pageSize, String pageToken) { /** * Add a photo from the media library. The asset has to be in a workspace * the caller can reach, and JPEG or PNG. + * + *

Wraps the API's {@code addGoogleBusinessMedia} operation. */ public JsonNode addMedia(String accountId, String mediaId, String category, String description) { Map body = new LinkedHashMap<>(); @@ -124,17 +160,29 @@ public JsonNode addMedia(String accountId, String mediaId, String category, Stri return ApiClient.unwrap(http.post(path(accountId, "/media"), body)); } - /** Remove a photo by the media key Google returned. */ + /** + * Remove a photo by the media key Google returned. + * + *

Wraps the API's {@code deleteGoogleBusinessMedia} operation. + */ public JsonNode deleteMedia(String accountId, String mediaKey) { return ApiClient.unwrap(http.delete(path(accountId, "/media/" + mediaKey))); } - /** The Book, Order and Reserve links on the listing. */ + /** + * The Book, Order and Reserve links on the listing. + * + *

Wraps the API's {@code listGoogleBusinessPlaceActions} operation. + */ public JsonNode listPlaceActions(String accountId) { return ApiClient.unwrap(http.get(path(accountId, "/place-actions"), null)); } - /** Add an action link to the listing. */ + /** + * Add an action link to the listing. + * + *

Wraps the API's {@code createGoogleBusinessPlaceAction} operation. + */ public JsonNode createPlaceAction(String accountId, String uri, String placeActionType, Boolean isPreferred) { Map body = new LinkedHashMap<>(); body.put("uri", uri); @@ -143,7 +191,11 @@ public JsonNode createPlaceAction(String accountId, String uri, String placeActi return ApiClient.unwrap(http.post(path(accountId, "/place-actions"), body)); } - /** Patch one action link; a null argument is left alone. */ + /** + * Patch one action link; a null argument is left alone. + * + *

Wraps the API's {@code updateGoogleBusinessPlaceAction} operation. + */ public JsonNode updatePlaceAction(String accountId, String linkId, String uri, Boolean isPreferred) { Map body = new LinkedHashMap<>(); putIfPresent(body, "uri", uri); @@ -152,12 +204,20 @@ public JsonNode updatePlaceAction(String accountId, String linkId, String uri, B http.request("PATCH", path(accountId, "/place-actions/" + linkId), body, null)); } - /** Remove one action link. */ + /** + * Remove one action link. + * + *

Wraps the API's {@code deleteGoogleBusinessPlaceAction} operation. + */ public JsonNode deletePlaceAction(String accountId, String linkId) { return ApiClient.unwrap(http.delete(path(accountId, "/place-actions/" + linkId))); } - /** The ways Google will let this location be verified. */ + /** + * The ways Google will let this location be verified. + * + *

Wraps the API's {@code getGoogleBusinessVerificationOptions} operation. + */ public JsonNode getVerificationOptions(String accountId) { return getVerificationOptions(accountId, null); } @@ -172,6 +232,8 @@ public JsonNode getVerificationOptions(String accountId, String languageCode) { * Start a verification. {@code method} is {@code ADDRESS}, {@code EMAIL}, * {@code PHONE_CALL}, {@code SMS}, {@code AUTO} or {@code VETTED_PARTNER}; * the response names the pending verification to complete with the PIN. + * + *

Wraps the API's {@code startGoogleBusinessVerification} operation. */ public JsonNode startVerification(String accountId, String method, Map options) { Map body = new LinkedHashMap<>(); @@ -182,7 +244,11 @@ public JsonNode startVerification(String accountId, String method, MapWraps the API's {@code completeGoogleBusinessVerification} operation. + */ public JsonNode completeVerification(String accountId, String verificationName, String pin) { return ApiClient.unwrap(http.post( path(accountId, "/verification/complete"), @@ -192,6 +258,8 @@ public JsonNode completeVerification(String accountId, String verificationName, /** * Daily impressions, calls, direction requests and clicks for the range. * A null or empty {@code dailyMetrics} leaves the API's own default set. + * + *

Wraps the API's {@code getGoogleBusinessPerformance} operation. */ public JsonNode getPerformance(String accountId, String startDate, String endDate, List dailyMetrics) { Map query = new LinkedHashMap<>(); @@ -216,6 +284,8 @@ public JsonNode getSearchKeywords(String accountId, String startDate, String end /** * Hand the location to another workspace the caller owns. The connection * and every row keyed to it move in one transaction. + * + *

Wraps the API's {@code assignGoogleBusinessLocation} operation. */ public JsonNode assign(String accountId, String workspaceId) { return ApiClient.unwrap(http.post(path(accountId, "/assign"), Map.of("workspace_id", workspaceId))); diff --git a/src/main/java/com/fopost/sdk/resource/LogsResource.java b/src/main/java/com/fopost/sdk/resource/LogsResource.java new file mode 100644 index 0000000..e243929 --- /dev/null +++ b/src/main/java/com/fopost/sdk/resource/LogsResource.java @@ -0,0 +1,37 @@ +package com.fopost.sdk.resource; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fopost.sdk.internal.ApiClient; +import com.fopost.sdk.model.LogEntry; +import com.fopost.sdk.model.LogPage; +import com.fopost.sdk.model.LogSummary; +import com.fopost.sdk.param.LogParams; +import java.util.List; + +/** The request log: every API call members made, kept for 30 days. Needs the {@code workspaces} scope. */ +public final class LogsResource { + + private final ApiClient http; + + public LogsResource(ApiClient http) { + this.http = http; + } + + public LogPage list() { + return list(LogParams.create()); + } + + /** Requests newest first, with route, status code, duration and any error message. */ + public LogPage list(LogParams params) { + // The response carries meta and summary beside data, so it is read whole rather than unwrapped. + JsonNode body = http.get("/v1/logs", params == null ? null : params.toQuery()); + List entries = http.convertList(body.path("data"), LogEntry.class); + JsonNode meta = body.path("meta"); + JsonNode cursor = meta.path("next_cursor"); + return new LogPage( + entries, + cursor.isNull() || cursor.isMissingNode() ? null : cursor.asText(), + meta.path("has_more").asBoolean(false), + http.convert(body.path("summary"), LogSummary.class)); + } +} diff --git a/src/main/java/com/fopost/sdk/resource/PostsResource.java b/src/main/java/com/fopost/sdk/resource/PostsResource.java index c9f37b1..06b4c76 100644 --- a/src/main/java/com/fopost/sdk/resource/PostsResource.java +++ b/src/main/java/com/fopost/sdk/resource/PostsResource.java @@ -21,6 +21,7 @@ import com.fopost.sdk.model.PublishResult; import com.fopost.sdk.model.PublishRun; import com.fopost.sdk.model.RetryResult; +import com.fopost.sdk.model.UnpublishResult; import com.fopost.sdk.model.UploadLimits; import com.fopost.sdk.model.UploadedMedia; import com.fopost.sdk.param.CreatePostParams; @@ -198,6 +199,16 @@ public CancelResult cancel(String postId, List accountIds) { return http.convert(ApiClient.unwrap(http.post("/v1/posts/" + postId + "/cancel", body)), CancelResult.class); } + /** + * Delete the post on every network it is live on. Each delivery becomes {@code unpublished} and + * the post stays as a record. Refused with 409 and {@code unsupported_platforms} when one of + * those networks cannot delete a live post. Needs the {@code publish} scope. + */ + public UnpublishResult unpublish(String postId) { + return http.convert( + ApiClient.unwrap(http.post("/v1/posts/" + postId + "/unpublish", null)), UnpublishResult.class); + } + /** Per-account blockers and advisory content signals, without publishing anything. */ public PreflightResult preflight(String postId) { return http.convert( diff --git a/src/main/java/com/fopost/sdk/resource/QueueResource.java b/src/main/java/com/fopost/sdk/resource/QueueResource.java new file mode 100644 index 0000000..5c8ad91 --- /dev/null +++ b/src/main/java/com/fopost/sdk/resource/QueueResource.java @@ -0,0 +1,90 @@ +package com.fopost.sdk.resource; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fopost.sdk.internal.ApiClient; +import com.fopost.sdk.model.QueueSlot; +import com.fopost.sdk.model.QueueSlotDeleted; +import com.fopost.sdk.model.QueueSlotUpdate; +import com.fopost.sdk.param.QueueSlotParams; +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * The posting queue: recurring weekly slots, and the times a queued post would get. A post joins + * the queue with {@code schedule: queue} on create or update, see + * {@link com.fopost.sdk.param.CreatePostParams#queue()}. Every call needs the {@code posts} scope. + * + *

{@code
+ * client.queue().createSlot(workspaceId, QueueSlotParams.create().weekday(1).time("09:30").timezone("UTC"));
+ * Instant next = client.queue().next(workspaceId);
+ * }
+ */ +public final class QueueResource { + + private final ApiClient http; + + public QueueResource(ApiClient http) { + this.http = http; + } + + /** The workspace's slots, ordered by weekday and time. */ + public List listSlots(String workspaceId) { + Map query = new LinkedHashMap<>(); + query.put("workspace_id", workspaceId); + return http.convertList(ApiClient.unwrap(http.get("/v1/queue/slots", query)), QueueSlot.class); + } + + public QueueSlot createSlot(String workspaceId, QueueSlotParams params) { + Map body = new LinkedHashMap<>(); + body.put("workspace_id", workspaceId); + body.putAll(params.toMap()); + return http.convert(ApiClient.unwrap(http.post("/v1/queue/slots", body)), QueueSlot.class); + } + + /** Change a slot. Queued posts sitting on it move with it, listed in {@code moved}. */ + public QueueSlotUpdate updateSlot(String slotId, QueueSlotParams params) { + return http.convert( + ApiClient.unwrap(http.put("/v1/queue/slots/" + slotId, params.toMap())), QueueSlotUpdate.class); + } + + /** Remove a slot. Queued posts sitting on it move to the next free one, listed in {@code moved}. */ + public QueueSlotDeleted deleteSlot(String slotId) { + return http.convert(ApiClient.unwrap(http.delete("/v1/queue/slots/" + slotId)), QueueSlotDeleted.class); + } + + /** The time a post added to the queue now would get, or null when no slot applies. */ + public Instant next(String workspaceId) { + return next(workspaceId, null); + } + + /** As {@link #next(String)}, for one account's slots. */ + public Instant next(String workspaceId, String accountId) { + JsonNode next = ApiClient.unwrap(http.get("/v1/queue/next", query(workspaceId, accountId, null))).path("next"); + return next.isMissingNode() || next.isNull() ? null : Instant.parse(next.asText()); + } + + /** The next free occurrences in order, a post-by-post preview of the queue. */ + public List preview(String workspaceId, String accountId, Integer count) { + JsonNode data = ApiClient.unwrap(http.get("/v1/queue/preview", query(workspaceId, accountId, count))); + List times = new ArrayList<>(); + for (JsonNode node : data) { + times.add(Instant.parse(node.asText())); + } + return times; + } + + private static Map query(String workspaceId, String accountId, Integer count) { + Map query = new LinkedHashMap<>(); + query.put("workspace_id", workspaceId); + if (accountId != null) { + query.put("account_id", accountId); + } + if (count != null) { + query.put("count", count); + } + return query; + } +} diff --git a/src/main/java/com/fopost/sdk/resource/WebhooksResource.java b/src/main/java/com/fopost/sdk/resource/WebhooksResource.java index eb94e78..f523607 100644 --- a/src/main/java/com/fopost/sdk/resource/WebhooksResource.java +++ b/src/main/java/com/fopost/sdk/resource/WebhooksResource.java @@ -4,6 +4,7 @@ import com.fopost.sdk.WebhookVerifier; import com.fopost.sdk.internal.ApiClient; import com.fopost.sdk.model.Webhook; +import com.fopost.sdk.model.WebhookDelivery; import com.fopost.sdk.param.UpdateWebhookParams; import java.util.LinkedHashMap; import java.util.List; @@ -49,6 +50,36 @@ public void test(String webhookId) { http.post("/v1/webhooks/" + webhookId + "/test", null); } + /** Every delivery attempt to the endpoint, newest first. Attempts are kept for 30 days. */ + public List deliveries(String webhookId) { + return deliveries(webhookId, null, null); + } + + /** As {@link #deliveries(String)}, filtered to {@code success} or {@code failed} and capped at {@code limit}. */ + public List deliveries(String webhookId, String status, Integer limit) { + Map query = new LinkedHashMap<>(); + if (status != null) { + query.put("status", status); + } + if (limit != null) { + query.put("limit", limit); + } + return http.convertList( + ApiClient.unwrap(http.get("/v1/webhooks/" + webhookId + "/deliveries", query)), + WebhookDelivery.class); + } + + public WebhookDelivery delivery(String webhookId, String deliveryId) { + return http.convert( + ApiClient.unwrap(http.get("/v1/webhooks/" + webhookId + "/deliveries/" + deliveryId, null)), + WebhookDelivery.class); + } + + /** Send the same event again, as a new attempt under the same delivery key. */ + public void redeliver(String webhookId, String deliveryId) { + http.post("/v1/webhooks/" + webhookId + "/deliveries/" + deliveryId + "/redeliver", null); + } + /** * Check a delivery's signature and parse it. {@code rawBody} is the request body exactly as * received. See {@link WebhookVerifier} for the rule and the failure codes. diff --git a/src/main/java/com/fopost/sdk/resource/WhatsappResource.java b/src/main/java/com/fopost/sdk/resource/WhatsappResource.java index 43899ac..f8004bc 100644 --- a/src/main/java/com/fopost/sdk/resource/WhatsappResource.java +++ b/src/main/java/com/fopost/sdk/resource/WhatsappResource.java @@ -39,25 +39,35 @@ private String base(String accountId) { // ─── Profile ────────────────────────────────────────────────────────────── + /** Wraps the API's {@code getWhatsappProfile} operation. */ public WhatsappProfile getProfile(String accountId) { return http.convert( ApiClient.unwrap(http.get(base(accountId) + "/profile", null)), WhatsappProfile.class); } - /** A partial update: keys left out of {@code fields} keep their value. */ + /** + * A partial update: keys left out of {@code fields} keep their value. + * + *

Wraps the API's {@code updateWhatsappProfile} operation. + */ public WhatsappProfile updateProfile(String accountId, Map fields) { return http.convert( ApiClient.unwrap(http.request("PATCH", base(accountId) + "/profile", fields, null)), WhatsappProfile.class); } - /** A review, not a write: the number keeps its old name until it passes. */ + /** + * A review, not a write: the number keeps its old name until it passes. + * + *

Wraps the API's {@code requestWhatsappDisplayName} operation. + */ public void requestDisplayName(String accountId, String displayName) { Map body = new LinkedHashMap<>(); body.put("display_name", displayName); http.post(base(accountId) + "/profile/display-name", body); } + /** Wraps the API's {@code setWhatsappUsername} operation. */ public WhatsappProfile setUsername(String accountId, String username) { Map body = new LinkedHashMap<>(); body.put("username", username); @@ -68,6 +78,7 @@ public WhatsappProfile setUsername(String accountId, String username) { // ─── Templates ──────────────────────────────────────────────────────────── + /** Wraps the API's {@code listWhatsappTemplates} operation. */ public List listTemplates(String accountId) { return listTemplates(accountId, null); } @@ -81,7 +92,11 @@ public List listTemplates(String accountId, String after) { ApiClient.unwrap(http.get(base(accountId) + "/templates", query)), WhatsappTemplate.class); } - /** The pre-written templates the platform offers, for adapting. */ + /** + * The pre-written templates the platform offers, for adapting. + * + *

Wraps the API's {@code listWhatsappTemplateLibrary} operation. + */ public List> listTemplateLibrary(String accountId, String search) { Map query = new LinkedHashMap<>(); if (search != null) { @@ -94,6 +109,7 @@ public List> listTemplateLibrary(String accountId, String se .toList(); } + /** Wraps the API's {@code getWhatsappTemplate} operation. */ public WhatsappTemplate getTemplate(String accountId, String templateId) { return http.convert( ApiClient.unwrap(http.get(base(accountId) + "/templates/" + templateId, null)), @@ -103,6 +119,8 @@ public WhatsappTemplate getTemplate(String accountId, String templateId) { /** * Files a template for review. The result carries the status the platform * assigned, which is {@code PENDING} on a normal submission. + * + *

Wraps the API's {@code createWhatsappTemplate} operation. */ public WhatsappTemplate createTemplate( String accountId, @@ -132,7 +150,11 @@ public WhatsappTemplate createTemplate( ApiClient.unwrap(http.post(base(accountId) + "/templates", body)), WhatsappTemplate.class); } - /** Creates a template from one of the platform's library entries. */ + /** + * Creates a template from one of the platform's library entries. + * + *

Wraps the API's {@code importWhatsappTemplate} operation. + */ public WhatsappTemplate importTemplate( String accountId, String libraryTemplateName, @@ -153,7 +175,11 @@ public WhatsappTemplate importTemplate( WhatsappTemplate.class); } - /** Edits a template. The name cannot change; create a new one instead. */ + /** + * Edits a template. The name cannot change; create a new one instead. + * + *

Wraps the API's {@code updateWhatsappTemplate} operation. + */ public WhatsappTemplate updateTemplate( String accountId, String templateId, String category, List> components) { Map body = new LinkedHashMap<>(); @@ -169,7 +195,11 @@ public WhatsappTemplate updateTemplate( WhatsappTemplate.class); } - /** The name is required: it is what the platform deletes by. */ + /** + * The name is required: it is what the platform deletes by. + * + *

Wraps the API's {@code deleteWhatsappTemplate} operation. + */ public void deleteTemplate(String accountId, String templateId, String name) { Map query = new LinkedHashMap<>(); query.put("name", name); @@ -178,12 +208,17 @@ public void deleteTemplate(String accountId, String templateId, String name) { // ─── Groups ─────────────────────────────────────────────────────────────── + /** Wraps the API's {@code listWhatsappGroups} operation. */ public List listGroups(String accountId) { return http.convertList( ApiClient.unwrap(http.get(base(accountId) + "/groups", null)), WhatsappGroup.class); } - /** Participation is invite-only: send the invite link, there is no add. */ + /** + * Participation is invite-only: send the invite link, there is no add. + * + *

Wraps the API's {@code createWhatsappGroup} operation. + */ public WhatsappGroup createGroup(String accountId, String subject, String description) { Map body = new LinkedHashMap<>(); body.put("subject", subject); @@ -194,12 +229,14 @@ public WhatsappGroup createGroup(String accountId, String subject, String descri ApiClient.unwrap(http.post(base(accountId) + "/groups", body)), WhatsappGroup.class); } + /** Wraps the API's {@code getWhatsappGroup} operation. */ public WhatsappGroup getGroup(String accountId, String groupId) { return http.convert( ApiClient.unwrap(http.get(base(accountId) + "/groups/" + groupId, null)), WhatsappGroup.class); } + /** Wraps the API's {@code updateWhatsappGroup} operation. */ public WhatsappGroup updateGroup( String accountId, String groupId, String subject, String description) { Map body = new LinkedHashMap<>(); @@ -214,6 +251,7 @@ public WhatsappGroup updateGroup( WhatsappGroup.class); } + /** Wraps the API's {@code deleteWhatsappGroup} operation. */ public void deleteGroup(String accountId, String groupId) { http.delete(base(accountId) + "/groups/" + groupId); } @@ -242,6 +280,7 @@ public void removeGroupParticipants(String accountId, String groupId, List listBlocked(String accountId, String after) { Map query = new LinkedHashMap<>(); if (after != null) { @@ -250,6 +289,7 @@ public List listBlocked(String accountId, String after) { return http.convertList(ApiClient.unwrap(http.get(base(accountId) + "/block", query)), String.class); } + /** Wraps the API's {@code blockWhatsappUsers} operation. */ public WhatsappBlockResult blockUsers(String accountId, List users) { Map body = new LinkedHashMap<>(); body.put("users", users); @@ -257,6 +297,7 @@ public WhatsappBlockResult blockUsers(String accountId, List users) { ApiClient.unwrap(http.post(base(accountId) + "/block", body)), WhatsappBlockResult.class); } + /** Wraps the API's {@code unblockWhatsappUsers} operation. */ public WhatsappBlockResult unblockUsers(String accountId, List users) { Map body = new LinkedHashMap<>(); body.put("users", users); @@ -267,12 +308,14 @@ public WhatsappBlockResult unblockUsers(String accountId, List users) { // ─── Commerce ───────────────────────────────────────────────────────────── + /** Wraps the API's {@code getWhatsappCommerce} operation. */ public WhatsappCommerceSettings getCommerceSettings(String accountId) { return http.convert( ApiClient.unwrap(http.get(base(accountId) + "/commerce", null)), WhatsappCommerceSettings.class); } + /** Wraps the API's {@code updateWhatsappCommerce} operation. */ public WhatsappCommerceSettings updateCommerceSettings( String accountId, Boolean cartEnabled, Boolean catalogVisible) { Map body = new LinkedHashMap<>(); @@ -287,7 +330,11 @@ public WhatsappCommerceSettings updateCommerceSettings( WhatsappCommerceSettings.class); } - /** Points the number at a catalog the customer already owns. */ + /** + * Points the number at a catalog the customer already owns. + * + *

Wraps the API's {@code linkWhatsappCatalog} operation. + */ public WhatsappCommerceSettings linkCatalog(String accountId, String catalogId) { Map body = new LinkedHashMap<>(); body.put("catalog_id", catalogId); @@ -298,17 +345,23 @@ public WhatsappCommerceSettings linkCatalog(String accountId, String catalogId) // ─── Flows ──────────────────────────────────────────────────────────────── + /** Wraps the API's {@code listWhatsappFlows} operation. */ public List listFlows(String accountId) { return http.convertList( ApiClient.unwrap(http.get(base(accountId) + "/flows", null)), WhatsappFlow.class); } + /** Wraps the API's {@code getWhatsappFlow} operation. */ public WhatsappFlow getFlow(String accountId, String flowId) { return http.convert( ApiClient.unwrap(http.get(base(accountId) + "/flows/" + flowId, null)), WhatsappFlow.class); } - /** Creates a draft flow; its screens are uploaded separately. */ + /** + * Creates a draft flow; its screens are uploaded separately. + * + *

Wraps the API's {@code createWhatsappFlow} operation. + */ public WhatsappFlow createFlow(String accountId, String name, List categories) { return createFlow(accountId, name, categories, null, null); } @@ -328,6 +381,7 @@ public WhatsappFlow createFlow( ApiClient.unwrap(http.post(base(accountId) + "/flows", body)), WhatsappFlow.class); } + /** Wraps the API's {@code updateWhatsappFlow} operation. */ public WhatsappFlow updateFlow( String accountId, String flowId, String name, List categories, String endpointUri) { Map body = new LinkedHashMap<>(); @@ -345,7 +399,11 @@ public WhatsappFlow updateFlow( WhatsappFlow.class); } - /** Drafts only; a published flow is deprecated instead. */ + /** + * Drafts only; a published flow is deprecated instead. + * + *

Wraps the API's {@code deleteWhatsappFlow} operation. + */ public void deleteFlow(String accountId, String flowId) { http.delete(base(accountId) + "/flows/" + flowId); } @@ -375,12 +433,14 @@ public WhatsappFlow deprecateFlow(String accountId, String flowId) { WhatsappFlow.class); } + /** Wraps the API's {@code listWhatsappFlowResponses} operation. */ public List listFlowResponses(String accountId) { return http.convertList( ApiClient.unwrap(http.get(base(accountId) + "/flows/responses", null)), WhatsappFlowResponse.class); } + /** Wraps the API's {@code getWhatsappEncryptionKey} operation. */ public WhatsappEncryptionKeyStatus getEncryptionKeyStatus(String accountId) { return http.convert( ApiClient.unwrap(http.get(base(accountId) + "/flows/encryption-key", null)), @@ -390,6 +450,8 @@ public WhatsappEncryptionKeyStatus getEncryptionKeyStatus(String accountId) { /** * Registers the public half of the key the platform encrypts a flow * endpoint's payloads with. The private half stays with the customer. + * + *

Wraps the API's {@code setWhatsappEncryptionKey} operation. */ public WhatsappEncryptionKeyStatus setEncryptionKey(String accountId, String businessPublicKey) { Map body = new LinkedHashMap<>(); @@ -401,7 +463,11 @@ public WhatsappEncryptionKeyStatus setEncryptionKey(String accountId, String bus // ─── Account state and sandbox ──────────────────────────────────────────── - /** The account review state and the number's quality and limit tier. */ + /** + * The account review state and the number's quality and limit tier. + * + *

Wraps the API's {@code getWhatsappAccountEvents} operation. + */ public Map getAccountEvents(String accountId) { return http.convert(ApiClient.unwrap(http.get(base(accountId) + "/events", null)), Map.class); } diff --git a/src/test/java/com/fopost/sdk/AutomationVersionsTest.java b/src/test/java/com/fopost/sdk/AutomationVersionsTest.java new file mode 100644 index 0000000..bf7fbb2 --- /dev/null +++ b/src/test/java/com/fopost/sdk/AutomationVersionsTest.java @@ -0,0 +1,52 @@ +package com.fopost.sdk; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import org.junit.jupiter.api.Test; + +class AutomationVersionsTest { + + @Test + void versionsListReadsThePageAndItsMeta() { + FakeTransport transport = new FakeTransport().enqueue(200, """ + {"data":[{"version":3,"note":"Added delay","created_by":{"name":"Ops"}, + "created_at":"2026-10-18T10:00:00Z","node_count":4, + "nodes_added":[{"id":"n4","kind":"delay","label":null}],"nodes_removed":[]}], + "meta":{"page":1,"per_page":20,"total":3,"total_pages":1}}"""); + + var page = TestSupport.client(transport).automations().versions("m1", 1, 20); + + assertEquals("GET", transport.last().method()); + assertEquals("https://api.fopost.test/v1/automations/m1/versions?page=1&per_page=20", transport.last().url()); + assertEquals(1, page.size()); + assertEquals(3, page.data().get(0).version()); + assertEquals("Ops", page.data().get(0).createdBy().name()); + assertEquals("delay", page.data().get(0).nodesAdded().get(0).kind()); + assertNull(page.data().get(0).graph()); + assertEquals(1, page.meta().totalPages()); + } + + @Test + void oneVersionCarriesItsGraphAndRestoreMakesANewOne() { + String graph = "{\"nodes\":[{\"id\":\"n1\",\"kind\":\"trigger\",\"label\":\"Start\",\"config\":{\"event\":\"post.published\"}," + + "\"position\":0,\"layout\":{\"x\":10,\"y\":20}}],\"edges\":[{\"id\":\"e1\",\"source\":\"n1\",\"source_handle\":null,\"target\":\"n2\"}]}"; + FakeTransport transport = new FakeTransport() + .enqueue(200, "{\"version\":2,\"note\":null,\"created_by\":null,\"created_at\":\"2026-10-17T10:00:00Z\",\"graph\":" + graph + "}") + .enqueue(201, "{\"version\":4,\"graph\":" + graph + ",\"steps\":[{\"position\":1,\"node_id\":\"n2\",\"action_type\":\"publish\",\"action_config\":{}}]}"); + FoPost client = TestSupport.client(transport); + + var version = client.automations().version("m1", 2); + assertEquals("https://api.fopost.test/v1/automations/m1/versions/2", transport.last().url()); + assertEquals("trigger", version.graph().nodes().get(0).kind()); + assertEquals(10.0, version.graph().nodes().get(0).layout().x()); + assertEquals("n2", version.graph().edges().get(0).target()); + + var restored = client.automations().restoreVersion("m1", 2); + assertEquals("POST", transport.last().method()); + assertEquals("https://api.fopost.test/v1/automations/m1/versions/2/restore", transport.last().url()); + assertEquals(4, restored.version()); + assertEquals("publish", restored.steps().get(0).actionType()); + assertEquals("n2", restored.steps().get(0).nodeId()); + } +} diff --git a/src/test/java/com/fopost/sdk/ConnectSessionsTest.java b/src/test/java/com/fopost/sdk/ConnectSessionsTest.java new file mode 100644 index 0000000..0570d35 --- /dev/null +++ b/src/test/java/com/fopost/sdk/ConnectSessionsTest.java @@ -0,0 +1,53 @@ +package com.fopost.sdk; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import com.fopost.sdk.param.ConnectSessionParams; +import java.time.Instant; +import java.util.List; +import org.junit.jupiter.api.Test; + +class ConnectSessionsTest { + + private static final String SESSION = + "{\"id\":\"cs1\",\"status\":\"pending\",\"external_user_id\":\"user_42\",\"platforms\":[\"linkedin\"]," + + "\"redirect_url\":\"https://yourbrand.com/connected\",\"branding\":{\"name\":\"Your Brand\",\"logo_url\":null}," + + "\"account_id\":null,\"url\":\"https://app.fopost.test/connect/cs1\"," + + "\"expires_at\":\"2026-10-18T11:00:00Z\",\"used_at\":null,\"created_at\":\"2026-10-18T10:00:00Z\"}"; + + @Test + void createSendsACamelCaseBodyAndReadsTheLink() { + FakeTransport transport = new FakeTransport().enqueue(201, SESSION); + + var session = TestSupport.client(transport) + .connectSessions() + .create(ConnectSessionParams.of("user_42", List.of("linkedin")) + .workspaceId("w1") + .redirectUrl("https://yourbrand.com/connected") + .branding("Your Brand", null)); + + assertEquals("POST", transport.last().method()); + assertEquals("https://api.fopost.test/v1/connect-sessions", transport.last().url()); + assertEquals( + "{\"externalUserId\":\"user_42\",\"platforms\":[\"linkedin\"],\"workspaceId\":\"w1\"," + + "\"redirectUrl\":\"https://yourbrand.com/connected\",\"branding\":{\"name\":\"Your Brand\"}}", + transport.lastBody()); + assertEquals("https://app.fopost.test/connect/cs1", session.url()); + assertEquals("Your Brand", session.branding().name()); + assertNull(session.branding().logoUrl()); + assertEquals(Instant.parse("2026-10-18T11:00:00Z"), session.expiresAt()); + } + + @Test + void getReadsTheSessionById() { + FakeTransport transport = new FakeTransport().enqueue(200, SESSION); + + var session = TestSupport.client(transport).connectSessions().get("cs1"); + + assertEquals("GET", transport.last().method()); + assertEquals("https://api.fopost.test/v1/connect-sessions/cs1", transport.last().url()); + assertEquals("pending", session.status()); + assertEquals("user_42", session.externalUserId()); + } +} diff --git a/src/test/java/com/fopost/sdk/InboxAnalyticsTest.java b/src/test/java/com/fopost/sdk/InboxAnalyticsTest.java new file mode 100644 index 0000000..6012bcf --- /dev/null +++ b/src/test/java/com/fopost/sdk/InboxAnalyticsTest.java @@ -0,0 +1,42 @@ +package com.fopost.sdk; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import com.fopost.sdk.param.AnalyticsParams; +import java.time.LocalDate; +import org.junit.jupiter.api.Test; + +class InboxAnalyticsTest { + + @Test + void inboxReadsTheAggregatesInTheAskedTimeZone() { + FakeTransport transport = new FakeTransport().enqueue(200, """ + {"totals":{"received":40,"sent":22,"answered":18,"open":9,"conversations":12,"median_response_minutes":null}, + "deltas":{"received":12.5,"sent":null,"answered":-3}, + "series":[{"date":"2026-10-17","received":4,"sent":2,"answered":2}], + "by_platform":[{"platform":"linkedin","received":30,"sent":20}], + "by_type":[{"type":"comment","count":25}], + "by_account":[{"account_id":"a1","platform":"linkedin","username":"yourbrand","name":"Your Brand","avatar":null,"received":30,"sent":20}], + "response_time":[{"bucket":"under_1h","count":10}], + "heatmap":[{"weekday":1,"hour":9,"count":6}], + "time_zone":"Europe/Berlin"}"""); + + var analytics = TestSupport.client(transport) + .analytics() + .inbox(AnalyticsParams.create().accountId("a1").workspaceId("w1").days(30), "Europe/Berlin"); + + assertEquals("GET", transport.last().method()); + assertEquals( + "https://api.fopost.test/v1/analytics/inbox?account_id=a1&workspace_id=w1&days=30&tz=Europe%2FBerlin", + transport.last().url()); + assertEquals(40, analytics.totals().received()); + assertNull(analytics.totals().medianResponseMinutes()); + assertEquals(12.5, analytics.deltas().received()); + assertEquals(LocalDate.of(2026, 10, 17), analytics.series().get(0).date()); + assertEquals("yourbrand", analytics.byAccount().get(0).username()); + assertEquals("under_1h", analytics.responseTime().get(0).bucket()); + assertEquals(9, analytics.heatmap().get(0).hour()); + assertEquals("Europe/Berlin", analytics.timeZone()); + } +} diff --git a/src/test/java/com/fopost/sdk/LogsTest.java b/src/test/java/com/fopost/sdk/LogsTest.java new file mode 100644 index 0000000..88667df --- /dev/null +++ b/src/test/java/com/fopost/sdk/LogsTest.java @@ -0,0 +1,50 @@ +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.assertTrue; + +import com.fopost.sdk.param.LogParams; +import org.junit.jupiter.api.Test; + +class LogsTest { + + @Test + void listSendsTheFiltersAndReadsTheCursorAndSummary() { + FakeTransport transport = new FakeTransport().enqueue(200, """ + {"data":[{"id":91,"workspace_id":"w1","time":"2026-10-18T10:00:00Z","auth_method":"api_key", + "method":"POST","route":"/v1/posts","path":"/v1/posts","status_code":422, + "duration_ms":12,"platform":null,"message":"content is required"}], + "meta":{"next_cursor":"90","has_more":true}, + "summary":{"since":"2026-10-11T10:00:00Z","total":40,"errors":3, + "buckets":[{"at":"2026-10-18T10:00:00Z","total":5,"errors":1}]}}"""); + + var page = TestSupport.client(transport) + .logs() + .list(LogParams.create().workspaceId("w1").days(7).status("failed").limit(50).before("100")); + + assertEquals("GET", transport.last().method()); + assertEquals( + "https://api.fopost.test/v1/logs?workspace_id=w1&days=7&status=failed&limit=50&before=100", + transport.last().url()); + assertEquals(91L, page.data().get(0).id()); + assertEquals(422, page.data().get(0).statusCode()); + assertEquals("content is required", page.data().get(0).message()); + assertEquals("90", page.nextCursor()); + assertTrue(page.hasMore()); + assertEquals(3, page.summary().errors()); + assertEquals(5, page.summary().buckets().get(0).total()); + } + + @Test + void theEndOfTheLogIsANullCursor() { + FakeTransport transport = new FakeTransport() + .enqueue(200, "{\"data\":[],\"meta\":{\"next_cursor\":null,\"has_more\":false},\"summary\":{\"total\":0,\"errors\":0,\"buckets\":[]}}"); + + var page = TestSupport.client(transport).logs().list(); + + assertEquals("https://api.fopost.test/v1/logs", transport.last().url()); + assertTrue(page.isEmpty()); + assertNull(page.nextCursor()); + } +} diff --git a/src/test/java/com/fopost/sdk/QueueTest.java b/src/test/java/com/fopost/sdk/QueueTest.java new file mode 100644 index 0000000..d832c3e --- /dev/null +++ b/src/test/java/com/fopost/sdk/QueueTest.java @@ -0,0 +1,95 @@ +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.assertTrue; + +import com.fopost.sdk.param.CreatePostParams; +import com.fopost.sdk.param.QueueSlotParams; +import com.fopost.sdk.param.UpdatePostParams; +import java.time.Instant; +import java.util.List; +import org.junit.jupiter.api.Test; + +class QueueTest { + + private static final String SLOT = + "{\"id\":\"s1\",\"workspace_id\":\"w1\",\"account_id\":null,\"weekday\":1,\"time\":\"09:30\"," + + "\"timezone\":\"UTC\",\"created_at\":\"2026-10-01T00:00:00.000Z\"}"; + + @Test + void listSlotsScopesByWorkspace() { + FakeTransport transport = new FakeTransport().enqueue(200, "{\"data\":[" + SLOT + "]}"); + + var slots = TestSupport.client(transport).queue().listSlots("w1"); + + assertEquals("GET", transport.last().method()); + assertEquals("https://api.fopost.test/v1/queue/slots?workspace_id=w1", transport.last().url()); + assertEquals("s1", slots.get(0).id()); + assertEquals(1, slots.get(0).weekday()); + assertNull(slots.get(0).accountId()); + } + + @Test + void createSlotSendsTheWorkspaceWithTheSlot() { + FakeTransport transport = new FakeTransport().enqueue(201, SLOT); + + var slot = TestSupport.client(transport) + .queue() + .createSlot("w1", QueueSlotParams.create().weekday(1).time("09:30").timezone("UTC")); + + assertEquals("POST", transport.last().method()); + assertEquals("https://api.fopost.test/v1/queue/slots", transport.last().url()); + assertEquals( + "{\"workspace_id\":\"w1\",\"weekday\":1,\"time\":\"09:30\",\"timezone\":\"UTC\"}", + transport.lastBody()); + assertEquals("09:30", slot.time()); + } + + @Test + void updateAndDeleteReportTheMovedPosts() { + String moved = "\"moved\":[{\"id\":\"p1\",\"from\":\"2026-10-05T09:30:00Z\",\"to\":\"2026-10-05T10:00:00Z\"}]"; + FakeTransport transport = new FakeTransport() + .enqueue(200, SLOT.substring(0, SLOT.length() - 1) + "," + moved + "}") + .enqueue(200, "{\"deleted\":true," + moved + "}"); + FoPost client = TestSupport.client(transport); + + var updated = client.queue().updateSlot("s1", QueueSlotParams.create().time("10:00")); + assertEquals("PUT", transport.last().method()); + assertEquals("https://api.fopost.test/v1/queue/slots/s1", transport.last().url()); + assertEquals("{\"time\":\"10:00\"}", transport.lastBody()); + assertEquals("p1", updated.moved().get(0).id()); + assertEquals(Instant.parse("2026-10-05T10:00:00Z"), updated.moved().get(0).to()); + + var deleted = client.queue().deleteSlot("s1"); + assertEquals("DELETE", transport.last().method()); + assertEquals("https://api.fopost.test/v1/queue/slots/s1", transport.last().url()); + assertTrue(deleted.deleted()); + assertEquals(1, deleted.moved().size()); + } + + @Test + void nextAndPreviewReadTimes() { + FakeTransport transport = new FakeTransport() + .enqueue(200, "{\"next\":\"2026-10-05T09:30:00.000Z\"}") + .enqueue(200, "{\"next\":null}") + .enqueue(200, "{\"data\":[\"2026-10-05T09:30:00Z\",\"2026-10-06T09:30:00Z\"]}"); + FoPost client = TestSupport.client(transport); + + assertEquals(Instant.parse("2026-10-05T09:30:00Z"), client.queue().next("w1", "a1")); + assertEquals("https://api.fopost.test/v1/queue/next?workspace_id=w1&account_id=a1", transport.last().url()); + + assertNull(client.queue().next("w1")); + + List preview = client.queue().preview("w1", null, 2); + assertEquals("https://api.fopost.test/v1/queue/preview?workspace_id=w1&count=2", transport.last().url()); + assertEquals(2, preview.size()); + assertEquals(Instant.parse("2026-10-06T09:30:00Z"), preview.get(1)); + } + + @Test + void postsCanJoinTheQueue() { + assertEquals("queue", CreatePostParams.of("w1").content("Hi").queue().toMap().get("schedule")); + assertEquals("queue", UpdatePostParams.create().queue().toMap().get("schedule")); + } +} diff --git a/src/test/java/com/fopost/sdk/UnpublishTest.java b/src/test/java/com/fopost/sdk/UnpublishTest.java new file mode 100644 index 0000000..ebe299d --- /dev/null +++ b/src/test/java/com/fopost/sdk/UnpublishTest.java @@ -0,0 +1,27 @@ +package com.fopost.sdk; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class UnpublishTest { + + @Test + void unpublishTakesThePostDownOnEveryNetwork() { + FakeTransport transport = new FakeTransport().enqueue(200, """ + {"post_status":"unpublished","deliveries":[ + {"account_id":"a1","platform":"linkedin","removed":true}, + {"account_id":"a2","platform":"threads","removed":false}]}"""); + + var result = TestSupport.client(transport).posts().unpublish("p1"); + + assertEquals("POST", transport.last().method()); + assertEquals("https://api.fopost.test/v1/posts/p1/unpublish", transport.last().url()); + assertEquals("unpublished", result.postStatus()); + assertTrue(result.deliveries().get(0).removed()); + assertFalse(result.deliveries().get(1).removed()); + assertEquals("threads", result.deliveries().get(1).platform()); + } +} diff --git a/src/test/java/com/fopost/sdk/WebhookDeliveriesTest.java b/src/test/java/com/fopost/sdk/WebhookDeliveriesTest.java new file mode 100644 index 0000000..ec84c78 --- /dev/null +++ b/src/test/java/com/fopost/sdk/WebhookDeliveriesTest.java @@ -0,0 +1,42 @@ +package com.fopost.sdk; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import org.junit.jupiter.api.Test; + +class WebhookDeliveriesTest { + + private static final String DELIVERY = + "{\"id\":\"d1\",\"webhook_id\":\"h1\",\"event\":\"post.published\",\"delivery_key\":\"k1\",\"attempt\":2," + + "\"status\":\"failed\",\"response_status\":502,\"response_snippet\":\"Bad Gateway\",\"duration_ms\":140," + + "\"payload_hash\":\"abc\",\"next_retry_at\":null,\"created_at\":\"2026-10-18T10:00:00Z\"}"; + + @Test + void deliveriesListSendsTheFilters() { + FakeTransport transport = new FakeTransport().enqueue(200, "{\"data\":[" + DELIVERY + "]}"); + + var deliveries = TestSupport.client(transport).webhooks().deliveries("h1", "failed", 10); + + assertEquals("GET", transport.last().method()); + assertEquals("https://api.fopost.test/v1/webhooks/h1/deliveries?status=failed&limit=10", transport.last().url()); + assertEquals("d1", deliveries.get(0).id()); + assertEquals(2, deliveries.get(0).attempt()); + assertEquals(502, deliveries.get(0).responseStatus()); + assertNull(deliveries.get(0).nextRetryAt()); + } + + @Test + void oneDeliveryAndARedelivery() { + FakeTransport transport = new FakeTransport().enqueue(200, DELIVERY).enqueue(204, ""); + FoPost client = TestSupport.client(transport); + + var delivery = client.webhooks().delivery("h1", "d1"); + assertEquals("https://api.fopost.test/v1/webhooks/h1/deliveries/d1", transport.last().url()); + assertEquals("k1", delivery.deliveryKey()); + + client.webhooks().redeliver("h1", "d1"); + assertEquals("POST", transport.last().method()); + assertEquals("https://api.fopost.test/v1/webhooks/h1/deliveries/d1/redeliver", transport.last().url()); + } +}