mirror of
https://github.com/jakejarvis/jarv.is.git
synced 2025-04-26 04:45:22 -04:00
use standard email/SMTP for contact form instead of airtable API
This commit is contained in:
parent
2de3914449
commit
4bef13f4ab
@ -1,10 +1,9 @@
|
||||
# absolutely required:
|
||||
NEXT_PUBLIC_BASE_URL=
|
||||
DATABASE_URL=
|
||||
GH_PUBLIC_TOKEN=
|
||||
POSTGRES_URL=
|
||||
POSTGRES_URL_NON_POOLING=
|
||||
|
||||
# optional (but not really):
|
||||
AIRTABLE_API_KEY=
|
||||
AIRTABLE_BASE=
|
||||
HCAPTCHA_SECRET_KEY=
|
||||
MAILGUN_SMTP_USER=
|
||||
MAILGUN_SMTP_PASS=
|
||||
|
@ -5,7 +5,7 @@
|
||||
[](LICENSE)
|
||||
[](https://github.com/jakejarvis/jarv.is)
|
||||
|
||||
My humble abode on the World Wide Web, created and deployed using [Next.js](https://nextjs.org/), [Stitches](https://stitches.dev/), [Prisma](https://www.prisma.io/), [Vercel](https://vercel.com/), [Supabase](https://supabase.com/), [and more](https://jarv.is/humans.txt).
|
||||
My humble abode on the World Wide Web, created and deployed using [Next.js](https://nextjs.org/), [Stitches](https://stitches.dev/), [Prisma](https://www.prisma.io/), [Vercel](https://vercel.com/), [PlanetScale](https://planetscale.com/), [and more](https://jarv.is/humans.txt).
|
||||
|
||||
I keep an ongoing list of [post ideas](https://github.com/jakejarvis/jarv.is/issues/1) and [coding to-dos](https://github.com/jakejarvis/jarv.is/issues/714) as issues in this repo. Outside contributions, improvements, and/or corrections are welcome too!
|
||||
|
||||
|
@ -4,9 +4,6 @@
|
||||
// https://nextjs.org/blog/next-9-1-7#new-built-in-polyfills-fetch-url-and-objectassign
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const fetcher = async (input: RequestInfo, init?: RequestInit): Promise<any> => {
|
||||
const res = await fetch(input, init);
|
||||
return res.json();
|
||||
};
|
||||
const fetcher = <T = any>(...args: Parameters<typeof fetch>): Promise<T> => fetch(...args).then((res) => res.json());
|
||||
|
||||
export default fetcher;
|
||||
|
10
package.json
10
package.json
@ -22,7 +22,7 @@
|
||||
"@hcaptcha/react-hcaptcha": "^1.10.1",
|
||||
"@novnc/novnc": "1.4.0",
|
||||
"@octokit/graphql": "^7.0.2",
|
||||
"@octokit/graphql-schema": "^14.57.0",
|
||||
"@octokit/graphql-schema": "^14.58.0",
|
||||
"@prisma/client": "^5.10.2",
|
||||
"@react-spring/web": "^9.7.3",
|
||||
"@stitches/react": "1.3.1-1",
|
||||
@ -39,6 +39,7 @@
|
||||
"next": "14.1.0",
|
||||
"next-mdx-remote": "^4.4.1",
|
||||
"next-seo": "^6.5.0",
|
||||
"nodemailer": "^6.9.10",
|
||||
"obj-str": "^1.1.0",
|
||||
"p-map": "^7.0.1",
|
||||
"p-memoize": "^7.1.1",
|
||||
@ -74,9 +75,10 @@
|
||||
"@jakejarvis/eslint-config": "^3.1.0",
|
||||
"@types/comma-number": "^2.1.2",
|
||||
"@types/node": "^20.11.20",
|
||||
"@types/nodemailer": "^6.4.14",
|
||||
"@types/novnc__novnc": "^1.3.4",
|
||||
"@types/prop-types": "^15.7.11",
|
||||
"@types/react": "^18.2.58",
|
||||
"@types/react": "^18.2.60",
|
||||
"@types/react-dom": "^18.2.19",
|
||||
"@types/react-is": "^18.2.4",
|
||||
"@typescript-eslint/eslint-plugin": "^7.1.0",
|
||||
@ -111,9 +113,9 @@
|
||||
"eslint"
|
||||
]
|
||||
},
|
||||
"packageManager": "pnpm@8.15.3",
|
||||
"packageManager": "pnpm@8.15.4",
|
||||
"volta": {
|
||||
"node": "20.11.1",
|
||||
"pnpm": "8.15.3"
|
||||
"pnpm": "8.15.4"
|
||||
}
|
||||
}
|
||||
|
@ -1,29 +1,17 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import nodemailer from "nodemailer";
|
||||
import queryString from "query-string";
|
||||
import { siteDomain, hcaptchaSiteKey } from "../../lib/config";
|
||||
import type { NextRequest } from "next/server";
|
||||
import fetcher from "../../lib/helpers/fetcher";
|
||||
import { siteDomain, authorEmail, hcaptchaSiteKey } from "../../lib/config";
|
||||
import type { NextApiHandler } from "next";
|
||||
|
||||
// fallback to dummy secret for testing: https://docs.hcaptcha.com/#integration-testing-test-keys
|
||||
const HCAPTCHA_SITE_KEY = hcaptchaSiteKey || "10000000-ffff-ffff-ffff-000000000001";
|
||||
const HCAPTCHA_SECRET_KEY = process.env.HCAPTCHA_SECRET_KEY || "0x0000000000000000000000000000000000000000";
|
||||
const HCAPTCHA_API_ENDPOINT = "https://hcaptcha.com/siteverify";
|
||||
|
||||
const { AIRTABLE_API_KEY, AIRTABLE_BASE } = process.env;
|
||||
const AIRTABLE_API_ENDPOINT = "https://api.airtable.com/v0/";
|
||||
|
||||
export const config = {
|
||||
runtime: "edge",
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-anonymous-default-export
|
||||
export default async (req: NextRequest) => {
|
||||
const handler: NextApiHandler = async (req, res) => {
|
||||
// redirect GET requests to this endpoint to the contact form itself
|
||||
if (req.method === "GET") {
|
||||
return NextResponse.redirect(`${process.env.NEXT_PUBLIC_BASE_URL || `https://${siteDomain}`}/contact/`);
|
||||
return res.redirect(`${process.env.NEXT_PUBLIC_BASE_URL || `https://${siteDomain}`}/contact/`);
|
||||
}
|
||||
|
||||
// possible weirdness? https://github.com/orgs/vercel/discussions/78#discussioncomment-5089059
|
||||
const data = await req.json();
|
||||
const data = req.body;
|
||||
|
||||
// these are both backups to client-side validations just in case someone squeezes through without them. the codes
|
||||
// are identical so they're caught in the same fashion.
|
||||
@ -36,61 +24,61 @@ export default async (req: NextRequest) => {
|
||||
throw new Error("INVALID_CAPTCHA");
|
||||
}
|
||||
|
||||
// sent directly to airtable
|
||||
const airtableResult = await sendToAirtable({
|
||||
Name: data.name,
|
||||
Email: data.email,
|
||||
Message: data.message,
|
||||
});
|
||||
|
||||
// throw an internal error, not user's fault
|
||||
if (airtableResult !== true) {
|
||||
throw new Error("AIRTABLE_API_ERROR");
|
||||
if (!(await sendMessage(data))) {
|
||||
throw new Error("NODEMAILER_ERROR");
|
||||
}
|
||||
|
||||
// disable caching on both ends. see:
|
||||
// https://vercel.com/docs/concepts/functions/edge-functions/edge-caching
|
||||
res.setHeader("Cache-Control", "private, no-cache, no-store, must-revalidate");
|
||||
|
||||
// success! let the client know
|
||||
return NextResponse.json(
|
||||
{ success: true },
|
||||
{
|
||||
status: 201,
|
||||
headers: {
|
||||
// disable caching on both ends. see:
|
||||
// https://vercel.com/docs/concepts/functions/edge-functions/edge-caching
|
||||
"Cache-Control": "private, no-cache, no-store, must-revalidate",
|
||||
},
|
||||
}
|
||||
);
|
||||
return res.status(201).json({ success: true });
|
||||
};
|
||||
|
||||
const validateCaptcha = async (formResponse: unknown): Promise<unknown> => {
|
||||
const response = await fetch(HCAPTCHA_API_ENDPOINT, {
|
||||
const response = await fetcher("https://hcaptcha.com/siteverify", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: queryString.stringify({
|
||||
response: formResponse,
|
||||
sitekey: HCAPTCHA_SITE_KEY,
|
||||
secret: HCAPTCHA_SECRET_KEY,
|
||||
// fallback to dummy secret for testing: https://docs.hcaptcha.com/#integration-testing-test-keys
|
||||
sitekey: hcaptchaSiteKey || "10000000-ffff-ffff-ffff-000000000001",
|
||||
secret: process.env.HCAPTCHA_SECRET_KEY || "0x0000000000000000000000000000000000000000",
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
return result.success;
|
||||
return response?.success;
|
||||
};
|
||||
|
||||
const sendToAirtable = async (data: unknown): Promise<boolean> => {
|
||||
const response = await fetch(`${AIRTABLE_API_ENDPOINT}${AIRTABLE_BASE}/Messages`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${AIRTABLE_API_KEY}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
fields: data,
|
||||
}),
|
||||
});
|
||||
const sendMessage = async (data: Record<string, unknown>): Promise<boolean> => {
|
||||
try {
|
||||
const transporter = nodemailer.createTransport({
|
||||
service: "mailgun",
|
||||
auth: {
|
||||
user: process.env.MAILGUN_SMTP_USER,
|
||||
pass: process.env.MAILGUN_SMTP_PASS,
|
||||
},
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
await transporter.sendMail({
|
||||
from: `${data.name} <${process.env.MAILGUN_SMTP_USER}>`,
|
||||
sender: `nodemailer <${process.env.MAILGUN_SMTP_USER}>`,
|
||||
replyTo: `${data.name} <${data.email}>`,
|
||||
to: `<${authorEmail}>`,
|
||||
subject: `[${siteDomain}] Contact Form Submission`,
|
||||
// TODO: add markdown parsing as promised on the form.
|
||||
text: `${data.message}`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export default handler;
|
||||
|
@ -48,7 +48,7 @@ const Privacy = () => {
|
||||
|
||||
<p>
|
||||
A very simple hit counter on each blog post tallies an aggregate number of pageviews (i.e.{" "}
|
||||
<CodeInline>hits = hits + 1</CodeInline>) in a <Link href="https://supabase.com/">Supabase Postgres</Link>{" "}
|
||||
<CodeInline>hits = hits + 1</CodeInline>) in a <Link href="https://planetscale.com/">PlanetScale</Link>{" "}
|
||||
database. Individual views and identifying (or non-identifying) details are{" "}
|
||||
<strong>never stored or logged</strong>.
|
||||
</p>
|
||||
|
63
pnpm-lock.yaml
generated
63
pnpm-lock.yaml
generated
@ -18,8 +18,8 @@ dependencies:
|
||||
specifier: ^7.0.2
|
||||
version: 7.0.2
|
||||
'@octokit/graphql-schema':
|
||||
specifier: ^14.57.0
|
||||
version: 14.57.0
|
||||
specifier: ^14.58.0
|
||||
version: 14.58.0
|
||||
'@prisma/client':
|
||||
specifier: ^5.10.2
|
||||
version: 5.10.2(prisma@5.10.2)
|
||||
@ -68,6 +68,9 @@ dependencies:
|
||||
next-seo:
|
||||
specifier: ^6.5.0
|
||||
version: 6.5.0(next@14.1.0)(react-dom@18.2.0)(react@18.2.0)
|
||||
nodemailer:
|
||||
specifier: ^6.9.10
|
||||
version: 6.9.10
|
||||
obj-str:
|
||||
specifier: ^1.1.0
|
||||
version: 1.1.0
|
||||
@ -103,7 +106,7 @@ dependencies:
|
||||
version: 5.0.1(react@18.2.0)
|
||||
react-innertext:
|
||||
specifier: ^1.1.5
|
||||
version: 1.1.5(@types/react@18.2.58)(react@18.2.0)
|
||||
version: 1.1.5(@types/react@18.2.60)(react@18.2.0)
|
||||
react-intersection-observer:
|
||||
specifier: ^9.8.1
|
||||
version: 9.8.1(react-dom@18.2.0)(react@18.2.0)
|
||||
@ -115,7 +118,7 @@ dependencies:
|
||||
version: 2.14.1(react@18.2.0)
|
||||
react-textarea-autosize:
|
||||
specifier: ^8.5.3
|
||||
version: 8.5.3(@types/react@18.2.58)(react@18.2.0)
|
||||
version: 8.5.3(@types/react@18.2.60)(react@18.2.0)
|
||||
react-tweet:
|
||||
specifier: ^3.2.0
|
||||
version: 3.2.0(react-dom@18.2.0)(react@18.2.0)
|
||||
@ -174,6 +177,9 @@ devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^20.11.20
|
||||
version: 20.11.20
|
||||
'@types/nodemailer':
|
||||
specifier: ^6.4.14
|
||||
version: 6.4.14
|
||||
'@types/novnc__novnc':
|
||||
specifier: ^1.3.4
|
||||
version: 1.3.4
|
||||
@ -181,8 +187,8 @@ devDependencies:
|
||||
specifier: ^15.7.11
|
||||
version: 15.7.11
|
||||
'@types/react':
|
||||
specifier: ^18.2.58
|
||||
version: 18.2.58
|
||||
specifier: ^18.2.60
|
||||
version: 18.2.60
|
||||
'@types/react-dom':
|
||||
specifier: ^18.2.19
|
||||
version: 18.2.19
|
||||
@ -604,7 +610,7 @@ packages:
|
||||
react: '>=16'
|
||||
dependencies:
|
||||
'@types/mdx': 2.0.8
|
||||
'@types/react': 18.2.58
|
||||
'@types/react': 18.2.60
|
||||
react: 18.2.0
|
||||
dev: false
|
||||
|
||||
@ -758,8 +764,8 @@ packages:
|
||||
universal-user-agent: 6.0.1
|
||||
dev: false
|
||||
|
||||
/@octokit/graphql-schema@14.57.0:
|
||||
resolution: {integrity: sha512-DvUIxJkfboiQEOme4DOpF4+EtOh1qbYffm/hbcFdqAQzWFPV19Nkul9t/XnyKRPFyy26Nwf8QesCOeYw8os5rQ==}
|
||||
/@octokit/graphql-schema@14.58.0:
|
||||
resolution: {integrity: sha512-89QSUV1Dgxzq90wqkv0Nmw7jHfFCAQ4K/fjp5ezvDEHqFFzMCn25TBQlm38WB8ams+hGxInRDbITCP0n7GTGlg==}
|
||||
dependencies:
|
||||
graphql: 16.8.1
|
||||
graphql-tag: 2.12.6(graphql@16.8.1)
|
||||
@ -969,7 +975,7 @@ packages:
|
||||
/@types/hoist-non-react-statics@3.3.2:
|
||||
resolution: {integrity: sha512-YIQtIg4PKr7ZyqNPZObpxfHsHEmuB8dXCxd6qVcGuQVDK2bpsF7bYNnBJ4Nn7giuACZg+WewExgrtAJ3XnA4Xw==}
|
||||
dependencies:
|
||||
'@types/react': 18.2.58
|
||||
'@types/react': 18.2.60
|
||||
hoist-non-react-statics: 3.3.2
|
||||
dev: false
|
||||
|
||||
@ -1021,6 +1027,12 @@ packages:
|
||||
dependencies:
|
||||
undici-types: 5.26.5
|
||||
|
||||
/@types/nodemailer@6.4.14:
|
||||
resolution: {integrity: sha512-fUWthHO9k9DSdPCSPRqcu6TWhYyxTBg382vlNIttSe9M7XfsT06y0f24KHXtbnijPGGRIcVvdKHTNikOI6qiHA==}
|
||||
dependencies:
|
||||
'@types/node': 20.11.20
|
||||
dev: true
|
||||
|
||||
/@types/novnc__novnc@1.3.4:
|
||||
resolution: {integrity: sha512-JseVFIc9bm6IV2TkHi/oL4tr3anYeOw2BXX44HgqdRnRCaimz/nfZDWCyylK/OsJIfLzcchLLf2pxevYMNXFbQ==}
|
||||
dev: true
|
||||
@ -1035,17 +1047,17 @@ packages:
|
||||
/@types/react-dom@18.2.19:
|
||||
resolution: {integrity: sha512-aZvQL6uUbIJpjZk4U8JZGbau9KDeAwMfmhyWorxgBkqDIEf6ROjRozcmPIicqsUwPUjbkDfHKgGee1Lq65APcA==}
|
||||
dependencies:
|
||||
'@types/react': 18.2.58
|
||||
'@types/react': 18.2.60
|
||||
dev: true
|
||||
|
||||
/@types/react-is@18.2.4:
|
||||
resolution: {integrity: sha512-wBc7HgmbCcrvw0fZjxbgz/xrrlZKzEqmABBMeSvpTvdm25u6KI6xdIi9pRE2G0C1Lw5ETFdcn4UbYZ4/rpqUYw==}
|
||||
dependencies:
|
||||
'@types/react': 18.2.58
|
||||
'@types/react': 18.2.60
|
||||
dev: true
|
||||
|
||||
/@types/react@18.2.58:
|
||||
resolution: {integrity: sha512-TaGvMNhxvG2Q0K0aYxiKfNDS5m5ZsoIBBbtfUorxdH4NGSXIlYvZxLJI+9Dd3KjeB3780bciLyAb7ylO8pLhPw==}
|
||||
/@types/react@18.2.60:
|
||||
resolution: {integrity: sha512-dfiPj9+k20jJrLGOu9Nf6eqxm2EyJRrq2NvwOFsfbb7sFExZ9WELPs67UImHj3Ayxg8ruTtKtNnbjaF8olPq0A==}
|
||||
dependencies:
|
||||
'@types/prop-types': 15.7.11
|
||||
'@types/scheduler': 0.16.4
|
||||
@ -4516,6 +4528,11 @@ packages:
|
||||
'@types/nlcst': 1.0.4
|
||||
dev: false
|
||||
|
||||
/nodemailer@6.9.10:
|
||||
resolution: {integrity: sha512-qtoKfGFhvIFW5kLfrkw2R6Nm6Ur4LNUMykyqu6n9BRKJuyQrqEGwdXXUAbwWEKt33dlWUGXb7rzmJP/p4+O+CA==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
dev: false
|
||||
|
||||
/nopt@7.2.0:
|
||||
resolution: {integrity: sha512-CVDtwCdhYIvnAzFoJ6NJ6dX3oga9/HyciQDnG1vQDjSLMeKLJ4A93ZqYKDrgYSr1FBY5/hMYC+2VCi24pgpkGA==}
|
||||
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
|
||||
@ -4905,13 +4922,13 @@ packages:
|
||||
react: 18.2.0
|
||||
dev: false
|
||||
|
||||
/react-innertext@1.1.5(@types/react@18.2.58)(react@18.2.0):
|
||||
/react-innertext@1.1.5(@types/react@18.2.60)(react@18.2.0):
|
||||
resolution: {integrity: sha512-PWAqdqhxhHIv80dT9znP2KvS+hfkbRovFp4zFYHFFlOoQLRiawIic81gKb3U1wEyJZgMwgs3JoLtwryASRWP3Q==}
|
||||
peerDependencies:
|
||||
'@types/react': '>=0.0.0 <=99'
|
||||
react: '>=0.0.0 <=99'
|
||||
dependencies:
|
||||
'@types/react': 18.2.58
|
||||
'@types/react': 18.2.60
|
||||
react: 18.2.0
|
||||
dev: false
|
||||
|
||||
@ -4948,7 +4965,7 @@ packages:
|
||||
react-fast-compare: 3.2.2
|
||||
dev: false
|
||||
|
||||
/react-textarea-autosize@8.5.3(@types/react@18.2.58)(react@18.2.0):
|
||||
/react-textarea-autosize@8.5.3(@types/react@18.2.60)(react@18.2.0):
|
||||
resolution: {integrity: sha512-XT1024o2pqCuZSuBt9FwHlaDeNtVrtCXu0Rnz88t1jUGheCLa3PhjE1GH8Ctm2axEtvdCl5SUHYschyQ0L5QHQ==}
|
||||
engines: {node: '>=10'}
|
||||
peerDependencies:
|
||||
@ -4957,7 +4974,7 @@ packages:
|
||||
'@babel/runtime': 7.23.1
|
||||
react: 18.2.0
|
||||
use-composed-ref: 1.3.0(react@18.2.0)
|
||||
use-latest: 1.2.1(@types/react@18.2.58)(react@18.2.0)
|
||||
use-latest: 1.2.1(@types/react@18.2.60)(react@18.2.0)
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
dev: false
|
||||
@ -5997,7 +6014,7 @@ packages:
|
||||
react: 18.2.0
|
||||
dev: false
|
||||
|
||||
/use-isomorphic-layout-effect@1.1.2(@types/react@18.2.58)(react@18.2.0):
|
||||
/use-isomorphic-layout-effect@1.1.2(@types/react@18.2.60)(react@18.2.0):
|
||||
resolution: {integrity: sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
@ -6006,11 +6023,11 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
dependencies:
|
||||
'@types/react': 18.2.58
|
||||
'@types/react': 18.2.60
|
||||
react: 18.2.0
|
||||
dev: false
|
||||
|
||||
/use-latest@1.2.1(@types/react@18.2.58)(react@18.2.0):
|
||||
/use-latest@1.2.1(@types/react@18.2.60)(react@18.2.0):
|
||||
resolution: {integrity: sha512-xA+AVm/Wlg3e2P/JiItTziwS7FK92LWrDB0p+hgXloIMuVCeJJ8v6f0eeHyPZaJrM+usM1FkFfbNCrJGs8A/zw==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
@ -6019,9 +6036,9 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
dependencies:
|
||||
'@types/react': 18.2.58
|
||||
'@types/react': 18.2.60
|
||||
react: 18.2.0
|
||||
use-isomorphic-layout-effect: 1.1.2(@types/react@18.2.58)(react@18.2.0)
|
||||
use-isomorphic-layout-effect: 1.1.2(@types/react@18.2.60)(react@18.2.0)
|
||||
dev: false
|
||||
|
||||
/use-sync-external-store@1.2.0(react@18.2.0):
|
||||
|
@ -1,7 +0,0 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "hits" (
|
||||
"slug" VARCHAR(128) NOT NULL,
|
||||
"hits" INTEGER NOT NULL DEFAULT 1,
|
||||
|
||||
CONSTRAINT "hits_pkey" PRIMARY KEY ("slug")
|
||||
);
|
@ -1,3 +0,0 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "postgresql"
|
@ -1,12 +1,11 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
previewFeatures = ["driverAdapters"]
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("POSTGRES_URL")
|
||||
directUrl = env("POSTGRES_URL_NON_POOLING")
|
||||
provider = "mysql"
|
||||
url = env("DATABASE_URL")
|
||||
relationMode = "prisma"
|
||||
}
|
||||
|
||||
model hits {
|
||||
|
@ -32,7 +32,7 @@
|
||||
- Vercel
|
||||
- Stitches
|
||||
- Prisma
|
||||
- Supabase
|
||||
- PlanetScale
|
||||
- Giscus
|
||||
- Fathom Analytics
|
||||
- ...and more: https://jarv.is/uses/
|
||||
|
Loading…
x
Reference in New Issue
Block a user