0.1.6Updated a month ago
import type { Ledger } from "@ledger/mod.ts";
import type { BodyValidationVerifyFunction } from "@infinity-beyond/utils/handle_body.ts";

import { Paginate, PaginationParams } from "@infinity-beyond/modules/pagination.ts";
import { REST_Wrapper } from "@infinity-beyond/classes/rest_wrapper.ts";
import { HandleBody } from "@infinity-beyond/mod.ts";
import { FeatureFlag } from "@infinity-beyond/modules/features/feature_flag.ts";
import { GetDracoUser } from "@infinity-beyond/modules/security/draco/get_draco_user.ts";
import { Infinity } from "@infinity-beyond/modules/infinity.ts";
import dayjs from "npm:dayjs";

export class LedgerRest<EntityName extends string> extends REST_Wrapper {
  instance: Ledger<EntityName, any>

  constructor(instance: Ledger<EntityName, any>) {
    super();

    this.instance = instance;

    // #region POST /

    this.post('/', async (req) => {
      const { body, error_response} = await HandleBody<Ledger.Entry>(req, (verify, entry) => {
        ValidateLedgerKey(verify, instance, entry);
        ValidateLedgerAmount(verify, entry, { min: 1, max: 50 });
        ValidateLedgerSource(verify, entry);
      });
  
      if(error_response) return error_response;
  
      const response = await instance.ConsumeEntries({
        key: body.key,
        amount: body.amount,
        source: body.source,
        description: body.description,
        reference: body.reference,
        request_id: req.state.uuid
      });

      return Response.json(response);
    });

    // #region GET /:key

    this.get('/:key', async (req, ctx) => {
      const { key, valid, reason } = instance.format_and_validate(ctx.params.key);

      if(!valid) return Response.json({errors: [`[key] ${reason || 'invalid format'}`]}, { status: 400 });

      const { page, page_size } = PaginationParams(req);

      let {
        balance,
        entry_count,
        history,
      } = await instance.UserData(key, page_size, page) ?? {};

      balance ??= 0;
      entry_count ??= 0;
      history ??= [];

      return Response.json(Paginate(req, history || [], entry_count, { balance, total_entries: entry_count }));
    });

    // #region POST /:key

    this.post('/:key', async (req, ctx) => {
      const { body, error_response} = await HandleBody<Ledger.Entry>(req, (verify, entry) => {
        entry.key = ctx.params.key;

        ValidateLedgerKey(verify, instance, entry);
        ValidateLedgerAmount(verify, entry, { min: 1, max: 50 });
        ValidateLedgerSource(verify, entry);
      });
  
      const key = instance.format_key(ctx.params.key);

      if(!instance.validate_key(key)) return Response.json('[key] invalid format', { status: 400 });

      if(error_response) return error_response;
  
      const response = await instance.ConsumeEntries({
        key,
        amount: body.amount,
        source: body.source,
        description: body.description,
        reference: body.reference,
        request_id: req.state.uuid
      });

      return Response.json(response);
    });

    // #region GET /:key/entry/:entry_id

    this.get('/:key/entry/:entry_id', async (_req, ctx) => {
      const key = ctx.params.key;
      const entry_id  = Number(ctx.params.entry_id);
      if(!Number.isSafeInteger(entry_id)) return Response.json({}, { status: 404 });

      const entry_data = await instance.EntryData(entry_id);
      if(!entry_data) return Response.json({}, { status: 404 });

      if(entry_data.entry.key !== key) return Response.json({}, { status: 404 });

      return Response.json(entry_data);
    });

    // #region POST /admin/award

    this.post('/admin/award', async (req, ctx) => {
      Infinity.Cookie(req, ctx);
      // const user = await GetDracoUser(ctx.state.cookie_uuid);
      // if(!user) return Response.json({ errors: ['Unauthorized'] }, { status: 401 });
      // if(!user?.has_permission(`VIEW_${instance.name.toUpperCase()}`)) return Response.json({ errors: ['Insufficient permissions'] }, { status: 403 });

      const { body, error_response } = await HandleBody<Ledger.Entry>(req, (verify, entry) => {
        ValidateLedgerKey(verify, instance, entry);
        ValidateLedgerAmount(verify, entry, { min: 1, max: 50 });
        ValidateLedgerSource(verify, entry);
        ValidateLedgerReason(verify, entry);

        verify(FeatureFlag.get('ADMIN_API', 'Enables `/admin` API routes for all data types')).fail('[SYSTEM] Admin API is disabled');
      })

      if(!instance.options?.rest?.enable_admin_api) return Response.json({
        key: body?.key,
        errors: [`The admin API for ${instance.name} is not enabled`]
      }, { status: 403 });

      if(error_response) return error_response;

      const add_response = await instance.AddEntry({
        key: body.key,
        amount: body.amount,
        source: body.source,
        request_id: req.state.uuid,
        description: body.description,
        reason: body.reason,
      })

      if(add_response.added) {
        return Response.json({
          key: body.key,
          ...add_response
        });
      }

      return Response.json({
        key: body.key,
        ...add_response
      }, { status: 400 });
    })

    // #region Stats & Charts

    const MetaChartAPI = async (field: string, req: Request, ctx: InfinityContext) => {
      Infinity.Cookie(req, ctx);
      const user = await GetDracoUser(ctx.state.cookie_uuid);
      if(!user) return Response.json({ errors: ['Unauthorized'] }, { status: 401 });
      if(!user?.has_permission(`VIEW_${instance.name.toUpperCase()}`)) return Response.json({ errors: ['Insufficient permissions'] }, { status: 403 });

      const params = new URL(req.url).searchParams;

      const param_values = {
        months: validInteger(params.get('months')) || null,
        weeks: validInteger(params.get('weeks')) || null,
        days: validInteger(params.get('days')) || null,
        hours: validInteger(params.get('hours')) || null,
        minutes: validInteger(params.get('minutes')) || null,
        start: validDate(params.get('start')) || null,
        end: validDate(params.get('end')) || null,
      }

      let start_date = param_values.start || dayjs().add(-6, 'hours').toDate();
      let end_date = param_values.end || new Date();

      let range: 'minute' | 'hour' | 'day' = 'minute';

      if(param_values.months !== null) {
        start_date = dayjs().subtract(param_values.months, 'months').toDate();
        end_date = new Date();
      } else if(param_values.weeks !== null) {
        start_date = dayjs().subtract(param_values.weeks, 'weeks').toDate();
        end_date = new Date();
      } else if(param_values.days !== null) {
        start_date = dayjs().subtract(param_values.days, 'days').toDate();
        end_date = new Date();
      } else if(param_values.hours !== null) {
        start_date = dayjs().subtract(param_values.hours, 'hours').toDate();
        end_date = new Date();
      } else if(param_values.minutes !== null) {
        start_date = dayjs().subtract(param_values.minutes, 'minutes').toDate();
        end_date = new Date();
      }

      const date_range_diff = dayjs(end_date).diff(start_date, 'days');
      if(date_range_diff >= 60) {
        range = 'day';
      } else if(date_range_diff >= 2) {
        range = 'hour';
      }

      const data = await instance.meta.snapshots({
        field,
        start: start_date,
        end: end_date,
        range,
      }) as Chart.DataPoint[];

      // let running = ~~(Math.random() * 2000 + 5000);
      // for(const entry of data) {
      //   entry.value = running += ~~(Math.random() * 100 - 49);
      // }

      return Response.json({ data });
    }

    this.get('/summary/aggregate', async (req, ctx) => {
      return await MetaChartAPI('aggregate_total', req, ctx);
    })

    this.get('/summary/additions', async (req, ctx) => {
      return await MetaChartAPI('total_additions', req, ctx);
    })

    this.get('/summary/consumptions', async (req, ctx) => {
      return await MetaChartAPI('total_consumptions', req, ctx);
    })

    this.get('/summary/expirations', async (req, ctx) => {
      return await MetaChartAPI('total_expirations', req, ctx);
    })

  }
}

