wip: refresh design

This commit is contained in:
Mythie
2023-06-09 18:21:18 +10:00
parent 76b2fb5edd
commit 159bcade7b
432 changed files with 19640 additions and 29359 deletions

View File

@@ -1,36 +0,0 @@
import toast from "react-hot-toast";
export const createOrUpdateField = async (
document: any,
field: any,
recipientToken: string = ""
): Promise<any> => {
try {
const created = await toast.promise(
fetch("/api/documents/" + document.id + "/fields?token=" + recipientToken, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(field),
}).then((res) => {
if (!res.ok) {
throw new Error(res.status.toString());
}
return res.json();
}),
{
loading: field?.id !== -1 ? "Saving..." : "Adding...",
success: field?.id !== -1 ? "Saved." : "Added.",
error: field?.id !== -1 ? "Could not save :/" : "Could not add :/",
},
{
id: "saving field",
style: {
minWidth: "200px",
},
}
);
return created;
} catch (error) {}
};

View File

@@ -1,32 +0,0 @@
import toast from "react-hot-toast";
export const createOrUpdateRecipient = async (recipient: any): Promise<any> => {
try {
const created = await toast.promise(
fetch("/api/documents/" + recipient.documentId + "/recipients", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(recipient),
}).then((res) => {
if (!res.ok) {
throw new Error(res.status.toString());
}
return res.json();
}),
{
loading: "Saving...",
success: "Saved.",
error: "Could not save :/",
},
{
id: "saving",
style: {
minWidth: "200px",
},
}
);
return created;
} catch (error) {}
};

View File

@@ -1,5 +0,0 @@
export const deleteDocument = (documentId: number): Promise<Response> => {
return fetch(`/api/documents/${documentId}`, {
method: "DELETE",
});
};

View File

@@ -1,36 +0,0 @@
import toast from "react-hot-toast";
export const deleteField = async (field: any) => {
if (!field.id) {
return;
}
try {
const deleted = toast.promise(
fetch("/api/documents/" + 0 + "/fields/" + field.id, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(field),
}).then((res) => {
if (!res.ok) {
throw new Error(res.status.toString());
}
return res;
}),
{
loading: "Deleting...",
success: "Deleted.",
error: "Could not delete :/",
},
{
id: "delete",
style: {
minWidth: "200px",
},
}
);
return deleted;
} catch (error) {}
};

View File

@@ -1,28 +0,0 @@
import toast from "react-hot-toast";
export const deleteRecipient = (recipient: any) => {
if (!recipient.id) {
return;
}
return toast.promise(
fetch("/api/documents/" + recipient.documentId + "/recipients/" + recipient.id, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(recipient),
}),
{
loading: "Deleting...",
success: "Deleted.",
error: "Could not delete :/",
},
{
id: "delete",
style: {
minWidth: "200px",
},
}
);
};

View File

@@ -1,7 +0,0 @@
export const getDocuments = (): Promise<Response> => {
return fetch("/api/documents", {
headers: {
"Content-Type": "application/json",
},
});
};

View File

@@ -1,3 +0,0 @@
export const getUser = (): Promise<Response> => {
return fetch("/api/users/me");
};

View File

@@ -1,10 +0,0 @@
export { createOrUpdateField } from "./createOrUpdateField";
export { deleteField } from "./deleteField";
export { signDocument } from "./signDocument";
export { getUser } from "./getUser";
export { signup } from "./signup";
export { getDocuments } from "./getDocuments";
export { deleteDocument } from "./deleteDocument";
export { deleteRecipient } from "./deleteRecipient";
export { createOrUpdateRecipient } from "./createOrUpdateRecipient";
export { sendSigningRequests } from "./sendSigningRequests";

View File

@@ -1,29 +0,0 @@
import toast from "react-hot-toast";
export const sendSigningRequests = async (document: any, resendTo: number[] = []) => {
if (!document || !document.id) return;
try {
const sent = await toast.promise(
fetch(`/api/documents/${document.id}/send`, {
body: JSON.stringify({ resendTo: resendTo }),
headers: { "Content-Type": "application/json" },
method: "POST",
})
.then((res: any) => {
if (!res.ok) {
throw new Error(res.status.toString());
}
})
.finally(() => {
location.reload();
}),
{
loading: "Sending...",
success: `Sent!`,
error: "Could not send :/",
}
);
} catch (err) {
console.log(err);
}
};

View File

