Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Bitly integration #732

Merged
merged 15 commits into from
Apr 18, 2024
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,22 @@ public IActionResult GetPublishedOrExpiredById([FromRoute] Guid id)
return StatusCode((int)HttpStatusCode.OK, result);
}

[SwaggerOperation(Summary = "Get sharing details for published or expired opportunity by id (Anonymous)")]
[HttpGet("{id}/sharing")]
[ProducesResponseType(typeof(OpportunityInfo), (int)HttpStatusCode.OK)]
[ProducesResponseType((int)HttpStatusCode.NotFound)]
[AllowAnonymous]
public async Task<IActionResult> GetSharingDetails([FromRoute] Guid id, [FromQuery] bool? includeQRCode)
{
_logger.LogInformation("Handling request {requestName}", nameof(GetSharingDetails));

var result = await _opportunityService.GetSharingDetails(id, true, includeQRCode);

_logger.LogInformation("Request {requestName} handled", nameof(GetSharingDetails));

return StatusCode((int)HttpStatusCode.OK, result);
}

[SwaggerOperation(Summary = "Search for published opportunities based on the supplied filter (Anonymous)",
Description = "Results are always associated with an active organization. By default published opportunities are included, thus active opportunities, irrespective of whether they started (includes both NotStarted and Active states). This default behavior is overridable")]
[HttpPost("search")]
Expand Down Expand Up @@ -204,13 +220,30 @@ public IActionResult GetInfoById([FromRoute] Guid id)
{
_logger.LogInformation("Handling request {requestName}", nameof(GetInfoById));

//by default, all users possess the user role. Therefore, organizational authorization checks are omitted here, allowing org admins to access information for all opportunities without restriction.
//by default, all users possess the user role. Therefore, organizational authorization checks are omitted here, allowing org admins to access information for all opportunities without restriction
var result = _opportunityInfoService.GetById(id, false);

_logger.LogInformation("Request {requestName} handled", nameof(GetInfoById));

return StatusCode((int)HttpStatusCode.OK, result);
}

[SwaggerOperation(Summary = "Get sharing details for the specified opportunity by id (User, Admin or Organization Admin role required)")]
[HttpGet("{id}/auth/sharing")]
[ProducesResponseType(typeof(OpportunityInfo), (int)HttpStatusCode.OK)]
[ProducesResponseType((int)HttpStatusCode.NotFound)]
[Authorize(Roles = $"{Constants.Role_User}, {Constants.Role_Admin}, {Constants.Role_OrganizationAdmin}")]
public async Task<IActionResult> GetSharingDetailsAuthed([FromRoute] Guid id, [FromQuery] bool? includeQRCode)
{
_logger.LogInformation("Handling request {requestName}", nameof(GetSharingDetailsAuthed));

//by default, all users possess the user role. Therefore, organizational authorization checks are omitted here, allowing org admins to access information for all opportunities without restriction
var result = await _opportunityService.GetSharingDetails(id, false, includeQRCode);

_logger.LogInformation("Request {requestName} handled", nameof(GetSharingDetailsAuthed));

return StatusCode((int)HttpStatusCode.OK, result);
}
#endregion

#region Administrative Actions
Expand Down
6 changes: 3 additions & 3 deletions src/api/src/application/Yoma.Core.Api/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"SentryEnabledEnvironments": "Development, Staging, Production",
"HttpsRedirectionEnabledEnvironments": null,
"LaborMarketProviderAsSourceEnabledEnvironments": "Production",
"ShortLinkProviderAsSourceEnabledEnvironments": "Development, Staging, Production",
"DatabaseRetryPolicy": {
"MaxRetryCount": 6,
"MaxRetryDelayInSeconds": 30
Expand Down Expand Up @@ -234,10 +235,9 @@
},

