diff --git a/src/main/java/io/getstream/client/Client.java b/src/main/java/io/getstream/client/Client.java index aef62d5a..79301137 100644 --- a/src/main/java/io/getstream/client/Client.java +++ b/src/main/java/io/getstream/client/Client.java @@ -19,6 +19,8 @@ import java.util.Date; import java.util.List; import java.util.Map; + +import io.getstream.core.utils.Auth; import java8.util.concurrent.CompletableFuture; public final class Client { @@ -363,4 +365,19 @@ CompletableFuture userProfile(String id) throws StreamException { final Token token = buildUsersToken(secret, TokenAction.READ); return stream.getUser(token, id, true); } + + public CompletableFuture deleteActivities(BatchDeleteActivitiesRequest request) throws StreamException { + final Token token = buildDataPrivacyToken(secret, Auth.TokenAction.WRITE); + return stream.deleteActivities(token, request); + } + + public CompletableFuture deleteReactions(BatchDeleteReactionsRequest request) throws StreamException { + final Token token = buildDataPrivacyToken(secret, Auth.TokenAction.WRITE); + return stream.deleteReactions(token, request); + } + + public CompletableFuture exportUserActivities(String userId) throws StreamException { + final Token token = buildDataPrivacyToken(secret, Auth.TokenAction.READ); + return stream.exportUserActivities(token, userId); + } } diff --git a/src/main/java/io/getstream/core/Stream.java b/src/main/java/io/getstream/core/Stream.java index 0eb9d413..a788533d 100644 --- a/src/main/java/io/getstream/core/Stream.java +++ b/src/main/java/io/getstream/core/Stream.java @@ -525,4 +525,51 @@ public CompletableFuture updateUser(Token token, String userID, Data u throw new StreamException(e); } } + + public CompletableFuture deleteActivities(Token token, BatchDeleteActivitiesRequest request) throws StreamException { + try { + final URL url = deleteActivitiesURL(baseURL); + final byte[] payload = toJSON(request); + io.getstream.core.http.Request httpRequest = buildPost(url, key, token, payload); + return httpClient.execute(httpRequest).thenApply(response -> null); + } catch (Exception e) { + throw new StreamException(e); + } + } + + public CompletableFuture deleteReactions(Token token, BatchDeleteReactionsRequest request) throws StreamException { + try { + + final URL url = deleteReactionsURL(baseURL); + final byte[] payload = toJSON(request); + io.getstream.core.http.Request httpRequest = buildPost(url, key, token, payload); + + return httpClient.execute(httpRequest).thenApply(response -> null); + } catch (Exception e) { + throw new StreamException(e); + } + } + + public CompletableFuture exportUserActivities(Token token, String userId) throws StreamException { + if (userId == null || userId.isEmpty()) { + throw new IllegalArgumentException("User ID can't be null or empty"); + } + + try { + final URL url = buildExportIDsURL(baseURL, userId); + io.getstream.core.http.Request request = buildGet(url, key, token); + return httpClient + .execute(request) + .thenApply( + response -> { + try { + return deserialize(response, ExportIDsResponse.class); + } catch (StreamException | IOException e) { + throw new CompletionException(e); + } + }); + } catch (MalformedURLException | URISyntaxException e) { + throw new StreamException(e); + } + } } diff --git a/src/main/java/io/getstream/core/models/BatchDeleteActivitiesRequest.java b/src/main/java/io/getstream/core/models/BatchDeleteActivitiesRequest.java new file mode 100644 index 00000000..b0e41757 --- /dev/null +++ b/src/main/java/io/getstream/core/models/BatchDeleteActivitiesRequest.java @@ -0,0 +1,39 @@ +package io.getstream.core.models; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BatchDeleteActivitiesRequest { + + private final List activities; + + public BatchDeleteActivitiesRequest(List activities) { + this.activities = activities; + } + + public List getActivities() { + return activities; + } + + public static class ActivityToDelete { + private final String id; + private final List removeFromFeeds; + + public ActivityToDelete( + @JsonProperty("id") String id, + @JsonProperty("remove_from_feeds") List removeFromFeeds) { + this.id = id; + this.removeFromFeeds = removeFromFeeds; + } + + public String getId() { + return id; + } + + public List getRemoveFromFeeds() { + return removeFromFeeds; + } + } +} \ No newline at end of file diff --git a/src/main/java/io/getstream/core/models/BatchDeleteReactionsRequest.java b/src/main/java/io/getstream/core/models/BatchDeleteReactionsRequest.java new file mode 100644 index 00000000..bf6efd4a --- /dev/null +++ b/src/main/java/io/getstream/core/models/BatchDeleteReactionsRequest.java @@ -0,0 +1,19 @@ +package io.getstream.core.models; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BatchDeleteReactionsRequest { + + private final List ids; + + public BatchDeleteReactionsRequest(@JsonProperty("ids") List ids) { + this.ids = ids; + } + + public List getIds() { + return ids; + } +} \ No newline at end of file diff --git a/src/main/java/io/getstream/core/models/ExportIDsResponse.java b/src/main/java/io/getstream/core/models/ExportIDsResponse.java new file mode 100644 index 00000000..190f6ddb --- /dev/null +++ b/src/main/java/io/getstream/core/models/ExportIDsResponse.java @@ -0,0 +1,35 @@ +package io.getstream.core.models; +import com.fasterxml.jackson.annotation.JsonProperty; + +public class ExportIDsResponse { + @JsonProperty("export") + private ExportIDsResult export; + + @JsonProperty("duration") + private String duration; + + // No-argument constructor + public ExportIDsResponse() { + } + + // Constructor with parameters + public ExportIDsResponse(String duration) { + this.duration = duration; + } + + public ExportIDsResult getExport() { + return export; + } + + public void setExport(ExportIDsResult export) { + this.export = export; + } + + public String getDuration() { + return duration; + } + + public void setDuration(String duration) { + this.duration = duration; + } +} \ No newline at end of file diff --git a/src/main/java/io/getstream/core/models/ExportIDsResult.java b/src/main/java/io/getstream/core/models/ExportIDsResult.java new file mode 100644 index 00000000..043c673b --- /dev/null +++ b/src/main/java/io/getstream/core/models/ExportIDsResult.java @@ -0,0 +1,62 @@ +package io.getstream.core.models; + +import java.util.List; +import com.fasterxml.jackson.annotation.JsonProperty; + +public class ExportIDsResult { + @JsonProperty("user_id") + private String userId; + + @JsonProperty("activity_count") + private int activityCount; + + @JsonProperty("activity_ids") + private List activityIds; + + @JsonProperty("reaction_count") + private int reactionCount; + + @JsonProperty("reaction_ids") + private List reactionIds; + + // Getters and Setters + public String getUserId() { + return userId; + } + + public void setUserId(String userId) { + this.userId = userId; + } + + public int getActivityCount() { + return activityCount; + } + + public void setActivityCount(int activityCount) { + this.activityCount = activityCount; + } + + public List getActivityIds() { + return activityIds; + } + + public void setActivityIds(List activityIds) { + this.activityIds = activityIds; + } + + public int getReactionCount() { + return reactionCount; + } + + public void setReactionCount(int reactionCount) { + this.reactionCount = reactionCount; + } + + public List getReactionIds() { + return reactionIds; + } + + public void setReactionIds(List reactionIds) { + this.reactionIds = reactionIds; + } +} \ No newline at end of file diff --git a/src/main/java/io/getstream/core/options/Filter.java b/src/main/java/io/getstream/core/options/Filter.java index a0b0c2a0..55c98043 100644 --- a/src/main/java/io/getstream/core/options/Filter.java +++ b/src/main/java/io/getstream/core/options/Filter.java @@ -10,7 +10,8 @@ enum OpType { ID_GREATER_THAN("id_gt"), ID_LESS_THAN_OR_EQUAL("id_lte"), ID_LESS_THAN("id_lt"), - REFRESH("refresh"); + REFRESH("refresh"), + DISCARD_DELETED_ACTIVITIES("discard_deleted_activities"); private String operator; @@ -46,6 +47,11 @@ public Filter idGreaterThanEqual(String id) { return this; } + public Filter discardDeletedActivities() { + ops.add(new OpEntry(OpType.DISCARD_DELETED_ACTIVITIES, "true")); + return this; + } + public Filter idLessThan(String id) { ops.add(new OpEntry(OpType.ID_LESS_THAN, id)); return this; diff --git a/src/main/java/io/getstream/core/utils/Auth.java b/src/main/java/io/getstream/core/utils/Auth.java index 3fdcada3..0c4ece57 100644 --- a/src/main/java/io/getstream/core/utils/Auth.java +++ b/src/main/java/io/getstream/core/utils/Auth.java @@ -42,7 +42,8 @@ public enum TokenResource { PERSONALIZATION("personalization"), REACTIONS("reactions"), USERS("users"), - MODERATION("moderation"); + MODERATION("moderation"), + DATAPRIVACY("data_privacy"); private final String resource; @@ -103,6 +104,10 @@ public static Token buildModerationToken(String secret, TokenAction action) { return buildBackendToken(secret, TokenResource.MODERATION, action, "*"); } + public static Token buildDataPrivacyToken(String secret, TokenAction action) { + return buildBackendToken(secret, TokenResource.DATAPRIVACY, action, "*"); + } + public static Token buildAnalyticsToken(String secret, TokenAction action) { return buildBackendToken(secret, TokenResource.ANALYTICS, action, "*"); } diff --git a/src/main/java/io/getstream/core/utils/Routes.java b/src/main/java/io/getstream/core/utils/Routes.java index 0d29cf8a..14f16b3e 100644 --- a/src/main/java/io/getstream/core/utils/Routes.java +++ b/src/main/java/io/getstream/core/utils/Routes.java @@ -28,6 +28,10 @@ public final class Routes { private static final String usersPath = "user/"; private static final String followStatsPath = "stats/follow/"; + private static final String exportIDsPath = "data_privacy/export_ids/"; + private static final String deleteActivitiesPath = "data_privacy/delete_activities/"; + private static final String deleteReactionsPath = "data_privacy/delete_reactions/"; + private Routes() { /* nothing to see here */ } @@ -118,6 +122,18 @@ public static URL buildModerationFlagURL(URL baseURL) throws MalformedURLExcepti return new URL(baseURL, basePath + moderationFlagPath); } + public static URL buildExportIDsURL(URL baseURL, String userID) throws MalformedURLException { + return new URL(baseURL, basePath + exportIDsPath+userID); + } + + public static URL deleteActivitiesURL(URL baseURL) throws MalformedURLException { + return new URL(baseURL, basePath + deleteActivitiesPath); + } + + public static URL deleteReactionsURL(URL baseURL) throws MalformedURLException { + return new URL(baseURL, basePath + deleteReactionsPath); + } + public static URL followStatsPath(URL baseURL) throws MalformedURLException { return new URL(baseURL, basePath + followStatsPath); } diff --git a/src/test/java/io/getstream/client/BatchDeleteActivitiesTest.java b/src/test/java/io/getstream/client/BatchDeleteActivitiesTest.java new file mode 100644 index 00000000..85ecd7ab --- /dev/null +++ b/src/test/java/io/getstream/client/BatchDeleteActivitiesTest.java @@ -0,0 +1,228 @@ +package io.getstream.client; + +import static org.junit.Assert.*; + +import com.google.common.collect.Lists; +import io.getstream.core.http.Response; +import io.getstream.core.models.BatchDeleteActivitiesRequest; +import io.getstream.core.models.BatchDeleteActivitiesRequest.ActivityToDelete; +import io.getstream.core.models.Activity; +import io.getstream.core.models.BatchDeleteReactionsRequest; +import io.getstream.core.models.Reaction; +import io.getstream.core.options.Filter; +import io.getstream.core.options.Limit; +import java8.util.concurrent.CompletableFuture; +import org.junit.*; + +import java.util.Arrays; +import java.util.List; +import java.util.UUID; + +public class BatchDeleteActivitiesTest { + + private static final String apiKey = + System.getenv("STREAM_KEY") != null + ? System.getenv("STREAM_KEY") + : System.getProperty("STREAM_KEY"); + private static final String secret = + System.getenv("STREAM_SECRET") != null + ? System.getenv("STREAM_SECRET") + : System.getProperty("STREAM_SECRET"); + + private Client client; + + @Before + public void setUp() throws Exception { + client = Client.builder(apiKey, secret).build(); + } + + @Test + public void testDeleteActivities() throws Exception { + String uuid1 = UUID.randomUUID().toString().replace("-", ""); + FlatFeed feed = client.flatFeed("flat", uuid1); + + // Insert some activities + Activity activity1 = Activity.builder() + .actor("user1") + .verb("post") + .object("object1") + .build(); + Activity activity1Res = feed.addActivity(activity1).join(); + + Activity activity2 = Activity.builder() + .actor("user1") + .verb("like") + .object("object2") + .build(); + Activity activity2Res = feed.addActivity(activity2).join(); + + // Create delete request + List activities = Arrays.asList( + new ActivityToDelete(activity1Res.getID(), Arrays.asList("user:user1", "user:alice")), + new ActivityToDelete(activity2Res.getID(), Arrays.asList("user:user1")) + ); + BatchClient clientBatch = Client.builder(apiKey, secret).build().batch(); + + // Verify activities are inserted + List activity1Resp = clientBatch.getActivitiesByID(activity1Res.getID()).join(); + assertEquals(1, activity1Resp.size()); + + List activity2Resp = clientBatch.getActivitiesByID(activity2Res.getID()).join(); + assertEquals(1, activity2Resp.size()); + + BatchDeleteActivitiesRequest request = new BatchDeleteActivitiesRequest(activities); + + // Delete activities + CompletableFuture future = client.deleteActivities(request); + future.join(); + + assertTrue(future.isDone()); + + // Verify activities are deleted + List deletedActivity1 = clientBatch.getActivitiesByID(activity1Res.getID()).join(); + assertEquals(0, deletedActivity1.size()); + + List deletedActivity2 = clientBatch.getActivitiesByID(activity2Res.getID()).join(); + assertEquals(0, deletedActivity2.size()); + } + + @Test + public void testDeleteReactions() throws Exception { + String uuid1 = UUID.randomUUID().toString().replace("-", ""); + FlatFeed feed = client.flatFeed("user", uuid1); + + // Insert some activities + Activity activity1 = Activity.builder() + .actor("user1") + .verb("post") + .object("object1") + .build(); + Activity activity1Res = feed.addActivity(activity1).join(); + + Activity activity2 = Activity.builder() + .actor("user1") + .verb("like") + .object("object2") + .build(); + Activity activity2Res = feed.addActivity(activity2).join(); + + //add reactions for activity1 + Reaction u1=client.reactions().add("user1", "like", activity1Res.getID()).join(); + Reaction u2=client.reactions().add("user2", "like", activity1Res.getID()).join(); + + Reaction u3=client.reactions().add("user1", "like", activity2Res.getID()).join(); + Reaction u4=client.reactions().add("user2", "like", activity2Res.getID()).join(); + + + //fetch test reactions + Reaction r1=client.reactions().get(u1.getId()).join(); + assertNotNull(r1); + + Reaction r2=client.reactions().get(u2.getId()).join(); + assertNotNull(r2); + + Reaction r3=client.reactions().get(u3.getId()).join(); + assertNotNull(r3); + + Reaction r4=client.reactions().get(u4.getId()).join(); + assertNotNull(r4); + + // Create reaction delete request + BatchDeleteReactionsRequest deleteReactionsRequest= + new BatchDeleteReactionsRequest(Arrays.asList + (u1.getId(), u2.getId(), u3.getId())); + + + client.deleteReactions(deleteReactionsRequest).join(); + + //fetch test reactions + + // DoesNotExistException + assertThrows(Exception.class, () -> { + client.reactions().get(u1.getId()).join(); + }); + assertThrows(Exception.class, () -> { + client.reactions().get(u2.getId()).join(); + }); + assertThrows(Exception.class, () -> { + client.reactions().get(u3.getId()).join(); + }); + + + } + + @Test + public void testDeleteActivitiesMultipleFeeds() throws Exception { + + // Insert some activities + String uuid1 = UUID.randomUUID().toString().replace("-", ""); + String uuid2 = UUID.randomUUID().toString().replace("-", ""); + + FlatFeed feed = client.flatFeed("flat", uuid1); + FlatFeed feedAlice = client.flatFeed("flat", uuid2); + Activity activity1 = Activity.builder() + .actor("user1") + .verb("post") + .to(Lists.newArrayList(feedAlice.getID())) + .object("object1") + .build(); + Activity activity1Res = feed.addActivity(activity1).join(); + + Activity activity2 = Activity.builder() + .actor("user1") + .verb("like") + .to(Lists.newArrayList(feedAlice.getID())) + .object("object2") + .build(); + Activity activity2Res = feed.addActivity(activity2).join(); + + // Verify activities are inserted + List activities =feed.getActivities( + new Limit(69), new Filter()).join(); + assertEquals(2, activities.size()); + + // Create delete request + List activitiesToDelete = Arrays.asList( + new ActivityToDelete(activity1Res.getID(), Arrays.asList(feed.getID().toString())), + new ActivityToDelete(activity2Res.getID(), Arrays.asList(feedAlice.getID().toString())) + ); + + activities =feedAlice.getActivities( + new Limit(10), new Filter()).join(); + assertEquals(2, activities.size());//0 + + activities =feedAlice.getActivities( + new Limit(10), new Filter().discardDeletedActivities()).join(); + assertEquals(2, activities.size());//1 + + + BatchDeleteActivitiesRequest request = new BatchDeleteActivitiesRequest(activitiesToDelete); + CompletableFuture future = client.deleteActivities(request); + future.join(); + + + // Verify activities are deleted by fetching by ID + BatchClient clientBatch = Client.builder(apiKey, secret).build().batch(); + List deletedActivity1 = clientBatch.getActivitiesByID(activity1Res.getID()).join(); + assertEquals(0, deletedActivity1.size()); + + // read feeds + // without discardDeletedActivities + activities =feed.getActivities( + new Limit(69), new Filter()).join(); + assertEquals(2, activities.size()); + + + activities =feed.getActivities( + new Limit(10), new Filter().discardDeletedActivities()).join(); + assertEquals(0, activities.size()); + + activities =feedAlice.getActivities( + new Limit(10), new Filter()).join(); + assertEquals(2, activities.size()); + + activities =feedAlice.getActivities( + new Limit(10), new Filter().discardDeletedActivities()).join(); + assertEquals(0, activities.size()); + } +} \ No newline at end of file diff --git a/src/test/java/io/getstream/client/ExportIDsTest.java b/src/test/java/io/getstream/client/ExportIDsTest.java new file mode 100644 index 00000000..a388ef5f --- /dev/null +++ b/src/test/java/io/getstream/client/ExportIDsTest.java @@ -0,0 +1,66 @@ +package io.getstream.client; + +import static org.junit.Assert.*; + +import io.getstream.core.models.Activity; +import io.getstream.core.models.ExportIDsResponse; +import io.getstream.core.models.ExportIDsResult; +import org.junit.*; + +import java.util.Date; + +public class ExportIDsTest { + + private static final String apiKey = + System.getenv("STREAM_KEY") != null + ? System.getenv("STREAM_KEY") + : System.getProperty("STREAM_KEY"); + private static final String secret = + System.getenv("STREAM_SECRET") != null + ? System.getenv("STREAM_SECRET") + : System.getProperty("STREAM_SECRET"); + + Client client; + + @Before + public void setUp() throws Exception { + client = Client.builder(apiKey, secret).build(); + } + + @Test + public void testExportUserActivities() throws Exception { + + String userId = "test-user"; + + // Insert some activities + Activity activity1 = Activity.builder() + .actor(userId) + .verb("post") + .object("object1") + .time(new Date()) + .build(); + Activity activity1Res = client.flatFeed("user", userId).addActivity(activity1).join(); + + Activity activity2 = Activity.builder() + .actor(userId) + .verb("like") + .object("object2") + .time(new Date()) + .build(); + Activity activity2Res = client.flatFeed("user", userId).addActivity(activity2).join(); + + // Export user activities + ExportIDsResponse exportResult = client.exportUserActivities(userId).join(); + ExportIDsResult exports = exportResult.getExport(); + + // Test the output + assertNotNull(exportResult); + assertEquals(userId, exports.getUserId()); + assertTrue(exports.getActivityCount() >= 0); + assertNotNull(exports.getActivityIds()); + assertTrue(exports.getActivityIds().contains(activity1Res.getID())); + assertTrue(exports.getActivityIds().contains(activity2Res.getID())); + assertTrue(exports.getReactionCount() >= 0); + assertNotNull(exports.getReactionIds()); + } +} \ No newline at end of file