const ValidateLedgerKey = (verify: BodyValidationVerifyFunction, instance: Ledger<any>, entry: Ledger.Entry) => {
  verify(!!entry.key).fail('[key] not provided');

  const { valid, reason } = instance.format_and_validate(entry.key);

  verify(valid).fail(reason || '[key] invalid');
}

const ValidateLedgerAmount = (verify: BodyValidationVerifyFunction, entry: Ledger.Entry, options: { min?: number, max?: number, integer?: boolean } = {}) => {
  verify(!!entry.amount).fail('[amount] not provided');
  if(options.integer) verify(Number.isSafeInteger(entry.amount)).fail('[amount] must be an integer');
  if(typeof options.min == 'number') verify(entry.amount >= options.min).fail(`[amount] must be more than or equal to ${options.min}`);
  if(typeof options.max == 'number') verify(entry.amount <= options.max).fail(`[amount] must be less than or equal to ${options.max}`);
}

const ValidateLedgerSource = (verify: BodyValidationVerifyFunction, entry: Ledger.Entry) => {
  verify(typeof entry.source == 'string').fail('[source] must be a string');
  verify(!!entry.source).fail('[source] not provided');
}

const ValidateLedgerReason = (verify: BodyValidationVerifyFunction, entry: Ledger.Entry) => {
  verify(!!entry.reason).fail('[reason] not provided');
  verify(typeof entry.reason == 'string').fail('[reason] must be a string');
}

const validInteger = (input: string | null) => Number.isSafeInteger(Number(input)) && Number(input);
const validDate = (input: string | null) => dayjs(input).isValid() && dayjs(input).toDate();