@@ -1,21 +0,0 @@
import { useRouter } from "next/router";
import toast from "react-hot-toast";
export const signDocument = (document: any, signatures: any[], token: string): Promise<any> => {
const body = { documentId: document.id, signatures };
return toast.promise(
fetch(`/api/documents/${document.id}/sign?token=${token}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
}),
{
loading: "Signing...",
success: `"${document.title}" signed successfully.`,
error: "Could not sign :/",
}
);
};

View File

@@ -1,12 +0,0 @@
export const signup = (source: any, data: any): Promise<Response> => {
return fetch("/api/auth/signup", {
body: JSON.stringify({
source: source,
...data,
}),
headers: {
"Content-Type": "application/json",
},
method: "POST",
});
};

View File

@@ -1,89 +0,0 @@
import type { NextApiRequest } from "next";
import { HttpError } from "@documenso/lib/server";
import { compare, hash } from "bcryptjs";
import type { Session } from "next-auth";
import { GetSessionParams, getSession as getSessionInner } from "next-auth/react";
export async function hashPassword(password: string) {
const hashedPassword = await hash(password, 12);
return hashedPassword;
}
export async function verifyPassword(password: string, hashedPassword: string) {
const isValid = await compare(password, hashedPassword);
return isValid;
}
export function validPassword(password: string) {
if (password.length < 7) return false;
if (!/[A-Z]/.test(password) || !/[a-z]/.test(password)) return false;
if (!/\d+/.test(password)) return false;
return true;
}
export async function getSession(options: GetSessionParams): Promise<Session | null> {
const session = await getSessionInner(options);
// that these are equal are ensured in `[...nextauth]`'s callback
return session as Session | null;
}
export function isPasswordValid(password: string): boolean;
export function isPasswordValid(
password: string,
breakdown: boolean,
strict?: boolean
): { caplow: boolean; num: boolean; min: boolean; admin_min: boolean };
export function isPasswordValid(password: string, breakdown?: boolean, strict?: boolean) {
let cap = false, // Has uppercase characters
low = false, // Has lowercase characters
num = false, // At least one number
min = false, // Eight characters, or fifteen in strict mode.
admin_min = false;
if (password.length > 7 && (!strict || password.length > 14)) min = true;
if (strict && password.length > 14) admin_min = true;
for (let i = 0; i < password.length; i++) {
if (!isNaN(parseInt(password[i]))) num = true;
else {
if (password[i] === password[i].toUpperCase()) cap = true;
if (password[i] === password[i].toLowerCase()) low = true;
}
}
if (!breakdown) return cap && low && num && min && (strict ? admin_min : true);
let errors: Record<string, boolean> = { caplow: cap && low, num, min };
// Only return the admin key if strict mode is enabled.
if (strict) errors = { ...errors, admin_min };
return errors;
}
type CtxOrReq =
| { req: NextApiRequest; ctx?: never }
| { ctx: { req: NextApiRequest }; req?: never };
export const ensureSession = async (ctxOrReq: CtxOrReq) => {
const session = await getSession(ctxOrReq);
if (!session?.user) throw new HttpError({ statusCode: 401, message: "Unauthorized" });
return session;
};
export enum ErrorCode {
UserNotFound = "user-not-found",
IncorrectPassword = "incorrect-password",
UserMissingPassword = "missing-password",
TwoFactorDisabled = "two-factor-disabled",
TwoFactorAlreadyEnabled = "two-factor-already-enabled",
TwoFactorSetupRequired = "two-factor-setup-required",
SecondFactorRequired = "second-factor-required",
IncorrectTwoFactorCode = "incorrect-two-factor-code",
InternalServerError = "internal-server-error",
NewPasswordMatchesOld = "new-password-matches-old",
ThirdPartyIdentityProviderEnabled = "third-party-identity-provider-enabled",
RateLimitExceeded = "rate-limit-exceeded",
SocialIdentityProviderRequired = "social-identity-provider-required",
}

View File

@@ -1,3 +0,0 @@
export default function classNames(...classes: unknown[]) {
return classes.filter(Boolean).join(" ");
}

View File

@@ -0,0 +1,21 @@
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
export const useUpdateSearchParams = () => {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
return (params: Record<string, string | number | boolean | null | undefined>) => {
const nextSearchParams = new URLSearchParams(searchParams?.toString() ?? '');
Object.entries(params).forEach(([key, value]) => {
if (value === undefined || value === null) {
nextSearchParams.delete(key);
} else {
nextSearchParams.set(key, String(value));
}
});
router.push(`${pathname}?${nextSearchParams.toString()}`);
};
};

View File

@@ -1,40 +0,0 @@
// https://stackoverflow.com/questions/9781218/how-to-change-node-jss-console-font-color
export class coloredConsole {
public static setupColoredConsole(): void {
let infoLog = console.info;
let logLog = console.log;
let errorLog = console.error;
let warnLog = console.warn;
let colors = {
Reset: "\x1b[0m",
Red: "\x1b[31m",
Green: "\x1b[32m",
Yellow: "\x1b[33m",
};
console.info = function (args: any) {
let copyArgs = Array.prototype.slice.call(arguments);
copyArgs.unshift(colors.Green);
copyArgs.push(colors.Reset);
infoLog.apply(null, copyArgs);
};
console.warn = function (args: any) {
let copyArgs = Array.prototype.slice.call(arguments);
copyArgs.unshift(colors.Yellow);
copyArgs.push(colors.Reset);
warnLog.apply(null, copyArgs);
};
console.error = function (args: any) {
let copyArgs = Array.prototype.slice.call(arguments);
copyArgs.unshift(colors.Red);
copyArgs.push(colors.Reset);
errorLog.apply(null, copyArgs);
};
}
}
coloredConsole.setupColoredConsole();

View File

@@ -1,4 +0,0 @@
export const NEXT_PUBLIC_WEBAPP_URL =
process.env.IS_PULL_REQUEST === "true"
? process.env.RENDER_EXTERNAL_URL
: process.env.NEXT_PUBLIC_WEBAPP_URL;

View File

@@ -0,0 +1 @@
export const SALT_ROUNDS = 12;

View File

@@ -0,0 +1,5 @@
/* eslint-disable turbo/no-undeclared-env-vars */
export const IS_SUBSCRIPTIONS_ENABLED = process.env.NEXT_PUBLIC_SUBSCRIPTIONS_ENABLED === 'true';
export const isSubscriptionsEnabled = () =>
process.env.NEXT_PUBLIC_SUBSCRIPTIONS_ENABLED === 'true';

View File

@@ -1 +0,0 @@
export const isENVProd = process.env.NODE_ENV === "production";

View File

@@ -0,0 +1,5 @@
export class UserExistsError extends Error {
constructor() {
super('User already exists');
}
}

View File

@@ -1,24 +0,0 @@
// It ensures that redirection URL safe where it is accepted through a query params or other means where user can change it.
export const getSafeRedirectUrl = (url = "") => {
if (!url) {
return null;
}
//It is important that this fn is given absolute URL because urls that don't start with HTTP can still deceive browser into redirecting to another domain
if (url.search(/^https?:\/\//) === -1) {
throw new Error("Pass an absolute URL");
}
const urlParsed = new URL(url);
// Avoid open redirection security vulnerability
if (
!["CONSOLE_URL", "WEBAPP_URL", "WEBSITE_URL"].some(
(u) => new URL(u).origin === urlParsed.origin
)
) {
url = `${"WEBAPP_URL"}/`;
}
return url;
};

View File

@@ -1 +0,0 @@
export * from './strings';

View File

@@ -1,13 +0,0 @@
/**
* Truncates a title to a given max length substituting the middle with an ellipsis.
*/
export const truncate = (str: string, maxLength: number = 20) => {
if (str.length <= maxLength) {
return str;
}
const startLength = Math.ceil((maxLength - 3) / 2);
const endLength = Math.floor((maxLength - 3) / 2);
return `${str.slice(0, startLength)}...${str.slice(-endLength)}`;
};

View File

@@ -1,5 +1 @@
export { coloredConsole } from "./coloredConsole";
export { default as classNames } from "./classNames";
export { NEXT_PUBLIC_WEBAPP_URL } from "./constants";
export { localStorage } from "./webstorage";
export { isENVProd } from "./env";
export {};

View File

@@ -1,36 +0,0 @@
import { NEXT_PUBLIC_WEBAPP_URL } from "../constants";
export const baseEmailTemplate = (message: string, content: string) => {
const html = `
<div style="background-color: #eaeaea; padding: 2%;">
<div style="text-align:center; margin: auto; font-size: 14px; color: #353434; max-width: 500px; border-radius: 0.375rem; background: white; padding: 50px">
<img src="${NEXT_PUBLIC_WEBAPP_URL}/logo_h.png" alt="Documenso Logo" style="width: 180px; display: block; margin: auto; margin-bottom: 14px;">
${message}
${content}
</div>
`;
const footer = `
<div style="text-align: left; line-height: 18px; color: #666666; margin: 24px">
<div>
<b>Do not forward.</b>
<br>
This email gives access to a secure document. Keep it secret and do not forward this email.
</div>
<div style="margin-top: 12px">
<b>Need help?</b>
<br>
Contact us at <a href="mailto:hi@documenso.com">hi@documenso.com</a>
</div>
<hr size="1" style="height: 1px; border: none; color: #D8D8D8; background-color: #D8D8D8">
<div style="text-align: center">
<small>Easy and beautiful document signing by Documenso.</small>
</div>
</div>
</div>
`;
return html + footer;
};
export default baseEmailTemplate;

View File

@@ -1,8 +0,0 @@
export { signingRequestTemplate } from "./signingRequestTemplate";
export { signingCompleteTemplate } from "./signingCompleteTemplate";
export { sendSigningRequest as sendSigningRequest } from "./sendSigningRequest";
export { sendSigningDoneMail } from "./sendSigningDoneMail";
export { resetPasswordTemplate } from "./resetPasswordTemplate";
export { sendResetPassword } from "./sendResetPassword";
export { resetPasswordSuccessTemplate } from "./resetPasswordSuccessTemplate";
export { sendResetPasswordSuccessMail } from "./sendResetPasswordSuccessMail";

View File

@@ -1,47 +0,0 @@
import nodemailer from "nodemailer";
import nodemailerSendgrid from "nodemailer-sendgrid";
export const sendMail = async (
to: string,
subject: string,
body: string,
attachments: {
filename: string;
content: string | Buffer;
}[] = []
) => {
let transport;
if (process.env.SENDGRID_API_KEY)
transport = nodemailer.createTransport(
nodemailerSendgrid({
apiKey: process.env.SENDGRID_API_KEY || "",
})
);
if (process.env.SMTP_MAIL_HOST)
transport = nodemailer.createTransport({
host: process.env.SMTP_MAIL_HOST || "",
port: Number(process.env.SMTP_MAIL_PORT) || 587,
auth: {
user: process.env.SMTP_MAIL_USER || "",
pass: process.env.SMTP_MAIL_PASSWORD || "",
},
});
if (!transport)
throw new Error(
"No valid transport for NodeMailer found. Probably Sendgrid API Key nor SMTP Mail host was set."
);
await transport
.sendMail({
from: process.env.MAIL_FROM,
to: to,
subject: subject,
html: body,
attachments: attachments,
})
.catch((err) => {
throw err;
});
};

View File

@@ -1,18 +0,0 @@
import { signingCompleteTemplate } from "@documenso/lib/mail";
import { addDigitalSignature } from "@documenso/signing/addDigitalSignature";
import { sendMail } from "./sendMail";
import { Document as PrismaDocument } from "@prisma/client";
export const sendSigningDoneMail = async (document: PrismaDocument, user: any) => {
await sendMail(
user.email,
`Completed: "${document.title}"`,
signingCompleteTemplate(`All recipients have signed "${document.title}".`),
[
{
filename: document.title,
content: Buffer.from(await addDigitalSignature(document.document), "base64"),
},
]
);
};

View File

@@ -1,47 +0,0 @@
import { signingRequestTemplate } from "@documenso/lib/mail";
import prisma from "@documenso/prisma";
import { NEXT_PUBLIC_WEBAPP_URL } from "../constants";
import { sendMail } from "./sendMail";
import { DocumentStatus, ReadStatus, SendStatus } from "@prisma/client";
export const sendSigningRequest = async (recipient: any, document: any, user: any) => {
const signingRequestMessage = user.name
? `${user.name} (${user.email}) has sent you a document to sign. `
: `${user.email} has sent you a document to sign. `;
await sendMail(
recipient.email,
`Please sign ${document.title}`,
signingRequestTemplate(
signingRequestMessage,
document,
recipient,
`${NEXT_PUBLIC_WEBAPP_URL}/documents/${document.id}/sign?token=${recipient.token}`,
`Sign Document`,
user
)
).catch((err) => {
throw err;
});
const expiryDate = new Date();
expiryDate.setDate(expiryDate.getDate() + 60);
await prisma.recipient.update({
where: {
id: recipient.id,
},
data: {
sendStatus: SendStatus.SENT,
readStatus: ReadStatus.NOT_OPENED,
expired: expiryDate,
},
});
await prisma.document.update({
where: {
id: document.id,
},
data: { status: DocumentStatus.PENDING },
});
};

View File

@@ -1,27 +0,0 @@
import { NEXT_PUBLIC_WEBAPP_URL } from "../constants";
import { baseEmailTemplate } from "./baseTemplate";
export const signingCompleteTemplate = (message: string) => {
const customContent = `
<div style="
width: 100px;
height: 100px;
margin: auto;
padding-top: 14px;
">
<img src="${NEXT_PUBLIC_WEBAPP_URL}/images/signed_100.png" alt="Documenso Logo" style="width: 100px; display: block;">
</div>
<p style="margin-top: 14px;">
A copy of the signed document has been attached to this email.
</p>
<p style="margin-top: 14px;">
<small>Like Documenso? <a href="https://documenso.com">Hosted Documenso is here!</a>.</small>
</p>`;
const html = baseEmailTemplate(message, customContent);
return html;
};
export default signingCompleteTemplate;

View File

@@ -1,32 +0,0 @@
import { NEXT_PUBLIC_WEBAPP_URL } from "../constants";
import { baseEmailTemplate } from "./baseTemplate";
import { Document as PrismaDocument } from "@prisma/client";
export const signingRequestTemplate = (
message: string,
document: any,
recipient: any,
ctaLink: string,
ctaLabel: string,
user: any
) => {
const customContent = `
<p style="margin: 30px 0px; text-align: center">
<a href="${ctaLink}" style="background-color: #37f095; white-space: nowrap; color: white; border-color: transparent; border-width: 1px; border-radius: 0.375rem; font-size: 18px; padding-left: 16px; padding-right: 16px; padding-top: 10px; padding-bottom: 10px; text-decoration: none; margin-top: 4px; margin-bottom: 4px;">
${ctaLabel}
</a>
</p>
<hr size="1" style="height:1px;border:none;color:#e0e0e0;background-color:#e0e0e0">
Click the button to view "${document.title}".<br>
<small>If you have questions about this document, you should ask ${user.name ?? user.email}.</small>
<hr size="1" style="height:1px;border:none;color:#e0e0e0;background-color:#e0e0e0">
<p style="margin-top: 14px;">
<small>Want to send you own signing links? <a href="https://documenso.com">Hosted Documenso is here!</a>.</small>
</p>`;
const html = baseEmailTemplate(message, customContent);
return html;
};
export default signingRequestTemplate;

View File

@@ -0,0 +1,59 @@
import { PrismaAdapter } from '@next-auth/prisma-adapter';
import { compare } from 'bcrypt';
import { AuthOptions, User } from 'next-auth';
import CredentialsProvider from 'next-auth/providers/credentials';
import { prisma } from '@documenso/prisma';
import { getUserByEmail } from '../server-only/user/get-user-by-email';
export const NEXT_AUTH_OPTIONS: AuthOptions = {
adapter: PrismaAdapter(prisma),
secret: process.env.NEXTAUTH_SECRET ?? 'secret',
session: {
strategy: 'jwt',
},
providers: [
CredentialsProvider({
name: 'Credentials',
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' },
},
authorize: async (credentials, _req) => {
if (!credentials) {
return null;
}
const { email, password } = credentials;
const user = await getUserByEmail({ email }).catch(() => null);
if (!user || !user.password) {
console.log('no user');
return null;
}
const isPasswordsSame = compare(password, user.password);
if (!isPasswordsSame) {
return null;
}
return {
id: String(user.id) as any,
email: user.email,
name: user.name,
image: '',
} satisfies User;
},
}),
],
// callbacks: {
// jwt: async ({ token, user: _user }) => {
// return {
// ...token,
// };
// },
// },
};

View File

@@ -0,0 +1,54 @@
import { GetServerSidePropsContext, NextApiRequest, NextApiResponse } from 'next';
import { getServerSession as getNextAuthServerSession } from 'next-auth';
import { prisma } from '@documenso/prisma';
import { NEXT_AUTH_OPTIONS } from './auth-options';
export interface GetServerSessionOptions {
req: NextApiRequest | GetServerSidePropsContext['req'];
res: NextApiResponse | GetServerSidePropsContext['res'];
}
export const getServerSession = async ({ req, res }: GetServerSessionOptions) => {
const session = await getNextAuthServerSession(req, res, NEXT_AUTH_OPTIONS);
if (!session || !session.user?.email) {
return null;
}
const user = await prisma.user.findFirstOrThrow({
where: {
email: session.user.email,
},
});
return user;
};
export const getServerComponentSession = async () => {
const session = await getNextAuthServerSession(NEXT_AUTH_OPTIONS);
if (!session || !session.user?.email) {
return null;
}
const user = await prisma.user.findFirstOrThrow({
where: {
email: session.user.email,
},
});
return user;
};
export const getRequiredServerComponentSession = async () => {
const session = await getServerComponentSession();
if (!session) {
throw new Error('No session found');
}
return session;
};

View File

@@ -1,13 +1,29 @@
{
"name": "@documenso/lib",
"version": "0.0.0",
"private": true,
"main": "index.ts",
"version": "1.0.0",
"main": "./index.ts",
"types": "./index.ts",
"license": "MIT",
"files": [
"client-only/",
"server-only/",
"universal/",
"next-auth/"
],
"scripts": {
},
"dependencies": {
"@documenso/prisma": "*",
"@prisma/client": "^4.8.1",
"bcryptjs": "^2.4.3",
"micro": "^10.0.1",
"stripe": "^12.4.0"
"@pdf-lib/fontkit": "^1.1.1",
"@next-auth/prisma-adapter": "^1.0.6",
"@upstash/redis": "^1.20.6",
"bcrypt": "^5.1.0",
"pdf-lib": "^1.17.1",
"next": "13.4.1",
"next-auth": "^4.22.1",
"stripe": "^12.7.0"
},
"devDependencies": {
"@types/bcrypt": "^5.0.0"
}
}
}

View File

@@ -1,24 +0,0 @@
declare namespace NodeJS {
export interface ProcessEnv {
DATABASE_URL: string;
NEXT_PUBLIC_WEBAPP_URL: string;
NEXTAUTH_SECRET: string;
NEXTAUTH_URL: string;
SENDGRID_API_KEY?: string;
SMTP_MAIL_HOST?: string;
SMTP_MAIL_PORT?: string;
SMTP_MAIL_USER?: string;
SMTP_MAIL_PASSWORD?: string;
MAIL_FROM: string;
STRIPE_API_KEY?: string;
STRIPE_WEBHOOK_SECRET?: string;
STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID?: string;
STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID?: string;
NEXT_PUBLIC_ALLOW_SIGNUP?: string;
NEXT_PUBLIC_ALLOW_SUBSCRIPTIONS?: string;
}
}

View File

@@ -1,31 +0,0 @@
import { getUserFromToken } from "@documenso/lib/server";
import prisma from "@documenso/prisma";
import { Document as PrismaDocument } from "@prisma/client";
export const getDocument = async (
documentId: number,
req: any,
res: any
): Promise<PrismaDocument> => {
const user = await getUserFromToken(req, res);
if (!user) return Promise.reject("Invalid user or token.");
if (!documentId) Promise.reject("No documentId");
if (!req || !res) Promise.reject("No res or req");
const document: PrismaDocument = await prisma.document.findFirstOrThrow({
where: {
id: documentId,
userId: user.id,
},
include: {
Recipient: {
orderBy: {
id: "asc",
},
},
Field: { include: { Recipient: true, Signature: true } },
},
});
return document;
};

View File

@@ -1,21 +0,0 @@
import { getUserFromToken } from "@documenso/lib/server";
import prisma from "@documenso/prisma";
export const getDocumentsForUserFromToken = async (context: any): Promise<any> => {
const user = await getUserFromToken(context.req, context.res);
if (!user) return Promise.reject("Invalid user or token.");
const documents = await prisma.document.findMany({
where: {
userId: user.id,
},
include: {
Recipient: true,
},
orderBy: {
created: "desc",
},
});
return documents.map((e) => ({ ...e, document: "" }));
};

View File

@@ -1,2 +0,0 @@
export { getDocumentsForUserFromToken } from "./getDocumentsForUserFromToken";
export { getDocument } from "./getDocument";

View File

@@ -0,0 +1,10 @@
import { hashSync as bcryptHashSync } from 'bcrypt';
import { SALT_ROUNDS } from '../../constants/auth';
/**
* @deprecated Use the methods built into `bcrypt` instead
*/
export const hashSync = (password: string) => {
return bcryptHashSync(password, SALT_ROUNDS);
};

View File

@@ -0,0 +1,66 @@
import { prisma } from '@documenso/prisma';
import { Document, DocumentStatus, Prisma } from '@documenso/prisma/client';
import { FindResultSet } from '../../types/find-result-set';
export interface FindDocumentsOptions {
userId: number;
term?: string;
status?: DocumentStatus;
page?: number;
perPage?: number;
orderBy?: {
column: keyof Omit<Document, 'document'>;
direction: 'asc' | 'desc';
};
}
export const findDocuments = async ({
userId,
term,
status,
page = 1,
perPage = 10,
orderBy,
}: FindDocumentsOptions): Promise<FindResultSet<Document>> => {
const orderByColumn = orderBy?.column ?? 'created';
const orderByDirection = orderBy?.direction ?? 'desc';
const filters: Prisma.DocumentWhereInput = {
status,
userId,
};
if (term) {
filters.title = {
contains: term,
mode: 'insensitive',
};
}
const [data, count] = await Promise.all([
prisma.document.findMany({
where: {
...filters,
},
skip: Math.max(page - 1, 0) * perPage,
take: perPage,
orderBy: {
[orderByColumn]: orderByDirection,
},
}),
prisma.document.count({
where: {
...filters,
},
}),
]);
return {
data,
count,
currentPage: Math.max(page, 1),
perPage,
totalPages: Math.ceil(count / perPage),
};
};

View File

@@ -0,0 +1,15 @@
import { prisma } from '@documenso/prisma';
export interface GetDocumentByIdOptions {
id: number;
userId: number;
}
export const getDocumentById = async ({ id, userId }: GetDocumentByIdOptions) => {
return await prisma.document.findFirstOrThrow({
where: {
id,
userId,
},
});
};

View File

@@ -0,0 +1,30 @@
import { prisma } from '@documenso/prisma';
import { DocumentStatus } from '@documenso/prisma/client';
export type GetStatsInput = {
userId: number;
};
export const getStats = async ({ userId }: GetStatsInput) => {
const result = await prisma.document.groupBy({
by: ['status'],
_count: {
_all: true,
},
where: {
userId,
},
});
const stats: Record<DocumentStatus, number> = {
[DocumentStatus.DRAFT]: 0,
[DocumentStatus.PENDING]: 0,
[DocumentStatus.COMPLETED]: 0,
};
result.forEach((stat) => {
stats[stat.status] = stat._count._all;
});
return stats;
};

View File

@@ -0,0 +1,11 @@
import { headers } from 'next/headers';
export const getLocale = () => {
const headerItems = headers();
const locales = headerItems.get('accept-language') ?? 'en-US';
const [locale] = locales.split(',');
return locale;
};

View File

@@ -0,0 +1,26 @@
import { PDFDocument } from 'pdf-lib';
export async function insertImageInPDF(
pdfAsBase64: string,
image: string | Uint8Array | ArrayBuffer,
positionX: number,
positionY: number,
page = 0,
): Promise<string> {
const existingPdfBytes = pdfAsBase64;
const pdfDoc = await PDFDocument.load(existingPdfBytes);
const pages = pdfDoc.getPages();
const pdfPage = pages[page];
const pngImage = await pdfDoc.embedPng(image);
const drawSize = { width: 192, height: 64 };
pdfPage.drawImage(pngImage, {
x: positionX,
y: pdfPage.getHeight() - positionY - drawSize.height,
width: drawSize.width,
height: drawSize.height,
});
const pdfAsUint8Array = await pdfDoc.save();
return Buffer.from(pdfAsUint8Array).toString('base64');
}

View File

@@ -0,0 +1,50 @@
import fontkit from '@pdf-lib/fontkit';
import * as fs from 'fs';
import { PDFDocument, StandardFonts, rgb } from 'pdf-lib';
export async function insertTextInPDF(
pdfAsBase64: string,
text: string,
positionX: number,
positionY: number,
page = 0,
useHandwritingFont = true,
): Promise<string> {
const fontBytes = fs.readFileSync('./public/fonts/caveat.ttf');
const pdfDoc = await PDFDocument.load(pdfAsBase64);
pdfDoc.registerFontkit(fontkit);
const font = await pdfDoc.embedFont(useHandwritingFont ? fontBytes : StandardFonts.Helvetica);
const pages = pdfDoc.getPages();
const pdfPage = pages[page];
const textSize = useHandwritingFont ? 50 : 15;
const textWidth = font.widthOfTextAtSize(text, textSize);
const textHeight = font.heightAtSize(textSize);
const fieldSize = { width: 250, height: 64 };
// Because pdf-lib use a bottom-left coordinate system, we need to invert the y position
// we then center the text in the middle by adding half the height of the text
// plus the height of the field and divide the result by 2
const invertedYPosition =
pdfPage.getHeight() - positionY - (fieldSize.height + textHeight / 2) / 2;
// We center the text by adding the width of the field, subtracting the width of the text
// and dividing the result by 2
const centeredXPosition = positionX + (fieldSize.width - textWidth) / 2;
pdfPage.drawText(text, {
x: centeredXPosition,
y: invertedYPosition,
size: textSize,
color: rgb(0, 0, 0),
font,
});
const pdfAsUint8Array = await pdfDoc.save();
return Buffer.from(pdfAsUint8Array).toString('base64');
}

View File

@@ -0,0 +1,8 @@
/* eslint-disable turbo/no-undeclared-env-vars */
import { Redis } from '@upstash/redis';
// !: We're null coalescing here because we don't want local builds to fail.
export const redis = new Redis({
url: process.env.NEXT_PRIVATE_REDIS_URL ?? '',
token: process.env.NEXT_PRIVATE_REDIS_TOKEN ?? '',
});

View File

@@ -0,0 +1,9 @@
import Stripe from 'stripe';
// eslint-disable-next-line turbo/no-undeclared-env-vars
export const stripe = new Stripe(process.env.NEXT_PRIVATE_STRIPE_API_KEY!, {
apiVersion: '2022-11-15',
typescript: true,
});
export { Stripe };

View File

@@ -0,0 +1,35 @@
import { hash } from 'bcrypt';
import { prisma } from '@documenso/prisma';
import { IdentityProvider } from '@documenso/prisma/client';
import { SALT_ROUNDS } from '../../constants/auth';
export interface CreateUserOptions {
name: string;
email: string;
password: string;
}
export const createUser = async ({ name, email, password }: CreateUserOptions) => {
const hashedPassword = await hash(password, SALT_ROUNDS);
const userExists = await prisma.user.findFirst({
where: {
email: email.toLowerCase(),
},
});
if (userExists) {
throw new Error('User already exists');
}
return await prisma.user.create({
data: {
name,
email: email.toLowerCase(),
password: hashedPassword,
identityProvider: IdentityProvider.DOCUMENSO,
},
});
};

View File

@@ -0,0 +1,13 @@
import { prisma } from '@documenso/prisma';
export interface GetUserByEmailOptions {
email: string;
}
export const getUserByEmail = async ({ email }: GetUserByEmailOptions) => {
return await prisma.user.findFirstOrThrow({
where: {
email: email.toLowerCase(),
},
});
};

View File

@@ -0,0 +1,13 @@
import { prisma } from '@documenso/prisma';
export interface GetUserByIdOptions {
id: number;
}
export const getUserById = async ({ id }: GetUserByIdOptions) => {
return await prisma.user.findFirstOrThrow({
where: {
id,
},
});
};

View File

@@ -0,0 +1,32 @@
import { hash } from 'bcrypt';
import { prisma } from '@documenso/prisma';
import { SALT_ROUNDS } from '../../constants/auth';
export type UpdatePasswordOptions = {
userId: number;
password: string;
};
export const updatePassword = async ({ userId, password }: UpdatePasswordOptions) => {
// Existence check
await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
});
const hashedPassword = await hash(password, SALT_ROUNDS);
const updatedUser = await prisma.user.update({
where: {
id: userId,
},
data: {
password: hashedPassword,
},
});
return updatedUser;
};

View File

@@ -0,0 +1,33 @@
import { prisma } from '@documenso/prisma';
export type UpdateProfileOptions = {
userId: number;
name: string;
signature: string;
};
export const updateProfile = async ({
userId,
name,
// TODO: Actually use signature
signature: _signature,
}: UpdateProfileOptions) => {
// Existence check
await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
});
const updatedUser = await prisma.user.update({
where: {
id: userId,
},
data: {
name,
// signature,
},
});
return updatedUser;
};

View File

@@ -1,27 +0,0 @@
import type { NextApiHandler, NextApiRequest, NextApiResponse } from "next";
type Handlers = {
[method in "GET" | "POST" | "PATCH" | "PUT" | "DELETE"]?: Promise<{
default: NextApiHandler;
}>;
};
/** Allows us to split big API handlers by method */
export const defaultHandler =
(handlers: Handlers) => async (req: NextApiRequest, res: NextApiResponse) => {
const handler = (await handlers[req.method as keyof typeof handlers])?.default;
// auto catch unsupported methods.
if (!handler) {
return res.status(405).json({
message: `Method Not Allowed (Allow: ${Object.keys(handlers).join(",")})`,
});
}
try {
await handler(req, res);
return;
} catch (error) {
console.error(error);
return res.status(500).json({ message: "Something went wrong" });
}
};

View File

@@ -1,19 +0,0 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { getServerErrorFromUnknown } from "@documenso/lib/server";
type Handle<T> = (req: NextApiRequest, res: NextApiResponse) => Promise<T>;
/** Allows us to get type inference from API handler responses */
export function defaultResponder<T>(f: Handle<T>) {
return async (req: NextApiRequest, res: NextApiResponse) => {
try {
const result = await f(req, res);
if (result) res.json(result);
} catch (err) {
console.error(err);
const error = getServerErrorFromUnknown(err);
res.statusCode = error.statusCode;
res.json({ message: error.message });
}
};
}

View File

@@ -1,39 +0,0 @@
import { HttpError } from "@documenso/lib/server";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
export function getServerErrorFromUnknown(cause: unknown): HttpError {
// Error was manually thrown and does not need to be parsed.
if (cause instanceof HttpError) {
return cause;
}
if (cause instanceof SyntaxError) {
return new HttpError({
statusCode: 500,
message: "Unexpected error, please reach out for our customer support.",
});
}
if (cause instanceof PrismaClientKnownRequestError) {
return new HttpError({ statusCode: 400, message: cause.message, cause });
}
if (cause instanceof PrismaClientKnownRequestError) {
return new HttpError({ statusCode: 404, message: cause.message, cause });
}
if (cause instanceof Error) {
return new HttpError({ statusCode: 500, message: cause.message, cause });
}
if (typeof cause === "string") {
// @ts-expect-error https://github.com/tc39/proposal-error-cause
return new Error(cause, { cause });
}
// Catch-All if none of the above triggered and something (even more) unexpected happened
return new HttpError({
statusCode: 500,
message: `Unhandled error of type '${typeof cause}'. Please reach out for our customer support.`,
});
}

View File

@@ -1,27 +0,0 @@
import { GetServerSidePropsContext, NextApiRequest, NextApiResponse } from "next";
import { NextRequest } from "next/server";
import prisma from "@documenso/prisma";
import { User as PrismaUser } from "@prisma/client";
import { getToken } from "next-auth/jwt";
export async function getUserFromToken(
req: GetServerSidePropsContext["req"] | NextRequest | NextApiRequest,
res?: NextApiResponse // TODO: Remove this optional parameter
): Promise<PrismaUser | null> {
const token = await getToken({ req });
const tokenEmail = token?.email?.toString();
if (!token || !tokenEmail) {
return null;
}
const user = await prisma.user.findFirst({
where: { email: tokenEmail },
});
if (!user) {
return null;
}
return user;
}

View File

@@ -1,39 +0,0 @@
export class HttpError<TCode extends number = number> extends Error {
public readonly cause?: Error;
public readonly statusCode: TCode;
public readonly message: string;
public readonly url: string | undefined;
public readonly method: string | undefined;
constructor(opts: {
url?: string;
method?: string;
message?: string;
statusCode: TCode;
cause?: Error;
}) {
super(opts.message ?? `HTTP Error ${opts.statusCode} `);
Object.setPrototypeOf(this, HttpError.prototype);
this.name = HttpError.prototype.constructor.name;
this.cause = opts.cause;
this.statusCode = opts.statusCode;
this.url = opts.url;
this.method = opts.method;
this.message = opts.message ?? `HTTP Error ${opts.statusCode}`;
if (opts.cause instanceof Error && opts.cause.stack) {
this.stack = opts.cause.stack;
}
}
public static fromRequest(request: Request, response: Response) {
return new HttpError({
message: response.statusText,
url: response.url,
method: request.method,
statusCode: response.status,
});
}
}

View File

@@ -1,5 +0,0 @@
export { defaultHandler } from "./defaultHandler";
export { defaultResponder } from "./defaultResponder";
export { HttpError } from "./http-error";
export { getServerErrorFromUnknown } from "./getServerErrorFromUnknown";
export { getUserFromToken } from "./getUserFromToken";

View File

@@ -1,7 +0,0 @@
import Stripe from 'stripe';
export const stripe = new Stripe(process.env.STRIPE_API_KEY!, {
apiVersion: "2022-11-15",
typescript: true,
});

View File

@@ -1,15 +0,0 @@
export const STRIPE_PLANS = [
{
name: "Community Plan",
prices: {
monthly: {
price: 30,
priceId: process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID ?? "",
},
yearly: {
price: 300,
priceId: process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID ?? "",
},
},
},
];

View File

@@ -1,23 +0,0 @@
import { CheckoutSessionRequest, CheckoutSessionResponse } from "../handlers/checkout-session"
export type FetchCheckoutSessionOptions = CheckoutSessionRequest['body']
export const fetchCheckoutSession = async ({
id,
priceId
}: FetchCheckoutSessionOptions) => {
const response = await fetch('/api/stripe/checkout-session', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
id,
priceId
})
});
const json: CheckoutSessionResponse = await response.json();
return json;
}

View File

@@ -1,14 +0,0 @@
import { GetSubscriptionResponse } from "../handlers/get-subscription";
export const fetchSubscription = async () => {
const response = await fetch("/api/stripe/subscription", {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
const json: GetSubscriptionResponse = await response.json();
return json;
};

View File

@@ -1,19 +0,0 @@
import { PortalSessionRequest, PortalSessionResponse } from "../handlers/portal-session";
export type FetchPortalSessionOptions = PortalSessionRequest["body"];
export const fetchPortalSession = async ({ id }: FetchPortalSessionOptions) => {
const response = await fetch("/api/stripe/portal-session", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
id,
}),
});
const json: PortalSessionResponse = await response.json();
return json;
};

View File

@@ -1,35 +0,0 @@
import { GetServerSideProps, GetServerSidePropsContext, NextApiRequest } from "next";
import { SubscriptionStatus } from "@prisma/client";
import { getToken } from "next-auth/jwt";
export const isSubscriptionsEnabled = () => {
return process.env.NEXT_PUBLIC_ALLOW_SUBSCRIPTIONS === "true";
};
export const isSubscribedServer = async (
req: NextApiRequest | GetServerSidePropsContext["req"]
) => {
const { default: prisma } = await import("@documenso/prisma");
if (!isSubscriptionsEnabled()) {
return true;
}
const token = await getToken({
req,
});
if (!token || !token.email) {
return false;
}
const subscription = await prisma.subscription.findFirst({
where: {
User: {
email: token.email,
},
},
});
return subscription !== null && subscription.status !== SubscriptionStatus.INACTIVE;
};

View File

@@ -1,92 +0,0 @@
import { NextApiRequest, NextApiResponse } from "next";
import prisma from "@documenso/prisma";
import { stripe } from "../client";
import { getToken } from "next-auth/jwt";
export type CheckoutSessionRequest = {
body: {
id?: string;
priceId: string;
};
};
export type CheckoutSessionResponse =
| {
success: false;
message: string;
}
| {
success: true;
url: string;
};
export const checkoutSessionHandler = async (req: NextApiRequest, res: NextApiResponse) => {
if (!process.env.NEXT_PUBLIC_ALLOW_SUBSCRIPTIONS) {
return res.status(500).json({
success: false,
message: "Subscriptions are not enabled",
});
}
if (req.method !== "POST") {
return res.status(405).json({
success: false,
message: "Method not allowed",
});
}
const token = await getToken({
req,
});
if (!token || !token.email) {
return res.status(401).json({
success: false,
message: "Unauthorized",
});
}
const user = await prisma.user.findFirst({
where: {
email: token.email,
},
});
if (!user) {
return res.status(404).json({
success: false,
message: "No user found",
});
}
const { id, priceId } = req.body;
if (typeof priceId !== "string") {
return res.status(400).json({
success: false,
message: "No id or priceId found in request",
});
}
const session = await stripe.checkout.sessions.create({
customer: id,
customer_email: user.email,
client_reference_id: String(user.id),
payment_method_types: ["card"],
line_items: [
{
price: priceId,
quantity: 1,
},
],
mode: "subscription",
allow_promotion_codes: true,
success_url: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing?success=true`,
cancel_url: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing?canceled=true`,
});
return res.status(200).json({
success: true,
url: session.url,
});
};

View File

@@ -1,63 +0,0 @@
import { NextApiRequest, NextApiResponse } from "next";
import prisma from "@documenso/prisma";
import { Subscription } from "@prisma/client";
import { getToken } from "next-auth/jwt";
export type GetSubscriptionRequest = never;
export type GetSubscriptionResponse =
| {
success: false;
message: string;
}
| {
success: true;
subscription: Subscription;
};
export const getSubscriptionHandler = async (req: NextApiRequest, res: NextApiResponse) => {
if (!process.env.NEXT_PUBLIC_ALLOW_SUBSCRIPTIONS) {
return res.status(500).json({
success: false,
message: "Subscriptions are not enabled",
});
}
if (req.method !== "GET") {
return res.status(405).json({
success: false,
message: "Method not allowed",
});
}
const token = await getToken({
req,
});
if (!token || !token.email) {
return res.status(401).json({
success: false,
message: "Unauthorized",
});
}
const subscription = await prisma.subscription.findFirst({
where: {
User: {
email: token.email,
},
},
});
if (!subscription) {
return res.status(404).json({
success: false,
message: "No subscription found",
});
}
return res.status(200).json({
success: true,
subscription,
});
};

View File

@@ -1,54 +0,0 @@
import { NextApiRequest, NextApiResponse } from "next";
import { stripe } from "../client";
export type PortalSessionRequest = {
body: {
id: string;
};
};
export type PortalSessionResponse =
| {
success: false;
message: string;
}
| {
success: true;
url: string;
};
export const portalSessionHandler = async (req: NextApiRequest, res: NextApiResponse<PortalSessionResponse>) => {
if (!process.env.NEXT_PUBLIC_ALLOW_SUBSCRIPTIONS) {
return res.status(500).json({
success: false,
message: "Subscriptions are not enabled",
});
}
if (req.method !== "POST") {
return res.status(405).json({
success: false,
message: "Method not allowed",
});
}
const { id } = req.body;
if (typeof id !== "string") {
return res.status(400).json({
success: false,
message: "No id found in request",
});
}
const session = await stripe.billingPortal.sessions.create({
customer: id,
return_url: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`,
});
return res.status(200).json({
success: true,
url: session.url,
});
};

