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

Feature/get expiration #19

Closed
wants to merge 9 commits into from
164 changes: 123 additions & 41 deletions web-app/pages/api/cert/[imageFileName].ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,32 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from 'next';
import { createCanvas } from 'canvas';
import { Dayjs } from 'dayjs';
import dayjs from 'dayjs';
import { getSimpleStringFromParam } from '../../../helpers/strings';
import { getNftContract, NFT } from '../mint-cert';
import { getNearAccountWithoutAccountIdOrKeyStoreForBackend } from '../../../helpers/near';
import { height, populateCert, width } from '../../../helpers/certificate-designs';
import prisma from '../../../helpers/prisma';
import { addCacheHeader } from '../../../helpers/caching';
import { convertTimestampDecimalToDayjsMoment, formatDate } from '../../../helpers/time';
import { formatDate } from '../../../helpers/time';

export const HTTP_ERROR_CODE_MISSING = 404;
const svg = 'svg';
const dot = '.';
const imagePng = 'image/png';
const expirationMonths = 6;
const expirationDays = 180;
const QUERY_DEFAULT_CELL_VALUE = 1234567890;
const CACHE_SECONDS: number = Number(process.env.DYNAMIC_CERT_IMAGE_GENERATION_CACHE_SECONDS) || 60 * 60 * 6;

type CanvasTypeDef = 'pdf' | 'svg' | undefined;
type BufferTypeDef = 'image/png' | undefined;
type RawQueryResult = [
{
moment: string;
diff_to_previous: number;
diff_from_last_activity_to_render_date: number;
},
];

