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:
2026-05-22 13:42:38 +02:00
parent e6a9774cfe
commit eab88c9f63
6 changed files with 42 additions and 14 deletions
+25
View File
@@ -3,9 +3,30 @@
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { eq } from "drizzle-orm";
import { db } from "@/db";
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({
name: z.string().min(1, "Nome 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())
const [newClient] = await db
.insert(clients)
@@ -33,6 +57,7 @@ export async function createClient(formData: FormData) {
name: parsed.data.name,
brand_name: parsed.data.brand_name,
brief: parsed.data.brief,
slug,
})
.returning({ id: clients.id, token: clients.token, brand_name: clients.brand_name });
+2 -2
View File
@@ -53,12 +53,12 @@ export function ClientRow({ client }: { client: ClientWithPayments }) {
</td>
<td className="py-3 px-4">
<a
href={`/client/${client.token}`}
href={`/client/${client.slug || client.token}`}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-[#1A463C] hover:underline font-mono"
>
/client/{client.token.slice(0, 8)}
/client/{(client.slug || client.token).slice(0, 12)}
</a>
</td>
</tr>
+1
View File
@@ -52,6 +52,7 @@ export function ProjectRow({ project }: { project: ProjectWithPayments }) {
<td className="py-3 px-4">
<TimerCell
clientId={project.id}
projectId={project.id}
activeEntryId={project.activeTimerEntryId}
activeStartedAt={project.activeTimerStartedAt}
totalTrackedSeconds={project.totalTrackedSeconds}
+3
View File
@@ -35,6 +35,7 @@ export type ClientWithPayments = {
name: string;
brand_name: string;
token: string;
slug: string | null;
accepted_total: string;
archived: boolean;
created_at: Date;
@@ -84,6 +85,7 @@ export async function getAllClientsWithPayments(
name: c.name,
brand_name: c.brand_name,
token: c.token,
slug: c.slug ?? null,
accepted_total: c.accepted_total ?? "0",
archived: c.archived ?? false,
created_at: c.created_at,
@@ -156,6 +158,7 @@ export async function getAllClientsWithPayments(
name: c.name,
brand_name: c.brand_name,
token: c.token,
slug: c.slug ?? null,
accepted_total: c.accepted_total ?? "0",
archived: c.archived ?? false,
created_at: c.created_at,
+3 -2
View File
@@ -4,8 +4,9 @@ import { sql, and, eq, inArray } from "drizzle-orm";
export async function getAnalyticsByYear(year: number) {
const [contracted] = await db
.select({ total: sql<string>`coalesce(sum(${clients.accepted_total}::numeric), 0)` })
.from(clients)
.select({ total: sql<string>`coalesce(sum(${projects.accepted_total}::numeric), 0)` })
.from(projects)
.innerJoin(clients, eq(projects.client_id, clients.id))
.where(sql`extract(year from ${clients.created_at}) = ${year}`);
const [clientsRow] = await db
+8 -10
View File
@@ -38,23 +38,21 @@ export async function proxy(request: NextRequest) {
const slugOrToken = slugOrTokenMatch[1];
try {
// Call internal Node.js API route — Edge middleware cannot use postgres-js directly
// postgres-js requires Node.js net/tls which are unavailable in the Edge runtime
// Use localhost to avoid hairpin NAT issues in Docker.
// 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
const validateSlugUrl = new URL(
`/api/internal/validate-slug?slug=${encodeURIComponent(slugOrToken)}`,
request.url
let res = await fetch(
`${base}/api/internal/validate-slug?slug=${encodeURIComponent(slugOrToken)}`
);
let res = await fetch(validateSlugUrl.toString());
// If slug not found, fall back to token validation (existing links continue to work)
if (!res.ok) {
const validateTokenUrl = new URL(
`/api/internal/validate-token?token=${encodeURIComponent(slugOrToken)}`,
request.url
res = await fetch(
`${base}/api/internal/validate-token?token=${encodeURIComponent(slugOrToken)}`
);
res = await fetch(validateTokenUrl.toString());
}
if (!res.ok) {