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();