64030afef3
1. AdminSidebar: removed yellow "Genera preventivo" CTA (duplicated Preventivi tab nav item) 2. Delete phase/task: new deletePhase/deleteTask server actions with FK cascade and phase-status recompute; DeletePhaseTaskButton client component with window.confirm; trash icons in PhasesTab per phase and per task 3. Public dashboard: extend activeOffers query with offer_macros join (offer_name, offer_type); reorder sidebar 1°Offerte 2°Pagamenti 3°Documenti; OffersSection shows macro public_name as heading (never tier letter/internal_name); PaymentStatus gains totalLabel/overrideAmount/hideRows props — retainer=monthly label + no Acconto/Saldo rows 4. Offer editor: Descrizione textarea (nota interna) and Modalità toggle moved directly under title; Tags section now contains only Categoria/Ticket/Tipo/Obiettivo 5. Basecamp chat: API route supports entity_type="phase" with authorization scoping; comments scope in client-view broadened to include phaseIds and client_id (general); CommentsTab updated with phase/general entities; ChatProvider React context; ChatPanel fixed slide-in panel with FAB (bottom-right) and composer tag selector (Generale + phases); PhaseCard gets chat-bubble icon pre-tagging that phase; inline ChatSection block removed from dashboard main column Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
116 lines
3.9 KiB
TypeScript
116 lines
3.9 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
import { eq, inArray } from "drizzle-orm";
|
|
import { z } from "zod";
|
|
import { db } from "@/db";
|
|
import { clients, comments, tasks, phases, deliverables, projects } from "@/db/schema";
|
|
import { rateLimit } from "@/lib/rate-limit";
|
|
|
|
const commentSchema = z.object({
|
|
token: z.string().min(1),
|
|
entity_type: z.enum(["task", "deliverable", "general", "phase"]),
|
|
entity_id: z.string().min(1),
|
|
body: z.string().min(1, "Il commento non può essere vuoto").max(2000),
|
|
});
|
|
|
|
export async function POST(request: NextRequest) {
|
|
const ip = request.headers.get("x-forwarded-for") ?? "unknown";
|
|
if (!rateLimit(`comment:${ip}`, 10, 60_000)) {
|
|
return NextResponse.json({ error: "Troppe richieste" }, { status: 429 });
|
|
}
|
|
|
|
try {
|
|
const body = await request.json();
|
|
const parsed = commentSchema.safeParse(body);
|
|
|
|
if (!parsed.success) {
|
|
return NextResponse.json(
|
|
{ error: parsed.error.issues[0].message },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
const { token, entity_type, entity_id, body: commentBody } = parsed.data;
|
|
|
|
// Validate token
|
|
const clientRows = await db
|
|
.select({ id: clients.id })
|
|
.from(clients)
|
|
.where(eq(clients.token, token))
|
|
.limit(1);
|
|
|
|
if (clientRows.length === 0) {
|
|
return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 });
|
|
}
|
|
|
|
const clientId = clientRows[0].id;
|
|
|
|
if (entity_type === "general") {
|
|
// General messages: entity_id must be the client's own id
|
|
if (entity_id !== clientId) {
|
|
return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 });
|
|
}
|
|
} else {
|
|
// Scope phases through projects → client
|
|
const clientProjects = await db
|
|
.select({ id: projects.id })
|
|
.from(projects)
|
|
.where(eq(projects.client_id, clientId));
|
|
const projectIds = clientProjects.map((p) => p.id);
|
|
|
|
if (projectIds.length === 0) {
|
|
return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 });
|
|
}
|
|
|
|
const phasesForClient = await db
|
|
.select({ id: phases.id })
|
|
.from(phases)
|
|
.where(inArray(phases.project_id, projectIds));
|
|
const phaseIds = phasesForClient.map((p) => p.id);
|
|
|
|
if (phaseIds.length === 0) {
|
|
return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 });
|
|
}
|
|
|
|
const taskRows = await db
|
|
.select({ id: tasks.id })
|
|
.from(tasks)
|
|
.where(inArray(tasks.phase_id, phaseIds));
|
|
|
|
if (entity_type === "phase") {
|
|
// Phase: entity_id must be one of the client's phases
|
|
if (!phasesForClient.find((p) => p.id === entity_id)) {
|
|
return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 });
|
|
}
|
|
} else if (entity_type === "task") {
|
|
if (!taskRows.find((r) => r.id === entity_id)) {
|
|
return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 });
|
|
}
|
|
} else {
|
|
// deliverable
|
|
const taskIds = taskRows.map((r) => r.id);
|
|
if (taskIds.length === 0) {
|
|
return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 });
|
|
}
|
|
const delivRows = await db
|
|
.select({ id: deliverables.id })
|
|
.from(deliverables)
|
|
.where(inArray(deliverables.task_id, taskIds));
|
|
if (!delivRows.find((r) => r.id === entity_id)) {
|
|
return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 });
|
|
}
|
|
}
|
|
}
|
|
|
|
await db.insert(comments).values({
|
|
entity_type,
|
|
entity_id,
|
|
author: "client",
|
|
body: commentBody,
|
|
});
|
|
|
|
return NextResponse.json({ success: true }, { status: 201 });
|
|
} catch (err) {
|
|
console.error("/api/client/comment error:", err);
|
|
return NextResponse.json({ error: "Errore interno" }, { status: 500 });
|
|
}
|
|
} |