Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f123e04e4c |
@@ -22,7 +22,6 @@
|
|||||||
"hono": "^4.6.17",
|
"hono": "^4.6.17",
|
||||||
"node-cron": "^3.0.3",
|
"node-cron": "^3.0.3",
|
||||||
"nodemailer": "^6.9.16",
|
"nodemailer": "^6.9.16",
|
||||||
"stripe": "^22.0.0",
|
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ import { resolveStaffMiddleware, requireRole, requireRoleOrSuperUser, requireSup
|
|||||||
import { devRouter } from "./routes/dev.js";
|
import { devRouter } from "./routes/dev.js";
|
||||||
import { adminSeedRouter } from "./routes/admin/seed.js";
|
import { adminSeedRouter } from "./routes/admin/seed.js";
|
||||||
import { startReminderScheduler } from "./services/reminders.js";
|
import { startReminderScheduler } from "./services/reminders.js";
|
||||||
import { webhooksRouter } from "./routes/stripe-webhooks.js";
|
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
@@ -51,9 +50,6 @@ app.route("/api/book", bookRouter);
|
|||||||
// Public portal routes — client-facing, authenticated via impersonation session header
|
// Public portal routes — client-facing, authenticated via impersonation session header
|
||||||
app.route("/api/portal", portalRouter);
|
app.route("/api/portal", portalRouter);
|
||||||
|
|
||||||
// Public Stripe webhook endpoint — signature-verified, no auth required
|
|
||||||
app.route("/api/webhooks/stripe", webhooksRouter);
|
|
||||||
|
|
||||||
// Dev/demo routes — config is always public, users endpoint is guarded internally
|
// Dev/demo routes — config is always public, users endpoint is guarded internally
|
||||||
app.route("/api/dev", devRouter);
|
app.route("/api/dev", devRouter);
|
||||||
|
|
||||||
|
|||||||
+11
-64
@@ -3,7 +3,6 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
|||||||
import { genericOAuth } from "better-auth/plugins";
|
import { genericOAuth } from "better-auth/plugins";
|
||||||
import { getDb, authProviderConfig, eq } from "@groombook/db";
|
import { getDb, authProviderConfig, eq } from "@groombook/db";
|
||||||
import { decryptSecret } from "@groombook/db";
|
import { decryptSecret } from "@groombook/db";
|
||||||
import { sendEmail } from "../services/email.js";
|
|
||||||
|
|
||||||
const BETTER_AUTH_SECRET = process.env.BETTER_AUTH_SECRET;
|
const BETTER_AUTH_SECRET = process.env.BETTER_AUTH_SECRET;
|
||||||
const BETTER_AUTH_URL = process.env.BETTER_AUTH_URL ?? "http://localhost:3000";
|
const BETTER_AUTH_URL = process.env.BETTER_AUTH_URL ?? "http://localhost:3000";
|
||||||
@@ -95,7 +94,7 @@ export async function initAuth(): Promise<void> {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
max: 10,
|
max: 10,
|
||||||
window: 60,
|
window: 60,
|
||||||
storage: "memory",
|
storage: "database",
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
genericOAuth({
|
genericOAuth({
|
||||||
@@ -177,52 +176,6 @@ export async function initAuth(): Promise<void> {
|
|||||||
const hasGoogle = !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET);
|
const hasGoogle = !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET);
|
||||||
const hasGitHub = !!(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET);
|
const hasGitHub = !!(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET);
|
||||||
|
|
||||||
// Fetch OIDC discovery document to derive canonical provider URLs.
|
|
||||||
// Replace the host of token/userinfo endpoints with internalBaseUrl when set,
|
|
||||||
// while keeping authorizationUrl public for browser redirects.
|
|
||||||
const discoveryUrlStr = `${providerConfig.issuerUrl}/.well-known/openid-configuration`;
|
|
||||||
let oidcConfig: Record<string, string> = {};
|
|
||||||
try {
|
|
||||||
const discoveryRes = await fetch(discoveryUrlStr);
|
|
||||||
if (discoveryRes.ok) {
|
|
||||||
const discovery = await discoveryRes.json() as {
|
|
||||||
authorization_endpoint?: string;
|
|
||||||
token_endpoint?: string;
|
|
||||||
userinfo_endpoint?: string;
|
|
||||||
};
|
|
||||||
const replaceHost = (url: string, newHost: string) => {
|
|
||||||
try {
|
|
||||||
const parsed = new URL(url);
|
|
||||||
const newParsed = new URL(newHost);
|
|
||||||
return `${newParsed.origin}${parsed.pathname}${parsed.search}`;
|
|
||||||
} catch {
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const authzUrl = discovery.authorization_endpoint;
|
|
||||||
const tokenUrl = discovery.token_endpoint;
|
|
||||||
const userInfoUrl = discovery.userinfo_endpoint;
|
|
||||||
if (authzUrl && tokenUrl && userInfoUrl) {
|
|
||||||
oidcConfig = {
|
|
||||||
authorizationUrl: authzUrl,
|
|
||||||
tokenUrl: providerConfig.internalBaseUrl
|
|
||||||
? replaceHost(tokenUrl, providerConfig.internalBaseUrl)
|
|
||||||
: tokenUrl,
|
|
||||||
userInfoUrl: providerConfig.internalBaseUrl
|
|
||||||
? replaceHost(userInfoUrl, providerConfig.internalBaseUrl)
|
|
||||||
: userInfoUrl,
|
|
||||||
};
|
|
||||||
console.log("[auth] OIDC discovery successful, provider:", providerConfig.providerId);
|
|
||||||
} else {
|
|
||||||
console.warn("[auth] OIDC discovery missing required endpoints, using discoveryUrl only");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.warn(`[auth] OIDC discovery failed (${discoveryRes.status}), using discoveryUrl only`);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(`[auth] OIDC discovery fetch failed: ${err}, using discoveryUrl only`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build Better-Auth instance using resolved config
|
// Build Better-Auth instance using resolved config
|
||||||
authInstance = betterAuth({
|
authInstance = betterAuth({
|
||||||
database: drizzleAdapter(db, {
|
database: drizzleAdapter(db, {
|
||||||
@@ -234,24 +187,11 @@ export async function initAuth(): Promise<void> {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
max: 10,
|
max: 10,
|
||||||
window: 60,
|
window: 60,
|
||||||
storage: "memory",
|
storage: "database",
|
||||||
},
|
},
|
||||||
account: {
|
account: {
|
||||||
storeStateStrategy: "cookie" as const,
|
storeStateStrategy: "cookie" as const,
|
||||||
},
|
},
|
||||||
emailAndPassword: {
|
|
||||||
enabled: true,
|
|
||||||
emailVerification: {
|
|
||||||
sendVerificationEmail: async ({ user, url }: { user: { email: string }; url: string }) => {
|
|
||||||
await sendEmail({
|
|
||||||
to: user.email,
|
|
||||||
subject: "Verify your GroomBook email",
|
|
||||||
text: `Click the link to verify your email: ${url}`,
|
|
||||||
html: `<p>Click the link to verify your email:</p><a href="${url}">${url}</a>`,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: [
|
plugins: [
|
||||||
genericOAuth({
|
genericOAuth({
|
||||||
config: [
|
config: [
|
||||||
@@ -259,8 +199,15 @@ export async function initAuth(): Promise<void> {
|
|||||||
providerId: providerConfig.providerId,
|
providerId: providerConfig.providerId,
|
||||||
clientId: providerConfig.clientId,
|
clientId: providerConfig.clientId,
|
||||||
clientSecret: providerConfig.clientSecret,
|
clientSecret: providerConfig.clientSecret,
|
||||||
discoveryUrl: discoveryUrlStr,
|
...(providerConfig.internalBaseUrl
|
||||||
...(Object.keys(oidcConfig).length > 0 ? oidcConfig : {}),
|
? {
|
||||||
|
authorizationUrl: `${new URL(providerConfig.issuerUrl).origin}/application/o/authorize/`,
|
||||||
|
tokenUrl: `${providerConfig.internalBaseUrl}/application/o/token/`,
|
||||||
|
userInfoUrl: `${providerConfig.internalBaseUrl}/application/o/userinfo/`,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
discoveryUrl: `${providerConfig.issuerUrl}/.well-known/openid-configuration`,
|
||||||
|
}),
|
||||||
scopes: providerConfig.scopes.split(" ").filter(Boolean),
|
scopes: providerConfig.scopes.split(" ").filter(Boolean),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
import { Hono } from "hono";
|
|
||||||
import Stripe from "stripe";
|
|
||||||
import { eq, getDb, invoices } from "@groombook/db";
|
|
||||||
|
|
||||||
export const webhooksRouter = new Hono();
|
|
||||||
|
|
||||||
webhooksRouter.post("/stripe", async (c) => {
|
|
||||||
const secret = process.env.STRIPE_WEBHOOK_SECRET;
|
|
||||||
if (!secret) {
|
|
||||||
return c.json({ error: "Webhook secret not configured" }, 503);
|
|
||||||
}
|
|
||||||
|
|
||||||
const signature = c.req.header("stripe-signature");
|
|
||||||
if (!signature) {
|
|
||||||
return c.json({ error: "Missing signature" }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
let rawBody: string;
|
|
||||||
try {
|
|
||||||
rawBody = await c.req.text();
|
|
||||||
} catch {
|
|
||||||
return c.json({ error: "Could not read body" }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const stripe = new Stripe(secret, { apiVersion: "2026-03-25.dahlia" });
|
|
||||||
|
|
||||||
let event: Stripe.Event;
|
|
||||||
try {
|
|
||||||
event = stripe.webhooks.constructEvent(rawBody, signature, secret);
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : "Invalid signature";
|
|
||||||
return c.json({ error: message }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = getDb();
|
|
||||||
|
|
||||||
if (event.type === "payment_intent.succeeded") {
|
|
||||||
const pi = event.data.object as Stripe.PaymentIntent;
|
|
||||||
if (pi.metadata?.groombook_invoice_ids) {
|
|
||||||
const invoiceIds = pi.metadata.groombook_invoice_ids.split(",");
|
|
||||||
for (const invoiceId of invoiceIds) {
|
|
||||||
if (!invoiceId) continue;
|
|
||||||
const [inv] = await db
|
|
||||||
.select()
|
|
||||||
.from(invoices)
|
|
||||||
.where(eq(invoices.id, invoiceId))
|
|
||||||
.limit(1);
|
|
||||||
if (!inv) continue;
|
|
||||||
if (inv.stripePaymentIntentId && inv.stripePaymentIntentId !== pi.id) continue;
|
|
||||||
await db
|
|
||||||
.update(invoices)
|
|
||||||
.set({
|
|
||||||
status: "paid",
|
|
||||||
paymentMethod: "card",
|
|
||||||
paidAt: new Date(),
|
|
||||||
stripePaymentIntentId: pi.id,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
})
|
|
||||||
.where(eq(invoices.id, invoiceId));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (event.type === "payment_intent.payment_failed") {
|
|
||||||
const pi = event.data.object as Stripe.PaymentIntent;
|
|
||||||
if (pi.metadata?.groombook_invoice_ids) {
|
|
||||||
const invoiceIds = pi.metadata.groombook_invoice_ids.split(",");
|
|
||||||
for (const invoiceId of invoiceIds) {
|
|
||||||
if (!invoiceId) continue;
|
|
||||||
await db
|
|
||||||
.update(invoices)
|
|
||||||
.set({
|
|
||||||
paymentFailureReason: pi.last_payment_error?.message ?? "Payment failed",
|
|
||||||
updatedAt: new Date(),
|
|
||||||
})
|
|
||||||
.where(eq(invoices.id, invoiceId));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (event.type === "charge.refunded") {
|
|
||||||
const charge = event.data.object as Stripe.Charge;
|
|
||||||
if (typeof charge.payment_intent === "string" && charge.payment_intent) {
|
|
||||||
const [inv] = await db
|
|
||||||
.select({ id: invoices.id })
|
|
||||||
.from(invoices)
|
|
||||||
.where(eq(invoices.stripePaymentIntentId, charge.payment_intent))
|
|
||||||
.limit(1);
|
|
||||||
if (inv) {
|
|
||||||
const refundId =
|
|
||||||
typeof charge.refunded === "boolean" && charge.refunded
|
|
||||||
? `ch_${charge.id}_refund`
|
|
||||||
: null;
|
|
||||||
await db
|
|
||||||
.update(invoices)
|
|
||||||
.set({
|
|
||||||
status: "void",
|
|
||||||
stripeRefundId: refundId,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
})
|
|
||||||
.where(eq(invoices.id, inv.id));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (event.type === "charge.dispute.created") {
|
|
||||||
const dispute = event.data.object as Stripe.Dispute;
|
|
||||||
console.error(
|
|
||||||
`[Stripe Webhook] Dispute created for payment intent: ${dispute.payment_intent}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.json({ received: true });
|
|
||||||
});
|
|
||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
services,
|
services,
|
||||||
staff,
|
staff,
|
||||||
reminderLogs,
|
reminderLogs,
|
||||||
session,
|
|
||||||
} from "@groombook/db";
|
} from "@groombook/db";
|
||||||
import {
|
import {
|
||||||
buildReminderEmail,
|
buildReminderEmail,
|
||||||
@@ -156,19 +155,6 @@ export function startReminderScheduler(): void {
|
|||||||
runReminderCheck().catch((err) => {
|
runReminderCheck().catch((err) => {
|
||||||
console.error("[reminders] Error during reminder check:", err);
|
console.error("[reminders] Error during reminder check:", err);
|
||||||
});
|
});
|
||||||
runSessionCleanup().catch((err) => {
|
|
||||||
console.error("[reminders] Error during session cleanup:", err);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
console.log("[reminders] Reminder scheduler started");
|
console.log("[reminders] Reminder scheduler started");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deletes expired sessions from the database.
|
|
||||||
// Runs every minute alongside reminder checks.
|
|
||||||
export async function runSessionCleanup(): Promise<void> {
|
|
||||||
const db = getDb();
|
|
||||||
const now = new Date();
|
|
||||||
await db
|
|
||||||
.delete(session)
|
|
||||||
.where(lt(session.expiresAt, now));
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ kind: Kustomization
|
|||||||
namespace: groombook-uat
|
namespace: groombook-uat
|
||||||
images:
|
images:
|
||||||
- name: ghcr.io/groombook/api
|
- name: ghcr.io/groombook/api
|
||||||
newTag: "2026.04.03-90be1be"
|
newTag: "2026.04.12-15131b7"
|
||||||
- name: ghcr.io/groombook/web
|
- name: ghcr.io/groombook/web
|
||||||
newTag: "2026.04.03-90be1be"
|
newTag: "2026.04.12-15131b7"
|
||||||
- name: ghcr.io/groombook/migrate
|
- name: ghcr.io/groombook/migrate
|
||||||
newTag: "2026.04.03-90be1be"
|
newTag: "2026.04.12-15131b7"
|
||||||
- name: ghcr.io/groombook/seed
|
- name: ghcr.io/groombook/seed
|
||||||
newTag: "2026.04.03-90be1be"
|
newTag: "2026.04.12-15131b7"
|
||||||
resources:
|
resources:
|
||||||
- ../../base
|
- ../../base
|
||||||
- postgres-sealed-secret.yaml
|
- postgres-sealed-secret.yaml
|
||||||
|
|||||||
@@ -4,4 +4,4 @@ export const authClient = createAuthClient({
|
|||||||
baseURL: import.meta.env.VITE_API_URL ?? "",
|
baseURL: import.meta.env.VITE_API_URL ?? "",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const { signIn, signOut, useSession, changePassword } = authClient;
|
export const { signIn, signOut, useSession } = authClient;
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { User, Lock, PawPrint, FileCheck, Plus, Archive } from "lucide-react";
|
import { User, Lock, PawPrint, FileCheck, Plus, Archive } from "lucide-react";
|
||||||
import { PetForm } from "./PetForm.js";
|
import { PetForm } from "./PetForm.js";
|
||||||
import { authClient } from "../../lib/auth-client.js";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
sessionId: string | null;
|
sessionId: string | null;
|
||||||
@@ -149,11 +148,9 @@ function PasswordChange({ readOnly }: { readOnly: boolean }) {
|
|||||||
const [newPassword, setNewPassword] = useState("");
|
const [newPassword, setNewPassword] = useState("");
|
||||||
const [confirmPassword, setConfirmPassword] = useState("");
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [success, setSuccess] = useState(false);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const passwordsMatch = newPassword === confirmPassword;
|
const passwordsMatch = newPassword === confirmPassword;
|
||||||
const canSubmit = newPassword.length > 0 && passwordsMatch && !loading;
|
const canSubmit = currentPassword.length > 0 && newPassword.length > 0 && passwordsMatch;
|
||||||
|
|
||||||
if (readOnly) {
|
if (readOnly) {
|
||||||
return (
|
return (
|
||||||
@@ -163,34 +160,17 @@ function PasswordChange({ readOnly }: { readOnly: boolean }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit() {
|
function handleSubmit() {
|
||||||
if (!canSubmit) return;
|
if (!canSubmit) return;
|
||||||
if (newPassword !== confirmPassword) {
|
if (newPassword !== confirmPassword) {
|
||||||
setError("Passwords do not match.");
|
setError("Passwords do not match.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// TODO: Wire up to actual password-change API endpoint once backend support exists
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(true);
|
setCurrentPassword("");
|
||||||
try {
|
setNewPassword("");
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
setConfirmPassword("");
|
||||||
const result = await (authClient as any).changePassword({
|
|
||||||
currentPassword,
|
|
||||||
newPassword,
|
|
||||||
});
|
|
||||||
if (result.error) {
|
|
||||||
setError(result.error.message ?? "Failed to change password.");
|
|
||||||
} else {
|
|
||||||
setSuccess(true);
|
|
||||||
setCurrentPassword("");
|
|
||||||
setNewPassword("");
|
|
||||||
setConfirmPassword("");
|
|
||||||
setTimeout(() => setSuccess(false), 4000);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setError("An unexpected error occurred.");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -225,13 +205,12 @@ function PasswordChange({ readOnly }: { readOnly: boolean }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||||
{success && <p className="text-sm text-green-600">Password updated successfully.</p>}
|
|
||||||
<button
|
<button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={!canSubmit}
|
disabled={!canSubmit}
|
||||||
className="px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover) disabled:opacity-50 disabled:cursor-not-allowed"
|
className="px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover) disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{loading ? "Updating..." : "Update Password"}
|
Update Password
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
ALTER TABLE "invoices" ADD COLUMN "stripe_payment_intent_id" text;
|
|
||||||
ALTER TABLE "invoices" ADD COLUMN "stripe_refund_id" text;
|
|
||||||
ALTER TABLE "invoices" ADD COLUMN "payment_failure_reason" text;
|
|
||||||
ALTER TABLE "invoices" ADD CONSTRAINT "idx_invoices_stripe_payment_intent_id" UNIQUE("stripe_payment_intent_id");
|
|
||||||
@@ -251,9 +251,6 @@ export const invoices = pgTable(
|
|||||||
status: invoiceStatusEnum("status").notNull().default("draft"),
|
status: invoiceStatusEnum("status").notNull().default("draft"),
|
||||||
paymentMethod: paymentMethodEnum("payment_method"),
|
paymentMethod: paymentMethodEnum("payment_method"),
|
||||||
paidAt: timestamp("paid_at"),
|
paidAt: timestamp("paid_at"),
|
||||||
stripePaymentIntentId: text("stripe_payment_intent_id"),
|
|
||||||
stripeRefundId: text("stripe_refund_id"),
|
|
||||||
paymentFailureReason: text("payment_failure_reason"),
|
|
||||||
notes: text("notes"),
|
notes: text("notes"),
|
||||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||||
@@ -262,7 +259,6 @@ export const invoices = pgTable(
|
|||||||
index("idx_invoices_client_id").on(t.clientId),
|
index("idx_invoices_client_id").on(t.clientId),
|
||||||
index("idx_invoices_status").on(t.status),
|
index("idx_invoices_status").on(t.status),
|
||||||
index("idx_invoices_created_at").on(t.createdAt),
|
index("idx_invoices_created_at").on(t.createdAt),
|
||||||
unique("idx_invoices_stripe_payment_intent_id").on(t.stripePaymentIntentId),
|
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Generated
-16
@@ -40,9 +40,6 @@ importers:
|
|||||||
nodemailer:
|
nodemailer:
|
||||||
specifier: ^6.9.16
|
specifier: ^6.9.16
|
||||||
version: 6.10.1
|
version: 6.10.1
|
||||||
stripe:
|
|
||||||
specifier: ^22.0.0
|
|
||||||
version: 22.0.1(@types/node@22.19.15)
|
|
||||||
zod:
|
zod:
|
||||||
specifier: ^4.3.6
|
specifier: ^4.3.6
|
||||||
version: 4.3.6
|
version: 4.3.6
|
||||||
@@ -4127,15 +4124,6 @@ packages:
|
|||||||
strip-literal@3.1.0:
|
strip-literal@3.1.0:
|
||||||
resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
|
resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
|
||||||
|
|
||||||
stripe@22.0.1:
|
|
||||||
resolution: {integrity: sha512-Yw764pZ6s8Xu4CtUZdD5uWOkw6gc9xzO9OKylCuj1gMhMDLbyGbDtaPNNSFE4mB6njYSHESYIVbF1iIzUfAl2g==}
|
|
||||||
engines: {node: '>=18'}
|
|
||||||
peerDependencies:
|
|
||||||
'@types/node': '>=18'
|
|
||||||
peerDependenciesMeta:
|
|
||||||
'@types/node':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
strnum@2.2.1:
|
strnum@2.2.1:
|
||||||
resolution: {integrity: sha512-BwRvNd5/QoAtyW1na1y1LsJGQNvRlkde6Q/ipqqEaivoMdV+B1OMOTVdwR+N/cwVUcIt9PYyHmV8HyexCZSupg==}
|
resolution: {integrity: sha512-BwRvNd5/QoAtyW1na1y1LsJGQNvRlkde6Q/ipqqEaivoMdV+B1OMOTVdwR+N/cwVUcIt9PYyHmV8HyexCZSupg==}
|
||||||
|
|
||||||
@@ -8786,10 +8774,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
js-tokens: 9.0.1
|
js-tokens: 9.0.1
|
||||||
|
|
||||||
stripe@22.0.1(@types/node@22.19.15):
|
|
||||||
optionalDependencies:
|
|
||||||
'@types/node': 22.19.15
|
|
||||||
|
|
||||||
strnum@2.2.1: {}
|
strnum@2.2.1: {}
|
||||||
|
|
||||||
supports-color@7.2.0:
|
supports-color@7.2.0:
|
||||||
|
|||||||
Reference in New Issue
Block a user