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:
@@ -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);
|
||||
|
||||
@@ -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")),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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." };
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user