diff --git a/.gitignore b/.gitignore index b64f1cc281..0b1326339d 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,7 @@ package-lock.json # Testing cypress + +# Airy config files +airy.yaml +cli.yaml diff --git a/VERSION b/VERSION index bb22182d4f..4ef2eb086f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.38.1 +0.39.0 diff --git a/WORKSPACE b/WORKSPACE index 517ddef88d..21ad51fe88 100644 --- a/WORKSPACE +++ b/WORKSPACE @@ -9,9 +9,9 @@ load("@bazel_tools//tools/build_defs/repo:git.bzl", "git_repository") # Airy Bazel tools git_repository( name = "com_github_airyhq_bazel_tools", - commit = "2f3dc965c8ad367d10d62d754f99336cd1cdd40f", + commit = "603c42f934d41c53bd93987419920cd9bc7959e7", remote = "https://github.com/airyhq/bazel-tools.git", - shallow_since = "1634214650 +0200", + shallow_since = "1643188910 +0100", ) load("@com_github_airyhq_bazel_tools//:repositories.bzl", "airy_bazel_tools_dependencies", "airy_jvm_deps") diff --git a/backend/api/admin/BUILD b/backend/api/admin/BUILD index bd90f49378..38e9acc358 100644 --- a/backend/api/admin/BUILD +++ b/backend/api/admin/BUILD @@ -1,4 +1,5 @@ load("@com_github_airyhq_bazel_tools//lint:buildifier.bzl", "check_pkg") +load("//tools/build:java_library.bzl", "custom_java_library") load("//tools/build:springboot.bzl", "springboot") load("//tools/build:junit5.bzl", "junit5") load("//tools/build:container_release.bzl", "container_release") @@ -13,6 +14,12 @@ app_deps = [ "//backend/model/template", "//backend/model/event", "//lib/java/uuid", + "//lib/java/pagination", + "//lib/java/date", + "//backend/avro:http-log", + "//backend/avro:user", + "//lib/java/kafka/schema:ops-application-logs", + "//lib/java/kafka/schema:application-communication-users", "//lib/java/spring/auth:spring-auth", "//lib/java/spring/web:spring-web", "//lib/java/spring/kafka/core:spring-kafka-core", @@ -27,6 +34,12 @@ springboot( deps = app_deps, ) +custom_java_library( + name = "test-util", + srcs = ["src/test/java/co/airy/core/api/admin/util/Topics.java"], + deps = app_deps, +) + [ junit5( size = "medium", @@ -34,6 +47,7 @@ springboot( resources = glob(["src/test/resources/**/*"]), deps = [ ":app", + ":test-util", "//backend:base_test", "//lib/java/kafka/test:kafka-test", "//lib/java/spring/test:spring-test", diff --git a/backend/api/admin/src/main/java/co/airy/core/api/admin/Stores.java b/backend/api/admin/src/main/java/co/airy/core/api/admin/Stores.java index 89f0221861..df3b8f734a 100644 --- a/backend/api/admin/src/main/java/co/airy/core/api/admin/Stores.java +++ b/backend/api/admin/src/main/java/co/airy/core/api/admin/Stores.java @@ -5,12 +5,16 @@ import co.airy.avro.communication.Metadata; import co.airy.avro.communication.Tag; import co.airy.avro.communication.Template; +import co.airy.avro.communication.User; import co.airy.avro.communication.Webhook; +import co.airy.avro.ops.HttpLog; import co.airy.kafka.schema.application.ApplicationCommunicationChannels; import co.airy.kafka.schema.application.ApplicationCommunicationMetadata; import co.airy.kafka.schema.application.ApplicationCommunicationTags; import co.airy.kafka.schema.application.ApplicationCommunicationTemplates; +import co.airy.kafka.schema.application.ApplicationCommunicationUsers; import co.airy.kafka.schema.application.ApplicationCommunicationWebhooks; +import co.airy.kafka.schema.ops.OpsApplicationLogs; import co.airy.kafka.streams.KafkaStreamsWrapper; import co.airy.model.channel.dto.ChannelContainer; import co.airy.model.metadata.dto.MetadataMap; @@ -32,8 +36,10 @@ import java.util.ArrayList; import java.util.List; +import java.util.Optional; import java.util.concurrent.ExecutionException; +import static co.airy.core.api.admin.TimestampExtractor.timestampExtractor; import static co.airy.model.metadata.MetadataRepository.getId; import static co.airy.model.metadata.MetadataRepository.getSubject; @@ -48,12 +54,15 @@ public class Stores implements HealthIndicator, ApplicationListener producer) { this.streams = streams; @@ -64,7 +73,7 @@ public Stores(KafkaStreamsWrapper streams, KafkaProducer metadataTable = builder.table(applicationCommunicationMetadata) .groupBy((metadataId, metadata) -> KeyValue.pair(getSubject(metadata).getIdentifier(), metadata)) .aggregate(MetadataMap::new, MetadataMap::adder, MetadataMap::subtractor); @@ -79,6 +88,36 @@ public void onApplicationEvent(ApplicationStartedEvent event) { builder.table(applicationCommunicationTemplates, Materialized.as(templatesStore)); + final KTable usersTable = builder.table(applicationCommunicationUsers, Materialized.as(usersStore)); + + // Extract users from the op log to the users topic + builder.stream(opsApplicationLogs) + .filter((logId, log) -> log.getUserId() != null) + .selectKey((logId, log) -> log.getUserId()) + // Extract the Kafka record timestamp header + .transform(timestampExtractor()) + .leftJoin(usersTable, (logWithTimestamp, user) -> { + final HttpLog log = logWithTimestamp.getLog(); + if (user == null) { + return User.newBuilder() + .setId(log.getUserId()) + .setName(log.getUserName()) + .setAvatarUrl(log.getUserAvatar()) + .setFirstSeenAt(logWithTimestamp.getTimestamp()) + .setLastSeenAt(logWithTimestamp.getTimestamp()) + .build(); + } + + return User.newBuilder() + .setId(user.getId()) + .setName(Optional.ofNullable(user.getName()).orElse(log.getUserName())) + .setAvatarUrl(Optional.ofNullable(user.getAvatarUrl()).orElse(log.getUserAvatar())) + .setFirstSeenAt(user.getFirstSeenAt()) + .setLastSeenAt(logWithTimestamp.getTimestamp()) + .build(); + }) + .to(applicationCommunicationUsers); + streams.start(builder.build(), appId); } @@ -125,6 +164,10 @@ public void deleteTemplate(Template template) { producer.send(new ProducerRecord<>(applicationCommunicationTemplates, template.getId(), null)); } + public ReadOnlyKeyValueStore getUsersStore() { + return streams.acquireLocalStore(usersStore); + } + public ReadOnlyKeyValueStore getConnectedChannelsStore() { return streams.acquireLocalStore(connectedChannelsStore); } @@ -175,6 +218,13 @@ public List getWebhooks() { return webhooks; } + public List getUsers() { + final KeyValueIterator iterator = getUsersStore().all(); + List users = new ArrayList<>(); + iterator.forEachRemaining(kv -> users.add(kv.value)); + return users; + } + @Override public void destroy() { if (streams != null) { @@ -187,6 +237,7 @@ public Health health() { getConnectedChannelsStore(); getWebhookStore(); getTagsStore(); + getUsersStore(); return Health.up().build(); } diff --git a/backend/api/admin/src/main/java/co/airy/core/api/admin/TimestampExtractor.java b/backend/api/admin/src/main/java/co/airy/core/api/admin/TimestampExtractor.java new file mode 100644 index 0000000000..6e8e6dd732 --- /dev/null +++ b/backend/api/admin/src/main/java/co/airy/core/api/admin/TimestampExtractor.java @@ -0,0 +1,36 @@ +package co.airy.core.api.admin; + +import co.airy.avro.ops.HttpLog; +import co.airy.core.api.admin.dto.LogWithTimestamp; +import org.apache.kafka.streams.KeyValue; +import org.apache.kafka.streams.kstream.Transformer; +import org.apache.kafka.streams.kstream.TransformerSupplier; +import org.apache.kafka.streams.processor.ProcessorContext; + +public class TimestampExtractor { + public static TransformerSupplier> timestampExtractor() { + return new TransformerSupplier<>() { + @Override + public Transformer> get() { + return new Transformer<>() { + + private ProcessorContext context; + + @Override + public void init(ProcessorContext processorContext) { + this.context = processorContext; + } + + @Override + public KeyValue transform(String logId, HttpLog log) { + return KeyValue.pair(logId, new LogWithTimestamp(context.timestamp(), log)); + } + + @Override + public void close() { + } + }; + } + }; + } +} diff --git a/backend/api/admin/src/main/java/co/airy/core/api/admin/UsersController.java b/backend/api/admin/src/main/java/co/airy/core/api/admin/UsersController.java new file mode 100644 index 0000000000..ad31b14051 --- /dev/null +++ b/backend/api/admin/src/main/java/co/airy/core/api/admin/UsersController.java @@ -0,0 +1,47 @@ +package co.airy.core.api.admin; + +import co.airy.avro.communication.User; +import co.airy.core.api.admin.payload.ListUsersResponsePayload; +import co.airy.core.api.admin.payload.PaginationData; +import co.airy.core.api.admin.payload.UserResponsePayload; +import co.airy.core.api.admin.payload.UsersListRequestPayload; +import co.airy.pagination.Page; +import co.airy.pagination.Paginator; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +import javax.validation.Valid; +import java.util.List; + +import static java.util.stream.Collectors.toList; + +@RestController +public class UsersController { + private final Stores stores; + + public UsersController(Stores stores) { + this.stores = stores; + } + + @PostMapping("/users.list") + public ResponseEntity list(@RequestBody(required = false) @Valid UsersListRequestPayload payload) { + payload = payload == null ? new UsersListRequestPayload() : payload; + + final List users = stores.getUsers(); + Paginator paginator = new Paginator<>(users, User::getId) + .perPage(payload.getPageSize()).from(payload.getCursor()); + + Page page = paginator.page(); + + return ResponseEntity.ok(ListUsersResponsePayload.builder() + .data(page.getData().stream().map(UserResponsePayload::fromUser).collect(toList())) + .paginationData(PaginationData.builder() + .nextCursor(page.getNextCursor()) + .previousCursor(page.getPreviousCursor()) + .total(users.size()) + .build()).build()); + } + +} diff --git a/backend/api/admin/src/main/java/co/airy/core/api/admin/dto/LogWithTimestamp.java b/backend/api/admin/src/main/java/co/airy/core/api/admin/dto/LogWithTimestamp.java new file mode 100644 index 0000000000..08581f0253 --- /dev/null +++ b/backend/api/admin/src/main/java/co/airy/core/api/admin/dto/LogWithTimestamp.java @@ -0,0 +1,16 @@ +package co.airy.core.api.admin.dto; + +import co.airy.avro.ops.HttpLog; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class LogWithTimestamp implements Serializable { + private long timestamp; + private HttpLog log; +} diff --git a/backend/api/admin/src/main/java/co/airy/core/api/admin/payload/ListUsersResponsePayload.java b/backend/api/admin/src/main/java/co/airy/core/api/admin/payload/ListUsersResponsePayload.java new file mode 100644 index 0000000000..d90dfb4f87 --- /dev/null +++ b/backend/api/admin/src/main/java/co/airy/core/api/admin/payload/ListUsersResponsePayload.java @@ -0,0 +1,17 @@ +package co.airy.core.api.admin.payload; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class ListUsersResponsePayload { + private List data; + private PaginationData paginationData; +} diff --git a/backend/api/admin/src/main/java/co/airy/core/api/admin/payload/PaginationData.java b/backend/api/admin/src/main/java/co/airy/core/api/admin/payload/PaginationData.java new file mode 100644 index 0000000000..fbc15579e3 --- /dev/null +++ b/backend/api/admin/src/main/java/co/airy/core/api/admin/payload/PaginationData.java @@ -0,0 +1,16 @@ +package co.airy.core.api.admin.payload; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PaginationData { + private String previousCursor; + private String nextCursor; + private long total; +} diff --git a/backend/api/admin/src/main/java/co/airy/core/api/admin/payload/UserResponsePayload.java b/backend/api/admin/src/main/java/co/airy/core/api/admin/payload/UserResponsePayload.java new file mode 100644 index 0000000000..651c274091 --- /dev/null +++ b/backend/api/admin/src/main/java/co/airy/core/api/admin/payload/UserResponsePayload.java @@ -0,0 +1,27 @@ +package co.airy.core.api.admin.payload; + +import co.airy.avro.communication.User; +import lombok.Builder; +import lombok.Data; + +import static co.airy.date.format.DateFormat.isoFromMillis; + +@Data +@Builder +public class UserResponsePayload { + private String id; + private String name; + private String avatarUrl; + private String firstSeenAt; + private String lastSeenAt; + + public static UserResponsePayload fromUser(User user) { + return UserResponsePayload.builder() + .id(user.getId()) + .name(user.getName()) + .avatarUrl(user.getAvatarUrl()) + .firstSeenAt(isoFromMillis(user.getFirstSeenAt())) + .lastSeenAt(isoFromMillis(user.getLastSeenAt())) + .build(); + } +} diff --git a/backend/api/admin/src/main/java/co/airy/core/api/admin/payload/UsersListRequestPayload.java b/backend/api/admin/src/main/java/co/airy/core/api/admin/payload/UsersListRequestPayload.java new file mode 100644 index 0000000000..9d5b0728c9 --- /dev/null +++ b/backend/api/admin/src/main/java/co/airy/core/api/admin/payload/UsersListRequestPayload.java @@ -0,0 +1,14 @@ +package co.airy.core.api.admin.payload; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class UsersListRequestPayload { + private String cursor; + private Integer pageSize = 20; +} diff --git a/backend/api/admin/src/test/java/co/airy/core/api/admin/ChannelsControllerTest.java b/backend/api/admin/src/test/java/co/airy/core/api/admin/ChannelsControllerTest.java index 2920fd3d92..8561931043 100644 --- a/backend/api/admin/src/test/java/co/airy/core/api/admin/ChannelsControllerTest.java +++ b/backend/api/admin/src/test/java/co/airy/core/api/admin/ChannelsControllerTest.java @@ -2,11 +2,7 @@ import co.airy.avro.communication.Channel; import co.airy.avro.communication.ChannelConnectionState; -import co.airy.kafka.schema.application.ApplicationCommunicationChannels; -import co.airy.kafka.schema.application.ApplicationCommunicationMetadata; -import co.airy.kafka.schema.application.ApplicationCommunicationTags; -import co.airy.kafka.schema.application.ApplicationCommunicationTemplates; -import co.airy.kafka.schema.application.ApplicationCommunicationWebhooks; +import co.airy.core.api.admin.util.Topics; import co.airy.kafka.test.KafkaTestHelper; import co.airy.kafka.test.junit.SharedKafkaTestResource; import co.airy.spring.core.AirySpringBootApplication; @@ -44,24 +40,13 @@ public class ChannelsControllerTest { @RegisterExtension public static final SharedKafkaTestResource sharedKafkaTestResource = new SharedKafkaTestResource(); private static KafkaTestHelper kafkaTestHelper; - private static final ApplicationCommunicationChannels applicationCommunicationChannels = new ApplicationCommunicationChannels(); - private static final ApplicationCommunicationWebhooks applicationCommunicationWebhooks = new ApplicationCommunicationWebhooks(); - private static final ApplicationCommunicationMetadata applicationCommunicationMetadata = new ApplicationCommunicationMetadata(); - private static final ApplicationCommunicationTags applicationCommunicationTags = new ApplicationCommunicationTags(); - private static final ApplicationCommunicationTemplates applicationCommunicationTemplates = new ApplicationCommunicationTemplates(); @Autowired private WebTestHelper webTestHelper; @BeforeAll static void beforeAll() throws Exception { - kafkaTestHelper = new KafkaTestHelper(sharedKafkaTestResource, - applicationCommunicationChannels, - applicationCommunicationWebhooks, - applicationCommunicationMetadata, - applicationCommunicationTags, - applicationCommunicationTemplates - ); + kafkaTestHelper = new KafkaTestHelper(sharedKafkaTestResource, Topics.getTopics()); kafkaTestHelper.beforeAll(); } @@ -87,7 +72,7 @@ void beforeEach() throws Exception { testDataInitialized = true; - kafkaTestHelper.produceRecord(new ProducerRecord<>(applicationCommunicationChannels.name(), + kafkaTestHelper.produceRecord(new ProducerRecord<>(Topics.applicationCommunicationChannels.name(), connectedChannel.getId(), connectedChannel)); webTestHelper.waitUntilHealthy(); @@ -98,7 +83,7 @@ void canListChannels() throws Exception { final String disconnectedChannel = "channel-id-2"; kafkaTestHelper.produceRecords(List.of( - new ProducerRecord<>(applicationCommunicationChannels.name(), disconnectedChannel, + new ProducerRecord<>(Topics.applicationCommunicationChannels.name(), disconnectedChannel, Channel.newBuilder() .setConnectionState(ChannelConnectionState.DISCONNECTED) .setId(disconnectedChannel) diff --git a/backend/api/admin/src/test/java/co/airy/core/api/admin/TagsControllerTest.java b/backend/api/admin/src/test/java/co/airy/core/api/admin/TagsControllerTest.java index 2ddbf3acc0..09e814e7a0 100644 --- a/backend/api/admin/src/test/java/co/airy/core/api/admin/TagsControllerTest.java +++ b/backend/api/admin/src/test/java/co/airy/core/api/admin/TagsControllerTest.java @@ -1,10 +1,6 @@ package co.airy.core.api.admin; -import co.airy.kafka.schema.application.ApplicationCommunicationChannels; -import co.airy.kafka.schema.application.ApplicationCommunicationMetadata; -import co.airy.kafka.schema.application.ApplicationCommunicationTags; -import co.airy.kafka.schema.application.ApplicationCommunicationTemplates; -import co.airy.kafka.schema.application.ApplicationCommunicationWebhooks; +import co.airy.core.api.admin.util.Topics; import co.airy.kafka.test.KafkaTestHelper; import co.airy.kafka.test.junit.SharedKafkaTestResource; import co.airy.spring.core.AirySpringBootApplication; @@ -36,11 +32,6 @@ public class TagsControllerTest { @RegisterExtension public static final SharedKafkaTestResource sharedKafkaTestResource = new SharedKafkaTestResource(); - private static final ApplicationCommunicationChannels applicationCommunicationChannels = new ApplicationCommunicationChannels(); - private static final ApplicationCommunicationWebhooks applicationCommunicationWebhooks = new ApplicationCommunicationWebhooks(); - private static final ApplicationCommunicationMetadata applicationCommunicationMetadata = new ApplicationCommunicationMetadata(); - private static final ApplicationCommunicationTags applicationCommunicationTags = new ApplicationCommunicationTags(); - private static final ApplicationCommunicationTemplates applicationCommunicationTemplates = new ApplicationCommunicationTemplates(); private static KafkaTestHelper kafkaTestHelper; @Autowired @@ -51,12 +42,7 @@ public class TagsControllerTest { @BeforeAll static void beforeAll() throws Exception { - kafkaTestHelper = new KafkaTestHelper(sharedKafkaTestResource, - applicationCommunicationChannels, - applicationCommunicationWebhooks, - applicationCommunicationMetadata, - applicationCommunicationTags, - applicationCommunicationTemplates + kafkaTestHelper = new KafkaTestHelper(sharedKafkaTestResource, Topics.getTopics() ); kafkaTestHelper.beforeAll(); } diff --git a/backend/api/admin/src/test/java/co/airy/core/api/admin/TemplatesControllerTest.java b/backend/api/admin/src/test/java/co/airy/core/api/admin/TemplatesControllerTest.java index ffcdbdbe86..35ca5d2cc2 100644 --- a/backend/api/admin/src/test/java/co/airy/core/api/admin/TemplatesControllerTest.java +++ b/backend/api/admin/src/test/java/co/airy/core/api/admin/TemplatesControllerTest.java @@ -1,10 +1,6 @@ package co.airy.core.api.admin; -import co.airy.kafka.schema.application.ApplicationCommunicationChannels; -import co.airy.kafka.schema.application.ApplicationCommunicationMetadata; -import co.airy.kafka.schema.application.ApplicationCommunicationTags; -import co.airy.kafka.schema.application.ApplicationCommunicationTemplates; -import co.airy.kafka.schema.application.ApplicationCommunicationWebhooks; +import co.airy.core.api.admin.util.Topics; import co.airy.kafka.test.KafkaTestHelper; import co.airy.kafka.test.junit.SharedKafkaTestResource; import co.airy.spring.core.AirySpringBootApplication; @@ -39,11 +35,6 @@ public class TemplatesControllerTest { @RegisterExtension public static final SharedKafkaTestResource sharedKafkaTestResource = new SharedKafkaTestResource(); - private static final ApplicationCommunicationChannels applicationCommunicationChannels = new ApplicationCommunicationChannels(); - private static final ApplicationCommunicationWebhooks applicationCommunicationWebhooks = new ApplicationCommunicationWebhooks(); - private static final ApplicationCommunicationMetadata applicationCommunicationMetadata = new ApplicationCommunicationMetadata(); - private static final ApplicationCommunicationTags applicationCommunicationTags = new ApplicationCommunicationTags(); - private static final ApplicationCommunicationTemplates applicationCommunicationTemplates = new ApplicationCommunicationTemplates(); private static KafkaTestHelper kafkaTestHelper; @Autowired @@ -54,13 +45,7 @@ public class TemplatesControllerTest { @BeforeAll static void beforeAll() throws Exception { - kafkaTestHelper = new KafkaTestHelper(sharedKafkaTestResource, - applicationCommunicationChannels, - applicationCommunicationWebhooks, - applicationCommunicationMetadata, - applicationCommunicationTags, - applicationCommunicationTemplates - ); + kafkaTestHelper = new KafkaTestHelper(sharedKafkaTestResource, Topics.getTopics()); kafkaTestHelper.beforeAll(); } diff --git a/backend/api/admin/src/test/java/co/airy/core/api/admin/UsersControllerTest.java b/backend/api/admin/src/test/java/co/airy/core/api/admin/UsersControllerTest.java new file mode 100644 index 0000000000..be3e8fbf05 --- /dev/null +++ b/backend/api/admin/src/test/java/co/airy/core/api/admin/UsersControllerTest.java @@ -0,0 +1,111 @@ +package co.airy.core.api.admin; + +import co.airy.avro.communication.User; +import co.airy.avro.ops.HttpLog; +import co.airy.core.api.admin.util.Topics; +import co.airy.core.api.config.ServiceDiscovery; +import co.airy.kafka.test.KafkaTestHelper; +import co.airy.kafka.test.junit.SharedKafkaTestResource; +import co.airy.spring.core.AirySpringBootApplication; +import co.airy.spring.test.WebTestHelper; +import org.apache.kafka.clients.producer.ProducerRecord; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.mockito.InjectMocks; +import org.mockito.MockitoAnnotations; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static co.airy.test.Timing.retryOnException; +import static java.util.stream.Collectors.toList; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.hasSize; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, classes = AirySpringBootApplication.class) +@TestPropertySource(value = "classpath:test.properties") +@AutoConfigureMockMvc +@ExtendWith(SpringExtension.class) +public class UsersControllerTest { + + @RegisterExtension + public static final SharedKafkaTestResource sharedKafkaTestResource = new SharedKafkaTestResource(); + private static KafkaTestHelper kafkaTestHelper; + + @Autowired + private WebTestHelper webTestHelper; + + @Autowired + @InjectMocks + private WebhooksController webhooksController; + + @MockBean + private ServiceDiscovery serviceDiscovery; + + private static final List users = List.of( + User.newBuilder() + .setId("user-id-1") + .setName("user-name-1") + .setFirstSeenAt(Instant.now().toEpochMilli()) + .setLastSeenAt(Instant.now().toEpochMilli()) + .build(), + User.newBuilder() + .setId("user-id-2") + .setName("user-name-2") + .setFirstSeenAt(Instant.now().toEpochMilli()) + .setLastSeenAt(Instant.now().toEpochMilli()) + .build() + ); + + @BeforeAll + static void beforeAll() throws Exception { + kafkaTestHelper = new KafkaTestHelper(sharedKafkaTestResource, Topics.getTopics()); + kafkaTestHelper.beforeAll(); + kafkaTestHelper.produceRecords(users.stream().map((user) -> + new ProducerRecord<>(Topics.opsApplicationLogs.name(), UUID.randomUUID().toString(), + HttpLog.newBuilder() + .setUri("/example") + .setUserId(user.getId()) + .setUserName(user.getName()) + .setHeaders(Map.of()) + .build() + )).collect(toList())); + } + + @BeforeEach + void beforeEach() throws Exception { + MockitoAnnotations.openMocks(this); + webTestHelper.waitUntilHealthy(); + } + + @AfterAll + static void afterAll() throws Exception { + kafkaTestHelper.afterAll(); + } + + @Test + public void canListUsers() throws Exception { + retryOnException(() -> webTestHelper.post("/users.list") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data", hasSize(2))) + .andExpect(jsonPath("$.data[*].id").value(containsInAnyOrder(users.stream() + .map(User::getId).toArray()))), + "list did not return users", 10_000 + ); + } + +} diff --git a/backend/api/admin/src/test/java/co/airy/core/api/admin/WebhooksControllerTest.java b/backend/api/admin/src/test/java/co/airy/core/api/admin/WebhooksControllerTest.java index dd7e2acb7c..f7255f6c24 100644 --- a/backend/api/admin/src/test/java/co/airy/core/api/admin/WebhooksControllerTest.java +++ b/backend/api/admin/src/test/java/co/airy/core/api/admin/WebhooksControllerTest.java @@ -2,13 +2,9 @@ import co.airy.avro.communication.Status; import co.airy.avro.communication.Webhook; +import co.airy.core.api.admin.util.Topics; import co.airy.core.api.config.ServiceDiscovery; import co.airy.core.api.config.dto.ServiceInfo; -import co.airy.kafka.schema.application.ApplicationCommunicationChannels; -import co.airy.kafka.schema.application.ApplicationCommunicationMetadata; -import co.airy.kafka.schema.application.ApplicationCommunicationTags; -import co.airy.kafka.schema.application.ApplicationCommunicationTemplates; -import co.airy.kafka.schema.application.ApplicationCommunicationWebhooks; import co.airy.kafka.test.KafkaTestHelper; import co.airy.kafka.test.junit.SharedKafkaTestResource; import co.airy.model.event.payload.EventType; @@ -65,21 +61,9 @@ public class WebhooksControllerTest { @MockBean private ServiceDiscovery serviceDiscovery; - private static final ApplicationCommunicationChannels applicationCommunicationChannels = new ApplicationCommunicationChannels(); - private static final ApplicationCommunicationWebhooks applicationCommunicationWebhooks = new ApplicationCommunicationWebhooks(); - private static final ApplicationCommunicationMetadata applicationCommunicationMetadata = new ApplicationCommunicationMetadata(); - private static final ApplicationCommunicationTags applicationCommunicationTags = new ApplicationCommunicationTags(); - private static final ApplicationCommunicationTemplates applicationCommunicationTemplates = new ApplicationCommunicationTemplates(); - @BeforeAll static void beforeAll() throws Exception { - kafkaTestHelper = new KafkaTestHelper(sharedKafkaTestResource, - applicationCommunicationChannels, - applicationCommunicationWebhooks, - applicationCommunicationMetadata, - applicationCommunicationTags, - applicationCommunicationTemplates - ); + kafkaTestHelper = new KafkaTestHelper(sharedKafkaTestResource, Topics.getTopics()); kafkaTestHelper.beforeAll(); } @@ -147,7 +131,7 @@ public void canManageWebhook() throws Exception { @Test public void canListWebhooks() throws Exception { kafkaTestHelper.produceRecords(List.of( - new ProducerRecord<>(applicationCommunicationWebhooks.name(), UUID.randomUUID().toString(), + new ProducerRecord<>(Topics.applicationCommunicationWebhooks.name(), UUID.randomUUID().toString(), Webhook.newBuilder() .setEndpoint("http://endpoint.com/webhook") .setId(UUID.randomUUID().toString()) @@ -155,7 +139,7 @@ public void canListWebhooks() throws Exception { .setSubscribedAt(Instant.now().toEpochMilli()) .build() ), - new ProducerRecord<>(applicationCommunicationWebhooks.name(), UUID.randomUUID().toString(), + new ProducerRecord<>(Topics.applicationCommunicationWebhooks.name(), UUID.randomUUID().toString(), Webhook.newBuilder() .setEndpoint("http://endpoint.com/webhook-2") .setId(UUID.randomUUID().toString()) diff --git a/backend/api/admin/src/test/java/co/airy/core/api/admin/util/Topics.java b/backend/api/admin/src/test/java/co/airy/core/api/admin/util/Topics.java new file mode 100644 index 0000000000..f1eec01a73 --- /dev/null +++ b/backend/api/admin/src/test/java/co/airy/core/api/admin/util/Topics.java @@ -0,0 +1,30 @@ +package co.airy.core.api.admin.util; + +import co.airy.kafka.schema.Topic; +import co.airy.kafka.schema.application.ApplicationCommunicationChannels; +import co.airy.kafka.schema.application.ApplicationCommunicationMetadata; +import co.airy.kafka.schema.application.ApplicationCommunicationTags; +import co.airy.kafka.schema.application.ApplicationCommunicationTemplates; +import co.airy.kafka.schema.application.ApplicationCommunicationUsers; +import co.airy.kafka.schema.application.ApplicationCommunicationWebhooks; +import co.airy.kafka.schema.ops.OpsApplicationLogs; + +public class Topics { + public static final Topic applicationCommunicationChannels = new ApplicationCommunicationChannels(); + public static final Topic applicationCommunicationWebhooks = new ApplicationCommunicationWebhooks(); + public static final Topic applicationCommunicationTags = new ApplicationCommunicationTags(); + public static final Topic applicationCommunicationMetadata = new ApplicationCommunicationMetadata(); + public static final Topic applicationCommunicationTemplates = new ApplicationCommunicationTemplates(); + public static final Topic applicationCommunicationUsers = new ApplicationCommunicationUsers(); + public static final Topic opsApplicationLogs = new OpsApplicationLogs(); + + public static Topic[] getTopics() { + return new Topic[]{applicationCommunicationChannels, + applicationCommunicationWebhooks, + applicationCommunicationTags, + applicationCommunicationMetadata, + applicationCommunicationTemplates, + applicationCommunicationUsers, + opsApplicationLogs}; + } +} diff --git a/backend/api/admin/src/test/java/co/airy/core/api/config/ClientConfigControllerTest.java b/backend/api/admin/src/test/java/co/airy/core/api/config/ClientConfigControllerTest.java index 36f9769273..464229aad7 100644 --- a/backend/api/admin/src/test/java/co/airy/core/api/config/ClientConfigControllerTest.java +++ b/backend/api/admin/src/test/java/co/airy/core/api/config/ClientConfigControllerTest.java @@ -1,10 +1,6 @@ package co.airy.core.api.config; -import co.airy.kafka.schema.application.ApplicationCommunicationChannels; -import co.airy.kafka.schema.application.ApplicationCommunicationMetadata; -import co.airy.kafka.schema.application.ApplicationCommunicationTags; -import co.airy.kafka.schema.application.ApplicationCommunicationTemplates; -import co.airy.kafka.schema.application.ApplicationCommunicationWebhooks; +import co.airy.core.api.admin.util.Topics; import co.airy.kafka.test.KafkaTestHelper; import co.airy.kafka.test.junit.SharedKafkaTestResource; import co.airy.spring.core.AirySpringBootApplication; @@ -60,21 +56,9 @@ public class ClientConfigControllerTest { @Autowired private WebTestHelper webTestHelper; - private static final ApplicationCommunicationChannels applicationCommunicationChannels = new ApplicationCommunicationChannels(); - private static final ApplicationCommunicationWebhooks applicationCommunicationWebhooks = new ApplicationCommunicationWebhooks(); - private static final ApplicationCommunicationMetadata applicationCommunicationMetadata = new ApplicationCommunicationMetadata(); - private static final ApplicationCommunicationTags applicationCommunicationTags = new ApplicationCommunicationTags(); - private static final ApplicationCommunicationTemplates applicationCommunicationTemplates = new ApplicationCommunicationTemplates(); - @BeforeAll static void beforeAll() throws Exception { - kafkaTestHelper = new KafkaTestHelper(sharedKafkaTestResource, - applicationCommunicationChannels, - applicationCommunicationWebhooks, - applicationCommunicationMetadata, - applicationCommunicationTags, - applicationCommunicationTemplates - ); + kafkaTestHelper = new KafkaTestHelper(sharedKafkaTestResource, Topics.getTopics()); kafkaTestHelper.beforeAll(); } @@ -113,5 +97,4 @@ public void canReturnConfig() throws Exception { mockServer.verify(); } - } diff --git a/backend/api/communication/BUILD b/backend/api/communication/BUILD index 860a4a9d7e..3b477a673b 100644 --- a/backend/api/communication/BUILD +++ b/backend/api/communication/BUILD @@ -8,6 +8,7 @@ app_deps = [ "//backend:base_app", "//:springboot_actuator", "//:springboot_websocket", + "//backend/avro:user", "//backend/model/message", "//backend/model/channel", "//backend/model/metadata", @@ -18,6 +19,7 @@ app_deps = [ "//lib/java/pagination", "//lib/java/spring/auth:spring-auth", "//lib/java/spring/web:spring-web", + "//lib/java/kafka/schema:application-communication-users", "//lib/java/spring/kafka/core:spring-kafka-core", "//lib/java/spring/kafka/streams:spring-kafka-streams", "@maven//:org_springframework_security_spring_security_core", diff --git a/backend/api/communication/src/main/java/co/airy/core/api/communication/ConversationsController.java b/backend/api/communication/src/main/java/co/airy/core/api/communication/ConversationsController.java index c30961adee..5006368c33 100644 --- a/backend/api/communication/src/main/java/co/airy/core/api/communication/ConversationsController.java +++ b/backend/api/communication/src/main/java/co/airy/core/api/communication/ConversationsController.java @@ -2,6 +2,7 @@ import co.airy.avro.communication.Metadata; import co.airy.avro.communication.ReadReceipt; +import co.airy.avro.communication.User; import co.airy.core.api.communication.dto.LuceneQueryResult; import co.airy.core.api.communication.lucene.AiryAnalyzer; import co.airy.core.api.communication.lucene.ExtendedQueryParser; @@ -14,16 +15,19 @@ import co.airy.core.api.communication.payload.ConversationTagRequestPayload; import co.airy.core.api.communication.payload.ConversationUpdateContactRequestPayload; import co.airy.core.api.communication.payload.PaginationData; +import co.airy.log.AiryLoggerFactory; import co.airy.model.conversation.Conversation; +import co.airy.model.message.dto.MessageContainer; +import co.airy.model.message.dto.Sender; import co.airy.model.metadata.MetadataKeys; import co.airy.model.metadata.Subject; import co.airy.model.metadata.dto.MetadataMap; import co.airy.spring.web.payload.RequestErrorResponsePayload; -import org.apache.kafka.streams.state.KeyValueIterator; import org.apache.kafka.streams.state.ReadOnlyKeyValueStore; import org.apache.lucene.queryparser.classic.ParseException; import org.apache.lucene.search.MatchAllDocsQuery; import org.apache.lucene.search.Query; +import org.slf4j.Logger; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; @@ -33,7 +37,6 @@ import javax.validation.Valid; import java.io.IOException; import java.time.Instant; -import java.util.ArrayList; import java.util.List; import java.util.Optional; import java.util.Set; @@ -99,7 +102,7 @@ private ResponseEntity queryConversations(Query query, Integer cursor, int pa .map((conversationIndex -> conversationsStore.get(conversationIndex.getId()))) .collect(toList()); - final List enrichedConversations = stores.addChannelMetadata(conversations); + final List enrichedConversations = stores.enrichConversations(conversations); String nextCursor = null; if (cursor + pageSize < queryResult.getFilteredTotal()) { @@ -130,18 +133,15 @@ ResponseEntity conversationInfo(@RequestBody @Valid ConversationByIdRequestPa final MetadataMap channelMetadata = stores.getMetadata(conversation.getChannelId()); conversation.getChannelContainer().setMetadataMap(channelMetadata); - return ResponseEntity.ok(ConversationResponsePayload.fromConversation(conversation)); - } - - private List fetchAllConversations() { - final ReadOnlyKeyValueStore store = stores.getConversationsStore(); - - final KeyValueIterator iterator = store.all(); - - List conversations = new ArrayList<>(); - iterator.forEachRemaining(kv -> conversations.add(kv.value)); + final MessageContainer lastMessageContainer = conversation.getLastMessageContainer(); + final String senderId = lastMessageContainer.getMessage().getSenderId(); + final User user = stores.getUser(senderId); + lastMessageContainer.setSender(Sender.builder().id(senderId) + .name(Optional.ofNullable(user).map(User::getName).orElse(null)) + .avatarUrl(Optional.ofNullable(user).map(User::getAvatarUrl).orElse(null)) + .build()); - return conversations; + return ResponseEntity.ok(ConversationResponsePayload.fromConversation(conversation)); } @PostMapping({"/conversations.markRead", "/conversations.mark-read"}) @@ -276,4 +276,27 @@ ResponseEntity conversationUpdateContact(@RequestBody @Valid ConversationUpda return ResponseEntity.noContent().build(); } + @PostMapping("/conversations.refetch") + ResponseEntity conversationMetadataRefetch(@RequestBody @Valid ConversationByIdRequestPayload payload) { + final String conversationId = payload.getConversationId().toString(); + final ReadOnlyKeyValueStore store = stores.getConversationsStore(); + final Conversation conversation = store.get(conversationId); + + if (conversation == null) { + return ResponseEntity.notFound().build(); + } + + // Removing the FETCH_STATE from the metadata that will trigger the source to refetch + // the metadata for the specific conversation (if supported) + final Metadata metadata = newConversationMetadata(conversationId, MetadataKeys.ConversationKeys.Contact.FETCH_STATE, ""); + + try { + stores.deleteMetadata(metadata); + } catch (Exception e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new RequestErrorResponsePayload(e.getMessage())); + } + + return ResponseEntity.accepted().build(); + } + } diff --git a/backend/api/communication/src/main/java/co/airy/core/api/communication/Stores.java b/backend/api/communication/src/main/java/co/airy/core/api/communication/Stores.java index 2b3a25c7c2..74e7c01131 100644 --- a/backend/api/communication/src/main/java/co/airy/core/api/communication/Stores.java +++ b/backend/api/communication/src/main/java/co/airy/core/api/communication/Stores.java @@ -4,6 +4,7 @@ import co.airy.avro.communication.Message; import co.airy.avro.communication.Metadata; import co.airy.avro.communication.ReadReceipt; +import co.airy.avro.communication.User; import co.airy.core.api.communication.dto.CountAction; import co.airy.core.api.communication.dto.Messages; import co.airy.core.api.communication.dto.UnreadCountState; @@ -15,10 +16,12 @@ import co.airy.kafka.schema.application.ApplicationCommunicationMessages; import co.airy.kafka.schema.application.ApplicationCommunicationMetadata; import co.airy.kafka.schema.application.ApplicationCommunicationReadReceipts; +import co.airy.kafka.schema.application.ApplicationCommunicationUsers; import co.airy.kafka.streams.KafkaStreamsWrapper; import co.airy.model.channel.dto.ChannelContainer; import co.airy.model.conversation.Conversation; import co.airy.model.message.dto.MessageContainer; +import co.airy.model.message.dto.Sender; import co.airy.model.metadata.MetadataKeys; import co.airy.model.metadata.Subject; import co.airy.model.metadata.dto.MetadataMap; @@ -40,13 +43,13 @@ import org.springframework.context.ApplicationListener; import org.springframework.stereotype.Component; -import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.concurrent.ExecutionException; +import java.util.stream.Collectors; import static co.airy.model.metadata.MetadataRepository.getId; import static co.airy.model.metadata.MetadataRepository.getSubject; @@ -70,6 +73,7 @@ public class Stores implements HealthIndicator, ApplicationListener getMetadataStore() { return streams.acquireLocalStore(metadataStore); } + public ReadOnlyKeyValueStore getUsersStore() { + return streams.acquireLocalStore(usersStore); + } + public ReadOnlyLuceneStore getConversationLuceneStore() { return luceneProvider; } @@ -224,6 +234,10 @@ public void deleteMetadata(Subject subject, String key) throws ExecutionExceptio producer.send(new ProducerRecord<>(applicationCommunicationMetadata, getId(subject, key).toString(), null)).get(); } + public void deleteMetadata(Metadata metadata) throws ExecutionException, InterruptedException { + producer.send(new ProducerRecord<>(applicationCommunicationMetadata, getId(metadata).toString(), null)).get(); + } + public MetadataMap getMetadata(String subjectId) { final ReadOnlyKeyValueStore store = getMetadataStore(); return store.get(subjectId); @@ -234,22 +248,42 @@ public MessageContainer getMessageContainer(String messageId) { return store.get(messageId); } - public List addChannelMetadata(List conversations) { - final ReadOnlyKeyValueStore store = getMetadataStore(); + // There are likely much more conversations than users or channels, therefore we + // look them up once per conversation and cache the responses + public List enrichConversations(List conversations) { + final ReadOnlyKeyValueStore metadataStore = getMetadataStore(); + final ReadOnlyKeyValueStore usersStore = getUsersStore(); Map metadataCache = new HashMap<>(); + Map userCache = new HashMap<>(); for (Conversation conversation : conversations) { + // Add channel metadata to the conversation final ChannelContainer container = conversation.getChannelContainer(); final String channelId = container.getChannel().getId(); - if (metadataCache.containsKey(channelId)) { - container.setMetadataMap(metadataCache.get(channelId)); - } else { - final MetadataMap metadataMap = store.get(channelId); + MetadataMap metadataMap = metadataCache.get(channelId); + if (metadataMap == null) { + metadataMap = metadataStore.get(channelId); if (metadataMap != null) { metadataCache.put(channelId, metadataMap); - container.setMetadataMap(metadataMap); } } + container.setMetadataMap(metadataMap); + + // Add sender user information to the conversation's last message + final MessageContainer lastMessageContainer = conversation.getLastMessageContainer(); + final String senderId = lastMessageContainer.getMessage().getSenderId(); + + User user = userCache.get(senderId); + if (user == null) { + user = usersStore.get(senderId); + if (user != null) { + userCache.put(senderId, user); + } + } + lastMessageContainer.setSender(Sender.builder().id(senderId) + .name(Optional.ofNullable(user).map(User::getName).orElse(null)) + .name(Optional.ofNullable(user).map(User::getAvatarUrl).orElse(null)) + .build()); } return conversations; @@ -259,7 +293,35 @@ public List getMessages(String conversationId) { final ReadOnlyKeyValueStore store = getMessagesStore(); final Messages messagesTreeSet = store.get(conversationId); - return messagesTreeSet == null ? null : new ArrayList<>(messagesTreeSet); + if (messagesTreeSet == null) { + return null; + } + // Enrich messages with user information + List senderIds = messagesTreeSet.stream().map((container) -> container.getMessage().getSenderId()).collect(Collectors.toList()); + Map users = collectUsers(senderIds); + return messagesTreeSet.stream().peek((container) -> { + final User user = users.get(container.getMessage().getSenderId()); + + container.setSender(Sender.builder().id(container.getMessage().getSenderId()) + .name(Optional.ofNullable(user).map(User::getName).orElse(null)) + .name(Optional.ofNullable(user).map(User::getAvatarUrl).orElse(null)) + .build()); + }).collect(Collectors.toList()); + } + + private Map collectUsers(List userIds) { + final Map users = new HashMap<>(); + + for (String userId : userIds) { + if (!users.containsKey(userId)) { + users.put(userId, getUser(userId)); + } + } + return users; + } + + public User getUser(String userId) { + return getUsersStore().get(userId); } @Override @@ -282,6 +344,7 @@ public Health health() { getMessagesStore(); getMessagesByIdStore(); getMetadataStore(); + getUsersStore(); return Health.status(Status.UP).build(); } diff --git a/backend/api/communication/src/test/java/co/airy/core/api/communication/ConversationsInfoTest.java b/backend/api/communication/src/test/java/co/airy/core/api/communication/ConversationsInfoTest.java index d0e168dce2..f37cffc8ed 100644 --- a/backend/api/communication/src/test/java/co/airy/core/api/communication/ConversationsInfoTest.java +++ b/backend/api/communication/src/test/java/co/airy/core/api/communication/ConversationsInfoTest.java @@ -3,6 +3,7 @@ import co.airy.avro.communication.Channel; import co.airy.avro.communication.ChannelConnectionState; import co.airy.avro.communication.Metadata; +import co.airy.avro.communication.User; import co.airy.core.api.communication.util.TestConversation; import co.airy.kafka.test.KafkaTestHelper; import co.airy.kafka.test.junit.SharedKafkaTestResource; @@ -27,6 +28,7 @@ import static co.airy.core.api.communication.util.Topics.applicationCommunicationChannels; import static co.airy.core.api.communication.util.Topics.applicationCommunicationMetadata; +import static co.airy.core.api.communication.util.Topics.applicationCommunicationUsers; import static co.airy.core.api.communication.util.Topics.getTopics; import static co.airy.model.metadata.MetadataRepository.getId; import static co.airy.model.metadata.MetadataRepository.newChannelMetadata; @@ -73,20 +75,31 @@ void canFetchConversationsInfo() throws Exception { .setSource("facebook") .setSourceChannelId("ps-id") .build(); + final String userId = "sender-user-id"; + final String userName = "Barbara Liskov"; + final User user = User.newBuilder() + .setId(userId) + .setName(userName) + .setFirstSeenAt(0) + .setLastSeenAt(0) + .build(); final Metadata metadata = newChannelMetadata(channel.getId(), MetadataKeys.ChannelKeys.NAME, channelName); kafkaTestHelper.produceRecords(List.of( + new ProducerRecord<>(applicationCommunicationUsers.name(), user.getId(), user), new ProducerRecord<>(applicationCommunicationMetadata.name(), getId(metadata).toString(), metadata), new ProducerRecord<>(applicationCommunicationChannels.name(), channel.getId(), channel) )); final String conversationId = UUID.randomUUID().toString(); - kafkaTestHelper.produceRecords(TestConversation.generateRecords(conversationId, channel, 1)); + kafkaTestHelper.produceRecords(TestConversation.generateRecords(conversationId, channel, 1, userId)); retryOnException( () -> webTestHelper.post("/conversations.info", - "{\"conversation_id\":\"" + conversationId + "\"}") + "{\"conversation_id\":\"" + conversationId + "\"}") .andExpect(status().isOk()) .andExpect(jsonPath("$.id", is(conversationId))) + .andExpect(jsonPath("$.last_message.sender.id", is(userId))) + .andExpect(jsonPath("$.last_message.sender.name", is(userName))) .andExpect(jsonPath("$.channel.metadata.name", is(channelName))), "Cannot find conversation" ); diff --git a/backend/api/communication/src/test/java/co/airy/core/api/communication/util/TestConversation.java b/backend/api/communication/src/test/java/co/airy/core/api/communication/util/TestConversation.java index 7d5950f39e..51ee44f681 100644 --- a/backend/api/communication/src/test/java/co/airy/core/api/communication/util/TestConversation.java +++ b/backend/api/communication/src/test/java/co/airy/core/api/communication/util/TestConversation.java @@ -54,11 +54,19 @@ public static TestConversation from(String conversationId, Channel channel, Map< } public static List> generateRecords(String conversationId, Channel channel, int messageCount) { - return TestConversation.from(conversationId, channel, messageCount).generateRecords(messageCount); + return TestConversation.generateRecords(conversationId, channel, messageCount, "airy-core-anonymous"); + } + + public static List> generateRecords(String conversationId, Channel channel, int messageCount, String senderId) { + return TestConversation.from(conversationId, channel, messageCount).generateRecords(messageCount, senderId); } private List> generateRecords(int messageCount) { - final List> messages = getMessages(messageCount); + return generateRecords(messageCount, "airy-core-anonymous"); + } + + private List> generateRecords(int messageCount, String senderId) { + final List> messages = getMessages(messageCount, senderId); this.lastMessageSentAt = ((Message) messages.get(messages.size() - 1).value()).getSentAt(); List> records = new ArrayList<>(messages); @@ -72,7 +80,7 @@ private List> generateRecords(int mes return records; } - private List> getMessages(int messageCount) { + private List> getMessages(int messageCount, String senderId) { List> records = new ArrayList<>(); Random random = new Random(); Instant startDate = Instant.now().minus(Duration.ofDays(random.nextInt(365))); @@ -81,7 +89,7 @@ private List> getMessages(int message records.add(new ProducerRecord<>(applicationCommunicationMessages, messageId, Message.newBuilder() .setId(messageId) .setSentAt(startDate.minus(Duration.ofDays(messageCount - index)).toEpochMilli()) - .setSenderId("source-conversation-id") + .setSenderId(senderId) .setDeliveryState(DeliveryState.DELIVERED) .setSource("facebook") .setConversationId(conversationId) diff --git a/backend/api/communication/src/test/java/co/airy/core/api/communication/util/Topics.java b/backend/api/communication/src/test/java/co/airy/core/api/communication/util/Topics.java index c849fc7591..c0749cf363 100644 --- a/backend/api/communication/src/test/java/co/airy/core/api/communication/util/Topics.java +++ b/backend/api/communication/src/test/java/co/airy/core/api/communication/util/Topics.java @@ -5,14 +5,16 @@ import co.airy.kafka.schema.application.ApplicationCommunicationMessages; import co.airy.kafka.schema.application.ApplicationCommunicationMetadata; import co.airy.kafka.schema.application.ApplicationCommunicationReadReceipts; +import co.airy.kafka.schema.application.ApplicationCommunicationUsers; public class Topics { public static final ApplicationCommunicationMessages applicationCommunicationMessages = new ApplicationCommunicationMessages(); public static final ApplicationCommunicationChannels applicationCommunicationChannels = new ApplicationCommunicationChannels(); public static final ApplicationCommunicationMetadata applicationCommunicationMetadata = new ApplicationCommunicationMetadata(); public static final ApplicationCommunicationReadReceipts applicationCommunicationReadReceipts = new ApplicationCommunicationReadReceipts(); + public static final ApplicationCommunicationUsers applicationCommunicationUsers = new ApplicationCommunicationUsers(); public static Topic[] getTopics() { - return new Topic[]{applicationCommunicationMessages, applicationCommunicationChannels, applicationCommunicationMetadata, applicationCommunicationReadReceipts}; + return new Topic[]{applicationCommunicationMessages, applicationCommunicationChannels, applicationCommunicationMetadata, applicationCommunicationReadReceipts, applicationCommunicationUsers}; } } diff --git a/backend/api/communication/src/test/resources/test.properties b/backend/api/communication/src/test/resources/test.properties index 1d10a69841..dc170ad9c2 100644 --- a/backend/api/communication/src/test/resources/test.properties +++ b/backend/api/communication/src/test/resources/test.properties @@ -1,2 +1,3 @@ kafka.cleanup=true kafka.commit-interval-ms=100 +logs.enabled=false diff --git a/backend/api/contacts/src/main/java/co/airy/core/contacts/ContactsController.java b/backend/api/contacts/src/main/java/co/airy/core/contacts/ContactsController.java index f2841a1dda..ac8b1f4d1f 100644 --- a/backend/api/contacts/src/main/java/co/airy/core/contacts/ContactsController.java +++ b/backend/api/contacts/src/main/java/co/airy/core/contacts/ContactsController.java @@ -1,11 +1,15 @@ package co.airy.core.contacts; +import co.airy.avro.communication.Metadata; import co.airy.core.contacts.dto.Contact; import co.airy.core.contacts.payload.ContactInfoRequestPayload; import co.airy.core.contacts.payload.ContactResponsePayload; +import co.airy.core.contacts.payload.ContactWithMergeHistoryResponsePayload; import co.airy.core.contacts.payload.CreateContactPayload; +import co.airy.core.contacts.payload.DeleteContactPayload; import co.airy.core.contacts.payload.ListContactsRequestPayload; import co.airy.core.contacts.payload.ListContactsResponsePayload; +import co.airy.core.contacts.payload.MergeContactsRequestPayload; import co.airy.core.contacts.payload.PaginationData; import co.airy.core.contacts.payload.UpdateContactPayload; import co.airy.pagination.Page; @@ -19,11 +23,12 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; -import javax.validation.Valid; import java.time.Instant; +import java.util.ArrayList; import java.util.List; import java.util.Optional; import java.util.UUID; +import javax.validation.Valid; import static java.util.stream.Collectors.toList; @@ -63,9 +68,37 @@ public ResponseEntity createContact(@RequestBody @Valid CreateContactPayload } @PostMapping("/contacts.import") - public ResponseEntity importContacts() { - // TODO import an array of contacts - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + public ResponseEntity importContacts(@RequestBody @Valid List payload) { + List contactsMetadata = new ArrayList(); + List createdContacts = new ArrayList(); + + payload.stream().forEach((p) -> { + final Contact newContact = Contact.builder() + .id(UUID.randomUUID().toString()) + .createdAt(Instant.now().toEpochMilli()) + .metadata(p.getMetadata()) + .address(p.getAddress()) + .conversations(p.getConversations()) + .displayName(p.getDisplayName()) + .avatarUrl(p.getAvatarUrl()) + .gender(p.getGender()) + .locale(p.getLocale()) + .organizationName(p.getOrganizationName()) + .timezone(p.getTimezone()) + .title(p.getTitle()) + .via(p.getVia()) + .build(); + contactsMetadata.addAll(newContact.toMetadata()); + createdContacts.add(ContactResponsePayload.fromContact(newContact)); + }); + + try { + stores.storeContact(contactsMetadata); + } catch (Exception e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(e.getMessage()); + } + + return ResponseEntity.status(HttpStatus.CREATED).body(createdContacts); } @PostMapping("/contacts.list") @@ -141,22 +174,41 @@ public ResponseEntity updateContact(@RequestBody @Valid UpdateContactPayload return ResponseEntity.status(HttpStatus.ACCEPTED).build(); } - @PostMapping("/contacts.refetch") - public ResponseEntity refetchContact() { - // TODO trigger sources to refetch contact information - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); - } - @PostMapping("/contacts.merge") - public ResponseEntity mergeContact() { - // TODO merge contact A into contact B. R - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + public ResponseEntity mergeContact(@RequestBody @Valid MergeContactsRequestPayload payload) { + final Contact sourceContact = stores.getContact(payload.getSourceId().toString()); + final Contact destinationContact = stores.getContact(payload.getDestinationId().toString()); + + if (sourceContact == null || destinationContact == null) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new RequestErrorResponsePayload("Contact not found")); + } + + final Contact mergedContact = destinationContact.merge(sourceContact); + try { + stores.storeContact(mergedContact.toMetadata()); + stores.storeContact(sourceContact.deleteAllMetadata()); + } catch (Exception e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(e.getMessage()); + } + + return ResponseEntity.status(HttpStatus.OK).body(ContactWithMergeHistoryResponsePayload.fromContact(mergedContact)); } @PostMapping("/contacts.delete") - public ResponseEntity deleteContact() { - // TODO delete contact - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + public ResponseEntity deleteContact(@RequestBody @Valid DeleteContactPayload payload) { + final String id = payload.getId().toString(); + final Contact contact = stores.getContact(id); + if (contact == null) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new RequestErrorResponsePayload("Contact not found")); + } + + try { + stores.storeContact(contact.deleteAllMetadata()); + } catch (Exception e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(e.getMessage()); + } + + return ResponseEntity.status(HttpStatus.ACCEPTED).build(); } @Override diff --git a/backend/api/contacts/src/main/java/co/airy/core/contacts/Stores.java b/backend/api/contacts/src/main/java/co/airy/core/contacts/Stores.java index c3cd613244..08d3f856f5 100644 --- a/backend/api/contacts/src/main/java/co/airy/core/contacts/Stores.java +++ b/backend/api/contacts/src/main/java/co/airy/core/contacts/Stores.java @@ -16,6 +16,7 @@ import org.apache.avro.specific.SpecificRecordBase; import org.apache.kafka.clients.producer.KafkaProducer; import org.apache.kafka.clients.producer.ProducerRecord; +import org.apache.kafka.streams.KafkaStreams; import org.apache.kafka.streams.KeyValue; import org.apache.kafka.streams.StreamsBuilder; import org.apache.kafka.streams.kstream.KTable; @@ -64,7 +65,8 @@ public void onApplicationEvent(ApplicationReadyEvent applicationReadyEvent) { final KTable conversationToContactTable = builder.table(applicationCommunicationContacts) .groupBy((metadataId, metadata) -> KeyValue.pair(getSubject(metadata).getIdentifier(), metadata)) // Create Contact table - .aggregate(MetadataMap::new, MetadataMap::adder, MetadataMap::subtractor, Materialized.as(contactsStore)) + .aggregate(MetadataMap::new, MetadataMap::adder, MetadataMap::subtractor) + .filter((metadataId, metadataMap) -> metadataMap.size() != 0, Materialized.as(contactsStore)) .toStream() // Create map of: conversation id -> contact metadatamap .flatMap((contactId, metadataMap) -> { @@ -88,6 +90,7 @@ public void onApplicationEvent(ApplicationReadyEvent applicationReadyEvent) { // 1. Auto create contacts if they don't exist // 2. Populate contact metadata with conversation metadata (if missing) builder.stream(new ApplicationCommunicationMessages().name()) + .filter((messageId, message) -> message != null) .groupBy((messageId, message) -> message.getConversationId()) .aggregate(Conversation::new, (conversationId, message, aggregate) -> { @@ -210,4 +213,9 @@ public void destroy() { streams.close(); } } + + // visible for testing + KafkaStreams.State getStreamState() { + return streams.state(); + } } diff --git a/backend/api/contacts/src/main/java/co/airy/core/contacts/dto/Contact.java b/backend/api/contacts/src/main/java/co/airy/core/contacts/dto/Contact.java index b77c769b5b..f9e07c2f91 100644 --- a/backend/api/contacts/src/main/java/co/airy/core/contacts/dto/Contact.java +++ b/backend/api/contacts/src/main/java/co/airy/core/contacts/dto/Contact.java @@ -1,21 +1,31 @@ package co.airy.core.contacts.dto; import co.airy.avro.communication.Metadata; +import co.airy.log.AiryLoggerFactory; import co.airy.model.metadata.dto.MetadataMap; + import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; +import org.slf4j.Logger; import java.io.Serializable; import java.util.ArrayList; import java.util.Collection; +import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.UUID; +import java.util.stream.Collectors; +import java.util.stream.Stream; import static co.airy.core.contacts.MetadataRepository.newContactMetadata; import static co.airy.core.contacts.dto.Contact.MetadataKeys.ADDRESS; @@ -26,6 +36,7 @@ import static co.airy.core.contacts.dto.Contact.MetadataKeys.GENDER; import static co.airy.core.contacts.dto.Contact.MetadataKeys.LOCALE; import static co.airy.core.contacts.dto.Contact.MetadataKeys.ORGANIZATION_NAME; +import static co.airy.core.contacts.dto.Contact.MetadataKeys.MERGE_HISTORY; import static co.airy.core.contacts.dto.Contact.MetadataKeys.TIMEZONE; import static co.airy.core.contacts.dto.Contact.MetadataKeys.TITLE; import static co.airy.core.contacts.dto.Contact.MetadataKeys.VIA; @@ -37,6 +48,8 @@ @AllArgsConstructor @NoArgsConstructor public class Contact implements Serializable { + private static final Logger log = AiryLoggerFactory.getLogger(Contact.class); + private String id; private String displayName; private String avatarUrl; @@ -49,6 +62,7 @@ public class Contact implements Serializable { private Address address; private Map conversations; private JsonNode metadata; + private List mergeHistory; @Data @Builder(toBuilder = true) @@ -80,13 +94,17 @@ public static Address fromMetadataMap(MetadataMap map) { @JsonIgnore public Address merge(Address address) { + if (address == null) { + return this.toBuilder().build(); + } + return this.toBuilder() - .addressLine1(Optional.ofNullable(address.getAddressLine1()).orElse(address.getAddressLine1())) - .addressLine2(Optional.ofNullable(address.getAddressLine2()).orElse(address.getAddressLine2())) - .city(Optional.ofNullable(address.getCity()).orElse(address.getCity())) - .country(Optional.ofNullable(address.getCountry()).orElse(address.getCountry())) - .postalCode(Optional.ofNullable(address.getPostalCode()).orElse(address.getPostalCode())) - .organizationName(Optional.ofNullable(address.getOrganizationName()).orElse(address.getOrganizationName())) + .addressLine1(Optional.ofNullable(this.getAddressLine1()).orElse(address.getAddressLine1())) + .addressLine2(Optional.ofNullable(this.getAddressLine2()).orElse(address.getAddressLine2())) + .city(Optional.ofNullable(this.getCity()).orElse(address.getCity())) + .country(Optional.ofNullable(this.getCountry()).orElse(address.getCountry())) + .postalCode(Optional.ofNullable(this.getPostalCode()).orElse(address.getPostalCode())) + .organizationName(Optional.ofNullable(this.getOrganizationName()).orElse(address.getOrganizationName())) .build(); } @@ -134,6 +152,7 @@ public static class MetadataKeys { public static String VIA = "via"; public static String CONVERSATIONS = "conversations"; public static String METADATA = "metadata"; + public static String MERGE_HISTORY = "mergeHistory"; public static String ADDRESS = "address"; @@ -148,6 +167,49 @@ public static class Address { } } + @JsonIgnore + public List deleteAllMetadata() { + // Using kafka tombstones to delete all contact's metadata + return toMetadata().stream() + .map((m) -> { + m.setValue(""); + return m; + }) + .collect(Collectors.toList()); + } + + @JsonIgnore + public Contact merge(Contact c) { + // Concatenate merge history of both contacts + final List history = Stream.concat( + Optional.ofNullable(this.getMergeHistory()).orElseGet(Collections::emptyList).stream(), + Optional.ofNullable(c.getMergeHistory()).orElseGet(Collections::emptyList).stream()) + .collect(Collectors.toList()); + + // Add source contact in this case c (without its history) to the history list. + history.add(c.toBuilder().mergeHistory(null).build()); + + return this.toBuilder() + .metadata(Optional.ofNullable(this.getMetadata()).orElse(c.getMetadata())) + .address(Optional.ofNullable(this.getAddress()).orElseGet(Address::new).merge(c.getAddress())) + // Aggregate conversations + .conversations(Stream.concat( + Optional.ofNullable(this.getConversations()).orElseGet(Collections::emptyMap).entrySet().stream(), + Optional.ofNullable(c.getConversations()).orElseGet(Collections::emptyMap).entrySet().stream()) + .collect(toMap(Map.Entry::getKey, Map.Entry::getValue))) + .displayName(Optional.ofNullable(this.getDisplayName()).orElse(c.getDisplayName())) + .avatarUrl(Optional.ofNullable(this.getAvatarUrl()).orElse(c.getAvatarUrl())) + .gender(Optional.ofNullable(this.getGender()).orElse(c.getGender())) + .locale(Optional.ofNullable(this.getLocale()).orElse(c.getLocale())) + .organizationName(Optional.ofNullable(this.getOrganizationName()).orElse(c.getOrganizationName())) + .timezone(Optional.ofNullable(this.getTimezone()).orElse(c.getTimezone())) + .title(Optional.ofNullable(this.getTitle()).orElse(c.getTitle())) + .via(Optional.ofNullable(this.getVia()).orElse(c.getVia())) + // Aggregate mergehistory + .mergeHistory(history) + .build(); + } + @JsonIgnore public List toMetadata() { @@ -185,6 +247,21 @@ public List toMetadata() { if (address != null) { metadata.addAll(address.toMetadata(id)); } + if (mergeHistory != null && !mergeHistory.isEmpty()) { + try { + final ObjectMapper mapper = new ObjectMapper(); + mapper.setSerializationInclusion(Include.NON_NULL); + final String mergeHistoryBlob = mapper.writeValueAsString(mergeHistory); + + metadata.add(newContactMetadata(id, MERGE_HISTORY, mergeHistoryBlob)); + } catch (JsonProcessingException e) { + log.error(String.format( + "unable to marshal mergeHistory << %s >> for contact id %s", + mergeHistory.toString(), + this.getId())); + + } + } return metadata; } @@ -211,6 +288,23 @@ public static Contact fromMetadataMap(MetadataMap map) { final boolean hasAddress = values.stream().anyMatch(metadata -> metadata.getKey().startsWith(ADDRESS)); + final String mergeHistoryBlob = map.getMetadataValue(MERGE_HISTORY); + List mergeHistory = null; + if (mergeHistoryBlob != null && mergeHistoryBlob != "") { + try { + final ObjectMapper mapper = new ObjectMapper(); + + mergeHistory = mapper.readValue( + mergeHistoryBlob, + new TypeReference>() {}); + } catch (JsonProcessingException e) { + log.error(String.format( + "unable to unmarshal mergeHistory << %s >> for contact id %s", + mergeHistoryBlob, + id)); + } + } + return Contact.builder() .id(id) .updatedAt(map.getUpdatedAt()) @@ -225,6 +319,7 @@ public static Contact fromMetadataMap(MetadataMap map) { .via(via.size() > 0 ? via : null) .address(hasAddress ? Address.fromMetadataMap(map) : null) .conversations(conversations.size() > 0 ? conversations : null) + .mergeHistory(mergeHistory) .build(); } diff --git a/backend/api/contacts/src/main/java/co/airy/core/contacts/payload/ContactWithMergeHistoryResponsePayload.java b/backend/api/contacts/src/main/java/co/airy/core/contacts/payload/ContactWithMergeHistoryResponsePayload.java new file mode 100644 index 0000000000..d5d575195e --- /dev/null +++ b/backend/api/contacts/src/main/java/co/airy/core/contacts/payload/ContactWithMergeHistoryResponsePayload.java @@ -0,0 +1,62 @@ +package co.airy.core.contacts.payload; + +import co.airy.core.contacts.dto.Contact; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.stream.Collectors; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ContactWithMergeHistoryResponsePayload { + private String id; + private String displayName; + private String avatarUrl; + private String title; + private String gender; + private Integer timezone; + private String locale; + private String organizationName; + private Map via; + private Contact.Address address; + private Map conversations; + private JsonNode metadata; + private Long createdAt; + private Long updatedAt; + private List mergeHistory; + + public static ContactWithMergeHistoryResponsePayload fromContact(Contact contact) { + return ContactWithMergeHistoryResponsePayload.builder() + .id(contact.getId()) + .displayName(contact.getDisplayName()) + .avatarUrl(contact.getAvatarUrl()) + .title(contact.getTitle()) + .gender(contact.getGender()) + .timezone(contact.getTimezone()) + .locale(contact.getLocale()) + .organizationName(contact.getOrganizationName()) + .via(contact.getVia()) + .address(contact.getAddress()) + .conversations(contact.getConversations()) + .metadata(contact.getMetadata()) + .createdAt(contact.getCreatedAt()) + .updatedAt(contact.getUpdatedAt()) + .mergeHistory(Optional.ofNullable(contact.getMergeHistory()) + .orElseGet(Collections::emptyList) + .stream() + .map((mc) -> ContactResponsePayload.fromContact(mc)) + .collect(Collectors.toList())) + .build(); + } +} diff --git a/backend/api/contacts/src/main/java/co/airy/core/contacts/payload/CreateContactPayload.java b/backend/api/contacts/src/main/java/co/airy/core/contacts/payload/CreateContactPayload.java index daca21a0c2..86305f1c30 100644 --- a/backend/api/contacts/src/main/java/co/airy/core/contacts/payload/CreateContactPayload.java +++ b/backend/api/contacts/src/main/java/co/airy/core/contacts/payload/CreateContactPayload.java @@ -2,6 +2,8 @@ import co.airy.core.contacts.dto.Contact; import com.fasterxml.jackson.databind.JsonNode; +import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; @@ -10,7 +12,9 @@ import java.util.UUID; @Data +@Builder @NoArgsConstructor +@AllArgsConstructor public class CreateContactPayload { private String displayName; private String avatarUrl; diff --git a/backend/api/contacts/src/main/java/co/airy/core/contacts/payload/DeleteContactPayload.java b/backend/api/contacts/src/main/java/co/airy/core/contacts/payload/DeleteContactPayload.java new file mode 100644 index 0000000000..3671632b14 --- /dev/null +++ b/backend/api/contacts/src/main/java/co/airy/core/contacts/payload/DeleteContactPayload.java @@ -0,0 +1,20 @@ +package co.airy.core.contacts.payload; + + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import javax.validation.constraints.NotNull; +import java.util.UUID; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class DeleteContactPayload { + @NotNull + private UUID id; +} + diff --git a/backend/api/contacts/src/main/java/co/airy/core/contacts/payload/MergeContactsRequestPayload.java b/backend/api/contacts/src/main/java/co/airy/core/contacts/payload/MergeContactsRequestPayload.java new file mode 100644 index 0000000000..760117630a --- /dev/null +++ b/backend/api/contacts/src/main/java/co/airy/core/contacts/payload/MergeContactsRequestPayload.java @@ -0,0 +1,22 @@ +package co.airy.core.contacts.payload; + + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import javax.validation.constraints.NotNull; +import java.util.UUID; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class MergeContactsRequestPayload { + @NotNull + private UUID sourceId; + + @NotNull + private UUID destinationId; +} diff --git a/backend/api/contacts/src/test/java/co/airy/core/contacts/DeleteContactTest.java b/backend/api/contacts/src/test/java/co/airy/core/contacts/DeleteContactTest.java new file mode 100644 index 0000000000..71fa0db0e9 --- /dev/null +++ b/backend/api/contacts/src/test/java/co/airy/core/contacts/DeleteContactTest.java @@ -0,0 +1,92 @@ +package co.airy.core.contacts; + +import co.airy.spring.core.AirySpringBootApplication; +import co.airy.spring.test.WebTestHelper; +import co.airy.core.contacts.dto.Contact; +import co.airy.core.contacts.payload.DeleteContactPayload; +import co.airy.core.contacts.util.TestContact; +import co.airy.core.contacts.util.Topics; +import co.airy.kafka.test.KafkaTestHelper; +import co.airy.kafka.test.junit.SharedKafkaTestResource; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; + +import static org.hamcrest.Matchers.hasSize; +import static co.airy.test.Timing.retryOnException; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.UUID; + +import com.fasterxml.jackson.databind.ObjectMapper; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, classes = AirySpringBootApplication.class) +@TestPropertySource(value = "classpath:test.properties") +@AutoConfigureMockMvc +@ExtendWith(SpringExtension.class) +public class DeleteContactTest { + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private WebTestHelper webTestHelper; + + @Autowired + private TestContact testContact; + + @RegisterExtension + public static final SharedKafkaTestResource sharedKafkaTestResource = new SharedKafkaTestResource(); + + private static KafkaTestHelper kafkaTestHelper; + + @BeforeAll + static void beforeAll() throws Exception { + kafkaTestHelper = new KafkaTestHelper(sharedKafkaTestResource, Topics.getTopics()); + kafkaTestHelper.beforeAll(); + } + + @AfterAll + static void afterAll() throws Exception { + kafkaTestHelper.afterAll(); + } + + @BeforeEach + void beforeEach() throws Exception { + webTestHelper.waitUntilHealthy(); + } + + @Test + void canDeleteContact() throws Exception { + final String contactId = testContact.createContact(Contact.builder().displayName("A contact").build()); + + // Check that the contact is committed to the stream + retryOnException(() -> { + webTestHelper.post("/contacts.list") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data", hasSize(1))); + }, "Contact was not created"); + + webTestHelper.post( + "/contacts.delete", + objectMapper.writeValueAsString(DeleteContactPayload.builder().id(UUID.fromString(contactId)).build())) + .andExpect(status().isAccepted()); + + // Check that the contact was deleted + retryOnException(() -> { + webTestHelper.post("/contacts.list") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data", hasSize(0))); + }, "Contact was not deleted"); + } +} diff --git a/backend/api/contacts/src/test/java/co/airy/core/contacts/ImportContactsTest.java b/backend/api/contacts/src/test/java/co/airy/core/contacts/ImportContactsTest.java new file mode 100644 index 0000000000..2c5c44383e --- /dev/null +++ b/backend/api/contacts/src/test/java/co/airy/core/contacts/ImportContactsTest.java @@ -0,0 +1,148 @@ +package co.airy.core.contacts; + +import co.airy.core.contacts.payload.ContactResponsePayload; +import co.airy.core.contacts.payload.CreateContactPayload; +import co.airy.core.contacts.payload.ListContactsResponsePayload; +import co.airy.core.contacts.util.Topics; +import co.airy.kafka.test.KafkaTestHelper; +import co.airy.kafka.test.junit.SharedKafkaTestResource; +import co.airy.spring.core.AirySpringBootApplication; +import co.airy.spring.test.WebTestHelper; +import co.airy.test.RunnableTest; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import java.util.List; +import java.util.Arrays; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +import static co.airy.test.Timing.retryOnException; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.core.IsEqual.equalTo; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, classes = AirySpringBootApplication.class) +@TestPropertySource(value = "classpath:test.properties") +@AutoConfigureMockMvc +@ExtendWith(SpringExtension.class) +public class ImportContactsTest { + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private WebTestHelper webTestHelper; + + @RegisterExtension + public static final SharedKafkaTestResource sharedKafkaTestResource = new SharedKafkaTestResource(); + + private static KafkaTestHelper kafkaTestHelper; + + @BeforeAll + static void beforeAll() throws Exception { + kafkaTestHelper = new KafkaTestHelper(sharedKafkaTestResource, Topics.getTopics()); + kafkaTestHelper.beforeAll(); + } + + @AfterAll + static void afterAll() throws Exception { + kafkaTestHelper.afterAll(); + } + + @BeforeEach + void beforeEach() throws Exception { + webTestHelper.waitUntilHealthy(); + } + + @NoArgsConstructor + private class ContactsList implements RunnableTest { + + @Getter + private String listContent; + + public void test() throws Exception { + listContent = webTestHelper.post("/contacts.list") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data", hasSize(4))) + .andReturn().getResponse().getContentAsString(); + } + + } + + @Test + void canImportContacts() throws Exception { + final List payload = mockContactsListPayload(); + + final String importContent = webTestHelper.post( + "/contacts.import", + objectMapper.writeValueAsString(payload)) + .andExpect(status().isCreated()).andReturn().getResponse().getContentAsString(); + + List contactsResp = objectMapper.readValue( + importContent, + new TypeReference>() {}); + + ContactsList cl = new ContactsList(); + retryOnException(cl, "Not able to get contacts list"); + + final ListContactsResponsePayload contactsList = objectMapper.readValue(cl.getListContent(), ListContactsResponsePayload.class); + final Map contactsMap = contactsList.getData().stream() + .collect(Collectors.toMap(ContactResponsePayload::getId, Function.identity())); + + contactsResp.stream().forEach((contactResponse) -> { + final ContactResponsePayload c = contactsMap.get(contactResponse.getId()); + assertNotNull(c, String.format("Contact with id %s not found", contactResponse.getId())); + + assertThat(contactResponse.getDisplayName(), equalTo(c.getDisplayName())); + assertThat(contactResponse.getAvatarUrl(), equalTo(c.getAvatarUrl())); + assertThat(contactResponse.getTitle(), equalTo(c.getTitle())); + assertThat(contactResponse.getGender(), equalTo(c.getGender())); + }); + } + + private List mockContactsListPayload() { + return Arrays.asList( + CreateContactPayload.builder() + .displayName("some name 1") + .avatarUrl("avatar-url-1") + .title("a-title-1") + .gender("M") + .build(), + CreateContactPayload.builder() + .displayName("some name 2") + .avatarUrl("avatar-url-2") + .title("a-title-2") + .gender("F") + .build(), + CreateContactPayload.builder() + .displayName("some name 3") + .avatarUrl("avatar-url-3") + .title("a-title-3") + .gender("D") + .build(), + CreateContactPayload.builder() + .displayName("some name 4") + .avatarUrl("avatar-url-4") + .title("a-title-4") + .gender("N") + .build()); + } +} diff --git a/backend/api/contacts/src/test/java/co/airy/core/contacts/MergeContactsTest.java b/backend/api/contacts/src/test/java/co/airy/core/contacts/MergeContactsTest.java new file mode 100644 index 0000000000..8e0d983504 --- /dev/null +++ b/backend/api/contacts/src/test/java/co/airy/core/contacts/MergeContactsTest.java @@ -0,0 +1,140 @@ +package co.airy.core.contacts; + +import co.airy.core.contacts.payload.ContactResponsePayload; +import co.airy.core.contacts.payload.ContactWithMergeHistoryResponsePayload; +import co.airy.core.contacts.payload.CreateContactPayload; +import co.airy.core.contacts.payload.MergeContactsRequestPayload; +import co.airy.core.contacts.util.Topics; +import co.airy.kafka.test.KafkaTestHelper; +import co.airy.kafka.test.junit.SharedKafkaTestResource; +import co.airy.spring.core.AirySpringBootApplication; +import co.airy.spring.test.WebTestHelper; +import co.airy.test.RunnableTest; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import lombok.Getter; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; + +import java.util.List; +import java.util.UUID; +import java.util.Arrays; + +import static co.airy.test.Timing.retryOnException; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.core.IsEqual.equalTo; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, classes = AirySpringBootApplication.class) +@TestPropertySource(value = "classpath:test.properties") +@AutoConfigureMockMvc +@ExtendWith(SpringExtension.class) +public class MergeContactsTest { + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private WebTestHelper webTestHelper; + + @RegisterExtension + public static final SharedKafkaTestResource sharedKafkaTestResource = new SharedKafkaTestResource(); + + private static KafkaTestHelper kafkaTestHelper; + + @BeforeAll + static void beforeAll() throws Exception { + kafkaTestHelper = new KafkaTestHelper(sharedKafkaTestResource, Topics.getTopics()); + kafkaTestHelper.beforeAll(); + } + + @AfterAll + static void afterAll() throws Exception { + kafkaTestHelper.afterAll(); + } + + @BeforeEach + void beforeEach() throws Exception { + webTestHelper.waitUntilHealthy(); + } + + @RequiredArgsConstructor + private class MergeContacts implements RunnableTest { + + @Getter + private String mergeContent; + + @NonNull + private List contactsResp; + + public void test() throws Exception { + mergeContent = webTestHelper.post( + "/contacts.merge", + objectMapper.writeValueAsString( + MergeContactsRequestPayload.builder() + .sourceId(UUID.fromString(contactsResp.get(0).getId())) + .destinationId(UUID.fromString(contactsResp.get(1).getId())) + .build())) + .andExpect(status().isOk()).andReturn().getResponse().getContentAsString(); + } + + } + + @Test + void canMergeContacts() throws Exception { + final List payload = mockContactsListPayload(); + + final String importContent = webTestHelper.post( + "/contacts.import", + objectMapper.writeValueAsString(payload)) + .andExpect(status().isCreated()).andReturn().getResponse().getContentAsString(); + + List contactsResp = objectMapper.readValue( + importContent, + new TypeReference>() {}); + assertEquals(contactsResp.size(), 2); + + MergeContacts mc = new MergeContacts(contactsResp); + retryOnException(mc, "Not able to merge contacts"); + + final ContactWithMergeHistoryResponsePayload mergedContact = objectMapper.readValue(mc.getMergeContent(), + ContactWithMergeHistoryResponsePayload.class); + final CreateContactPayload sourceContact = payload.get(0); + final CreateContactPayload destinationContact = payload.get(1); + + assertThat(mergedContact.getDisplayName(), equalTo(destinationContact.getDisplayName())); + assertThat(mergedContact.getTitle(), equalTo(sourceContact.getTitle())); + assertNotNull(mergedContact.getMergeHistory()); + assertEquals(mergedContact.getMergeHistory().size(), 1); + assertThat(mergedContact.getMergeHistory().get(0).getAvatarUrl(), equalTo(sourceContact.getAvatarUrl())); + + } + + private List mockContactsListPayload() { + return Arrays.asList( + CreateContactPayload.builder() + .displayName("source contact display name") + .avatarUrl("source contact avatar URL") + .title("source contact title") + .gender("source contact gender") + .organizationName("source contact organization name") + .build(), + CreateContactPayload.builder() + .displayName("destination contact display name") + .avatarUrl("destination contact avatar URL") + .build()); + } +} diff --git a/backend/api/contacts/src/test/resources/test.properties b/backend/api/contacts/src/test/resources/test.properties index 1d10a69841..62720ec7f3 100644 --- a/backend/api/contacts/src/test/resources/test.properties +++ b/backend/api/contacts/src/test/resources/test.properties @@ -1,2 +1,5 @@ kafka.cleanup=true -kafka.commit-interval-ms=100 +kafka.commit-interval-ms=0 +kafka.cache.max.bytes=0 +kafka.delete.retention.ms=0 +logs.enabled=false diff --git a/backend/api/websocket/src/test/resources/test.properties b/backend/api/websocket/src/test/resources/test.properties index 1d10a69841..dc170ad9c2 100644 --- a/backend/api/websocket/src/test/resources/test.properties +++ b/backend/api/websocket/src/test/resources/test.properties @@ -1,2 +1,3 @@ kafka.cleanup=true kafka.commit-interval-ms=100 +logs.enabled=false diff --git a/backend/avro/BUILD b/backend/avro/BUILD index aa68df7714..dfc87e2f96 100644 --- a/backend/avro/BUILD +++ b/backend/avro/BUILD @@ -40,9 +40,8 @@ avro_java_library( srcs = ["template.avsc"], ) -avro_java_library( - name = "http-log", - srcs = ["http-log.avsc"], -) +avro_java_library(name = "http-log") + +avro_java_library(name = "user") check_pkg(name = "buildifier") diff --git a/backend/avro/user.avsc b/backend/avro/user.avsc new file mode 100644 index 0000000000..4c43ee6e4e --- /dev/null +++ b/backend/avro/user.avsc @@ -0,0 +1,12 @@ +{ + "namespace": "co.airy.avro.communication", + "name": "User", + "type": "record", + "fields": [ + {"name": "id", "type": "string"}, + {"name": "firstSeenAt", "type": "long", "logicalType": "timestamp-millis"}, + {"name": "lastSeenAt", "type": "long", "logicalType": "timestamp-millis"}, + {"name": "name", "type": ["null", "string"], "default": null}, + {"name": "avatarUrl", "type": ["null", "string"], "default": null} + ] +} diff --git a/backend/media/src/test/resources/test.properties b/backend/media/src/test/resources/test.properties index 1a1f793e18..53586e9673 100644 --- a/backend/media/src/test/resources/test.properties +++ b/backend/media/src/test/resources/test.properties @@ -1,5 +1,6 @@ kafka.cleanup=true kafka.commit-interval-ms=100 +logs.enabled=false s3.key=no s3.secret=no diff --git a/backend/model/message/src/main/java/co/airy/model/message/dto/MessageContainer.java b/backend/model/message/src/main/java/co/airy/model/message/dto/MessageContainer.java index e0b7c167fc..3d39c453a7 100644 --- a/backend/model/message/src/main/java/co/airy/model/message/dto/MessageContainer.java +++ b/backend/model/message/src/main/java/co/airy/model/message/dto/MessageContainer.java @@ -2,6 +2,7 @@ import co.airy.avro.communication.Message; import co.airy.model.metadata.dto.MetadataMap; +import com.fasterxml.jackson.annotation.JsonIgnore; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -18,9 +19,20 @@ public class MessageContainer implements Serializable { private Message message; @Builder.Default private MetadataMap metadataMap = new MetadataMap(); + private Sender sender; + + public MessageContainer(Message message, MetadataMap metadataMap) { + this.message = message; + this.metadataMap = metadataMap; + } public long getUpdatedAt() { return Math.max(Optional.ofNullable(message.getUpdatedAt()).orElse(message.getSentAt()), Optional.ofNullable(metadataMap).map(MetadataMap::getUpdatedAt).orElse(0L)); } + + @JsonIgnore + public Sender getDefaultSender() { + return Sender.builder().id(message.getSenderId()).build(); + } } diff --git a/backend/model/message/src/main/java/co/airy/model/message/dto/MessageResponsePayload.java b/backend/model/message/src/main/java/co/airy/model/message/dto/MessageResponsePayload.java index b8fd0bcb76..166a980dfc 100644 --- a/backend/model/message/src/main/java/co/airy/model/message/dto/MessageResponsePayload.java +++ b/backend/model/message/src/main/java/co/airy/model/message/dto/MessageResponsePayload.java @@ -7,6 +7,8 @@ import lombok.Data; import lombok.NoArgsConstructor; +import java.util.Optional; + import static co.airy.date.format.DateFormat.isoFromMillis; import static co.airy.model.message.MessageRepository.resolveContent; import static co.airy.model.metadata.MetadataObjectMapper.getMetadataPayload; @@ -23,11 +25,15 @@ public class MessageResponsePayload { private boolean isFromContact; private String source; private JsonNode metadata; + private Sender sender; public static MessageResponsePayload fromMessageContainer(MessageContainer messageContainer) { final Message message = messageContainer.getMessage(); + final Sender sender = Optional.ofNullable(messageContainer.getSender()) + .orElse(messageContainer.getDefaultSender()); return MessageResponsePayload.builder() .content(resolveContent(message, messageContainer.getMetadataMap())) + .sender(sender) .isFromContact(message.getIsFromContact()) .deliveryState(message.getDeliveryState().toString().toLowerCase()) .id(message.getId()) @@ -40,6 +46,7 @@ public static MessageResponsePayload fromMessageContainer(MessageContainer messa public static MessageResponsePayload fromMessage(Message message) { return MessageResponsePayload.builder() .content(resolveContent(message)) + .sender(Sender.builder().id(message.getSenderId()).build()) .isFromContact(message.getIsFromContact()) .deliveryState(message.getDeliveryState().toString().toLowerCase()) .id(message.getId()) diff --git a/backend/model/message/src/main/java/co/airy/model/message/dto/Sender.java b/backend/model/message/src/main/java/co/airy/model/message/dto/Sender.java new file mode 100644 index 0000000000..8a8e623f4b --- /dev/null +++ b/backend/model/message/src/main/java/co/airy/model/message/dto/Sender.java @@ -0,0 +1,16 @@ +package co.airy.model.message.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class Sender { + private String id; + private String name; + private String avatarUrl; +} diff --git a/backend/sources/api/src/test/resources/test.properties b/backend/sources/api/src/test/resources/test.properties index 1d10a69841..dc170ad9c2 100644 --- a/backend/sources/api/src/test/resources/test.properties +++ b/backend/sources/api/src/test/resources/test.properties @@ -1,2 +1,3 @@ kafka.cleanup=true kafka.commit-interval-ms=100 +logs.enabled=false diff --git a/backend/sources/chat-plugin/src/test/resources/test.properties b/backend/sources/chat-plugin/src/test/resources/test.properties index 73d040ce86..ec7644a57d 100644 --- a/backend/sources/chat-plugin/src/test/resources/test.properties +++ b/backend/sources/chat-plugin/src/test/resources/test.properties @@ -2,3 +2,4 @@ kafka.cleanup=true kafka.commit-interval-ms=100 chat-plugin.auth.jwt-secret=this-needs-to-be-replaced-in-production-buffer:424242424242424242424242424242 systemToken=system-user-token +logs.enabled=false diff --git a/backend/sources/facebook/connector/src/test/resources/test.properties b/backend/sources/facebook/connector/src/test/resources/test.properties index b61c057467..048aacfe06 100644 --- a/backend/sources/facebook/connector/src/test/resources/test.properties +++ b/backend/sources/facebook/connector/src/test/resources/test.properties @@ -1,6 +1,7 @@ kafka.cleanup=true kafka.commit-interval-ms=100 kafka.suppress-interval-ms=0 +logs.enabled=false facebook.webhook-secret=theansweris42 facebook.app-id=12345 facebook.app-secret=secret diff --git a/backend/sources/facebook/events-router/src/test/resources/test.properties b/backend/sources/facebook/events-router/src/test/resources/test.properties index 0a30b9f6c9..640e67798c 100644 --- a/backend/sources/facebook/events-router/src/test/resources/test.properties +++ b/backend/sources/facebook/events-router/src/test/resources/test.properties @@ -1,3 +1,4 @@ kafka.cleanup=true kafka.commit-interval-ms=100 -facebook.app-id=12345 \ No newline at end of file +facebook.app-id=12345 +logs.enabled=false diff --git a/backend/sources/google/connector/src/test/resources/test.properties b/backend/sources/google/connector/src/test/resources/test.properties index 1f0c7a7858..9cbfba19a7 100644 --- a/backend/sources/google/connector/src/test/resources/test.properties +++ b/backend/sources/google/connector/src/test/resources/test.properties @@ -1,3 +1,4 @@ google.auth.sa={"type":"service_account","project_id":"airy","private_key_id":"no","private_key":"nokey","client_email":"no","client_id":"no","auth_uri":"no","token_uri":"no","no":"no","client_x509_cert_url":"no"} google.partner-key=whatever kafka.commit-interval-ms=100 +logs.enabled=false diff --git a/backend/sources/google/events-router/src/test/resources/test.properties b/backend/sources/google/events-router/src/test/resources/test.properties index 1d10a69841..dc170ad9c2 100644 --- a/backend/sources/google/events-router/src/test/resources/test.properties +++ b/backend/sources/google/events-router/src/test/resources/test.properties @@ -1,2 +1,3 @@ kafka.cleanup=true kafka.commit-interval-ms=100 +logs.enabled=false diff --git a/backend/sources/twilio/connector/src/test/resources/test.properties b/backend/sources/twilio/connector/src/test/resources/test.properties index 457b6c1aed..6f70c77a87 100644 --- a/backend/sources/twilio/connector/src/test/resources/test.properties +++ b/backend/sources/twilio/connector/src/test/resources/test.properties @@ -2,3 +2,4 @@ kafka.cleanup=true kafka.commit-interval-ms=100 twilio.account-sid=42 twilio.auth-token=token +logs.enabled=false diff --git a/backend/sources/twilio/events-router/src/test/resources/test.properties b/backend/sources/twilio/events-router/src/test/resources/test.properties index 4ff2a34427..dc170ad9c2 100644 --- a/backend/sources/twilio/events-router/src/test/resources/test.properties +++ b/backend/sources/twilio/events-router/src/test/resources/test.properties @@ -1,2 +1,3 @@ kafka.cleanup=true -kafka.commit-interval-ms=100 \ No newline at end of file +kafka.commit-interval-ms=100 +logs.enabled=false diff --git a/backend/sources/viber/connector/src/test/resources/test.properties b/backend/sources/viber/connector/src/test/resources/test.properties index a32f4bd309..be9e104654 100644 --- a/backend/sources/viber/connector/src/test/resources/test.properties +++ b/backend/sources/viber/connector/src/test/resources/test.properties @@ -1,5 +1,6 @@ kafka.cleanup=true kafka.commit-interval-ms=100 +logs.enabled=false authToken=no welcomeMessage={\"type\":\"text\",\"text\":\"Welcome message text\"} diff --git a/backend/webhook/consumer/src/test/resources/test.properties b/backend/webhook/consumer/src/test/resources/test.properties index 83ae0196c3..1a8eabaff4 100644 --- a/backend/webhook/consumer/src/test/resources/test.properties +++ b/backend/webhook/consumer/src/test/resources/test.properties @@ -2,3 +2,4 @@ kafka.cleanup=true kafka.commit-interval-ms=100 beanstalk.hostname=no beanstalk.port=10 +logs.enabled=false diff --git a/backend/webhook/publisher/src/test/resources/test.properties b/backend/webhook/publisher/src/test/resources/test.properties index 0cb65c22c5..deab9d67ba 100644 --- a/backend/webhook/publisher/src/test/resources/test.properties +++ b/backend/webhook/publisher/src/test/resources/test.properties @@ -3,3 +3,4 @@ kafka.cache.max.bytes=0 kafka.commit-interval-ms=100 beanstalk.hostname=no beanstalk.port=10 +logs.enabled=false diff --git a/docs/docs/api/contacts.md b/docs/docs/api/contacts.md index 98e65e08ed..5392606999 100644 --- a/docs/docs/api/contacts.md +++ b/docs/docs/api/contacts.md @@ -79,7 +79,74 @@ import ContactResponsePayload from './../sources/applyVariables-note.mdx' -### Create contact +### Import contacts + +`POST /contacts.import` + +Creates contacts in a bulk. + +**Sample request** + +```json5 +[ + { + "display_name": "Barabara Liskov", + "avatar_url": "https://example.org/avatar.jpg", + "title": "Professor" + }, + { + "display_name": "Eleanor B. Garcia", + "avatar_url": "https://example.org/avatar.jpg", + "title": "Estimator project manager" + }, + { + "display_name": "Marie R. Lemelin", + "avatar_url": "https://example.org/avatar.jpg", + "title": "Accountant" + }, + { + "display_name": "Lori L. Carter", + "avatar_url": "https://example.org/avatar.jpg", + "title": "Occupational health and safety technician" + } +] +``` + +**(201) Success Response Payload** + +```json5 +{ + "data": [ + { + "display_name": "Barabara Liskov", + "avatar_url": "https://example.org/avatar.jpg", + "title": "Professor" + }, + { + "display_name": "Eleanor B. Garcia", + "avatar_url": "https://example.org/avatar.jpg", + "title": "Estimator project manager" + }, + { + "display_name": "Marie R. Lemelin", + "avatar_url": "https://example.org/avatar.jpg", + "title": "Accountant" + }, + { + "display_name": "Lori L. Carter", + "avatar_url": "https://example.org/avatar.jpg", + "title": "Occupational health and safety technician" + } + ], + "pagination_data": { + "previous_cursor": "", + "next_cursor": "", + "total": 4 + } +} +``` + +### List contacts `POST /contacts.list` @@ -162,17 +229,13 @@ All fields set on the [creation request](#create-contact) can be updated. To rem **Sample response 202 (Accepted)** -### Re-fetch contact - -`POST /contacts.refetch` - -If there are conversations associated with this contact the sources will try to re-fetch the contact data that was ingested. This only works for messaging sources that allow you to pull contact data such as [Facebook Messenger](https://developers.facebook.com/docs/messenger-platform/identity/user-profile/). Others like Google Business Messages will push new contact information, which will cause it to show up automatically. +### Delete contact **Sample request** ```json5 { - "contacts": ["6b80b10c-ae6e-4995-844d-c56c4da11623", "c564cea4-a96f-4ebb-a220-3fb81b6ad522"] + "id": "6b80b10c-ae6e-4995-844d-c56c4da11623" } ``` @@ -180,35 +243,35 @@ If there are conversations associated with this contact the sources will try to ### Merge contacts -If you are sure two conversations belong to the same contact you can either: - -1. Call this endpoint if there are already contacts for both -2. Update the either contact if the other does not exist -3. Create a fresh contact if neither exist. +`POST /contacts.merge` -Calling this endpoint will cause the source contact to be deleted. However, calls to `/contacts.info` and `/contacts.update` will continue to be forwarded to the target contact. +Merge the source contact into the destination one, only the fields that are not present on the +destination contact will be merged, a copy of the source contact will be saved in the merge history **Sample request** ```json5 { - "source_contact": "6b80b10c-ae6e-4995-844d-c56c4da11623", // merge contact with this id - "target_contact": "c564cea4-a96f-4ebb-a220-3fb81b6ad522" // into contact with this id + "source_id": "6b80b10c-ae6e-4995-844d-c56c4da11623", + "destination_id": "c564cea4-a96f-4ebb-a220-3fb81b6ad522" } ``` -**Sample response** - - - -### Delete contact - -**Sample request** +**(200) Success Response Payload** ```json5 { - "id": "6b80b10c-ae6e-4995-844d-c56c4da11623" + "display_name": "Barabara Liskov", + "avatar_url": "https://example.org/avatar.jpg", + "title": "Estimator project manager" + "organization_name": "Airy.co", + "merge_history": [ + { + "display_name": "Eleanor B. Garcia", + "avatar_url": "https://example.org/avatar.jpg", + "title": "Estimator project manager" + "organization_name": "Airy.co" + } + ] } ``` - -**(202) Sample response** diff --git a/docs/docs/api/endpoints/chatplugin.md b/docs/docs/api/endpoints/chatplugin.md index abb91406e7..374be57de7 100644 --- a/docs/docs/api/endpoints/chatplugin.md +++ b/docs/docs/api/endpoints/chatplugin.md @@ -45,17 +45,21 @@ endpoint](#get-a-resume-token). "messages": [ { "id": "{UUID}", - "content": {"text": "Hello World"}, // source message payload - "state": "{String}", + "content": {"text": "Hello World"}, // delivery state of message, one of PENDING, FAILED, DELIVERED + "state": "{String}", "from_contact": true, - "sent_at": "{string}", //'yyyy-MM-dd'T'HH:mm:ss.SSSZ' date in UTC form, to be localized by clients + "sent_at": "{string}", + // metadata object of the message "metadata": { "sentFrom": "iPhone" + }, + // details about the sender + "sender": { + "id": "github:12345" // For unauthenticated instances this defaults to "airy-core-anonymous" } - // metadata object of the message } ] } @@ -111,17 +115,21 @@ endpoint](#authenticating-web-users) as an `Authorization` header. ```json5 { "id": "{UUID}", - "content": {"text": "Hello World"}, // source message payload - "state": "{String}", + "content": {"text": "Hello World"}, // delivery state of message, one of PENDING, FAILED, DELIVERED + "state": "{String}", "from_contact": true, - "sent_at": "{string}", //'yyyy-MM-dd'T'HH:mm:ss.SSSZ' date in UTC form, to be localized by clients + "sent_at": "{string}", + // metadata object of the message "metadata": { "sentFrom": "iPhone" + }, + // details about the sender + "sender": { + "id": "github:12345" // For unauthenticated instances this defaults to "airy-core-anonymous" } - // metadata object of the message } ``` @@ -143,17 +151,21 @@ The WebSocket connection endpoint is at `/ws.chatplugin`. { "message": { "id": "{UUID}", - "content": {"text": "Hello World"}, // source message payload - "state": "{String}", + "content": {"text": "Hello World"}, // delivery state of message, one of PENDING, FAILED, DELIVERED + "state": "{String}", "from_contact": true, - "sent_at": "{string}", //'yyyy-MM-dd'T'HH:mm:ss.SSSZ' date in UTC form, to be localized by clients + "sent_at": "{string}", + // metadata object of the message "metadata": { "sentFrom": "iPhone" + }, + // details about the sender + "sender": { + "id": "github:12345" // For unauthenticated instances this defaults to "airy-core-anonymous" } - // metadata object of the message } } ``` diff --git a/docs/docs/api/endpoints/conversations.md b/docs/docs/api/endpoints/conversations.md index 12dc5848f2..5937a25861 100644 --- a/docs/docs/api/endpoints/conversations.md +++ b/docs/docs/api/endpoints/conversations.md @@ -67,16 +67,21 @@ Find users whose name ends with "Lovelace": }, "last_message": { id: "{UUID}", - "content": {"text": "Hello World"}, // source message payload - // typed source message model - state: "{String}", + "content": {"text": "Hello World"}, // delivery state of message, one of PENDING, FAILED, DELIVERED + state: "{String}", "from_contact": true, - sent_at: "{string}", //'yyyy-MM-dd'T'HH:mm:ss.SSSZ' date in UTC form, to be localized by clients - "source": "{String}" + sent_at: "{string}", // one of the possible sources + "source": "{String}", + // details about the sender + "sender": { + "id": "github:12345", // For unauthenticated instances this defaults to "airy-core-anonymous" + "name": "John Doe", // optional + "avatar_url": "http://example.org/avatar.png" // optional + } } } ], @@ -125,14 +130,19 @@ Find users whose name ends with "Lovelace": }, "last_message": { "id": "{UUID}", - "content": {"text": "Hello World"}, // source message payload - // typed source message model - "delivery_state": "{String}", + "content": {"text": "Hello World"}, // delivery state of message, one of PENDING, FAILED, DELIVERED + "delivery_state": "{String}", "from_contact": true, - "sent_at": "{string}" //'yyyy-MM-dd'T'HH:mm:ss.SSSZ' date in UTC form, to be localized by clients + "sent_at": "{string}", + // details about the sender + "sender": { + "id": "github:12345", // For unauthenticated instances this defaults to "airy-core-anonymous" + "name": "John Doe", // optional + "avatar_url": "http://example.org/avatar.png" // optional + } } } ``` @@ -231,3 +241,19 @@ Supported states of a conversation are `OPEN` and `CLOSED`. ``` **Empty response (204)** + +## Refetch conversation metadata + +Refetch all conversation metadata including contact information if supported by the source + +`POST /conversations.refetch` + +**Sample request** + +```json5 +{ + "conversation_id": "CONVERSATION_ID" +} +``` + +**Empty response (202)** diff --git a/docs/docs/api/endpoints/messages.md b/docs/docs/api/endpoints/messages.md index 21bf373a87..7f2ec0cc66 100644 --- a/docs/docs/api/endpoints/messages.md +++ b/docs/docs/api/endpoints/messages.md @@ -30,19 +30,25 @@ are sorted from oldest to latest. "data": [ { "id": "{UUID}", - "content": {"text": "Hello World"}, // source message payload - "state": "{String}", + "content": {"text": "Hello World"}, // delivery state of message, one of PENDING, FAILED, DELIVERED + "state": "{String}", "from_contact": true, - "sent_at": "{string}", // ISO 8601 date string - "source": "{String}", + "sent_at": "{string}", // one of the possible sources + "source": "{String}", + // metadata object of the message "metadata": { "sentFrom": "iPhone" + }, + // details about the sender + "sender": { + "id": "github:12345", // For unauthenticated instances this defaults to "airy-core-anonymous" + "name": "John Doe", // optional + "avatar_url": "http://example.org/avatar.png" // optional } - // metadata object of the message } ], "pagination_data": { @@ -80,16 +86,22 @@ to see learn how to send text, media, and many more message types. "id": "{UUID}", "content": "{\"text\":\"Hello\"}", "state": "pending|failed|delivered", - "from_contact": true, // See glossary - "sent_at": "{string}", + "from_contact": true, //'yyyy-MM-dd'T'HH:mm:ss.SSSZ' date in UTC form, to be localized by clients - "source": "{String}", + "sent_at": "{string}", // one of the possible sources + "source": "{String}", + // metadata object of the message "metadata": { "sentFrom": "iPhone" + }, + // details about the sender + "sender": { + "id": "github:12345", // For unauthenticated instances this defaults to "airy-core-anonymous" + "name": "John Doe", // optional + "avatar_url": "http://example.org/avatar.png" // optional } - // metadata object of the message } ``` @@ -120,7 +132,13 @@ allow you to send messages to contacts that did not previously message you first "from_contact": true, "sent_at": "{string}", "source": "{String}", - "metadata": {} + "metadata": {}, + // details about the sender + "sender": { + "id": "github:12345", // For unauthenticated instances this defaults to "airy-core-anonymous" + "name": "John Doe", // optional + "avatar_url": "http://example.org/avatar.png" // optional + } } ``` diff --git a/docs/docs/api/endpoints/users.md b/docs/docs/api/endpoints/users.md new file mode 100644 index 0000000000..66b9501d74 --- /dev/null +++ b/docs/docs/api/endpoints/users.md @@ -0,0 +1,55 @@ +--- +title: Users +sidebar_label: Users +--- + +:::note + +Different from contacts. See [glossary](getting-started/glossary.md#Contact). + +::: + +Users are not directly managed by Airy. Instead, users are managed by an [authentication](getting-started/installation/security.md) +provider and whenever a user interacts with Airy their profile is presented in this API. + +## List + +This is a [paginated](/api/endpoints/introduction.md#pagination) endpoint. + +`POST /users.list` + +**Sample request** + +```json5 +{ + "cursor": "next-cursor", + "page_size": 2 +} +``` + +**Sample Response** + +```json5 +{ + "data": [ + { + "id": "user-id", + "first_seen_at": "2022-01-11T16:07:23.602Z", + "last_seen_at": "2022-01-12T16:07:23.602Z", + "name": "Barbara Liskov", // optional + "avatar_url": "http://example.org/profile.jpg" // optional + }, + { + "id": "user-id", + "first_seen_at": "2022-01-11T16:07:23.602Z", + "last_seen_at": "2022-01-12T16:07:23.602Z", + "name": "Ada Lovelace" + } + ], + "pagination_data": { + "previous_cursor": null, + "next_cursor": "2", + "total": 104 + } +} +``` diff --git a/docs/docs/api/webhook.md b/docs/docs/api/webhook.md index f6ad5f9199..334624e11a 100644 --- a/docs/docs/api/webhook.md +++ b/docs/docs/api/webhook.md @@ -164,7 +164,11 @@ request with one the following payloads: "delivery_state": "pending|failed|delivered", // delivery state of message, one of pending, failed, delivered "from_contact": true, "sent_at": "2020-10-25T21:24:54.560Z", // ISO 8601 date string - "source": "facebook" // messaging source + "source": "facebook", // messaging source + // details about the sender + "sender": { + "id": "github:12345" // For unauthenticated instances this defaults to "airy-core-anonymous" + } } } } @@ -192,6 +196,10 @@ Sent whenever a message is updated (e.g. delivery state) or its [metadata](conce "id": "facebook message id", "delivery_state": "seen" } + }, + // details about the sender + "sender": { + "id": "github:12345" // For unauthenticated instances this defaults to "airy-core-anonymous" } } } @@ -221,7 +229,11 @@ Sent whenever a message is updated (e.g. delivery state) or its [metadata](conce "delivery_state": "pending|failed|delivered", // delivery state of message, one of pending, failed, delivered "from_contact": true, "sent_at": "2020-10-25T21:24:54.560Z", // ISO 8601 date string - "source": "facebook" // messaging source + "source": "facebook", // messaging source + // details about the sender + "sender": { + "id": "github:12345" // For unauthenticated instances this defaults to "airy-core-anonymous" + } } } } diff --git a/docs/docs/api/websocket.md b/docs/docs/api/websocket.md index a60380c368..21e60395b2 100644 --- a/docs/docs/api/websocket.md +++ b/docs/docs/api/websocket.md @@ -32,15 +32,19 @@ payload. "channel_id": "{UUID}", "message": { "id": "{UUID}", - "content": {"text": "Hello World"}, // source message payload - "delivery_state": "pending|failed|delivered", + "content": {"text": "Hello World"}, // delivery state of message, one of pending, failed, delivered + "delivery_state": "pending|failed|delivered", "from_contact": true, - "sent_at": "{string}", //'yyyy-MM-dd'T'HH:mm:ss.SSSZ' date in UTC form, to be localized by clients - "source": "{String}" + "sent_at": "{string}", // one of the possible sources + "source": "{String}", + // details about the sender + "sender": { + "id": "github:12345" // For unauthenticated instances this defaults to "airy-core-anonymous" + } } } } diff --git a/docs/docs/changelog.md b/docs/docs/changelog.md index f81fe1ab18..e702ec3f77 100644 --- a/docs/docs/changelog.md +++ b/docs/docs/changelog.md @@ -3,11 +3,69 @@ title: Changelog sidebar_label: 📝 Changelog --- +## 0.39.0 + +#### 🚀 Features + +- [#2693] Contacts merge endpoint (#2773) +- [#2769] Added custom host url (#2772) +- [#2768] Make hostname optional for frontend-ui (#2770) +- [#2562] Possibility to send media/templates with text (#2735) +- [#2745] Conversations.refetch endpoint (#2747) +- [#2647] Add sender information to messages (#2746) + +#### 🐛 Bug Fixes + +- [#2781] Endless loop for channels.list (#2783) +- [#2764] Bug null pointer exception on api contacts service (#2767) +- [#2715] Fixed notification badge after reload (#2765) + +#### 📚 Documentation + +- [#2693] Docs for contacts service endpoins (#2752) + +#### 🧰 Maintenance + +- Bump @babel/plugin-proposal-object-rest-spread from 7.15.6 to 7.16.7 (#2776) +- Bump css-loader from 6.2.0 to 6.5.1 (#2777) +- Bump react-router-dom from 5.3.0 to 6.2.1 (#2749) +- Update Bazel Dev Server (#2771) +- Bump terser-webpack-plugin from 5.2.5 to 5.3.0 (#2760) +- Bump node-fetch from 2.6.6 to 2.6.7 (#2763) +- Bump webpack from 5.66.0 to 5.67.0 (#2762) +- Bump react-i18next from 11.14.2 to 11.15.3 (#2761) +- Bump nanoid from 3.1.23 to 3.2.0 (#2759) +- Bump nanoid from 3.1.20 to 3.2.0 in /docs (#2758) +- Bump react-modal from 3.14.3 to 3.14.4 (#2751) +- Bump webpack-dev-server from 3.11.2 to 4.7.3 (#2729) +- Bump core-js from 3.18.3 to 3.20.3 (#2748) +- Bump nth-check from 2.0.0 to 2.0.1 (#2750) +- Bump @bazel/typescript from 4.3.0 to 4.6.1 (#2741) +- Bump @svgr/webpack from 5.5.0 to 6.2.0 (#2717) +- Bump @babel/preset-env from 7.15.8 to 7.16.8 (#2732) +- Bump reselect from 4.1.1 to 4.1.5 (#2621) + +#### Airy CLI + +You can download the Airy CLI for your operating system from the following links: + +[MacOS](https://airy-core-binaries.s3.amazonaws.com/0.39.0/darwin/amd64/airy) +[Linux](https://airy-core-binaries.s3.amazonaws.com/0.39.0/linux/amd64/airy) +[Windows](https://airy-core-binaries.s3.amazonaws.com/0.39.0/windows/amd64/airy.exe) + ## 0.38.1 #### 🐛 Bug Fixes -- [[#2736](https://github.com/airyhq/airy/issues/2736)] Hotfix for re-provisioning topics on upgrade +- [[#2736](https://github.com/airyhq/airy/issues/2736)] Hotfix for re-provisioning topics on upgrade [[#2740](https://github.com/airyhq/airy/pull/2740)] + +#### Airy CLI + +You can download the Airy CLI for your operating system from the following links: + +[MacOS](https://airy-core-binaries.s3.amazonaws.com/0.38.1/darwin/amd64/airy) +[Linux](https://airy-core-binaries.s3.amazonaws.com/0.38.1/linux/amd64/airy) +[Windows](https://airy-core-binaries.s3.amazonaws.com/0.38.1/windows/amd64/airy.exe) ## 0.38.0 @@ -49,9 +107,9 @@ sidebar_label: 📝 Changelog You can download the Airy CLI for your operating system from the following links: -[MacOS](https://airy-core-binaries.s3.amazonaws.com/0.37.1/darwin/amd64/airy) -[Linux](https://airy-core-binaries.s3.amazonaws.com/0.37.1/linux/amd64/airy) -[Windows](https://airy-core-binaries.s3.amazonaws.com/0.37.1/windows/amd64/airy.exe) +[MacOS](https://airy-core-binaries.s3.amazonaws.com/0.38.0/darwin/amd64/airy) +[Linux](https://airy-core-binaries.s3.amazonaws.com/0.38.0/linux/amd64/airy) +[Windows](https://airy-core-binaries.s3.amazonaws.com/0.38.0/windows/amd64/airy.exe) ## 0.37.0 diff --git a/docs/docs/getting-started/glossary.md b/docs/docs/getting-started/glossary.md index a117ae0919..3df84da352 100644 --- a/docs/docs/getting-started/glossary.md +++ b/docs/docs/getting-started/glossary.md @@ -39,6 +39,13 @@ A contact represents the [source](#source) participant. A [conversation](#conversation) exists _only_ if it has _at least one_ [message](#message) from a contact. +:::note + +Not the same as [users](#user). Contacts are source participants whereas users are the actual users interacting with +the airy platform. + +::: + ## Conversation A conversation is the logical aggregation of [messages](#message) (at least one) @@ -118,7 +125,7 @@ for the Twilio SMS and WhatsApp sources. ## User -A user represents one authorized agent in Airy Core. +A user represents one authorized agent in Airy Core, which is different from a Contact ## Template diff --git a/docs/docs/sources/google.md b/docs/docs/sources/google.md index 84d1812424..ef5c5bc9db 100644 --- a/docs/docs/sources/google.md +++ b/docs/docs/sources/google.md @@ -147,14 +147,20 @@ Whatever is put on the `message` field will be forwarded "as-is" to the source's } }, "delivery_state": "pending|failed|delivered", - "from_contact": "true|false", // See glossary - "sent_at": "{string}", + "from_contact": "true|false", //'yyyy-MM-dd'T'HH:mm:ss.SSSZ' date in UTC form, to be localized by clients + "sent_at": "{string}", "source": "google", + // metadata object of the message "metadata": { "sentFrom": "iPhone" + }, + // details about the sender + "sender": { + "id": "github:12345", // For unauthenticated instances this defaults to "airy-core-anonymous" + "name": "John Doe", // optional + "avatar_url": "http://example.org/avatar.png" // optional } - // metadata object of the message } ``` diff --git a/docs/sidebars.js b/docs/sidebars.js index 7ca6e41a62..9eaf896bb2 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -58,6 +58,7 @@ module.exports = { 'api/endpoints/metadata', 'api/endpoints/tags', 'api/endpoints/templates', + 'api/endpoints/users', ], }, 'api/websocket', diff --git a/docs/yarn.lock b/docs/yarn.lock index 531d8e0f7e..4c400bf540 100644 --- a/docs/yarn.lock +++ b/docs/yarn.lock @@ -2360,9 +2360,9 @@ bail@^1.0.0: integrity sha512-xFbRxM1tahm08yHBP16MMjVUAvDaBMD38zsM9EMAUN61omwLmKlOpB/Zku5QkjZ8TZ4vn53pj+t518cH0S03RQ== balanced-match@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" - integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c= + version "1.0.2" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" + integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== base16@^1.0.0: version "1.0.0" @@ -4406,9 +4406,9 @@ flux@^4.0.1: fbjs "^3.0.0" follow-redirects@^1.0.0, follow-redirects@^1.14.0: - version "1.14.4" - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.4.tgz#838fdf48a8bbdd79e52ee51fb1c94e3ed98b9379" - integrity sha512-zwGkiSXC1MUJG/qmeIFH2HBJx9u0V46QGUe3YR1fXG8bXQxq7fLj0RjLZQ5nubr9qNJUZrH+xUcwXEoXNpfS+g== + version "1.14.7" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.7.tgz#2004c02eb9436eee9a21446a6477debf17e81685" + integrity sha512-+hbxoLbFMbRKDwohX8GkTataGqO6Jb7jGwpAlwgy2bIz25XtRm7KEzJM76R1WiNT5SwZkX4Y75SwBolkpmE7iQ== for-in@^1.0.2: version "1.0.2" @@ -4579,9 +4579,9 @@ glob-parent@^5.1.0, glob-parent@^5.1.1, glob-parent@~5.1.0: is-glob "^4.0.1" glob@^7.0.0, glob@^7.0.3, glob@^7.1.3, glob@^7.1.4: - version "7.1.6" - resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6" - integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA== + version "7.2.0" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.0.tgz#d15535af7732e02e948f4c41628bd910293f6023" + integrity sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q== dependencies: fs.realpath "^1.0.0" inflight "^1.0.4" @@ -5351,6 +5351,13 @@ is-core-module@^2.2.0: dependencies: has "^1.0.3" +is-core-module@^2.8.0: + version "2.8.1" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.8.1.tgz#f59fdfca701d5879d0a6b100a40aa1560ce27211" + integrity sha512-SdNCUs284hr40hFTFP6l0IfZ/RSrMXF3qgoRHd3/79unUTvrFO/JoXwkGm+5J/Oe3E/b5GsnG330uUNgRpu1PA== + dependencies: + has "^1.0.3" + is-data-descriptor@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz#0b5ee648388e2c860282e793f1856fec3f301b56" @@ -6396,9 +6403,9 @@ nan@^2.12.1: integrity sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ== nanoid@^3.1.20: - version "3.1.20" - resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.20.tgz#badc263c6b1dcf14b71efaa85f6ab4c1d6cfc788" - integrity sha512-a1cQNyczgKbLX9jwbS/+d7W8fX/RfgYR7lVWwWOGIPNgK2m0MWvrGF6/m4kk6U3QcFMnZf3RIhL0v2Jgh/0Uxw== + version "3.2.0" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.2.0.tgz#62667522da6673971cca916a6d3eff3f415ff80c" + integrity sha512-fmsZYa9lpn69Ad5eDn7FMcnnSR+8R34W9qJEijxYhTbfOWzr22n1QxCMzXLK+ODyW2973V3Fux959iQoUxzUIA== nanomatch@^1.2.9: version "1.2.13" @@ -6928,7 +6935,7 @@ path-key@^3.0.0, path-key@^3.1.0: resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== -path-parse@^1.0.6: +path-parse@^1.0.6, path-parse@^1.0.7: version "1.0.7" resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== @@ -8407,7 +8414,16 @@ resolve-url@^0.2.1: resolved "https://registry.yarnpkg.com/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a" integrity sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo= -resolve@^1.1.6, resolve@^1.14.2, resolve@^1.3.2: +resolve@^1.1.6: + version "1.21.0" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.21.0.tgz#b51adc97f3472e6a5cf4444d34bc9d6b9037591f" + integrity sha512-3wCbTpk5WJlyE4mSOtDLhqQmGFi0/TD9VPwmiolnk8U0wRgMEktqCXd3vy5buTO3tljvalNvKrjHEfrd2WpEKA== + dependencies: + is-core-module "^2.8.0" + path-parse "^1.0.7" + supports-preserve-symlinks-flag "^1.0.0" + +resolve@^1.14.2, resolve@^1.3.2: version "1.20.0" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.20.0.tgz#629a013fb3f70755d6f0b7935cc1c2c5378b1975" integrity sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A== @@ -8750,9 +8766,9 @@ shell-quote@1.7.2: integrity sha512-mRz/m/JVscCrkMyPqHc/bczi3OQHkLTqXHEFu0zDhK/qfv3UcOA4SVmRCLmos4bhjr9ekVQubj/R7waKapmiQg== shelljs@^0.8.4: - version "0.8.4" - resolved "https://registry.yarnpkg.com/shelljs/-/shelljs-0.8.4.tgz#de7684feeb767f8716b326078a8a00875890e3c2" - integrity sha512-7gk3UZ9kOfPLIAbslLzyWeGiEqx9e3rxwZM0KE6EL8GlGwjym9Mrlx5/p33bWTu9YG6vcS4MBxYZDHYr5lr8BQ== + version "0.8.5" + resolved "https://registry.yarnpkg.com/shelljs/-/shelljs-0.8.5.tgz#de055408d8361bed66c669d2f000538ced8ee20c" + integrity sha512-TiwcRcrkhHvbrZbnRcFYMLl30Dfov3HKqzp5tO5b4pt6G/SezKcYhmDg15zXVBswHmctSAQKznqNW2LO5tTDow== dependencies: glob "^7.0.0" interpret "^1.0.0" @@ -9162,6 +9178,11 @@ supports-color@^7.0.0, supports-color@^7.1.0: dependencies: has-flag "^4.0.0" +supports-preserve-symlinks-flag@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" + integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== + svg-parser@^2.0.2: version "2.0.4" resolved "https://registry.yarnpkg.com/svg-parser/-/svg-parser-2.0.4.tgz#fdc2e29e13951736140b76cb122c8ee6630eb6b5" diff --git a/frontend/chat-plugin/lib/src/airyRenderProps/AiryInputBar/index.module.scss b/frontend/chat-plugin/lib/src/airyRenderProps/AiryInputBar/index.module.scss index 988e49c396..9ac69c452b 100644 --- a/frontend/chat-plugin/lib/src/airyRenderProps/AiryInputBar/index.module.scss +++ b/frontend/chat-plugin/lib/src/airyRenderProps/AiryInputBar/index.module.scss @@ -156,7 +156,7 @@ pointer-events: none; path { - fill: #a0abb2; + fill: var(--color-icons-gray); } } @@ -190,6 +190,10 @@ height: 24px; width: 24px; } + + path { + fill: var(--color-icons-gray); + } } .iconButton:hover { diff --git a/frontend/chat-plugin/lib/src/components/chat/index.tsx b/frontend/chat-plugin/lib/src/components/chat/index.tsx index f6bf20791f..90ca6606f7 100644 --- a/frontend/chat-plugin/lib/src/components/chat/index.tsx +++ b/frontend/chat-plugin/lib/src/components/chat/index.tsx @@ -72,17 +72,20 @@ const Chat = ({config, ...props}: Props) => { const [showModal, setShowModal] = useState(false); const [newConversation, setNewConversation] = useState(false); const [unreadMessage, setUnreadMessage] = useState(false); - - const messageLengthRef = useRef(messages.length); + const localStorageMessageLength = parseInt(localStorage.getItem('messagesLength')); + const messageLengthRef = useRef(localStorageMessageLength); const lastMessage = messages[messages.length - 1]; useEffect(() => { - messages.length > messageLengthRef.current && !lastMessage.fromContact + messages.length > localStorageMessageLength && + messages.length > messageLengthRef.current && + !lastMessage.fromContact ? setUnreadMessage(true) : setUnreadMessage(false); - !isChatHidden && messages.length > messageLengthRef.current && setUnreadMessage(false); + !isChatHidden && setUnreadMessage(false); + localStorage.setItem('messagesLength', `${messageLengthRef.current}`); messageLengthRef.current = messages.length; }, [isChatHidden, messages]); diff --git a/frontend/ui/BUILD b/frontend/ui/BUILD index b4ea9bc9cc..e7c9d27562 100644 --- a/frontend/ui/BUILD +++ b/frontend/ui/BUILD @@ -29,6 +29,7 @@ ts_web_library( "@npm//@types/react", "@npm//@types/react-dom", "@npm//@types/react-redux", + "@npm//@types/lodash-es", "@npm//lodash-es", "@npm//react", "@npm//react-router-dom", diff --git a/frontend/ui/src/App.tsx b/frontend/ui/src/App.tsx index ab4c479b6b..8bd1bfa5fc 100644 --- a/frontend/ui/src/App.tsx +++ b/frontend/ui/src/App.tsx @@ -1,69 +1,71 @@ -import React, {Component} from 'react'; +import React, {useEffect} from 'react'; import _, {connect, ConnectedProps} from 'react-redux'; -import {withRouter, Route, Switch, Redirect, RouteComponentProps} from 'react-router-dom'; +import {Route, Routes, Navigate} from 'react-router-dom'; import TopBar from './components/TopBar'; import Channels from './pages/Channels'; import Inbox from './pages/Inbox'; import Tags from './pages/Tags'; import NotFound from './pages/NotFound'; -import Sidebar from './components/Sidebar'; +import {Sidebar} from './components/Sidebar'; import AiryWebSocket from './components/AiryWebsocket'; -import {fakeSettingsAPICall} from './actions/settings'; -import {StateModel} from './reducers'; +import {fakeSettingsAPICall} from './actions'; import {INBOX_ROUTE, CHANNELS_ROUTE, ROOT_ROUTE, TAGS_ROUTE} from './routes/routes'; import styles from './App.module.scss'; import {getClientConfig} from './actions/config'; - -const mapStateToProps = (state: StateModel, ownProps: RouteComponentProps) => { - return { - user: state.data.user, - pathname: ownProps.location.pathname, - }; -}; +import ConnectedChannelsList from './pages/Channels/ConnectedChannelsList'; +import FacebookConnect from './pages/Channels/Providers/Facebook/Messenger/FacebookConnect'; +import ChatPluginConnect from './pages/Channels/Providers/Airy/ChatPlugin/ChatPluginConnect'; +import TwilioSmsConnect from './pages/Channels/Providers/Twilio/SMS/TwilioSmsConnect'; +import TwilioWhatsappConnect from './pages/Channels/Providers/Twilio/WhatsApp/TwilioWhatsappConnect'; +import GoogleConnect from './pages/Channels/Providers/Google/GoogleConnect'; +import InstagramConnect from './pages/Channels/Providers/Instagram/InstagramConnect'; +import MainPage from './pages/Channels/MainPage'; const mapDispatchToProps = { fakeSettingsAPICall, getClientConfig, }; -const connector = connect(mapStateToProps, mapDispatchToProps); +const connector = connect(null, mapDispatchToProps); -class App extends Component & RouteComponentProps> { - constructor(props: ConnectedProps & RouteComponentProps) { - super(props); - } +const App = (props: ConnectedProps) => { + useEffect(() => { + props.fakeSettingsAPICall(); + props.getClientConfig(); + }, []); - componentDidMount() { - this.props.fakeSettingsAPICall(); - this.props.getClientConfig(); - } - - render() { - return ( - -
-
- <> - - - - - - - - - - - - -
+ return ( + +
+
+ <> + + + + + } /> + } /> + } /> + } /> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + } /> +
- - ); - } -} +
+
+ ); +}; -export default withRouter(connector(App)); +export default connector(App); diff --git a/frontend/ui/src/components/Sidebar/index.tsx b/frontend/ui/src/components/Sidebar/index.tsx index fff9e0dc6e..725925b2c2 100644 --- a/frontend/ui/src/components/Sidebar/index.tsx +++ b/frontend/ui/src/components/Sidebar/index.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import {withRouter, Link, matchPath, RouteProps} from 'react-router-dom'; +import {Link, matchPath, useLocation} from 'react-router-dom'; import {ReactComponent as PlugIcon} from 'assets/images/icons/git-merge.svg'; import {ReactComponent as InboxIcon} from 'assets/images/icons/inbox.svg'; @@ -9,9 +9,10 @@ import {INBOX_ROUTE, CHANNELS_ROUTE, TAGS_ROUTE} from '../../routes/routes'; import styles from './index.module.scss'; -const Sidebar = (props: RouteProps) => { +export const Sidebar = () => { + const location = useLocation(); const isActive = (route: string) => { - return !!matchPath(props.location.pathname, route); + return !!matchPath(location.pathname, route); }; return ( @@ -39,5 +40,3 @@ const Sidebar = (props: RouteProps) => { ); }; - -export default withRouter(Sidebar); diff --git a/frontend/ui/src/components/TopBar/index.tsx b/frontend/ui/src/components/TopBar/index.tsx index 624951fb0c..bf8d918aa1 100644 --- a/frontend/ui/src/components/TopBar/index.tsx +++ b/frontend/ui/src/components/TopBar/index.tsx @@ -1,6 +1,5 @@ import React, {useState, useCallback} from 'react'; import _, {connect, ConnectedProps} from 'react-redux'; -import {withRouter, RouteComponentProps} from 'react-router-dom'; import {ListenOutsideClick} from 'components'; import {StateModel} from '../../reducers'; import {ReactComponent as ShortcutIcon} from 'assets/images/icons/shortcut.svg'; @@ -22,7 +21,7 @@ const logoutUrl = `${env.API_HOST}/logout`; const connector = connect(mapStateToProps); -const TopBar = (props: TopBarProps & ConnectedProps & RouteComponentProps) => { +const TopBar = (props: TopBarProps & ConnectedProps) => { const [isAccountDropdownOn, setAccountDropdownOn] = useState(false); const [isFaqDropdownOn, setFaqDropdownOn] = useState(false); @@ -113,4 +112,4 @@ const TopBar = (props: TopBarProps & ConnectedProps & RouteCom ); }; -export default withRouter(connect(mapStateToProps)(TopBar)); +export default connect(mapStateToProps)(TopBar); diff --git a/frontend/ui/src/httpClient.ts b/frontend/ui/src/httpClient.ts index 2eb13e301f..5d80c9dc25 100644 --- a/frontend/ui/src/httpClient.ts +++ b/frontend/ui/src/httpClient.ts @@ -1,7 +1,9 @@ import {HttpClient} from 'httpclient/src'; import {env} from './env'; -export const HttpClientInstance = new HttpClient(env.API_HOST, (error, loginUrl) => { +export const apiHostUrl = env.API_HOST ?? `${location.protocol + '//' + location.host}`; + +export const HttpClientInstance = new HttpClient(apiHostUrl, (error, loginUrl) => { console.error(error); if (location.href != loginUrl) { location.replace(loginUrl); diff --git a/frontend/ui/src/pages/Channels/ConnectedChannelsBySourceCard/index.tsx b/frontend/ui/src/pages/Channels/ConnectedChannelsBySourceCard/index.tsx index 9ab90f2f27..2976ed0afe 100644 --- a/frontend/ui/src/pages/Channels/ConnectedChannelsBySourceCard/index.tsx +++ b/frontend/ui/src/pages/Channels/ConnectedChannelsBySourceCard/index.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import {RouteComponentProps, withRouter} from 'react-router-dom'; import {LinkButton} from 'components'; import {Channel} from 'model'; @@ -8,74 +7,72 @@ import ChannelAvatar from '../../../components/ChannelAvatar'; import {ReactComponent as PlusCircleIcon} from 'assets/images/icons/plus-circle.svg'; import styles from './index.module.scss'; +import {useNavigate} from 'react-router-dom'; type ConnectedChannelsBySourceCardProps = { sourceInfo: SourceInfo; channels: Channel[]; }; -const ConnectedChannelsBySourceCard = (props: ConnectedChannelsBySourceCardProps & RouteComponentProps) => { +const ConnectedChannelsBySourceCard = (props: ConnectedChannelsBySourceCardProps) => { const {sourceInfo, channels} = props; + const navigate = useNavigate(); const hasExtraChannels = channels.length > sourceInfo.channelsToShow; + if (channels.length === 0) { + return null; + } + return ( <> - {channels && channels.length > 0 && ( - <> -
-
-

{channels.length} Connected

-
-
props.history.push(sourceInfo.channelsListRoute)} - > -
- {channels.slice(0, sourceInfo.channelsToShow).map((channel: Channel) => { - return ( -
  • - -
  • - ); - })} -
    -
    - {hasExtraChannels && ( - - +{channels.length - sourceInfo.channelsToShow} {sourceInfo.itemInfoString} - - )} -
    -
    +
    +
    +

    {channels.length} Connected

    +
    +
    navigate(sourceInfo.channelsListRoute)}> +
    + {channels.slice(0, sourceInfo.channelsToShow).map((channel: Channel) => { + return ( +
  • + +
  • + ); + })} +
    +
    + {hasExtraChannels && ( + + +{channels.length - sourceInfo.channelsToShow} {sourceInfo.itemInfoString} + + )}
    +
    +
    -
    - +
    + +
    ); }; -export default withRouter(ConnectedChannelsBySourceCard); + +export default ConnectedChannelsBySourceCard; diff --git a/frontend/ui/src/pages/Channels/ConnectedChannelsList/ChannelListItem/index.tsx b/frontend/ui/src/pages/Channels/ConnectedChannelsList/ChannelListItem/index.tsx index 00348a2732..3a9d43423b 100644 --- a/frontend/ui/src/pages/Channels/ConnectedChannelsList/ChannelListItem/index.tsx +++ b/frontend/ui/src/pages/Channels/ConnectedChannelsList/ChannelListItem/index.tsx @@ -1,6 +1,5 @@ import React, {useState} from 'react'; import _, {connect, ConnectedProps} from 'react-redux'; -import {RouteComponentProps, withRouter} from 'react-router-dom'; import {disconnectChannel} from '../../../../actions/channel'; @@ -11,11 +10,11 @@ import {ReactComponent as CheckMarkIcon} from 'assets/images/icons/checkmark.svg import ChannelAvatar from '../../../../components/ChannelAvatar'; import styles from './index.module.scss'; +import {useNavigate} from 'react-router-dom'; type ChannelListItemProps = { channel: Channel; -} & ConnectedProps & - RouteComponentProps<{channelId: string}>; +} & ConnectedProps; const mapDispatchToProps = { disconnectChannel, @@ -25,6 +24,7 @@ const connector = connect(null, mapDispatchToProps); const ChannelListItem = (props: ChannelListItemProps) => { const {channel} = props; + const navigate = useNavigate(); const [deletePopupVisible, setDeletePopupVisible] = useState(false); const togglePopupVisibility = () => { @@ -63,8 +63,7 @@ const ChannelListItem = (props: ChannelListItemProps) => { styleVariant="link" type="button" onClick={() => - props.history.push({ - pathname: `/channels/${channel.source}/${channel.id}`, + navigate(`/channels/${channel.source}/${channel.id}`, { state: {channel: channel}, }) } @@ -105,4 +104,4 @@ const ChannelListItem = (props: ChannelListItemProps) => { ); }; -export default withRouter(connector(ChannelListItem)); +export default connector(ChannelListItem); diff --git a/frontend/ui/src/pages/Channels/ConnectedChannelsList/index.tsx b/frontend/ui/src/pages/Channels/ConnectedChannelsList/index.tsx index 5a04f4d999..26e43eb2f3 100644 --- a/frontend/ui/src/pages/Channels/ConnectedChannelsList/index.tsx +++ b/frontend/ui/src/pages/Channels/ConnectedChannelsList/index.tsx @@ -1,6 +1,6 @@ import React, {useEffect, useState} from 'react'; -import _, {connect, ConnectedProps} from 'react-redux'; -import {withRouter, RouteComponentProps, Link} from 'react-router-dom'; +import _, {useSelector} from 'react-redux'; +import {Link, useNavigate, useParams} from 'react-router-dom'; import {sortBy} from 'lodash-es'; import {StateModel} from '../../../reducers'; @@ -26,26 +26,18 @@ import { CHANNELS_INSTAGRAM_ROUTE, } from '../../../routes/routes'; -type ConnectedChannelsListProps = {} & ConnectedProps & RouteComponentProps<{source: string}>; - -const mapStateToProps = (state: StateModel, ownProps: RouteComponentProps<{source: string}>) => ({ - channels: Object.values(allChannels(state)).filter( - (channel: Channel) => channel.source === ownProps.match.params.source - ), -}); - -const connector = connect(mapStateToProps, null); - -const ConnectedChannelsList = (props: ConnectedChannelsListProps) => { - const {channels} = props; +const ConnectedChannelsList = () => { + const {source} = useParams(); + const navigate = useNavigate(); + const channels = useSelector((state: StateModel) => { + return Object.values(allChannels(state)).filter((channel: Channel) => channel.source === source); + }); const [name, setName] = useState(''); const [path, setPath] = useState(''); const [searchText, setSearchText] = useState(''); const [showingSearchField, setShowingSearchField] = useState(false); - const source = props.match.params.source; - const filteredChannels = channels.filter((channel: Channel) => channel.metadata?.name?.toLowerCase().includes(searchText.toLowerCase()) ); @@ -58,7 +50,7 @@ const ConnectedChannelsList = (props: ConnectedChannelsListProps) => { switch (source) { case Source.facebook: setName('Facebook Messenger'); - setPath(CHANNELS_FACEBOOK_ROUTE); + setPath(CHANNELS_FACEBOOK_ROUTE + '/new'); break; case Source.google: setName('Google Business Messages'); @@ -112,7 +104,7 @@ const ConnectedChannelsList = (props: ConnectedChannelsListProps) => { )} -
    @@ -144,4 +136,4 @@ const ConnectedChannelsList = (props: ConnectedChannelsListProps) => { ); }; -export default withRouter(connector(ConnectedChannelsList)); +export default ConnectedChannelsList; diff --git a/frontend/ui/src/pages/Channels/MainPage/index.module.scss b/frontend/ui/src/pages/Channels/MainPage/index.module.scss index 5508e602a6..7f8427a9a3 100644 --- a/frontend/ui/src/pages/Channels/MainPage/index.module.scss +++ b/frontend/ui/src/pages/Channels/MainPage/index.module.scss @@ -1,6 +1,17 @@ @import 'assets/scss/fonts.scss'; @import 'assets/scss/colors.scss'; +.channelsWrapper { + background: white; + display: block; + border-radius: 10px; + width: 100%; + padding: 32px; + margin: 88px 1.5em 0 16px; + height: calc(100vh - 88px); + overflow-y: scroll; +} + .channelsHeadline { @include font-xl; font-weight: 900; diff --git a/frontend/ui/src/pages/Channels/MainPage/index.tsx b/frontend/ui/src/pages/Channels/MainPage/index.tsx index 09970dff94..7f56387563 100644 --- a/frontend/ui/src/pages/Channels/MainPage/index.tsx +++ b/frontend/ui/src/pages/Channels/MainPage/index.tsx @@ -1,7 +1,6 @@ import React, {useState} from 'react'; -import {withRouter, RouteComponentProps} from 'react-router-dom'; -import {Source, Channel, Config} from 'model'; +import {Source, Channel} from 'model'; import {FacebookMessengerRequirementsDialog} from '../Providers/Facebook/Messenger/FacebookMessengerRequirementsDialog'; import {InstagramRequirementsDialog} from '../Providers/Instagram/InstagramRequirementsDialog'; import {GoogleBusinessMessagesRequirementsDialog} from '../Providers/Google/GoogleBusinessMessagesRequirementsDialog'; @@ -40,11 +39,10 @@ import { CHANNELS_GOOGLE_ROUTE, CHANNELS_INSTAGRAM_ROUTE, } from '../../../routes/routes'; - -type MainPageProps = { - channels: Channel[]; - config: Config; -}; +import {useNavigate} from 'react-router-dom'; +import {StateModel} from '../../../reducers'; +import {allChannelsConnected} from '../../../selectors/channels'; +import {useSelector} from 'react-redux'; export type SourceInfo = { type: Source; @@ -79,7 +77,7 @@ const SourcesInfo: SourceInfo[] = [ title: 'Messenger', description: 'Connect multiple Facebook pages', image: , - newChannelRoute: CHANNELS_FACEBOOK_ROUTE, + newChannelRoute: CHANNELS_FACEBOOK_ROUTE + '/new', channelsListRoute: CHANNELS_CONNECTED_ROUTE + '/facebook', configKey: 'sources-facebook', channelsToShow: 4, @@ -131,7 +129,7 @@ const SourcesInfo: SourceInfo[] = [ title: 'Instagram', description: 'Connect multiple Instagram pages', image: , - newChannelRoute: CHANNELS_INSTAGRAM_ROUTE, + newChannelRoute: CHANNELS_INSTAGRAM_ROUTE + '/new', channelsListRoute: CHANNELS_CONNECTED_ROUTE + '/instagram', configKey: 'sources-facebook', channelsToShow: 4, @@ -141,8 +139,9 @@ const SourcesInfo: SourceInfo[] = [ }, ]; -const MainPage = (props: MainPageProps & RouteComponentProps) => { - const {channels, config} = props; +const MainPage = () => { + const channels = useSelector((state: StateModel) => Object.values(allChannelsConnected(state))); + const config = useSelector((state: StateModel) => state.data.config); const [displayDialogFromSource, setDisplayDialogFromSource] = useState(''); const OpenRequirementsDialog = ({source}: {source: string}): JSX.Element => { @@ -162,9 +161,10 @@ const MainPage = (props: MainPageProps & RouteComponentProps) => { }; const channelsBySource = (Source: Source) => channels.filter((channel: Channel) => channel.source === Source); + const navigate = useNavigate(); return ( - <> +

    Channels

    @@ -184,7 +184,7 @@ const MainPage = (props: MainPageProps & RouteComponentProps) => { displayButton={!channelsBySource(infoItem.type).length} addChannelAction={() => { if (config.components[infoItem.configKey] && config.components[infoItem.configKey].enabled) { - props.history.push(infoItem.newChannelRoute); + navigate(infoItem.newChannelRoute); } else { setDisplayDialogFromSource(infoItem.type); } @@ -194,8 +194,8 @@ const MainPage = (props: MainPageProps & RouteComponentProps) => {
    ))}
    - +
    ); }; -export default withRouter(MainPage); +export default MainPage; diff --git a/frontend/ui/src/pages/Channels/Providers/Airy/ChatPlugin/ChatPluginConnect.tsx b/frontend/ui/src/pages/Channels/Providers/Airy/ChatPlugin/ChatPluginConnect.tsx index 6c3a9fc7d2..e7d0598ff6 100644 --- a/frontend/ui/src/pages/Channels/Providers/Airy/ChatPlugin/ChatPluginConnect.tsx +++ b/frontend/ui/src/pages/Channels/Providers/Airy/ChatPlugin/ChatPluginConnect.tsx @@ -1,11 +1,11 @@ import React from 'react'; import _, {connect, ConnectedProps} from 'react-redux'; -import {withRouter, RouteComponentProps, Link} from 'react-router-dom'; +import {Link, useNavigate, useParams} from 'react-router-dom'; -import {env} from '../../../../../env'; +import {apiHostUrl} from '../../../../../httpClient'; import {StateModel} from '../../../../../reducers'; import {allChannels} from '../../../../../selectors/channels'; -import {connectChatPlugin, updateChannel, disconnectChannel} from '../../../../../actions/channel'; +import {connectChatPlugin, updateChannel, disconnectChannel} from '../../../../../actions'; import {Button, LinkButton, InfoButton} from 'components'; import {Channel} from 'model'; @@ -33,14 +33,9 @@ const mapStateToProps = (state: StateModel) => ({ const connector = connect(mapStateToProps, mapDispatchToProps); -interface ChatPluginRouterProps { - channelId?: string; -} - -type ChatPluginProps = {} & ConnectedProps & RouteComponentProps; - -const ChatPluginConnect = (props: ChatPluginProps) => { - const channelId = props.match.params.channelId; +const ChatPluginConnect = (props: ConnectedProps) => { + const {channelId} = useParams(); + const navigate = useNavigate(); const createNewConnection = (displayName: string, imageUrl?: string) => { props @@ -51,13 +46,13 @@ const ChatPluginConnect = (props: ChatPluginProps) => { }), }) .then(() => { - props.history.replace(CHANNELS_CONNECTED_ROUTE + '/chatplugin'); + navigate(CHANNELS_CONNECTED_ROUTE + '/chatplugin', {replace: true}); }); }; const updateConnection = (displayName: string, imageUrl?: string) => { props.updateChannel({channelId: channelId, name: displayName, imageUrl: imageUrl}).then(() => { - props.history.replace(CHANNELS_CONNECTED_ROUTE + '/chatplugin'); + navigate(CHANNELS_CONNECTED_ROUTE + '/chatplugin', {replace: true}); }); }; @@ -67,9 +62,7 @@ const ChatPluginConnect = (props: ChatPluginProps) => { } }; - const openNewPage = () => { - props.history.push(CHANNELS_CHAT_PLUGIN_ROUTE + '/new'); - }; + const openNewPage = () => navigate(CHANNELS_CHAT_PLUGIN_ROUTE + '/new'); const OverviewSection = () => (
    @@ -112,7 +105,7 @@ const ChatPluginConnect = (props: ChatPluginProps) => { } if (channelId?.length > 0) { const channel = props.channels.find((channel: Channel) => channel.id === channelId); - return ; + return ; } return ; }; @@ -135,7 +128,7 @@ const ChatPluginConnect = (props: ChatPluginProps) => { text="more information about this source" color="grey" /> - + navigate(-1)} type="button"> Back @@ -145,4 +138,4 @@ const ChatPluginConnect = (props: ChatPluginProps) => { ); }; -export default connector(withRouter(ChatPluginConnect)); +export default connector(ChatPluginConnect); diff --git a/frontend/ui/src/pages/Channels/Providers/Airy/ChatPlugin/sections/CustomiseSection.module.scss b/frontend/ui/src/pages/Channels/Providers/Airy/ChatPlugin/sections/CustomiseSection.module.scss index 959b0f0b2e..d62a52ba9c 100644 --- a/frontend/ui/src/pages/Channels/Providers/Airy/ChatPlugin/sections/CustomiseSection.module.scss +++ b/frontend/ui/src/pages/Channels/Providers/Airy/ChatPlugin/sections/CustomiseSection.module.scss @@ -77,3 +77,7 @@ right: 0px; position: absolute; } + +.copyButtonHostName { + display: flex; +} diff --git a/frontend/ui/src/pages/Channels/Providers/Airy/ChatPlugin/sections/CustomiseSection.tsx b/frontend/ui/src/pages/Channels/Providers/Airy/ChatPlugin/sections/CustomiseSection.tsx index dba24fd1fe..df9eae3180 100644 --- a/frontend/ui/src/pages/Channels/Providers/Airy/ChatPlugin/sections/CustomiseSection.tsx +++ b/frontend/ui/src/pages/Channels/Providers/Airy/ChatPlugin/sections/CustomiseSection.tsx @@ -1,5 +1,5 @@ import React, {createRef, useEffect} from 'react'; -import {Button, Dropdown, Input, ListenOutsideClick, Toggle} from 'components'; +import {Button, Dropdown, ErrorNotice, Input, ListenOutsideClick, Toggle} from 'components'; import styles from './CustomiseSection.module.scss'; import {SketchPicker} from 'react-color'; import {AiryChatPlugin, AiryChatPluginConfiguration} from 'chat-plugin'; @@ -24,6 +24,7 @@ interface CustomiseSectionProps { export const CustomiseSection = ({channelId, host}: CustomiseSectionProps) => { const useLocalState = getUseLocalState(channelId); + const [customHost, setCustomHost] = useLocalState('customHost', host); const [headerText, setHeaderText] = useLocalState('headerText', ''); const [subtitleText, setSubtitleText] = useLocalState('subTitleText', ''); const [startNewConversationText, setStartNewConversationText] = useLocalState('startNewConversationText', ''); @@ -202,7 +203,6 @@ export const CustomiseSection = ({channelId, host}: CustomiseSectionProps) => { ...(hideFiles && {hideFiles: hideFiles}), }, }; - const copyToClipboard = () => { codeAreaRef.current?.select(); document.execCommand('copy'); @@ -213,7 +213,7 @@ export const CustomiseSection = ({channelId, host}: CustomiseSectionProps) => { (function(w, d, s, n) { w[n] = w[n] || {}; w[n].channelId = '${channelId}'; - w[n].host = '${host}'; + w[n].host = '${customHost}'; ${getTemplateConfig()} var f = d.getElementsByTagName(s)[0], j = d.createElement(s); @@ -232,7 +232,16 @@ export const CustomiseSection = ({channelId, host}: CustomiseSectionProps) => {