function parseFileName(imageFileNameString: string) {
const extension = imageFileNameString.split(dot).pop(); // https://stackoverflow.com/a/1203361/470749
Expand All @@ -41,46 +49,115 @@ async function generateImage(canvasType: CanvasTypeDef, bufferType: BufferTypeDe
return buffer;
}

async function getMostRecentActivityDateTime(accountName: string): Promise<Dayjs> {
// eslint-disable-next-line max-lines-per-function
async function getExpiration(accountName: string, issuedAt: string): Promise<string> {
// TODO: Shorten comments
// Pulls from the public indexer. https://github.com/near/near-indexer-for-explorer#shared-public-access
/* This function includes an ugly temporary workaround. `findFirst` (instead of findMany with `take: 2`) should have worked but was causing a timeout.
Apparently it's not Prisma's fault though.Even running a LIMIT 1 query in pgAdmin causes a timeout, surprisingly, even though a LIMIT 2 query does not.
https://stackoverflow.com/questions/71026316/why-would-limit-2-queries-work-but-limit-1-always-times-out
This is suspicious but seems unrelated to this repo. */
try {
const rows = await prisma.receipts.findMany({
where: {
action_receipts: {
// https://www.prisma.io/docs/concepts/components/prisma-client/filtering-and-sorting#filter-on-relations
signer_account_id: {
equals: accountName,
},
},
},
orderBy: {
included_in_block_timestamp: 'desc',
},
take: 2, // see comment above about workaround
});
if (rows) {
const result = rows[0]; // see comment above about workaround
console.log({ accountName, result });
const moment = convertTimestampDecimalToDayjsMoment(result.included_in_block_timestamp);
console.log({ accountName, result, moment }, moment.format());
return moment;
}
} catch (error) {
console.error({ error });
}

throw new Error(
'getMostRecentActivityDateTime did not produce a result. Perhaps a certificate for the original_recipient_id could not be found or the public indexer query timed out.',
);
}
/**
* This query uses Common Table Expressions(CTE) to execute two separate queries;
* the second query being executed if first query doesn't return any result.
* https://www.postgresql.org/docs/9.1/queries-with.html
* https://stackoverflow.com/a/68684814/10684149
*/
/**
* First query checks If the account has a period where it hasn't been active for 180 days straight after the issue date (exluding the render date)
* Second query is run if if no 180-day-inactivity period is found and returns most recent activity date
* AND amount of days between account's last activity date and render date of certificate
*/
/**
* Both queries produce the same temporary table, therefore all cell data types must match.
* Both queries show a date in the moment column, but differ in diff_from_last_activity_to_render_date and diff_to_previous columns
* To make a distinction between results, a 'password' is used which is 1234567890
*/
/**
* If first query returns a result, diff_from_last_activity_to_render_date is set to show 1234567890
* If second query returns a result, diff_to_previous is set to show 1234567890
*/
/**
* Both diff_from_last_activity_to_render_date and diff_to_previous show days as integers
* The 'password' used as 1234567890 days equals to 3382377.780822 years
* Using this value should be fine for 3382378 years.
*/

const issuedAtUnixNano = dayjs(issuedAt).unix() * 1_000_000_000;
console.log({ issuedAt, issuedAtUnixNano, accountName });
// https://www.prisma.io/docs/concepts/components/prisma-client/raw-database-access#queryraw
const result: RawQueryResult = await prisma.$queryRaw<RawQueryResult>`
WITH long_period_of_inactivity AS (
SELECT
moment,
diff_to_previous,
1234567890 AS diff_from_last_activity_to_render_date
FROM (
SELECT *,
/* 1 day = 60sec * 60min * 24h = 86400 sec*/
((EXTRACT(epoch FROM moment) - EXTRACT(epoch FROM lag(moment) over (ORDER BY moment))) / 86400)::int
AS diff_to_previous
FROM (
SELECT TO_TIMESTAMP(R."included_in_block_timestamp"/1000000000) as moment
FROM PUBLIC.RECEIPTS R
LEFT OUTER JOIN PUBLIC.ACTION_RECEIPTS AR ON R.RECEIPT_ID = AR.RECEIPT_ID
WHERE SIGNER_ACCOUNT_ID = ${accountName}
AND R."included_in_block_timestamp" > ${issuedAtUnixNano}
) as t1
) as t2
WHERE (diff_to_previous > ${expirationDays})
ORDER BY moment ASC
LIMIT 1
), most_recent_activity AS (
SELECT TO_TIMESTAMP(receipt."included_in_block_timestamp"/1000000000) as moment,
1234567890,
((EXTRACT(epoch FROM CURRENT_TIMESTAMP) - EXTRACT(epoch FROM TO_TIMESTAMP(receipt."included_in_block_timestamp"/1000000000))) / 86400)::int
AS diff_from_last_activity_to_render_date
FROM (
SELECT *
FROM PUBLIC.receipts R
LEFT OUTER JOIN PUBLIC.ACTION_RECEIPTS AR ON R.RECEIPT_ID = AR.RECEIPT_ID
WHERE SIGNER_ACCOUNT_ID = ${accountName}
AND R."included_in_block_timestamp" > ${issuedAtUnixNano}
) as receipt
WHERE NOT EXISTS (TABLE long_period_of_inactivity)
ORDER BY moment DESC
LIMIT 1
)
TABLE long_period_of_inactivity
UNION ALL
TABLE most_recent_activity`;

console.log('getStartOfFirstLongPeriodOfInactivity', { result });

/**
* If the account doesn't have a period where it hasn't been active for 180 days straight after the issue date:
* Days between last activity and render date is checked:
* If this value is >180; Certificate is expired. Expiration date = last activity + 180 days
* If this value is <180; Certificate hasn't expired yet. Expiration date = last activity + 180 days
* Otherwise, if >180-day period of inactivity exist after issueDate, expiration = the beginning of the *first* such period + 180 days.
*/
const moment = dayjs(result[0].moment);
let expirationDate;

if (result[0].diff_to_previous === QUERY_DEFAULT_CELL_VALUE) {
expirationDate =
result[0].diff_from_last_activity_to_render_date > expirationDays
? `Expired on ${formatDate(moment.add(expirationDays, 'days'))}`
: formatDate(moment.add(expirationDays, 'days'));
} else {
/**
* >180-day period of inactivity exists.
* moment is the end date of such period.
* Extract 180 from moment to get the exact days between inactivity period in days and 180
*/
const daysToMomentOfExpiration = result[0].diff_to_previous - 180;

/**
* Subtract daysToMomentOfExpiration from moment to get the specific date of expiration.
* This subtraction equals to (start of inactivity period + 180 days)
*/
expirationDate = `Expired on ${formatDate(moment.subtract(daysToMomentOfExpiration, 'days'))}`;
}

async function getExpiration(accountName: string): Promise<string> {
const recent = await getMostRecentActivityDateTime(accountName);
return formatDate(recent.add(expirationMonths, 'months'));
return expirationDate;
}

// eslint-disable-next-line max-lines-per-function
Expand All @@ -97,7 +174,12 @@ async function fetchCertificateDetails(tokenId: string) {
if (certificateMetadata.valid) {
const accountName = certificateMetadata.original_recipient_id;
const programCode = certificateMetadata.program;
const expiration = await getExpiration(accountName); // TODO: Wrap in try/catch blocks to handle when indexer service is unavailable.
let expiration = null; // The UI (see `generateImage`) will need to gracefully handle this case when indexer service is unavailable.
try {
expiration = await getExpiration(accountName, metadata.issued_at);
} catch (error) {
console.error('Perhaps a certificate for the original_recipient_id could not be found or the public indexer query timed out.', error);
}
const date = formatDate(metadata.issued_at);
const programName = metadata.title;
const programDescription = metadata.description;
Expand Down