"Bitly": {
"BaseUrl": "https://api-ssl.bitly.com",
"GroupId": "{groupid}",
"ApiKey": "{apikey}",
"ShortLinkType": "CustomDomainAndBackHalf",
"DomainCustom": "go.yoma.world",
"Tags": [ "Yoma", "Opportunity", "Shortlink" ]
"CustomDomain": "go.yoma.world"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ public static IFlurlRequest WithAuthHeaders(this Url url, Dictionary<string, str
/// </summary>
/// <param name="response"></param>
/// <returns></returns>
public static async Task<IFlurlResponse> EnsureSuccessStatusCodeAsync(this Task<IFlurlResponse> response)
public static async Task<IFlurlResponse> EnsureSuccessStatusCodeAsync(this Task<IFlurlResponse> response, List<HttpStatusCode>? AdditionalSuccessStatusCodes = null)
{
IFlurlResponse resp;

Expand All @@ -66,7 +66,9 @@ public static async Task<IFlurlResponse> EnsureSuccessStatusCodeAsync(this Task<
var statusCode = (HttpStatusCode)resp.StatusCode;
var message = await resp.ResponseMessage.Content.ReadAsStringAsync();

if (statusCode == HttpStatusCode.OK) return resp;
var successStatusCodes = AdditionalSuccessStatusCodes ?? [];
if (!successStatusCodes.Contains(HttpStatusCode.OK)) successStatusCodes.Add(HttpStatusCode.OK);
if (successStatusCodes.Contains(statusCode)) return resp;

throw new HttpClientException(statusCode, message);
}
Expand Down
19 changes: 19 additions & 0 deletions src/api/src/domain/Yoma.Core.Domain/Core/Helpers/QRCodeHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using QRCoder;

namespace Yoma.Core.Domain.Core.Helpers
{
public static class QRCodeHelper
{
public static string GenerateQRCodeBase64(string url, QRCodeGenerator.ECCLevel ecc = QRCodeGenerator.ECCLevel.Q, int pixelsPerModule = 20)
{
using var qrGenerator = new QRCodeGenerator();
var qrCodeData = qrGenerator.CreateQrCode(url, ecc);

using var qrCode = new PngByteQRCode(qrCodeData);
var qrCodeAsBytes = qrCode.GetGraphic(pixelsPerModule);

var base64String = Convert.ToBase64String(qrCodeAsBytes);
return $"data:image/png;base64,{base64String}";
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,10 @@ public CacheItemType CacheEnabledByCacheItemTypesAsEnum

public Environment LaborMarketProviderAsSourceEnabledEnvironmentsAsEnum => ParseEnvironmentInput(LaborMarketProviderAsSourceEnabledEnvironments);

public string ShortLinkProviderAsSourceEnabledEnvironments { get; set; }

public Environment ShortLinkProviderAsSourceEnabledEnvironmentsAsEnum => ParseEnvironmentInput(ShortLinkProviderAsSourceEnabledEnvironments);

public AppSettingsDatabaseRetryPolicy DatabaseRetryPolicy { get; set; }

public bool? RedisSSLCertificateValidationBypass { get; set; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ public async Task ProcessDeclination()
using (JobStorage.Current.GetConnection().AcquireDistributedLock(lockIdentifier, lockDuration))
{
_logger.LogInformation("Lock '{lockIdentifier}' acquired by {hostName} at {dateStamp}. Lock duration set to {lockDurationInMinutes} minutes",
lockIdentifier, System.Environment.MachineName, DateTimeOffset.UtcNow, lockDuration.TotalMinutes);
lockIdentifier, Environment.MachineName, DateTimeOffset.UtcNow, lockDuration.TotalMinutes);

_logger.LogInformation("Processing organization declination");

Expand Down Expand Up @@ -179,7 +179,7 @@ public async Task ProcessDeletion()
using (JobStorage.Current.GetConnection().AcquireDistributedLock(lockIdentifier, lockDuration))
{
_logger.LogInformation("Lock '{lockIdentifier}' acquired by {hostName} at {dateStamp}. Lock duration set to {lockDurationInMinutes} minutes",
lockIdentifier, System.Environment.MachineName, DateTimeOffset.UtcNow, lockDuration.TotalMinutes);
lockIdentifier, Environment.MachineName, DateTimeOffset.UtcNow, lockDuration.TotalMinutes);

_logger.LogInformation("Processing organization deletion");

Expand Down Expand Up @@ -240,7 +240,7 @@ public async Task SeedLogoAndDocuments()
using (JobStorage.Current.GetConnection().AcquireDistributedLock(lockIdentifier, lockDuration))
{
_logger.LogInformation("Lock '{lockIdentifier}' acquired by {hostName} at {dateStamp}. Lock duration set to {lockDurationInMinutes} minutes",
lockIdentifier, System.Environment.MachineName, DateTimeOffset.UtcNow, lockDuration.TotalMinutes);
lockIdentifier, Environment.MachineName, DateTimeOffset.UtcNow, lockDuration.TotalMinutes);

if (!_appSettings.TestDataSeedingEnvironmentsAsEnum.HasFlag(_environmentProvider.Environment))
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ public async Task SeedPhotos()
using (JobStorage.Current.GetConnection().AcquireDistributedLock(lockIdentifier, lockDuration))
{
_logger.LogInformation("Lock '{lockIdentifier}' acquired by {hostName} at {dateStamp}. Lock duration set to {lockDurationInMinutes} minutes",
lockIdentifier, System.Environment.MachineName, DateTimeOffset.UtcNow, lockDuration.TotalMinutes);
lockIdentifier, Environment.MachineName, DateTimeOffset.UtcNow, lockDuration.TotalMinutes);

if (!_appSettings.TestDataSeedingEnvironmentsAsEnum.HasFlag(_environmentProvider.Environment))
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ public async Task SeedSkills(bool onStartupInitialSeeding)
using (JobStorage.Current.GetConnection().AcquireDistributedLock(lockIdentifier, lockDuration))
{
_logger.LogInformation("Lock '{lockIdentifier}' acquired by {hostName} at {dateStamp}. Lock duration set to {lockDurationInMinutes} minutes",
lockIdentifier, System.Environment.MachineName, DateTimeOffset.UtcNow, lockDuration.TotalMinutes);
lockIdentifier, Environment.MachineName, DateTimeOffset.UtcNow, lockDuration.TotalMinutes);

try
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,20 @@ public static int TimeIntervalToDays(this Models.Opportunity opportunity)
return days;
}

public static (bool result, string? message) PublishedOrExpired(this Models.Opportunity opportunity)
{
ArgumentNullException.ThrowIfNull(opportunity, nameof(opportunity));

if (opportunity.OrganizationStatus != Entity.OrganizationStatus.Active)
return (false, $"Opportunity with id '{opportunity.Id}' belongs to an inactive organization");

var statuses = new List<Status>() { Status.Active, Status.Expired }; //ignore DateStart, includes both not started and started
if (!statuses.Contains(opportunity.Status))
return (false, $"Opportunity with id '{opportunity.Id}' has an invalid status. Expected status(es): '{string.Join(", ", statuses)}'");

return (true, null);
}

public static void SetPublished(this Models.Opportunity opportunity)
{
ArgumentNullException.ThrowIfNull(opportunity, nameof(opportunity));
Expand All @@ -65,6 +79,11 @@ public static OpportunitySearchCriteriaItem ToOpportunitySearchCriteria(this Mod
};
}

public static string YomaInfoURL(this Models.Opportunity value, string appBaseURL)
{
return appBaseURL.AppendPathSegment("opportunities").AppendPathSegment(value.Id).ToString();
}

public static OpportunityInfo ToOpportunityInfo(this Models.Opportunity value, string appBaseURL)
{
ArgumentNullException.ThrowIfNull(value, nameof(value));
Expand All @@ -84,6 +103,7 @@ public static OpportunityInfo ToOpportunityInfo(this Models.Opportunity value, s
Summary = value.Summary,
Instructions = value.Instructions,
URL = value.URL,
ShortURL = value.ShortURL,
ZltoReward = value.ZltoReward,
YomaReward = value.YomaReward,
VerificationEnabled = value.VerificationEnabled,
Expand All @@ -101,7 +121,7 @@ public static OpportunityInfo ToOpportunityInfo(this Models.Opportunity value, s
DateStart = value.DateStart,
DateEnd = value.DateEnd,
Published = value.Published,
YomaInfoURL = appBaseURL.AppendPathSegment("opportunities").AppendPathSegment(value.Id).ToString(),
YomaInfoURL = value.YomaInfoURL(appBaseURL),
Categories = value.Categories,
Countries = value.Countries,
Languages = value.Languages,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ public interface IOpportunityService

Models.Opportunity? GetByTitleOrNull(string title, bool includeChildItems, bool includeComputed);

Task<OpportunitySharingResult> GetSharingDetails(Guid id, bool publishedOrExpiredOnly, bool? includeQRCode);

List<Models.Opportunity> Contains(string value, bool includeComputed);

OpportunitySearchResultsCriteria SearchCriteriaOpportunities(OpportunitySearchFilterCriteria filter, bool ensureOrganizationAuthorization);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ public class Opportunity

public string? URL { get; set; }

public string? ShortURL { get; set; }

public decimal? ZltoReward { get; set; }

public decimal? ZltoRewardPool { get; set; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ public class OpportunityInfo

public string? URL { get; set; }

public string? ShortURL { get; set; }

[Name("Zlto Reward")]
public decimal? ZltoReward { get; set; }

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace Yoma.Core.Domain.Opportunity.Models
{
public class OpportunitySharingResult
{
public string ShortURL { get; set; }

public string? QRCodeBase64 { get; set; }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ public async Task ProcessExpiration()
using (JobStorage.Current.GetConnection().AcquireDistributedLock(lockIdentifier, lockDuration))
{
_logger.LogInformation("Lock '{lockIdentifier}' acquired by {hostName} at {dateStamp}. Lock duration set to {lockDurationInMinutes} minutes",
lockIdentifier, System.Environment.MachineName, DateTimeOffset.UtcNow, lockDuration.TotalMinutes);
lockIdentifier, Environment.MachineName, DateTimeOffset.UtcNow, lockDuration.TotalMinutes);

_logger.LogInformation("Processing opportunity expiration");

Expand Down Expand Up @@ -134,7 +134,7 @@ public async Task ProcessExpirationNotifications()
using (JobStorage.Current.GetConnection().AcquireDistributedLock(lockIdentifier, lockDuration))
{
_logger.LogInformation("Lock '{lockIdentifier}' acquired by {hostName} at {dateStamp}. Lock duration set to {lockDurationInMinutes} minutes",
lockIdentifier, System.Environment.MachineName, DateTimeOffset.UtcNow, lockDuration.TotalMinutes);
lockIdentifier, Environment.MachineName, DateTimeOffset.UtcNow, lockDuration.TotalMinutes);

_logger.LogInformation("Processing opportunity expiration notifications");

Expand Down Expand Up @@ -184,7 +184,7 @@ public async Task ProcessDeletion()
using (JobStorage.Current.GetConnection().AcquireDistributedLock(lockIdentifier, lockDuration))
{
_logger.LogInformation("Lock '{lockIdentifier}' acquired by {hostName} at {dateStamp}. Lock duration set to {lockDurationInMinutes} minutes",
lockIdentifier, System.Environment.MachineName, DateTimeOffset.UtcNow, lockDuration.TotalMinutes);
lockIdentifier, Environment.MachineName, DateTimeOffset.UtcNow, lockDuration.TotalMinutes);

_logger.LogInformation("Processing opportunity deletion");

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,13 @@ public OpportunityInfo GetPublishedOrExpiredById(Guid id)
{
var opportunity = _opportunityService.GetById(id, true, true, false);

//inactive organization
if (opportunity.OrganizationStatus != Entity.OrganizationStatus.Active)
throw new EntityNotFoundException($"Opportunity with id '{id}' belongs to an inactive organization");

//status criteria not met
var statuses = new List<Status>() { Status.Active, Status.Expired }; //ignore DateStart, includes both not started and started
if (!statuses.Contains(opportunity.Status))
throw new EntityNotFoundException($"Opportunity with id '{id}' has an invalid status. Expected status(es): '{string.Join(", ", statuses)}'");
var (publishedOrExpiredResult, message) = opportunity.PublishedOrExpired();

if (!publishedOrExpiredResult)
{
ArgumentException.ThrowIfNullOrEmpty(message);
throw new EntityNotFoundException(message);
}

var result = opportunity.ToOpportunityInfo(_appSettings.AppBaseURL);
SetParticipantCounts(result);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
using Yoma.Core.Domain.Opportunity.Interfaces.Lookups;
using Yoma.Core.Domain.Opportunity.Models;
using Yoma.Core.Domain.Opportunity.Validators;
using Yoma.Core.Domain.ShortLinkProvider.Interfaces;

namespace Yoma.Core.Domain.Opportunity.Services
{
Expand All @@ -51,6 +52,7 @@ public class OpportunityService : IOpportunityService
private readonly IEmailURLFactory _emailURLFactory;
private readonly IEmailProviderClient _emailProviderClient;
private readonly IIdentityProviderClient _identityProviderClient;
private readonly IShortLinkProviderClient _shortLinkProviderClient;

private readonly OpportunityRequestValidatorCreate _opportunityRequestValidatorCreate;
private readonly OpportunityRequestValidatorUpdate _opportunityRequestValidatorUpdate;
Expand Down Expand Up @@ -94,6 +96,7 @@ public OpportunityService(ILogger<OpportunityService> logger,
IEmailURLFactory emailURLFactory,
IEmailProviderClientFactory emailProviderClientFactory,
IIdentityProviderClientFactory identityProviderClientFactory,
IShortLinkProviderClientFactory shortLinkProviderClientFactory,
OpportunityRequestValidatorCreate opportunityRequestValidatorCreate,
OpportunityRequestValidatorUpdate opportunityRequestValidatorUpdate,
OpportunitySearchFilterValidator opportunitySearchFilterValidator,
Expand Down Expand Up @@ -126,6 +129,7 @@ public OpportunityService(ILogger<OpportunityService> logger,
_emailURLFactory = emailURLFactory;
_emailProviderClient = emailProviderClientFactory.CreateClient();
_identityProviderClient = identityProviderClientFactory.CreateClient();
_shortLinkProviderClient = shortLinkProviderClientFactory.CreateClient();

_opportunityRequestValidatorCreate = opportunityRequestValidatorCreate;
_opportunityRequestValidatorUpdate = opportunityRequestValidatorUpdate;
Expand Down Expand Up @@ -194,6 +198,46 @@ public Models.Opportunity GetById(Guid id, bool includeChildItems, bool includeC
return result;
}

public async Task<OpportunitySharingResult> GetSharingDetails(Guid id, bool publishedOrExpiredOnly, bool? includeQRCode)
{
if (id == Guid.Empty)
throw new ArgumentNullException(nameof(id));

var opportunity = GetById(id, false, false, false);

if (publishedOrExpiredOnly)
{
var (result, message) = opportunity.PublishedOrExpired();

if (!result)
{
ArgumentException.ThrowIfNullOrEmpty(message);
throw new EntityNotFoundException(message);
}
}

if (string.IsNullOrEmpty(opportunity.ShortURL))
{
var request = new ShortLinkProvider.Models.ShortLinkRequest
{
Type = ShortLinkProvider.EntityType.Opportunity,
Action = ShortLinkProvider.Action.Sharing,
Title = opportunity.Title,
URL = opportunity.YomaInfoURL(_appSettings.AppBaseURL)
};

var response = await _shortLinkProviderClient.CreateShortLink(request);
opportunity.ShortURL = response.Link;
await _opportunityRepository.Update(opportunity);
}

return new OpportunitySharingResult
{
ShortURL = opportunity.ShortURL,
QRCodeBase64 = includeQRCode == true ? QRCodeHelper.GenerateQRCodeBase64(opportunity.ShortURL) : null
};
}

public List<Models.Opportunity> Contains(string value, bool includeComputed)
{
if (string.IsNullOrWhiteSpace(value))
Expand Down
Loading
Loading