added new migration stuff
This commit is contained in:
parent
b02ee5f74b
commit
f285e97843
12 changed files with 313 additions and 14 deletions
22
.github/workflows/stripe-to-invoice.yml
vendored
22
.github/workflows/stripe-to-invoice.yml
vendored
|
|
@ -124,17 +124,37 @@ jobs:
|
||||||
STRIPE_SECRET_KEY="$PROD_STRIPE_SECRET_KEY"
|
STRIPE_SECRET_KEY="$PROD_STRIPE_SECRET_KEY"
|
||||||
STRIPE_CLIENT_ID="$PROD_STRIPE_CLIENT_ID"
|
STRIPE_CLIENT_ID="$PROD_STRIPE_CLIENT_ID"
|
||||||
APP_URL="$PROD_APP_URL"
|
APP_URL="$PROD_APP_URL"
|
||||||
|
XERO_CLIENT_ID="$PROD_XERO_CLIENT_ID"
|
||||||
|
XERO_CLIENT_SECRET="$PROD_CLIENT_SECRET"
|
||||||
|
XERO_REDIRECT_URI="$PROD_REDIRECT_URI"
|
||||||
|
|
||||||
|
|
||||||
else
|
else
|
||||||
STRIPE_SECRET_KEY="$DEV_STRIPE_SECRET_KEY"
|
STRIPE_SECRET_KEY="$DEV_STRIPE_SECRET_KEY"
|
||||||
STRIPE_CLIENT_ID="$DEV_STRIPE_CLIENT_ID"
|
STRIPE_CLIENT_ID="$DEV_STRIPE_CLIENT_ID"
|
||||||
APP_URL="$DEV_APP_URL"
|
APP_URL="$DEV_APP_URL"
|
||||||
|
XERO_CLIENT_ID="$DEV_XERO_CLIENT_ID"
|
||||||
|
XERO_CLIENT_SECRET="$DEV_CLIENT_SECRET"
|
||||||
|
XERO_REDIRECT_URI="$DEV_REDIRECT_URI"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
: "${STRIPE_SECRET_KEY:?missing STRIPE_SECRET_KEY}"
|
: "${STRIPE_SECRET_KEY:?missing STRIPE_SECRET_KEY}"
|
||||||
: "${STRIPE_CLIENT_ID:?missing STRIPE_CLIENT_ID}"
|
: "${STRIPE_CLIENT_ID:?missing STRIPE_CLIENT_ID}"
|
||||||
: "${APP_URL:?missing APP_URL}"
|
: "${APP_URL:?missing APP_URL}"
|
||||||
|
: "${STRIPE_REDIRECT_URI:?missing STRIPE_REDIRECT_URI}"
|
||||||
|
: "${XERO_CLIENT_ID:?missing XERO_CLIENT_ID}"
|
||||||
|
: "${XERO_CLIENT_SECRET:?missing XERO_CLIENT_SECRET}"
|
||||||
|
: "${XERO_REDIRECT_URI:?missing XERO_REDIRECT_URI}"
|
||||||
|
|
||||||
export STRIPE_SECRET_KEY STRIPE_CLIENT_ID APP_URL NAMESPACE
|
export \
|
||||||
|
STRIPE_SECRET_KEY \
|
||||||
|
STRIPE_CLIENT_ID \
|
||||||
|
STRIPE_REDIRECT_URI \
|
||||||
|
APP_URL \
|
||||||
|
XERO_CLIENT_ID \
|
||||||
|
XERO_CLIENT_SECRET \
|
||||||
|
XERO_REDIRECT_URI \
|
||||||
|
NAMESPACE
|
||||||
|
|
||||||
envsubst < stripe_to_invoice/deployment/secrets/stripe-secrets.yaml \
|
envsubst < stripe_to_invoice/deployment/secrets/stripe-secrets.yaml \
|
||||||
| kubectl apply -f -
|
| kubectl apply -f -
|
||||||
|
|
|
||||||
1
db/.env
1
db/.env
|
|
@ -1,4 +1,5 @@
|
||||||
# Dev Stripe-to-invoice
|
# Dev Stripe-to-invoice
|
||||||
|
# postgres-dev.dev.svc.cluster.local
|
||||||
DEV_POSTGRES_USER=postgres
|
DEV_POSTGRES_USER=postgres
|
||||||
DEV_POSTGRES_PASSWORD=averysecretpasswordPersonAppleWinter938
|
DEV_POSTGRES_PASSWORD=averysecretpasswordPersonAppleWinter938
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
-- Ensure one Xero connection per user
|
||||||
|
CREATE UNIQUE INDEX xero_connections_user_unique
|
||||||
|
ON xero_connections (user_id);
|
||||||
|
|
||||||
|
-- Prevent the same Xero organisation being linked twice
|
||||||
|
CREATE UNIQUE INDEX xero_connections_tenant_unique
|
||||||
|
ON xero_connections (tenant_id);
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
h1:DR4yJ9fatAVhOP+U23Yz+bOzijyhq/720tACLkaFuXw=
|
h1:FS8jSKRjrxTpVXMVhNisHxEgUk/fmiQEEpBMvdqVh88=
|
||||||
0001_init.sql h1:gzb02ZbjrrJkXOC+2qIZsngnj7A+29O2/b4awScPlPs=
|
0001_init.sql h1:gzb02ZbjrrJkXOC+2qIZsngnj7A+29O2/b4awScPlPs=
|
||||||
0002_auth.sql h1:4NhBu26dIBMy9gxMxM3tf6Z2CS2kfKlGjFBj07T/aBw=
|
0002_auth.sql h1:4NhBu26dIBMy9gxMxM3tf6Z2CS2kfKlGjFBj07T/aBw=
|
||||||
0003_stripe_xero.sql h1:E2bcdUDnondsXwbdIwVlZqR4DQwzcoDiyeRFJwVxXwg=
|
0003_stripe_xero.sql h1:E2bcdUDnondsXwbdIwVlZqR4DQwzcoDiyeRFJwVxXwg=
|
||||||
|
|
@ -6,3 +6,4 @@ h1:DR4yJ9fatAVhOP+U23Yz+bOzijyhq/720tACLkaFuXw=
|
||||||
20251228182659_add_used_at_to_login_tokens.sql h1:/0puYQvwBFzpfSKjiZj2XR/7Mui39lS/IbFZW1TPQOc=
|
20251228182659_add_used_at_to_login_tokens.sql h1:/0puYQvwBFzpfSKjiZj2XR/7Mui39lS/IbFZW1TPQOc=
|
||||||
20251230154354_add_used_at_to_login_tokens.sql h1:FIP2MMRnfhi4hmFC3VBuABZZrxZQ1icranrXy0ljERc=
|
20251230154354_add_used_at_to_login_tokens.sql h1:FIP2MMRnfhi4hmFC3VBuABZZrxZQ1icranrXy0ljERc=
|
||||||
20260118151944_add_unique_index_to_stripe_accounts.sql h1:PyI8cM8Xyn4bG7BBlD7YRwK1iRQ8HPfzf0r1+Swfe1Y=
|
20260118151944_add_unique_index_to_stripe_accounts.sql h1:PyI8cM8Xyn4bG7BBlD7YRwK1iRQ8HPfzf0r1+Swfe1Y=
|
||||||
|
20260118165004_add_unique_for_xero.sql h1:/qk/tJiDo6wMnOdDnmEjKMwx2TmxpdQWmpdliaw6xZ8=
|
||||||
|
|
|
||||||
102
stripe_to_invoice/app/api/xero/callback/route.ts
Normal file
102
stripe_to_invoice/app/api/xero/callback/route.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { xeroConnections } from "@/lib/schema/xeroConnections";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
type XeroTokenResponse = {
|
||||||
|
access_token: string;
|
||||||
|
refresh_token: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type XeroTenant = {
|
||||||
|
tenantId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const session = cookieStore.get("session");
|
||||||
|
|
||||||
|
// Must be logged in
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.redirect(
|
||||||
|
new URL("/login", process.env.APP_URL)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = session.value;
|
||||||
|
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const code = searchParams.get("code");
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Missing OAuth code" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exchange code for token
|
||||||
|
const tokenRes = await fetch("https://identity.xero.com/connect/token", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization:
|
||||||
|
"Basic " +
|
||||||
|
Buffer.from(
|
||||||
|
`${process.env.XERO_CLIENT_ID}:${process.env.XERO_CLIENT_SECRET}`
|
||||||
|
).toString("base64"),
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code,
|
||||||
|
redirect_uri: process.env.XERO_REDIRECT_URI!,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tokenRes.ok) {
|
||||||
|
const text = await tokenRes.text();
|
||||||
|
console.error("Xero token exchange failed:", text);
|
||||||
|
return NextResponse.redirect(
|
||||||
|
new URL("/connect/xero?error=token_failed", process.env.APP_URL)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenData = (await tokenRes.json()) as XeroTokenResponse;
|
||||||
|
|
||||||
|
// Fetch connected tenants (organisations)
|
||||||
|
const tenantRes = await fetch(
|
||||||
|
"https://api.xero.com/connections",
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${tokenData.access_token}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const tenants = (await tenantRes.json()) as XeroTenant[];
|
||||||
|
const tenantId = tenants[0]?.tenantId;
|
||||||
|
|
||||||
|
if (!tenantId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "No Xero organisation found" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save user ↔ tenant (minimal MVP)
|
||||||
|
await db
|
||||||
|
.insert(xeroConnections)
|
||||||
|
.values({
|
||||||
|
userId,
|
||||||
|
tenantId,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: xeroConnections.userId,
|
||||||
|
set: { tenantId },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.redirect(
|
||||||
|
new URL("/connect/xero/success", process.env.APP_URL)
|
||||||
|
);
|
||||||
|
}
|
||||||
20
stripe_to_invoice/app/api/xero/connect/route.ts
Normal file
20
stripe_to_invoice/app/api/xero/connect/route.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
response_type: "code",
|
||||||
|
client_id: process.env.XERO_CLIENT_ID!,
|
||||||
|
redirect_uri: process.env.XERO_REDIRECT_URI!,
|
||||||
|
scope: [
|
||||||
|
"offline_access",
|
||||||
|
"accounting.transactions",
|
||||||
|
"accounting.contacts",
|
||||||
|
"accounting.settings",
|
||||||
|
].join(" "),
|
||||||
|
state: "xero_oauth",
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.redirect(
|
||||||
|
`https://login.xero.com/identity/connect/authorize?${params}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -8,8 +8,8 @@ export default function StripeSuccessPage() {
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p className="text-gray-600">
|
<p className="text-gray-600">
|
||||||
Your Stripe account is now linked. We can now automate payments and
|
Your Stripe account is now linked. We can now detect successful
|
||||||
reconciliation for you.
|
payments and automatically reconcile invoices in Xero.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Progress */}
|
{/* Progress */}
|
||||||
|
|
@ -31,19 +31,12 @@ export default function StripeSuccessPage() {
|
||||||
</ol>
|
</ol>
|
||||||
|
|
||||||
{/* Primary CTA */}
|
{/* Primary CTA */}
|
||||||
<div className="pt-6 flex gap-4">
|
<div className="pt-6 border-t">
|
||||||
<Link
|
<Link
|
||||||
href="/app"
|
href="/connect/xero"
|
||||||
className="inline-block rounded bg-black text-white px-5 py-3"
|
className="inline-block rounded bg-black text-white px-5 py-3"
|
||||||
>
|
>
|
||||||
Continue setup
|
Continue → Connect Xero
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
href="/app"
|
|
||||||
className="inline-block rounded border px-5 py-3"
|
|
||||||
>
|
|
||||||
Go to dashboard
|
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
74
stripe_to_invoice/app/connect/xero/page.tsx
Normal file
74
stripe_to_invoice/app/connect/xero/page.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
// STEP 3 — Connect Xero
|
||||||
|
// Purpose:
|
||||||
|
// - Explain why Xero access is needed
|
||||||
|
// - Make the next step obvious
|
||||||
|
// - Match the Stripe connect page exactly
|
||||||
|
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default async function ConnectXeroPage() {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const session = cookieStore.get("session");
|
||||||
|
|
||||||
|
// Safety: if not logged in, bounce to login
|
||||||
|
if (!session) {
|
||||||
|
redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="max-w-2xl mx-auto p-8 space-y-10">
|
||||||
|
{/* --------------------------------------------------
|
||||||
|
Header
|
||||||
|
-------------------------------------------------- */}
|
||||||
|
<section>
|
||||||
|
<h1 className="text-2xl font-semibold">
|
||||||
|
Connect Xero
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="mt-3 text-gray-700">
|
||||||
|
We need access to your Xero organisation so we can automatically
|
||||||
|
create invoices and mark them as paid when Stripe payments succeed.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* --------------------------------------------------
|
||||||
|
What will happen
|
||||||
|
-------------------------------------------------- */}
|
||||||
|
<section>
|
||||||
|
<h2 className="text-lg font-medium">
|
||||||
|
What happens next
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<ul className="mt-3 space-y-2 list-disc list-inside text-gray-700">
|
||||||
|
<li>You’ll be redirected to Xero</li>
|
||||||
|
<li>You’ll choose which organisation to connect</li>
|
||||||
|
<li>You’ll be sent back here once connected</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* --------------------------------------------------
|
||||||
|
Trust / reassurance
|
||||||
|
-------------------------------------------------- */}
|
||||||
|
<section className="text-sm text-gray-600">
|
||||||
|
<p>
|
||||||
|
We never see your Xero password.
|
||||||
|
<br />
|
||||||
|
Access can be revoked at any time from Xero.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* --------------------------------------------------
|
||||||
|
Primary action
|
||||||
|
-------------------------------------------------- */}
|
||||||
|
<section className="pt-4 border-t">
|
||||||
|
<a
|
||||||
|
href="/api/xero/connect"
|
||||||
|
className="inline-block px-6 py-3 bg-black text-white rounded text-sm"
|
||||||
|
>
|
||||||
|
Connect Xero →
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
stripe_to_invoice/app/connect/xero/success/page.tsx
Normal file
44
stripe_to_invoice/app/connect/xero/success/page.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function XeroSuccessPage() {
|
||||||
|
return (
|
||||||
|
<main className="max-w-2xl mx-auto p-8 space-y-10">
|
||||||
|
<h1 className="text-2xl font-semibold">
|
||||||
|
Xero connected 🎉
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-gray-600">
|
||||||
|
Your Xero organisation is now linked. We can now automatically
|
||||||
|
create invoices and mark them as paid when Stripe payments succeed.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Progress */}
|
||||||
|
<ol className="space-y-4">
|
||||||
|
<li className="flex items-center gap-3">
|
||||||
|
<span className="text-green-600">✔</span>
|
||||||
|
<span>Logged in</span>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li className="flex items-center gap-3">
|
||||||
|
<span className="text-green-600">✔</span>
|
||||||
|
<span>Stripe connected</span>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li className="flex items-center gap-3">
|
||||||
|
<span className="text-green-600">✔</span>
|
||||||
|
<span>Xero connected</span>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
{/* Primary CTA */}
|
||||||
|
<div className="pt-6 border-t">
|
||||||
|
<Link
|
||||||
|
href="/app"
|
||||||
|
className="inline-block rounded bg-black text-white px-5 py-3"
|
||||||
|
>
|
||||||
|
Go to dashboard →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -84,6 +84,24 @@ spec:
|
||||||
name: stripe-secrets
|
name: stripe-secrets
|
||||||
key: STRIPE_REDIRECT_URI
|
key: STRIPE_REDIRECT_URI
|
||||||
|
|
||||||
|
- name: XERO_CLIENT_ID
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: stripe-secrets
|
||||||
|
key: XERO_CLIENT_ID
|
||||||
|
|
||||||
|
- name: XERO_CLIENT_SECRET
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: stripe-secrets
|
||||||
|
key: XERO_CLIENT_SECRET
|
||||||
|
|
||||||
|
- name: XERO_REDIRECT_URI
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: stripe-secrets
|
||||||
|
key: XERO_REDIRECT_URI
|
||||||
|
|
||||||
imagePullSecrets:
|
imagePullSecrets:
|
||||||
- name: registrypullsecret
|
- name: registrypullsecret
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,9 @@ DEV_AWS_ACCESS_KEY_ID=AKIAQL67W6HI2547OPVG
|
||||||
DEV_AWS_SECRET_ACCESS_KEY=qCTirw/OCdw6P2aVknGlyh8MQVMmOkrm0NrXTz4j
|
DEV_AWS_SECRET_ACCESS_KEY=qCTirw/OCdw6P2aVknGlyh8MQVMmOkrm0NrXTz4j
|
||||||
DEV_SES_FROM_EMAIL=no-reply@juntekim.com
|
DEV_SES_FROM_EMAIL=no-reply@juntekim.com
|
||||||
DEV_STRIPE_REDIRECT_URI=https://stripe-to-invoice.dev.juntekim.com/api/stripe/callback
|
DEV_STRIPE_REDIRECT_URI=https://stripe-to-invoice.dev.juntekim.com/api/stripe/callback
|
||||||
|
DEV_XERO_CLIENT_ID=4C24EEA5583046519AD39B3905ED2BD3
|
||||||
|
DEV_XERO_SECRET_KEY=PAYDhzqMLvNtPrN5vDC7iwtXkgu99yG8Gbu86IlrdHH8hGjA
|
||||||
|
DEV_XERO_REDIRECT_URI=https://stripe-to-invoice.dev.juntekim.com/api/connect/xero/callback
|
||||||
|
|
||||||
|
|
||||||
# Prod
|
# Prod
|
||||||
|
|
@ -18,4 +21,7 @@ PROD_AWS_ACCESS_KEY_ID=AKIAQL67W6HI2547OPVG
|
||||||
PROD_AWS_SECRET_ACCESS_KEY=qCTirw/OCdw6P2aVknGlyh8MQVMmOkrm0NrXTz4j
|
PROD_AWS_SECRET_ACCESS_KEY=qCTirw/OCdw6P2aVknGlyh8MQVMmOkrm0NrXTz4j
|
||||||
PROD_SES_FROM_EMAIL=no-reply@juntekim.com
|
PROD_SES_FROM_EMAIL=no-reply@juntekim.com
|
||||||
PROD_STRIPE_REDIRECT_URI=https://stripe-to-invoice.dev.juntekim.com/api/stripe/callback
|
PROD_STRIPE_REDIRECT_URI=https://stripe-to-invoice.dev.juntekim.com/api/stripe/callback
|
||||||
|
PROD_XERO_CLIENT_ID=4C24EEA5583046519AD39B3905ED2BD3
|
||||||
|
PROD_XERO_SECRET_KEY=PAYDhzqMLvNtPrN5vDC7iwtXkgu99yG8Gbu86IlrdHH8hGjA
|
||||||
|
PROD_XERO_REDIRECT_URI=https://stripe-to-invoice.juntekim.com/api/connect/xero/callback
|
||||||
|
|
||||||
|
|
|
||||||
13
stripe_to_invoice/lib/schema/xeroConnections.ts
Normal file
13
stripe_to_invoice/lib/schema/xeroConnections.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { pgTable, uuid, text, timestamp } from "drizzle-orm/pg-core";
|
||||||
|
import { users } from "./users";
|
||||||
|
|
||||||
|
export const xeroConnections = pgTable("xero_connections", {
|
||||||
|
id: uuid("id").defaultRandom().primaryKey(),
|
||||||
|
userId: uuid("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
tenantId: text("tenant_id").notNull(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue