From bce6e923566de2d7e7149f2d7f224e6cd1cf84fe Mon Sep 17 00:00:00 2001 From: Christoph Proeschel Date: Tue, 15 Dec 2020 15:43:02 +0100 Subject: [PATCH 01/27] [#499] Future-proof metadata model (#514) --- backend/BUILD | 2 +- .../ConversationsController.java | 42 +++++++++------ .../api/communication/MetadataController.java | 34 +++++------- .../airy/core/api/communication/Stores.java | 33 +++++++----- .../communication/util/TestConversation.java | 13 ++--- backend/avro/communication/BUILD | 16 +++--- .../{metadata-action.avsc => metadata.avsc} | 5 +- .../airy/avro/communication/MetadataKeys.java | 0 .../avro/communication/MetadataMapper.java | 0 .../communication/MetadataRepository.java | 52 +++++++++++++++++++ .../co/airy/avro/communication/Subject.java | 20 +++++++ .../core/sources/google/GoogleWebhook.java | 3 +- docs/docs/guides/airy-core-in-test-env.md | 6 +-- frontend/demo/src/App.tsx | 4 +- 14 files changed, 153 insertions(+), 77 deletions(-) rename backend/avro/communication/{metadata-action.avsc => metadata.avsc} (55%) rename backend/avro/communication/{metadata-action => metadata}/src/main/java/co/airy/avro/communication/MetadataKeys.java (100%) rename backend/avro/communication/{metadata-action => metadata}/src/main/java/co/airy/avro/communication/MetadataMapper.java (100%) create mode 100644 backend/avro/communication/metadata/src/main/java/co/airy/avro/communication/MetadataRepository.java create mode 100644 backend/avro/communication/metadata/src/main/java/co/airy/avro/communication/Subject.java diff --git a/backend/BUILD b/backend/BUILD index a55d6f2287..fe7523b97f 100644 --- a/backend/BUILD +++ b/backend/BUILD @@ -42,7 +42,7 @@ java_library( java_library( name = "metadata", exports = [ - "//backend/avro/communication:metadata-action", + "//backend/avro/communication:metadata", "//lib/java/kafka/schema:application-communication-metadata", ], ) 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 f72eb53dcc..07629024b9 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 @@ -1,9 +1,9 @@ package co.airy.core.api.communication; -import co.airy.avro.communication.MetadataAction; -import co.airy.avro.communication.MetadataActionType; +import co.airy.avro.communication.Metadata; import co.airy.avro.communication.MetadataKeys; import co.airy.avro.communication.ReadReceipt; +import co.airy.avro.communication.Subject; import co.airy.core.api.communication.dto.Conversation; import co.airy.core.api.communication.dto.ConversationIndex; import co.airy.core.api.communication.dto.LuceneQueryResult; @@ -34,6 +34,7 @@ import java.util.ArrayList; import java.util.List; +import static co.airy.avro.communication.MetadataRepository.newConversationTag; import static java.util.Comparator.comparing; import static java.util.stream.Collectors.toList; @@ -57,7 +58,7 @@ ResponseEntity conversationList(@RequestBody @Valid ConversationListRequestPa return queryConversations(requestPayload); } - private ResponseEntity queryConversations(ConversationListRequestPayload requestPayload) throws Exception { + private ResponseEntity queryConversations(ConversationListRequestPayload requestPayload) { final ReadOnlyLuceneStore conversationLuceneStore = stores.getConversationLuceneStore(); final ReadOnlyKeyValueStore conversationsStore = stores.getConversationsStore(); @@ -179,15 +180,28 @@ ResponseEntity conversationMarkRead(@RequestBody @Valid ConversationByIdReque @PostMapping("/conversations.tag") ResponseEntity conversationTag(@RequestBody @Valid ConversationTagRequestPayload requestPayload) { - return setConversationTag(requestPayload, MetadataActionType.SET); + final String conversationId = requestPayload.getConversationId().toString(); + final String tagId = requestPayload.getTagId().toString(); + final ReadOnlyKeyValueStore store = stores.getConversationsStore(); + final Conversation conversation = store.get(conversationId); + + if (conversation == null) { + return ResponseEntity.notFound().build(); + } + + final Metadata metadata = newConversationTag(conversationId, tagId); + + try { + stores.storeMetadata(metadata); + } catch (Exception e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new RequestErrorResponsePayload(e.getMessage())); + } + + return ResponseEntity.accepted().build(); } @PostMapping("/conversations.untag") ResponseEntity conversationUntag(@RequestBody @Valid ConversationTagRequestPayload requestPayload) { - return setConversationTag(requestPayload, MetadataActionType.REMOVE); - } - - private ResponseEntity setConversationTag(ConversationTagRequestPayload requestPayload, MetadataActionType actionType) { final String conversationId = requestPayload.getConversationId().toString(); final String tagId = requestPayload.getTagId().toString(); final ReadOnlyKeyValueStore store = stores.getConversationsStore(); @@ -197,16 +211,10 @@ private ResponseEntity setConversationTag(ConversationTagRequestPayload reque return ResponseEntity.notFound().build(); } - final MetadataAction metadataAction = MetadataAction.newBuilder() - .setActionType(actionType) - .setTimestamp(Instant.now().toEpochMilli()) - .setConversationId(conversationId) - .setValue("") - .setKey(String.format("%s.%s", MetadataKeys.TAGS, tagId)) - .build(); - try { - stores.storeMetadata(metadataAction); + final Subject subject = new Subject("conversation", conversationId); + final String metadataKey = String.format("%s.%s", MetadataKeys.TAGS, tagId); + stores.deleteMetadata(subject, metadataKey); } catch (Exception e) { return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new RequestErrorResponsePayload(e.getMessage())); } diff --git a/backend/api/communication/src/main/java/co/airy/core/api/communication/MetadataController.java b/backend/api/communication/src/main/java/co/airy/core/api/communication/MetadataController.java index de36a1d794..464c0a52b9 100644 --- a/backend/api/communication/src/main/java/co/airy/core/api/communication/MetadataController.java +++ b/backend/api/communication/src/main/java/co/airy/core/api/communication/MetadataController.java @@ -1,7 +1,7 @@ package co.airy.core.api.communication; -import co.airy.avro.communication.MetadataAction; -import co.airy.avro.communication.MetadataActionType; +import co.airy.avro.communication.Metadata; +import co.airy.avro.communication.Subject; import co.airy.core.api.communication.payload.RemoveMetadataRequestPayload; import co.airy.core.api.communication.payload.SetMetadataRequestPayload; import co.airy.payload.response.EmptyResponsePayload; @@ -13,9 +13,9 @@ import org.springframework.web.bind.annotation.RestController; import javax.validation.Valid; -import java.time.Instant; import static co.airy.avro.communication.MetadataKeys.PUBLIC; +import static co.airy.avro.communication.MetadataRepository.newConversationMetadata; @RestController public class MetadataController { @@ -26,16 +26,12 @@ public MetadataController(Stores stores) { } @PostMapping("/metadata.set") - ResponseEntity setMetadata(@RequestBody @Valid SetMetadataRequestPayload setMetadataRequestPayload) { - final MetadataAction metadataAction = MetadataAction.newBuilder() - .setActionType(MetadataActionType.SET) - .setTimestamp(Instant.now().toEpochMilli()) - .setConversationId(setMetadataRequestPayload.getConversationId()) - .setValue(setMetadataRequestPayload.getValue()) - .setKey(PUBLIC + "." + setMetadataRequestPayload.getKey()) - .build(); + ResponseEntity setMetadata(@RequestBody @Valid SetMetadataRequestPayload requestPayload) { + final Metadata metadata = newConversationMetadata(requestPayload.getConversationId(), + PUBLIC + "." + requestPayload.getKey(), + requestPayload.getValue()); try { - stores.storeMetadata(metadataAction); + stores.storeMetadata(metadata); } catch (Exception e) { return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new RequestErrorResponsePayload(e.getMessage())); } @@ -43,16 +39,12 @@ ResponseEntity setMetadata(@RequestBody @Valid SetMetadataRequestPayload setM } @PostMapping("/metadata.remove") - ResponseEntity removeMetadata(@RequestBody @Valid RemoveMetadataRequestPayload removeMetadataRequestPayload) { - final MetadataAction metadataAction = MetadataAction.newBuilder() - .setActionType(MetadataActionType.REMOVE) - .setTimestamp(Instant.now().toEpochMilli()) - .setConversationId(removeMetadataRequestPayload.getConversationId()) - .setKey(PUBLIC + "." + removeMetadataRequestPayload.getKey()) - .setValue("") - .build(); + ResponseEntity removeMetadata(@RequestBody @Valid RemoveMetadataRequestPayload requestPayload) { + final Subject subject = new Subject("conversation", requestPayload.getConversationId()); + final String metadataKey = PUBLIC + "." + requestPayload.getKey(); + try { - stores.storeMetadata(metadataAction); + stores.deleteMetadata(subject, metadataKey); } catch (Exception e) { return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new RequestErrorResponsePayload(e.getMessage())); } 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 e2c7357142..43351abc14 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 @@ -2,10 +2,10 @@ import co.airy.avro.communication.Channel; import co.airy.avro.communication.Message; -import co.airy.avro.communication.MetadataAction; -import co.airy.avro.communication.MetadataActionType; +import co.airy.avro.communication.Metadata; import co.airy.avro.communication.ReadReceipt; import co.airy.avro.communication.SenderType; +import co.airy.avro.communication.Subject; import co.airy.core.api.communication.dto.Conversation; import co.airy.core.api.communication.dto.CountAction; import co.airy.core.api.communication.dto.MessagesTreeSet; @@ -22,6 +22,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.KeyValue; import org.apache.kafka.streams.StreamsBuilder; import org.apache.kafka.streams.kstream.KGroupedStream; import org.apache.kafka.streams.kstream.KStream; @@ -44,6 +45,9 @@ import java.util.Map; import java.util.concurrent.ExecutionException; +import static co.airy.avro.communication.MetadataRepository.getId; +import static co.airy.avro.communication.MetadataRepository.getSubject; +import static co.airy.avro.communication.MetadataRepository.isConversationMetadata; import static java.util.stream.Collectors.toCollection; @Component @@ -86,15 +90,14 @@ private void startStream() { .peek((channelId, channel) -> webSocketController.onChannelUpdate(channel)) .toTable(); - final KTable> metadataTable = builder.stream(applicationCommunicationMetadata) - .groupByKey() - .aggregate(HashMap::new, (conversationId, metadataAction, aggregate) -> { - if (metadataAction.getActionType().equals(MetadataActionType.SET)) { - aggregate.put(metadataAction.getKey(), metadataAction.getValue()); - } else { - aggregate.remove(metadataAction.getKey()); - } - + final KTable> metadataTable = builder.table(applicationCommunicationMetadata) + .filter((metadataId, metadata) -> isConversationMetadata(metadata)) + .groupBy((metadataId, metadata) -> KeyValue.pair(getSubject(metadata).getIdentifier(), metadata)) + .aggregate(HashMap::new, (conversationId, metadata, aggregate) -> { + aggregate.put(metadata.getKey(), metadata.getValue()); + return aggregate; + }, (conversationId, metadata, aggregate) -> { + aggregate.remove(metadata.getKey()); return aggregate; }); @@ -192,8 +195,12 @@ public void storeReadReceipt(ReadReceipt readReceipt) throws ExecutionException, producer.send(new ProducerRecord<>(applicationCommunicationReadReceipts, readReceipt.getConversationId(), readReceipt)).get(); } - public void storeMetadata(MetadataAction metadataAction) throws ExecutionException, InterruptedException { - producer.send(new ProducerRecord<>(applicationCommunicationMetadata, metadataAction.getConversationId(), metadataAction)).get(); + public void storeMetadata(Metadata metadata) throws ExecutionException, InterruptedException { + producer.send(new ProducerRecord<>(applicationCommunicationMetadata, getId(metadata).toString(), metadata)).get(); + } + + public void deleteMetadata(Subject subject, String key) throws ExecutionException, InterruptedException { + producer.send(new ProducerRecord<>(applicationCommunicationMetadata, getId(subject, key).toString(), null)).get(); } public List getMessages(String conversationId) { 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 14b6530eb6..93b2224f9e 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 @@ -3,8 +3,7 @@ import co.airy.avro.communication.Channel; import co.airy.avro.communication.DeliveryState; import co.airy.avro.communication.Message; -import co.airy.avro.communication.MetadataAction; -import co.airy.avro.communication.MetadataActionType; +import co.airy.avro.communication.Metadata; import co.airy.avro.communication.SenderType; import co.airy.kafka.schema.application.ApplicationCommunicationMessages; import co.airy.kafka.schema.application.ApplicationCommunicationMetadata; @@ -21,6 +20,8 @@ import java.util.Random; import java.util.UUID; +import static co.airy.avro.communication.MetadataRepository.newConversationMetadata; + @Data @NoArgsConstructor public class TestConversation { @@ -67,13 +68,7 @@ private List> generateRecords() { if (metadata != null) { metadata.forEach((metadataKey, metadataValue) -> records.add(new ProducerRecord<>(applicationCommunicationMetadata, conversationId, - MetadataAction.newBuilder() - .setKey(metadataKey) - .setValue(metadataValue) - .setConversationId(conversationId) - .setActionType(MetadataActionType.SET) - .setTimestamp(Instant.now().toEpochMilli()) - .build() + newConversationMetadata(conversationId, metadataKey, metadataValue) ))); } diff --git a/backend/avro/communication/BUILD b/backend/avro/communication/BUILD index 67ab81f543..08d465e0ea 100644 --- a/backend/avro/communication/BUILD +++ b/backend/avro/communication/BUILD @@ -29,15 +29,19 @@ avro_java_library( ) custom_java_library( - name = "metadata-action", - srcs = glob(["metadata-action/src/main/**/*.java"]), - exports = [":metadata-action-avro"], - deps = [":metadata-action-avro"], + name = "metadata", + srcs = glob(["metadata/src/main/**/*.java"]), + exports = [":metadata-avro"], + deps = [ + ":metadata-avro", + "//:lombok", + "//lib/java/uuid", + ], ) avro_java_library( - name = "metadata-action-avro", - srcs = ["metadata-action.avsc"], + name = "metadata-avro", + srcs = ["metadata.avsc"], visibility = ["//visibility:private"], ) diff --git a/backend/avro/communication/metadata-action.avsc b/backend/avro/communication/metadata.avsc similarity index 55% rename from backend/avro/communication/metadata-action.avsc rename to backend/avro/communication/metadata.avsc index 60aab9b926..86d73cd134 100644 --- a/backend/avro/communication/metadata-action.avsc +++ b/backend/avro/communication/metadata.avsc @@ -1,12 +1,11 @@ { "namespace": "co.airy.avro.communication", - "name": "MetadataAction", + "name": "Metadata", "type": "record", "fields": [ - {"name": "conversationId", "type": "string"}, + {"name": "subject", "type": "string"}, {"name": "key", "type": "string"}, {"name": "value", "type": "string"}, - {"name": "actionType", "type": {"name": "MetadataActionType", "type": "enum", "symbols": ["SET", "REMOVE"]}}, {"name": "timestamp", "type": "long", "logicalType": "timestamp-millis"} ] } diff --git a/backend/avro/communication/metadata-action/src/main/java/co/airy/avro/communication/MetadataKeys.java b/backend/avro/communication/metadata/src/main/java/co/airy/avro/communication/MetadataKeys.java similarity index 100% rename from backend/avro/communication/metadata-action/src/main/java/co/airy/avro/communication/MetadataKeys.java rename to backend/avro/communication/metadata/src/main/java/co/airy/avro/communication/MetadataKeys.java diff --git a/backend/avro/communication/metadata-action/src/main/java/co/airy/avro/communication/MetadataMapper.java b/backend/avro/communication/metadata/src/main/java/co/airy/avro/communication/MetadataMapper.java similarity index 100% rename from backend/avro/communication/metadata-action/src/main/java/co/airy/avro/communication/MetadataMapper.java rename to backend/avro/communication/metadata/src/main/java/co/airy/avro/communication/MetadataMapper.java diff --git a/backend/avro/communication/metadata/src/main/java/co/airy/avro/communication/MetadataRepository.java b/backend/avro/communication/metadata/src/main/java/co/airy/avro/communication/MetadataRepository.java new file mode 100644 index 0000000000..e53292519c --- /dev/null +++ b/backend/avro/communication/metadata/src/main/java/co/airy/avro/communication/MetadataRepository.java @@ -0,0 +1,52 @@ +package co.airy.avro.communication; + +import co.airy.uuid.UUIDv5; + +import java.time.Instant; +import java.util.UUID; + +public class MetadataRepository { + public static Metadata newConversationMetadata(String conversationId, String key, String value) { + return Metadata.newBuilder() + .setSubject(new Subject("conversation",conversationId).toString()) + .setKey(key) + .setValue(value) + .setTimestamp(Instant.now().toEpochMilli()) + .build(); + } + + public static boolean isConversationMetadata(Metadata metadata) { + return metadata.getSubject().startsWith("conversation:"); + } + + public static Metadata newConversationTag(String conversationId, String tagId) { + return Metadata.newBuilder() + .setSubject(new Subject("conversation",conversationId).toString()) + .setKey(String.format("%s.%s", MetadataKeys.TAGS, tagId)) + .setValue("") + .setTimestamp(Instant.now().toEpochMilli()) + .build(); + } + + public static Subject getSubject(Metadata metadata) { + final String subjectString = metadata.getSubject(); + int lastIndexOf = subjectString.lastIndexOf(":"); + + // You do not have to pass an identifier if the namespace you want to + // use metadata for consists of a single object + if (lastIndexOf == -1) { + return new Subject(subjectString, null); + } + + String namespace = subjectString.substring(0, lastIndexOf); + String identifier = subjectString.substring(lastIndexOf + 1); + return new Subject(namespace, identifier); + } + + public static UUID getId(Metadata metadata) { + return UUIDv5.fromNamespaceAndName(metadata.getSubject(), metadata.getKey()); + } + public static UUID getId(Subject subject, String key) { + return UUIDv5.fromNamespaceAndName(subject.toString(), key); + } +} diff --git a/backend/avro/communication/metadata/src/main/java/co/airy/avro/communication/Subject.java b/backend/avro/communication/metadata/src/main/java/co/airy/avro/communication/Subject.java new file mode 100644 index 0000000000..5c3bd12d48 --- /dev/null +++ b/backend/avro/communication/metadata/src/main/java/co/airy/avro/communication/Subject.java @@ -0,0 +1,20 @@ +package co.airy.avro.communication; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NonNull; + +@Data +@AllArgsConstructor +public class Subject { + @NonNull + private final String namespace; + private final String identifier; + + public String toString() { + if (identifier == null) { + return namespace; + } + return String.format("%s:%s", namespace, identifier); + } +} diff --git a/backend/sources/google/webhook/src/main/java/co/airy/core/sources/google/GoogleWebhook.java b/backend/sources/google/webhook/src/main/java/co/airy/core/sources/google/GoogleWebhook.java index dea4af3ede..81a12c6907 100644 --- a/backend/sources/google/webhook/src/main/java/co/airy/core/sources/google/GoogleWebhook.java +++ b/backend/sources/google/webhook/src/main/java/co/airy/core/sources/google/GoogleWebhook.java @@ -73,7 +73,7 @@ public Health health() { } @PostMapping("/google") - ResponseEntity accept(@RequestBody String event, @RequestHeader("X-Goog-Signature") String signature) throws NoSuchAlgorithmException, InvalidKeyException { + ResponseEntity accept(@RequestBody String event, @RequestHeader("X-Goog-Signature") String signature) { if(!validRequest(event, signature)) { return ResponseEntity.status(HttpStatus.FORBIDDEN).body(new EmptyResponsePayload()); } @@ -86,7 +86,6 @@ ResponseEntity accept(@RequestBody String event, @RequestHeader("X-Goog-Signa } catch (Exception e) { return new ResponseEntity<>(HttpStatus.SERVICE_UNAVAILABLE); } - } private boolean validRequest(String payload, String signature) { diff --git a/docs/docs/guides/airy-core-in-test-env.md b/docs/docs/guides/airy-core-in-test-env.md index 6d9f2b1ebb..679d505a28 100644 --- a/docs/docs/guides/airy-core-in-test-env.md +++ b/docs/docs/guides/airy-core-in-test-env.md @@ -115,7 +115,7 @@ then provides a reversed proxy connectivity back to the webhook services, running inside the Kubernetes cluster. By default, the ngrok client is configured to use the ngrok server created by -Airy and running on https://tunnel.airy.co. This configuration is specified in +Airy and run on https://tunnel.airy.co. This configuration is specified in the `ngrok-client-config` ConfigMap. ``` @@ -183,8 +183,8 @@ vagrant destroy ## Known Issues -If you have just installed Virtualbox and see this error during the bootstrap -you should [give Virtualbox +If you have just installed VirtualBox and see this error during the bootstrap +you should [give VirtualBox permissions](https://www.howtogeek.com/658047/how-to-fix-virtualboxs-%E2%80%9Ckernel-driver-not-installed-rc-1908-error/). ``` diff --git a/frontend/demo/src/App.tsx b/frontend/demo/src/App.tsx index 19f6d9cbbf..6e1c8755eb 100644 --- a/frontend/demo/src/App.tsx +++ b/frontend/demo/src/App.tsx @@ -31,7 +31,7 @@ class App extends Component & RouteComponentPro return this.props.user.token && this.props.user.token !== ''; } - shouldShowSidebar = (path: string) => { + shouldShowSidebar = () => { return this.isAuthSuccess; }; @@ -47,7 +47,7 @@ class App extends Component & RouteComponentPro return (
- {this.shouldShowSidebar(this.props.location.pathname) ? ( + {this.shouldShowSidebar() ? ( <> From 5dc964a46867f00c55275d7b18e66ce2bc08866e Mon Sep 17 00:00:00 2001 From: Kazeem Adetunji Date: Wed, 16 Dec 2020 14:04:01 +0100 Subject: [PATCH 02/27] Doc/489 how to run the frontend (#518) * [#489]-how-to-run-frontend * Update README.md * Update README.md * Airy logo in documentation resized * Npm badge added to documentation * Hyperlink added to airy logo * Update README.md Changed sentence structure * Typo fixed * Frontend documentation updated * [489] how to run frontend * Typo fixed * Sentence restructured * Sentence restructured * [#489] how to run frontend * [#489] how to run frontend * Requested changes implemented --- frontend/README.md | 34 ++++++----------- frontend/assets/airy_demo_login.png | Bin 0 -> 119770 bytes frontend/assets/airy_primary_rgb.svg | 1 + frontend/demo/README.md | 55 ++++++++++++++++++++++++++- 4 files changed, 65 insertions(+), 25 deletions(-) create mode 100644 frontend/assets/airy_demo_login.png create mode 100644 frontend/assets/airy_primary_rgb.svg diff --git a/frontend/README.md b/frontend/README.md index 40bc51d5d8..87dc722d22 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,32 +1,20 @@ +

+ + Airy Logo + +

+ # Frontend -The `frontend` top-level folder of the Airy Core Platform -[monorepo](https://en.wikipedia.org/wiki/Monorepo) contains the UI related code -of the platform. +The Frontend section of the Airy core platform contains the user interaction(UI) related code. Here is a quick introduction to the frontend projects: -- `components` - - Here you can find the [Airy - Components Library](https://www.npmjs.com/package/@airyhq/components library. We are open-sourcing - (it's a work in progress) the critical components necessary to build a - messaging application. We use this library to power our commercial offering. - -- `showcase` - - This directory contains a small React application that showcases the - components of the [Airy Components - Library](https://www.npmjs.com/package/@airyhq/components), we host the `main` - branch version at [https://components.airy.co](https://components.airy.co). -- `demo` +- `Demo` - This project is a minimum UI implementation of the provided [Airy Core Platform API](https://docs.airy.co/api/http). - Unlike `showcase` it does not use the `npm` version of the `components` library, but instead uses the - local repository version. + The [Demo project](https://github.com/airyhq/airy/tree/develop/frontend/demo) is a minimum UI implementation of the provided [Airy Core Platform API](https://docs.airy.co/api/http). -- `chat_plugin` +- `Chat_Plugin` - This project is implements a UI for the [Chat Plugin Source](https://docs.airy.co/sources/chat-plugin) that can - be installed as Javascript tag on websites. + This project is implements a UI for the [Chat Plugin Source](https://docs.airy.co/sources/chat-plugin) that can be installed as Javascript tag on websites. diff --git a/frontend/assets/airy_demo_login.png b/frontend/assets/airy_demo_login.png new file mode 100644 index 0000000000000000000000000000000000000000..7a92c0752bf10c280b0fd7316bd457b8bb945845 GIT binary patch literal 119770 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
iCZq$HEI~%E{VidO_k@7 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 literal 0 HcmV?d00001 diff --git a/frontend/assets/airy_primary_rgb.svg b/frontend/assets/airy_primary_rgb.svg new file mode 100644 index 0000000000..2d6ca166ca --- /dev/null +++ b/frontend/assets/airy_primary_rgb.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/demo/README.md b/frontend/demo/README.md index 859423c7a9..56918800cb 100644 --- a/frontend/demo/README.md +++ b/frontend/demo/README.md @@ -1,3 +1,54 @@ -# Airy Demo UI +

+ Airy Login + +

+ + +### Airy Demo UI + +The Airy Demo UI is a minimal user interactive frontend project that showcases the Airy Core Platform API.It enables users to experience the functionalities of our Airy Core Platform. + + +- [Prerequities](#prerequities) +- [Building Airy Demo UI](#building-airy-demo-ui) +- [Installation](#installation) +- [Authentication](#authentication) +- [Endpoints](#endpoints) +- [How to contribute](#how-to-contribute) +- [Code of Conduct](#code-of-conduct) + +### Prerequisites + +* [Node.js](https://nodejs.org/) version 10 or newer +* [Git](https://www.atlassian.com/git/tutorials/install-git/) for your platform +* [Bazel](https://docs.bazel.build/versions/3.7.0/install.html) for building and testing the app + + +### Building Airy Demo UI + +You can run the Airy Demo UI locally by running the following commands: + +``` +$ git clone https://github.com/airyhq/airy +$ cd airy +$ AIRY_VERSION=beta ./scripts/bootstrap.sh (Takes a few minutes) +``` +When the bootstrap process finishes, open another terminal and run ``` $ ibazel run //frontend/demo:bundle_server ``` +Then open `http://localhost:8080/` in a web browser to access the Airy Demo UI + +### Installation +The bootstrap installation requires [Vagrant](https://www.vagrantup.com/downloads) and [VirtualBox](https://www.virtualbox.org/wiki/Downloads). If they are not +found, the script ```$ AIRY_VERSION=beta ./scripts/bootstrap.sh``` will attempt to install them for you. Check out our [test deployment guide](/docs/docs/guides/airy-core-in-test-env.md) for detailed information. + +### Authentication + +In order to communicate with our API endpoints, you need a valid [JWT](https://jwt.io/) token. To get a valid token you first need to signup using the signup [endpoint](#endpoints) and then login using the login [endpoint](#endpoints). + +### Endpoints + To communicate with our signup endpoint and register your email, open another terminal and type in the terminal ```curl -X POST -H 'Content-Type: application/json' -d '{"first_name": "your_name","last_name": "your_last_name","password": "your_password","email": "your_email@airy.co"}' http://api.airy/users.signup``` + + To sign in, type in the terminal ```token=$(echo $(curl -H 'Content-Type: application/json' -d \"{ \\"email\":\"your_email@airy.co\",\\"password\":\"your_last_name\" \}" api.airy/users.login) | jq -r '.token')``` + +Aside from Curl, [PostMan](https://www.postman.com/downloads/) and other API testing tools could also be used to access the endpoints. + -This app demos a minimal frontend that showcases the Airy Core Platform API From 6ed669771e7e4868d50328e4ecb28001bc46efce Mon Sep 17 00:00:00 2001 From: Thorsten Date: Wed, 16 Dec 2020 14:11:18 +0100 Subject: [PATCH 03/27] [#464] Feature/add logout core (#519) --- frontend/demo/src/App.tsx | 5 +++-- frontend/demo/src/actions/user/index.ts | 15 +++++-------- frontend/demo/src/pages/Logout/index.tsx | 28 ++++++++++++++++++++++++ frontend/demo/src/reducers/index.ts | 6 +++-- frontend/demo/src/routes/routes.ts | 1 + 5 files changed, 42 insertions(+), 13 deletions(-) create mode 100644 frontend/demo/src/pages/Logout/index.tsx diff --git a/frontend/demo/src/App.tsx b/frontend/demo/src/App.tsx index 6e1c8755eb..e0d0fcbc3a 100644 --- a/frontend/demo/src/App.tsx +++ b/frontend/demo/src/App.tsx @@ -5,12 +5,12 @@ import {withRouter, Route, Switch, Redirect, RouteComponentProps} from 'react-ro import {AiryLoader} from '@airyhq/components'; import TopBar from './components/TopBar'; import Login from './pages/Login'; +import Logout from './pages/Logout'; import NotFound from './pages/NotFound'; import Sidebar from './components/Sidebar'; import {StateModel} from './reducers'; - -import {CHANNELS_ROUTE, LOGIN_ROUTE, ROOT_ROUTE, TAGS_ROUTE} from './routes/routes'; +import {CHANNELS_ROUTE, LOGIN_ROUTE, LOGOUT_ROUTE, ROOT_ROUTE, TAGS_ROUTE} from './routes/routes'; import {Tags} from './pages/Tags'; import Channels from './pages/Channels'; @@ -61,6 +61,7 @@ class App extends Component & RouteComponentPro + diff --git a/frontend/demo/src/actions/user/index.ts b/frontend/demo/src/actions/user/index.ts index 64639649b2..c68a2dc59a 100644 --- a/frontend/demo/src/actions/user/index.ts +++ b/frontend/demo/src/actions/user/index.ts @@ -10,17 +10,8 @@ const USER_AUTH_ERROR = '@@auth/ERROR'; const USER_LOGOUT = '@@auth/LOGOUT_USER'; export const setCurrentUserAction = createAction(SET_CURRENT_USER, resolve => (user: User) => resolve(user)); - export const userAuthErrorAction = createAction(USER_AUTH_ERROR, resolve => (error: Error) => resolve(error)); - export const logoutUserAction = createAction(USER_LOGOUT); - -export const logoutUser = () => { - return function(dispatch: Dispatch) { - dispatch(logoutUserAction()); - }; -}; - export interface LoginViaEmailRequestPayload { email: String; password: String; @@ -40,3 +31,9 @@ export function loginViaEmail(requestPayload: LoginViaEmailRequestPayload) { }); }; } + +export function logoutUser() { + return async (dispatch: Dispatch) => { + dispatch(logoutUserAction()); + }; +} diff --git a/frontend/demo/src/pages/Logout/index.tsx b/frontend/demo/src/pages/Logout/index.tsx new file mode 100644 index 0000000000..8b40997e0d --- /dev/null +++ b/frontend/demo/src/pages/Logout/index.tsx @@ -0,0 +1,28 @@ +import React, {useEffect} from 'react'; +import {connect} from 'react-redux'; +import {withRouter, RouteComponentProps} from 'react-router-dom'; + +import {Redirect} from 'react-router-dom'; +import {logoutUser} from '../../actions/user'; +import {LOGIN_ROUTE} from '../../routes/routes'; + +type LogoutConnectProps = { + history: History; + logoutUser: () => void; +}; + +const Logout = ({history, logoutUser}: LogoutConnectProps & RouteComponentProps) => { + useEffect(() => { + logoutUser(); + history.push(LOGIN_ROUTE); + }, []); + return ; +}; + +const mapDispatchToProps = { + logoutUser, +}; + +const connector = connect(null, mapDispatchToProps); + +export default withRouter(connector(Logout)); diff --git a/frontend/demo/src/reducers/index.ts b/frontend/demo/src/reducers/index.ts index 393e5fa276..cbf52e1269 100644 --- a/frontend/demo/src/reducers/index.ts +++ b/frontend/demo/src/reducers/index.ts @@ -1,5 +1,5 @@ import {combineReducers} from 'redux-starter-kit'; -import {getType} from 'typesafe-actions'; +import {ActionType, getType} from 'typesafe-actions'; import _, {CombinedState} from 'redux'; import * as authActions from '../actions/user'; @@ -7,6 +7,8 @@ import {clearUserData} from '../api/webStore'; import data, {DataState} from './data'; +type Action = ActionType; + export type StateModel = { data: DataState; }; @@ -17,7 +19,7 @@ const applicationReducer = combineReducers({ export type RootState = ReturnType; -const rootReducer: (state: any, action: any) => CombinedState = (state, action) => { +const rootReducer: (state: any, action: any) => CombinedState = (state, action: Action) => { if (action.type === getType(authActions.logoutUserAction)) { clearUserData(); return applicationReducer(undefined, action); diff --git a/frontend/demo/src/routes/routes.ts b/frontend/demo/src/routes/routes.ts index 261238730c..d455f39ba1 100644 --- a/frontend/demo/src/routes/routes.ts +++ b/frontend/demo/src/routes/routes.ts @@ -1,5 +1,6 @@ export const ROOT_ROUTE = '/'; export const LOGIN_ROUTE = '/login'; +export const LOGOUT_ROUTE = '/logout'; export const CHANNELS_ROUTE = '/channels'; export const TAGS_ROUTE = '/tags'; From b1365f5ca2f9ade138df85bbd1fcfc94fe3e3e8e Mon Sep 17 00:00:00 2001 From: Paulo Diniz Date: Wed, 16 Dec 2020 16:17:12 +0100 Subject: [PATCH 04/27] [#496] Changing content render api --- .../payload/MessageResponsePayload.java | 5 +- .../java/co/airy/core/chat_plugin/Mapper.java | 1 - .../payload/MessageResponsePayload.java | 3 +- .../core/chat_plugin/ChatControllerTest.java | 6 ++- .../co/airy/core/sources/twilio/Sender.java | 8 +++- .../airy/core/webhook/publisher/Mapper.java | 12 +++-- .../core/webhook/publisher/Publisher.java | 1 + docs/docs/api/http.md | 48 +++++++++++-------- .../java/co/airy/mapping/ContentMapper.java | 6 +-- .../java/co/airy/mapping/OutboundMapper.java | 6 ++- .../java/co/airy/mapping/SourceMapper.java | 2 +- .../java/co/airy/mapping/model/Image.java | 20 ++++++++ .../sources/facebook/FacebookMapper.java | 4 +- .../mapping/sources/google/GoogleMapper.java | 4 +- .../mapping/sources/twilio/TwilioMapper.java | 4 +- .../co/airy/mapping/ContentMapperTest.java | 2 +- .../java/co/airy/mapping/FacebookTest.java | 2 +- .../test/java/co/airy/mapping/GoogleTest.java | 2 +- .../test/java/co/airy/mapping/TwilioTest.java | 2 +- 19 files changed, 91 insertions(+), 47 deletions(-) create mode 100644 lib/java/mapping/src/main/java/co/airy/mapping/model/Image.java diff --git a/backend/api/communication/src/main/java/co/airy/core/api/communication/payload/MessageResponsePayload.java b/backend/api/communication/src/main/java/co/airy/core/api/communication/payload/MessageResponsePayload.java index 00e34b7ca9..806db54106 100644 --- a/backend/api/communication/src/main/java/co/airy/core/api/communication/payload/MessageResponsePayload.java +++ b/backend/api/communication/src/main/java/co/airy/core/api/communication/payload/MessageResponsePayload.java @@ -1,19 +1,20 @@ package co.airy.core.api.communication.payload; -import co.airy.avro.communication.SenderType; import co.airy.mapping.model.Content; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; +import java.util.List; + @Data @Builder @NoArgsConstructor @AllArgsConstructor public class MessageResponsePayload { private String id; - private Content content; + private List content; private String senderType; private String sentAt; private String deliveryState; diff --git a/backend/sources/chat-plugin/src/main/java/co/airy/core/chat_plugin/Mapper.java b/backend/sources/chat-plugin/src/main/java/co/airy/core/chat_plugin/Mapper.java index 637e7c80fc..dd4cf2b2d7 100644 --- a/backend/sources/chat-plugin/src/main/java/co/airy/core/chat_plugin/Mapper.java +++ b/backend/sources/chat-plugin/src/main/java/co/airy/core/chat_plugin/Mapper.java @@ -1,7 +1,6 @@ package co.airy.core.chat_plugin; import co.airy.avro.communication.Message; -import co.airy.avro.communication.SenderType; import co.airy.core.chat_plugin.payload.MessageResponsePayload; import co.airy.mapping.ContentMapper; import org.springframework.stereotype.Component; diff --git a/backend/sources/chat-plugin/src/main/java/co/airy/core/chat_plugin/payload/MessageResponsePayload.java b/backend/sources/chat-plugin/src/main/java/co/airy/core/chat_plugin/payload/MessageResponsePayload.java index a7287e8f72..150e27f2e4 100644 --- a/backend/sources/chat-plugin/src/main/java/co/airy/core/chat_plugin/payload/MessageResponsePayload.java +++ b/backend/sources/chat-plugin/src/main/java/co/airy/core/chat_plugin/payload/MessageResponsePayload.java @@ -7,6 +7,7 @@ import lombok.NoArgsConstructor; import java.io.Serializable; +import java.util.List; @Data @Builder @@ -14,7 +15,7 @@ @AllArgsConstructor public class MessageResponsePayload implements Serializable { private String id; - private Content content; + private List content; private String state; private String senderType; private String sentAt; diff --git a/backend/sources/chat-plugin/src/test/java/co/airy/core/chat_plugin/ChatControllerTest.java b/backend/sources/chat-plugin/src/test/java/co/airy/core/chat_plugin/ChatControllerTest.java index 6a31b8214e..98154ebcdd 100644 --- a/backend/sources/chat-plugin/src/test/java/co/airy/core/chat_plugin/ChatControllerTest.java +++ b/backend/sources/chat-plugin/src/test/java/co/airy/core/chat_plugin/ChatControllerTest.java @@ -7,6 +7,7 @@ import co.airy.kafka.schema.application.ApplicationCommunicationMessages; import co.airy.kafka.test.KafkaTestHelper; import co.airy.kafka.test.junit.SharedKafkaTestResource; +import co.airy.mapping.model.Content; import co.airy.mapping.model.Text; import co.airy.spring.core.AirySpringBootApplication; import com.fasterxml.jackson.databind.JsonNode; @@ -130,14 +131,15 @@ void authenticateSendAndReceive() throws Exception { .headers(buildHeaders(token)) .content(sendMessagePayload)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.content.text", containsString(messageText))), + .andExpect(jsonPath("$.content[0].text", containsString(messageText))), "Message was not sent" ); final MessageUpsertPayload messageUpsertPayload = messageFuture.get(); assertNotNull(messageUpsertPayload); - assertThat(((Text) messageUpsertPayload.getMessage().getContent()).getText(), containsString(messageText)); + final Text text = (Text) messageUpsertPayload.getMessage().getContent().get(0); + assertThat(text.getText(), containsString(messageText)); } private HttpHeaders buildHeaders(String jwtToken) { diff --git a/backend/sources/twilio/sender/src/main/java/co/airy/core/sources/twilio/Sender.java b/backend/sources/twilio/sender/src/main/java/co/airy/core/sources/twilio/Sender.java index 3abff6f25d..6453068543 100644 --- a/backend/sources/twilio/sender/src/main/java/co/airy/core/sources/twilio/Sender.java +++ b/backend/sources/twilio/sender/src/main/java/co/airy/core/sources/twilio/Sender.java @@ -88,8 +88,12 @@ private Message sendMessage(SendMessageRequest sendMessageRequest) { try { // TODO Figure out how we can let clients know which outbound message types are supported - final Text content = (Text) mapper.render(message); - api.sendMessage(from, to, content.getText()); + final Text text = (Text) mapper.render(message) + .stream() + .filter(c -> c instanceof Text) + .findFirst() + .orElse(null); + api.sendMessage(from, to, text.getText()); updateDeliveryState(message, DeliveryState.DELIVERED); return message; diff --git a/backend/webhook/publisher/src/main/java/co/airy/core/webhook/publisher/Mapper.java b/backend/webhook/publisher/src/main/java/co/airy/core/webhook/publisher/Mapper.java index 3519f57731..bcde1920a3 100644 --- a/backend/webhook/publisher/src/main/java/co/airy/core/webhook/publisher/Mapper.java +++ b/backend/webhook/publisher/src/main/java/co/airy/core/webhook/publisher/Mapper.java @@ -8,6 +8,7 @@ import co.airy.mapping.model.Text; import org.springframework.stereotype.Component; +import java.util.List; import java.util.Map; import static co.airy.payload.format.DateFormat.isoFromMillis; @@ -21,16 +22,21 @@ public class Mapper { } public WebhookBody fromMessage(Message message) throws Exception { - final Content content = contentMapper.renderWithDefaultAndLog(message); + final List content = contentMapper.renderWithDefaultAndLog(message); - if (!(content instanceof Text)) { + final Text textContent = (Text) content.stream() + .filter(c -> (c instanceof Text)) + .findFirst() + .orElse(null); + + if (textContent == null) { throw new NotATextMessage(); } return WebhookBody.builder() .conversationId(message.getConversationId()) .id(message.getId()) - .text(((Text) content).getText()) + .text(textContent.getText()) .source(message.getSource()) .postback(buildPostback(message)) .sentAt(isoFromMillis(message.getSentAt())) diff --git a/backend/webhook/publisher/src/main/java/co/airy/core/webhook/publisher/Publisher.java b/backend/webhook/publisher/src/main/java/co/airy/core/webhook/publisher/Publisher.java index 60dcf0172d..6e654d5eef 100644 --- a/backend/webhook/publisher/src/main/java/co/airy/core/webhook/publisher/Publisher.java +++ b/backend/webhook/publisher/src/main/java/co/airy/core/webhook/publisher/Publisher.java @@ -5,6 +5,7 @@ import co.airy.avro.communication.Status; import co.airy.avro.communication.Webhook; import co.airy.core.webhook.publisher.model.QueueMessage; +import co.airy.core.webhook.publisher.model.WebhookBody; import co.airy.kafka.schema.application.ApplicationCommunicationMessages; import co.airy.kafka.schema.application.ApplicationCommunicationWebhooks; import co.airy.kafka.streams.KafkaStreamsWrapper; diff --git a/docs/docs/api/http.md b/docs/docs/api/http.md index c66f39d541..08eaa2f3f3 100644 --- a/docs/docs/api/http.md +++ b/docs/docs/api/http.md @@ -151,11 +151,13 @@ Find all users with the last name "Lovelace": "tags": ["f339c325-8614-43cb-a70a-e83d81bf56fc"], "last_message": { id: "{UUID}", - content: { - text: "{String}", - type: "text" - // Determines the schema of the content - }, + content: [ + { + text: "{String}", + type: "text" + // Determines the schema of the content + }, + ] // typed source message model state: "{String}", // delivery state of message, one of PENDING, FAILED, DELIVERED @@ -211,11 +213,13 @@ Find all users with the last name "Lovelace": "tags": ["f339c325-8614-43cb-a70a-e83d81bf56fc"], "last_message": { "id": "{UUID}", - "content": { - "text": "{String}", - "type": "text" - // Determines the schema of the content - }, + "content": [ + { + "text": "{String}", + "type": "text" + // Determines the schema of the content + } + ], // typed source message model "delivery_state": "{String}", // delivery state of message, one of PENDING, FAILED, DELIVERED @@ -316,11 +320,13 @@ This is a [paginated](#pagination) endpoint. Messages are sorted from oldest to "data": [ { "id": "{UUID}", - "content": { - "text": "{String}", - "type": "text" - // Determines the schema of the content - }, + "content": [ + { + "text": "{String}", + "type": "text" + // Determines the schema of the content + } + ], // typed source message model "state": "{String}", // delivery state of message, one of PENDING, FAILED, DELIVERED @@ -361,11 +367,13 @@ Sends a message to a conversation and returns a payload. ```json5 { "id": "{UUID}", - "content": { - "text": "{String}", - "type": "text" - // Determines the schema of the content - }, + "content": [ + { + "text": "{String}", + "type": "text" + // Determines the schema of the content + } + ], // typed source message model "state": "{String}", // delivery state of message, one of PENDING, FAILED, DELIVERED diff --git a/lib/java/mapping/src/main/java/co/airy/mapping/ContentMapper.java b/lib/java/mapping/src/main/java/co/airy/mapping/ContentMapper.java index b9941f327d..43256d4116 100644 --- a/lib/java/mapping/src/main/java/co/airy/mapping/ContentMapper.java +++ b/lib/java/mapping/src/main/java/co/airy/mapping/ContentMapper.java @@ -27,7 +27,7 @@ public ContentMapper(List sourceMappers, OutboundMapper outboundMa this.outboundMapper = outboundMapper; } - public Content render(Message message) throws Exception { + public List render(Message message) throws Exception { if (SenderType.APP_USER.equals(message.getSenderType()) || "chat_plugin".equals(message.getSource())) { return outboundMapper.render(message.getContent()); } @@ -40,12 +40,12 @@ public Content render(Message message) throws Exception { return sourceMapper.render(message.getContent()); } - public Content renderWithDefaultAndLog(Message message) { + public List renderWithDefaultAndLog(Message message) { try { return this.render(message); } catch (Exception e) { log.error("Failed to render message {}", message, e); - return new Text("This content cannot be displayed"); + return List.of(new Text("This content cannot be displayed")); } } } diff --git a/lib/java/mapping/src/main/java/co/airy/mapping/OutboundMapper.java b/lib/java/mapping/src/main/java/co/airy/mapping/OutboundMapper.java index 3332979b1a..ac3eb07efd 100644 --- a/lib/java/mapping/src/main/java/co/airy/mapping/OutboundMapper.java +++ b/lib/java/mapping/src/main/java/co/airy/mapping/OutboundMapper.java @@ -6,6 +6,8 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.stereotype.Component; +import java.util.List; + @Component public class OutboundMapper { @@ -15,8 +17,8 @@ public OutboundMapper() { this.objectMapper = new ObjectMapper(); } - public Content render(String payload) throws Exception { + public List render(String payload) throws Exception { final JsonNode jsonNode = objectMapper.readTree(payload); - return new Text(jsonNode.get("text").textValue()); + return List.of(new Text(jsonNode.get("text").textValue())); } } diff --git a/lib/java/mapping/src/main/java/co/airy/mapping/SourceMapper.java b/lib/java/mapping/src/main/java/co/airy/mapping/SourceMapper.java index 4e6f1d93d4..8484c3d5e0 100644 --- a/lib/java/mapping/src/main/java/co/airy/mapping/SourceMapper.java +++ b/lib/java/mapping/src/main/java/co/airy/mapping/SourceMapper.java @@ -7,5 +7,5 @@ public interface SourceMapper { List getIdentifiers(); - Content render(String payload) throws Exception; + List render(String payload) throws Exception; } diff --git a/lib/java/mapping/src/main/java/co/airy/mapping/model/Image.java b/lib/java/mapping/src/main/java/co/airy/mapping/model/Image.java new file mode 100644 index 0000000000..ac22624d0c --- /dev/null +++ b/lib/java/mapping/src/main/java/co/airy/mapping/model/Image.java @@ -0,0 +1,20 @@ +package co.airy.mapping.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import javax.validation.constraints.NotNull; +import java.io.Serializable; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = false) +public class Image extends Content implements Serializable { + @NotNull + private String url; +} diff --git a/lib/java/mapping/src/main/java/co/airy/mapping/sources/facebook/FacebookMapper.java b/lib/java/mapping/src/main/java/co/airy/mapping/sources/facebook/FacebookMapper.java index 1dd05a743b..699c41818b 100644 --- a/lib/java/mapping/src/main/java/co/airy/mapping/sources/facebook/FacebookMapper.java +++ b/lib/java/mapping/src/main/java/co/airy/mapping/sources/facebook/FacebookMapper.java @@ -24,9 +24,9 @@ public List getIdentifiers() { } @Override - public Content render(String payload) throws Exception { + public List render(String payload) throws Exception { final JsonNode jsonNode = objectMapper.readTree(payload); final JsonNode messageNode = jsonNode.get("message"); - return new Text(messageNode.get("text").textValue()); + return List.of(new Text(messageNode.get("text").textValue())); } } diff --git a/lib/java/mapping/src/main/java/co/airy/mapping/sources/google/GoogleMapper.java b/lib/java/mapping/src/main/java/co/airy/mapping/sources/google/GoogleMapper.java index f49c2011d9..28a48ab33a 100644 --- a/lib/java/mapping/src/main/java/co/airy/mapping/sources/google/GoogleMapper.java +++ b/lib/java/mapping/src/main/java/co/airy/mapping/sources/google/GoogleMapper.java @@ -23,9 +23,9 @@ public List getIdentifiers() { } @Override - public Content render(String payload) throws Exception { + public List render(String payload) throws Exception { final JsonNode jsonNode = objectMapper.readTree(payload); final JsonNode messageNode = jsonNode.get("message"); - return new Text(messageNode.get("text").textValue()); + return List.of(new Text(messageNode.get("text").textValue())); } } diff --git a/lib/java/mapping/src/main/java/co/airy/mapping/sources/twilio/TwilioMapper.java b/lib/java/mapping/src/main/java/co/airy/mapping/sources/twilio/TwilioMapper.java index ffe4a92dc4..4a0691ba4d 100644 --- a/lib/java/mapping/src/main/java/co/airy/mapping/sources/twilio/TwilioMapper.java +++ b/lib/java/mapping/src/main/java/co/airy/mapping/sources/twilio/TwilioMapper.java @@ -23,9 +23,9 @@ public List getIdentifiers() { } @Override - public Content render(String payload) { + public List render(String payload) { Map decodedPayload = parseUrlEncoded(payload); - return new Text(decodedPayload.get("Body")); + return List.of(new Text(decodedPayload.get("Body"))); } private static Map parseUrlEncoded(String payload) { diff --git a/lib/java/mapping/src/test/java/co/airy/mapping/ContentMapperTest.java b/lib/java/mapping/src/test/java/co/airy/mapping/ContentMapperTest.java index 112a753f6f..cc3988160f 100644 --- a/lib/java/mapping/src/test/java/co/airy/mapping/ContentMapperTest.java +++ b/lib/java/mapping/src/test/java/co/airy/mapping/ContentMapperTest.java @@ -42,7 +42,7 @@ void rendersOutbound() throws Exception { .setContent("{\"text\":\"" + text + "\"}") .build(); - final Text textMessage = (Text) mapper.render(message); + final Text textMessage = (Text) mapper.render(message).get(0); assertThat(textMessage.getText(), equalTo(text)); Mockito.verify(outboundMapper).render(Mockito.anyString()); diff --git a/lib/java/mapping/src/test/java/co/airy/mapping/FacebookTest.java b/lib/java/mapping/src/test/java/co/airy/mapping/FacebookTest.java index 299f93ab43..a5f151a40b 100644 --- a/lib/java/mapping/src/test/java/co/airy/mapping/FacebookTest.java +++ b/lib/java/mapping/src/test/java/co/airy/mapping/FacebookTest.java @@ -19,7 +19,7 @@ void textMessage() throws Exception { final String text = "Hello world"; final String sourceContent = String.format(StreamUtils.copyToString(getClass().getClassLoader().getResourceAsStream("facebook/text.json"), StandardCharsets.UTF_8), text); - final Text message = (Text) mapper.render(sourceContent); + final Text message = (Text) mapper.render(sourceContent).get(0); assertThat(message.getText(), equalTo(text)); } diff --git a/lib/java/mapping/src/test/java/co/airy/mapping/GoogleTest.java b/lib/java/mapping/src/test/java/co/airy/mapping/GoogleTest.java index 38676e6079..644c2c5840 100644 --- a/lib/java/mapping/src/test/java/co/airy/mapping/GoogleTest.java +++ b/lib/java/mapping/src/test/java/co/airy/mapping/GoogleTest.java @@ -27,7 +27,7 @@ void canRenderText() throws Exception { " \"agent\": \"brands/af0ef816-cef8-479e-b4b6-650d5e8b90b1/agents/31a8d3e0-490f-4ecc-887b-42df4dd1952e\"\n" + "}"; - final Text message = (Text) mapper.render(content); + final Text message = (Text) mapper.render(content).get(0); assertThat(message.getText(), equalTo("Yes confirmed")); } } diff --git a/lib/java/mapping/src/test/java/co/airy/mapping/TwilioTest.java b/lib/java/mapping/src/test/java/co/airy/mapping/TwilioTest.java index 51611d4885..368a321256 100644 --- a/lib/java/mapping/src/test/java/co/airy/mapping/TwilioTest.java +++ b/lib/java/mapping/src/test/java/co/airy/mapping/TwilioTest.java @@ -19,7 +19,7 @@ void canRenderText() { "&From=whatsapp%3A%2B&MessageSid=SMbc31b6419de618d65076200c54676476" + "&Body=" + body + "&AccountSid=AC64c9ab479b849275b7b50bd19540c602&NumMedia=0"; - final Text message = (Text) mapper.render(event); + final Text message = (Text) mapper.render(event).get(0); assertThat(message.getText(), equalTo(body)); } } From 2b0dda91b411913457cfc3b3b68bc133f4903bb5 Mon Sep 17 00:00:00 2001 From: lucapette Date: Wed, 16 Dec 2020 16:31:37 +0100 Subject: [PATCH 05/27] [#523] Return source type in the channel payload (#529) Fixes #523 --- .../main/java/co/airy/core/api/admin/ChannelsController.java | 5 +++-- .../src/main/java/co/airy/core/api/communication/Mapper.java | 1 + .../airy/core/api/communication/dto/ConversationIndex.java | 2 ++ docs/docs/api/http.md | 4 ++-- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/backend/api/admin/src/main/java/co/airy/core/api/admin/ChannelsController.java b/backend/api/admin/src/main/java/co/airy/core/api/admin/ChannelsController.java index d8944ca9e2..3ebb69ea96 100644 --- a/backend/api/admin/src/main/java/co/airy/core/api/admin/ChannelsController.java +++ b/backend/api/admin/src/main/java/co/airy/core/api/admin/ChannelsController.java @@ -24,6 +24,7 @@ import java.util.Map; import java.util.Optional; +import static co.airy.core.api.admin.Mapper.fromChannel; import static java.util.stream.Collectors.toList; @RestController @@ -113,7 +114,7 @@ ResponseEntity connectChannel(@RequestBody @Valid ConnectChannelRequestPayloa final Channel existingChannel = channelsMap.get(channelId); if (existingChannel != null && ChannelConnectionState.CONNECTED.equals(existingChannel.getConnectionState())) { - return ResponseEntity.ok(Mapper.fromChannel(existingChannel)); + return ResponseEntity.ok(fromChannel(existingChannel)); } final ChannelMetadata channelMetadata; @@ -140,7 +141,7 @@ ResponseEntity connectChannel(@RequestBody @Valid ConnectChannelRequestPayloa return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).build(); } - return ResponseEntity.ok(Mapper.fromChannel(channel)); + return ResponseEntity.ok(fromChannel(channel)); } @PostMapping("/channels.disconnect") diff --git a/backend/api/communication/src/main/java/co/airy/core/api/communication/Mapper.java b/backend/api/communication/src/main/java/co/airy/core/api/communication/Mapper.java index d2508360f9..c6c6473efa 100644 --- a/backend/api/communication/src/main/java/co/airy/core/api/communication/Mapper.java +++ b/backend/api/communication/src/main/java/co/airy/core/api/communication/Mapper.java @@ -33,6 +33,7 @@ public ConversationResponsePayload fromConversation(Conversation conversation) { .channel(ChannelPayload.builder() .id(conversation.getChannelId()) .name(conversation.getChannel().getName()) + .source(conversation.getChannel().getSource()) .build()) .id(conversation.getId()) .unreadMessageCount(conversation.getUnreadCount()) diff --git a/backend/api/communication/src/main/java/co/airy/core/api/communication/dto/ConversationIndex.java b/backend/api/communication/src/main/java/co/airy/core/api/communication/dto/ConversationIndex.java index 41a547336e..d766746c81 100644 --- a/backend/api/communication/src/main/java/co/airy/core/api/communication/dto/ConversationIndex.java +++ b/backend/api/communication/src/main/java/co/airy/core/api/communication/dto/ConversationIndex.java @@ -17,6 +17,7 @@ public class ConversationIndex implements Serializable { private String id; private String displayName; private String channelId; + private String source; private Long createdAt; private Integer unreadCount; @@ -27,6 +28,7 @@ public static ConversationIndex fromConversation(Conversation conversation) { return ConversationIndex.builder() .id(conversation.getId()) .channelId(conversation.getChannelId()) + .source(conversation.getChannel().getSource()) .displayName(conversation.getDisplayNameOrDefault().toString()) .metadata(new HashMap<>(conversation.getMetadata())) .createdAt(conversation.getCreatedAt()) diff --git a/docs/docs/api/http.md b/docs/docs/api/http.md index 08eaa2f3f3..1834d7aa5f 100644 --- a/docs/docs/api/http.md +++ b/docs/docs/api/http.md @@ -136,8 +136,8 @@ Find all users with the last name "Lovelace": { "id": "a688d36c-a85e-44af-bc02-4248c2c97622", "channel": { - "name": "facebook", - // name of the source + "name": "Facebook page name", + "source": "facebook", "id": "318efa04-7cc1-4200-988e-50699d0dd6e3" }, "created_at": "2019-01-07T09:01:44.000Z", From a7ea6767bec4196c4f5f50d33794af1dc4b39419 Mon Sep 17 00:00:00 2001 From: Christoph Proeschel Date: Wed, 16 Dec 2020 16:57:08 +0100 Subject: [PATCH 06/27] [#493] Route Google metadata to get displayname (#521) --- backend/sources/google/events-router/BUILD | 1 + .../{GoogleEventInfo.java => EventInfo.java} | 6 +- .../core/sources/google/EventsRouter.java | 105 ++++++++++++------ .../sources/google/GoogleInfoExtractor.java | 30 ----- .../core/sources/google/InfoExtractor.java | 61 ++++++++++ .../sources/google/ObjectMapperConfig.java | 2 + .../core/sources/google/WebhookEvent.java | 11 +- .../core/sources/google/EventsRouterTest.java | 65 ++++++++++- 8 files changed, 209 insertions(+), 72 deletions(-) rename backend/sources/google/events-router/src/main/java/co/airy/core/sources/google/{GoogleEventInfo.java => EventInfo.java} (77%) delete mode 100644 backend/sources/google/events-router/src/main/java/co/airy/core/sources/google/GoogleInfoExtractor.java create mode 100644 backend/sources/google/events-router/src/main/java/co/airy/core/sources/google/InfoExtractor.java diff --git a/backend/sources/google/events-router/BUILD b/backend/sources/google/events-router/BUILD index 04522d102e..7a0807c84d 100644 --- a/backend/sources/google/events-router/BUILD +++ b/backend/sources/google/events-router/BUILD @@ -6,6 +6,7 @@ app_deps = [ "//backend:base_app", "//backend:channel", "//backend:message", + "//backend:metadata", "//lib/java/uuid", "//lib/java/payload", "//lib/java/kafka/schema:source-google-events", diff --git a/backend/sources/google/events-router/src/main/java/co/airy/core/sources/google/GoogleEventInfo.java b/backend/sources/google/events-router/src/main/java/co/airy/core/sources/google/EventInfo.java similarity index 77% rename from backend/sources/google/events-router/src/main/java/co/airy/core/sources/google/GoogleEventInfo.java rename to backend/sources/google/events-router/src/main/java/co/airy/core/sources/google/EventInfo.java index ff4ccda28c..894379ac13 100644 --- a/backend/sources/google/events-router/src/main/java/co/airy/core/sources/google/GoogleEventInfo.java +++ b/backend/sources/google/events-router/src/main/java/co/airy/core/sources/google/EventInfo.java @@ -12,10 +12,10 @@ @AllArgsConstructor @NoArgsConstructor @Builder(toBuilder = true) -public class GoogleEventInfo implements Serializable { +public class EventInfo implements Serializable { private String agentId; - private String conversationId; - private String eventPayload; + private String sourceConversationId; + private WebhookEvent event; private Channel channel; private Long timestamp; private boolean isMessage; diff --git a/backend/sources/google/events-router/src/main/java/co/airy/core/sources/google/EventsRouter.java b/backend/sources/google/events-router/src/main/java/co/airy/core/sources/google/EventsRouter.java index 555499cce8..5505b829ae 100644 --- a/backend/sources/google/events-router/src/main/java/co/airy/core/sources/google/EventsRouter.java +++ b/backend/sources/google/events-router/src/main/java/co/airy/core/sources/google/EventsRouter.java @@ -4,14 +4,17 @@ import co.airy.avro.communication.ChannelConnectionState; import co.airy.avro.communication.DeliveryState; import co.airy.avro.communication.Message; +import co.airy.avro.communication.Metadata; import co.airy.avro.communication.SenderType; import co.airy.kafka.schema.application.ApplicationCommunicationChannels; import co.airy.kafka.schema.application.ApplicationCommunicationMessages; +import co.airy.kafka.schema.application.ApplicationCommunicationMetadata; import co.airy.kafka.schema.source.SourceGoogleEvents; import co.airy.kafka.streams.KafkaStreamsWrapper; import co.airy.log.AiryLoggerFactory; import co.airy.uuid.UUIDv5; import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.avro.specific.SpecificRecordBase; import org.apache.kafka.streams.KafkaStreams; import org.apache.kafka.streams.KeyValue; import org.apache.kafka.streams.StreamsBuilder; @@ -24,8 +27,13 @@ import org.springframework.stereotype.Component; import java.time.Instant; +import java.util.ArrayList; +import java.util.List; import java.util.Map; +import static co.airy.avro.communication.MetadataRepository.getId; +import static co.airy.core.sources.google.InfoExtractor.getMetadataFromContext; + @Component public class EventsRouter implements DisposableBean, ApplicationListener { private static final String appId = "sources.google.EventsRouter"; @@ -40,7 +48,7 @@ public EventsRouter(KafkaStreamsWrapper streams, @Qualifier("googleObjectMapper" } @Override - public void destroy() throws Exception { + public void destroy() { if (streams != null) { streams.close(); } @@ -53,6 +61,8 @@ public void onApplicationEvent(ApplicationReadyEvent applicationReadyEvent) { private void startStream() { final StreamsBuilder builder = new StreamsBuilder(); + final String applicationCommunicationMessages = new ApplicationCommunicationMessages().name(); + final String applicationCommunicationMetadata = new ApplicationCommunicationMetadata().name(); // Channels table KTable channelsTable = builder.stream(new ApplicationCommunicationChannels().name()) @@ -75,51 +85,78 @@ private void startStream() { return KeyValue.pair("skip", null); } - GoogleEventInfo googleEventInfo = GoogleInfoExtractor.extract(webhookEvent); - googleEventInfo.setEventPayload(sourceEvent); - googleEventInfo.setTimestamp(Instant.parse(webhookEvent.getSendTime()).toEpochMilli()); + final EventInfo eventInfo = InfoExtractor.extract(webhookEvent); + eventInfo.setEvent(webhookEvent); + eventInfo.setTimestamp(Instant.parse(webhookEvent.getSendTime()).toEpochMilli()); - if (!webhookEvent.isMessage()) { - return KeyValue.pair(googleEventInfo.getAgentId(), null); + if (!webhookEvent.hasMessage() && !webhookEvent.hasContext()) { + return KeyValue.pair(eventInfo.getAgentId(), null); } - return KeyValue.pair(googleEventInfo.getAgentId(), googleEventInfo); + return KeyValue.pair(eventInfo.getAgentId(), eventInfo); }) .filter((agentId, event) -> event != null) .join(channelsTable, (event, channel) -> event.toBuilder().channel(channel).build()) - .map((agentId, event) -> { + .flatMap((agentId, event) -> { final Channel channel = event.getChannel(); - final String payload = event.getEventPayload(); - - final String messageId = UUIDv5.fromNamespaceAndName(channel.getId(), payload).toString(); - final String conversationId = UUIDv5.fromNamespaceAndName(channel.getId(), event.getConversationId()).toString(); - final String sourceConversationId = event.getConversationId(); - - Message.Builder messageBuilder = Message.newBuilder(); - return KeyValue.pair( - messageId, - messageBuilder - .setSource(channel.getSource()) - .setDeliveryState(DeliveryState.DELIVERED) - .setId(messageId) - .setChannelId(channel.getId()) - .setConversationId(conversationId) - .setSenderType(SenderType.SOURCE_CONTACT) - .setContent(payload) - .setSenderId(sourceConversationId) - .setHeaders(Map.of()) // TODO we can add place Id - .setSentAt(event.getTimestamp()) - .setUpdatedAt(null) - .build() - ); + final WebhookEvent webhookEvent = event.getEvent(); + + String payload; + try { + payload = objectMapper.writeValueAsString(webhookEvent); + } catch (Exception e) { + throw new RuntimeException(e); + } + + final String sourceConversationId = event.getSourceConversationId(); + final String conversationId = UUIDv5.fromNamespaceAndName(channel.getId(), sourceConversationId).toString(); + final List> records = new ArrayList<>(); + + if (webhookEvent.hasMessage()) { + final String messageId = UUIDv5.fromNamespaceAndName(channel.getId(), payload).toString(); + records.add(KeyValue.pair(messageId, + Message.newBuilder() + .setSource(channel.getSource()) + .setDeliveryState(DeliveryState.DELIVERED) + .setId(messageId) + .setChannelId(channel.getId()) + .setConversationId(conversationId) + .setSenderType(SenderType.SOURCE_CONTACT) + .setContent(payload) + .setSenderId(sourceConversationId) + .setHeaders(Map.of()) + .setSentAt(event.getTimestamp()) + .setUpdatedAt(null) + .build() + )); + } + + if (webhookEvent.hasContext()) { + final List metadataFromContext = getMetadataFromContext(conversationId, webhookEvent); + + for (Metadata metadata : metadataFromContext) { + records.add(KeyValue.pair(getId(metadata).toString(), metadata)); + } + } + + return records; }) - .filter((messageId, message) -> message != null) - .to(new ApplicationCommunicationMessages().name()); + .filter((recordId, record) -> record != null) + .to((recordId, record, context) -> { + if (record instanceof Metadata) { + return applicationCommunicationMetadata; + } + if (record instanceof Message) { + return applicationCommunicationMessages; + } + + throw new IllegalStateException("Unknown type for record " + record); + }); streams.start(builder.build(), appId); } - // visible for testing + // Visible for testing KafkaStreams.State getStreamState() { return streams.state(); } diff --git a/backend/sources/google/events-router/src/main/java/co/airy/core/sources/google/GoogleInfoExtractor.java b/backend/sources/google/events-router/src/main/java/co/airy/core/sources/google/GoogleInfoExtractor.java deleted file mode 100644 index ed7e626f25..0000000000 --- a/backend/sources/google/events-router/src/main/java/co/airy/core/sources/google/GoogleInfoExtractor.java +++ /dev/null @@ -1,30 +0,0 @@ -package co.airy.core.sources.google; - -import co.airy.log.AiryLoggerFactory; -import org.slf4j.Logger; - -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -public class GoogleInfoExtractor { - private static final Logger log = AiryLoggerFactory.getLogger(GoogleInfoExtractor.class); - - private static final Pattern agentPattern = Pattern.compile("brands/(.*?)/agents/(.*)"); - - static GoogleEventInfo extract(WebhookEvent event) { - try { - Matcher agentBrandMatcher = agentPattern.matcher(event.getAgent()); - agentBrandMatcher.find(); - final String agentId = agentBrandMatcher.group(2); - - return GoogleEventInfo.builder() - .agentId(agentId) - .conversationId(event.getConversationId()) - .build(); - } catch (Throwable e) { - log.info("Event {} is not parseable", event); - throw new IllegalArgumentException("Could not extract event", e); - } - } - -} diff --git a/backend/sources/google/events-router/src/main/java/co/airy/core/sources/google/InfoExtractor.java b/backend/sources/google/events-router/src/main/java/co/airy/core/sources/google/InfoExtractor.java new file mode 100644 index 0000000000..f8c6c89b33 --- /dev/null +++ b/backend/sources/google/events-router/src/main/java/co/airy/core/sources/google/InfoExtractor.java @@ -0,0 +1,61 @@ +package co.airy.core.sources.google; + +import co.airy.avro.communication.Metadata; +import co.airy.avro.communication.MetadataKeys; +import co.airy.log.AiryLoggerFactory; +import com.fasterxml.jackson.databind.JsonNode; +import org.slf4j.Logger; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static co.airy.avro.communication.MetadataRepository.newConversationMetadata; + +public class InfoExtractor { + private static final Logger log = AiryLoggerFactory.getLogger(InfoExtractor.class); + + private static final Pattern agentPattern = Pattern.compile("brands/(.*?)/agents/(.*)"); + + static EventInfo extract(WebhookEvent event) { + try { + Matcher agentBrandMatcher = agentPattern.matcher(event.getAgent()); + agentBrandMatcher.find(); + final String agentId = agentBrandMatcher.group(2); + + return EventInfo.builder() + .agentId(agentId) + .sourceConversationId(event.getConversationId()) + .build(); + } catch (Throwable e) { + log.info("Event {} is not parseable", event); + throw new IllegalArgumentException("Could not extract event", e); + } + } + + static List getMetadataFromContext(String conversationId, WebhookEvent webhookEvent) { + final JsonNode context = webhookEvent.getContext(); + + List metadata = new ArrayList<>(); + + final JsonNode userInfo = context.get("userInfo"); + if (userInfo != null && userInfo.has("displayName")) { + final String displayName = userInfo.get("displayName").textValue(); + final int lastIndexOf = displayName.indexOf(" "); + + // There's only a first name + if (lastIndexOf != -1) { + final String firstName = displayName.substring(0, lastIndexOf); + final String lastName = displayName.substring(lastIndexOf + 1); + + metadata.add(newConversationMetadata(conversationId, MetadataKeys.Source.Contact.FIRST_NAME, firstName)); + metadata.add(newConversationMetadata(conversationId, MetadataKeys.Source.Contact.LAST_NAME, lastName)); + } else { + metadata.add(newConversationMetadata(conversationId, MetadataKeys.Source.Contact.FIRST_NAME, displayName)); + } + } + + return metadata; + } +} diff --git a/backend/sources/google/events-router/src/main/java/co/airy/core/sources/google/ObjectMapperConfig.java b/backend/sources/google/events-router/src/main/java/co/airy/core/sources/google/ObjectMapperConfig.java index d8bb99af3e..adc68f94e9 100644 --- a/backend/sources/google/events-router/src/main/java/co/airy/core/sources/google/ObjectMapperConfig.java +++ b/backend/sources/google/events-router/src/main/java/co/airy/core/sources/google/ObjectMapperConfig.java @@ -1,5 +1,6 @@ package co.airy.core.sources.google; +import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.PropertyNamingStrategy; @@ -15,6 +16,7 @@ public ObjectMapper objectMapper() { return new ObjectMapper() .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) .configure(DeserializationFeature.FAIL_ON_MISSING_EXTERNAL_TYPE_ID_PROPERTY, false) + .setSerializationInclusion(JsonInclude.Include.NON_NULL) .setPropertyNamingStrategy(PropertyNamingStrategy.LOWER_CAMEL_CASE); } } diff --git a/backend/sources/google/events-router/src/main/java/co/airy/core/sources/google/WebhookEvent.java b/backend/sources/google/events-router/src/main/java/co/airy/core/sources/google/WebhookEvent.java index bf502e93a8..9b76a3e1bd 100644 --- a/backend/sources/google/events-router/src/main/java/co/airy/core/sources/google/WebhookEvent.java +++ b/backend/sources/google/events-router/src/main/java/co/airy/core/sources/google/WebhookEvent.java @@ -1,5 +1,7 @@ package co.airy.core.sources.google; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.databind.JsonNode; import lombok.Data; import lombok.NoArgsConstructor; @@ -25,6 +27,7 @@ public class WebhookEvent { private String sendTime; + @JsonIgnore public JsonNode getPayload() { return Stream.of(this.message, this.suggestionResponse, this.surveyResponse, this.receipts, this.userStatus) .filter(Objects::nonNull) @@ -32,7 +35,13 @@ public JsonNode getPayload() { .get(); } - public boolean isMessage() { + @JsonIgnore + public boolean hasMessage() { return this.message != null; } + + @JsonIgnore + public boolean hasContext() { + return this.context != null && !this.context.isEmpty(); + } } diff --git a/backend/sources/google/events-router/src/test/java/co/airy/core/sources/google/EventsRouterTest.java b/backend/sources/google/events-router/src/test/java/co/airy/core/sources/google/EventsRouterTest.java index daecb03cc6..07cada9569 100644 --- a/backend/sources/google/events-router/src/test/java/co/airy/core/sources/google/EventsRouterTest.java +++ b/backend/sources/google/events-router/src/test/java/co/airy/core/sources/google/EventsRouterTest.java @@ -3,9 +3,12 @@ import co.airy.avro.communication.Channel; import co.airy.avro.communication.ChannelConnectionState; import co.airy.avro.communication.Message; +import co.airy.avro.communication.Metadata; +import co.airy.avro.communication.MetadataKeys; import co.airy.kafka.schema.Topic; import co.airy.kafka.schema.application.ApplicationCommunicationChannels; import co.airy.kafka.schema.application.ApplicationCommunicationMessages; +import co.airy.kafka.schema.application.ApplicationCommunicationMetadata; import co.airy.kafka.schema.source.SourceGoogleEvents; import co.airy.kafka.test.KafkaTestHelper; import co.airy.kafka.test.junit.SharedKafkaTestResource; @@ -30,6 +33,7 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.collection.IsCollectionWithSize.hasSize; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; @SpringBootTest(properties = { "kafka.cleanup=true", @@ -43,6 +47,7 @@ public class EventsRouterTest { private static final Topic sourceGoogleEvents = new SourceGoogleEvents(); private static final Topic applicationCommunicationChannels = new ApplicationCommunicationChannels(); private static final Topic applicationCommunicationMessages = new ApplicationCommunicationMessages(); + private static final Topic applicationCommunicationMetadata = new ApplicationCommunicationMetadata(); @Autowired private EventsRouter worker; @@ -52,7 +57,8 @@ static void beforeAll() throws Exception { kafkaTestHelper = new KafkaTestHelper(sharedKafkaTestResource, sourceGoogleEvents, applicationCommunicationChannels, - applicationCommunicationMessages + applicationCommunicationMessages, + applicationCommunicationMetadata ); kafkaTestHelper.beforeAll(); @@ -71,11 +77,11 @@ void beforeEach() throws InterruptedException { @Test void canRouteGoogleMessages() throws Exception { String channelId = UUID.randomUUID().toString(); - String pageId = UUID.randomUUID().toString(); + String agentId = UUID.randomUUID().toString(); kafkaTestHelper.produceRecord(new ProducerRecord<>(applicationCommunicationChannels.name(), channelId, Channel.newBuilder() .setId(channelId) .setConnectionState(ChannelConnectionState.CONNECTED) - .setSourceChannelId(pageId) + .setSourceChannelId(agentId) .setName("Awesome place") .setSource("google") .setToken("") @@ -86,10 +92,61 @@ void canRouteGoogleMessages() throws Exception { final String eventPayload = "{ \"agent\": \"brands/somebrand/agents/%s\", \"conversationId\": \"CONVERSATION_ID\", \"customAgentId\": \"CUSTOM_AGENT_ID\", \"message\": { \"messageId\": \"MESSAGE_ID\", \"name\": \"conversations/CONVERSATION_ID/messages/MESSAGE_ID\", \"text\": \"MESSAGE_TEXT\", \"createTime\": \"MESSAGE_CREATE_TIME\" }, \"context\": { \"placeId\": \"LOCATION_PLACE_ID\" }, \"sendTime\": \"2014-10-02T15:01:23.045123456Z\" }"; - List> events = List.of(new ProducerRecord<>(sourceGoogleEvents.name(), UUID.randomUUID().toString(), String.format(eventPayload, pageId))); + List> events = List.of(new ProducerRecord<>(sourceGoogleEvents.name(), UUID.randomUUID().toString(), String.format(eventPayload, agentId))); kafkaTestHelper.produceRecords(events); List messages = kafkaTestHelper.consumeValues(1, applicationCommunicationMessages.name()); assertThat(messages, hasSize(1)); } + + @Test + void canRouteGoogleMetadata() throws Exception { + String channelId = UUID.randomUUID().toString(); + String agentId = UUID.randomUUID().toString(); + kafkaTestHelper.produceRecord(new ProducerRecord<>(applicationCommunicationChannels.name(), channelId, Channel.newBuilder() + .setId(channelId) + .setConnectionState(ChannelConnectionState.CONNECTED) + .setSourceChannelId(agentId) + .setName("Awesome place") + .setSource("google") + .setToken("") + .build())); + + // Wait for the channels table to catch up + TimeUnit.SECONDS.sleep(5); + + final String displayName = "Grace Brewster Murray Hopper"; + final String singleName = "hal9000"; + + // Two different event types that both carry context + final String messagePayload = "{ \"agent\": \"brands/somebrand/agents/%s\", \"conversationId\": \"CONVERSATION_ID\"," + + " \"customAgentId\": \"CUSTOM_AGENT_ID\", \"message\": { \"messageId\": \"MESSAGE_ID\", \"name\": \"conversations/CONVERSATION_ID/messages/MESSAGE_ID\", \"text\": \"MESSAGE_TEXT\", \"createTime\": \"MESSAGE_CREATE_TIME\" }," + + " \"context\": { \"userInfo\": { \"displayName\": \"%s\" } }, \"sendTime\": \"2014-10-02T15:01:23.045123456Z\" }"; + final String userStatusPayload = "{ \"agent\": \"brands/somebrand/agents/%s\", \"conversationId\": \"CONVERSATION_ID\", " + + "\"customAgentId\": \"CUSTOM_AGENT_ID\", \"userStatus\": { \"isTyping\": true }, " + + "\"context\": { \"userInfo\": { \"displayName\": \"%s\" } }, \"sendTime\": \"2014-10-02T15:01:23.045123456Z\" }"; + + List> events = List.of( + new ProducerRecord<>(sourceGoogleEvents.name(), UUID.randomUUID().toString(), String.format(messagePayload, agentId, displayName)), + new ProducerRecord<>(sourceGoogleEvents.name(), UUID.randomUUID().toString(), String.format(userStatusPayload, agentId, singleName)) + ); + + kafkaTestHelper.produceRecords(events); + + List metadataList = kafkaTestHelper.consumeValues(3, applicationCommunicationMetadata.name()); + assertThat(metadataList, hasSize(3)); + + assertTrue(metadataList.stream().anyMatch((metadata -> + metadata.getKey().equals(MetadataKeys.Source.Contact.FIRST_NAME) && + metadata.getValue().equals(singleName) + ))); + assertTrue(metadataList.stream().anyMatch((metadata -> + metadata.getKey().equals(MetadataKeys.Source.Contact.FIRST_NAME) && + metadata.getValue().equals("Grace") + ))); + assertTrue(metadataList.stream().anyMatch((metadata -> + metadata.getKey().equals(MetadataKeys.Source.Contact.LAST_NAME) && + metadata.getValue().equals("Brewster Murray Hopper") + ))); + } } From c7cd06bcbcb0b42f0bd4bca55d5812b49f66d745 Mon Sep 17 00:00:00 2001 From: Kazeem Adetunji Date: Wed, 16 Dec 2020 17:57:41 +0100 Subject: [PATCH 07/27] [#524] remove hyperlinks (#530) --- frontend/demo/README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/frontend/demo/README.md b/frontend/demo/README.md index 56918800cb..5fe00df59c 100644 --- a/frontend/demo/README.md +++ b/frontend/demo/README.md @@ -14,8 +14,6 @@ The Airy Demo UI is a minimal user interactive frontend project that showcases t - [Installation](#installation) - [Authentication](#authentication) - [Endpoints](#endpoints) -- [How to contribute](#how-to-contribute) -- [Code of Conduct](#code-of-conduct) ### Prerequisites From aa226b28566c535ebd02b1a6b690242b1eeb27c2 Mon Sep 17 00:00:00 2001 From: Paulo Diniz Date: Thu, 17 Dec 2020 13:49:39 +0100 Subject: [PATCH 08/27] Added Image content model for gogle (#531) * Added Image content model for gogle * Checking host --- .../mapping/sources/google/GoogleMapper.java | 46 ++++++++++++++++++- .../test/java/co/airy/mapping/GoogleTest.java | 27 +++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/lib/java/mapping/src/main/java/co/airy/mapping/sources/google/GoogleMapper.java b/lib/java/mapping/src/main/java/co/airy/mapping/sources/google/GoogleMapper.java index 28a48ab33a..5f894fbd0a 100644 --- a/lib/java/mapping/src/main/java/co/airy/mapping/sources/google/GoogleMapper.java +++ b/lib/java/mapping/src/main/java/co/airy/mapping/sources/google/GoogleMapper.java @@ -2,12 +2,19 @@ import co.airy.mapping.SourceMapper; import co.airy.mapping.model.Content; +import co.airy.mapping.model.Image; import co.airy.mapping.model.Text; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.stereotype.Component; +import java.net.URI; +import java.net.URISyntaxException; import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static java.util.stream.Collectors.toMap; @Component public class GoogleMapper implements SourceMapper { @@ -26,6 +33,43 @@ public List getIdentifiers() { public List render(String payload) throws Exception { final JsonNode jsonNode = objectMapper.readTree(payload); final JsonNode messageNode = jsonNode.get("message"); - return List.of(new Text(messageNode.get("text").textValue())); + + final String messageNodeValue = messageNode.get("text").textValue(); + if(isGoogleStorageUrl(messageNodeValue)) { + return List.of(new Image(messageNodeValue)); + } else { + return List.of(new Text(messageNodeValue)); + } + } + + private boolean isGoogleStorageUrl(final String url) { + URI uri; + try { + uri = new URI(url); + } catch (URISyntaxException e) { + return false; + } + + if(!uri.getHost().startsWith("storage.googleapis.com")) { + return false; + } + + final Map params = List.of(uri.getQuery().split("&")) + .stream() + .map(param -> param.split("=")) + .map(l -> Map.of(l[0], l[1])) + .flatMap(map -> map.entrySet().stream()) + .collect(toMap( + m -> m.getKey().toLowerCase(), + Map.Entry::getValue, + (s1, s2) -> s2)); + + return !params.get("x-goog-algorithm").isEmpty() + && !params.get("x-goog-credential").isEmpty() + && !params.get("x-goog-date").isEmpty() + && !params.get("x-goog-expires").isEmpty() + && !params.get("x-goog-signedheaders").isEmpty() + && !params.get("x-goog-signature").isEmpty(); } + } diff --git a/lib/java/mapping/src/test/java/co/airy/mapping/GoogleTest.java b/lib/java/mapping/src/test/java/co/airy/mapping/GoogleTest.java index 644c2c5840..2a11d8e72c 100644 --- a/lib/java/mapping/src/test/java/co/airy/mapping/GoogleTest.java +++ b/lib/java/mapping/src/test/java/co/airy/mapping/GoogleTest.java @@ -1,5 +1,6 @@ package co.airy.mapping; +import co.airy.mapping.model.Image; import co.airy.mapping.model.Text; import co.airy.mapping.sources.google.GoogleMapper; import org.junit.jupiter.api.Test; @@ -30,4 +31,30 @@ void canRenderText() throws Exception { final Text message = (Text) mapper.render(content).get(0); assertThat(message.getText(), equalTo("Yes confirmed")); } + + @Test + void canRenderImage() throws Exception { + final String signedImageUrl = "https://storage.googleapis.com/business-messages-us/936640919331/jzsu6cdguNGsBhmGJGuLs1DS?x-goog-algorithm\u003dGOOG4-RSA-SHA256\u0026x-goog-credential\u003duranium%40rcs-uranium.iam.gserviceaccount.com%2F20190826%2Fauto%2Fstorage%2Fgoog4_request\u0026x-goog-date\u003d20190826T201038Z\u0026x-goog-expires\u003d604800\u0026x-goog-signedheaders\u003dhost\u0026x-goog-signature\u003d89dbf7a74d21ab42ad25be071b37840a544a43d68e67270382054e1442d375b0b53d15496dbba12896b9d88a6501cac03b5cfca45d789da3e0cae75b050a89d8f54c1ffb27e467bd6ba1d146b7d42e30504c295c5c372a46e44728f554ba74b7b99bd9c6d3ed45f18588ed1b04522af1a47330cff73a711a6a8c65bb15e3289f480486f6695127e1014727cac949e284a7f74afd8220840159c589d48dddef1cc97b248dfc34802570448242eac4d7190b1b10a008404a330b4ff6f9656fa84e87f9a18ab59dc9b91e54ad11ffdc0ad1dc9d1ccc7855c0d263d93fce6f999971ec79879f922b582cf3bb196a1fedc3eefa226bb412e49af7dfd91cc072608e98"; + final String content = "{\n" + + " \"agent\": \"brands/BRAND_ID/agents/AGENT_ID\",\n" + + " \"conversationId\": \"CONVERSATION_ID\",\n" + + " \"customAgentId\": \"CUSTOM_AGENT_ID\",\n" + + " \"requestId\": \"REQUEST_ID\",\n" + + " \"message\": {\n" + + " \"messageId\": \"MESSAGE_ID\",\n" + + " \"name\": \"conversations/CONVERSATION_ID/messages/MESSAGE_ID\",\n" + + " \"text\": \"" + signedImageUrl + "\",\n" + + " \"createTime\": \"MESSAGE_CREATE_TIME\"\n" + + " },\n" + + " \"context\": {},\n" + + " \"sendTime\": \"2020-05-14T12:45:55.302Z\",\n" + + " \"conversationId\": \"9cec28cc-8dbe-40d0-ad68-edd0f440c743\",\n" + + " \"customAgentId\": \"5b43b04d-aa75-4b7b-bdca-28e90a344db1\",\n" + + " \"requestId\": \"3A25E132-20D6-4A5D-8602-7DF4979F181B\",\n" + + " \"agent\": \"brands/af0ef816-cef8-479e-b4b6-650d5e8b90b1/agents/31a8d3e0-490f-4ecc-887b-42df4dd1952e\"\n" + + "}\n"; + + final Image message = (Image) mapper.render(content).get(0); + assertThat(message.getUrl(), equalTo(signedImageUrl)); + } } From a8ea5c144f5c6464b523dbdcc62fcaaf0325c100 Mon Sep 17 00:00:00 2001 From: Paulo Diniz Date: Thu, 17 Dec 2020 15:44:40 +0100 Subject: [PATCH 09/27] [#496] Added Image content model for Twilio (#532) --- .../mapping/sources/twilio/TwilioMapper.java | 13 ++++++++++- .../test/java/co/airy/mapping/TwilioTest.java | 22 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/lib/java/mapping/src/main/java/co/airy/mapping/sources/twilio/TwilioMapper.java b/lib/java/mapping/src/main/java/co/airy/mapping/sources/twilio/TwilioMapper.java index 4a0691ba4d..113f5a936c 100644 --- a/lib/java/mapping/src/main/java/co/airy/mapping/sources/twilio/TwilioMapper.java +++ b/lib/java/mapping/src/main/java/co/airy/mapping/sources/twilio/TwilioMapper.java @@ -2,11 +2,13 @@ import co.airy.mapping.SourceMapper; import co.airy.mapping.model.Content; +import co.airy.mapping.model.Image; import co.airy.mapping.model.Text; import org.springframework.stereotype.Component; import java.net.URLDecoder; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Map; @@ -25,7 +27,16 @@ public List getIdentifiers() { @Override public List render(String payload) { Map decodedPayload = parseUrlEncoded(payload); - return List.of(new Text(decodedPayload.get("Body"))); + List contents = new ArrayList<>(); + + contents.add(new Text(decodedPayload.get("Body"))); + + final String mediaUrl = decodedPayload.get("MediaUrl"); + if(mediaUrl != null && !mediaUrl.isBlank()) { + contents.add(new Image(mediaUrl)); + } + + return contents; } private static Map parseUrlEncoded(String payload) { diff --git a/lib/java/mapping/src/test/java/co/airy/mapping/TwilioTest.java b/lib/java/mapping/src/test/java/co/airy/mapping/TwilioTest.java index 368a321256..a5dc7dd7c1 100644 --- a/lib/java/mapping/src/test/java/co/airy/mapping/TwilioTest.java +++ b/lib/java/mapping/src/test/java/co/airy/mapping/TwilioTest.java @@ -1,11 +1,17 @@ package co.airy.mapping; +import co.airy.mapping.model.Content; +import co.airy.mapping.model.Image; import co.airy.mapping.model.Text; import co.airy.mapping.sources.twilio.TwilioMapper; import org.junit.jupiter.api.Test; +import java.util.List; +import java.util.Optional; + import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; public class TwilioTest { private final TwilioMapper mapper = new TwilioMapper(); @@ -22,4 +28,20 @@ void canRenderText() { final Text message = (Text) mapper.render(event).get(0); assertThat(message.getText(), equalTo(body)); } + + @Test + void canRenderImage() throws Exception { + final String body = "Heres a picture of an owl!"; + final String imageUrl = "https://demo.twilio.com/owl.png"; + + String event = "ApiVersion=2010-04-01&SmsSid=SMbc31b6419de618d65076200c54676476&SmsStatus=received" + + "&SmsMessageSid=SMbc31b6419de618d65076200c54676476&NumSegments=1&To=whatsapp%3A%2B" + + "&From=whatsapp%3A%2B&MessageSid=SMbc31b6419de618d65076200c54676476" + + "&Body=" + body + "&AccountSid=AC64c9ab479b849275b7b50bd19540c602&NumMedia=0" + + "&MediaUrl=" + imageUrl; + + final List message = mapper.render(event); + final Image image = (Image) message.stream().filter(c -> c instanceof Image).findFirst().get(); + assertThat(image.getUrl(), is(imageUrl)); + } } From 2897727450481fd3f8a94c161933f7489d5fb3e6 Mon Sep 17 00:00:00 2001 From: Aitor Algorta Date: Thu, 17 Dec 2020 16:46:06 +0100 Subject: [PATCH 10/27] [#399] Conversations List (#507) * Inbox displaying conversations * improve the conversation list component * type optimisation done * linting * improvements from review * UI improvements in the convesation list item --- frontend/demo/BUILD | 2 + frontend/demo/src/App.tsx | 10 +- .../demo/src/actions/conversations/index.ts | 73 ++++++++ frontend/demo/src/actions/user/index.ts | 5 +- .../src/assets/images/icons/airy-icon.svg | 1 + .../src/assets/images/icons/airy_avatar.svg | 18 ++ .../src/assets/images/icons/arrow-left-2.svg | 11 ++ .../assets/images/icons/facebook_rounded.svg | 20 ++ .../assets/images/icons/google-messages.svg | 1 + .../src/assets/images/icons/google_avatar.svg | 1 + .../demo/src/assets/images/icons/inbox.svg | 13 ++ .../assets/images/icons/messenger_avatar.svg | 14 ++ .../demo/src/assets/images/icons/search.svg | 13 ++ .../demo/src/assets/images/icons/sms-icon.svg | 1 + .../src/assets/images/icons/sms_avatar.svg | 12 ++ .../src/assets/images/icons/whatsapp-icon.svg | 1 + .../assets/images/icons/whatsapp_avatar.svg | 10 + .../demo/src/components/IconChannel/index.tsx | 143 ++++++++++++++ .../components/IconChannel/style.module.scss | 45 +++++ .../ResizableWindowList/index.module.scss | 3 + .../components/ResizableWindowList/index.tsx | 79 ++++++++ .../demo/src/components/Sidebar/index.tsx | 9 +- frontend/demo/src/components/TopBar/index.tsx | 4 +- frontend/demo/src/model/Contact.ts | 11 ++ frontend/demo/src/model/Conversation.ts | 60 ++++++ frontend/demo/src/model/Message.ts | 62 ++++++ frontend/demo/src/model/ResponseMetadata.ts | 5 + .../pages/Inbox/ConversationList/index.scss | 41 ++++ .../pages/Inbox/ConversationList/index.tsx | 119 ++++++++++++ .../ConversationListHeader/index.module.scss | 5 + .../Inbox/ConversationListHeader/index.tsx | 9 + .../ConversationListItem/index.module.scss | 176 ++++++++++++++++++ .../Inbox/ConversationListItem/index.tsx | 82 ++++++++ .../demo/src/pages/Inbox/Messenger/index.scss | 22 +++ .../demo/src/pages/Inbox/Messenger/index.tsx | 52 ++++++ .../Inbox/NoConversations/index.module.scss | 16 ++ .../src/pages/Inbox/NoConversations/index.tsx | 21 +++ frontend/demo/src/pages/Inbox/index.tsx | 34 ++++ frontend/demo/src/pages/Tags/index.tsx | 4 +- .../src/reducers/data/conversations/index.ts | 151 +++++++++++++++ frontend/demo/src/reducers/data/index.ts | 3 + frontend/demo/src/routes/routes.ts | 2 + frontend/demo/src/selectors/conversations.ts | 33 ++++ frontend/demo/src/services/format/date.ts | 143 ++++++++++++++ lib/typescript/types/global.d.ts | 6 +- package.json | 8 +- yarn.lock | 47 ++++- 47 files changed, 1584 insertions(+), 17 deletions(-) create mode 100644 frontend/demo/src/actions/conversations/index.ts create mode 100644 frontend/demo/src/assets/images/icons/airy-icon.svg create mode 100644 frontend/demo/src/assets/images/icons/airy_avatar.svg create mode 100644 frontend/demo/src/assets/images/icons/arrow-left-2.svg create mode 100644 frontend/demo/src/assets/images/icons/facebook_rounded.svg create mode 100644 frontend/demo/src/assets/images/icons/google-messages.svg create mode 100644 frontend/demo/src/assets/images/icons/google_avatar.svg create mode 100644 frontend/demo/src/assets/images/icons/inbox.svg create mode 100644 frontend/demo/src/assets/images/icons/messenger_avatar.svg create mode 100644 frontend/demo/src/assets/images/icons/search.svg create mode 100644 frontend/demo/src/assets/images/icons/sms-icon.svg create mode 100644 frontend/demo/src/assets/images/icons/sms_avatar.svg create mode 100644 frontend/demo/src/assets/images/icons/whatsapp-icon.svg create mode 100644 frontend/demo/src/assets/images/icons/whatsapp_avatar.svg create mode 100644 frontend/demo/src/components/IconChannel/index.tsx create mode 100644 frontend/demo/src/components/IconChannel/style.module.scss create mode 100644 frontend/demo/src/components/ResizableWindowList/index.module.scss create mode 100644 frontend/demo/src/components/ResizableWindowList/index.tsx create mode 100644 frontend/demo/src/model/Contact.ts create mode 100644 frontend/demo/src/model/Conversation.ts create mode 100644 frontend/demo/src/model/Message.ts create mode 100644 frontend/demo/src/model/ResponseMetadata.ts create mode 100644 frontend/demo/src/pages/Inbox/ConversationList/index.scss create mode 100644 frontend/demo/src/pages/Inbox/ConversationList/index.tsx create mode 100644 frontend/demo/src/pages/Inbox/ConversationListHeader/index.module.scss create mode 100644 frontend/demo/src/pages/Inbox/ConversationListHeader/index.tsx create mode 100644 frontend/demo/src/pages/Inbox/ConversationListItem/index.module.scss create mode 100644 frontend/demo/src/pages/Inbox/ConversationListItem/index.tsx create mode 100644 frontend/demo/src/pages/Inbox/Messenger/index.scss create mode 100644 frontend/demo/src/pages/Inbox/Messenger/index.tsx create mode 100644 frontend/demo/src/pages/Inbox/NoConversations/index.module.scss create mode 100644 frontend/demo/src/pages/Inbox/NoConversations/index.tsx create mode 100644 frontend/demo/src/pages/Inbox/index.tsx create mode 100644 frontend/demo/src/reducers/data/conversations/index.ts create mode 100644 frontend/demo/src/selectors/conversations.ts create mode 100644 frontend/demo/src/services/format/date.ts diff --git a/frontend/demo/BUILD b/frontend/demo/BUILD index b452f4c152..a54ed7cb52 100644 --- a/frontend/demo/BUILD +++ b/frontend/demo/BUILD @@ -19,6 +19,8 @@ ts_library( "@npm//react", "@npm//react-facebook-login", "@npm//react-router-dom", + "@npm//react-window", + "@npm//react-window-infinite-loader", "@npm//redux", "@npm//redux-starter-kit", "@npm//reselect", diff --git a/frontend/demo/src/App.tsx b/frontend/demo/src/App.tsx index e0d0fcbc3a..9dac7583b1 100644 --- a/frontend/demo/src/App.tsx +++ b/frontend/demo/src/App.tsx @@ -5,15 +5,16 @@ import {withRouter, Route, Switch, Redirect, RouteComponentProps} from 'react-ro import {AiryLoader} from '@airyhq/components'; import TopBar from './components/TopBar'; import Login from './pages/Login'; +import Channels from './pages/Channels'; +import Inbox from './pages/Inbox'; +import Tags from './pages/Tags'; import Logout from './pages/Logout'; import NotFound from './pages/NotFound'; import Sidebar from './components/Sidebar'; import {StateModel} from './reducers'; -import {CHANNELS_ROUTE, LOGIN_ROUTE, LOGOUT_ROUTE, ROOT_ROUTE, TAGS_ROUTE} from './routes/routes'; -import {Tags} from './pages/Tags'; -import Channels from './pages/Channels'; +import {INBOX_ROUTE, CHANNELS_ROUTE, LOGIN_ROUTE, LOGOUT_ROUTE, ROOT_ROUTE, TAGS_ROUTE} from './routes/routes'; import styles from './App.module.scss'; @@ -57,10 +58,11 @@ class App extends Component & RouteComponentPro )} - {this.isAuthSuccess ? : } + {this.isAuthSuccess ? : } + diff --git a/frontend/demo/src/actions/conversations/index.ts b/frontend/demo/src/actions/conversations/index.ts new file mode 100644 index 0000000000..93dcf3e1e8 --- /dev/null +++ b/frontend/demo/src/actions/conversations/index.ts @@ -0,0 +1,73 @@ +import {Dispatch} from 'redux'; +import {createAction} from 'typesafe-actions'; +import {doFetchFromBackend} from '../../api/airyConfig'; + +import {Conversation, ConversationPayload, conversationsMapper} from '../../model/Conversation'; +import {ResponseMetadata} from '../../model/ResponseMetadata'; +import {RootState, StateModel} from '../../reducers'; + +export const CONVERSATION_LOADING = '@@conversation/LOADING'; +export const CONVERSATIONS_LOADING = '@@conversations/LOADING'; +export const CONVERSATIONS_MERGE = '@@conversations/MERGE'; +export const CONVERSATION_ADD_ERROR = '@@conversations/ADD_ERROR_TO_CONVERSATION'; +export const CONVERSATION_REMOVE_ERROR = '@@conversations/REMOVE_ERROR_FROM_CONVERSATION'; + +export const loadingConversationAction = createAction(CONVERSATION_LOADING, resolve => (conversationId: string) => + resolve(conversationId) +); + +export const loadingConversationsAction = createAction(CONVERSATIONS_LOADING, resolve => () => resolve()); + +export const mergeConversationsAction = createAction( + CONVERSATIONS_MERGE, + resolve => (conversations: Conversation[], responseMetadata: ResponseMetadata) => + resolve({conversations, responseMetadata}) +); + +export const addErrorToConversationAction = createAction( + CONVERSATION_ADD_ERROR, + resolve => (conversationId: string, errorMessage: string) => resolve({conversationId, errorMessage}) +); + +export const removeErrorFromConversationAction = createAction( + CONVERSATION_REMOVE_ERROR, + resolve => (conversationId: string) => resolve({conversationId}) +); + +export interface FetchConversationsResponse { + data: ConversationPayload[]; + metadata: ResponseMetadata; +} + +export function fetchConversations() { + return async (dispatch: Dispatch) => { + dispatch(loadingConversationsAction()); + return doFetchFromBackend('conversations.list', { + page_size: 10, + }) + .then((response: FetchConversationsResponse) => { + dispatch(mergeConversationsAction(conversationsMapper(response.data), response.metadata)); + return Promise.resolve(true); + }) + .catch((error: Error) => { + return Promise.reject(error); + }); + }; +} + +export function fetchNextConversations() { + return async (dispatch: Dispatch, state: StateModel) => { + const cursor = state.data.conversations.all.metadata.next_cursor; + dispatch(loadingConversationsAction()); + return doFetchFromBackend('conversations.list', { + cursor, + }) + .then((response: FetchConversationsResponse) => { + dispatch(mergeConversationsAction(conversationsMapper(response.data), response.metadata)); + return Promise.resolve(true); + }) + .catch((error: Error) => { + return Promise.reject(error); + }); + }; +} diff --git a/frontend/demo/src/actions/user/index.ts b/frontend/demo/src/actions/user/index.ts index c68a2dc59a..1ad2f28212 100644 --- a/frontend/demo/src/actions/user/index.ts +++ b/frontend/demo/src/actions/user/index.ts @@ -21,9 +21,8 @@ export function loginViaEmail(requestPayload: LoginViaEmailRequestPayload) { return async (dispatch: Dispatch) => { return doFetchFromBackend('users.login', requestPayload) .then((response: UserPayload) => { - const user = userMapper(response); - dispatch(setCurrentUserAction(user)); - return Promise.resolve(user); + dispatch(setCurrentUserAction(userMapper(response))); + return Promise.resolve(true); }) .catch((error: Error) => { dispatch(userAuthErrorAction(error)); diff --git a/frontend/demo/src/assets/images/icons/airy-icon.svg b/frontend/demo/src/assets/images/icons/airy-icon.svg new file mode 100644 index 0000000000..671c694b3b --- /dev/null +++ b/frontend/demo/src/assets/images/icons/airy-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/demo/src/assets/images/icons/airy_avatar.svg b/frontend/demo/src/assets/images/icons/airy_avatar.svg new file mode 100644 index 0000000000..6ccf9258a9 --- /dev/null +++ b/frontend/demo/src/assets/images/icons/airy_avatar.svg @@ -0,0 +1,18 @@ + + + avatar/Airy Live Agent + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/demo/src/assets/images/icons/arrow-left-2.svg b/frontend/demo/src/assets/images/icons/arrow-left-2.svg new file mode 100644 index 0000000000..3b70e9c287 --- /dev/null +++ b/frontend/demo/src/assets/images/icons/arrow-left-2.svg @@ -0,0 +1,11 @@ + + + + icon/arrow/arrow-left copy + Created with Sketch. + + + + + + \ No newline at end of file diff --git a/frontend/demo/src/assets/images/icons/facebook_rounded.svg b/frontend/demo/src/assets/images/icons/facebook_rounded.svg new file mode 100644 index 0000000000..34cc7ba1ad --- /dev/null +++ b/frontend/demo/src/assets/images/icons/facebook_rounded.svg @@ -0,0 +1,20 @@ + + + + Group 3 + Created with Sketch. + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/demo/src/assets/images/icons/google-messages.svg b/frontend/demo/src/assets/images/icons/google-messages.svg new file mode 100644 index 0000000000..922380f68b --- /dev/null +++ b/frontend/demo/src/assets/images/icons/google-messages.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/demo/src/assets/images/icons/google_avatar.svg b/frontend/demo/src/assets/images/icons/google_avatar.svg new file mode 100644 index 0000000000..68b33c0898 --- /dev/null +++ b/frontend/demo/src/assets/images/icons/google_avatar.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/demo/src/assets/images/icons/inbox.svg b/frontend/demo/src/assets/images/icons/inbox.svg new file mode 100644 index 0000000000..048285f515 --- /dev/null +++ b/frontend/demo/src/assets/images/icons/inbox.svg @@ -0,0 +1,13 @@ + + + + + + + + diff --git a/frontend/demo/src/assets/images/icons/messenger_avatar.svg b/frontend/demo/src/assets/images/icons/messenger_avatar.svg new file mode 100644 index 0000000000..7276cb745c --- /dev/null +++ b/frontend/demo/src/assets/images/icons/messenger_avatar.svg @@ -0,0 +1,14 @@ + + + avatar/Messenger + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/demo/src/assets/images/icons/search.svg b/frontend/demo/src/assets/images/icons/search.svg new file mode 100644 index 0000000000..2ae349b29b --- /dev/null +++ b/frontend/demo/src/assets/images/icons/search.svg @@ -0,0 +1,13 @@ + + + + Icon / Search / Static + Created with Sketch. + + + + + + + + diff --git a/frontend/demo/src/assets/images/icons/sms-icon.svg b/frontend/demo/src/assets/images/icons/sms-icon.svg new file mode 100644 index 0000000000..11428838ae --- /dev/null +++ b/frontend/demo/src/assets/images/icons/sms-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/demo/src/assets/images/icons/sms_avatar.svg b/frontend/demo/src/assets/images/icons/sms_avatar.svg new file mode 100644 index 0000000000..c7de6dc3f8 --- /dev/null +++ b/frontend/demo/src/assets/images/icons/sms_avatar.svg @@ -0,0 +1,12 @@ + + + Group 16 + + + + + + + + + \ No newline at end of file diff --git a/frontend/demo/src/assets/images/icons/whatsapp-icon.svg b/frontend/demo/src/assets/images/icons/whatsapp-icon.svg new file mode 100644 index 0000000000..f6f002d96b --- /dev/null +++ b/frontend/demo/src/assets/images/icons/whatsapp-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/demo/src/assets/images/icons/whatsapp_avatar.svg b/frontend/demo/src/assets/images/icons/whatsapp_avatar.svg new file mode 100644 index 0000000000..95773d4399 --- /dev/null +++ b/frontend/demo/src/assets/images/icons/whatsapp_avatar.svg @@ -0,0 +1,10 @@ + + + Group 14 + + + + + + + \ No newline at end of file diff --git a/frontend/demo/src/components/IconChannel/index.tsx b/frontend/demo/src/components/IconChannel/index.tsx new file mode 100644 index 0000000000..38ecd51354 --- /dev/null +++ b/frontend/demo/src/components/IconChannel/index.tsx @@ -0,0 +1,143 @@ +import React from 'react'; +import {Channel} from '../../model/Channel'; + +import {ReactComponent as FacebookIcon} from '../../assets/images/icons/facebook_rounded.svg'; +import {ReactComponent as GoogleIcon} from '../../assets/images/icons/google-messages.svg'; +import {ReactComponent as SmsIcon} from '../../assets/images/icons/sms-icon.svg'; +import {ReactComponent as WhatsappIcon} from '../../assets/images/icons/whatsapp-icon.svg'; +import {ReactComponent as MessengerAvatar} from '../../assets/images/icons/messenger_avatar.svg'; +import {ReactComponent as GoogleAvatar} from '../../assets/images/icons/google_avatar.svg'; +import {ReactComponent as SmsAvatar} from '../../assets/images/icons/sms_avatar.svg'; +import {ReactComponent as WhatsappAvatar} from '../../assets/images/icons/whatsapp_avatar.svg'; +import {ReactComponent as AiryAvatar} from '../../assets/images/icons/airy_avatar.svg'; +import {ReactComponent as AiryIcon} from '../../assets/images/icons/airy-icon.svg'; + +import styles from './style.module.scss'; + +type IconChannelProps = { + channel: Channel; + icon?: boolean; + avatar?: boolean; + name?: boolean; + text?: boolean; +}; + +const PlaceholderChannelData = { + id: 'id', + name: 'Retriving Data...', + source: 'FACEBOOK', + sourceChannelId: 'external_channel_id', + connected: true, +}; + +const IconChannel = ({channel, icon, avatar, name, text}: IconChannelProps) => { + if (!channel) { + channel = PlaceholderChannelData; + } + + const SOURCE_INFO = { + facebook: { + text: 'Facebook page', + icon: function() { + return ; + }, + avatar: function() { + return ; + }, + name: channel.name, + }, + google: { + text: 'Google page', + icon: function() { + return ; + }, + avatar: function() { + return ; + }, + name: channel.name, + }, + 'twilio.sms': { + text: 'SMS page', + icon: function() { + return ; + }, + avatar: function() { + return ; + }, + name: channel.name, + }, + 'twilio.whatsapp': { + text: 'Whatsapp page', + icon: function() { + return ; + }, + avatar: function() { + return ; + }, + name: channel.name, + }, + chat_plugin: { + text: 'Airy Chat plugin', + icon: function() { + return ; + }, + avatar: function() { + return ; + }, + name: channel.name, + }, + }; + + //TODO: This has to go once the backend returns the source + const channelInfo = SOURCE_INFO[channel.source || 'chat_plugin']; + const fbFallback = SOURCE_INFO['FACEBOOK']; + + if (!channelInfo) { + return ( + <> + {fbFallback.icon()} {fbFallback.text} + + ); + } + + if (icon && name) { + return ( +
+ {channelInfo.icon()} +

{channelInfo.name}

+
+ ); + } else if (avatar && name) { + return ( +
+ {channelInfo.avatar()} +

{channelInfo.name}

+
+ ); + } else if (icon && text) { + return ( +
+ {channelInfo.icon()} +

{channelInfo.text}

+
+ ); + } else if (avatar && text) { + return ( +
+ {channelInfo.avatar()} +

{channelInfo.text}

+
+ ); + } else if (icon) { + return <>{channelInfo.icon()}; + } else if (avatar) { + return <>{channelInfo.avatar()}; + } + return ( + <> + {fbFallback.icon()} {fbFallback.text} + + ); +}; + +export default IconChannel; diff --git a/frontend/demo/src/components/IconChannel/style.module.scss b/frontend/demo/src/components/IconChannel/style.module.scss new file mode 100644 index 0000000000..f69c552777 --- /dev/null +++ b/frontend/demo/src/components/IconChannel/style.module.scss @@ -0,0 +1,45 @@ +.iconName { + display: flex; + align-items: center; + min-width: 0px; + div { + height: 20px; + width: 20px; + } + svg { + height: 20px; + width: 20px; + } + p { + overflow: hidden; + text-overflow: ellipsis; + } +} + +.avatarName { + display: flex; + align-items: center; + min-width: 0px; + div { + height: 20px; + width: 20px; + } + svg { + height: 20px; + width: 20px; + } + p { + overflow: hidden; + text-overflow: ellipsis; + } +} + +.iconText { + display: flex; + align-items: center; +} + +.avatarText { + display: flex; + align-items: center; +} diff --git a/frontend/demo/src/components/ResizableWindowList/index.module.scss b/frontend/demo/src/components/ResizableWindowList/index.module.scss new file mode 100644 index 0000000000..81086b9878 --- /dev/null +++ b/frontend/demo/src/components/ResizableWindowList/index.module.scss @@ -0,0 +1,3 @@ +.resizablewindowlist { + height: 100%; +} diff --git a/frontend/demo/src/components/ResizableWindowList/index.tsx b/frontend/demo/src/components/ResizableWindowList/index.tsx new file mode 100644 index 0000000000..de1d3633c3 --- /dev/null +++ b/frontend/demo/src/components/ResizableWindowList/index.tsx @@ -0,0 +1,79 @@ +import React, {Component} from 'react'; +import {ComponentType} from 'react'; +import {ListOnItemsRenderedProps, ListChildComponentProps, VariableSizeList as List} from 'react-window'; +import './index.module.scss'; + +type ResizableWindowProps = { + itemCount: number; + itemSize?: number | ((index: number) => number); + width: string; + children: ComponentType; + onItemsRendered: (event: ListOnItemsRenderedProps) => void; +}; + +type ResizableWindowState = { + height: number; + mounted: boolean; +}; + +class ResizableWindowList extends Component { + resizeRef: React.RefObject; + listRef: React.MutableRefObject; + + constructor(props: ResizableWindowProps) { + super(props); + this.resizeRef = React.createRef(); + this.listRef = React.createRef(); + this.state = {height: 250, mounted: true}; + } + + componentDidMount() { + window.addEventListener('resize', this.resizeToFit); + this.resizeToFit(); + setTimeout(this.resizeAfterStart, 500); + } + + resizeAfterStart = () => { + this.resizeRef.current ? this.resizeToFit() : setTimeout(this.resizeAfterStart, 500); + }; + + componentWillUnmount() { + this.setState({mounted: false}); + window.removeEventListener('resize', this.resizeToFit); + } + + resizeToFit = () => { + if (this.state.mounted) { + this.setState({ + height: Math.floor(this.resizeRef.current.getBoundingClientRect().height) + 200, + }); + } + }; + + resetSizeCache = () => { + this.listRef.current && this.listRef.current.resetAfterIndex(0, true); + }; + + setRef = (ref: List) => { + this.listRef.current = ref; + }; + + render() { + const {itemCount, itemSize, width, children, onItemsRendered} = this.props; + return ( +
+ (typeof itemSize === 'function' ? itemSize(item) : itemSize)} + width={width}> + {children} + +
+ ); + } +} + +export default ResizableWindowList; diff --git a/frontend/demo/src/components/Sidebar/index.tsx b/frontend/demo/src/components/Sidebar/index.tsx index d4ef67d170..924224c9bd 100644 --- a/frontend/demo/src/components/Sidebar/index.tsx +++ b/frontend/demo/src/components/Sidebar/index.tsx @@ -2,9 +2,10 @@ import React from 'react'; import {withRouter, Link, matchPath, RouteProps} 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'; import {ReactComponent as TagIcon} from '../../assets/images/icons/price-tag.svg'; -import {CHANNELS_ROUTE, TAGS_ROUTE} from '../../routes/routes'; +import {INBOX_ROUTE, CHANNELS_ROUTE, TAGS_ROUTE} from '../../routes/routes'; import styles from './index.module.scss'; @@ -16,6 +17,12 @@ const Sidebar = (props: RouteProps) => { return (