From 69327247e0df20eb642751a9fb48d5cecb7c0fee Mon Sep 17 00:00:00 2001 From: Ali Hesari Date: Sun, 20 Sep 2026 03:02:55 +0200 Subject: [PATCH] feat: add the activity resource and the security audit log client.activity().list() reads GET /v1/activity; KIND_SECURITY is the append-only audit trail of membership, role, access and sign-in changes. --- README.md | 1 + src/main/java/com/fopost/sdk/FoPost.java | 7 ++ .../com/fopost/sdk/model/ActivityActor.java | 4 + .../com/fopost/sdk/model/ActivityEvent.java | 14 ++++ .../com/fopost/sdk/model/ActivityPage.java | 29 +++++++ .../com/fopost/sdk/param/ActivityParams.java | 77 +++++++++++++++++++ .../fopost/sdk/resource/ActivityResource.java | 35 +++++++++ .../java/com/fopost/sdk/ActivityTest.java | 49 ++++++++++++ 8 files changed, 216 insertions(+) create mode 100644 src/main/java/com/fopost/sdk/model/ActivityActor.java create mode 100644 src/main/java/com/fopost/sdk/model/ActivityEvent.java create mode 100644 src/main/java/com/fopost/sdk/model/ActivityPage.java create mode 100644 src/main/java/com/fopost/sdk/param/ActivityParams.java create mode 100644 src/main/java/com/fopost/sdk/resource/ActivityResource.java create mode 100644 src/test/java/com/fopost/sdk/ActivityTest.java diff --git a/README.md b/README.md index 343931c..b37ed5a 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,7 @@ long failed = client.posts().stream(PostListParams.create().workspaceId(workspac | `inbox()` | `list`, `threads`, `conversations`, `unreadCount`, `accounts`, `platforms`, `markThreadRead`, `markConversationRead`, `refresh`, `update`, `editComment`, `reply`, `hide`, `unhide`, `delete`, `like`, `unlike`, `pin`, `unpin`, `react`, `startConversation`, `setTyping`, `listApprovals`, `approveReply`, `rejectReply` | | `ads()` | `list`, `external`, `boostable`, `connections`, `sources`, `authorizeMeta`, `deleteConnection`, `boost`, `create`, `refresh`, `setStatus`, `delete`, `accountTree`, `createCampaign`, `campaign`, `updateCampaign`, `deleteCampaign`, `duplicateCampaign`, `createAdSet`, `adSet`, `updateAdSet`, `deleteAdSet`, `duplicateAdSet`, `createNetworkAd`, `networkAd`, `updateNetworkAd`, `deleteNetworkAd`, `duplicateNetworkAd`, `bulkSetStatus`, `creatives`, `createCreative`, `creative`, `deleteCreative`, `estimateReach`, `insights`, `adInsights`, `audiences`, `createAudience`, `audience`, `updateAudience`, `deleteAudience`, `addAudienceUsers`, `searchTargeting`, `leadForms`, `createLeadForm`, `leadForm`, `archiveLeadForm`, `leads`, `leadsFeed`, `leadPages`, `subscribeLeadPage`, `unsubscribeLeadPage` | | `validate()` | `post`, `length`, `media` | +| `activity()` | `list` | `accounts().communities()` covers the X communities an account can post into: `list`, `sync`, `search`, `add`, `remove`. diff --git a/src/main/java/com/fopost/sdk/FoPost.java b/src/main/java/com/fopost/sdk/FoPost.java index 9094803..8df8c10 100644 --- a/src/main/java/com/fopost/sdk/FoPost.java +++ b/src/main/java/com/fopost/sdk/FoPost.java @@ -9,6 +9,7 @@ import com.fopost.sdk.resource.AccountGroupsResource; import com.fopost.sdk.resource.AccountsResource; import com.fopost.sdk.resource.AdsResource; +import com.fopost.sdk.resource.ActivityResource; import com.fopost.sdk.resource.AiResource; import com.fopost.sdk.resource.AnalyticsResource; import com.fopost.sdk.resource.AutomationsResource; @@ -66,6 +67,7 @@ public final class FoPost { private final AutomationsResource automations; private final MediaResource media; private final AiResource ai; + private final ActivityResource activity; private final InboxResource inbox; private final AdsResource ads; private final ValidateResource validate; @@ -77,6 +79,7 @@ private FoPost(ApiClient http) { this.accountGroups = new AccountGroupsResource(http); this.workspaces = new WorkspacesResource(http); this.labels = new LabelsResource(http); + this.activity = new ActivityResource(http); this.webhooks = new WebhooksResource(http); this.analytics = new AnalyticsResource(http); this.automations = new AutomationsResource(http); @@ -136,6 +139,10 @@ public MediaResource media() { return media; } + public ActivityResource activity() { + return activity; + } + public AiResource ai() { return ai; } diff --git a/src/main/java/com/fopost/sdk/model/ActivityActor.java b/src/main/java/com/fopost/sdk/model/ActivityActor.java new file mode 100644 index 0000000..9e8e42d --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/ActivityActor.java @@ -0,0 +1,4 @@ +package com.fopost.sdk.model; + +/** Who did it: {@code user}, {@code api_key}, {@code agent} or {@code system}. */ +public record ActivityActor(String type, String name) {} diff --git a/src/main/java/com/fopost/sdk/model/ActivityEvent.java b/src/main/java/com/fopost/sdk/model/ActivityEvent.java new file mode 100644 index 0000000..3bc083e --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/ActivityEvent.java @@ -0,0 +1,14 @@ +package com.fopost.sdk.model; + +import java.time.Instant; + +/** One thing that happened in a workspace. A {@code security} kind is an audit row. */ +public record ActivityEvent( + String id, + String workspaceId, + String kind, + String refType, + String refId, + String summary, + ActivityActor actor, + Instant time) {} diff --git a/src/main/java/com/fopost/sdk/model/ActivityPage.java b/src/main/java/com/fopost/sdk/model/ActivityPage.java new file mode 100644 index 0000000..7b10e32 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/ActivityPage.java @@ -0,0 +1,29 @@ +package com.fopost.sdk.model; + +import java.util.Iterator; +import java.util.List; + +/** + * One page of activity, newest first. Pass {@code nextCursor} back as the cursor for the next + * page; it is {@code null} at the end of the list. + */ +public record ActivityPage(List data, String nextCursor) + implements Iterable { + + public ActivityPage { + 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/param/ActivityParams.java b/src/main/java/com/fopost/sdk/param/ActivityParams.java new file mode 100644 index 0000000..82c9247 --- /dev/null +++ b/src/main/java/com/fopost/sdk/param/ActivityParams.java @@ -0,0 +1,77 @@ +package com.fopost.sdk.param; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Filters for the activity log. Every field is optional; leaving {@code workspaceId} unset reads + * every workspace the key can reach. + */ +public final class ActivityParams { + + /** The audit log: append-only, and the one kind that never expires. */ + public static final String KIND_SECURITY = "security"; + + public static final String KIND_PUBLISH = "publish"; + public static final String KIND_CONNECTION = "connection"; + public static final String KIND_WEBHOOK = "webhook"; + public static final String KIND_INBOX = "inbox"; + public static final String KIND_AUTOMATION = "automation"; + public static final String KIND_BILLING = "billing"; + + private String workspaceId; + private String kind; + private String from; + private String to; + private String cursor; + private Integer limit; + + public static ActivityParams create() { + return new ActivityParams(); + } + + public ActivityParams workspaceId(String workspaceId) { + this.workspaceId = workspaceId; + return this; + } + + /** One of the {@code KIND_*} constants. */ + public ActivityParams kind(String kind) { + this.kind = kind; + return this; + } + + /** ISO 8601. Only events at or after this time. */ + public ActivityParams from(String from) { + this.from = from; + return this; + } + + /** ISO 8601. Only events at or before this time. */ + public ActivityParams to(String to) { + this.to = to; + return this; + } + + /** The {@code nextCursor} of the previous page. */ + public ActivityParams cursor(String cursor) { + this.cursor = cursor; + return this; + } + + public ActivityParams limit(Integer limit) { + this.limit = limit; + return this; + } + + public Map toQuery() { + Map query = new LinkedHashMap<>(); + Params.put(query, "workspace_id", workspaceId); + Params.put(query, "kind", kind); + Params.put(query, "from", from); + Params.put(query, "to", to); + Params.put(query, "cursor", cursor); + Params.put(query, "limit", limit); + return query; + } +} diff --git a/src/main/java/com/fopost/sdk/resource/ActivityResource.java b/src/main/java/com/fopost/sdk/resource/ActivityResource.java new file mode 100644 index 0000000..0c5abec --- /dev/null +++ b/src/main/java/com/fopost/sdk/resource/ActivityResource.java @@ -0,0 +1,35 @@ +package com.fopost.sdk.resource; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fopost.sdk.internal.ApiClient; +import com.fopost.sdk.model.ActivityEvent; +import com.fopost.sdk.model.ActivityPage; +import com.fopost.sdk.param.ActivityParams; +import java.util.List; + +/** What happened in a workspace, including the security audit log. */ +public final class ActivityResource { + + private final ApiClient http; + + public ActivityResource(ApiClient http) { + this.http = http; + } + + public ActivityPage list() { + return list(ActivityParams.create()); + } + + /** + * Activity newest first. {@link ActivityParams#KIND_SECURITY} is the audit log: members + * joining, leaving or changing role and access, and changes to two-step verification, + * passkeys, single sign-on and signed-in devices. Those rows are append-only and never expire. + */ + public ActivityPage list(ActivityParams params) { + // The response carries meta beside data, so it is read whole rather than unwrapped. + JsonNode body = http.get("/v1/activity", params == null ? null : params.toQuery()); + List events = http.convertList(body.path("data"), ActivityEvent.class); + JsonNode cursor = body.path("meta").path("next_cursor"); + return new ActivityPage(events, cursor.isNull() || cursor.isMissingNode() ? null : cursor.asText()); + } +} diff --git a/src/test/java/com/fopost/sdk/ActivityTest.java b/src/test/java/com/fopost/sdk/ActivityTest.java new file mode 100644 index 0000000..34f104e --- /dev/null +++ b/src/test/java/com/fopost/sdk/ActivityTest.java @@ -0,0 +1,49 @@ +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.model.ActivityPage; +import com.fopost.sdk.param.ActivityParams; +import org.junit.jupiter.api.Test; + +class ActivityTest { + + private static final String SECURITY_EVENT = + "{\"data\":[{\"id\":\"evt_1\",\"workspace_id\":\"w1\",\"kind\":\"security\"," + + "\"ref_type\":\"member_removed\",\"ref_id\":\"u2\"," + + "\"summary\":\"Removed sam@example.com\"," + + "\"actor\":{\"type\":\"user\",\"name\":\"Ada\"}," + + "\"time\":\"2026-09-20T10:00:00Z\"}],\"meta\":{\"next_cursor\":\"42\"}}"; + + @Test + void readsTheAuditLogAndKeepsTheCursor() { + FakeTransport transport = new FakeTransport().enqueue(200, SECURITY_EVENT); + + ActivityPage page = TestSupport.client(transport) + .activity() + .list(ActivityParams.create() + .workspaceId("w1") + .kind(ActivityParams.KIND_SECURITY) + .limit(1)); + + assertTrue(transport.last().url().contains("kind=security")); + assertTrue(transport.last().url().contains("workspace_id=w1")); + assertEquals(1, page.size()); + assertEquals("member_removed", page.data().get(0).refType()); + assertEquals("Ada", page.data().get(0).actor().name()); + assertEquals("42", page.nextCursor()); + } + + @Test + void theEndOfTheListIsANullCursor() { + FakeTransport transport = + new FakeTransport().enqueue(200, "{\"data\":[],\"meta\":{\"next_cursor\":null}}"); + + ActivityPage page = TestSupport.client(transport).activity().list(); + + assertTrue(page.isEmpty()); + assertNull(page.nextCursor()); + } +}