init: lab project bootstrapped from isletmem-kovakcrm

- CRM domain modules removed (customers, services, software, calendar, tasks, invoices, leads, finance, etc.)
- DLS branding: package name=lab, logo wordmark, sidebar nav, header CTA
- Tenant layer extended with kind dimension (lab|clinic) + requireTenantKind helper
- Schema rewritten for DLS domain: jobs, job_files, job_status_history, prosthetics, connections, finance_entries, notifications
- Onboarding form: clinic/lab account-type selection + auto-generated memberNumber
- Placeholder routes for jobs/{inbound,outbound,new}, products, finance, connections
- PDF spec + spec.md under belgeler/
- db: lab database + 13 collections + indexes + storage bucket (job-files) provisioned via Appwrite MCP

Ref: belgeler/dls-ui-tasarim.pdf
This commit is contained in:
kovakmedya
2026-05-21 18:28:38 +03:00
commit cb150f7a24
215 changed files with 54262 additions and 0 deletions
+44
View File
@@ -0,0 +1,44 @@
import "server-only";
import { Query } from "node-appwrite";
import { createAdminClient, getCurrentUser } from "./server";
import { DATABASE_ID, TABLES, type TenantSettings } from "./schema";
import { getActiveTenantId, getUserTeams } from "./tenant";
export type ActiveContext = {
user: { id: string; name: string; email: string };
tenantId: string;
settings: TenantSettings | null;
};
export async function getActiveContext(): Promise<ActiveContext | null> {
const user = await getCurrentUser();
if (!user) return null;
let tenantId = await getActiveTenantId();
if (!tenantId) {
const teams = await getUserTeams();
tenantId = teams?.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,
queries: [Query.equal("tenantId", tenantId), Query.limit(1)],
});
settings = (result.rows[0] as unknown as TenantSettings) ?? null;
} catch {
settings = null;
}
return {
user: { id: user.$id, name: user.name, email: user.email },
tenantId,
settings,
};
}
+43
View File
@@ -0,0 +1,43 @@
import "server-only";
import { headers } from "next/headers";
import { ID, Permission, Role } from "node-appwrite";
import { createAdminClient } from "./server";
import { DATABASE_ID, TABLES, type AuditAction } from "./schema";
export async function logAudit(args: {
tenantId: string;
userId: string;
action: AuditAction;
entityType: string;
entityId: string;
changes?: Record<string, unknown>;
}) {
try {
const h = await headers();
const ipAddress =
h.get("x-forwarded-for")?.split(",")[0]?.trim() || h.get("x-real-ip") || undefined;
const userAgent = h.get("user-agent")?.slice(0, 500) || undefined;
const { tablesDB } = createAdminClient();
await tablesDB.createRow(
DATABASE_ID,
TABLES.auditLogs,
ID.unique(),
{
tenantId: args.tenantId,
userId: args.userId,
action: args.action,
entityType: args.entityType,
entityId: args.entityId,
changes: args.changes ? JSON.stringify(args.changes).slice(0, 10000) : undefined,
ipAddress,
userAgent,
},
[Permission.read(Role.team(args.tenantId))],
);
} catch {
// audit failures must never block the user-facing operation
}
}
+112
View File
@@ -0,0 +1,112 @@
"use server";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { AppwriteException, ID } from "node-appwrite";
import { APPWRITE_SESSION_COOKIE, createAdminClient, createSessionClient } from "./server";
import type { AuthState } from "./auth-types";
function appwriteError(e: unknown): string {
if (e instanceof AppwriteException) {
switch (e.type) {
case "user_invalid_credentials":
return "Email veya şifre hatalı.";
case "user_blocked":
return "Hesabınız engellenmiş.";
case "user_already_exists":
case "user_email_already_exists":
return "Bu email ile zaten bir hesap var.";
case "user_password_mismatch":
return "Şifreler eşleşmiyor.";
case "general_rate_limit_exceeded":
return "Çok fazla deneme. Birkaç dakika sonra tekrar deneyin.";
default:
return e.message || "Beklenmeyen bir hata oluştu.";
}
}
return "Bağlantı hatası. Tekrar deneyin.";
}
async function setSessionCookie(secret: string, expire: string) {
(await cookies()).set(APPWRITE_SESSION_COOKIE, secret, {
path: "/",
httpOnly: true,
sameSite: "strict",
secure: process.env.NODE_ENV === "production",
expires: new Date(expire),
});
}
export async function signInAction(_prev: AuthState, formData: FormData): Promise<AuthState> {
const email = String(formData.get("email") ?? "").trim();
const password = String(formData.get("password") ?? "");
const inviteCode = String(formData.get("inviteCode") ?? "").trim();
if (!email || !password) {
return { ok: false, error: "Email ve şifre zorunlu." };
}
try {
const { account } = createAdminClient();
const session = await account.createEmailPasswordSession(email, password);
await setSessionCookie(session.secret, session.expire);
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
redirect(inviteCode ? `/d/${inviteCode}` : "/dashboard");
}
export async function signUpAction(_prev: AuthState, formData: FormData): Promise<AuthState> {
const name = String(formData.get("name") ?? "").trim();
const email = String(formData.get("email") ?? "").trim();
const password = String(formData.get("password") ?? "");
const inviteCode = String(formData.get("inviteCode") ?? "").trim();
if (!name || !email || !password) {
return { ok: false, error: "Tüm alanlar zorunlu." };
}
if (password.length < 8) {
return { ok: false, error: "Şifre en az 8 karakter olmalı." };
}
try {
const { account } = createAdminClient();
await account.create(ID.unique(), email, password, name);
const session = await account.createEmailPasswordSession(email, password);
await setSessionCookie(session.secret, session.expire);
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
redirect(inviteCode ? `/d/${inviteCode}` : "/onboarding");
}
export async function forgotPasswordAction(
_prev: AuthState,
formData: FormData,
): Promise<AuthState> {
const email = String(formData.get("email") ?? "").trim();
if (!email) return { ok: false, error: "Email zorunlu." };
try {
const { account } = createAdminClient();
const recoveryUrl = `${process.env.APP_URL ?? "http://localhost:3000"}/reset-password`;
await account.createRecovery(email, recoveryUrl);
return { ok: true };
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
}
export async function signOutAction() {
try {
const { account } = await createSessionClient();
await account.deleteSession("current");
} catch {
// ignore — cookie will be cleared anyway
}
(await cookies()).delete(APPWRITE_SESSION_COOKIE);
redirect("/sign-in");
}
+6
View File
@@ -0,0 +1,6 @@
export type AuthState = {
ok: boolean;
error?: string;
};
export const initialAuthState: AuthState = { ok: false };
+19
View File
@@ -0,0 +1,19 @@
import { Account, Avatars, Client, Databases, Storage, TablesDB, Teams } from "appwrite";
const endpoint = process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT;
const projectId = process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID;
if (!endpoint || !projectId) {
throw new Error(
"Missing NEXT_PUBLIC_APPWRITE_ENDPOINT or NEXT_PUBLIC_APPWRITE_PROJECT_ID. Check .env.local.",
);
}
export const client = new Client().setEndpoint(endpoint).setProject(projectId);
export const account = new Account(client);
export const teams = new Teams(client);
export const databases = new Databases(client);
export const tablesDB = new TablesDB(client);
export const storage = new Storage(client);
export const avatars = new Avatars(client);
+171
View File
@@ -0,0 +1,171 @@
"use server";
import { revalidatePath } from "next/cache";
import { ID, Permission, Role } from "node-appwrite";
import { InputFile } from "node-appwrite/file";
import { logAudit } from "./audit";
import type { LogoActionState } from "./logo-types";
import { BUCKETS, DATABASE_ID, TABLES } from "./schema";
import { createAdminClient } from "./server";
import { requireRole, requireTenant } from "./tenant-guard";
const MAX_BYTES = 2 * 1024 * 1024;
const ALLOWED_TYPES = new Set([
"image/png",
"image/jpeg",
"image/jpg",
"image/webp",
"image/svg+xml",
]);
function teamLogoPermissions(tenantId: string) {
return [
Permission.read(Role.any()),
Permission.update(Role.team(tenantId, "owner")),
Permission.update(Role.team(tenantId, "admin")),
Permission.delete(Role.team(tenantId, "owner")),
Permission.delete(Role.team(tenantId, "admin")),
];
}
export async function uploadLogoAction(
_prev: LogoActionState,
formData: FormData,
): Promise<LogoActionState> {
let ctx;
try {
ctx = await requireTenant();
requireRole(ctx, ["owner", "admin"]);
} catch {
return { ok: false, error: "Logo yüklemek için yönetici yetkisi gerekli." };
}
const file = formData.get("logo");
if (!(file instanceof File) || file.size === 0) {
return { ok: false, error: "Dosya seçin." };
}
if (file.size > MAX_BYTES) {
return { ok: false, error: "Dosya 2MB'dan büyük olamaz." };
}
if (!ALLOWED_TYPES.has(file.type)) {
return { ok: false, error: "Sadece PNG, JPG, WebP veya SVG yükleyebilirsiniz." };
}
if (!ctx.settings) {
return { ok: false, error: "Çalışma alanı ayarları bulunamadı." };
}
const { storage, tablesDB } = createAdminClient();
const previousLogoId = ctx.settings.logo;
let newFileId: string | null = null;
try {
const buffer = Buffer.from(await file.arrayBuffer());
const inputFile = InputFile.fromBuffer(buffer, file.name);
const created = await storage.createFile({
bucketId: BUCKETS.tenantLogos,
fileId: ID.unique(),
file: inputFile,
permissions: teamLogoPermissions(ctx.tenantId),
});
newFileId = created.$id;
await tablesDB.updateRow(DATABASE_ID, TABLES.tenantSettings, ctx.settings.$id, {
logo: newFileId,
});
if (previousLogoId && previousLogoId !== newFileId) {
try {
await storage.deleteFile({
bucketId: BUCKETS.tenantLogos,
fileId: previousLogoId,
});
} catch {
// best-effort — orphaned file is acceptable, won't block the new logo
}
}
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "update",
entityType: "tenant_logo",
entityId: newFileId,
changes: { previous: previousLogoId ?? null },
});
} catch (e) {
if (newFileId) {
try {
await storage.deleteFile({
bucketId: BUCKETS.tenantLogos,
fileId: newFileId,
});
} catch {
/* ignore cleanup error */
}
}
return {
ok: false,
error: e instanceof Error ? e.message : "Logo yüklenemedi.",
};
}
revalidatePath("/settings/workspace");
revalidatePath("/", "layout");
return { ok: true };
}
export async function removeLogoAction(): Promise<LogoActionState> {
let ctx;
try {
ctx = await requireTenant();
requireRole(ctx, ["owner", "admin"]);
} catch {
return { ok: false, error: "Logo silmek için yönetici yetkisi gerekli." };
}
if (!ctx.settings) {
return { ok: false, error: "Çalışma alanı ayarları bulunamadı." };
}
const previousLogoId = ctx.settings.logo;
if (!previousLogoId) {
return { ok: true };
}
try {
const { storage, tablesDB } = createAdminClient();
await tablesDB.updateRow(DATABASE_ID, TABLES.tenantSettings, ctx.settings.$id, {
logo: null,
});
try {
await storage.deleteFile({
bucketId: BUCKETS.tenantLogos,
fileId: previousLogoId,
});
} catch {
/* file already gone, fine */
}
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "delete",
entityType: "tenant_logo",
entityId: previousLogoId,
});
} catch (e) {
return {
ok: false,
error: e instanceof Error ? e.message : "Logo silinemedi.",
};
}
revalidatePath("/settings/workspace");
revalidatePath("/", "layout");
return { ok: true };
}
+6
View File
@@ -0,0 +1,6 @@
export type LogoActionState = {
ok: boolean;
error?: string;
};
export const initialLogoState: LogoActionState = { ok: false };
+135
View File
@@ -0,0 +1,135 @@
"use server";
import { createHash, randomBytes } from "crypto";
import { ID, Query } from "node-appwrite";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { createAdminClient } from "./server";
import { DATABASE_ID, TABLES } from "./schema";
import type { AuthState } from "./auth-types";
const TOKEN_EXPIRY_MS = 15 * 60 * 1000;
const RESET_SESSION_COOKIE = "pw_reset";
function generateToken(): { plain: string; hash: string } {
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
const bytes = randomBytes(8);
const plain = Array.from(bytes)
.map((b) => chars[b % chars.length])
.join("");
const hash = createHash("sha256").update(plain).digest("hex");
return { plain, hash };
}
export async function requestPasswordResetAction(
_prev: AuthState,
formData: FormData,
): Promise<AuthState> {
const email = String(formData.get("email") ?? "").trim().toLowerCase();
if (!email) return { ok: false, error: "Email zorunlu." };
try {
const { tablesDB, users, messaging } = createAdminClient();
const found = await users.list([Query.equal("email", email), Query.limit(1)]);
// Kullanıcı yoksa hata vermiyoruz — timing attack önlemi
if (found.total === 0) return { ok: true };
const user = found.users[0];
const { plain, hash } = generateToken();
const expiresAt = new Date(Date.now() + TOKEN_EXPIRY_MS).toISOString();
await tablesDB.createRow(DATABASE_ID, TABLES.passwordResets, ID.unique(), {
email,
userId: user.$id,
tokenHash: hash,
expiresAt,
});
const appUrl = process.env.APP_URL?.replace(/\/$/, "") ?? "http://localhost:3000";
const resetLink = `${appUrl}/reset-password?token=${plain}`;
await messaging.createEmail(
ID.unique(),
"Şifre Sıfırlama Kodunuz",
`<p>Merhaba,</p>
<p>Şifre sıfırlama talebiniz alındı. Aşağıdaki kodu kullanın:</p>
<h2 style="letter-spacing:6px;font-size:32px;">${plain}</h2>
<p>Veya doğrudan linke tıklayın:</p>
<p><a href="${resetLink}">${resetLink}</a></p>
<p style="color:#888;font-size:12px;">Bu kod 15 dakika geçerlidir. Bu talebi siz yapmadıysanız bu e-postayı dikkate almayın.</p>`,
[],
[user.$id],
[],
);
return { ok: true };
} catch {
return { ok: false, error: "Bir hata oluştu. Tekrar deneyin." };
}
}
export async function verifyResetToken(
token: string,
): Promise<{ valid: boolean; tokenId?: string; userId?: string }> {
if (!token) return { valid: false };
try {
const hash = createHash("sha256").update(token.toUpperCase().trim()).digest("hex");
const { tablesDB } = createAdminClient();
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.passwordResets,
queries: [
Query.equal("tokenHash", hash),
Query.greaterThan("expiresAt", new Date().toISOString()),
Query.limit(1),
],
});
if (result.total === 0) return { valid: false };
const row = result.rows[0] as unknown as { $id: string; userId: string; usedAt?: string };
if (row.usedAt) return { valid: false };
return { valid: true, tokenId: row.$id, userId: row.userId };
} catch {
return { valid: false };
}
}
export async function resetPasswordAction(
_prev: AuthState,
formData: FormData,
): Promise<AuthState> {
const token = String(formData.get("token") ?? "").trim();
const password = String(formData.get("password") ?? "");
const confirmPassword = String(formData.get("confirmPassword") ?? "");
if (!token || !password) return { ok: false, error: "Tüm alanlar zorunlu." };
if (password.length < 8) return { ok: false, error: "Şifre en az 8 karakter olmalı." };
if (password !== confirmPassword) return { ok: false, error: "Şifreler eşleşmiyor." };
const { valid, tokenId, userId } = await verifyResetToken(token);
if (!valid || !tokenId || !userId) {
return { ok: false, error: "Kod geçersiz veya süresi dolmuş." };
}
try {
const { tablesDB, users } = createAdminClient();
await users.updatePassword(userId, password);
await tablesDB.updateRow(DATABASE_ID, TABLES.passwordResets, tokenId, {
usedAt: new Date().toISOString(),
});
(await cookies()).delete(RESET_SESSION_COOKIE);
} catch {
return { ok: false, error: "Şifre güncellenemedi. Tekrar deneyin." };
}
redirect("/sign-in?reset=success");
}
+141
View File
@@ -0,0 +1,141 @@
"use server";
import { revalidatePath } from "next/cache";
import { AppwriteException } from "node-appwrite";
import { logAudit } from "./audit";
import { createSessionClient } from "./server";
import { getActiveTenantId } from "./tenant";
import type { ProfileState } from "./profile-types";
function appwriteError(e: unknown): string {
if (e instanceof AppwriteException) {
if (e.type === "user_invalid_credentials") return "Şifre hatalı.";
if (e.type === "user_password_mismatch") return "Şifreler eşleşmiyor.";
if (e.type === "user_email_already_exists")
return "Bu email zaten başka bir hesapta kullanımda.";
if (e.type === "user_password_recently_used")
return "Bu şifreyi yakın zamanda kullandınız, başka bir şifre seçin.";
if (e.type === "general_rate_limit_exceeded")
return "Çok fazla deneme. Birkaç dakika sonra tekrar deneyin.";
return e.message || "Beklenmeyen hata.";
}
return "Bağlantı hatası. Tekrar deneyin.";
}
async function audit(action: "update", entityType: string, changes: Record<string, unknown>) {
try {
const session = await createSessionClient();
const user = await session.account.get();
const tenantId = (await getActiveTenantId()) ?? "global";
await logAudit({
tenantId,
userId: user.$id,
action,
entityType,
entityId: user.$id,
changes,
});
} catch {
/* ignore */
}
}
export async function updateNameAction(
_prev: ProfileState,
formData: FormData,
): Promise<ProfileState> {
const name = String(formData.get("name") ?? "").trim();
if (!name) {
return { ok: false, error: "İsim boş olamaz.", fieldErrors: { name: "Zorunlu" } };
}
if (name.length > 128) {
return { ok: false, error: "İsim çok uzun." };
}
try {
const { account } = await createSessionClient();
await account.updateName(name);
await audit("update", "user_name", { name });
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
revalidatePath("/", "layout");
return { ok: true };
}
export async function updateEmailAction(
_prev: ProfileState,
formData: FormData,
): Promise<ProfileState> {
const email = String(formData.get("email") ?? "").trim().toLowerCase();
const password = String(formData.get("password") ?? "");
if (!email || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
return {
ok: false,
error: "Geçerli bir email girin.",
fieldErrors: { email: "Geçersiz" },
};
}
if (!password) {
return {
ok: false,
error: "Doğrulama için şifrenizi girin.",
fieldErrors: { password: "Zorunlu" },
};
}
try {
const { account } = await createSessionClient();
await account.updateEmail(email, password);
await audit("update", "user_email", { email });
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
revalidatePath("/", "layout");
return { ok: true };
}
export async function updatePasswordAction(
_prev: ProfileState,
formData: FormData,
): Promise<ProfileState> {
const oldPassword = String(formData.get("oldPassword") ?? "");
const newPassword = String(formData.get("newPassword") ?? "");
const confirmPassword = String(formData.get("confirmPassword") ?? "");
if (!oldPassword) {
return {
ok: false,
error: "Mevcut şifrenizi girin.",
fieldErrors: { oldPassword: "Zorunlu" },
};
}
if (newPassword.length < 8) {
return {
ok: false,
error: "Yeni şifre en az 8 karakter olmalı.",
fieldErrors: { newPassword: "En az 8 karakter" },
};
}
if (newPassword !== confirmPassword) {
return {
ok: false,
error: "Şifreler eşleşmiyor.",
fieldErrors: { confirmPassword: "Eşleşmiyor" },
};
}
try {
const { account } = await createSessionClient();
await account.updatePassword(newPassword, oldPassword);
await audit("update", "user_password", { changed: true });
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
return { ok: true };
}
+7
View File
@@ -0,0 +1,7 @@
export type ProfileState = {
ok: boolean;
error?: string;
fieldErrors?: Record<string, string>;
};
export const initialProfileState: ProfileState = { ok: false };
+185
View File
@@ -0,0 +1,185 @@
export const DATABASE_ID = "lab";
export const BUCKETS = {
tenantLogos: "tenant-logos",
jobFiles: "job-files",
} as const;
export const TABLES = {
tenantSettings: "tenant_settings",
profiles: "profiles",
connections: "connections",
jobs: "jobs",
jobFiles: "job_files",
jobStatusHistory: "job_status_history",
prosthetics: "prosthetics",
financeEntries: "finance_entries",
notifications: "notifications",
auditLogs: "audit_logs",
inviteLinks: "invite_links",
passwordResets: "password_resets",
userPreferences: "user_preferences",
} as const;
export type TableId = (typeof TABLES)[keyof typeof TABLES];
export type SystemRow = {
$id: string;
$createdAt: string;
$updatedAt: string;
$permissions: string[];
$databaseId?: string;
$tableId?: string;
$sequence?: number;
};
type Row = SystemRow;
export type TenantRole = "owner" | "admin" | "member";
export type TenantKind = "lab" | "clinic";
export interface TenantSettings extends Row {
tenantId: string;
kind: TenantKind;
memberNumber: string;
companyName: string;
companyTaxId?: string;
companyAddress?: string;
companyEmail?: string;
companyPhone?: string;
logo?: string;
defaultCurrency?: string;
}
export interface Profile extends Row {
tenantId: string;
userId: string;
displayName?: string;
phone?: string;
title?: string;
}
export type ConnectionStatus = "pending" | "approved" | "rejected";
export interface Connection extends Row {
clinicTenantId: string;
labTenantId: string;
status: ConnectionStatus;
requestedBy: string;
requestedAt: string;
approvedAt?: string;
rejectedAt?: string;
}
export type JobStatus = "pending" | "in_progress" | "sent" | "delivered" | "cancelled";
export type JobStep = "olcu" | "alt_yapi_prova" | "ust_yapi_prova" | "cila_bitim";
export type ProstheticType =
| "metal_porselen"
| "zirkonyum"
| "implant_ustu_zirkonyum"
| "gecici"
| "e_max"
| "diger";
export interface Job extends Row {
clinicTenantId: string;
labTenantId: string;
createdBy: string;
patientCode: string;
prostheticType: ProstheticType;
memberCount: number;
color?: string;
description?: string;
price?: number;
currency?: string;
status: JobStatus;
currentStep?: JobStep;
dueDate?: string;
}
export type JobFileKind = "scan" | "image" | "document";
export interface JobFile extends Row {
jobId: string;
clinicTenantId: string;
labTenantId: string;
uploadedBy: string;
kind: JobFileKind;
fileId: string;
name: string;
size: number;
mimeType?: string;
}
export interface JobStatusHistory extends Row {
jobId: string;
clinicTenantId: string;
labTenantId: string;
step: JobStep;
completedBy: string;
completedAt: string;
note?: string;
}
export interface Prosthetic extends Row {
tenantId: string;
createdBy: string;
name: string;
type: ProstheticType;
unitPrice: number;
currency?: string;
archived?: boolean;
}
export type FinanceType = "income" | "expense" | "receivable" | "payable";
export type FinanceStatus = "pending" | "paid" | "cancelled";
export interface FinanceEntry extends Row {
tenantId: string;
createdBy: string;
jobId?: string;
counterpartTenantId?: string;
type: FinanceType;
amount: number;
currency?: string;
status: FinanceStatus;
date: string;
description?: string;
}
export interface Notification extends Row {
tenantId: string;
userId?: string;
jobId?: string;
connectionId?: string;
message: string;
read: boolean;
}
export type AuditAction = "create" | "update" | "delete";
export interface AuditLog extends Row {
tenantId: string;
userId: string;
action: AuditAction;
entityType: string;
entityId: string;
changes?: string;
ipAddress?: string;
userAgent?: string;
}
export type InviteRole = "admin" | "member";
export type InviteStatus = "pending" | "accepted" | "cancelled" | "expired";
export interface InviteLink extends Row {
tenantId: string;
code: string;
email: string;
role?: InviteRole;
status?: InviteStatus;
invitedBy: string;
expiresAt?: string;
acceptedAt?: string;
acceptedBy?: string;
}
+71
View File
@@ -0,0 +1,71 @@
import "server-only";
import { cookies } from "next/headers";
import {
Account,
Client,
Databases,
Messaging,
Storage,
TablesDB,
Teams,
Users,
} from "node-appwrite";
const endpoint = process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT;
const projectId = process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID;
const apiKey = process.env.APPWRITE_API_KEY;
if (!endpoint || !projectId) {
throw new Error(
"Missing NEXT_PUBLIC_APPWRITE_ENDPOINT or NEXT_PUBLIC_APPWRITE_PROJECT_ID. Check .env.local.",
);
}
export const APPWRITE_SESSION_COOKIE = "lab-session";
function baseClient() {
return new Client().setEndpoint(endpoint!).setProject(projectId!);
}
export function createAdminClient() {
if (!apiKey) {
throw new Error("Missing APPWRITE_API_KEY. Required for admin operations.");
}
const client = baseClient().setKey(apiKey);
return {
client,
account: new Account(client),
teams: new Teams(client),
users: new Users(client),
databases: new Databases(client),
tablesDB: new TablesDB(client),
storage: new Storage(client),
messaging: new Messaging(client),
};
}
export async function createSessionClient() {
const session = (await cookies()).get(APPWRITE_SESSION_COOKIE);
if (!session?.value) {
throw new Error("No active session.");
}
const client = baseClient().setSession(session.value);
return {
client,
account: new Account(client),
teams: new Teams(client),
databases: new Databases(client),
tablesDB: new TablesDB(client),
storage: new Storage(client),
};
}
export async function getCurrentUser() {
try {
const { account } = await createSessionClient();
return await account.get();
} catch {
return null;
}
}
+16
View File
@@ -0,0 +1,16 @@
import "server-only";
import { BUCKETS } from "./schema";
const ENDPOINT = process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT?.replace(/\/$/, "") ?? "";
const PROJECT_ID = process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID ?? "";
export function getFileViewUrl(bucketId: string, fileId: string): string {
if (!ENDPOINT || !PROJECT_ID || !fileId) return "";
return `${ENDPOINT}/storage/buckets/${bucketId}/files/${fileId}/view?project=${PROJECT_ID}`;
}
export function getLogoUrl(fileId?: string | null): string | null {
if (!fileId) return null;
return getFileViewUrl(BUCKETS.tenantLogos, fileId);
}
+474
View File
@@ -0,0 +1,474 @@
"use server";
import { cookies } from "next/headers";
import { revalidatePath } from "next/cache";
import { AppwriteException, ID, Permission, Query, Role } from "node-appwrite";
import { logAudit } from "./audit";
import { DATABASE_ID, TABLES, type InviteLink, type InviteRole } from "./schema";
import { createAdminClient, createSessionClient } from "./server";
import { requireRole, requireTenant } from "./tenant-guard";
import type { InviteState, MemberActionState } from "./team-types";
import { ACTIVE_TENANT_COOKIE } from "./tenant-types";
const APP_URL = process.env.APP_URL ?? "http://localhost:3000";
const INVITE_TTL_DAYS = 7;
function appwriteError(e: unknown): string {
if (e instanceof AppwriteException) {
if (e.type === "team_invite_already_exists") return "Bu kişi zaten davetli.";
if (e.type === "team_membership_already_confirmed") return "Bu kişi zaten ekipte.";
if (e.type === "user_not_found") return "Kullanıcı bulunamadı.";
return e.message || "Beklenmeyen bir hata oluştu.";
}
return "Bağlantı hatası. Tekrar deneyin.";
}
function generateCode(length = 8): string {
const chars = "abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789";
let out = "";
const bytes = new Uint8Array(length);
crypto.getRandomValues(bytes);
for (let i = 0; i < length; i++) out += chars[bytes[i] % chars.length];
return out;
}
async function findUserByEmail(email: string) {
const { users } = createAdminClient();
const result = await users.list({
queries: [Query.equal("email", email), Query.limit(1)],
});
return result.users[0] ?? null;
}
export async function inviteMemberAction(
_prev: InviteState,
formData: FormData,
): Promise<InviteState> {
const email = String(formData.get("email") ?? "").trim().toLowerCase();
const role = (String(formData.get("role") ?? "member") as InviteRole) === "admin"
? "admin"
: "member";
if (!email || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
return { ok: false, error: "Geçerli bir email girin." };
}
let ctx;
try {
ctx = await requireTenant();
requireRole(ctx, ["owner", "admin"]);
} catch {
return { ok: false, error: "Üye davet etme yetkiniz yok." };
}
if (email === ctx.user.email.toLowerCase()) {
return { ok: false, error: "Kendinizi davet edemezsiniz." };
}
const admin = createAdminClient();
// 1. Kullanıcı zaten Appwrite'ta var mı?
let existingUser = null;
try {
existingUser = await findUserByEmail(email);
} catch {
/* ignore */
}
// 2. Zaten ekipte mi?
if (existingUser) {
try {
const memberships = await admin.teams.listMemberships(ctx.tenantId);
if (memberships.memberships.some((m) => m.userId === existingUser!.$id)) {
return { ok: false, error: "Bu kullanıcı zaten ekipte." };
}
} catch {
/* ignore */
}
}
// 3. Pending davet var mı?
try {
const existing = await admin.tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.inviteLinks,
queries: [
Query.equal("tenantId", ctx.tenantId),
Query.equal("email", email),
Query.equal("status", "pending"),
Query.limit(1),
],
});
const found = existing.rows[0] as unknown as InviteLink | undefined;
if (found) {
return {
ok: true,
shortUrl: `${APP_URL}/d/${found.code}`,
message: "Bu email için zaten aktif bir davet var. Mevcut linki kopyaladık.",
};
}
} catch {
/* continue */
}
// 4. Yeni davet linki üret
const code = generateCode(8);
const expiresAt = new Date(Date.now() + INVITE_TTL_DAYS * 24 * 60 * 60 * 1000).toISOString();
try {
const row = await admin.tablesDB.createRow(
DATABASE_ID,
TABLES.inviteLinks,
ID.unique(),
{
tenantId: ctx.tenantId,
code,
email,
role,
status: "pending",
invitedBy: ctx.user.id,
expiresAt,
},
[
Permission.read(Role.team(ctx.tenantId)),
Permission.update(Role.team(ctx.tenantId, "owner")),
Permission.update(Role.team(ctx.tenantId, "admin")),
Permission.delete(Role.team(ctx.tenantId, "owner")),
Permission.delete(Role.team(ctx.tenantId, "admin")),
],
);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "create",
entityType: "invite",
entityId: row.$id,
changes: { email, role },
});
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
revalidatePath("/settings/members");
return {
ok: true,
shortUrl: `${APP_URL}/d/${code}`,
message: existingUser
? "Davet linki oluşturuldu. Linki bu kişiye iletin; tıklayınca giriş yapıp katılabilir."
: "Davet linki oluşturuldu. Linki bu kişiye iletin; tıklayınca hesap açıp katılabilir.",
};
}
export async function cancelInviteAction(
_prev: MemberActionState,
formData: FormData,
): Promise<MemberActionState> {
const inviteId = String(formData.get("inviteId") ?? "");
if (!inviteId) return { ok: false, error: "Davet bulunamadı." };
let ctx;
try {
ctx = await requireTenant();
requireRole(ctx, ["owner", "admin"]);
} catch {
return { ok: false, error: "Yetkiniz yok." };
}
try {
const { tablesDB } = createAdminClient();
await tablesDB.updateRow(DATABASE_ID, TABLES.inviteLinks, inviteId, {
status: "cancelled",
});
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "update",
entityType: "invite",
entityId: inviteId,
changes: { status: "cancelled" },
});
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
revalidatePath("/settings/members");
return { ok: true };
}
export async function removeMemberAction(
_prev: MemberActionState,
formData: FormData,
): Promise<MemberActionState> {
const membershipId = String(formData.get("membershipId") ?? "");
if (!membershipId) return { ok: false, error: "Üyelik bulunamadı." };
let ctx;
try {
ctx = await requireTenant();
requireRole(ctx, ["owner", "admin"]);
} catch {
return { ok: false, error: "Yetkiniz yok." };
}
try {
const { teams } = createAdminClient();
const memberships = await teams.listMemberships(ctx.tenantId);
const target = memberships.memberships.find((m) => m.$id === membershipId);
if (!target) return { ok: false, error: "Üyelik bulunamadı." };
if (target.userId === ctx.user.id) {
return { ok: false, error: "Kendinizi çıkaramazsınız." };
}
if (target.roles.includes("owner") && ctx.role !== "owner") {
return { ok: false, error: "Sahibi yalnızca başka bir sahip kaldırabilir." };
}
await teams.deleteMembership(ctx.tenantId, membershipId);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "delete",
entityType: "membership",
entityId: membershipId,
changes: { userEmail: target.userEmail },
});
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
revalidatePath("/settings/members");
return { ok: true };
}
/**
* Self-leave: the current user removes themselves from the active tenant.
* - If they're the only owner, the call is refused (would leave the workspace
* ownerless).
* - On success the active-tenant cookie + prefs.activeTenant are cleared so
* the next request goes through fallback to another team or onboarding.
*/
export async function leaveWorkspaceAction(): Promise<MemberActionState> {
let ctx;
try {
ctx = await requireTenant();
} catch {
return { ok: false, error: "Yetkiniz yok." };
}
try {
const admin = createAdminClient();
const memberships = await admin.teams.listMemberships(ctx.tenantId);
const me = memberships.memberships.find((m) => m.userId === ctx.user.id);
if (!me) return { ok: false, error: "Üyelik bulunamadı." };
if (me.roles.includes("owner")) {
const otherOwners = memberships.memberships.filter(
(m) => m.userId !== ctx.user.id && m.roles.includes("owner"),
);
if (otherOwners.length === 0) {
return {
ok: false,
error:
"Tek sahip olduğunuz için ayrılamazsınız. Önce başka bir üyeyi sahip yapın.",
};
}
}
await admin.teams.deleteMembership(ctx.tenantId, me.$id);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "delete",
entityType: "membership",
entityId: me.$id,
changes: { self: true, userEmail: ctx.user.email },
});
// Clear active-tenant pointers so the user lands somewhere safe next request.
try {
const session = await createSessionClient();
const user = await session.account.get();
const prefs = { ...(user.prefs as Record<string, unknown>) };
delete prefs.activeTenant;
await session.account.updatePrefs(prefs);
} catch {
/* ignore */
}
try {
(await cookies()).delete(ACTIVE_TENANT_COOKIE);
} catch {
/* ignore */
}
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
revalidatePath("/", "layout");
return { ok: true };
}
export async function updateMemberRoleAction(
_prev: MemberActionState,
formData: FormData,
): Promise<MemberActionState> {
const membershipId = String(formData.get("membershipId") ?? "");
const role = String(formData.get("role") ?? "");
if (!membershipId || !["admin", "member"].includes(role)) {
return { ok: false, error: "Geçersiz parametre." };
}
let ctx;
try {
ctx = await requireTenant();
requireRole(ctx, ["owner"]);
} catch {
return { ok: false, error: "Sadece sahibi rol değiştirebilir." };
}
try {
const { teams } = createAdminClient();
await teams.updateMembership(ctx.tenantId, membershipId, [role]);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "update",
entityType: "membership",
entityId: membershipId,
changes: { role },
});
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
revalidatePath("/settings/members");
return { ok: true };
}
/**
* Resolve a short invite code to its row. Used by /d/[code] (public-ish).
* Returns minimal info; callers should not pass full row to client.
*/
export async function resolveInviteCode(code: string): Promise<InviteLink | null> {
if (!code) return null;
try {
const { tablesDB } = createAdminClient();
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.inviteLinks,
queries: [Query.equal("code", code), Query.limit(1)],
});
return (result.rows[0] as unknown as InviteLink) ?? null;
} catch {
return null;
}
}
/**
* Accept an invite for the currently logged-in user. Caller is responsible
* for ensuring session.email matches invite.email (we double-check here too).
*/
export async function acceptInviteAction(code: string): Promise<MemberActionState> {
if (!code) return { ok: false, error: "Geçersiz davet linki." };
let user: Awaited<ReturnType<Awaited<ReturnType<typeof createSessionClient>>["account"]["get"]>>;
try {
const session = await createSessionClient();
user = await session.account.get();
} catch {
return { ok: false, error: "Önce giriş yapmanız gerekiyor." };
}
const invite = await resolveInviteCode(code);
if (!invite) return { ok: false, error: "Davet bulunamadı." };
if (invite.status !== "pending") {
return { ok: false, error: "Bu davet artık geçerli değil." };
}
if (invite.expiresAt && new Date(invite.expiresAt).getTime() < Date.now()) {
return { ok: false, error: "Bu davetin süresi dolmuş." };
}
if (invite.email.toLowerCase() !== user.email.toLowerCase()) {
return {
ok: false,
error: `Bu davet ${invite.email} adresine gönderilmiş; mevcut hesabınız (${user.email}) eşleşmiyor.`,
};
}
const admin = createAdminClient();
const activeTenantId = invite.tenantId;
async function activateTenant() {
try {
const session = await createSessionClient();
await session.account.updatePrefs({
...(user.prefs ?? {}),
activeTenant: activeTenantId,
});
} catch {
/* ignore */
}
try {
(await cookies()).set(ACTIVE_TENANT_COOKIE, activeTenantId, {
path: "/",
httpOnly: true,
sameSite: "strict",
secure: process.env.NODE_ENV === "production",
maxAge: 60 * 60 * 24 * 365,
});
} catch {
/* ignore */
}
}
// Already a member? Mark accepted, redirect.
try {
const memberships = await admin.teams.listMemberships(invite.tenantId);
const existing = memberships.memberships.find((m) => m.userId === user.$id);
if (existing) {
await admin.tablesDB.updateRow(DATABASE_ID, TABLES.inviteLinks, invite.$id, {
status: "accepted",
acceptedAt: new Date().toISOString(),
acceptedBy: user.$id,
});
await activateTenant();
return { ok: true };
}
} catch {
/* ignore */
}
try {
await admin.teams.createMembership(
invite.tenantId,
[invite.role ?? "member"],
undefined,
user.$id,
);
await admin.tablesDB.updateRow(DATABASE_ID, TABLES.inviteLinks, invite.$id, {
status: "accepted",
acceptedAt: new Date().toISOString(),
acceptedBy: user.$id,
});
await logAudit({
tenantId: invite.tenantId,
userId: user.$id,
action: "create",
entityType: "membership",
entityId: invite.$id,
changes: { via: "invite", code },
});
await activateTenant();
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
return { ok: true };
}
+15
View File
@@ -0,0 +1,15 @@
export type InviteState = {
ok: boolean;
error?: string;
shortUrl?: string;
message?: string;
};
export const initialInviteState: InviteState = { ok: false };
export type MemberActionState = {
ok: boolean;
error?: string;
};
export const initialMemberState: MemberActionState = { ok: false };
+209
View File
@@ -0,0 +1,209 @@
"use server";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { AppwriteException, ID, Permission, Role, Query } from "node-appwrite";
import { createAdminClient, createSessionClient } from "./server";
import { DATABASE_ID, TABLES, type TenantKind } from "./schema";
import { ACTIVE_TENANT_COOKIE, type WorkspaceState } from "./tenant-types";
const MEMBER_NUMBER_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
function generateMemberNumber(): string {
let out = "";
for (let i = 0; i < 6; i++) {
out += MEMBER_NUMBER_ALPHABET[Math.floor(Math.random() * MEMBER_NUMBER_ALPHABET.length)];
}
return out;
}
async function reserveUniqueMemberNumber(
tablesDB: ReturnType<typeof createAdminClient>["tablesDB"],
): Promise<string> {
for (let attempt = 0; attempt < 8; attempt++) {
const candidate = generateMemberNumber();
const existing = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.tenantSettings,
queries: [Query.equal("memberNumber", candidate), Query.limit(1)],
});
if (existing.total === 0) return candidate;
}
throw new Error("MEMBER_NUMBER_GENERATION_FAILED");
}
function appwriteError(e: unknown): string {
if (e instanceof AppwriteException) {
if (e.type === "team_already_exists") return "Bu isimde bir çalışma alanı zaten var.";
if (e.type === "general_unauthorized_scope") return "Yetki hatası. Tekrar giriş yapın.";
return e.message || "Beklenmeyen bir hata oluştu.";
}
return "Bağlantı hatası. Tekrar deneyin.";
}
async function setActiveTenantCookie(tenantId: string) {
(await cookies()).set(ACTIVE_TENANT_COOKIE, tenantId, {
path: "/",
httpOnly: true,
sameSite: "strict",
secure: process.env.NODE_ENV === "production",
maxAge: 60 * 60 * 24 * 365,
});
}
export async function createWorkspaceAction(
_prev: WorkspaceState,
formData: FormData,
): Promise<WorkspaceState> {
const companyName = String(formData.get("companyName") ?? "").trim();
const companyTaxId = String(formData.get("companyTaxId") ?? "").trim() || undefined;
const companyPhone = String(formData.get("companyPhone") ?? "").trim() || undefined;
const rawKind = String(formData.get("kind") ?? "").trim();
const kind: TenantKind | null =
rawKind === "lab" || rawKind === "clinic" ? rawKind : null;
if (!companyName) {
return { ok: false, error: "Şirket adı zorunlu." };
}
if (!kind) {
return { ok: false, error: "Klinik veya laboratuvar seçimi zorunlu." };
}
let teamId: string | null = null;
const admin = createAdminClient();
try {
const session = await createSessionClient();
const user = await session.account.get();
const team = await session.teams.create(ID.unique(), companyName, ["owner"]);
teamId = team.$id;
const memberNumber = await reserveUniqueMemberNumber(admin.tablesDB);
await admin.tablesDB.createRow(
DATABASE_ID,
TABLES.tenantSettings,
ID.unique(),
{
tenantId: teamId,
kind,
memberNumber,
companyName,
companyTaxId,
companyPhone,
},
[
Permission.read(Role.team(teamId)),
Permission.update(Role.team(teamId, "owner")),
Permission.update(Role.team(teamId, "admin")),
Permission.delete(Role.team(teamId, "owner")),
],
);
await session.account.updatePrefs({
...(user.prefs ?? {}),
activeTenant: teamId,
});
await setActiveTenantCookie(teamId);
} catch (e) {
if (teamId) {
try {
await admin.teams.delete(teamId);
} catch {
// best-effort cleanup; original error is what we surface
}
}
return { ok: false, error: appwriteError(e) };
}
redirect("/dashboard");
}
export async function importWorkspaceAction(
_prev: WorkspaceState,
formData: FormData,
): Promise<WorkspaceState> {
const teamId = String(formData.get("teamId") ?? "").trim();
const rawKind = String(formData.get("kind") ?? "").trim();
const kind: TenantKind | null =
rawKind === "lab" || rawKind === "clinic" ? rawKind : null;
if (!teamId) return { ok: false, error: "Geçersiz istek." };
if (!kind) return { ok: false, error: "Klinik veya laboratuvar seçimi zorunlu." };
try {
const { account, teams, tablesDB } = await createSessionClient();
const user = await account.get();
const allTeams = await teams.list();
const team = allTeams.teams.find((t) => t.$id === teamId);
if (!team) return { ok: false, error: "Çalışma alanı bulunamadı." };
const existing = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.tenantSettings,
queries: [Query.equal("tenantId", teamId), Query.limit(1)],
});
if (existing.total > 0) return { ok: false, error: "Bu çalışma alanı zaten mevcut." };
const admin = createAdminClient();
const memberNumber = await reserveUniqueMemberNumber(admin.tablesDB);
await admin.tablesDB.createRow(
DATABASE_ID,
TABLES.tenantSettings,
ID.unique(),
{
tenantId: teamId,
kind,
memberNumber,
companyName: team.name,
defaultCurrency: "TRY",
},
[
Permission.read(Role.team(teamId)),
Permission.update(Role.team(teamId, "owner")),
Permission.update(Role.team(teamId, "admin")),
Permission.delete(Role.team(teamId, "owner")),
],
);
await account.updatePrefs({ ...(user.prefs ?? {}), activeTenant: teamId });
await setActiveTenantCookie(teamId);
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
redirect("/dashboard");
}
export async function setActiveTenantAction(tenantId: string) {
try {
const { account, teams, tablesDB } = await createSessionClient();
const user = await account.get();
const allTeams = await teams.list();
const isMember = allTeams.teams.some((t) => t.$id === tenantId);
if (!isMember) {
return { ok: false, error: "Bu çalışma alanına erişiminiz yok." };
}
// Verify this team belongs to this app (not a team from another KovakSoft product)
const settingsCheck = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.tenantSettings,
queries: [Query.equal("tenantId", tenantId), Query.limit(1)],
});
if (settingsCheck.total === 0) {
return { ok: false, error: "Bu çalışma alanına erişiminiz yok." };
}
await account.updatePrefs({ ...(user.prefs ?? {}), activeTenant: tenantId });
await setActiveTenantCookie(tenantId);
return { ok: true };
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
}
+96
View File
@@ -0,0 +1,96 @@
import "server-only";
import { cookies } from "next/headers";
import { Query } from "node-appwrite";
import { createAdminClient, getCurrentUser } from "./server";
import { DATABASE_ID, TABLES, type TenantKind, type TenantSettings } from "./schema";
import { ACTIVE_TENANT_COOKIE } from "./tenant-types";
import { getActiveTenantId, getUserTeams } from "./tenant";
export type TenantRole = "owner" | "admin" | "member";
export type TenantContext = {
user: { id: string; name: string; email: string };
tenantId: string;
role: TenantRole;
kind: TenantKind | null;
settings: TenantSettings | null;
};
function pickHighestRole(roles: string[]): TenantRole | null {
if (roles.includes("owner")) return "owner";
if (roles.includes("admin")) return "admin";
if (roles.includes("member")) return "member";
return null;
}
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();
const memberships = await teams.listMemberships(tenantId);
const membership = memberships.memberships.find((m) => m.userId === user.$id);
if (!membership) throw new Error("NOT_A_MEMBER");
const role = pickHighestRole(membership.roles) ?? "member";
let settings: TenantSettings | null = null;
try {
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.tenantSettings,
queries: [Query.equal("tenantId", tenantId), Query.limit(1)],
});
settings = (result.rows[0] as unknown as TenantSettings) ?? null;
} catch {
settings = null;
}
return {
user: { id: user.$id, name: user.name, email: user.email },
tenantId,
role,
kind: settings?.kind ?? null,
settings,
};
}
export function requireRole(ctx: TenantContext, allowed: TenantRole[]): void {
if (!allowed.includes(ctx.role)) {
throw new Error("FORBIDDEN");
}
}
export function requireTenantKind(
ctx: TenantContext,
allowed: TenantKind[],
): void {
if (!ctx.kind || !allowed.includes(ctx.kind)) {
throw new Error("FORBIDDEN_KIND");
}
}
+8
View File
@@ -0,0 +1,8 @@
export type WorkspaceState = {
ok: boolean;
error?: string;
};
export const initialWorkspaceState: WorkspaceState = { ok: false };
export const ACTIVE_TENANT_COOKIE = "lab-tenant";
+70
View File
@@ -0,0 +1,70 @@
import "server-only";
import { cookies } from "next/headers";
import { Query } from "node-appwrite";
import { createSessionClient } from "./server";
import { ACTIVE_TENANT_COOKIE } from "./tenant-types";
import { DATABASE_ID, TABLES } from "./schema";
export async function getUserTeams() {
try {
const { teams, tablesDB } = await createSessionClient();
const allTeams = await teams.list();
if (allTeams.teams.length === 0) return allTeams;
// Filter to only teams that belong to this app (have a tenant_settings row in this database)
const teamIds = allTeams.teams.map((t) => t.$id);
const settings = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.tenantSettings,
queries: [Query.equal("tenantId", teamIds), Query.limit(100)],
});
const validIds = new Set(settings.rows.map((r) => r.tenantId as string));
const filtered = allTeams.teams.filter((t) => validIds.has(t.$id));
return { ...allTeams, teams: filtered, total: filtered.length };
} catch {
return null;
}
}
export async function getCrossAppTeams(): Promise<Array<{ $id: string; name: string }>> {
try {
const { teams, tablesDB } = await createSessionClient();
const allTeams = await teams.list();
if (allTeams.teams.length === 0) return [];
const teamIds = allTeams.teams.map((t) => t.$id);
const settings = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.tenantSettings,
queries: [Query.equal("tenantId", teamIds), Query.limit(100)],
});
const thisAppIds = new Set(settings.rows.map((r) => r.tenantId as string));
return allTeams.teams
.filter((t) => !thisAppIds.has(t.$id))
.map((t) => ({ $id: t.$id, name: t.name }));
} catch {
return [];
}
}
export async function getActiveTenantId(): Promise<string | null> {
const cookie = (await cookies()).get(ACTIVE_TENANT_COOKIE)?.value;
if (cookie) return cookie;
try {
const { account } = await createSessionClient();
const user = await account.get();
const fromPrefs = (user.prefs as Record<string, unknown> | undefined)?.activeTenant;
if (typeof fromPrefs === "string" && fromPrefs.length > 0) return fromPrefs;
} catch {
return null;
}
return null;
}
+118
View File
@@ -0,0 +1,118 @@
"use server";
import { ID, Permission, Query, Role } from "node-appwrite";
import { createAdminClient, createSessionClient } from "./server";
import { DATABASE_ID, TABLES } from "./schema";
export interface UserPrefs {
theme?: "dark" | "light" | "system";
colorTheme?: string;
tweakcnTheme?: string;
radius?: string;
sidebarVariant?: "sidebar" | "floating" | "inset";
sidebarCollapsible?: "offcanvas" | "icon" | "none";
sidebarSide?: "left" | "right";
}
export async function getUserPrefs(): Promise<UserPrefs> {
try {
const { account } = await createSessionClient();
const user = await account.get();
const { tablesDB } = createAdminClient();
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.userPreferences,
queries: [Query.equal("userId", user.$id), Query.limit(1)],
});
if (result.rows.length === 0) {
// Pre-create an empty row so saveUserPrefsAction always calls updateRow
// (createRow inside a Server Action causes router cache invalidation → remount loop)
try {
await tablesDB.createRow(
DATABASE_ID,
TABLES.userPreferences,
ID.unique(),
{ userId: user.$id },
[
Permission.read(Role.user(user.$id)),
Permission.update(Role.user(user.$id)),
Permission.delete(Role.user(user.$id)),
],
);
} catch {
// race condition or already exists — fine
}
return {};
}
const row = result.rows[0] as Record<string, unknown>;
const str = (v: unknown) => (v && typeof v === "string" ? v : undefined);
return {
theme: (row.theme as UserPrefs["theme"]) ?? undefined,
colorTheme: str(row.colorTheme),
tweakcnTheme: str(row.tweakcnTheme),
radius: str(row.radius),
sidebarVariant: (row.sidebarVariant as UserPrefs["sidebarVariant"]) ?? undefined,
sidebarCollapsible: (row.sidebarCollapsible as UserPrefs["sidebarCollapsible"]) ?? undefined,
sidebarSide: (row.sidebarSide as UserPrefs["sidebarSide"]) ?? undefined,
};
} catch {
return {};
}
}
export async function saveUserPrefsAction(
update: Partial<UserPrefs>,
): Promise<{ ok: boolean; error?: string }> {
try {
const { account } = await createSessionClient();
const user = await account.get();
const { tablesDB } = createAdminClient();
// undefined → skip, "" → null (Appwrite rejects empty strings on nullable attrs)
const clean: Record<string, unknown> = {};
for (const [k, v] of Object.entries(update)) {
if (v === undefined) continue;
clean[k] = v === "" ? null : v;
}
if (Object.keys(clean).length === 0) return { ok: true };
const existing = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.userPreferences,
queries: [Query.equal("userId", user.$id), Query.limit(1)],
});
const perms = [
Permission.read(Role.user(user.$id)),
Permission.update(Role.user(user.$id)),
Permission.delete(Role.user(user.$id)),
];
if (existing.rows.length === 0) {
await tablesDB.createRow(
DATABASE_ID,
TABLES.userPreferences,
ID.unique(),
{ userId: user.$id, ...clean },
perms,
);
} else {
await tablesDB.updateRow(
DATABASE_ID,
TABLES.userPreferences,
existing.rows[0].$id,
clean,
);
}
return { ok: true };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error("[saveUserPrefsAction]", msg);
return { ok: false, error: msg };
}
}
+105
View File
@@ -0,0 +1,105 @@
"use server";
import { revalidatePath } from "next/cache";
import { AppwriteException, ID, Permission, Query, Role } from "node-appwrite";
import { z } from "zod";
import { logAudit } from "./audit";
import { DATABASE_ID, TABLES, type TenantSettings } from "./schema";
import { createAdminClient } from "./server";
import { requireRole, requireTenant } from "./tenant-guard";
import type { WorkspaceSettingsState } from "./workspace-types";
import { workspaceSettingsSchema } from "@/lib/validation/workspace";
function appwriteError(e: unknown): string {
if (e instanceof AppwriteException) return e.message || "Beklenmeyen hata.";
return "Bağlantı hatası. Tekrar deneyin.";
}
function flattenErrors(err: z.ZodError): Record<string, string> {
const out: Record<string, string> = {};
for (const issue of err.issues) {
const key = issue.path.join(".");
if (key && !out[key]) out[key] = issue.message;
}
return out;
}
function pickFormFields(formData: FormData) {
return {
companyName: String(formData.get("companyName") ?? "").trim(),
companyTaxId: String(formData.get("companyTaxId") ?? "").trim(),
companyAddress: String(formData.get("companyAddress") ?? "").trim(),
companyEmail: String(formData.get("companyEmail") ?? "").trim(),
companyPhone: String(formData.get("companyPhone") ?? "").trim(),
defaultCurrency: String(formData.get("defaultCurrency") ?? "").trim(),
};
}
export async function updateWorkspaceSettingsAction(
_prev: WorkspaceSettingsState,
formData: FormData,
): Promise<WorkspaceSettingsState> {
let ctx;
try {
ctx = await requireTenant();
requireRole(ctx, ["owner", "admin"]);
} catch {
return { ok: false, error: "Düzenleme yetkiniz yok." };
}
const parsed = workspaceSettingsSchema.safeParse(pickFormFields(formData));
if (!parsed.success) {
return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) };
}
try {
const { tablesDB } = createAdminClient();
const existing = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.tenantSettings,
queries: [Query.equal("tenantId", ctx.tenantId), Query.limit(1)],
});
const row = existing.rows[0] as unknown as TenantSettings | undefined;
if (row) {
await tablesDB.updateRow(DATABASE_ID, TABLES.tenantSettings, row.$id, parsed.data);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "update",
entityType: "tenant_settings",
entityId: row.$id,
changes: parsed.data,
});
} else {
const created = await tablesDB.createRow(
DATABASE_ID,
TABLES.tenantSettings,
ID.unique(),
{ tenantId: ctx.tenantId, ...parsed.data },
[
Permission.read(Role.team(ctx.tenantId)),
Permission.update(Role.team(ctx.tenantId, "owner")),
Permission.update(Role.team(ctx.tenantId, "admin")),
Permission.delete(Role.team(ctx.tenantId, "owner")),
],
);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "create",
entityType: "tenant_settings",
entityId: created.$id,
changes: parsed.data,
});
}
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
// Tenant name shows up everywhere — revalidate broadly
revalidatePath("/", "layout");
return { ok: true };
}
+7
View File
@@ -0,0 +1,7 @@
export type WorkspaceSettingsState = {
ok: boolean;
error?: string;
fieldErrors?: Record<string, string>;
};
export const initialWorkspaceSettingsState: WorkspaceSettingsState = { ok: false };