feat: weighted match scoring, photo upload, property detail page

- scoring.ts: pure scoreMatch + scoreMatchBreakdown with per-criterion weights
- matching.ts: soft scoring (0-100), updates score on re-sync, threshold 20
- search-form-sheet: weight selectors (1-5) per criterion
- customer-search-actions: save/update weight fields
- storage-actions: upload/delete property images to property-images bucket
- storage-utils: getPropertyImagePreviewUrl, parseImageIds helpers
- property-image-uploader: client component with preview grid + delete
- property-form-sheet: integrated image uploader
- properties/[id]: detail page with gallery, specs, matches sidebar
- properties-client: Detay link in dropdown
- matches page: MatchesClient with click-to-breakdown dialog
- sunum page: cover image from first imageIds entry
- matches-client + match-breakdown-dialog: score breakdown per criterion
This commit is contained in:
egecankomur
2026-05-05 19:55:34 +03:00
parent 3d044c5d5b
commit a40e68254b
22 changed files with 1105 additions and 169 deletions
+2 -2
View File
@@ -12,12 +12,12 @@ export async function listCustomers(tenantId: string): Promise<Customer[]> {
tableId: TABLES.customers,
queries: [Query.equal("tenantId", tenantId), Query.orderDesc("$createdAt"), Query.limit(500)],
});
return result.rows as unknown as Customer[];
return JSON.parse(JSON.stringify(result.rows)) as Customer[];
}
export async function getCustomer(id: string, tenantId: string): Promise<Customer | null> {
const { tablesDB } = createAdminClient();
const row = (await tablesDB.getRow(DATABASE_ID, TABLES.customers, id)) as unknown as Customer;
if (row.tenantId !== tenantId) return null;
return row;
return JSON.parse(JSON.stringify(row)) as Customer;
}
@@ -70,6 +70,11 @@ export async function createCustomerSearchAction(
districts: toJsonList(data.districts),
isActive: true,
notes: data.notes,
priceWeight: data.priceWeight,
m2Weight: data.m2Weight,
locationWeight: data.locationWeight,
roomCountWeight: data.roomCountWeight,
propertyTypeWeight: data.propertyTypeWeight,
createdBy: ctx.user.id,
},
[
@@ -117,6 +122,11 @@ export async function updateCustomerSearchAction(
cities: toJsonList(data.cities),
districts: toJsonList(data.districts),
notes: data.notes,
priceWeight: data.priceWeight,
m2Weight: data.m2Weight,
locationWeight: data.locationWeight,
roomCountWeight: data.roomCountWeight,
propertyTypeWeight: data.propertyTypeWeight,
});
await syncMatchesForSearch(ctx.tenantId, ctx.user.id);
+30 -72
View File
@@ -4,53 +4,11 @@ import { ID, Permission, Query, Role } from "node-appwrite";
import { DATABASE_ID, TABLES, type Property, type CustomerSearch } from "./schema";
import { createAdminClient } from "./server";
import { scoreMatch } from "@/lib/scoring";
function priceMatches(price: number, min?: number | null, max?: number | null): boolean {
if (min != null && price < min) return false;
if (max != null && price > max) return false;
return true;
}
export { scoreMatch };
function m2Matches(m2?: number | null, min?: number | null, max?: number | null): boolean {
if (!m2) return true;
if (min != null && m2 < min) return false;
if (max != null && m2 > max) return false;
return true;
}
function listMatches(value: string, jsonList?: string | null): boolean {
if (!jsonList) return true;
try {
const arr = JSON.parse(jsonList) as string[];
if (!arr.length) return true;
return arr.includes(value);
} catch {
return true;
}
}
function cityMatches(city: string, jsonCities?: string | null): boolean {
if (!jsonCities) return true;
try {
const arr = JSON.parse(jsonCities) as string[];
if (!arr.length) return true;
return arr.some((c) => c.toLowerCase() === city.toLowerCase());
} catch {
return true;
}
}
function districtMatches(district?: string | null, jsonDistricts?: string | null): boolean {
if (!jsonDistricts) return true;
try {
const arr = JSON.parse(jsonDistricts) as string[];
if (!arr.length) return true;
if (!district) return false;
return arr.some((d) => d.toLowerCase() === district.toLowerCase());
} catch {
return true;
}
}
const SCORE_THRESHOLD = 20;
export async function matchPropertyToSearches(
property: Property,
@@ -75,14 +33,10 @@ export async function matchPropertyToSearches(
for (const search of searches) {
if (search.listingType && search.listingType !== property.listingType) continue;
if (!listMatches(property.propertyType, search.propertyTypes)) continue;
if (property.roomCount && !listMatches(property.roomCount, search.roomCounts)) continue;
if (!priceMatches(property.price, search.minPrice, search.maxPrice)) continue;
if (!m2Matches(property.netM2, search.minM2, search.maxM2)) continue;
if (!cityMatches(property.city, search.cities)) continue;
if (!districtMatches(property.district, search.districts)) continue;
// duplicate kontrolü
const score = scoreMatch(property, search);
if (score < SCORE_THRESHOLD) continue;
const existing = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.propertyMatches,
@@ -92,26 +46,30 @@ export async function matchPropertyToSearches(
Query.limit(1),
],
});
if (existing.rows.length > 0) continue;
await tablesDB.createRow(
DATABASE_ID,
TABLES.propertyMatches,
ID.unique(),
{
tenantId,
propertyId: property.$id,
customerId: search.customerId,
searchId: search.$id,
notified: false,
createdBy,
},
[
Permission.read(Role.team(tenantId)),
Permission.update(Role.team(tenantId)),
Permission.delete(Role.team(tenantId, "owner")),
Permission.delete(Role.team(tenantId, "admin")),
],
);
if (existing.rows.length > 0) {
await tablesDB.updateRow(DATABASE_ID, TABLES.propertyMatches, existing.rows[0].$id, { score });
} else {
await tablesDB.createRow(
DATABASE_ID,
TABLES.propertyMatches,
ID.unique(),
{
tenantId,
propertyId: property.$id,
customerId: search.customerId,
searchId: search.$id,
notified: false,
score,
createdBy,
},
[
Permission.read(Role.team(tenantId)),
Permission.update(Role.team(tenantId)),
Permission.delete(Role.team(tenantId, "owner")),
Permission.delete(Role.team(tenantId, "admin")),
],
);
}
}
}
+2 -2
View File
@@ -12,12 +12,12 @@ export async function listProperties(tenantId: string): Promise<Property[]> {
tableId: TABLES.properties,
queries: [Query.equal("tenantId", tenantId), Query.orderDesc("$createdAt"), Query.limit(200)],
});
return result.rows as unknown as Property[];
return JSON.parse(JSON.stringify(result.rows)) as Property[];
}
export async function getProperty(id: string, tenantId: string): Promise<Property | null> {
const { tablesDB } = createAdminClient();
const row = (await tablesDB.getRow(DATABASE_ID, TABLES.properties, id)) as unknown as Property;
if (row.tenantId !== tenantId) return null;
return row;
return JSON.parse(JSON.stringify(row)) as Property;
}
+7
View File
@@ -105,6 +105,12 @@ export interface CustomerSearch extends Row {
isActive?: boolean;
notes?: string;
createdBy: string;
// ağırlıklar: 1=önemsiz … 5=çok önemli
priceWeight?: number;
m2Weight?: number;
locationWeight?: number;
roomCountWeight?: number;
propertyTypeWeight?: number;
}
export interface PropertyMatch extends Row {
@@ -114,6 +120,7 @@ export interface PropertyMatch extends Row {
searchId: string;
notified?: boolean;
viewedAt?: string;
score?: number;
createdBy: string;
}
+53
View File
@@ -0,0 +1,53 @@
"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();
try {
await storage.deleteFile(BUCKETS.propertyImages, fileId);
return { ok: true };
} catch {
return { ok: false, error: "Fotoğraf silinemedi." };
}
}
+21
View File
@@ -0,0 +1,21 @@
export function getPropertyImageUrl(fileId: string): string {
const endpoint = (process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT ?? "").replace(/\/$/, "");
const projectId = process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID ?? "";
return `${endpoint}/storage/buckets/property-images/files/${fileId}/view?project=${projectId}`;
}
export function getPropertyImagePreviewUrl(fileId: string, width = 800, height = 600): string {
const endpoint = (process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT ?? "").replace(/\/$/, "");
const projectId = process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID ?? "";
return `${endpoint}/storage/buckets/property-images/files/${fileId}/preview?project=${projectId}&width=${width}&height=${height}&quality=85`;
}
export function parseImageIds(imageIds?: string | null): string[] {
if (!imageIds) return [];
try {
const arr = JSON.parse(imageIds);
return Array.isArray(arr) ? arr : [];
} catch {
return [];
}
}