View File

@@ -1,201 +0,0 @@
import { NextApiRequest, NextApiResponse } from "next";
import prisma from "@documenso/prisma";
import { stripe } from "../client";
import { SubscriptionStatus } from "@prisma/client";
import { buffer } from "micro";
import Stripe from "stripe";
const log = (...args: any[]) => console.log("[stripe]", ...args);
export const webhookHandler = async (req: NextApiRequest, res: NextApiResponse) => {
if (!process.env.NEXT_PUBLIC_ALLOW_SUBSCRIPTIONS) {
return res.status(500).json({
success: false,
message: "Subscriptions are not enabled",
});
}
const sig =
typeof req.headers["stripe-signature"] === "string" ? req.headers["stripe-signature"] : "";
if (!sig) {
return res.status(400).json({
success: false,
message: "No signature found in request",
});
}
const body = await buffer(req);
const event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
log("event-type:", event.type);
if (event.type === "checkout.session.completed") {
const session = event.data.object as Stripe.Checkout.Session;
const subscription = await stripe.subscriptions.retrieve(session.subscription as string);
const customerId =
typeof subscription.customer === "string" ? subscription.customer : subscription.customer?.id;
await prisma.subscription.upsert({
where: {
customerId,
},
create: {
customerId,
status: SubscriptionStatus.ACTIVE,
planId: subscription.id,
priceId: subscription.items.data[0].price.id,
periodEnd: new Date(subscription.current_period_end * 1000),
userId: Number(session.client_reference_id as string),
},
update: {
customerId,
status: SubscriptionStatus.ACTIVE,
planId: subscription.id,
priceId: subscription.items.data[0].price.id,
periodEnd: new Date(subscription.current_period_end * 1000),
},
});
return res.status(200).json({
success: true,
message: "Webhook received",
});
}
if (event.type === "invoice.payment_succeeded") {
const invoice = event.data.object as Stripe.Invoice;
if (invoice.billing_reason !== "subscription_cycle") {
return res.status(200).json({
success: true,
message: "Webhook received",
});
}
const customerId =
typeof invoice.customer === "string" ? invoice.customer : invoice.customer?.id;
const subscription = await stripe.subscriptions.retrieve(invoice.subscription as string);
const hasSubscription = await prisma.subscription.findFirst({
where: {
customerId,
},
});
if (hasSubscription) {
await prisma.subscription.update({
where: {
customerId,
},
data: {
status: SubscriptionStatus.ACTIVE,
planId: subscription.id,
priceId: subscription.items.data[0].price.id,
periodEnd: new Date(subscription.current_period_end * 1000),
},
});
}
return res.status(200).json({
success: true,
message: "Webhook received",
});
}
if (event.type === "invoice.payment_failed") {
const failedInvoice = event.data.object as Stripe.Invoice;
const customerId = failedInvoice.customer as string;
const hasSubscription = await prisma.subscription.findFirst({
where: {
customerId,
},
});
if (hasSubscription) {
await prisma.subscription.update({
where: {
customerId,
},
data: {
status: SubscriptionStatus.PAST_DUE,
},
});
}
return res.status(200).json({
success: true,
message: "Webhook received",
});
}
if (event.type === "customer.subscription.updated") {
const updatedSubscription = event.data.object as Stripe.Subscription;
const customerId = updatedSubscription.customer as string;
const hasSubscription = await prisma.subscription.findFirst({
where: {
customerId,
},
});
if (hasSubscription) {
await prisma.subscription.update({
where: {
customerId,
},
data: {
status: SubscriptionStatus.ACTIVE,
planId: updatedSubscription.id,
priceId: updatedSubscription.items.data[0].price.id,
periodEnd: new Date(updatedSubscription.current_period_end * 1000),
},
});
}
return res.status(200).json({
success: true,
message: "Webhook received",
});
}
if (event.type === "customer.subscription.deleted") {
const deletedSubscription = event.data.object as Stripe.Subscription;
const customerId = deletedSubscription.customer as string;
const hasSubscription = await prisma.subscription.findFirst({
where: {
customerId,
},
});
if (hasSubscription) {
await prisma.subscription.update({
where: {
customerId,
},
data: {
status: SubscriptionStatus.INACTIVE,
},
});
}
return res.status(200).json({
success: true,
message: "Webhook received",
});
}
log("Unhandled webhook event", event.type);
return res.status(400).json({
success: false,
message: "Unhandled webhook event",
});
};

