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:
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export type AuthState = {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export const initialAuthState: AuthState = { ok: false };
|
||||
@@ -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);
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export type LogoActionState = {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export const initialLogoState: LogoActionState = { ok: false };
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export type ProfileState = {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
fieldErrors?: Record<string, string>;
|
||||
};
|
||||
|
||||
export const initialProfileState: ProfileState = { ok: false };
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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) };
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export type WorkspaceState = {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export const initialWorkspaceState: WorkspaceState = { ok: false };
|
||||
|
||||
export const ACTIVE_TENANT_COOKIE = "lab-tenant";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export type WorkspaceSettingsState = {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
fieldErrors?: Record<string, string>;
|
||||
};
|
||||
|
||||
export const initialWorkspaceSettingsState: WorkspaceSettingsState = { ok: false };
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Inter } from 'next/font/google'
|
||||
|
||||
// Configure Inter font to match exactly what Next.js optimizes for
|
||||
export const inter = Inter({
|
||||
subsets: ['latin'],
|
||||
display: 'swap',
|
||||
variable: '--font-inter',
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
export function formatTRY(amount: number): string {
|
||||
return new Intl.NumberFormat("tr-TR", {
|
||||
style: "currency",
|
||||
currency: "TRY",
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
export function formatCurrency(amount: number, currency = "TRY"): string {
|
||||
return new Intl.NumberFormat("tr-TR", {
|
||||
style: "currency",
|
||||
currency,
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
export function formatDate(iso: string | undefined | null): string {
|
||||
if (!iso) return "—";
|
||||
return new Date(iso).toLocaleDateString("tr-TR", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export function formatDateTime(iso: string | undefined | null): string {
|
||||
if (!iso) return "—";
|
||||
return new Date(iso).toLocaleString("tr-TR", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
export const BILLING_PERIOD_LABEL: Record<string, string> = {
|
||||
monthly: "Aylık",
|
||||
yearly: "Yıllık",
|
||||
onetime: "Tek seferlik",
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
const STORAGE_KEY = "lab-theme-prefs"
|
||||
|
||||
export interface LocalThemePrefs {
|
||||
colorTheme?: string
|
||||
tweakcnTheme?: string
|
||||
radius?: string
|
||||
sidebarVariant?: string
|
||||
sidebarCollapsible?: string
|
||||
sidebarSide?: string
|
||||
}
|
||||
|
||||
export function getLocalThemePrefs(): LocalThemePrefs {
|
||||
if (typeof window === "undefined") return {}
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
return raw ? (JSON.parse(raw) as LocalThemePrefs) : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export function saveLocalThemePrefs(update: Partial<LocalThemePrefs>): void {
|
||||
if (typeof window === "undefined") return
|
||||
try {
|
||||
const existing = getLocalThemePrefs()
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ ...existing, ...update }))
|
||||
} catch {}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const workspaceSettingsSchema = z.object({
|
||||
companyName: z.string().trim().min(1, "Şirket adı zorunlu.").max(255),
|
||||
companyTaxId: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(50)
|
||||
.optional()
|
||||
.transform((v) => (v ? v : undefined)),
|
||||
companyAddress: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(500)
|
||||
.optional()
|
||||
.transform((v) => (v ? v : undefined)),
|
||||
companyEmail: z
|
||||
.union([z.string().email("Geçerli bir email girin."), z.literal("")])
|
||||
.optional()
|
||||
.transform((v) => (v ? v : undefined)),
|
||||
companyPhone: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(30)
|
||||
.optional()
|
||||
.transform((v) => (v ? v : undefined)),
|
||||
defaultCurrency: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(8)
|
||||
.optional()
|
||||
.transform((v) => (v ? v.toUpperCase() : "TRY")),
|
||||
});
|
||||
|
||||
export type WorkspaceSettingsInput = z.infer<typeof workspaceSettingsSchema>;
|
||||
Reference in New Issue
Block a user