feat(backend): migrate freelancer and portal runtimes

This commit is contained in:
poyrazavsever
2026-07-17 00:16:38 +03:00
parent 561af11b70
commit 678c0236db
41 changed files with 5293 additions and 2324 deletions
+14 -37
View File
@@ -1,49 +1,26 @@
"use server";
import { createClient } from "@/lib/supabase/server";
import { revalidatePath } from "next/cache";
import { cleanText, optionalDate, requiredText } from "@/server/web/form-data";
import { requireFreelancerBackend } from "@/server/web/freelancer";
function cleanText(value: FormDataEntryValue | null) {
const text = typeof value === "string" ? value.trim() : "";
return text.length > 0 ? text : null;
}
const ACTIVITY_TYPES = ["note", "call", "meeting", "email"] as const;
export async function addClientActivity(clientId: string, formData: FormData) {
const supabase = await createClient();
const {
data: { user },
error: userError,
} = await supabase.auth.getUser();
const { actor, service } = await requireFreelancerBackend();
const rawType = cleanText(formData.get("type"));
const type = rawType && ACTIVITY_TYPES.includes(rawType as (typeof ACTIVITY_TYPES)[number])
? rawType as (typeof ACTIVITY_TYPES)[number]
: "note";
if (userError || !user) {
throw new Error("Kullanıcı bulunamadı.");
}
const title = cleanText(formData.get("title"));
if (!title) {
throw new Error("Aktivite başlığı zorunludur.");
}
const { error } = await supabase.from("client_activities").insert({
user_id: user.id,
client_id: clientId,
type: formData.get("type") as string || "note",
title,
service.addClientActivity(actor, {
clientId,
type,
title: requiredText(formData.get("title"), "Aktivite başlığı zorunludur."),
content: cleanText(formData.get("content")),
activity_date: formData.get("activity_date") as string || new Date().toISOString(),
activityDate: optionalDate(formData.get("activity_date")) ?? new Date(),
});
if (error) {
throw new Error(`Aktivite eklenemedi: ${error.message}`);
}
// Update client's last_contact_date
await supabase
.from("clients")
.update({ last_contact_date: new Date().toISOString() })
.eq("id", clientId)
.eq("user_id", user.id);
revalidatePath(`/clients/${clientId}`);
revalidatePath(`/clients`);
revalidatePath("/clients");
}
+32 -25
View File
@@ -1,34 +1,41 @@
import { createClient } from "@/lib/supabase/server";
import { ClientDetailClient, type ClientDetailData, type ClientActivity } from "./client-detail-client";
import { notFound } from "next/navigation";
import { ClientDetailClient, type ClientDetailData, type ClientActivity } from "./client-detail-client";
import { DomainError } from "@/server/domain/errors";
import { requireFreelancerBackend } from "@/server/web/freelancer";
export default async function ClientDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
const { actor, service } = await requireFreelancerBackend();
if (!user) return null;
let data: { client: ClientDetailData; activities: ClientActivity[] };
try {
const row = service.getClient(actor, id);
const client: ClientDetailData = {
id: row.id,
name: row.name,
company_name: row.companyName,
email: row.email,
phone: row.phone,
website: row.website,
pipeline_stage: row.pipelineStage,
status: row.status,
notes: row.notes,
client_auth_id: row.authUserId,
};
const activities: ClientActivity[] = service.listClientActivities(actor, id).map((activity) => ({
id: activity.id,
type: activity.type,
title: activity.title,
content: activity.content,
activity_date: activity.activityDate.toISOString(),
created_at: activity.createdAt.toISOString(),
}));
const { data: clientData, error } = await supabase
.from("clients")
.select("id, name, company_name, email, phone, website, pipeline_stage, status, notes, client_auth_id")
.eq("id", id)
.eq("user_id", user.id)
.single();
if (error || !clientData) {
notFound();
data = { client, activities };
} catch (error) {
if (error instanceof DomainError && error.code === "NOT_FOUND") notFound();
throw error;
}
const { data: activitiesData } = await supabase
.from("client_activities")
.select("id, type, title, content, activity_date, created_at")
.eq("client_id", id)
.eq("user_id", user.id)
.order("activity_date", { ascending: false });
const client: ClientDetailData = clientData as ClientDetailData;
const activities: ClientActivity[] = (activitiesData || []) as ClientActivity[];
return <ClientDetailClient client={client} activities={activities} />;
return <ClientDetailClient client={data.client} activities={data.activities} />;
}
+36 -113
View File
@@ -1,143 +1,66 @@
"use server";
import { createClient } from "@/lib/supabase/server";
import { revalidatePath } from "next/cache";
import { requireFreelancerBackend } from "@/server/web/freelancer";
import { cleanText, requiredText } from "@/server/web/form-data";
const CLIENT_STATUSES = ["active", "paused", "archived"] as const;
const PIPELINE_STAGES = ["lead", "contacted", "proposal_sent", "won", "lost"] as const;
function cleanText(value: FormDataEntryValue | null) {
const text = typeof value === "string" ? value.trim() : "";
return text.length > 0 ? text : null;
}
function readStatus(value: FormDataEntryValue | null) {
const status = typeof value === "string" ? value : "active";
return CLIENT_STATUSES.includes(status as (typeof CLIENT_STATUSES)[number])
? status
: "active";
function enumValue<T extends readonly string[]>(
value: FormDataEntryValue | string | null,
values: T,
fallback: T[number],
): T[number] {
return typeof value === "string" && values.includes(value) ? value as T[number] : fallback;
}
function cleanWebsite(value: FormDataEntryValue | null) {
const website = cleanText(value)?.replace(/\s/g, "") || null;
if (!website) {
return null;
}
return /^https?:\/\//i.test(website) ? website : `https://${website}`;
const website = cleanText(value)?.replace(/\s/g, "") ?? null;
return website && !/^https?:\/\//i.test(website) ? `https://${website}` : website;
}
async function getCurrentUserId() {
const supabase = await createClient();
const {
data: { user },
error,
} = await supabase.auth.getUser();
if (error || !user) {
throw new Error("Müşteri işlemi için giriş yapmış kullanıcı bulunamadı.");
}
return { supabase, userId: user.id };
}
export async function createClientRecord(formData: FormData) {
const { supabase, userId } = await getCurrentUserId();
const name = cleanText(formData.get("name"));
if (!name) {
throw new Error("Müşteri adı zorunludur.");
}
const { error } = await supabase.from("clients").insert({
user_id: userId,
name,
company_name: cleanText(formData.get("company_name")),
function readPayload(formData: FormData) {
return {
name: requiredText(formData.get("name"), "Müşteri adı zorunludur."),
companyName: cleanText(formData.get("company_name")),
email: cleanText(formData.get("email")),
phone: cleanText(formData.get("phone")),
website: cleanWebsite(formData.get("website")),
status: readStatus(formData.get("status")),
status: enumValue(formData.get("status"), CLIENT_STATUSES, "active"),
notes: cleanText(formData.get("notes")),
pipeline_stage: formData.get("pipeline_stage") ? String(formData.get("pipeline_stage")) : "lead",
next_follow_up_date: cleanText(formData.get("next_follow_up_date")) || null,
});
if (error) {
throw new Error(`Müşteri eklenemedi: ${error.message}`);
}
pipelineStage: enumValue(formData.get("pipeline_stage"), PIPELINE_STAGES, "lead"),
nextFollowUpDate: cleanText(formData.get("next_follow_up_date")),
};
}
export async function createClientRecord(formData: FormData) {
const { actor, service } = await requireFreelancerBackend();
service.createClient(actor, readPayload(formData));
revalidatePath("/clients");
}
export async function updateClientRecord(formData: FormData) {
const { supabase, userId } = await getCurrentUserId();
const id = cleanText(formData.get("id"));
const name = cleanText(formData.get("name"));
if (!id || !name) {
throw new Error("Müşteri güncellemek için müşteri adı ve kayıt kimliği zorunludur.");
}
const { error } = await supabase
.from("clients")
.update({
name,
company_name: cleanText(formData.get("company_name")),
email: cleanText(formData.get("email")),
phone: cleanText(formData.get("phone")),
website: cleanWebsite(formData.get("website")),
status: readStatus(formData.get("status")),
notes: cleanText(formData.get("notes")),
pipeline_stage: formData.get("pipeline_stage") ? String(formData.get("pipeline_stage")) : "lead",
next_follow_up_date: cleanText(formData.get("next_follow_up_date")) || null,
})
.eq("id", id)
.eq("user_id", userId);
if (error) {
throw new Error(`Müşteri güncellenemedi: ${error.message}`);
}
const { actor, service } = await requireFreelancerBackend();
const id = requiredText(formData.get("id"), "Müşteri kaydı bulunamadı.");
service.updateClient(actor, id, readPayload(formData));
revalidatePath("/clients");
revalidatePath(`/clients/${id}`);
}
export async function archiveClientRecord(formData: FormData) {
const { supabase, userId } = await getCurrentUserId();
const id = cleanText(formData.get("id"));
if (!id) {
throw new Error("Arşivlenecek müşteri bulunamadı.");
}
const { error } = await supabase
.from("clients")
.update({ status: "archived" })
.eq("id", id)
.eq("user_id", userId);
if (error) {
throw new Error(`Müşteri arşivlenemedi: ${error.message}`);
}
const { actor, service } = await requireFreelancerBackend();
const id = requiredText(formData.get("id"), "Arşivlenecek müşteri bulunamadı.");
service.updateClient(actor, id, { status: "archived" });
revalidatePath("/clients");
revalidatePath(`/clients/${id}`);
}
export async function updateClientPipelineStage(id: string, stage: string) {
const { supabase, userId } = await getCurrentUserId();
if (!id || !stage) {
throw new Error("Eksik bilgi.");
}
const { error } = await supabase
.from("clients")
.update({ pipeline_stage: stage })
.eq("id", id)
.eq("user_id", userId);
if (error) {
throw new Error(`Aşama güncellenemedi: ${error.message}`);
}
const { actor, service } = await requireFreelancerBackend();
service.updateClient(actor, id, {
pipelineStage: enumValue(stage, PIPELINE_STAGES, "lead"),
});
revalidatePath("/clients");
revalidatePath(`/clients/${id}`);
}
+49 -95
View File
@@ -1,110 +1,64 @@
import { ClientsClient, type ClientListItem } from "@/app/(dashboard)/clients/clients-client";
import { createClient } from "@/lib/supabase/server";
type ClientRow = {
id: string;
name: string;
company_name: string | null;
email: string | null;
phone: string | null;
website: string | null;
status: "active" | "paused" | "archived";
notes: string | null;
pipeline_stage: "lead" | "contacted" | "proposal_sent" | "won" | "lost";
next_follow_up_date: string | null;
last_contact_date: string | null;
client_value_score: number;
created_at: string;
};
type ProjectRow = {
client_id: string | null;
};
type FinanceRow = {
client_id: string | null;
amount: number | string;
type: "income" | "expense";
payment_status: "planned" | "pending" | "paid" | "cancelled";
};
import { requireFreelancerBackend } from "@/server/web/freelancer";
export default async function ClientsPage() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
const { actor, service } = await requireFreelancerBackend();
const clientsData = service.listClients(actor);
const projects = service.listProjects(actor);
const finance = service.listFinanceTransactions(actor);
const activities = service.listAllClientActivities(actor);
if (!user) {
return null;
const projectCountByClient = new Map<string, number>();
for (const project of projects) {
if (project.clientId) {
projectCountByClient.set(project.clientId, (projectCountByClient.get(project.clientId) ?? 0) + 1);
}
}
const [{ data: clientRows }, { data: projectRows }, { data: financeRows }] =
await Promise.all([
supabase
.from("clients")
.select("id, name, company_name, email, phone, website, status, notes, created_at, pipeline_stage, next_follow_up_date, last_contact_date, client_value_score")
.eq("user_id", user.id)
.order("created_at", { ascending: false }),
supabase.from("projects").select("client_id").eq("user_id", user.id),
supabase
.from("finance_transactions")
.select("client_id, amount, type, payment_status")
.eq("user_id", user.id),
]);
const revenueByClient = new Map<string, number>();
for (const transaction of finance) {
if (transaction.clientId && transaction.type === "income" && transaction.paymentStatus === "paid") {
revenueByClient.set(
transaction.clientId,
(revenueByClient.get(transaction.clientId) ?? 0) + transaction.amountMinor / 100,
);
}
}
const projectCountByClient = countProjectsByClient((projectRows || []) as ProjectRow[]);
const revenueByClient = sumRevenueByClient((financeRows || []) as FinanceRow[]);
const lastActivityByClient = new Map<string, Date>();
for (const activity of activities) {
if (!lastActivityByClient.has(activity.clientId)) {
lastActivityByClient.set(activity.clientId, activity.activityDate);
}
}
const clients: ClientListItem[] = ((clientRows || []) as ClientRow[]).map((client) => ({
...client,
projectCount: projectCountByClient.get(client.id) || 0,
revenueTotal: revenueByClient.get(client.id) || 0,
}));
const activeCount = clients.filter((client) => client.status === "active").length;
const pausedCount = clients.filter((client) => client.status === "paused").length;
const archivedCount = clients.filter((client) => client.status === "archived").length;
const totalRevenue = clients.reduce((sum, client) => sum + client.revenueTotal, 0);
const clients: ClientListItem[] = clientsData.map((client) => {
return {
id: client.id,
name: client.name,
company_name: client.companyName,
email: client.email,
phone: client.phone,
website: client.website,
status: client.status,
notes: client.notes,
pipeline_stage: client.pipelineStage,
next_follow_up_date: client.nextFollowUpDate,
last_contact_date: lastActivityByClient.get(client.id)?.toISOString() ?? null,
client_value_score: 0,
created_at: client.createdAt.toISOString(),
projectCount: projectCountByClient.get(client.id) ?? 0,
revenueTotal: revenueByClient.get(client.id) ?? 0,
};
});
return (
<ClientsClient
clients={clients}
totalRevenue={totalRevenue}
activeCount={activeCount}
pausedCount={pausedCount}
archivedCount={archivedCount}
totalRevenue={clients.reduce((sum, client) => sum + client.revenueTotal, 0)}
activeCount={clients.filter((client) => client.status === "active").length}
pausedCount={clients.filter((client) => client.status === "paused").length}
archivedCount={clients.filter((client) => client.status === "archived").length}
/>
);
}
function countProjectsByClient(projects: ProjectRow[]) {
const countByClient = new Map<string, number>();
for (const project of projects) {
if (!project.client_id) continue;
countByClient.set(project.client_id, (countByClient.get(project.client_id) || 0) + 1);
}
return countByClient;
}
function sumRevenueByClient(transactions: FinanceRow[]) {
const revenueByClient = new Map<string, number>();
for (const transaction of transactions) {
if (
!transaction.client_id ||
transaction.type !== "income" ||
transaction.payment_status !== "paid"
) {
continue;
}
revenueByClient.set(
transaction.client_id,
(revenueByClient.get(transaction.client_id) || 0) + Number(transaction.amount || 0),
);
}
return revenueByClient;
}