teamdigitale/digital-citizenship-functions

View on GitHub
lib/controllers/profiles.ts

Summary

Maintainability
C
1 day
Test Coverage
/*
 * Implements the API handlers for the Profile resource.
 */
import * as express from "express";

import { isLeft, isRight } from "fp-ts/lib/Either";
import { isNone, isSome } from "fp-ts/lib/Option";
import { ExtendedProfile } from "../api/definitions/ExtendedProfile";
import { FiscalCode } from "../api/definitions/FiscalCode";
import { LimitedProfile } from "../api/definitions/LimitedProfile";
import { Profile as LimitedOrExtendedProfile } from "../api/definitions/Profile";

import {
  AzureUserAttributesMiddleware,
  IAzureUserAttributes
} from "io-functions-commons/dist/src/utils/middlewares/azure_user_attributes";

import {
  ClientIp,
  ClientIpMiddleware
} from "io-functions-commons/dist/src/utils/middlewares/client_ip_middleware";

import {
  AzureApiAuthMiddleware,
  IAzureApiAuthorization,
  UserGroup
} from "io-functions-commons/dist/src/utils/middlewares/azure_api_auth";
import { FiscalCodeMiddleware } from "io-functions-commons/dist/src/utils/middlewares/fiscalcode";

import {
  IRequestMiddleware,
  withRequestMiddlewares,
  wrapRequestHandler
} from "io-functions-commons/dist/src/utils/request_middleware";

import {
  IResponseErrorQuery,
  ResponseErrorQuery
} from "io-functions-commons/dist/src/utils/response";
import {
  IResponseErrorConflict,
  IResponseErrorInternal,
  IResponseErrorNotFound,
  IResponseErrorValidation,
  IResponseSuccessJson,
  ResponseErrorConflict,
  ResponseErrorFromValidationErrors,
  ResponseErrorInternal,
  ResponseErrorNotFound,
  ResponseSuccessJson
} from "italia-ts-commons/lib/responses";

import {
  checkSourceIpForHandler,
  clientIPAndCidrTuple as ipTuple
} from "io-functions-commons/dist/src/utils/source_ip_check";

import { Context } from "@azure/functions";

import {
  IProfileBlockedInboxOrChannels,
  Profile,
  ProfileModel,
  RetrievedProfile
} from "io-functions-commons/dist/src/models/profile";
import { ServiceModel } from "io-functions-commons/dist/src/models/service";
import { ContextMiddleware } from "io-functions-commons/dist/src/utils/middlewares/context_middleware";
import { BlockedInboxOrChannelEnum } from "../api/definitions/BlockedInboxOrChannel";
import { ServiceId } from "../api/definitions/ServiceId";

export interface IProfileCreatedEvent {
  readonly kind: "ProfileCreatedEvent";
  readonly fiscalCode: FiscalCode;
  readonly newProfile: ExtendedProfile;
}

export interface IProfileUpdatedEvent {
  readonly kind: "ProfileUpdatedEvent";
  readonly fiscalCode: FiscalCode;
  readonly oldProfile: ExtendedProfile;
  readonly newProfile: ExtendedProfile;
}

function toExtendedProfile(profile: RetrievedProfile): ExtendedProfile {
  return {
    accepted_tos_version: profile.acceptedTosVersion,
    blocked_inbox_or_channels: profile.blockedInboxOrChannels,
    email: profile.email,
    is_inbox_enabled: profile.isInboxEnabled === true,
    is_webhook_enabled: profile.isWebhookEnabled === true,
    preferred_languages: profile.preferredLanguages,
    version: profile.version
  };
}

/**
 * Whether the sender service is allowed to send
 * notififications to the user identified by this profile
 */
export function isSenderAllowed(
  blockedInboxOrChannels: IProfileBlockedInboxOrChannels | undefined,
  serviceId: ServiceId
): boolean {
  return (
    blockedInboxOrChannels === undefined ||
    blockedInboxOrChannels[serviceId] === undefined ||
    !blockedInboxOrChannels[serviceId].has(BlockedInboxOrChannelEnum.INBOX)
  );
}

function toLimitedProfile(
  profile: RetrievedProfile,
  senderAllowed: boolean
): LimitedProfile {
  return {
    preferred_languages: profile.preferredLanguages,
    // computed property
    sender_allowed: senderAllowed
  };
}

/**
 * Type of a GetProfile handler.
 *
 * GetProfile expects a FiscalCode as input and returns a Profile or
 * a Not Found error.
 */