View File

@@ -1,6 +0,0 @@
export * from './data/plans'
export * from './fetchers/checkout-session'
export * from './fetchers/get-subscription'
export * from './fetchers/portal-session'
export * from './guards/subscriptions'
export * from './providers/subscription-provider'

View File

@@ -1,89 +0,0 @@
import React, { createContext, useContext, useEffect, useMemo, useState } from "react";
import { fetchSubscription } from "../fetchers/get-subscription";
import { Subscription, SubscriptionStatus } from "@prisma/client";
import { useSession } from "next-auth/react";
export type SubscriptionContextValue = {
subscription: Subscription | null;
hasSubscription: boolean;
isLoading: boolean;
};
const SubscriptionContext = createContext<SubscriptionContextValue>({
subscription: null,
hasSubscription: false,
isLoading: false,
});
export const useSubscription = () => {
const context = useContext(SubscriptionContext);
if (!context) {
throw new Error(`useSubscription must be used within a SubscriptionProvider`);
}
return context;
};
export interface SubscriptionProviderProps {
children: React.ReactNode;
initialSubscription?: Subscription;
}
export const SubscriptionProvider = ({
children,
initialSubscription,
}: SubscriptionProviderProps) => {
const session = useSession();
const [isLoading, setIsLoading] = useState(false);
const [subscription, setSubscription] = useState<Subscription | null>(
initialSubscription || null
);
const hasSubscription = useMemo(() => {
console.log({
"process.env.NEXT_PUBLIC_ALLOW_SUBSCRIPTIONS": process.env.NEXT_PUBLIC_ALLOW_SUBSCRIPTIONS,
enabled: process.env.NEXT_PUBLIC_ALLOW_SUBSCRIPTIONS === "true",
"subscription.status": subscription?.status,
"subscription.periodEnd": subscription?.periodEnd,
});
if (process.env.NEXT_PUBLIC_ALLOW_SUBSCRIPTIONS === "true") {
return (
subscription?.status === SubscriptionStatus.ACTIVE &&
!!subscription?.periodEnd &&
new Date(subscription.periodEnd) > new Date()
);
}
return true;
}, [subscription]);
useEffect(() => {
if (process.env.NEXT_PUBLIC_ALLOW_SUBSCRIPTIONS === "true" && session.data) {
setIsLoading(true);
fetchSubscription().then((res) => {
if (res.success) {
setSubscription(res.subscription);
} else {
setSubscription(null);
}
setIsLoading(false);
});
}
}, [session.data]);
return (
<SubscriptionContext.Provider
value={{
subscription,
hasSubscription,
isLoading,
}}>
{children}
</SubscriptionContext.Provider>
);
};

