/ aws

Happy Smiley People

Providing a feedback mechanism in your apps may save you from a lot of random phone calls and/or emails and/or tweets or whatever. This sort of thing used to be buried deep in an apps Contact Us pages or Help menu option number 398. Clearly, companies that hide contact mechanisms don't give a shit about their customers! Of course, making it too easy can flood you with trivia so, well, you need to decide for yourself. I'd rather have happy customers and grumby staff than the other way around.

But, I digress.

I'm here to explain how I did this. The scene:

  • 1 Angular front end SPA
  • 1 .Net Core API back end
  • 1 AWS Cloud

The front end

I chose to put the feedback smiley/sadface icons right where everyone can see them, at the top of the page, next to the login button.

HappyToolbar

The i button shows an about box, which will probably include a version number and other useful stuff.

Clicking either button shows a dialog:

feedbackdialog

The message changes if the user clicks sadface.

The code for this dialog is straight forward:

import { Component, Inject, ViewChild } from '@angular/core';
import { MatDialog, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material';
import { Form, FormBuilder, FormGroup, Validators } from '@angular/forms';

@Component({
  template: `
  <h1 mat-dialog-title>{{title}}</h1>
  <mat-dialog-content>
    <p class="mat-body-2">
      {{mood}}
    </p>
    <form [formGroup]="form">
      <mat-form-field>
        <textarea #comment matInput matAutosizeMinRows="4" matAutosizeMaxRows="8" formControlName="message" style="width:350px">
        </textarea>
      </mat-form-field>
    </form>
  </mat-dialog-content>
  <mat-dialog-actions>
    <button type="button" mat-raised-button color="primary" (click)="goAhead(true)">Send It</button>
  </mat-dialog-actions>
  `
})
export class FeedbackDialogComponent {
  title = 'Feedback';

  form: FormGroup;

  get mood(): string {
    if (this.data) {
      return 'Tell us what you like about MyProduct.  We love compliments!';
    } else {
      return 'Tell use what you dont like about MyProduct.  We would love to make things better for you.';
    }
  }

  constructor(
    private dialogRef: MatDialogRef<FeedbackDialogComponent>,
    private fb: FormBuilder,
    @Inject(MAT_DIALOG_DATA) public data: boolean) {
    this.form = this.fb.group({
      message: ['', Validators.required]
    });
  }

  goAhead(response: boolean) {
    this.dialogRef.close(this.form.controls.message.value);
  }
}

This is called from the main app.component:

feedback(isGood: boolean): void {
    const dlg = this.dialog.open(FeedbackDialogComponent, {
      data: isGood
    });

    dlg.afterClosed().subscribe(msg => {
      if (msg) {
        const sub = this.feedbackService.postFeedback(msg, isGood).subscribe(_ => {
          this.dialog.open(PopupDialogComponent, {
            data: new MessageOptions('Thank you for your feedback. It has been sent to our support team who may contact you later if necessary.', 'Thank You')
          });
        },
          err => {
            this.dialog.open(PopupDialogComponent, {
              data: new MessageOptions('Sadly, your feedback could not be sent at this time.  Please try again later.', 'Oh dear')
            });
          },
          () => sub.unsubscribe());
      }
    });
  }

There's a simple service injected here, FeedbackService, that has the following method to post the feedback:

postFeedback(message: string, isGood: boolean): Observable<boolean> {
    const headers = this.getHeaders();
    const parms = new HttpParams()
      .set('message', message);

    const fbMessage = { message : message, isGood : isGood};

    return this.httpClient.post<boolean>(environment.feedbackApiUrl,
      fbMessage,
      {
        headers: headers,
        params: parms
      });
  }

Oh and we need a config setting for the service URL in environment:

export const environment = {
  production: true,
  ... 
  feedbackApiUrl: 'http://localhost:5001/api/v1/feedback'
};

And that's it for the front end.

The back end

This is even easier. I already had configuration to use AWS for S3 so I just needed to add the SNS api to the app:

Install-Package AWSSDK.SimpleNotificationService

I added some custom config to my appsettings.json:

  "Feedback": {
    "Enabled": true,
    "SNSTopic": "feedback.productname.prod"
  }

The SNSTopic should be the full ARN of the topic, not just the name - more on this later.

And a POCO to load this:

  public class FeedbackSettings
  {
    public bool Enabled { get; set; }
    public string SNSTopic { get; set; }
  }

In the app Startup we add all the bits to the service collection:

services.AddSingleton<FeedbackSettings>(c => 
  Configuration.GetSection("Feedback").Get<FeedbackSettings>());

var awsOptions = Configuration.GetAWSOptions();
services.AddDefaultAWSOptions(awsOptions);
services.AddAWSService<IAmazonSimpleNotificationService>();

Now I just need a controller:


namespace MyProduct.Api.Controllers
{
  // NOTE: NOT Authenticated - anon access is ok ??? Possible DOS attack??
  [Route("api/v1/feedback")]
  [Produces("application/json")]
  public class FeedbackController : Controller
  {
    private readonly UserContext _userContext;
    private readonly IAmazonSimpleNotificationService _sns;
    private readonly FeedbackSettings _settings;
    private readonly ILogger<FeedbackController> _logger;

    public FeedbackController(
      UserContext userContext,
      IAmazonSimpleNotificationService sns,
      FeedbackSettings settings,
      ILogger<FeedbackController> logger)
    {
      _userContext = userContext;
      _sns = sns;
      _settings = settings;
      _logger = logger;
    }

    [HttpPost]
    [ProducesResponseType((int)HttpStatusCode.OK)]
    [ProducesResponseType((int)HttpStatusCode.BadRequest)]
    public async Task<IActionResult> PostFeedback([FromBody] FeedbackMessage message)
    {
      var subject = message.IsGood ? "WebTicket:: Good Feedback" : "WebTicket:: Sad Feedback";
      var msg = $"From Org: {_userContext.OrganisationID} User: {_userContext.UserID} \n\n {message.Message}";
      _logger.LogInformation("FEEDBACK RECEIVED *** " + subject + " *** " + msg);
      if (_settings.Enabled)
      {
        var pr = new PublishRequest(_settings.SNSTopic, msg, subject);
        var resp = await _sns.PublishAsync(pr);
        if (resp.HttpStatusCode != HttpStatusCode.OK)
        {
          return BadRequest();
        }
      }
      return Ok(true);
    }
  }
}

A couple of notes here:

  • yes, this is an unauthenticated endpoint. Probably needs something else to protect it. I think I probably will require auth on this.
  • UserContext is gotten from the authentication ticket - it has empty Guids if the user is not authenticated.
  • The feedback is also logged

To send the message to SNS couldn't be easier. However, you need the full ARN, not just the name. There is a way around this that also avoids having to create the Topic in the first place. Simply call the CreateTopicAsync() method. If the topic already exists (or not) you will get the ARN returned with the result.

I chose to pre-create the topic using my Terraform script:

resource "aws_sns_topic" "feedback" {
  name = "${var.project-name}-feedback-${var.environment-name}"
}