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 { 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 });
+2 -2
View File
@@ -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>
+1
View File
@@ -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}
+3
View File
@@ -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,
+3 -2
View File
@@ -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
View File
@@ -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) {