wip: refresh design
This commit is contained in:
@@ -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) {}
|
||||
};
|
||||
@@ -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) {}
|
||||
};
|
||||
@@ -1,5 +0,0 @@
|
||||
export const deleteDocument = (documentId: number): Promise<Response> => {
|
||||
return fetch(`/api/documents/${documentId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
};
|
||||
@@ -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) {}
|
||||
};
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -1,7 +0,0 @@
|
||||
export const getDocuments = (): Promise<Response> => {
|
||||
return fetch("/api/documents", {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,3 +0,0 @@
|
||||
export const getUser = (): Promise<Response> => {
|
||||
return fetch("/api/users/me");
|
||||
};
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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 :/",
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -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",
|
||||
});
|
||||
};
|
||||
@@ -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",
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export default function classNames(...classes: unknown[]) {
|
||||
return classes.filter(Boolean).join(" ");
|
||||
}
|
||||
21
packages/lib/client-only/hooks/use-update-search-params.ts
Normal file
21
packages/lib/client-only/hooks/use-update-search-params.ts
Normal 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()}`);
|
||||
};
|
||||
};
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
1
packages/lib/constants/auth.ts
Normal file
1
packages/lib/constants/auth.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const SALT_ROUNDS = 12;
|
||||
5
packages/lib/constants/features.ts
Normal file
5
packages/lib/constants/features.ts
Normal 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';
|
||||
@@ -1 +0,0 @@
|
||||
export const isENVProd = process.env.NODE_ENV === "production";
|
||||
5
packages/lib/errors/user-exists.ts
Normal file
5
packages/lib/errors/user-exists.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export class UserExistsError extends Error {
|
||||
constructor() {
|
||||
super('User already exists');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export * from './strings';
|
||||
@@ -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)}`;
|
||||
};
|
||||
@@ -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 {};
|
||||
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
});
|
||||
};
|
||||
@@ -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"),
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
@@ -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 },
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
59
packages/lib/next-auth/auth-options.ts
Normal file
59
packages/lib/next-auth/auth-options.ts
Normal 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,
|
||||
// };
|
||||
// },
|
||||
// },
|
||||
};
|
||||
54
packages/lib/next-auth/get-server-session.ts
Normal file
54
packages/lib/next-auth/get-server-session.ts
Normal 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;
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
24
packages/lib/process-env.d.ts
vendored
24
packages/lib/process-env.d.ts
vendored
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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: "" }));
|
||||
};
|
||||
@@ -1,2 +0,0 @@
|
||||
export { getDocumentsForUserFromToken } from "./getDocumentsForUserFromToken";
|
||||
export { getDocument } from "./getDocument";
|
||||
10
packages/lib/server-only/auth/hash.ts
Normal file
10
packages/lib/server-only/auth/hash.ts
Normal 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);
|
||||
};
|
||||
66
packages/lib/server-only/document/find-documents.ts
Normal file
66
packages/lib/server-only/document/find-documents.ts
Normal 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),
|
||||
};
|
||||
};
|
||||
15
packages/lib/server-only/document/get-document-by-id.ts
Normal file
15
packages/lib/server-only/document/get-document-by-id.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
};
|
||||
30
packages/lib/server-only/document/get-stats.ts
Normal file
30
packages/lib/server-only/document/get-stats.ts
Normal 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;
|
||||
};
|
||||
11
packages/lib/server-only/headers/get-locale.tsx
Normal file
11
packages/lib/server-only/headers/get-locale.tsx
Normal 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;
|
||||
};
|
||||
26
packages/lib/server-only/pdf/insert-image-in-pdf.ts
Normal file
26
packages/lib/server-only/pdf/insert-image-in-pdf.ts
Normal 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');
|
||||
}
|
||||
50
packages/lib/server-only/pdf/insert-text-in-pdf.ts
Normal file
50
packages/lib/server-only/pdf/insert-text-in-pdf.ts
Normal 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');
|
||||
}
|
||||
8
packages/lib/server-only/redis/index.ts
Normal file
8
packages/lib/server-only/redis/index.ts
Normal 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 ?? '',
|
||||
});
|
||||
9
packages/lib/server-only/stripe/index.ts
Normal file
9
packages/lib/server-only/stripe/index.ts
Normal 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 };
|
||||
35
packages/lib/server-only/user/create-user.ts
Normal file
35
packages/lib/server-only/user/create-user.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
};
|
||||
13
packages/lib/server-only/user/get-user-by-email.ts
Normal file
13
packages/lib/server-only/user/get-user-by-email.ts
Normal 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(),
|
||||
},
|
||||
});
|
||||
};
|
||||
13
packages/lib/server-only/user/get-user-by-id.ts
Normal file
13
packages/lib/server-only/user/get-user-by-id.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
};
|
||||
32
packages/lib/server-only/user/update-password.ts
Normal file
32
packages/lib/server-only/user/update-password.ts
Normal 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;
|
||||
};
|
||||
33
packages/lib/server-only/user/update-profile.ts
Normal file
33
packages/lib/server-only/user/update-profile.ts
Normal 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;
|
||||
};
|
||||
@@ -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" });
|
||||
}
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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.`,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -1,7 +0,0 @@
|
||||
import Stripe from 'stripe';
|
||||
|
||||
|
||||
export const stripe = new Stripe(process.env.STRIPE_API_KEY!, {
|
||||
apiVersion: "2022-11-15",
|
||||
typescript: true,
|
||||
});
|
||||
@@ -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 ?? "",
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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",
|
||||
});
|
||||
};
|
||||
@@ -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'
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
7
packages/lib/types/find-result-set.ts
Normal file
7
packages/lib/types/find-result-set.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export type FindResultSet<T> = {
|
||||
data: T[];
|
||||
count: number;
|
||||
currentPage: number;
|
||||
perPage: number;
|
||||
totalPages: number;
|
||||
};
|
||||
5
packages/lib/types/is-document-status.ts
Normal file
5
packages/lib/types/is-document-status.ts
Normal 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);
|
||||
};
|
||||
16
packages/lib/universal/get-base-url.ts
Normal file
16
packages/lib/universal/get-base-url.ts
Normal 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}`;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user