mirror of
https://github.com/documenso/documenso.git
synced 2025-11-15 01:01:49 +10:00
feat: open page api (#1419)
This commit is contained in:
135
apps/openpage-api/lib/cors.ts
Normal file
135
apps/openpage-api/lib/cors.ts
Normal file
@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Multi purpose CORS lib.
|
||||
* Note: Based on the `cors` package in npm but using only web APIs.
|
||||
* Taken from: https://github.com/vercel/examples/blob/main/edge-functions/cors/lib/cors.ts
|
||||
*/
|
||||
|
||||
type StaticOrigin = boolean | string | RegExp | (boolean | string | RegExp)[];
|
||||
|
||||
type OriginFn = (origin: string | undefined, req: Request) => StaticOrigin | Promise<StaticOrigin>;
|
||||
|
||||
interface CorsOptions {
|
||||
origin?: StaticOrigin | OriginFn;
|
||||
methods?: string | string[];
|
||||
allowedHeaders?: string | string[];
|
||||
exposedHeaders?: string | string[];
|
||||
credentials?: boolean;
|
||||
maxAge?: number;
|
||||
preflightContinue?: boolean;
|
||||
optionsSuccessStatus?: number;
|
||||
}
|
||||
|
||||
const defaultOptions: CorsOptions = {
|
||||
origin: '*',
|
||||
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
|
||||
preflightContinue: false,
|
||||
optionsSuccessStatus: 204,
|
||||
};
|
||||
|
||||
function isOriginAllowed(origin: string, allowed: StaticOrigin): boolean {
|
||||
return Array.isArray(allowed)
|
||||
? allowed.some((o) => isOriginAllowed(origin, o))
|
||||
: typeof allowed === 'string'
|
||||
? origin === allowed
|
||||
: allowed instanceof RegExp
|
||||
? allowed.test(origin)
|
||||
: !!allowed;
|
||||
}
|
||||
|
||||
function getOriginHeaders(reqOrigin: string | undefined, origin: StaticOrigin) {
|
||||
const headers = new Headers();
|
||||
|
||||
if (origin === '*') {
|
||||
headers.set('Access-Control-Allow-Origin', '*');
|
||||
} else if (typeof origin === 'string') {
|
||||
headers.set('Access-Control-Allow-Origin', origin);
|
||||
headers.append('Vary', 'Origin');
|
||||
} else {
|
||||
const allowed = isOriginAllowed(reqOrigin ?? '', origin);
|
||||
|
||||
if (allowed && reqOrigin) {
|
||||
headers.set('Access-Control-Allow-Origin', reqOrigin);
|
||||
}
|
||||
headers.append('Vary', 'Origin');
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
async function originHeadersFromReq(req: Request, origin: StaticOrigin | OriginFn) {
|
||||
const reqOrigin = req.headers.get('Origin') || undefined;
|
||||
const value = typeof origin === 'function' ? await origin(reqOrigin, req) : origin;
|
||||
|
||||
if (!value) return;
|
||||
return getOriginHeaders(reqOrigin, value);
|
||||
}
|
||||
|
||||
function getAllowedHeaders(req: Request, allowed?: string | string[]) {
|
||||
const headers = new Headers();
|
||||
|
||||
if (!allowed) {
|
||||
allowed = req.headers.get('Access-Control-Request-Headers')!;
|
||||
headers.append('Vary', 'Access-Control-Request-Headers');
|
||||
} else if (Array.isArray(allowed)) {
|
||||
allowed = allowed.join(',');
|
||||
}
|
||||
if (allowed) {
|
||||
headers.set('Access-Control-Allow-Headers', allowed);
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
export default async function cors(req: Request, res: Response, options?: CorsOptions) {
|
||||
const opts = { ...defaultOptions, ...options };
|
||||
const { headers } = res;
|
||||
const originHeaders = await originHeadersFromReq(req, opts.origin ?? false);
|
||||
const mergeHeaders = (v: string, k: string) => {
|
||||
if (k === 'Vary') headers.append(k, v);
|
||||
else headers.set(k, v);
|
||||
};
|
||||
|
||||
// If there's no origin we won't touch the response
|
||||
if (!originHeaders) return res;
|
||||
|
||||
originHeaders.forEach(mergeHeaders);
|
||||
|
||||
if (opts.credentials) {
|
||||
headers.set('Access-Control-Allow-Credentials', 'true');
|
||||
}
|
||||
|
||||
const exposed = Array.isArray(opts.exposedHeaders)
|
||||
? opts.exposedHeaders.join(',')
|
||||
: opts.exposedHeaders;
|
||||
|
||||
if (exposed) {
|
||||
headers.set('Access-Control-Expose-Headers', exposed);
|
||||
}
|
||||
|
||||
// Handle the preflight request
|
||||
if (req.method === 'OPTIONS') {
|
||||
if (opts.methods) {
|
||||
const methods = Array.isArray(opts.methods) ? opts.methods.join(',') : opts.methods;
|
||||
|
||||
headers.set('Access-Control-Allow-Methods', methods);
|
||||
}
|
||||
|
||||
getAllowedHeaders(req, opts.allowedHeaders).forEach(mergeHeaders);
|
||||
|
||||
if (typeof opts.maxAge === 'number') {
|
||||
headers.set('Access-Control-Max-Age', String(opts.maxAge));
|
||||
}
|
||||
|
||||
if (opts.preflightContinue) return res;
|
||||
|
||||
headers.set('Content-Length', '0');
|
||||
return new Response(null, { status: opts.optionsSuccessStatus, headers });
|
||||
}
|
||||
|
||||
// If we got here, it's a normal request
|
||||
return res;
|
||||
}
|
||||
|
||||
export function initCors(options?: CorsOptions) {
|
||||
return async (req: Request, res: Response) => cors(req, res, options);
|
||||
}
|
||||
@ -0,0 +1,43 @@
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { kyselyPrisma, sql } from '@documenso/prisma';
|
||||
import { DocumentStatus } from '@documenso/prisma/client';
|
||||
|
||||
export const getCompletedDocumentsMonthly = async (type: 'count' | 'cumulative' = 'count') => {
|
||||
const qb = kyselyPrisma.$kysely
|
||||
.selectFrom('Document')
|
||||
.select(({ fn }) => [
|
||||
fn<Date>('DATE_TRUNC', [sql.lit('MONTH'), 'Document.updatedAt']).as('month'),
|
||||
fn.count('id').as('count'),
|
||||
fn
|
||||
.sum(fn.count('id'))
|
||||
// Feels like a bug in the Kysely extension but I just can not do this orderBy in a type-safe manner
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any
|
||||
.over((ob) => ob.orderBy(fn('DATE_TRUNC', [sql.lit('MONTH'), 'Document.updatedAt']) as any))
|
||||
.as('cume_count'),
|
||||
])
|
||||
.where(() => sql`"Document"."status" = ${DocumentStatus.COMPLETED}::"DocumentStatus"`)
|
||||
.groupBy('month')
|
||||
.orderBy('month', 'desc')
|
||||
.limit(12);
|
||||
|
||||
const result = await qb.execute();
|
||||
|
||||
const transformedData = {
|
||||
labels: result.map((row) => DateTime.fromJSDate(row.month).toFormat('MMM yyyy')).reverse(),
|
||||
datasets: [
|
||||
{
|
||||
label: type === 'count' ? 'Completed Documents per Month' : 'Total Completed Documents',
|
||||
data: result
|
||||
.map((row) => (type === 'count' ? Number(row.count) : Number(row.cume_count)))
|
||||
.reverse(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return transformedData;
|
||||
};
|
||||
|
||||
export type GetCompletedDocumentsMonthlyResult = Awaited<
|
||||
ReturnType<typeof getCompletedDocumentsMonthly>
|
||||
>;
|
||||
42
apps/openpage-api/lib/growth/get-signer-conversion.ts
Normal file
42
apps/openpage-api/lib/growth/get-signer-conversion.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { kyselyPrisma, sql } from '@documenso/prisma';
|
||||
|
||||
export const getSignerConversionMonthly = async (type: 'count' | 'cumulative' = 'count') => {
|
||||
const qb = kyselyPrisma.$kysely
|
||||
.selectFrom('Recipient')
|
||||
.innerJoin('User', 'Recipient.email', 'User.email')
|
||||
.select(({ fn }) => [
|
||||
fn<Date>('DATE_TRUNC', [sql.lit('MONTH'), 'User.createdAt']).as('month'),
|
||||
fn.count('Recipient.email').distinct().as('count'),
|
||||
fn
|
||||
.sum(fn.count('Recipient.email').distinct())
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any
|
||||
.over((ob) => ob.orderBy(fn('DATE_TRUNC', [sql.lit('MONTH'), 'User.createdAt']) as any))
|
||||
.as('cume_count'),
|
||||
])
|
||||
.where('Recipient.signedAt', 'is not', null)
|
||||
.where('Recipient.signedAt', '<', (eb) => eb.ref('User.createdAt'))
|
||||
.groupBy(({ fn }) => fn('DATE_TRUNC', [sql.lit('MONTH'), 'User.createdAt']))
|
||||
.orderBy('month', 'desc');
|
||||
|
||||
const result = await qb.execute();
|
||||
|
||||
const transformedData = {
|
||||
labels: result.map((row) => DateTime.fromJSDate(row.month).toFormat('MMM yyyy')).reverse(),
|
||||
datasets: [
|
||||
{
|
||||
label: type === 'count' ? 'Signers That Signed Up' : 'Total Signers That Signed Up',
|
||||
data: result
|
||||
.map((row) => (type === 'count' ? Number(row.count) : Number(row.cume_count)))
|
||||
.reverse(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return transformedData;
|
||||
};
|
||||
|
||||
export type GetSignerConversionMonthlyResult = Awaited<
|
||||
ReturnType<typeof getSignerConversionMonthly>
|
||||
>;
|
||||
38
apps/openpage-api/lib/growth/get-user-monthly-growth.ts
Normal file
38
apps/openpage-api/lib/growth/get-user-monthly-growth.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { kyselyPrisma, sql } from '@documenso/prisma';
|
||||
|
||||
export const getUserMonthlyGrowth = async (type: 'count' | 'cumulative' = 'count') => {
|
||||
const qb = kyselyPrisma.$kysely
|
||||
.selectFrom('User')
|
||||
.select(({ fn }) => [
|
||||
fn<Date>('DATE_TRUNC', [sql.lit('MONTH'), 'User.createdAt']).as('month'),
|
||||
fn.count('id').as('count'),
|
||||
fn
|
||||
.sum(fn.count('id'))
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any
|
||||
.over((ob) => ob.orderBy(fn('DATE_TRUNC', [sql.lit('MONTH'), 'User.createdAt']) as any))
|
||||
.as('cume_count'),
|
||||
])
|
||||
.groupBy('month')
|
||||
.orderBy('month', 'desc')
|
||||
.limit(12);
|
||||
|
||||
const result = await qb.execute();
|
||||
|
||||
const transformedData = {
|
||||
labels: result.map((row) => DateTime.fromJSDate(row.month).toFormat('MMM yyyy')).reverse(),
|
||||
datasets: [
|
||||
{
|
||||
label: type === 'count' ? 'New Users' : 'Total Users',
|
||||
data: result
|
||||
.map((row) => (type === 'count' ? Number(row.count) : Number(row.cume_count)))
|
||||
.reverse(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return transformedData;
|
||||
};
|
||||
|
||||
export type GetUserMonthlyGrowthResult = Awaited<ReturnType<typeof getUserMonthlyGrowth>>;
|
||||
68
apps/openpage-api/lib/transform-data.ts
Normal file
68
apps/openpage-api/lib/transform-data.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
type MetricKeys = {
|
||||
stars: number;
|
||||
forks: number;
|
||||
mergedPRs: number;
|
||||
openIssues: number;
|
||||
earlyAdopters: number;
|
||||
};
|
||||
|
||||
type DataEntry = {
|
||||
[key: string]: MetricKeys;
|
||||
};
|
||||
|
||||
type TransformData = {
|
||||
labels: string[];
|
||||
datasets: {
|
||||
label: string;
|
||||
data: number[];
|
||||
}[];
|
||||
};
|
||||
|
||||
type MetricKey = keyof MetricKeys;
|
||||
|
||||
const FRIENDLY_METRIC_NAMES: { [key in MetricKey]: string } = {
|
||||
stars: 'Stars',
|
||||
forks: 'Forks',
|
||||
mergedPRs: 'Merged PRs',
|
||||
openIssues: 'Open Issues',
|
||||
earlyAdopters: 'Customers',
|
||||
};
|
||||
|
||||
export function transformData({
|
||||
data,
|
||||
metric,
|
||||
}: {
|
||||
data: DataEntry;
|
||||
metric: MetricKey;
|
||||
}): TransformData {
|
||||
const sortedEntries = Object.entries(data).sort(([dateA], [dateB]) => {
|
||||
const [yearA, monthA] = dateA.split('-').map(Number);
|
||||
const [yearB, monthB] = dateB.split('-').map(Number);
|
||||
|
||||
return DateTime.local(yearA, monthA).toMillis() - DateTime.local(yearB, monthB).toMillis();
|
||||
});
|
||||
|
||||
const labels = sortedEntries.map(([date]) => {
|
||||
const [year, month] = date.split('-');
|
||||
const dateTime = DateTime.fromObject({
|
||||
year: Number(year),
|
||||
month: Number(month),
|
||||
});
|
||||
return dateTime.toFormat('MMM yyyy');
|
||||
});
|
||||
|
||||
return {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: `Total ${FRIENDLY_METRIC_NAMES[metric]}`,
|
||||
data: sortedEntries.map(([_, stats]) => stats[metric]),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// To be on the safer side
|
||||
export const transformRepoStats = transformData;
|
||||
Reference in New Issue
Block a user