feat: billing/invoice details (full name, address, phone) under Settings > Billing (v0.76.0)
Adds a new user_billing_details table (1:1 with users, separate from the display name) with a form and GET/PUT API route. Full name is required by the form/API; address and phone stay optional. Not wired into Stripe invoicing yet — just captured for future use. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,11 @@
|
|||||||
|
|
||||||
All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together.
|
All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together.
|
||||||
|
|
||||||
|
## 0.76.0 — 2026-07-24 12:15
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Settings → Billing now has a billing/invoice details section: full name (required), address, and phone number (both optional). Separate from your display name — this is what will appear on future invoices.
|
||||||
|
|
||||||
## 0.75.0 — 2026-07-24 11:30
|
## 0.75.0 — 2026-07-24 11:30
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ Status legend: **Exists** (fully working) · **Partial** (works but with a real
|
|||||||
| Pantry manual CRUD | Exists | Includes a bulk case-insensitive name+unit merge endpoint (used by the scan-confirm flow), undocumented until this pass | `apps/web/app/api/v1/pantry/**` |
|
| Pantry manual CRUD | Exists | Includes a bulk case-insensitive name+unit merge endpoint (used by the scan-confirm flow), undocumented until this pass | `apps/web/app/api/v1/pantry/**` |
|
||||||
| Auto-deduct pantry on cook | Exists (closed 2026-07-24) | Both UI callers that previously hardcoded `deductFromPantry: false` (`batch-cook-dishes.tsx`, `meal-planner.tsx`) now pass `true`. The new general "Mark cooked" feature (see below) additionally exposes it as a per-cook checkbox, default checked, rather than a silent always-on. | `apps/web/app/api/v1/recipes/[id]/cooked/route.ts`, `apps/web/components/recipe/batch-cook-dishes.tsx`, `apps/web/components/meal-plan/meal-planner.tsx` |
|
| Auto-deduct pantry on cook | Exists (closed 2026-07-24) | Both UI callers that previously hardcoded `deductFromPantry: false` (`batch-cook-dishes.tsx`, `meal-planner.tsx`) now pass `true`. The new general "Mark cooked" feature (see below) additionally exposes it as a per-cook checkbox, default checked, rather than a silent always-on. | `apps/web/app/api/v1/recipes/[id]/cooked/route.ts`, `apps/web/components/recipe/batch-cook-dishes.tsx`, `apps/web/components/meal-plan/meal-planner.tsx` |
|
||||||
| Mark recipe as cooked (any recipe, not just batch-cook) | Exists (new, 2026-07-24) | A recipe can be logged as cooked any number of times — each log is a new `cookingHistory` row, no unique constraint, this was already true at the schema level, just not exposed for plain (non-batch) recipes before. New dialog on the recipe page: date (defaults today, can backdate), servings, and a deduct-from-pantry toggle (default on). Shows a "cooked N times · last DATE" indicator once logged. | `apps/web/components/recipe/{mark-cooked-dialog,mark-cooked-section}.tsx`, `apps/web/app/api/v1/recipes/[id]/cooked/route.ts` (`cookedAt` field, new) |
|
| Mark recipe as cooked (any recipe, not just batch-cook) | Exists (new, 2026-07-24) | A recipe can be logged as cooked any number of times — each log is a new `cookingHistory` row, no unique constraint, this was already true at the schema level, just not exposed for plain (non-batch) recipes before. New dialog on the recipe page: date (defaults today, can backdate), servings, and a deduct-from-pantry toggle (default on). Shows a "cooked N times · last DATE" indicator once logged. | `apps/web/components/recipe/{mark-cooked-dialog,mark-cooked-section}.tsx`, `apps/web/app/api/v1/recipes/[id]/cooked/route.ts` (`cookedAt` field, new) |
|
||||||
|
| Billing/invoice details (full name, address, phone) | Exists (new, 2026-07-24) | New `userBillingDetails` table (1:1 with `users`), separate from the display `name` field. `fullName` is required by the form/API but nullable at the DB level (no backfill for existing users); address lines/city/postal code/country/phone are all optional. Lives under `Settings → Billing`, feeds future invoice generation — not wired into Stripe yet. | `packages/db/src/schema/billing.ts` (`userBillingDetails`), `apps/web/app/api/v1/users/me/billing-details/route.ts`, `apps/web/components/settings/billing-details-form.tsx` |
|
||||||
| Expiring-soon tracking | Exists | With "use it up" recipe suggestions. The leftover-expiry push/email cron only covers batch-cook dishes — plain pantry items only get an in-app badge, no reminder. | `apps/web/components/pantry/expiring-soon-suggestions.tsx`, `apps/web/app/api/internal/cron/leftover-reminders/route.ts` |
|
| Expiring-soon tracking | Exists | With "use it up" recipe suggestions. The leftover-expiry push/email cron only covers batch-cook dishes — plain pantry items only get an in-app badge, no reminder. | `apps/web/components/pantry/expiring-soon-suggestions.tsx`, `apps/web/app/api/internal/cron/leftover-reminders/route.ts` |
|
||||||
| **Barcode scan (pantry)** | **Exists** | Real Open Food Facts API lookup by UPC/EAN | `apps/web/app/api/v1/pantry/scan/barcode` |
|
| **Barcode scan (pantry)** | **Exists** | Real Open Food Facts API lookup by UPC/EAN | `apps/web/app/api/v1/pantry/scan/barcode` |
|
||||||
| Photo scan (pantry) | Exists | Vision-model based | `apps/web/app/api/v1/pantry/scan/photo` |
|
| Photo scan (pantry) | Exists | Vision-model based | `apps/web/app/api/v1/pantry/scan/photo` |
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { auth } from "@/lib/auth/server";
|
import { auth } from "@/lib/auth/server";
|
||||||
import { db, users, tierDefinitions, tierEnum, userUsage, eq, and } from "@epicure/db";
|
import { db, users, tierDefinitions, tierEnum, userUsage, userBillingDetails, eq, and } from "@epicure/db";
|
||||||
import { getRecipeCount, getStorageUsedMb, UNLIMITED } from "@/lib/tiers";
|
import { getRecipeCount, getStorageUsedMb, UNLIMITED } from "@/lib/tiers";
|
||||||
import { BillingPlanCards } from "@/components/settings/billing-plan-cards";
|
import { BillingPlanCards } from "@/components/settings/billing-plan-cards";
|
||||||
import { ManageBillingButton } from "@/components/settings/manage-billing-button";
|
import { ManageBillingButton } from "@/components/settings/manage-billing-button";
|
||||||
import { UsageQuotaSection } from "@/components/settings/usage-quota-section";
|
import { UsageQuotaSection } from "@/components/settings/usage-quota-section";
|
||||||
|
import { BillingDetailsForm } from "@/components/settings/billing-details-form";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
export const metadata: Metadata = {};
|
export const metadata: Metadata = {};
|
||||||
@@ -23,7 +24,7 @@ export default async function BillingPage({ searchParams }: { searchParams: Prom
|
|||||||
|
|
||||||
const currentMonth = new Date().toISOString().slice(0, 7);
|
const currentMonth = new Date().toISOString().slice(0, 7);
|
||||||
|
|
||||||
const [dbUser, allTierDefs, usage, recipeCount, storageUsedMb] = await Promise.all([
|
const [dbUser, allTierDefs, usage, recipeCount, storageUsedMb, billingDetails] = await Promise.all([
|
||||||
db.query.users.findFirst({
|
db.query.users.findFirst({
|
||||||
where: eq(users.id, session.user.id),
|
where: eq(users.id, session.user.id),
|
||||||
columns: { tier: true, subscriptionStatus: true, currentPeriodEnd: true, stripeCustomerId: true },
|
columns: { tier: true, subscriptionStatus: true, currentPeriodEnd: true, stripeCustomerId: true },
|
||||||
@@ -32,6 +33,7 @@ export default async function BillingPage({ searchParams }: { searchParams: Prom
|
|||||||
db.query.userUsage.findFirst({ where: and(eq(userUsage.userId, session.user.id), eq(userUsage.month, currentMonth)) }),
|
db.query.userUsage.findFirst({ where: and(eq(userUsage.userId, session.user.id), eq(userUsage.month, currentMonth)) }),
|
||||||
getRecipeCount(session.user.id),
|
getRecipeCount(session.user.id),
|
||||||
getStorageUsedMb(session.user.id),
|
getStorageUsedMb(session.user.id),
|
||||||
|
db.query.userBillingDetails.findFirst({ where: eq(userBillingDetails.userId, session.user.id) }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const currentTier = dbUser?.tier ?? "free";
|
const currentTier = dbUser?.tier ?? "free";
|
||||||
@@ -100,6 +102,14 @@ export default async function BillingPage({ searchParams }: { searchParams: Prom
|
|||||||
<UsageQuotaSection metrics={usageMetrics} />
|
<UsageQuotaSection metrics={usageMetrics} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-xl border p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="font-semibold text-lg">Billing details</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">Used on invoices. Full name is required; address and phone are optional.</p>
|
||||||
|
</div>
|
||||||
|
<BillingDetailsForm initialDetails={billingDetails ?? null} />
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<h2 className="font-semibold text-lg">Plans</h2>
|
<h2 className="font-semibold text-lg">Plans</h2>
|
||||||
<BillingPlanCards currentTier={currentTier} plans={plans} />
|
<BillingPlanCards currentTier={currentTier} plans={plans} />
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { db, userBillingDetails, eq } from "@epicure/db";
|
||||||
|
import { requireSession } from "@/lib/api-auth";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const PutSchema = z.object({
|
||||||
|
fullName: z.string().trim().min(1).max(200),
|
||||||
|
addressLine1: z.string().trim().max(200).optional(),
|
||||||
|
addressLine2: z.string().trim().max(200).optional(),
|
||||||
|
city: z.string().trim().max(120).optional(),
|
||||||
|
postalCode: z.string().trim().max(20).optional(),
|
||||||
|
country: z.string().trim().max(120).optional(),
|
||||||
|
phone: z.string().trim().max(40).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const { session, response } = await requireSession();
|
||||||
|
if (response) return response;
|
||||||
|
|
||||||
|
const details = await db.query.userBillingDetails.findFirst({
|
||||||
|
where: eq(userBillingDetails.userId, session!.user.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ data: details ?? null });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(req: NextRequest) {
|
||||||
|
const { session, response } = await requireSession();
|
||||||
|
if (response) return response;
|
||||||
|
|
||||||
|
const parsed = PutSchema.safeParse(await req.json());
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = parsed.data;
|
||||||
|
const userId = session!.user.id;
|
||||||
|
|
||||||
|
await db
|
||||||
|
.insert(userBillingDetails)
|
||||||
|
.values({ id: crypto.randomUUID(), userId, ...body, updatedAt: new Date() })
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: userBillingDetails.userId,
|
||||||
|
set: { ...body, updatedAt: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
|
type BillingDetails = {
|
||||||
|
fullName?: string | null;
|
||||||
|
addressLine1?: string | null;
|
||||||
|
addressLine2?: string | null;
|
||||||
|
city?: string | null;
|
||||||
|
postalCode?: string | null;
|
||||||
|
country?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function BillingDetailsForm({ initialDetails }: { initialDetails: BillingDetails | null }) {
|
||||||
|
const t = useTranslations("settingsForm");
|
||||||
|
const [details, setDetails] = useState<BillingDetails>(initialDetails ?? {});
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
|
function setField(field: keyof BillingDetails, value: string) {
|
||||||
|
setDetails((prev) => ({ ...prev, [field]: value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!details.fullName?.trim()) {
|
||||||
|
setError(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(false);
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/v1/users/me/billing-details", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(details),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Save failed");
|
||||||
|
toast.success(t("billingDetailsSaved"));
|
||||||
|
} catch {
|
||||||
|
toast.error(t("billingDetailsSaveFailed"));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="billing-full-name">
|
||||||
|
{t("billingFullName")} <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="billing-full-name"
|
||||||
|
value={details.fullName ?? ""}
|
||||||
|
onChange={(e) => setField("fullName", e.target.value)}
|
||||||
|
aria-invalid={error || undefined}
|
||||||
|
/>
|
||||||
|
{error && <p className="text-xs text-destructive">{t("billingFullNameRequired")}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="col-span-2 space-y-1.5">
|
||||||
|
<Label htmlFor="billing-address-1">{t("billingAddressLine1")}</Label>
|
||||||
|
<Input id="billing-address-1" value={details.addressLine1 ?? ""} onChange={(e) => setField("addressLine1", e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 space-y-1.5">
|
||||||
|
<Label htmlFor="billing-address-2">{t("billingAddressLine2")}</Label>
|
||||||
|
<Input id="billing-address-2" value={details.addressLine2 ?? ""} onChange={(e) => setField("addressLine2", e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="billing-city">{t("billingCity")}</Label>
|
||||||
|
<Input id="billing-city" value={details.city ?? ""} onChange={(e) => setField("city", e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="billing-postal-code">{t("billingPostalCode")}</Label>
|
||||||
|
<Input id="billing-postal-code" value={details.postalCode ?? ""} onChange={(e) => setField("postalCode", e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 space-y-1.5">
|
||||||
|
<Label htmlFor="billing-country">{t("billingCountry")}</Label>
|
||||||
|
<Input id="billing-country" value={details.country ?? ""} onChange={(e) => setField("country", e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 space-y-1.5">
|
||||||
|
<Label htmlFor="billing-phone">{t("billingPhone")}</Label>
|
||||||
|
<Input id="billing-phone" type="tel" value={details.phone ?? ""} onChange={(e) => setField("phone", e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button onClick={() => { void handleSave(); }} disabled={saving} size="sm">
|
||||||
|
{saving ? t("billingDetailsSaving") : t("saveBillingDetails")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// Mirrors CHANGELOG.md at the repo root — update both together.
|
// Mirrors CHANGELOG.md at the repo root — update both together.
|
||||||
export const APP_VERSION = "0.75.0";
|
export const APP_VERSION = "0.76.0";
|
||||||
|
|
||||||
export type ChangelogEntry = {
|
export type ChangelogEntry = {
|
||||||
version: string;
|
version: string;
|
||||||
@@ -11,6 +11,13 @@ export type ChangelogEntry = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const CHANGELOG: ChangelogEntry[] = [
|
export const CHANGELOG: ChangelogEntry[] = [
|
||||||
|
{
|
||||||
|
version: "0.76.0",
|
||||||
|
date: "2026-07-24 12:15",
|
||||||
|
added: [
|
||||||
|
"Settings -> Billing now has a billing/invoice details section: full name (required), address, and phone number (both optional). Separate from your display name — this is what will appear on future invoices.",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
version: "0.75.0",
|
version: "0.75.0",
|
||||||
date: "2026-07-24 11:30",
|
date: "2026-07-24 11:30",
|
||||||
|
|||||||
@@ -497,6 +497,16 @@ export function generateOpenApiSpec(): object {
|
|||||||
caloriesKcal: z.number().int().min(0).optional(), proteinG: z.number().int().min(0).optional(),
|
caloriesKcal: z.number().int().min(0).optional(), proteinG: z.number().int().min(0).optional(),
|
||||||
carbsG: z.number().int().min(0).optional(), fatG: z.number().int().min(0).optional(),
|
carbsG: z.number().int().min(0).optional(), fatG: z.number().int().min(0).optional(),
|
||||||
}));
|
}));
|
||||||
|
const BillingDetailsRef = registry.register("BillingDetailsMe", z.object({
|
||||||
|
id: z.string(), userId: z.string(),
|
||||||
|
fullName: z.string().nullable(), addressLine1: z.string().nullable(), addressLine2: z.string().nullable(),
|
||||||
|
city: z.string().nullable(), postalCode: z.string().nullable(), country: z.string().nullable(), phone: z.string().nullable(),
|
||||||
|
updatedAt: z.string().datetime(),
|
||||||
|
}).nullable());
|
||||||
|
const UpdateBillingDetailsRef = registry.register("UpdateBillingDetails", z.object({
|
||||||
|
fullName: z.string().min(1).max(200), addressLine1: z.string().max(200).optional(), addressLine2: z.string().max(200).optional(),
|
||||||
|
city: z.string().max(120).optional(), postalCode: z.string().max(20).optional(), country: z.string().max(120).optional(), phone: z.string().max(40).optional(),
|
||||||
|
}));
|
||||||
const NutritionDiaryEntryRef = registry.register("NutritionDiaryEntry", z.object({
|
const NutritionDiaryEntryRef = registry.register("NutritionDiaryEntry", z.object({
|
||||||
id: z.string(), recipeId: z.string(), title: z.string(), servings: z.number(),
|
id: z.string(), recipeId: z.string(), title: z.string(), servings: z.number(),
|
||||||
cookedAt: z.string().datetime(), nutritionKnown: z.boolean(),
|
cookedAt: z.string().datetime(), nutritionKnown: z.boolean(),
|
||||||
@@ -546,6 +556,8 @@ export function generateOpenApiSpec(): object {
|
|||||||
registry.registerPath({ method: "get", path: "/api/v1/users/me/nutrition-diary", summary: "Get a day's cooked-recipe nutrition totals vs. your goals, or a multi-day trend", description: "Pass `range` (7, 30, or 90) instead of `date` for a daily calorie/macro trend over that many days — days with nothing cooked appear with zero totals.", security, request: { query: z.object({ date: z.string().optional().describe("ISO date YYYY-MM-DD, defaults to today (UTC). Ignored if range is set."), range: z.enum(["7", "30", "90"]).optional().describe("Switches to trend mode: returns { range, days: [{date, calories, proteinG, carbsG, fatG}], goalCalories } instead of the single-day shape.") }) }, responses: { 200: { description: "Diary or trend", content: { "application/json": { schema: z.union([NutritionDiaryRef, NutritionTrendRef]) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
registry.registerPath({ method: "get", path: "/api/v1/users/me/nutrition-diary", summary: "Get a day's cooked-recipe nutrition totals vs. your goals, or a multi-day trend", description: "Pass `range` (7, 30, or 90) instead of `date` for a daily calorie/macro trend over that many days — days with nothing cooked appear with zero totals.", security, request: { query: z.object({ date: z.string().optional().describe("ISO date YYYY-MM-DD, defaults to today (UTC). Ignored if range is set."), range: z.enum(["7", "30", "90"]).optional().describe("Switches to trend mode: returns { range, days: [{date, calories, proteinG, carbsG, fatG}], goalCalories } instead of the single-day shape.") }) }, responses: { 200: { description: "Diary or trend", content: { "application/json": { schema: z.union([NutritionDiaryRef, NutritionTrendRef]) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||||
registry.registerPath({ method: "get", path: "/api/v1/users/me/nutrition-goals", summary: "Get your daily nutrition goals", security, responses: { 200: { description: "Goals", content: { "application/json": { schema: z.object({ data: NutritionGoalsRef2 }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
registry.registerPath({ method: "get", path: "/api/v1/users/me/nutrition-goals", summary: "Get your daily nutrition goals", security, responses: { 200: { description: "Goals", content: { "application/json": { schema: z.object({ data: NutritionGoalsRef2 }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||||
registry.registerPath({ method: "put", path: "/api/v1/users/me/nutrition-goals", summary: "Set your daily nutrition goals", security, request: { body: { content: { "application/json": { schema: UpdateNutritionGoalsRef } }, required: true } }, responses: { 200: { description: "Saved", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Invalid request", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
registry.registerPath({ method: "put", path: "/api/v1/users/me/nutrition-goals", summary: "Set your daily nutrition goals", security, request: { body: { content: { "application/json": { schema: UpdateNutritionGoalsRef } }, required: true } }, responses: { 200: { description: "Saved", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Invalid request", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||||
|
registry.registerPath({ method: "get", path: "/api/v1/users/me/billing-details", summary: "Get your billing/invoice details", description: "Full name, address, and phone used on invoices — separate from your display name.", security, responses: { 200: { description: "Billing details or null", content: { "application/json": { schema: z.object({ data: BillingDetailsRef }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||||
|
registry.registerPath({ method: "put", path: "/api/v1/users/me/billing-details", summary: "Set your billing/invoice details", description: "fullName is required; addressLine1/2, city, postalCode, country, and phone are all optional.", security, request: { body: { content: { "application/json": { schema: UpdateBillingDetailsRef } }, required: true } }, responses: { 200: { description: "Saved", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Invalid request", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||||
registry.registerPath({ method: "get", path: "/api/v1/users/search", summary: "Search users by name or username", description: "Excludes private accounts and any account you've blocked or that has blocked you. Requires at least 2 characters.", security, request: { query: z.object({ q: z.string().min(2).max(50) }) }, responses: { 200: { description: "Matches (up to 20)", content: { "application/json": { schema: z.object({ users: z.array(UserSearchResultRef) }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
registry.registerPath({ method: "get", path: "/api/v1/users/search", summary: "Search users by name or username", description: "Excludes private accounts and any account you've blocked or that has blocked you. Requires at least 2 characters.", security, request: { query: z.object({ q: z.string().min(2).max(50) }) }, responses: { 200: { description: "Matches (up to 20)", content: { "application/json": { schema: z.object({ users: z.array(UserSearchResultRef) }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||||
registry.registerPath({ method: "get", path: "/api/v1/users/{username}", summary: "Get a public profile by username", security: [], request: { params: usernameParam }, responses: { 200: { description: "Profile", content: { "application/json": { schema: UserProfilePublicRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
registry.registerPath({ method: "get", path: "/api/v1/users/{username}", summary: "Get a public profile by username", security: [], request: { params: usernameParam }, responses: { 200: { description: "Profile", content: { "application/json": { schema: UserProfilePublicRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||||
registry.registerPath({ method: "post", path: "/api/v1/users/{username}/follow", summary: "Follow a user", description: "Rate-limited: 30 req/min. Fails if either side has blocked the other.", security, request: { params: usernameParam }, responses: { 200: { description: "Following", content: { "application/json": { schema: z.object({ following: z.boolean() }) } } }, 400: { description: "Cannot follow yourself", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
registry.registerPath({ method: "post", path: "/api/v1/users/{username}/follow", summary: "Follow a user", description: "Rate-limited: 30 req/min. Fails if either side has blocked the other.", security, request: { params: usernameParam }, responses: { 200: { description: "Following", content: { "application/json": { schema: z.object({ following: z.boolean() }) } } }, 400: { description: "Cannot follow yourself", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||||
|
|||||||
@@ -1600,6 +1600,18 @@
|
|||||||
"modelDefault": "Default",
|
"modelDefault": "Default",
|
||||||
"modelSaving": "Saving…",
|
"modelSaving": "Saving…",
|
||||||
"saveModelPrefs": "Save model preferences",
|
"saveModelPrefs": "Save model preferences",
|
||||||
|
"billingFullName": "Full name",
|
||||||
|
"billingFullNameRequired": "Full name is required",
|
||||||
|
"billingAddressLine1": "Address line 1",
|
||||||
|
"billingAddressLine2": "Address line 2",
|
||||||
|
"billingCity": "City",
|
||||||
|
"billingPostalCode": "Postal code",
|
||||||
|
"billingCountry": "Country",
|
||||||
|
"billingPhone": "Phone number",
|
||||||
|
"billingDetailsSaved": "Billing details saved",
|
||||||
|
"billingDetailsSaveFailed": "Failed to save billing details",
|
||||||
|
"billingDetailsSaving": "Saving…",
|
||||||
|
"saveBillingDetails": "Save billing details",
|
||||||
"userUpdated": "User updated successfully",
|
"userUpdated": "User updated successfully",
|
||||||
"userUpdateFailed": "Failed to save",
|
"userUpdateFailed": "Failed to save",
|
||||||
"adminSettingsSaved": "Settings saved",
|
"adminSettingsSaved": "Settings saved",
|
||||||
|
|||||||
@@ -1591,6 +1591,18 @@
|
|||||||
"saveModelPrefs": "Enregistrer les préférences de modèle",
|
"saveModelPrefs": "Enregistrer les préférences de modèle",
|
||||||
"modelDefaultPlaceholder": "Par défaut",
|
"modelDefaultPlaceholder": "Par défaut",
|
||||||
"modelNamePlaceholder": "ex. llama3.2",
|
"modelNamePlaceholder": "ex. llama3.2",
|
||||||
|
"billingFullName": "Nom complet",
|
||||||
|
"billingFullNameRequired": "Le nom complet est obligatoire",
|
||||||
|
"billingAddressLine1": "Adresse (ligne 1)",
|
||||||
|
"billingAddressLine2": "Adresse (ligne 2)",
|
||||||
|
"billingCity": "Ville",
|
||||||
|
"billingPostalCode": "Code postal",
|
||||||
|
"billingCountry": "Pays",
|
||||||
|
"billingPhone": "Numéro de téléphone",
|
||||||
|
"billingDetailsSaved": "Coordonnées de facturation enregistrées",
|
||||||
|
"billingDetailsSaveFailed": "Échec de l'enregistrement des coordonnées de facturation",
|
||||||
|
"billingDetailsSaving": "Enregistrement…",
|
||||||
|
"saveBillingDetails": "Enregistrer les coordonnées",
|
||||||
"userUpdated": "Utilisateur mis à jour avec succès",
|
"userUpdated": "Utilisateur mis à jour avec succès",
|
||||||
"userUpdateFailed": "Échec de l'enregistrement",
|
"userUpdateFailed": "Échec de l'enregistrement",
|
||||||
"adminSettingsSaved": "Paramètres enregistrés",
|
"adminSettingsSaved": "Paramètres enregistrés",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@epicure/web",
|
"name": "@epicure/web",
|
||||||
"version": "0.75.0",
|
"version": "0.76.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "epicure",
|
"name": "epicure",
|
||||||
"version": "0.75.0",
|
"version": "0.76.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "pnpm --filter web dev",
|
"dev": "pnpm --filter web dev",
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
CREATE TABLE "user_billing_details" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"full_name" text,
|
||||||
|
"address_line1" text,
|
||||||
|
"address_line2" text,
|
||||||
|
"city" text,
|
||||||
|
"postal_code" text,
|
||||||
|
"country" text,
|
||||||
|
"phone" text,
|
||||||
|
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "user_billing_details_user_id_unique" UNIQUE("user_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "user_billing_details" ADD CONSTRAINT "user_billing_details_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -442,6 +442,13 @@
|
|||||||
"when": 1784805788971,
|
"when": 1784805788971,
|
||||||
"tag": "0062_mean_ikaris",
|
"tag": "0062_mean_ikaris",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 63,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1784885406472,
|
||||||
|
"tag": "0063_sharp_stone_men",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import { pgTable, text, timestamp } from "drizzle-orm/pg-core";
|
import { pgTable, text, timestamp } from "drizzle-orm/pg-core";
|
||||||
|
import { relations } from "drizzle-orm";
|
||||||
|
import { users } from "./users";
|
||||||
|
|
||||||
// Dedup log for inbound Stripe webhook events. Stripe may redeliver the same
|
// Dedup log for inbound Stripe webhook events. Stripe may redeliver the same
|
||||||
// event within its retry/tolerance window; we insert the event id here
|
// event within its retry/tolerance window; we insert the event id here
|
||||||
@@ -9,3 +11,25 @@ export const processedStripeEvents = pgTable("processed_stripe_events", {
|
|||||||
type: text("type").notNull(),
|
type: text("type").notNull(),
|
||||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Billing/invoice details, separate from the display `name` on `users` —
|
||||||
|
// fullName here is the legal/billing name used on invoices. All columns are
|
||||||
|
// nullable at the DB level (existing users have none of this filled in yet);
|
||||||
|
// fullName being "mandatory" is enforced by the API/form, not a NOT NULL
|
||||||
|
// constraint, since we can't backfill it for every existing user.
|
||||||
|
export const userBillingDetails = pgTable("user_billing_details", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }).unique(),
|
||||||
|
fullName: text("full_name"),
|
||||||
|
addressLine1: text("address_line1"),
|
||||||
|
addressLine2: text("address_line2"),
|
||||||
|
city: text("city"),
|
||||||
|
postalCode: text("postal_code"),
|
||||||
|
country: text("country"),
|
||||||
|
phone: text("phone"),
|
||||||
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const userBillingDetailsRelations = relations(userBillingDetails, ({ one }) => ({
|
||||||
|
user: one(users, { fields: [userBillingDetails.userId], references: [users.id] }),
|
||||||
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user