fix(auth): SDK'yı kaldırıp ince REST katmanına geç (lib/appwrite-rest.ts)

Sorun:
- node-appwrite v20-25 hepsi Node 26'da bozuk (node-fetch-native-with-agent polyfill)
- appwrite browser SDK Server Action context'inde 'unexpected response' veriyor
  (büyük olasılıkla browser-only global'ları kontrol ederken)

Çözüm:
- Tüm Appwrite SDK'larını sil (appwrite + node-appwrite)
- lib/appwrite-rest.ts: native fetch üzerinde ~250 satırlık ince REST wrapper
  - account: createEmailPasswordSession, get, deleteSession
  - tablesDB: listRows, getRow, createRow, updateRow, deleteRow
  - storage: listFiles, createFile, deleteFile, fileViewUrl
  - Q helpers: equal, orderAsc/Desc, limit, offset
  - AppwriteError class
- Session secret cookie tabanlı auth korundu (isletmem-kovakcrm'deki desen)
- Tüm CRUD action ve query'ler REST katmanına bağlandı

End-to-end test edildi:
✓ Login (proper 401 hata mesajları, başarılı durumda redirect)
✓ Public read (services, blog, testimonials)
✓ Anonim create (contact form)
✓ Build (24 route)
✓ Sıfır SDK bağımlılığı — Node 26 sorunu yok
This commit is contained in:
Ege Can Komur
2026-05-20 02:29:19 +03:00
parent 4096b3d87b
commit 7eb0c1acc2
18 changed files with 503 additions and 333 deletions
+12 -15
View File
@@ -1,7 +1,6 @@
"use server"; "use server";
import { ID } from "appwrite"; import { DATABASE_ID, ID, TABLES, tablesDB } from "@/lib/appwrite-rest";
import { publicDB, DATABASE_ID, TABLES } from "@/lib/appwrite-server";
export type ContactFormState = { export type ContactFormState = {
ok: boolean; ok: boolean;
@@ -34,20 +33,18 @@ export async function submitContact(
} }
try { try {
await publicDB.createRow({ await tablesDB.createRow(DATABASE_ID, TABLES.contactMessages, ID.unique(), {
databaseId: DATABASE_ID, name,
tableId: TABLES.contactMessages, email,
rowId: ID.unique(), phone: phone || null,
data: { subject: subject || null,
name, message,
email, status: "new",
phone: phone || null,
subject: subject || null,
message,
status: "new",
},
}); });
return { ok: true, message: "Mesajınız iletildi. En kısa sürede dönüş yapacağız." }; return {
ok: true,
message: "Mesajınız iletildi. En kısa sürede dönüş yapacağız.",
};
} catch (err) { } catch (err) {
const detail = err instanceof Error ? err.message : "Bilinmeyen hata"; const detail = err instanceof Error ? err.message : "Bilinmeyen hata";
return { ok: false, message: `Kayıt başarısız: ${detail}` }; return { ok: false, message: `Kayıt başarısız: ${detail}` };
@@ -1,6 +1,6 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { getRow } from "@/lib/data"; import { getRow } from "@/lib/data";
import { TABLES } from "@/lib/appwrite-server"; import { TABLES } from "@/lib/appwrite-rest";
import type { BlogPostRow } from "@/lib/types"; import type { BlogPostRow } from "@/lib/types";
import { BlogForm } from "../../form"; import { BlogForm } from "../../form";
@@ -1,6 +1,6 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { getRow } from "@/lib/data"; import { getRow } from "@/lib/data";
import { TABLES } from "@/lib/appwrite-server"; import { TABLES } from "@/lib/appwrite-rest";
import type { ServiceRow } from "@/lib/types"; import type { ServiceRow } from "@/lib/types";
import { ServiceForm } from "../../form"; import { ServiceForm } from "../../form";
+7 -7
View File
@@ -1,7 +1,6 @@
import { PageHeader } from "@/components/admin/form"; import { PageHeader } from "@/components/admin/form";
import { MEDIA_BUCKET_ID, userStorage } from "@/lib/appwrite-server"; import { MEDIA_BUCKET_ID, Q, storage } from "@/lib/appwrite-rest";
import { requireSessionSecret } from "@/lib/auth"; import { requireSessionSecret } from "@/lib/auth";
import { Query } from "appwrite";
import { Upload } from "lucide-react"; import { Upload } from "lucide-react";
import { DeleteButton } from "@/components/admin/delete-button"; import { DeleteButton } from "@/components/admin/delete-button";
import { uploadMedia, deleteMediaFile } from "@/lib/admin-actions"; import { uploadMedia, deleteMediaFile } from "@/lib/admin-actions";
@@ -9,10 +8,11 @@ import { uploadMedia, deleteMediaFile } from "@/lib/admin-actions";
async function listFiles() { async function listFiles() {
try { try {
const secret = await requireSessionSecret(); const secret = await requireSessionSecret();
const res = await userStorage(secret).listFiles({ const res = await storage.listFiles(
bucketId: MEDIA_BUCKET_ID, MEDIA_BUCKET_ID,
queries: [Query.orderDesc("$createdAt"), Query.limit(100)], [Q.orderDesc("$createdAt"), Q.limit(100)],
}); secret,
);
return res.files; return res.files;
} catch { } catch {
return []; return [];
@@ -20,7 +20,7 @@ async function listFiles() {
} }
function fileViewUrl(id: string) { function fileViewUrl(id: string) {
return `${process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT}/storage/buckets/${MEDIA_BUCKET_ID}/files/${id}/view?project=${process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID}`; return storage.fileViewUrl(MEDIA_BUCKET_ID, id);
} }
export default async function MediaPage() { export default async function MediaPage() {
+9 -9
View File
@@ -1,17 +1,17 @@
import Link from "next/link"; import Link from "next/link";
import { ArrowRight } from "lucide-react"; import { ArrowRight } from "lucide-react";
import { Query } from "appwrite"; import { DATABASE_ID, Q, TABLES, tablesDB } from "@/lib/appwrite-rest";
import { DATABASE_ID, TABLES, userDB } from "@/lib/appwrite-server";
import { requireSessionSecret } from "@/lib/auth"; import { requireSessionSecret } from "@/lib/auth";
async function safeCount(tableId: string, queries: string[] = []) { async function safeCount(tableId: string, queries: string[] = []) {
try { try {
const secret = await requireSessionSecret(); const secret = await requireSessionSecret();
const res = await userDB(secret).listRows({ const res = await tablesDB.listRows(
databaseId: DATABASE_ID, DATABASE_ID,
tableId, tableId,
queries: [...queries, Query.limit(1)], [...queries, Q.limit(1)],
}); secret,
);
return res.total ?? 0; return res.total ?? 0;
} catch { } catch {
return 0; return 0;
@@ -21,12 +21,12 @@ async function safeCount(tableId: string, queries: string[] = []) {
export default async function AdminDashboard() { export default async function AdminDashboard() {
const [posts, drafts, services, projects, testimonials, newMessages, totalMessages] = const [posts, drafts, services, projects, testimonials, newMessages, totalMessages] =
await Promise.all([ await Promise.all([
safeCount(TABLES.blogPosts, [Query.equal("status", "published")]), safeCount(TABLES.blogPosts, [Q.equal("status", "published")]),
safeCount(TABLES.blogPosts, [Query.equal("status", "draft")]), safeCount(TABLES.blogPosts, [Q.equal("status", "draft")]),
safeCount(TABLES.services), safeCount(TABLES.services),
safeCount(TABLES.projects), safeCount(TABLES.projects),
safeCount(TABLES.testimonials), safeCount(TABLES.testimonials),
safeCount(TABLES.contactMessages, [Query.equal("status", "new")]), safeCount(TABLES.contactMessages, [Q.equal("status", "new")]),
safeCount(TABLES.contactMessages), safeCount(TABLES.contactMessages),
]); ]);
@@ -1,6 +1,6 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { getRow } from "@/lib/data"; import { getRow } from "@/lib/data";
import { TABLES } from "@/lib/appwrite-server"; import { TABLES } from "@/lib/appwrite-rest";
import type { ProjectRow } from "@/lib/types"; import type { ProjectRow } from "@/lib/types";
import { ProjectForm } from "../../form"; import { ProjectForm } from "../../form";
@@ -1,6 +1,6 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { getRow } from "@/lib/data"; import { getRow } from "@/lib/data";
import { TABLES } from "@/lib/appwrite-server"; import { TABLES } from "@/lib/appwrite-rest";
import type { TestimonialRow } from "@/lib/types"; import type { TestimonialRow } from "@/lib/types";
import { TestimonialForm } from "../../form"; import { TestimonialForm } from "../../form";
+1 -1
View File
@@ -1,6 +1,6 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { getRow } from "@/lib/data"; import { getRow } from "@/lib/data";
import { TABLES } from "@/lib/appwrite-server"; import { TABLES } from "@/lib/appwrite-rest";
import type { SeoPageRow } from "@/lib/types"; import type { SeoPageRow } from "@/lib/types";
import { SeoPageForm } from "../../page-form"; import { SeoPageForm } from "../../page-form";
+37 -17
View File
@@ -2,14 +2,27 @@
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { Account, Client } from "appwrite"; import { account, AppwriteError } from "@/lib/appwrite-rest";
import { SESSION_COOKIE } from "@/lib/auth"; import { SESSION_COOKIE } from "@/lib/auth";
const endpoint = process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT!;
const projectId = process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID!;
export type LoginState = { error?: string }; export type LoginState = { error?: string };
function friendly(err: unknown): string {
if (err instanceof AppwriteError) {
switch (err.type) {
case "user_invalid_credentials":
return "E-posta veya şifre hatalı.";
case "user_blocked":
return "Hesabınız engellenmiş.";
case "general_rate_limit_exceeded":
return "Çok fazla deneme. Birkaç dakika sonra tekrar deneyin.";
default:
return err.message;
}
}
return err instanceof Error ? err.message : "Giriş başarısız";
}
export async function loginAction( export async function loginAction(
_prev: LoginState | undefined, _prev: LoginState | undefined,
formData: FormData, formData: FormData,
@@ -19,28 +32,35 @@ export async function loginAction(
if (!email || !password) return { error: "E-posta ve şifre zorunlu" }; if (!email || !password) return { error: "E-posta ve şifre zorunlu" };
let session;
try { try {
const client = new Client().setEndpoint(endpoint).setProject(projectId); session = await account.createEmailPasswordSession(email, password);
const account = new Account(client);
const session = await account.createEmailPasswordSession({ email, password });
const store = await cookies();
store.set(SESSION_COOKIE, session.secret, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
expires: new Date(session.expire),
});
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : "Giriş başarısız"; return { error: friendly(err) };
return { error: msg };
} }
const store = await cookies();
store.set(SESSION_COOKIE, session.secret, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
expires: new Date(session.expire),
});
redirect("/admin"); redirect("/admin");
} }
export async function logoutAction() { export async function logoutAction() {
const store = await cookies(); const store = await cookies();
const secret = store.get(SESSION_COOKIE)?.value;
if (secret) {
try {
await account.deleteSession("current", secret);
} catch {
// ignore — cookie is cleared anyway
}
}
store.delete(SESSION_COOKIE); store.delete(SESSION_COOKIE);
redirect("/admin/login"); redirect("/admin/login");
} }
+92 -140
View File
@@ -1,25 +1,16 @@
"use server"; "use server";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { ID } from "appwrite";
import { import {
DATABASE_ID, DATABASE_ID,
ID,
MEDIA_BUCKET_ID, MEDIA_BUCKET_ID,
storage,
TABLES, TABLES,
userDB, tablesDB,
userStorage, } from "@/lib/appwrite-rest";
} from "@/lib/appwrite-server";
import { requireSessionSecret } from "@/lib/auth"; import { requireSessionSecret } from "@/lib/auth";
async function db() {
const secret = await requireSessionSecret();
return userDB(secret);
}
async function storage() {
const secret = await requireSessionSecret();
return userStorage(secret);
}
function slugify(s: string) { function slugify(s: string) {
return s return s
.toLowerCase() .toLowerCase()
@@ -55,37 +46,34 @@ function strArr(v: FormDataEntryValue | null) {
.filter(Boolean); .filter(Boolean);
} }
// ─── Media upload ──────────────────────────────────────────────── // ─── Media ───────────────────────────────────────────────────────
export async function uploadMedia(formData: FormData): Promise<void> { export async function uploadMedia(formData: FormData): Promise<void> {
const secret = await requireSessionSecret();
const file = formData.get("file"); const file = formData.get("file");
if (!(file instanceof File) || file.size === 0) { if (!(file instanceof File) || file.size === 0) {
throw new Error("Dosya seçilmedi"); throw new Error("Dosya seçilmedi");
} }
const s = await storage(); await storage.createFile(MEDIA_BUCKET_ID, ID.unique(), file, secret);
await s.createFile({
bucketId: MEDIA_BUCKET_ID,
fileId: ID.unique(),
file,
});
revalidatePath("/admin/medya"); revalidatePath("/admin/medya");
} }
export async function deleteMediaFile(fileId: string) { export async function deleteMediaFile(fileId: string) {
const s = await storage(); const secret = await requireSessionSecret();
await s.deleteFile({ bucketId: MEDIA_BUCKET_ID, fileId }); await storage.deleteFile(MEDIA_BUCKET_ID, fileId, secret);
revalidatePath("/admin/medya"); revalidatePath("/admin/medya");
} }
// ─── Blog ──────────────────────────────────────────────────────── // ─── Blog ────────────────────────────────────────────────────────
export async function saveBlogPost(formData: FormData) { export async function saveBlogPost(formData: FormData) {
const d = await db(); const secret = await requireSessionSecret();
const id = str(formData.get("id")); const id = str(formData.get("id"));
const title = str(formData.get("title")); const title = str(formData.get("title"));
if (!title) throw new Error("Başlık zorunlu"); if (!title) throw new Error("Başlık zorunlu");
const slug = str(formData.get("slug")) || slugify(title); const slug = str(formData.get("slug")) || slugify(title);
const status = (str(formData.get("status")) ?? "draft") as "draft" | "published"; const status =
(str(formData.get("status")) ?? "draft") as "draft" | "published";
const data = { const data = {
slug, slug,
@@ -107,19 +95,15 @@ export async function saveBlogPost(formData: FormData) {
}; };
if (id) { if (id) {
await d.updateRow({ await tablesDB.updateRow(DATABASE_ID, TABLES.blogPosts, id, data, secret);
databaseId: DATABASE_ID,
tableId: TABLES.blogPosts,
rowId: id,
data,
});
} else { } else {
await d.createRow({ await tablesDB.createRow(
databaseId: DATABASE_ID, DATABASE_ID,
tableId: TABLES.blogPosts, TABLES.blogPosts,
rowId: ID.unique(), ID.unique(),
data, data,
}); secret,
);
} }
revalidatePath("/admin/blog"); revalidatePath("/admin/blog");
revalidatePath("/blog"); revalidatePath("/blog");
@@ -127,13 +111,9 @@ export async function saveBlogPost(formData: FormData) {
} }
export async function deleteBlogPost(formData: FormData) { export async function deleteBlogPost(formData: FormData) {
const d = await db(); const secret = await requireSessionSecret();
const id = String(formData.get("id")); const id = String(formData.get("id"));
await d.deleteRow({ await tablesDB.deleteRow(DATABASE_ID, TABLES.blogPosts, id, secret);
databaseId: DATABASE_ID,
tableId: TABLES.blogPosts,
rowId: id,
});
revalidatePath("/admin/blog"); revalidatePath("/admin/blog");
revalidatePath("/blog"); revalidatePath("/blog");
} }
@@ -141,7 +121,7 @@ export async function deleteBlogPost(formData: FormData) {
// ─── Services ──────────────────────────────────────────────────── // ─── Services ────────────────────────────────────────────────────
export async function saveService(formData: FormData) { export async function saveService(formData: FormData) {
const d = await db(); const secret = await requireSessionSecret();
const id = str(formData.get("id")); const id = str(formData.get("id"));
const title = str(formData.get("title")); const title = str(formData.get("title"));
if (!title) throw new Error("Başlık zorunlu"); if (!title) throw new Error("Başlık zorunlu");
@@ -158,19 +138,15 @@ export async function saveService(formData: FormData) {
featured: bool(formData.get("featured")), featured: bool(formData.get("featured")),
}; };
if (id) { if (id) {
await d.updateRow({ await tablesDB.updateRow(DATABASE_ID, TABLES.services, id, data, secret);
databaseId: DATABASE_ID,
tableId: TABLES.services,
rowId: id,
data,
});
} else { } else {
await d.createRow({ await tablesDB.createRow(
databaseId: DATABASE_ID, DATABASE_ID,
tableId: TABLES.services, TABLES.services,
rowId: slug, slug,
data, data,
}); secret,
);
} }
revalidatePath("/admin/hizmetler"); revalidatePath("/admin/hizmetler");
revalidatePath("/hizmetler"); revalidatePath("/hizmetler");
@@ -178,13 +154,9 @@ export async function saveService(formData: FormData) {
} }
export async function deleteService(formData: FormData) { export async function deleteService(formData: FormData) {
const d = await db(); const secret = await requireSessionSecret();
const id = String(formData.get("id")); const id = String(formData.get("id"));
await d.deleteRow({ await tablesDB.deleteRow(DATABASE_ID, TABLES.services, id, secret);
databaseId: DATABASE_ID,
tableId: TABLES.services,
rowId: id,
});
revalidatePath("/admin/hizmetler"); revalidatePath("/admin/hizmetler");
revalidatePath("/hizmetler"); revalidatePath("/hizmetler");
} }
@@ -192,7 +164,7 @@ export async function deleteService(formData: FormData) {
// ─── Projects ──────────────────────────────────────────────────── // ─── Projects ────────────────────────────────────────────────────
export async function saveProject(formData: FormData) { export async function saveProject(formData: FormData) {
const d = await db(); const secret = await requireSessionSecret();
const id = str(formData.get("id")); const id = str(formData.get("id"));
const title = str(formData.get("title")); const title = str(formData.get("title"));
if (!title) throw new Error("Başlık zorunlu"); if (!title) throw new Error("Başlık zorunlu");
@@ -213,19 +185,15 @@ export async function saveProject(formData: FormData) {
}; };
if (id) { if (id) {
await d.updateRow({ await tablesDB.updateRow(DATABASE_ID, TABLES.projects, id, data, secret);
databaseId: DATABASE_ID,
tableId: TABLES.projects,
rowId: id,
data,
});
} else { } else {
await d.createRow({ await tablesDB.createRow(
databaseId: DATABASE_ID, DATABASE_ID,
tableId: TABLES.projects, TABLES.projects,
rowId: ID.unique(), ID.unique(),
data, data,
}); secret,
);
} }
revalidatePath("/admin/projeler"); revalidatePath("/admin/projeler");
revalidatePath("/projeler"); revalidatePath("/projeler");
@@ -233,13 +201,9 @@ export async function saveProject(formData: FormData) {
} }
export async function deleteProject(formData: FormData) { export async function deleteProject(formData: FormData) {
const d = await db(); const secret = await requireSessionSecret();
const id = String(formData.get("id")); const id = String(formData.get("id"));
await d.deleteRow({ await tablesDB.deleteRow(DATABASE_ID, TABLES.projects, id, secret);
databaseId: DATABASE_ID,
tableId: TABLES.projects,
rowId: id,
});
revalidatePath("/admin/projeler"); revalidatePath("/admin/projeler");
revalidatePath("/projeler"); revalidatePath("/projeler");
} }
@@ -247,7 +211,7 @@ export async function deleteProject(formData: FormData) {
// ─── Testimonials ──────────────────────────────────────────────── // ─── Testimonials ────────────────────────────────────────────────
export async function saveTestimonial(formData: FormData) { export async function saveTestimonial(formData: FormData) {
const d = await db(); const secret = await requireSessionSecret();
const id = str(formData.get("id")); const id = str(formData.get("id"));
const name = str(formData.get("name")); const name = str(formData.get("name"));
if (!name) throw new Error("Ad zorunlu"); if (!name) throw new Error("Ad zorunlu");
@@ -266,39 +230,31 @@ export async function saveTestimonial(formData: FormData) {
}; };
if (id) { if (id) {
await d.updateRow({ await tablesDB.updateRow(DATABASE_ID, TABLES.testimonials, id, data, secret);
databaseId: DATABASE_ID,
tableId: TABLES.testimonials,
rowId: id,
data,
});
} else { } else {
await d.createRow({ await tablesDB.createRow(
databaseId: DATABASE_ID, DATABASE_ID,
tableId: TABLES.testimonials, TABLES.testimonials,
rowId: ID.unique(), ID.unique(),
data, data,
}); secret,
);
} }
revalidatePath("/admin/referanslar"); revalidatePath("/admin/referanslar");
revalidatePath("/"); revalidatePath("/");
} }
export async function deleteTestimonial(formData: FormData) { export async function deleteTestimonial(formData: FormData) {
const d = await db(); const secret = await requireSessionSecret();
const id = String(formData.get("id")); const id = String(formData.get("id"));
await d.deleteRow({ await tablesDB.deleteRow(DATABASE_ID, TABLES.testimonials, id, secret);
databaseId: DATABASE_ID,
tableId: TABLES.testimonials,
rowId: id,
});
revalidatePath("/admin/referanslar"); revalidatePath("/admin/referanslar");
} }
// ─── SEO Settings (singleton) ──────────────────────────────────── // ─── SEO Settings ────────────────────────────────────────────────
export async function saveSeoSettings(formData: FormData) { export async function saveSeoSettings(formData: FormData) {
const d = await db(); const secret = await requireSessionSecret();
const data = { const data = {
site_name: str(formData.get("site_name")), site_name: str(formData.get("site_name")),
site_description: str(formData.get("site_description")), site_description: str(formData.get("site_description")),
@@ -311,28 +267,30 @@ export async function saveSeoSettings(formData: FormData) {
gtm_id: str(formData.get("gtm_id")), gtm_id: str(formData.get("gtm_id")),
}; };
try { try {
await d.updateRow({ await tablesDB.updateRow(
databaseId: DATABASE_ID, DATABASE_ID,
tableId: TABLES.seoSettings, TABLES.seoSettings,
rowId: "global", "global",
data, data,
}); secret,
);
} catch { } catch {
await d.createRow({ await tablesDB.createRow(
databaseId: DATABASE_ID, DATABASE_ID,
tableId: TABLES.seoSettings, TABLES.seoSettings,
rowId: "global", "global",
data, data,
}); secret,
);
} }
revalidatePath("/", "layout"); revalidatePath("/", "layout");
revalidatePath("/admin/seo"); revalidatePath("/admin/seo");
} }
// ─── SEO Page (per path) ───────────────────────────────────────── // ─── SEO Page ────────────────────────────────────────────────────
export async function saveSeoPage(formData: FormData) { export async function saveSeoPage(formData: FormData) {
const d = await db(); const secret = await requireSessionSecret();
const id = str(formData.get("id")); const id = str(formData.get("id"));
const path = str(formData.get("path")); const path = str(formData.get("path"));
if (!path) throw new Error("Path zorunlu"); if (!path) throw new Error("Path zorunlu");
@@ -347,61 +305,55 @@ export async function saveSeoPage(formData: FormData) {
}; };
if (id) { if (id) {
await d.updateRow({ await tablesDB.updateRow(DATABASE_ID, TABLES.seoPages, id, data, secret);
databaseId: DATABASE_ID,
tableId: TABLES.seoPages,
rowId: id,
data,
});
} else { } else {
await d.createRow({ await tablesDB.createRow(
databaseId: DATABASE_ID, DATABASE_ID,
tableId: TABLES.seoPages, TABLES.seoPages,
rowId: ID.unique(), ID.unique(),
data, data,
}); secret,
);
} }
revalidatePath(path); revalidatePath(path);
revalidatePath("/admin/seo"); revalidatePath("/admin/seo");
} }
export async function deleteSeoPage(formData: FormData) { export async function deleteSeoPage(formData: FormData) {
const d = await db(); const secret = await requireSessionSecret();
const id = String(formData.get("id")); const id = String(formData.get("id"));
await d.deleteRow({ await tablesDB.deleteRow(DATABASE_ID, TABLES.seoPages, id, secret);
databaseId: DATABASE_ID,
tableId: TABLES.seoPages,
rowId: id,
});
revalidatePath("/admin/seo"); revalidatePath("/admin/seo");
} }
// ─── Contact messages ──────────────────────────────────────────── // ─── Contact ─────────────────────────────────────────────────────
export async function updateMessageStatus(formData: FormData) { export async function updateMessageStatus(formData: FormData) {
const d = await db(); const secret = await requireSessionSecret();
const id = String(formData.get("id")); const id = String(formData.get("id"));
const status = String(formData.get("status")) as const status = String(formData.get("status")) as
| "new" | "new"
| "read" | "read"
| "replied" | "replied"
| "archived"; | "archived";
await d.updateRow({ await tablesDB.updateRow(
databaseId: DATABASE_ID, DATABASE_ID,
tableId: TABLES.contactMessages, TABLES.contactMessages,
rowId: id, id,
data: { status }, { status },
}); secret,
);
revalidatePath("/admin/iletisim"); revalidatePath("/admin/iletisim");
} }
export async function deleteMessage(formData: FormData) { export async function deleteMessage(formData: FormData) {
const d = await db(); const secret = await requireSessionSecret();
const id = String(formData.get("id")); const id = String(formData.get("id"));
await d.deleteRow({ await tablesDB.deleteRow(
databaseId: DATABASE_ID, DATABASE_ID,
tableId: TABLES.contactMessages, TABLES.contactMessages,
rowId: id, id,
}); secret,
);
revalidatePath("/admin/iletisim"); revalidatePath("/admin/iletisim");
} }
+276
View File
@@ -0,0 +1,276 @@
import "server-only";
const ENDPOINT = process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT!;
const PROJECT_ID = process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID!;
if (!ENDPOINT || !PROJECT_ID) {
throw new Error(
"Missing NEXT_PUBLIC_APPWRITE_ENDPOINT or NEXT_PUBLIC_APPWRITE_PROJECT_ID",
);
}
export const DATABASE_ID = process.env.NEXT_PUBLIC_APPWRITE_DATABASE_ID!;
export const MEDIA_BUCKET_ID =
process.env.NEXT_PUBLIC_APPWRITE_MEDIA_BUCKET_ID ?? "kovak-yazilim-media";
export const TABLES = {
contactMessages: "contact_messages",
services: "services",
projects: "projects",
blogPosts: "blog_posts",
testimonials: "testimonials",
seoPages: "seo_pages",
seoSettings: "seo_settings",
} as const;
export class AppwriteError extends Error {
code: number;
type?: string;
constructor(message: string, code: number, type?: string) {
super(message);
this.name = "AppwriteError";
this.code = code;
this.type = type;
}
}
type FetchOpts = {
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
body?: unknown;
session?: string;
query?: Record<string, string | string[] | number | undefined>;
formData?: FormData;
};
async function aw<T>(path: string, opts: FetchOpts = {}): Promise<T> {
const url = new URL(ENDPOINT + path);
if (opts.query) {
for (const [k, v] of Object.entries(opts.query)) {
if (v === undefined) continue;
if (Array.isArray(v)) v.forEach((x) => url.searchParams.append(k + "[]", String(x)));
else url.searchParams.set(k, String(v));
}
}
const headers: Record<string, string> = {
"X-Appwrite-Project": PROJECT_ID,
"X-Appwrite-Response-Format": "1.9.0",
};
if (opts.session) headers["X-Appwrite-Session"] = opts.session;
let body: BodyInit | undefined;
if (opts.formData) {
body = opts.formData;
} else if (opts.body !== undefined) {
headers["Content-Type"] = "application/json";
body = JSON.stringify(opts.body);
}
const res = await fetch(url, {
method: opts.method ?? "GET",
headers,
body,
cache: "no-store",
});
if (!res.ok) {
let data: { message?: string; type?: string } = {};
try {
data = await res.json();
} catch {
/* ignore */
}
throw new AppwriteError(
data.message || `HTTP ${res.status}`,
res.status,
data.type,
);
}
const ct = res.headers.get("content-type") ?? "";
if (ct.includes("application/json")) {
return (await res.json()) as T;
}
return undefined as T;
}
// ─── Query helpers ───────────────────────────────────────────────
export const Q = {
equal: (attr: string, value: string | number | boolean | string[]) =>
JSON.stringify({
method: "equal",
attribute: attr,
values: Array.isArray(value) ? value : [value],
}),
notEqual: (attr: string, value: string | number) =>
JSON.stringify({ method: "notEqual", attribute: attr, values: [value] }),
orderAsc: (attr: string) =>
JSON.stringify({ method: "orderAsc", attribute: attr }),
orderDesc: (attr: string) =>
JSON.stringify({ method: "orderDesc", attribute: attr }),
limit: (n: number) => JSON.stringify({ method: "limit", values: [n] }),
offset: (n: number) => JSON.stringify({ method: "offset", values: [n] }),
};
// ─── Types ───────────────────────────────────────────────────────
export interface AwRow {
$id: string;
$createdAt: string;
$updatedAt: string;
[k: string]: unknown;
}
export interface AwListResponse<T> {
total: number;
rows: T[];
}
export interface AwFile {
$id: string;
name: string;
sizeOriginal: number;
mimeType: string;
$createdAt: string;
}
export interface AwSession {
$id: string;
secret: string;
expire: string;
userId: string;
provider: string;
}
export interface AwUser {
$id: string;
email: string;
name: string;
status: boolean;
}
// ─── Account ─────────────────────────────────────────────────────
export const account = {
createEmailPasswordSession(email: string, password: string) {
return aw<AwSession>("/account/sessions/email", {
method: "POST",
body: { email, password },
});
},
get(session: string) {
return aw<AwUser>("/account", { session });
},
deleteSession(sessionId: string, session: string) {
return aw<void>(`/account/sessions/${sessionId}`, {
method: "DELETE",
session,
});
},
};
// ─── TablesDB ────────────────────────────────────────────────────
export const tablesDB = {
listRows<T = AwRow>(
databaseId: string,
tableId: string,
queries: string[] = [],
session?: string,
) {
return aw<AwListResponse<T>>(
`/tablesdb/${databaseId}/tables/${tableId}/rows`,
{ query: { queries }, session },
);
},
getRow<T = AwRow>(
databaseId: string,
tableId: string,
rowId: string,
session?: string,
) {
return aw<T>(`/tablesdb/${databaseId}/tables/${tableId}/rows/${rowId}`, {
session,
});
},
createRow<T = AwRow>(
databaseId: string,
tableId: string,
rowId: string,
data: Record<string, unknown>,
session?: string,
) {
return aw<T>(`/tablesdb/${databaseId}/tables/${tableId}/rows`, {
method: "POST",
body: { rowId, data },
session,
});
},
updateRow<T = AwRow>(
databaseId: string,
tableId: string,
rowId: string,
data: Record<string, unknown>,
session?: string,
) {
return aw<T>(`/tablesdb/${databaseId}/tables/${tableId}/rows/${rowId}`, {
method: "PATCH",
body: { data },
session,
});
},
deleteRow(
databaseId: string,
tableId: string,
rowId: string,
session?: string,
) {
return aw<void>(`/tablesdb/${databaseId}/tables/${tableId}/rows/${rowId}`, {
method: "DELETE",
session,
});
},
};
// ─── Storage ─────────────────────────────────────────────────────
export const storage = {
listFiles(bucketId: string, queries: string[] = [], session?: string) {
return aw<{ total: number; files: AwFile[] }>(
`/storage/buckets/${bucketId}/files`,
{ query: { queries }, session },
);
},
createFile(
bucketId: string,
fileId: string,
file: File,
session?: string,
) {
const fd = new FormData();
fd.append("fileId", fileId);
fd.append("file", file);
return aw<AwFile>(`/storage/buckets/${bucketId}/files`, {
method: "POST",
formData: fd,
session,
});
},
deleteFile(bucketId: string, fileId: string, session?: string) {
return aw<void>(`/storage/buckets/${bucketId}/files/${fileId}`, {
method: "DELETE",
session,
});
},
fileViewUrl(bucketId: string, fileId: string) {
return `${ENDPOINT}/storage/buckets/${bucketId}/files/${fileId}/view?project=${PROJECT_ID}`;
},
};
// ─── ID generator (Appwrite "unique()") ──────────────────────────
export const ID = {
unique() {
return "unique()";
},
custom(id: string) {
return id;
},
};
-45
View File
@@ -1,45 +0,0 @@
import "server-only";
import { Account, Client, Storage, TablesDB } from "appwrite";
const endpoint = process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT!;
const projectId = process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID!;
export const DATABASE_ID = process.env.NEXT_PUBLIC_APPWRITE_DATABASE_ID!;
export const MEDIA_BUCKET_ID =
process.env.NEXT_PUBLIC_APPWRITE_MEDIA_BUCKET_ID ?? "kovak-yazilim-media";
export const TABLES = {
contactMessages: "contact_messages",
services: "services",
projects: "projects",
blogPosts: "blog_posts",
testimonials: "testimonials",
seoPages: "seo_pages",
seoSettings: "seo_settings",
} as const;
function newClient() {
return new Client().setEndpoint(endpoint).setProject(projectId);
}
export function publicClient() {
return newClient();
}
export function sessionClient(sessionSecret: string) {
return newClient().setSession(sessionSecret);
}
export const publicDB = new TablesDB(publicClient());
export const publicStorage = new Storage(publicClient());
export const publicAccount = new Account(publicClient());
export function userDB(secret: string) {
return new TablesDB(sessionClient(secret));
}
export function userStorage(secret: string) {
return new Storage(sessionClient(secret));
}
export function userAccount(secret: string) {
return new Account(sessionClient(secret));
}
-16
View File
@@ -1,16 +0,0 @@
import { Client, TablesDB } from "appwrite";
const endpoint = process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT!;
const projectId = process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID!;
export const DATABASE_ID = process.env.NEXT_PUBLIC_APPWRITE_DATABASE_ID!;
export const TABLES = {
contactMessages: "contact_messages",
services: "services",
projects: "projects",
} as const;
export const client = new Client().setEndpoint(endpoint).setProject(projectId);
export const tablesDB = new TablesDB(client);
+2 -2
View File
@@ -1,7 +1,7 @@
import "server-only"; import "server-only";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { userAccount } from "@/lib/appwrite-server"; import { account } from "@/lib/appwrite-rest";
export const SESSION_COOKIE = "kovak_session"; export const SESSION_COOKIE = "kovak_session";
@@ -14,7 +14,7 @@ export async function getCurrentUser() {
const secret = await getSessionSecret(); const secret = await getSessionSecret();
if (!secret) return null; if (!secret) return null;
try { try {
return await userAccount(secret).get(); return await account.get(secret);
} catch { } catch {
return null; return null;
} }
+53 -37
View File
@@ -1,6 +1,6 @@
import "server-only"; import "server-only";
import { Query } from "appwrite"; import { DATABASE_ID, Q, TABLES, tablesDB } from "@/lib/appwrite-rest";
import { publicDB, DATABASE_ID, TABLES } from "@/lib/appwrite-server"; import { getSessionSecret } from "@/lib/auth";
import type { import type {
BlogPostRow, BlogPostRow,
ContactMessageRow, ContactMessageRow,
@@ -13,98 +13,114 @@ import type {
async function safeList<T>(tableId: string, queries: string[]): Promise<T[]> { async function safeList<T>(tableId: string, queries: string[]): Promise<T[]> {
try { try {
const res = await publicDB.listRows({ const res = await tablesDB.listRows<T>(DATABASE_ID, tableId, queries);
databaseId: DATABASE_ID, return res.rows;
} catch {
return [];
}
}
async function safeListAuth<T>(tableId: string, queries: string[]): Promise<T[]> {
try {
const secret = await getSessionSecret();
const res = await tablesDB.listRows<T>(
DATABASE_ID,
tableId, tableId,
queries, queries,
}); secret ?? undefined,
return res.rows as T[]; );
return res.rows;
} catch { } catch {
return []; return [];
} }
} }
export async function listServices(opts?: { featured?: boolean }) { export async function listServices(opts?: { featured?: boolean }) {
const q = [Query.orderAsc("order"), Query.limit(50)]; const q = [Q.orderAsc("order"), Q.limit(50)];
if (opts?.featured) q.unshift(Query.equal("featured", true)); if (opts?.featured) q.unshift(Q.equal("featured", true));
return safeList<ServiceRow>(TABLES.services, q); return safeList<ServiceRow>(TABLES.services, q);
} }
export async function listProjects(opts?: { featured?: boolean; limit?: number }) { export async function listProjects(opts?: { featured?: boolean; limit?: number }) {
const q = [Query.orderDesc("year"), Query.limit(opts?.limit ?? 50)]; const q = [Q.orderDesc("year"), Q.limit(opts?.limit ?? 50)];
if (opts?.featured) q.unshift(Query.equal("featured", true)); if (opts?.featured) q.unshift(Q.equal("featured", true));
return safeList<ProjectRow>(TABLES.projects, q); return safeList<ProjectRow>(TABLES.projects, q);
} }
export async function listPublishedPosts(opts?: { limit?: number }) { export async function listPublishedPosts(opts?: { limit?: number }) {
return safeList<BlogPostRow>(TABLES.blogPosts, [ return safeList<BlogPostRow>(TABLES.blogPosts, [
Query.equal("status", "published"), Q.equal("status", "published"),
Query.orderDesc("published_at"), Q.orderDesc("published_at"),
Query.limit(opts?.limit ?? 50), Q.limit(opts?.limit ?? 50),
]); ]);
} }
export async function listAllPosts() { export async function listAllPosts() {
return safeList<BlogPostRow>(TABLES.blogPosts, [ return safeListAuth<BlogPostRow>(TABLES.blogPosts, [
Query.orderDesc("$createdAt"), Q.orderDesc("$createdAt"),
Query.limit(200), Q.limit(200),
]); ]);
} }
export async function getPostBySlug(slug: string): Promise<BlogPostRow | null> { export async function getPostBySlug(slug: string): Promise<BlogPostRow | null> {
const res = await safeList<BlogPostRow>(TABLES.blogPosts, [ const res = await safeList<BlogPostRow>(TABLES.blogPosts, [
Query.equal("slug", slug), Q.equal("slug", slug),
Query.limit(1), Q.limit(1),
]); ]);
return res[0] ?? null; return res[0] ?? null;
} }
export async function listTestimonials(opts?: { featured?: boolean }) { export async function listTestimonials(opts?: { featured?: boolean }) {
const q = [Query.orderAsc("order"), Query.limit(50)]; const q = [Q.orderAsc("order"), Q.limit(50)];
if (opts?.featured) q.unshift(Query.equal("featured", true)); if (opts?.featured) q.unshift(Q.equal("featured", true));
return safeList<TestimonialRow>(TABLES.testimonials, q); return safeList<TestimonialRow>(TABLES.testimonials, q);
} }
export async function listMessages(status?: ContactMessageRow["status"]) { export async function listMessages(status?: ContactMessageRow["status"]) {
const q = [Query.orderDesc("$createdAt"), Query.limit(200)]; const q = [Q.orderDesc("$createdAt"), Q.limit(200)];
if (status) q.unshift(Query.equal("status", status)); if (status) q.unshift(Q.equal("status", status));
return safeList<ContactMessageRow>(TABLES.contactMessages, q); return safeListAuth<ContactMessageRow>(TABLES.contactMessages, q);
} }
export async function getSeoPage(path: string): Promise<SeoPageRow | null> { export async function getSeoPage(path: string): Promise<SeoPageRow | null> {
const res = await safeList<SeoPageRow>(TABLES.seoPages, [ const res = await safeList<SeoPageRow>(TABLES.seoPages, [
Query.equal("path", path), Q.equal("path", path),
Query.limit(1), Q.limit(1),
]); ]);
return res[0] ?? null; return res[0] ?? null;
} }
export async function listSeoPages() { export async function listSeoPages() {
return safeList<SeoPageRow>(TABLES.seoPages, [ return safeListAuth<SeoPageRow>(TABLES.seoPages, [
Query.orderAsc("path"), Q.orderAsc("path"),
Query.limit(200), Q.limit(200),
]); ]);
} }
export async function getSeoSettings(): Promise<SeoSettingsRow | null> { export async function getSeoSettings(): Promise<SeoSettingsRow | null> {
try { try {
return (await publicDB.getRow({ return await tablesDB.getRow<SeoSettingsRow>(
databaseId: DATABASE_ID, DATABASE_ID,
tableId: TABLES.seoSettings, TABLES.seoSettings,
rowId: "global", "global",
})) as unknown as SeoSettingsRow; );
} catch { } catch {
return null; return null;
} }
} }
export async function getRow<T>(tableId: string, rowId: string): Promise<T | null> { export async function getRow<T>(
tableId: string,
rowId: string,
): Promise<T | null> {
try { try {
return (await publicDB.getRow({ const secret = await getSessionSecret();
databaseId: DATABASE_ID, return await tablesDB.getRow<T>(
DATABASE_ID,
tableId, tableId,
rowId, rowId,
})) as unknown as T; secret ?? undefined,
);
} catch { } catch {
return null; return null;
} }
+10 -8
View File
@@ -1,6 +1,8 @@
import type { Models } from "appwrite"; import type { AwRow } from "@/lib/appwrite-rest";
export interface ServiceRow extends Models.Row { export type Row = AwRow;
export interface ServiceRow extends AwRow {
slug: string; slug: string;
title: string; title: string;
description: string; description: string;
@@ -9,7 +11,7 @@ export interface ServiceRow extends Models.Row {
featured?: boolean | null; featured?: boolean | null;
} }
export interface ProjectRow extends Models.Row { export interface ProjectRow extends AwRow {
slug: string; slug: string;
title: string; title: string;
description: string; description: string;
@@ -21,7 +23,7 @@ export interface ProjectRow extends Models.Row {
featured?: boolean | null; featured?: boolean | null;
} }
export interface BlogPostRow extends Models.Row { export interface BlogPostRow extends AwRow {
slug: string; slug: string;
title: string; title: string;
excerpt?: string | null; excerpt?: string | null;
@@ -37,7 +39,7 @@ export interface BlogPostRow extends Models.Row {
seo_image?: string | null; seo_image?: string | null;
} }
export interface TestimonialRow extends Models.Row { export interface TestimonialRow extends AwRow {
name: string; name: string;
role?: string | null; role?: string | null;
company?: string | null; company?: string | null;
@@ -48,7 +50,7 @@ export interface TestimonialRow extends Models.Row {
featured?: boolean | null; featured?: boolean | null;
} }
export interface SeoPageRow extends Models.Row { export interface SeoPageRow extends AwRow {
path: string; path: string;
title?: string | null; title?: string | null;
description?: string | null; description?: string | null;
@@ -57,7 +59,7 @@ export interface SeoPageRow extends Models.Row {
noindex?: boolean | null; noindex?: boolean | null;
} }
export interface SeoSettingsRow extends Models.Row { export interface SeoSettingsRow extends AwRow {
site_name?: string | null; site_name?: string | null;
site_description?: string | null; site_description?: string | null;
default_og_image?: string | null; default_og_image?: string | null;
@@ -69,7 +71,7 @@ export interface SeoSettingsRow extends Models.Row {
gtm_id?: string | null; gtm_id?: string | null;
} }
export interface ContactMessageRow extends Models.Row { export interface ContactMessageRow extends AwRow {
name: string; name: string;
email: string; email: string;
phone?: string | null; phone?: string | null;
-31
View File
@@ -8,7 +8,6 @@
"name": "kovak-yazilim", "name": "kovak-yazilim",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"appwrite": "^25.1.1",
"lucide-react": "^1.16.0", "lucide-react": "^1.16.0",
"marked": "^18.0.4", "marked": "^18.0.4",
"next": "16.2.6", "next": "16.2.6",
@@ -1079,18 +1078,6 @@
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
}, },
"node_modules/appwrite": {
"version": "25.1.1",
"resolved": "https://registry.npmjs.org/appwrite/-/appwrite-25.1.1.tgz",
"integrity": "sha512-h+vVPErfCLZ9FCnWH1cZOphgX8MsXNNN0PvxCxoOCbqyX6XubtvvOEpp/BpVOAHp7jIDFEJu72ingBu9kh1vDQ==",
"license": "BSD-3-Clause",
"dependencies": {
"json-bigint": "1.0.0"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.10.31", "version": "2.10.31",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.31.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.31.tgz",
@@ -1103,15 +1090,6 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/bignumber.js": {
"version": "9.3.1",
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
"integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001793", "version": "1.0.30001793",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz",
@@ -1186,15 +1164,6 @@
"jiti": "lib/jiti-cli.mjs" "jiti": "lib/jiti-cli.mjs"
} }
}, },
"node_modules/json-bigint": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz",
"integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==",
"license": "MIT",
"dependencies": {
"bignumber.js": "^9.0.0"
}
},
"node_modules/lightningcss": { "node_modules/lightningcss": {
"version": "1.32.0", "version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
-1
View File
@@ -8,7 +8,6 @@
"start": "next start" "start": "next start"
}, },
"dependencies": { "dependencies": {
"appwrite": "^25.1.1",
"lucide-react": "^1.16.0", "lucide-react": "^1.16.0",
"marked": "^18.0.4", "marked": "^18.0.4",
"next": "16.2.6", "next": "16.2.6",