View File

@@ -0,0 +1,7 @@
export type FindResultSet<T> = {
data: T[];
count: number;
currentPage: number;
perPage: number;
totalPages: number;
};

View File

@@ -0,0 +1,5 @@
import { DocumentStatus } from '@documenso/prisma/client';
export const isDocumentStatus = (value: unknown): value is DocumentStatus => {
return Object.values(DocumentStatus).includes(value as DocumentStatus);
};

View File

@@ -0,0 +1,16 @@
/* eslint-disable turbo/no-undeclared-env-vars */
export const getBaseUrl = () => {
if (typeof window !== 'undefined') {
return '';
}
if (process.env.VERCEL_URL) {
return `https://${process.env.VERCEL_URL}`;
}
if (process.env.NEXT_PUBLIC_SITE_URL) {
return `https://${process.env.NEXT_PUBLIC_SITE_URL}`;
}
return `http://localhost:${process.env.PORT ?? 3000}`;
};

View File

@@ -1,21 +0,0 @@
export const localStorage = {
getItem(key: string) {
try {
return window.localStorage.getItem(key);
} catch (e) {
// In case storage is restricted. Possible reasons
// 1. Chrome/Firefox/... Incognito mode.
return null;
}
},
setItem(key: string, value: string) {
try {
window.localStorage.setItem(key, value);
} catch (e) {
// In case storage is restricted. Possible reasons
// 1. Chrome/Firefox/... Incognito mode.
// 2. Storage limit reached
return;
}
},
};