type IGetProfileHandler = (
  auth: IAzureApiAuthorization,
  clientIp: ClientIp,
  attrs: IAzureUserAttributes,
  fiscalCode: FiscalCode
) => Promise<
  | IResponseSuccessJson<LimitedOrExtendedProfile>
  | IResponseErrorNotFound
  | IResponseErrorQuery
>;

/**
 * Type of an UpsertProfile handler.
 *
 * UpsertProfile expects a FiscalCode and a Profile as input and
 * returns a Profile or a Validation or a Generic error.
 */
type IUpsertProfileHandler = (
  context: Context,
  auth: IAzureApiAuthorization,
  clientIp: ClientIp,
  attrs: IAzureUserAttributes,
  fiscalCode: FiscalCode,
  profileModelPayload: ExtendedProfile
) => Promise<
  // tslint:disable-next-line:max-union-size
  | IResponseSuccessJson<ExtendedProfile>
  | IResponseErrorValidation
  | IResponseErrorQuery
  | IResponseErrorInternal
  | IResponseErrorConflict
>;

/**
 * Return a type safe GetProfile handler.
 */
export function GetProfileHandler(
  profileModel: ProfileModel
): IGetProfileHandler {
  return async (auth, _, userAttributes, fiscalCode) => {
    const errorOrMaybeProfile = await profileModel.findOneProfileByFiscalCode(
      fiscalCode
    );
    if (isRight(errorOrMaybeProfile)) {
      const maybeProfile = errorOrMaybeProfile.value;
      if (isSome(maybeProfile)) {
        const profile = maybeProfile.value;
        if (auth.groups.has(UserGroup.ApiFullProfileRead)) {
          // if the client is a trusted application we return the
          // extended profile
          return ResponseSuccessJson(toExtendedProfile(profile));
        } else {
          // or else, we return a limited profile
          return ResponseSuccessJson(
            toLimitedProfile(
              profile,
              isSenderAllowed(
                profile.blockedInboxOrChannels,
                userAttributes.service.serviceId
              )
            )
          );
        }
      } else {
        return ResponseErrorNotFound(
          "Profile not found",
          "The profile you requested was not found in the system."
        );
      }
    } else {
      return ResponseErrorQuery(
        "Error while retrieving the profile",
        errorOrMaybeProfile.value
      );
    }
  };
}

/**
 * Wraps a GetProfile handler inside an Express request handler.
 */
export function GetProfile(
  serviceModel: ServiceModel,
  profileModel: ProfileModel
): express.RequestHandler {
  const handler = GetProfileHandler(profileModel);
  const azureUserAttributesMiddleware = AzureUserAttributesMiddleware(
    serviceModel
  );
  const middlewaresWrap = withRequestMiddlewares(
    AzureApiAuthMiddleware(
      new Set([UserGroup.ApiLimitedProfileRead, UserGroup.ApiFullProfileRead])
    ),
    ClientIpMiddleware,
    azureUserAttributesMiddleware,
    FiscalCodeMiddleware
  );
  return wrapRequestHandler(
    middlewaresWrap(
      checkSourceIpForHandler(handler, (_, c, u, __) => ipTuple(c, u))
    )
  );
}

/**
 * A middleware that extracts a Profile payload from a request.
 */
export const ProfilePayloadMiddleware: IRequestMiddleware<
  "IResponseErrorValidation",
  ExtendedProfile
> = request =>
  new Promise(resolve => {
    const validation = ExtendedProfile.decode(request.body);
    const result = validation.mapLeft(
      ResponseErrorFromValidationErrors(ExtendedProfile)
    );
    resolve(result);
  });

async function createNewProfileFromPayload(
  profileModel: ProfileModel,
  fiscalCode: FiscalCode,
  profileModelPayload: ExtendedProfile
): Promise<IResponseSuccessJson<ExtendedProfile> | IResponseErrorQuery> {
  const profile: Profile = {
    acceptedTosVersion: profileModelPayload.accepted_tos_version,
    blockedInboxOrChannels: profileModelPayload.blocked_inbox_or_channels,
    email: profileModelPayload.email,
    fiscalCode,
    isInboxEnabled: profileModelPayload.is_inbox_enabled,
    isWebhookEnabled: profileModelPayload.is_webhook_enabled,
    preferredLanguages: profileModelPayload.preferred_languages
  };
  const errorOrProfile = await profileModel.create(profile, profile.fiscalCode);
  const errorOrProfileAsPublicExtendedProfile = errorOrProfile.map(
    toExtendedProfile
  );
  if (isRight(errorOrProfileAsPublicExtendedProfile)) {
    return ResponseSuccessJson(errorOrProfileAsPublicExtendedProfile.value);
  } else {
    return ResponseErrorQuery(
      "Error while creating a new profile",
      errorOrProfileAsPublicExtendedProfile.value
    );
  }
}

