feat: desktop image thumbnails, gallery lightbox portal, client-side compression, clickable table rows, fix header gap

This commit is contained in:
egecankomur
2026-05-12 04:49:36 +03:00
parent 3cce632eb3
commit 3554b39800
134 changed files with 7736 additions and 1913 deletions
+24 -4
View File
@@ -1,9 +1,11 @@
import "server-only";
import { cookies } from "next/headers";
import { Query } from "node-appwrite";
import { createAdminClient, getCurrentUser } from "./server";
import { DATABASE_ID, TABLES, type TenantSettings } from "./schema";
import { ACTIVE_TENANT_COOKIE } from "./tenant-types";
import { getActiveTenantId, getUserTeams } from "./tenant";
export type ActiveContext = {
@@ -16,16 +18,34 @@ export async function getActiveContext(): Promise<ActiveContext | null> {
const user = await getCurrentUser();
if (!user) return null;
const { teams: adminTeams, tablesDB } = createAdminClient();
let tenantId = await getActiveTenantId();
if (!tenantId) {
const teams = await getUserTeams();
tenantId = teams?.teams[0]?.$id ?? null;
// Validate cookie tenantId — user must actually be a member of that team.
if (tenantId) {
try {
const memberships = await adminTeams.listMemberships(tenantId);
const isMember = memberships.memberships.some((m) => m.userId === user.$id);
if (!isMember) {
// Stale or cross-account cookie — clear and re-resolve.
try { (await cookies()).delete(ACTIVE_TENANT_COOKIE); } catch { /* ignore */ }
tenantId = null;
}
} catch {
tenantId = null;
}
}
if (!tenantId) {
const userTeams = await getUserTeams();
tenantId = userTeams?.teams[0]?.$id ?? null;
}
if (!tenantId) return null;
let settings: TenantSettings | null = null;
try {
const { tablesDB } = createAdminClient();
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.tenantSettings,
+27 -2
View File
@@ -1,10 +1,10 @@
"use server";
import { ID, Permission, Role } from "node-appwrite";
import { ID, Permission, Role, Query } from "node-appwrite";
import { revalidatePath } from "next/cache";
import { customerSchema } from "@/lib/validation/customers";
import { DATABASE_ID, TABLES } from "./schema";
import { DATABASE_ID, TABLES, type CustomerStage } from "./schema";
import { createAdminClient } from "./server";
import { requireTenant } from "@/lib/appwrite/tenant-guard";
@@ -35,6 +35,10 @@ export async function createCustomerAction(
email: data.email,
phone: data.phone,
type: data.type,
stage: data.stage ?? "ilk_temas",
source: data.source,
nextFollowUpDate: data.nextFollowUpDate,
assigneeId: data.assigneeId,
notes: data.notes,
createdBy: ctx.user.id,
},
@@ -74,6 +78,10 @@ export async function updateCustomerAction(
email: data.email,
phone: data.phone,
type: data.type,
stage: data.stage,
source: data.source,
nextFollowUpDate: data.nextFollowUpDate,
assigneeId: data.assigneeId,
notes: data.notes,
});
} catch {
@@ -84,6 +92,23 @@ export async function updateCustomerAction(
return { ok: true };
}
export async function updateCustomerStageAction(
id: string,
stage: CustomerStage,
): Promise<ActionState> {
await requireTenant();
const { tablesDB } = createAdminClient();
try {
await tablesDB.updateRow(DATABASE_ID, TABLES.customers, id, { stage });
} catch {
return { ok: false, error: "Aşama güncellenemedi." };
}
revalidatePath("/customers");
return { ok: true };
}
export async function deleteCustomerAction(id: string): Promise<ActionState> {
await requireTenant();
const { tablesDB } = createAdminClient();
+158
View File
@@ -0,0 +1,158 @@
import "server-only";
import { Query } from "node-appwrite";
import { DATABASE_ID, TABLES, type Activity, type Customer, type Property } from "./schema";
import { createAdminClient } from "./server";
function buildSixMonthKeys() {
const months: Record<string, number> = {};
for (let i = 5; i >= 0; i--) {
const d = new Date();
d.setDate(1);
d.setMonth(d.getMonth() - i);
months[d.toLocaleString("tr-TR", { month: "short", year: "2-digit" })] = 0;
}
return months;
}
function buildMonthlyTrend(properties: Property[]) {
const months = buildSixMonthKeys();
for (const p of properties) {
const key = new Date(p.$createdAt).toLocaleString("tr-TR", { month: "short", year: "2-digit" });
if (key in months) months[key]++;
}
return Object.entries(months).map(([ay, ilanSayisi]) => ({ ay, ilanSayisi }));
}
function buildCustomerMonthlyTrend(customers: Customer[]) {
const months = buildSixMonthKeys();
for (const c of customers) {
const key = new Date(c.$createdAt).toLocaleString("tr-TR", { month: "short", year: "2-digit" });
if (key in months) months[key]++;
}
return Object.entries(months).map(([ay, musteriSayisi]) => ({ ay, musteriSayisi }));
}
function buildActivityMonthlyTrend(activities: Activity[]) {
const months = buildSixMonthKeys();
for (const a of activities) {
const key = new Date(a.$createdAt).toLocaleString("tr-TR", { month: "short", year: "2-digit" });
if (key in months) months[key]++;
}
return Object.entries(months).map(([ay, aktiviteSayisi]) => ({ ay, aktiviteSayisi }));
}
const PROPERTY_TYPE_LABELS: Record<string, string> = {
daire: "Daire",
villa: "Villa",
arsa: "Arsa",
dukkan: "Dükkan",
ofis: "Ofis",
depo: "Depo",
};
function buildPropertyDistribution(properties: Property[]) {
const counts: Record<string, number> = {};
for (const p of properties) {
counts[p.propertyType] = (counts[p.propertyType] ?? 0) + 1;
}
return Object.entries(counts)
.filter(([, n]) => n > 0)
.sort((a, b) => b[1] - a[1])
.map(([tip, sayi]) => ({ tip, label: PROPERTY_TYPE_LABELS[tip] ?? tip, sayi }));
}
export type DashboardStats = {
aktifIlanlar: number;
satilikAktif: number;
kiralikAktif: number;
toplamMusteri: number;
aliciMusteri: number;
kiraciMusteri: number;
yatirimciMusteri: number;
rezerveIlanlar: number;
bekleyenEslesmeler: number;
buAyIlanlar: number;
sonAktiviteler: Activity[];
sonIlanlar: Property[];
aylikTrend: { ay: string; ilanSayisi: number }[];
aylikMusteriTrend: { ay: string; musteriSayisi: number }[];
aylikAktiviteTrend: { ay: string; aktiviteSayisi: number }[];
portfoyDagilim: { tip: string; label: string; sayi: number }[];
takipMusteri: Customer[];
};
export async function getDashboardStats(tenantId: string): Promise<DashboardStats> {
const { tablesDB } = createAdminClient();
const today = new Date();
today.setHours(23, 59, 59, 999);
const thisMonthStart = new Date();
thisMonthStart.setDate(1);
thisMonthStart.setHours(0, 0, 0, 0);
const sixMonthsAgo = new Date();
sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6);
sixMonthsAgo.setDate(1);
sixMonthsAgo.setHours(0, 0, 0, 0);
const base = (extra: string[]) => [Query.equal("tenantId", tenantId), ...extra, Query.limit(1)];
const trend = (table: string) => [
Query.equal("tenantId", tenantId),
Query.greaterThanEqual("$createdAt", sixMonthsAgo.toISOString()),
Query.orderAsc("$createdAt"),
Query.limit(500),
];
const [
aktifRes, satilikRes, kiralikRes,
musteriRes, aliciRes, kiraciRes, yatirimciRes,
eslesmelerRes, buAyRes,
aktivitelerRes, ilanlarRes,
ilanTrendRes, musteriTrendRes, aktiviteTrendRes,
aktifPropAllRes,
takipRes, rezerveRes,
] = await Promise.all([
tablesDB.listRows({ databaseId: DATABASE_ID, tableId: TABLES.properties, queries: base([Query.equal("status", "aktif")]) }),
tablesDB.listRows({ databaseId: DATABASE_ID, tableId: TABLES.properties, queries: base([Query.equal("status", "aktif"), Query.equal("listingType", "satilik")]) }),
tablesDB.listRows({ databaseId: DATABASE_ID, tableId: TABLES.properties, queries: base([Query.equal("status", "aktif"), Query.equal("listingType", "kiralik")]) }),
tablesDB.listRows({ databaseId: DATABASE_ID, tableId: TABLES.customers, queries: base([]) }),
tablesDB.listRows({ databaseId: DATABASE_ID, tableId: TABLES.customers, queries: base([Query.equal("type", "alici")]) }),
tablesDB.listRows({ databaseId: DATABASE_ID, tableId: TABLES.customers, queries: base([Query.equal("type", "kiraci")]) }),
tablesDB.listRows({ databaseId: DATABASE_ID, tableId: TABLES.customers, queries: base([Query.equal("type", "yatirimci")]) }),
tablesDB.listRows({ databaseId: DATABASE_ID, tableId: TABLES.propertyMatches, queries: base([Query.equal("notified", false)]) }),
tablesDB.listRows({ databaseId: DATABASE_ID, tableId: TABLES.properties, queries: base([Query.greaterThanEqual("$createdAt", thisMonthStart.toISOString())]) }),
tablesDB.listRows({ databaseId: DATABASE_ID, tableId: TABLES.activities, queries: [Query.equal("tenantId", tenantId), Query.orderDesc("$createdAt"), Query.limit(6)] }),
tablesDB.listRows({ databaseId: DATABASE_ID, tableId: TABLES.properties, queries: [Query.equal("tenantId", tenantId), Query.orderDesc("$createdAt"), Query.limit(5)] }),
tablesDB.listRows({ databaseId: DATABASE_ID, tableId: TABLES.properties, queries: trend(TABLES.properties) }),
tablesDB.listRows({ databaseId: DATABASE_ID, tableId: TABLES.customers, queries: trend(TABLES.customers) }),
tablesDB.listRows({ databaseId: DATABASE_ID, tableId: TABLES.activities, queries: trend(TABLES.activities) }),
tablesDB.listRows({ databaseId: DATABASE_ID, tableId: TABLES.properties, queries: [Query.equal("tenantId", tenantId), Query.equal("status", "aktif"), Query.limit(500)] }),
tablesDB.listRows({ databaseId: DATABASE_ID, tableId: TABLES.customers, queries: [Query.equal("tenantId", tenantId), Query.lessThanEqual("nextFollowUpDate", today.toISOString()), Query.orderAsc("nextFollowUpDate"), Query.limit(10)] }),
tablesDB.listRows({ databaseId: DATABASE_ID, tableId: TABLES.properties, queries: base([Query.equal("status", "rezerve")]) }),
]);
const parse = <T>(res: { rows: unknown[] }) => JSON.parse(JSON.stringify(res.rows)) as T[];
return {
aktifIlanlar: aktifRes.total,
satilikAktif: satilikRes.total,
kiralikAktif: kiralikRes.total,
toplamMusteri: musteriRes.total,
aliciMusteri: aliciRes.total,
kiraciMusteri: kiraciRes.total,
yatirimciMusteri: yatirimciRes.total,
bekleyenEslesmeler: eslesmelerRes.total,
buAyIlanlar: buAyRes.total,
rezerveIlanlar: rezerveRes.total,
sonAktiviteler: parse<Activity>(aktivitelerRes),
sonIlanlar: parse<Property>(ilanlarRes),
aylikTrend: buildMonthlyTrend(parse<Property>(ilanTrendRes)),
aylikMusteriTrend: buildCustomerMonthlyTrend(parse<Customer>(musteriTrendRes)),
aylikAktiviteTrend: buildActivityMonthlyTrend(parse<Activity>(aktiviteTrendRes)),
portfoyDagilim: buildPropertyDistribution(parse<Property>(aktifPropAllRes)),
takipMusteri: parse<Customer>(takipRes),
};
}
+187
View File
@@ -0,0 +1,187 @@
"use server";
import { ID, Permission, Role, Query } from "node-appwrite";
import { revalidatePath } from "next/cache";
import { dealSchema } from "@/lib/validation/deals";
import { DATABASE_ID, TABLES, type DealStatus, type Customer } from "./schema";
import { createAdminClient } from "./server";
import { requireTenant, requireRole } from "@/lib/appwrite/tenant-guard";
async function resolveCustomerName(tablesDB: ReturnType<typeof createAdminClient>["tablesDB"], customerId?: string): Promise<string | undefined> {
if (!customerId) return undefined;
try {
const row = await tablesDB.getRow(DATABASE_ID, TABLES.customers, customerId);
return (row as unknown as Customer).name;
} catch {
return undefined;
}
}
type ActionState = { ok: boolean; error?: string; fieldErrors?: Record<string, string[]> };
export async function createDealAction(
_prev: ActionState,
formData: FormData,
): Promise<ActionState> {
const ctx = await requireTenant();
const raw = Object.fromEntries(formData.entries());
const parsed = dealSchema.safeParse(raw);
if (!parsed.success) {
return { ok: false, fieldErrors: parsed.error.flatten().fieldErrors };
}
const { tablesDB } = createAdminClient();
const data = parsed.data;
const customerName = await resolveCustomerName(tablesDB, data.customerId);
try {
await tablesDB.createRow(
DATABASE_ID,
TABLES.deals,
ID.unique(),
{
tenantId: ctx.tenantId,
propertyId: data.propertyId,
customerId: data.customerId,
propertyTitle: data.propertyTitle,
customerName,
type: data.type,
status: "bekleyen",
salePrice: data.salePrice,
commissionRate: data.commissionRate,
commissionAmount: data.commissionAmount,
officeSharePercent: data.officeSharePercent,
agentSharePercent: data.agentSharePercent,
referralName: data.referralName || null,
referralPhone: data.referralPhone || null,
referralPercent: data.referralPercent ?? null,
agentId: ctx.user.id,
agentName: ctx.user.name,
closingDate: data.closingDate,
notes: data.notes,
createdBy: ctx.user.id,
},
[
Permission.read(Role.team(ctx.tenantId)),
Permission.update(Role.team(ctx.tenantId)),
Permission.delete(Role.team(ctx.tenantId, "owner")),
Permission.delete(Role.team(ctx.tenantId, "admin")),
],
);
} catch {
return { ok: false, error: "İşlem kaydedilemedi." };
}
revalidatePath("/finance");
return { ok: true };
}
export async function updateDealAction(
id: string,
_prev: ActionState,
formData: FormData,
): Promise<ActionState> {
const ctx = await requireTenant();
const raw = Object.fromEntries(formData.entries());
const parsed = dealSchema.safeParse(raw);
if (!parsed.success) {
return { ok: false, fieldErrors: parsed.error.flatten().fieldErrors };
}
const { tablesDB } = createAdminClient();
const data = parsed.data;
const customerName = await resolveCustomerName(tablesDB, data.customerId);
// Agents can only edit their own deals; owners/admins can edit all
if (ctx.role === "member") {
const existing = await tablesDB.getRow(DATABASE_ID, TABLES.deals, id);
if ((existing as unknown as { agentId: string }).agentId !== ctx.user.id) {
return { ok: false, error: "Bu işlemi düzenleme yetkiniz yok." };
}
}
try {
await tablesDB.updateRow(DATABASE_ID, TABLES.deals, id, {
propertyId: data.propertyId,
customerId: data.customerId,
propertyTitle: data.propertyTitle,
customerName,
type: data.type,
salePrice: data.salePrice,
commissionRate: data.commissionRate,
commissionAmount: data.commissionAmount,
officeSharePercent: data.officeSharePercent,
agentSharePercent: data.agentSharePercent,
referralName: data.referralName || null,
referralPhone: data.referralPhone || null,
referralPercent: data.referralPercent ?? null,
closingDate: data.closingDate,
notes: data.notes,
});
} catch {
return { ok: false, error: "İşlem güncellenemedi." };
}
revalidatePath("/finance");
return { ok: true };
}
export async function updateDealStatusAction(
id: string,
status: DealStatus,
): Promise<ActionState> {
const ctx = await requireTenant();
requireRole(ctx, ["owner", "admin"]);
const { tablesDB } = createAdminClient();
try {
await tablesDB.updateRow(DATABASE_ID, TABLES.deals, id, { status });
} catch {
return { ok: false, error: "Durum güncellenemedi." };
}
revalidatePath("/finance");
return { ok: true };
}
export async function deleteDealAction(id: string): Promise<ActionState> {
const ctx = await requireTenant();
requireRole(ctx, ["owner", "admin"]);
const { tablesDB } = createAdminClient();
try {
await tablesDB.deleteRow(DATABASE_ID, TABLES.deals, id);
} catch {
return { ok: false, error: "İşlem silinemedi." };
}
revalidatePath("/finance");
return { ok: true };
}
export async function getDealsAction() {
const ctx = await requireTenant();
const { tablesDB } = createAdminClient();
const queries = [
Query.equal("tenantId", ctx.tenantId),
Query.orderDesc("$createdAt"),
Query.limit(200),
];
// Members only see their own deals
if (ctx.role === "member") {
queries.push(Query.equal("agentId", ctx.user.id));
}
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.deals,
queries,
});
return result.rows;
}
+43
View File
@@ -0,0 +1,43 @@
"use server";
import { revalidatePath } from "next/cache";
import { DATABASE_ID, TABLES } from "./schema";
import { createAdminClient } from "./server";
import { requireTenant } from "./tenant-guard";
type ActionState = { ok: boolean; error?: string };
export async function markMatchNotifiedAction(id: string): Promise<ActionState> {
await requireTenant();
const { tablesDB } = createAdminClient();
try {
await tablesDB.updateRow(DATABASE_ID, TABLES.propertyMatches, id, {
notified: true,
});
} catch {
return { ok: false, error: "Güncellenemedi." };
}
revalidatePath("/customers/matches");
return { ok: true };
}
export async function markAllNotifiedAction(ids: string[]): Promise<ActionState> {
await requireTenant();
const { tablesDB } = createAdminClient();
try {
await Promise.all(
ids.map((id) =>
tablesDB.updateRow(DATABASE_ID, TABLES.propertyMatches, id, { notified: true }),
),
);
} catch {
return { ok: false, error: "Toplu güncelleme başarısız." };
}
revalidatePath("/customers/matches");
return { ok: true };
}
+29
View File
@@ -0,0 +1,29 @@
import type { TenantPlan } from "./schema";
export type PlanResource = "properties" | "customers" | "members" | "presentations";
export const PLAN_LIMIT_EXCEEDED = "PLAN_LIMIT_EXCEEDED";
const INF = Number.POSITIVE_INFINITY;
export const PLAN_LIMITS: Record<TenantPlan, Record<PlanResource, number>> = {
free: {
properties: 5,
customers: 10,
members: 2,
presentations: 3,
},
pro: {
properties: INF,
customers: INF,
members: INF,
presentations: INF,
},
};
export const RESOURCE_LABELS: Record<PlanResource, string> = {
properties: "ilan",
customers: "müşteri",
members: "ekip üyesi",
presentations: "sunum",
};
+10 -27
View File
@@ -3,37 +3,20 @@ import "server-only";
import { Query } from "node-appwrite";
import { createAdminClient } from "./server";
import { DATABASE_ID, TABLES, type TenantPlan } from "./schema";
import { DATABASE_ID, TABLES } from "./schema";
import type { TenantContext } from "./tenant-guard";
import type { TenantPlan } from "./schema";
import {
PLAN_LIMITS,
RESOURCE_LABELS,
type PlanResource,
} from "./plan-limits-shared";
export type PlanResource = "properties" | "customers" | "members" | "presentations";
export const PLAN_LIMIT_EXCEEDED = "PLAN_LIMIT_EXCEEDED";
export type { PlanResource } from "./plan-limits-shared";
export { PLAN_LIMIT_EXCEEDED, PLAN_LIMITS, RESOURCE_LABELS } from "./plan-limits-shared";
const INF = Number.POSITIVE_INFINITY;
export const PLAN_LIMITS: Record<TenantPlan, Record<PlanResource, number>> = {
free: {
properties: 5,
customers: 10,
members: 2,
presentations: 3,
},
pro: {
properties: INF,
customers: INF,
members: INF,
presentations: INF,
},
};
export const RESOURCE_LABELS: Record<PlanResource, string> = {
properties: "ilan",
customers: "müşteri",
members: "ekip üyesi",
presentations: "sunum",
};
export function getEffectivePlan(ctx: TenantContext): TenantPlan {
const plan = (ctx.settings?.plan as TenantPlan | undefined) ?? "free";
if (plan === "pro") {
@@ -84,7 +67,7 @@ export async function getPlanUsage(ctx: TenantContext): Promise<PlanUsage> {
}
export class PlanLimitError extends Error {
code = PLAN_LIMIT_EXCEEDED;
code = "PLAN_LIMIT_EXCEEDED";
constructor(public resource: PlanResource, public limit: number) {
super(`Plan limit reached for ${resource} (${limit})`);
}
+19
View File
@@ -128,6 +128,25 @@ export async function updatePropertyAction(
return { ok: true };
}
export async function updatePropertyImagesAction(
propertyId: string,
imageIds: string[],
): Promise<ActionState> {
await requireTenant();
const { tablesDB } = createAdminClient();
try {
await tablesDB.updateRow(DATABASE_ID, TABLES.properties, propertyId, {
imageIds: JSON.stringify(imageIds),
});
} catch {
return { ok: false, error: "Fotoğraflar kaydedilemedi." };
}
revalidatePath(`/properties/${propertyId}`);
return { ok: true };
}
export async function deletePropertyAction(id: string): Promise<ActionState> {
await requireTenant();
const { tablesDB } = createAdminClient();
+61 -1
View File
@@ -15,6 +15,7 @@ export const TABLES = {
activities: "activities",
tenantSettings: "tenant_settings",
inviteLinks: "invite_links",
deals: "deals",
} as const;
export type TableId = (typeof TABLES)[keyof typeof TABLES];
@@ -49,9 +50,13 @@ export interface InviteLink extends SystemRow {
export type PropertyType = "daire" | "villa" | "arsa" | "dukkan" | "ofis" | "depo";
export type ListingType = "satilik" | "kiralik";
export type PropertyStatus = "aktif" | "pasif" | "satildi" | "kiralandit";
export type PropertyStatus = "aktif" | "rezerve" | "pasif" | "satildi" | "kiralandit";
export type CustomerType = "alici" | "kiraci" | "yatirimci";
export type CustomerStage = "ilk_temas" | "aktif_arama" | "teklif" | "sozlesme" | "kapandi";
export type CustomerSource = "referans" | "ilan" | "instagram" | "sahibinden" | "diger";
export type ActivityType = "gorusme" | "teklif" | "ziyaret" | "arama" | "not";
export type DealType = "satis" | "kiralama";
export type DealStatus = "bekleyen" | "tahsil_edildi" | "iptal";
export interface Property extends Row {
tenantId: string;
@@ -86,6 +91,10 @@ export interface Customer extends Row {
email?: string;
phone?: string;
type: CustomerType;
stage?: CustomerStage;
source?: CustomerSource;
nextFollowUpDate?: string;
assigneeId?: string;
notes?: string;
createdBy: string;
}
@@ -149,6 +158,29 @@ export interface Investor extends Row {
createdBy: string;
}
export interface Deal extends Row {
tenantId: string;
propertyId?: string;
customerId?: string;
propertyTitle?: string;
customerName?: string;
type: DealType;
status?: DealStatus;
salePrice: number;
commissionRate: number;
commissionAmount: number;
officeSharePercent?: number;
agentSharePercent?: number;
referralName?: string;
referralPhone?: string;
referralPercent?: number;
agentId: string;
agentName: string;
closingDate?: string;
notes?: string;
createdBy: string;
}
export interface Activity extends Row {
tenantId: string;
customerId?: string;
@@ -218,17 +250,45 @@ export const LISTING_TYPE_LABELS: Record<ListingType, string> = {
export const PROPERTY_STATUS_LABELS: Record<PropertyStatus, string> = {
aktif: "Aktif",
rezerve: "Rezerve",
pasif: "Pasif",
satildi: "Satıldı",
kiralandit: "Kiralandı",
};
export const CUSTOMER_STAGE_LABELS: Record<CustomerStage, string> = {
ilk_temas: "İlk Temas",
aktif_arama: "Aktif Arama",
teklif: "Teklif",
sozlesme: "Sözleşme",
kapandi: "Kapandı",
};
export const CUSTOMER_SOURCE_LABELS: Record<CustomerSource, string> = {
referans: "Referans",
ilan: "İlan",
instagram: "InstagramLogo",
sahibinden: "Sahibinden",
diger: "Diğer",
};
export const CUSTOMER_TYPE_LABELS: Record<CustomerType, string> = {
alici: "Alıcı",
kiraci: "Kiracı",
yatirimci: "Yatırımcı",
};
export const DEAL_TYPE_LABELS: Record<DealType, string> = {
satis: "Satış",
kiralama: "Kiralama",
};
export const DEAL_STATUS_LABELS: Record<DealStatus, string> = {
bekleyen: "Bekleyen",
tahsil_edildi: "Tahsil Edildi",
iptal: "İptal",
};
export const ACTIVITY_TYPE_LABELS: Record<ActivityType, string> = {
gorusme: "Görüşme",
teklif: "Teklif",
-34
View File
@@ -1,45 +1,11 @@
"use server";
import { ID, Permission, Role } from "node-appwrite";
import { InputFile } from "node-appwrite/file";
import { BUCKETS } from "./schema";
import { createAdminClient } from "./server";
import { requireTenant } from "./tenant-guard";
type UploadResult = { ok: boolean; fileId?: string; error?: string };
type DeleteResult = { ok: boolean; error?: string };
export async function uploadPropertyImageAction(formData: FormData): Promise<UploadResult> {
const ctx = await requireTenant();
const file = formData.get("file") as File | null;
if (!file) return { ok: false, error: "Dosya seçilmedi." };
const { storage } = createAdminClient();
try {
const buffer = Buffer.from(await file.arrayBuffer());
const inputFile = InputFile.fromBuffer(buffer, file.name);
const result = await storage.createFile(
BUCKETS.propertyImages,
ID.unique(),
inputFile,
[
Permission.read(Role.any()),
Permission.update(Role.team(ctx.tenantId)),
Permission.delete(Role.team(ctx.tenantId, "owner")),
Permission.delete(Role.team(ctx.tenantId, "admin")),
],
);
return { ok: true, fileId: result.$id };
} catch (e) {
console.error("Image upload error:", e);
return { ok: false, error: "Fotoğraf yüklenemedi." };
}
}
export async function deletePropertyImageAction(fileId: string): Promise<DeleteResult> {
await requireTenant();
const { storage } = createAdminClient();
+47 -20
View File
@@ -24,34 +24,61 @@ function pickHighestRole(roles: string[]): TenantRole | null {
return null;
}
async function resolveFirstValidTenantId(userId: string): Promise<string | null> {
try {
const { users, tablesDB } = createAdminClient();
const memberships = await users.listMemberships(userId);
if (memberships.total === 0) return null;
const teamIds = memberships.memberships.map((m) => m.teamId);
const settings = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.tenantSettings,
queries: [Query.equal("tenantId", teamIds), Query.limit(1)],
});
return (settings.rows[0] as unknown as { tenantId: string })?.tenantId ?? null;
} catch {
return null;
}
}
async function setTenantCookie(tenantId: string) {
try {
(await cookies()).set(ACTIVE_TENANT_COOKIE, tenantId, {
path: "/",
httpOnly: true,
sameSite: "strict",
secure: process.env.NODE_ENV === "production",
maxAge: 60 * 60 * 24 * 365,
});
} catch { /* ignore */ }
}
export async function requireTenant(): Promise<TenantContext> {
const user = await getCurrentUser();
if (!user) throw new Error("UNAUTHENTICATED");
let tenantId = await getActiveTenantId();
if (!tenantId) {
// Fallback: pick the user's first team (handles invite acceptees and
// sessions where the active-tenant cookie/prefs weren't set yet).
const userTeams = await getUserTeams();
tenantId = userTeams?.teams[0]?.$id ?? null;
if (tenantId) {
try {
(await cookies()).set(ACTIVE_TENANT_COOKIE, tenantId, {
path: "/",
httpOnly: true,
sameSite: "strict",
secure: process.env.NODE_ENV === "production",
maxAge: 60 * 60 * 24 * 365,
});
} catch {
/* setting cookie can fail in some Server Component paths; ignore */
}
}
}
if (!tenantId) throw new Error("NO_TENANT");
const { tablesDB, teams } = createAdminClient();
// If we have a tenantId from cookie/prefs, verify the team still exists.
if (tenantId) {
try {
await teams.get(tenantId);
} catch {
// Team was deleted — clear stale pointer and fall through to resolution.
try { (await cookies()).delete(ACTIVE_TENANT_COOKIE); } catch { /* ignore */ }
tenantId = null;
}
}
if (!tenantId) {
tenantId = await resolveFirstValidTenantId(user.$id);
if (tenantId) await setTenantCookie(tenantId);
}
if (!tenantId) throw new Error("NO_TENANT");
const memberships = await teams.listMemberships(tenantId);
const membership = memberships.memberships.find((m) => m.userId === user.$id);
if (!membership) throw new Error("NOT_A_MEMBER");