fix(04-06): five post-deploy fixes
- Middleware (proxy.ts): switch internal API calls to localhost instead of
request.url — avoids Docker hairpin NAT issues where the container can't
reach its own external hostname via Traefik
- ProjectRow timer: pass projectId={project.id} to TimerCell so it calls
startTimer(projectId) directly instead of startTimerForClient(project.id)
- Admin client list: add slug field to ClientWithPayments + show slug link
when available (falls back to token) — so admins see the human-readable URL
- Analytics contracted: sum projects.accepted_total (authoritative) instead
of clients.accepted_total (always 0 in multi-project architecture)
- createClient: auto-generate slug from client name at creation time
(e.g. "Mario Rossi" → "mario-rossi"), with -2/-3 suffix on conflict
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,9 +3,30 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { clients, projects, payments } from "@/db/schema";
|
import { clients, projects, payments } from "@/db/schema";
|
||||||
|
|
||||||
|
function toSlug(name: string): string {
|
||||||
|
return name
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize("NFD")
|
||||||
|
.replace(/[̀-ͯ]/g, "")
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
.slice(0, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uniqueSlug(base: string): Promise<string | null> {
|
||||||
|
if (base.length < 3) return null;
|
||||||
|
const candidates = [base, ...Array.from({ length: 8 }, (_, i) => `${base.slice(0, 47)}-${i + 2}`)];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const rows = await db.select({ id: clients.id }).from(clients).where(eq(clients.slug, candidate)).limit(1);
|
||||||
|
if (rows.length === 0) return candidate;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const createClientSchema = z.object({
|
const createClientSchema = z.object({
|
||||||
name: z.string().min(1, "Nome richiesto"),
|
name: z.string().min(1, "Nome richiesto"),
|
||||||
brand_name: z.string().min(1, "Nome brand richiesto"),
|
brand_name: z.string().min(1, "Nome brand richiesto"),
|
||||||
@@ -26,6 +47,9 @@ export async function createClient(formData: FormData) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-generate slug from name (e.g. "Mario Rossi" → "mario-rossi")
|
||||||
|
const slug = await uniqueSlug(toSlug(parsed.data.name));
|
||||||
|
|
||||||
// Insert client — token and id are auto-generated by $defaultFn(() => nanoid())
|
// Insert client — token and id are auto-generated by $defaultFn(() => nanoid())
|
||||||
const [newClient] = await db
|
const [newClient] = await db
|
||||||
.insert(clients)
|
.insert(clients)
|
||||||
@@ -33,6 +57,7 @@ export async function createClient(formData: FormData) {
|
|||||||
name: parsed.data.name,
|
name: parsed.data.name,
|
||||||
brand_name: parsed.data.brand_name,
|
brand_name: parsed.data.brand_name,
|
||||||
brief: parsed.data.brief,
|
brief: parsed.data.brief,
|
||||||
|
slug,
|
||||||
})
|
})
|
||||||
.returning({ id: clients.id, token: clients.token, brand_name: clients.brand_name });
|
.returning({ id: clients.id, token: clients.token, brand_name: clients.brand_name });
|
||||||
|
|
||||||
|
|||||||
@@ -53,12 +53,12 @@ export function ClientRow({ client }: { client: ClientWithPayments }) {
|
|||||||
</td>
|
</td>
|
||||||
<td className="py-3 px-4">
|
<td className="py-3 px-4">
|
||||||
<a
|
<a
|
||||||
href={`/client/${client.token}`}
|
href={`/client/${client.slug || client.token}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-xs text-[#1A463C] hover:underline font-mono"
|
className="text-xs text-[#1A463C] hover:underline font-mono"
|
||||||
>
|
>
|
||||||
/client/{client.token.slice(0, 8)}…
|
/client/{(client.slug || client.token).slice(0, 12)}…
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export function ProjectRow({ project }: { project: ProjectWithPayments }) {
|
|||||||
<td className="py-3 px-4">
|
<td className="py-3 px-4">
|
||||||
<TimerCell
|
<TimerCell
|
||||||
clientId={project.id}
|
clientId={project.id}
|
||||||
|
projectId={project.id}
|
||||||
activeEntryId={project.activeTimerEntryId}
|
activeEntryId={project.activeTimerEntryId}
|
||||||
activeStartedAt={project.activeTimerStartedAt}
|
activeStartedAt={project.activeTimerStartedAt}
|
||||||
totalTrackedSeconds={project.totalTrackedSeconds}
|
totalTrackedSeconds={project.totalTrackedSeconds}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export type ClientWithPayments = {
|
|||||||
name: string;
|
name: string;
|
||||||
brand_name: string;
|
brand_name: string;
|
||||||
token: string;
|
token: string;
|
||||||
|
slug: string | null;
|
||||||
accepted_total: string;
|
accepted_total: string;
|
||||||
archived: boolean;
|
archived: boolean;
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
@@ -84,6 +85,7 @@ export async function getAllClientsWithPayments(
|
|||||||
name: c.name,
|
name: c.name,
|
||||||
brand_name: c.brand_name,
|
brand_name: c.brand_name,
|
||||||
token: c.token,
|
token: c.token,
|
||||||
|
slug: c.slug ?? null,
|
||||||
accepted_total: c.accepted_total ?? "0",
|
accepted_total: c.accepted_total ?? "0",
|
||||||
archived: c.archived ?? false,
|
archived: c.archived ?? false,
|
||||||
created_at: c.created_at,
|
created_at: c.created_at,
|
||||||
@@ -156,6 +158,7 @@ export async function getAllClientsWithPayments(
|
|||||||
name: c.name,
|
name: c.name,
|
||||||
brand_name: c.brand_name,
|
brand_name: c.brand_name,
|
||||||
token: c.token,
|
token: c.token,
|
||||||
|
slug: c.slug ?? null,
|
||||||
accepted_total: c.accepted_total ?? "0",
|
accepted_total: c.accepted_total ?? "0",
|
||||||
archived: c.archived ?? false,
|
archived: c.archived ?? false,
|
||||||
created_at: c.created_at,
|
created_at: c.created_at,
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ import { sql, and, eq, inArray } from "drizzle-orm";
|
|||||||
|
|
||||||
export async function getAnalyticsByYear(year: number) {
|
export async function getAnalyticsByYear(year: number) {
|
||||||
const [contracted] = await db
|
const [contracted] = await db
|
||||||
.select({ total: sql<string>`coalesce(sum(${clients.accepted_total}::numeric), 0)` })
|
.select({ total: sql<string>`coalesce(sum(${projects.accepted_total}::numeric), 0)` })
|
||||||
.from(clients)
|
.from(projects)
|
||||||
|
.innerJoin(clients, eq(projects.client_id, clients.id))
|
||||||
.where(sql`extract(year from ${clients.created_at}) = ${year}`);
|
.where(sql`extract(year from ${clients.created_at}) = ${year}`);
|
||||||
|
|
||||||
const [clientsRow] = await db
|
const [clientsRow] = await db
|
||||||
|
|||||||
+8
-10
@@ -38,23 +38,21 @@ export async function proxy(request: NextRequest) {
|
|||||||
const slugOrToken = slugOrTokenMatch[1];
|
const slugOrToken = slugOrTokenMatch[1];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Call internal Node.js API route — Edge middleware cannot use postgres-js directly
|
// Use localhost to avoid hairpin NAT issues in Docker.
|
||||||
// postgres-js requires Node.js net/tls which are unavailable in the Edge runtime
|
// request.url is the external hostname (via Traefik); inside the container the app is on localhost.
|
||||||
|
const port = process.env.PORT ?? "3000";
|
||||||
|
const base = `http://localhost:${port}`;
|
||||||
|
|
||||||
// Try slug first (D-06) — user-friendly slugs before token fallback
|
// Try slug first (D-06) — user-friendly slugs before token fallback
|
||||||
const validateSlugUrl = new URL(
|
let res = await fetch(
|
||||||
`/api/internal/validate-slug?slug=${encodeURIComponent(slugOrToken)}`,
|
`${base}/api/internal/validate-slug?slug=${encodeURIComponent(slugOrToken)}`
|
||||||
request.url
|
|
||||||
);
|
);
|
||||||
let res = await fetch(validateSlugUrl.toString());
|
|
||||||
|
|
||||||
// If slug not found, fall back to token validation (existing links continue to work)
|
// If slug not found, fall back to token validation (existing links continue to work)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const validateTokenUrl = new URL(
|
res = await fetch(
|
||||||
`/api/internal/validate-token?token=${encodeURIComponent(slugOrToken)}`,
|
`${base}/api/internal/validate-token?token=${encodeURIComponent(slugOrToken)}`
|
||||||
request.url
|
|
||||||
);
|
);
|
||||||
res = await fetch(validateTokenUrl.toString());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|||||||
Reference in New Issue
Block a user