"use server"; 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"; 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 { 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 { 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 { 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 }; } export async function updateMemberRoleAction( _prev: MemberActionState, formData: FormData, ): Promise { 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 { 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 { if (!code) return { ok: false, error: "Geçersiz davet linki." }; let user; 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(); // 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, }); 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 }, }); } catch (e) { return { ok: false, error: appwriteError(e) }; } return { ok: true }; }