async function updateExistingProfileFromPayload(
  profileModel: ProfileModel,
  existingProfile: RetrievedProfile,
  profileModelPayload: ExtendedProfile
): Promise<
  | IResponseSuccessJson<ExtendedProfile>
  | IResponseErrorQuery
  | IResponseErrorInternal
> {
  const profile: Profile = {
    acceptedTosVersion: profileModelPayload.accepted_tos_version,
    blockedInboxOrChannels: profileModelPayload.blocked_inbox_or_channels,
    email: profileModelPayload.email,
    fiscalCode: existingProfile.fiscalCode,
    isInboxEnabled: profileModelPayload.is_inbox_enabled,
    isWebhookEnabled: profileModelPayload.is_webhook_enabled,
    preferredLanguages: profileModelPayload.preferred_languages
  };

  const errorOrMaybeProfile = await profileModel.update(
    existingProfile.id,
    existingProfile.fiscalCode,
    p => {
      return {
        ...p,
        ...profile
      };
    }
  );

  if (isLeft(errorOrMaybeProfile)) {
    return ResponseErrorQuery(
      "Error while updating the existing profile",
      errorOrMaybeProfile.value
    );
  }

  const maybeProfile = errorOrMaybeProfile.value;

  return maybeProfile.foldL<
    IResponseErrorInternal | IResponseSuccessJson<ExtendedProfile>
  >(
    () =>
      // this should never happen since if the profile doesn't exist this function
      // will never be called, but let's deal with this anyway, you never know
      ResponseErrorInternal(
        "Error while updating the existing profile, the profile does not exist!"
      ),
    p => ResponseSuccessJson(toExtendedProfile(p))
  );
}

/**
 * This handler will receive attributes for a profile and create a
 * profile with those attributes if the profile does not yet exist or
 * update the profile with it already exist.
 */
export function UpsertProfileHandler(
  profileModel: ProfileModel
): IUpsertProfileHandler {
  return async (context, _, __, ___, fiscalCode, profileModelPayload) => {
    const errorOrMaybeProfile = await profileModel.findOneProfileByFiscalCode(
      fiscalCode
    );
    if (isLeft(errorOrMaybeProfile)) {
      return ResponseErrorQuery("Error", errorOrMaybeProfile.value);
    }
    const maybeProfile = errorOrMaybeProfile.value;

    if (isNone(maybeProfile)) {
      // create a new profile
      const response = await createNewProfileFromPayload(
        profileModel,
        fiscalCode,
        profileModelPayload
      );
      // if we successfully created the user's profile
      // broadcast a profile-created event
      if (response.kind === "IResponseSuccessJson") {
        // tslint:disable-next-line:no-object-mutation
        context.bindings.profileEvent = {
          fiscalCode,
          kind: "ProfileCreatedEvent",
          newProfile: response.value
        };
      }
      return response;
    } else {
      const existingProfile = maybeProfile.value;
      // verify that the client asked to update the latest version
      if (profileModelPayload.version !== existingProfile.version) {
        return ResponseErrorConflict(
          `Version ${profileModelPayload.version} is not the latest version.`
        );
      }
      // update existing profile
      const response = await updateExistingProfileFromPayload(
        profileModel,
        existingProfile,
        profileModelPayload
      );
      // if we successfully updated the user's profile
      // broadcast a profile-updated event
      if (response.kind === "IResponseSuccessJson") {
        // tslint:disable-next-line:no-object-mutation
        context.bindings.profileEvent = {
          fiscalCode: existingProfile.fiscalCode,
          kind: "ProfileUpdatedEvent",
          newProfile: response.value,
          oldProfile: toExtendedProfile(existingProfile)
        };
      }
      return response;
    }
  };
}

/**
 * Wraps an UpsertProfile handler inside an Express request handler.
 */
export function UpsertProfile(
  serviceModel: ServiceModel,
  profileModel: ProfileModel
): express.RequestHandler {
  const handler = UpsertProfileHandler(profileModel);
  const azureUserAttributesMiddleware = AzureUserAttributesMiddleware(
    serviceModel
  );
  const middlewaresWrap = withRequestMiddlewares(
    ContextMiddleware(),
    AzureApiAuthMiddleware(new Set([UserGroup.ApiProfileWrite])),
    ClientIpMiddleware,
    azureUserAttributesMiddleware,
    FiscalCodeMiddleware,
    ProfilePayloadMiddleware
  );
  return wrapRequestHandler(
    middlewaresWrap(
      checkSourceIpForHandler(handler, (_, __, c, u, ___, ____) =>
        ipTuple(c, u)
      )
    )
  );
}