diff --git a/apps/documentation/README.md b/apps/documentation/README.md index adf7cb8a7..66280ee92 100644 --- a/apps/documentation/README.md +++ b/apps/documentation/README.md @@ -1,36 +1 @@ -This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). - -## Getting Started - -First, run the development server: - -```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev -``` - -Open [http://localhost:3002](http://localhost:3002) with your browser to see the result. - -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. - -This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. - -## Learn More - -To learn more about Next.js, take a look at the following resources: - -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. - -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! - -## Deploy on Vercel - -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. - -Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. +# @documenso/documentation diff --git a/apps/openpage-api/.gitignore b/apps/openpage-api/.gitignore new file mode 100644 index 000000000..26b002aac --- /dev/null +++ b/apps/openpage-api/.gitignore @@ -0,0 +1,40 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# env files (can opt-in for commiting if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/apps/openpage-api/README.md b/apps/openpage-api/README.md new file mode 100644 index 000000000..41c767aa2 --- /dev/null +++ b/apps/openpage-api/README.md @@ -0,0 +1 @@ +# @documenso/openpage-api diff --git a/apps/openpage-api/app/community/route.ts b/apps/openpage-api/app/community/route.ts new file mode 100644 index 000000000..2679c34d0 --- /dev/null +++ b/apps/openpage-api/app/community/route.ts @@ -0,0 +1,36 @@ +import type { NextRequest } from 'next/server'; + +import cors from '@/lib/cors'; + +const paths = [ + { path: '/total-prs', description: 'Total GitHub Merged PRs' }, + { path: '/total-stars', description: 'Total GitHub Stars' }, + { path: '/total-forks', description: 'Total GitHub Forks' }, + { path: '/total-issues', description: 'Total GitHub Issues' }, +]; + +export function GET(request: NextRequest) { + const url = request.nextUrl.toString(); + const apis = paths.map(({ path, description }) => { + return { path: url + path, description }; + }); + + return cors( + request, + new Response(JSON.stringify(apis), { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }), + ); +} + +export function OPTIONS(request: Request) { + return cors( + request, + new Response(null, { + status: 204, + }), + ); +} diff --git a/apps/openpage-api/app/community/total-forks/route.ts b/apps/openpage-api/app/community/total-forks/route.ts new file mode 100644 index 000000000..330ddc587 --- /dev/null +++ b/apps/openpage-api/app/community/total-forks/route.ts @@ -0,0 +1,27 @@ +import cors from '@/lib/cors'; +import { transformData } from '@/lib/transform-data'; + +export async function GET(request: Request) { + const res = await fetch('https://stargrazer-live.onrender.com/api/stats'); + const data = await res.json(); + const transformedData = transformData({ data, metric: 'forks' }); + + return cors( + request, + new Response(JSON.stringify(transformedData), { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }), + ); +} + +export function OPTIONS(request: Request) { + return cors( + request, + new Response(null, { + status: 204, + }), + ); +} diff --git a/apps/openpage-api/app/community/total-issues/route.ts b/apps/openpage-api/app/community/total-issues/route.ts new file mode 100644 index 000000000..386e766cb --- /dev/null +++ b/apps/openpage-api/app/community/total-issues/route.ts @@ -0,0 +1,27 @@ +import cors from '@/lib/cors'; +import { transformData } from '@/lib/transform-data'; + +export async function GET(request: Request) { + const res = await fetch('https://stargrazer-live.onrender.com/api/stats'); + const data = await res.json(); + const transformedData = transformData({ data, metric: 'openIssues' }); + + return cors( + request, + new Response(JSON.stringify(transformedData), { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }), + ); +} + +export function OPTIONS(request: Request) { + return cors( + request, + new Response(null, { + status: 204, + }), + ); +} diff --git a/apps/openpage-api/app/community/total-prs/route.ts b/apps/openpage-api/app/community/total-prs/route.ts new file mode 100644 index 000000000..4bffd1702 --- /dev/null +++ b/apps/openpage-api/app/community/total-prs/route.ts @@ -0,0 +1,27 @@ +import cors from '@/lib/cors'; +import { transformData } from '@/lib/transform-data'; + +export async function GET(request: Request) { + const res = await fetch('https://stargrazer-live.onrender.com/api/stats'); + const data = await res.json(); + const transformedData = transformData({ data, metric: 'mergedPRs' }); + + return cors( + request, + new Response(JSON.stringify(transformedData), { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }), + ); +} + +export function OPTIONS(request: Request) { + return cors( + request, + new Response(null, { + status: 204, + }), + ); +} diff --git a/apps/openpage-api/app/community/total-stars/route.ts b/apps/openpage-api/app/community/total-stars/route.ts new file mode 100644 index 000000000..60e8b431e --- /dev/null +++ b/apps/openpage-api/app/community/total-stars/route.ts @@ -0,0 +1,27 @@ +import cors from '@/lib/cors'; +import { transformData } from '@/lib/transform-data'; + +export async function GET(request: Request) { + const res = await fetch('https://stargrazer-live.onrender.com/api/stats'); + const data = await res.json(); + const transformedData = transformData({ data, metric: 'stars' }); + + return cors( + request, + new Response(JSON.stringify(transformedData), { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }), + ); +} + +export function OPTIONS(request: Request) { + return cors( + request, + new Response(null, { + status: 204, + }), + ); +} diff --git a/apps/openpage-api/app/github/forks/route.ts b/apps/openpage-api/app/github/forks/route.ts new file mode 100644 index 000000000..f512dd57d --- /dev/null +++ b/apps/openpage-api/app/github/forks/route.ts @@ -0,0 +1,25 @@ +import cors from '@/lib/cors'; + +export async function GET(request: Request) { + const res = await fetch('https://api.github.com/repos/documenso/documenso'); + const { forks_count } = await res.json(); + + return cors( + request, + new Response(JSON.stringify({ data: forks_count }), { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }), + ); +} + +export function OPTIONS(request: Request) { + return cors( + request, + new Response(null, { + status: 204, + }), + ); +} diff --git a/apps/openpage-api/app/github/issues/route.ts b/apps/openpage-api/app/github/issues/route.ts new file mode 100644 index 000000000..eccc1bd32 --- /dev/null +++ b/apps/openpage-api/app/github/issues/route.ts @@ -0,0 +1,27 @@ +import cors from '@/lib/cors'; + +export async function GET(request: Request) { + const res = await fetch( + 'https://api.github.com/search/issues?q=repo:documenso/documenso+type:issue+state:open&page=0&per_page=1', + ); + const { total_count } = await res.json(); + + return cors( + request, + new Response(JSON.stringify({ data: total_count }), { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }), + ); +} + +export function OPTIONS(request: Request) { + return cors( + request, + new Response(null, { + status: 204, + }), + ); +} diff --git a/apps/openpage-api/app/github/prs/route.ts b/apps/openpage-api/app/github/prs/route.ts new file mode 100644 index 000000000..7516e6986 --- /dev/null +++ b/apps/openpage-api/app/github/prs/route.ts @@ -0,0 +1,27 @@ +import cors from '@/lib/cors'; + +export async function GET(request: Request) { + const res = await fetch( + 'https://api.github.com/search/issues?q=repo:documenso/documenso/+is:pr+merged:>=2010-01-01&page=0&per_page=1', + ); + const { total_count } = await res.json(); + + return cors( + request, + new Response(JSON.stringify({ data: total_count }), { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }), + ); +} + +export function OPTIONS(request: Request) { + return cors( + request, + new Response(null, { + status: 204, + }), + ); +} diff --git a/apps/openpage-api/app/github/route.ts b/apps/openpage-api/app/github/route.ts new file mode 100644 index 000000000..4b7f34172 --- /dev/null +++ b/apps/openpage-api/app/github/route.ts @@ -0,0 +1,36 @@ +import type { NextRequest } from 'next/server'; + +import cors from '@/lib/cors'; + +const paths = [ + { path: '/forks', description: 'GitHub Forks' }, + { path: '/stars', description: 'GitHub Stars' }, + { path: '/issues', description: 'GitHub Merged Issues' }, + { path: '/prs', description: 'GitHub Pull Requests' }, +]; + +export function GET(request: NextRequest) { + const url = request.nextUrl.toString(); + const apis = paths.map(({ path, description }) => { + return { path: url + path, description }; + }); + + return cors( + request, + new Response(JSON.stringify(apis), { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }), + ); +} + +export function OPTIONS(request: Request) { + return cors( + request, + new Response(null, { + status: 204, + }), + ); +} diff --git a/apps/openpage-api/app/github/stars/route.ts b/apps/openpage-api/app/github/stars/route.ts new file mode 100644 index 000000000..79c03f0cd --- /dev/null +++ b/apps/openpage-api/app/github/stars/route.ts @@ -0,0 +1,25 @@ +import cors from '@/lib/cors'; + +export async function GET(request: Request) { + const res = await fetch('https://api.github.com/repos/documenso/documenso'); + const { stargazers_count } = await res.json(); + + return cors( + request, + new Response(JSON.stringify({ data: stargazers_count }), { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }), + ); +} + +export function OPTIONS(request: Request) { + return cors( + request, + new Response(null, { + status: 204, + }), + ); +} diff --git a/apps/openpage-api/app/growth/completed-documents/route.ts b/apps/openpage-api/app/growth/completed-documents/route.ts new file mode 100644 index 000000000..0887d39ec --- /dev/null +++ b/apps/openpage-api/app/growth/completed-documents/route.ts @@ -0,0 +1,25 @@ +import cors from '@/lib/cors'; +import { getCompletedDocumentsMonthly } from '@/lib/growth/get-monthly-completed-document'; + +export async function GET(request: Request) { + const completedDocuments = await getCompletedDocumentsMonthly(); + + return cors( + request, + new Response(JSON.stringify(completedDocuments), { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }), + ); +} + +export function OPTIONS(request: Request) { + return cors( + request, + new Response(null, { + status: 204, + }), + ); +} diff --git a/apps/openpage-api/app/growth/new-users/route.ts b/apps/openpage-api/app/growth/new-users/route.ts new file mode 100644 index 000000000..460adf9da --- /dev/null +++ b/apps/openpage-api/app/growth/new-users/route.ts @@ -0,0 +1,25 @@ +import cors from '@/lib/cors'; +import { getUserMonthlyGrowth } from '@/lib/growth/get-user-monthly-growth'; + +export async function GET(request: Request) { + const monthlyUsers = await getUserMonthlyGrowth(); + + return cors( + request, + new Response(JSON.stringify(monthlyUsers), { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }), + ); +} + +export function OPTIONS(request: Request) { + return cors( + request, + new Response(null, { + status: 204, + }), + ); +} diff --git a/apps/openpage-api/app/growth/route.ts b/apps/openpage-api/app/growth/route.ts new file mode 100644 index 000000000..5c4dbe83a --- /dev/null +++ b/apps/openpage-api/app/growth/route.ts @@ -0,0 +1,39 @@ +import type { NextRequest } from 'next/server'; + +import cors from '@/lib/cors'; + +const paths = [ + { path: '/total-customers', description: 'Total Customers' }, + { path: '/total-users', description: 'Total Users' }, + { path: '/new-users', description: 'New Users' }, + { path: '/completed-documents', description: 'Completed Documents per Month' }, + { path: '/total-completed-documents', description: 'Total Completed Documents' }, + { path: '/signer-conversion', description: 'Signers That Signed Up' }, + { path: '/total-signer-conversion', description: 'Total Signers That Signed Up' }, +]; + +export function GET(request: NextRequest) { + const url = request.nextUrl.toString(); + const apis = paths.map(({ path, description }) => { + return { path: url + path, description }; + }); + + return cors( + request, + new Response(JSON.stringify(apis), { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }), + ); +} + +export function OPTIONS(request: Request) { + return cors( + request, + new Response(null, { + status: 204, + }), + ); +} diff --git a/apps/openpage-api/app/growth/signer-conversion/route.ts b/apps/openpage-api/app/growth/signer-conversion/route.ts new file mode 100644 index 000000000..8be97ded5 --- /dev/null +++ b/apps/openpage-api/app/growth/signer-conversion/route.ts @@ -0,0 +1,25 @@ +import cors from '@/lib/cors'; +import { getSignerConversionMonthly } from '@/lib/growth/get-signer-conversion'; + +export async function GET(request: Request) { + const signers = await getSignerConversionMonthly(); + + return cors( + request, + new Response(JSON.stringify(signers), { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }), + ); +} + +export function OPTIONS(request: Request) { + return cors( + request, + new Response(null, { + status: 204, + }), + ); +} diff --git a/apps/openpage-api/app/growth/total-completed-documents/route.ts b/apps/openpage-api/app/growth/total-completed-documents/route.ts new file mode 100644 index 000000000..29bd0c9ce --- /dev/null +++ b/apps/openpage-api/app/growth/total-completed-documents/route.ts @@ -0,0 +1,25 @@ +import cors from '@/lib/cors'; +import { getCompletedDocumentsMonthly } from '@/lib/growth/get-monthly-completed-document'; + +export async function GET(request: Request) { + const totalCompletedDocuments = await getCompletedDocumentsMonthly('cumulative'); + + return cors( + request, + new Response(JSON.stringify(totalCompletedDocuments), { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }), + ); +} + +export function OPTIONS(request: Request) { + return cors( + request, + new Response(null, { + status: 204, + }), + ); +} diff --git a/apps/openpage-api/app/growth/total-customers/route.ts b/apps/openpage-api/app/growth/total-customers/route.ts new file mode 100644 index 000000000..c403ac311 --- /dev/null +++ b/apps/openpage-api/app/growth/total-customers/route.ts @@ -0,0 +1,31 @@ +import cors from '@/lib/cors'; +import { transformData } from '@/lib/transform-data'; + +export async function GET(request: Request) { + const res = await fetch('https://stargrazer-live.onrender.com/api/stats/stripe'); + const EARLY_ADOPTERS_DATA = await res.json(); + + const transformedData = transformData({ + data: EARLY_ADOPTERS_DATA, + metric: 'earlyAdopters', + }); + + return cors( + request, + new Response(JSON.stringify(transformedData), { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }), + ); +} + +export function OPTIONS(request: Request) { + return cors( + request, + new Response(null, { + status: 204, + }), + ); +} diff --git a/apps/openpage-api/app/growth/total-signer-conversion/route.ts b/apps/openpage-api/app/growth/total-signer-conversion/route.ts new file mode 100644 index 000000000..d620ee4e6 --- /dev/null +++ b/apps/openpage-api/app/growth/total-signer-conversion/route.ts @@ -0,0 +1,25 @@ +import cors from '@/lib/cors'; +import { getSignerConversionMonthly } from '@/lib/growth/get-signer-conversion'; + +export async function GET(request: Request) { + const totalSigners = await getSignerConversionMonthly('cumulative'); + + return cors( + request, + new Response(JSON.stringify(totalSigners), { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }), + ); +} + +export function OPTIONS(request: Request) { + return cors( + request, + new Response(null, { + status: 204, + }), + ); +} diff --git a/apps/openpage-api/app/growth/total-users/route.ts b/apps/openpage-api/app/growth/total-users/route.ts new file mode 100644 index 000000000..0617ecda2 --- /dev/null +++ b/apps/openpage-api/app/growth/total-users/route.ts @@ -0,0 +1,25 @@ +import cors from '@/lib/cors'; +import { getUserMonthlyGrowth } from '@/lib/growth/get-user-monthly-growth'; + +export async function GET(request: Request) { + const totalUsers = await getUserMonthlyGrowth("cumulative"); + + return cors( + request, + new Response(JSON.stringify(totalUsers), { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }), + ); +} + +export function OPTIONS(request: Request) { + return cors( + request, + new Response(null, { + status: 204, + }), + ); +} diff --git a/apps/openpage-api/app/route.ts b/apps/openpage-api/app/route.ts new file mode 100644 index 000000000..47d2d4ba2 --- /dev/null +++ b/apps/openpage-api/app/route.ts @@ -0,0 +1,35 @@ +import type { NextRequest } from 'next/server'; + +import cors from '@/lib/cors'; + +const paths = [ + { path: 'github', description: 'GitHub Data' }, + { path: 'community', description: 'Community Data' }, + { path: 'growth', description: 'Growth Data' }, +]; + +export function GET(request: NextRequest) { + const url = request.nextUrl.toString(); + const apis = paths.map(({ path, description }) => { + return { path: url + path, description }; + }); + + return cors( + request, + new Response(JSON.stringify(apis), { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }), + ); +} + +export function OPTIONS(request: Request) { + return cors( + request, + new Response(null, { + status: 204, + }), + ); +} diff --git a/apps/openpage-api/lib/cors.ts b/apps/openpage-api/lib/cors.ts new file mode 100644 index 000000000..03c1f1c4a --- /dev/null +++ b/apps/openpage-api/lib/cors.ts @@ -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; + +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); +} diff --git a/apps/openpage-api/lib/growth/get-monthly-completed-document.ts b/apps/openpage-api/lib/growth/get-monthly-completed-document.ts new file mode 100644 index 000000000..1b4c83650 --- /dev/null +++ b/apps/openpage-api/lib/growth/get-monthly-completed-document.ts @@ -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_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 +>; diff --git a/apps/openpage-api/lib/growth/get-signer-conversion.ts b/apps/openpage-api/lib/growth/get-signer-conversion.ts new file mode 100644 index 000000000..aca2decb8 --- /dev/null +++ b/apps/openpage-api/lib/growth/get-signer-conversion.ts @@ -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_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 +>; diff --git a/apps/openpage-api/lib/growth/get-user-monthly-growth.ts b/apps/openpage-api/lib/growth/get-user-monthly-growth.ts new file mode 100644 index 000000000..6d4e526cb --- /dev/null +++ b/apps/openpage-api/lib/growth/get-user-monthly-growth.ts @@ -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_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>; diff --git a/apps/openpage-api/lib/transform-data.ts b/apps/openpage-api/lib/transform-data.ts new file mode 100644 index 000000000..b5f0cd838 --- /dev/null +++ b/apps/openpage-api/lib/transform-data.ts @@ -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; diff --git a/apps/openpage-api/next.config.js b/apps/openpage-api/next.config.js new file mode 100644 index 000000000..658404ac6 --- /dev/null +++ b/apps/openpage-api/next.config.js @@ -0,0 +1,4 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = {}; + +module.exports = nextConfig; diff --git a/apps/openpage-api/package.json b/apps/openpage-api/package.json new file mode 100644 index 000000000..349bc356f --- /dev/null +++ b/apps/openpage-api/package.json @@ -0,0 +1,23 @@ +{ + "name": "@documenso/openpage-api", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "next dev -p 3003", + "build": "next build", + "start": "next start", + "lint:fix": "next lint --fix", + "clean": "rimraf .next && rimraf node_modules", + "copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs" + }, + "dependencies": { + "@documenso/prisma": "*", + "luxon": "^3.5.0", + "next": "14.2.6" + }, + "devDependencies": { + "@types/node": "20.16.5", + "@types/react": "18.3.5", + "typescript": "5.5.4" + } +} diff --git a/apps/openpage-api/tsconfig.json b/apps/openpage-api/tsconfig.json new file mode 100644 index 000000000..d8b93235f --- /dev/null +++ b/apps/openpage-api/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/package-lock.json b/package-lock.json index de373eeb6..df976197c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,8 +13,10 @@ ], "dependencies": { "@documenso/pdf-sign": "^0.1.0", + "@documenso/prisma": "^0.0.0", "@lingui/core": "^4.11.3", "inngest-cli": "^0.29.1", + "luxon": "^3.5.0", "next-runtime-env": "^3.2.0", "react": "^18" }, @@ -436,6 +438,58 @@ "node": ">=14.17" } }, + "apps/openpage-api": { + "name": "@documenso/openpage-api", + "version": "1.0.0", + "dependencies": { + "@documenso/prisma": "*", + "luxon": "^3.5.0", + "next": "14.2.6" + }, + "devDependencies": { + "@types/node": "20.16.5", + "@types/react": "18.3.5", + "typescript": "5.5.4" + } + }, + "apps/openpage-api/node_modules/@types/node": { + "version": "20.16.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.5.tgz", + "integrity": "sha512-VwYCweNo3ERajwy0IUlqqcyZ8/A7Zwa9ZP3MnENWcB11AejO+tLy3pu850goUW2FC/IJMdZUfKpX/yxL1gymCA==", + "dev": true, + "dependencies": { + "undici-types": "~6.19.2" + } + }, + "apps/openpage-api/node_modules/@types/react": { + "version": "18.3.5", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.5.tgz", + "integrity": "sha512-WeqMfGJLGuLCqHGYRGHxnKrXcTitc6L/nBUWfWPcTarG3t9PsquqUMuVeXZeca+mglY4Vo5GZjCi0A3Or2lnxA==", + "dev": true, + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.0.2" + } + }, + "apps/openpage-api/node_modules/typescript": { + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", + "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "apps/openpage-api/node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true + }, "apps/web": { "name": "@documenso/web", "version": "1.8.1", @@ -2669,6 +2723,10 @@ "nodemailer": "^6.9.3" } }, + "node_modules/@documenso/openpage-api": { + "resolved": "apps/openpage-api", + "link": true + }, "node_modules/@documenso/pdf-sign": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/@documenso/pdf-sign/-/pdf-sign-0.1.0.tgz", @@ -22312,9 +22370,9 @@ } }, "node_modules/luxon": { - "version": "3.4.4", - "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.4.tgz", - "integrity": "sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA==", + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.5.0.tgz", + "integrity": "sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==", "engines": { "node": ">=12" } diff --git a/package.json b/package.json index 8d0a044f6..758499337 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "dev:web": "turbo run dev --filter=@documenso/web", "dev:marketing": "turbo run dev --filter=@documenso/marketing", "dev:docs": "turbo run dev --filter=@documenso/documentation", - "start": "turbo run start --filter=@documenso/web --filter=@documenso/marketing --filter=@documenso/documentation", + "dev:openpage-api": "turbo run dev --filter=@documenso/openpage-api", + "start": "turbo run start --filter=@documenso/web --filter=@documenso/marketing --filter=@documenso/documentation --filter=@documenso/openpage-api", "lint": "turbo run lint", "lint:fix": "turbo run lint:fix", "format": "prettier --write \"**/*.{js,jsx,cjs,mjs,ts,tsx,cts,mts,mdx}\"", @@ -63,8 +64,10 @@ ], "dependencies": { "@documenso/pdf-sign": "^0.1.0", + "@documenso/prisma": "^0.0.0", "@lingui/core": "^4.11.3", "inngest-cli": "^0.29.1", + "luxon": "^3.5.0", "next-runtime-env": "^3.2.0", "react": "^18" },