Files
isletmem-kovakcrm/src/lib/appwrite/software-actions.ts
T
kovakmedya 196036c0d8 feat: plan tier system, mock checkout, saved cards, tenant logo upload + mobile sheet fix
Plan & billing layer:
- New tables: subscription_payments, saved_cards (via Appwrite MCP)
- tenant_settings: plan/planStartedAt/planExpiresAt/lastPaymentId columns
- Free tier limits (50 customers / 100 finance entries / 5 software / 1 member)
  enforced via requirePlanCapacity gate in create actions
- PlanLimitDialog opens when limit hit; UsageBanner at 80% threshold
- /pricing rebuilt with Free + Pro tiers and Klinik/Ajans ecosystem teasers
- /settings/billing redesigned: compact plan summary, saved cards list,
  KVKK transparency block, payment history
- Usage stats moved to /pricing where they are decision-relevant

Mock checkout flow:
- 3D animated credit card with sync inputs and CVC flip
- Brand auto-detection (Visa / Mastercard / Amex / troy)
- Saved-card mode when previous cards exist; first card defaults to default
- 'Bu kartı kaydet' checkbox with explicit storage scope disclosure
- /settings/billing/checkout/[orderId] route

Saved cards:
- saved_cards bucket stores last4 + brand + expiry + holder only
- Default toggle, remove action, owner-only management
- Architecture ready for Shopier provider token swap-in

Tenant logo upload (first file upload feature):
- New Appwrite bucket: tenant-logos (max 2MB, image only, public read)
- uploadLogoAction with orphan cleanup, removeLogoAction
- LogoUploader UI: drag-drop, client-side preview, validation
- Sidebar shows logo when set, falls back to default icon

Mobile sheet fix:
- SheetContent uses h-dvh instead of h-full (dynamic viewport)
- SheetFooter pads pb-[max(1rem,env(safe-area-inset-bottom))]
- 13 form sheets switched py-4 → pt-4 to let safe-area apply

db: subscription_payments, saved_cards tables; tenant_settings plan columns;
    tenant-logos storage bucket
2026-04-30 21:36:01 +03:00

389 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 {
isPlanLimitError,
planLimitMessage,
requirePlanCapacity,
} from "./plan-limits";
import {
DATABASE_ID,
TABLES,
type CustomerSoftware,
type Software,
} from "./schema";
import { createAdminClient } from "./server";
import { requireTenant } from "./tenant-guard";
import type { SoftwareActionState } from "./software-types";
import { customerSoftwareSchema, softwareSchema } from "@/lib/validation/software";
function appwriteError(e: unknown): string {
if (e instanceof AppwriteException) {
return e.message || "Beklenmeyen bir hata oluştu.";
}
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 teamRowPermissions(tenantId: string) {
return [
Permission.read(Role.team(tenantId)),
Permission.update(Role.team(tenantId)),
Permission.delete(Role.team(tenantId, "owner")),
Permission.delete(Role.team(tenantId, "admin")),
];
}
// -------------------- Software (catalog) --------------------
function pickSoftwareFields(formData: FormData) {
return {
name: String(formData.get("name") ?? "").trim(),
version: String(formData.get("version") ?? "").trim(),
description: String(formData.get("description") ?? "").trim(),
defaultFee: String(formData.get("defaultFee") ?? ""),
};
}
export async function createSoftwareAction(
_prev: SoftwareActionState,
formData: FormData,
): Promise<SoftwareActionState> {
let ctx;
try {
ctx = await requireTenant();
} catch {
return { ok: false, error: "Yetkiniz yok." };
}
const parsed = softwareSchema.safeParse(pickSoftwareFields(formData));
if (!parsed.success) {
return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) };
}
try {
await requirePlanCapacity(ctx, "software");
} catch (e) {
if (isPlanLimitError(e)) {
return {
ok: false,
error: planLimitMessage(e.resource, e.limit),
code: "PLAN_LIMIT_EXCEEDED",
};
}
throw e;
}
try {
const { tablesDB } = createAdminClient();
const row = await tablesDB.createRow(
DATABASE_ID,
TABLES.software,
ID.unique(),
{
tenantId: ctx.tenantId,
createdBy: ctx.user.id,
...parsed.data,
},
teamRowPermissions(ctx.tenantId),
);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "create",
entityType: "software",
entityId: row.$id,
changes: parsed.data,
});
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
revalidatePath("/software");
return { ok: true };
}
export async function updateSoftwareAction(
_prev: SoftwareActionState,
formData: FormData,
): Promise<SoftwareActionState> {
const id = String(formData.get("id") ?? "");
if (!id) return { ok: false, error: "ID eksik." };
let ctx;
try {
ctx = await requireTenant();
} catch {
return { ok: false, error: "Yetkiniz yok." };
}
const parsed = softwareSchema.safeParse(pickSoftwareFields(formData));
if (!parsed.success) {
return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) };
}
try {
const { tablesDB } = createAdminClient();
const existing = (await tablesDB.getRow(
DATABASE_ID,
TABLES.software,
id,
)) as unknown as Software;
if (existing.tenantId !== ctx.tenantId) {
return { ok: false, error: "Erişim engellendi." };
}
await tablesDB.updateRow(DATABASE_ID, TABLES.software, id, parsed.data);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "update",
entityType: "software",
entityId: id,
changes: parsed.data,
});
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
revalidatePath("/software");
return { ok: true };
}
export async function deleteSoftwareAction(formData: FormData): Promise<SoftwareActionState> {
const id = String(formData.get("id") ?? "");
if (!id) return { ok: false, error: "ID eksik." };
let ctx;
try {
ctx = await requireTenant();
} catch {
return { ok: false, error: "Yetkiniz yok." };
}
try {
const { tablesDB } = createAdminClient();
const existing = (await tablesDB.getRow(
DATABASE_ID,
TABLES.software,
id,
)) as unknown as Software;
if (existing.tenantId !== ctx.tenantId) {
return { ok: false, error: "Erişim engellendi." };
}
// Detach from all customer_software rows first
const assignments = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.customerSoftware,
queries: [
Query.equal("tenantId", ctx.tenantId),
Query.equal("softwareId", id),
Query.limit(500),
],
});
for (const row of assignments.rows) {
await tablesDB.deleteRow(DATABASE_ID, TABLES.customerSoftware, row.$id);
}
await tablesDB.deleteRow(DATABASE_ID, TABLES.software, id);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "delete",
entityType: "software",
entityId: id,
changes: { name: existing.name, detachedAssignments: assignments.rows.length },
});
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
revalidatePath("/software");
return { ok: true };
}
// -------------------- customer_software (assignments) --------------------
function pickAssignmentFields(formData: FormData) {
return {
customerId: String(formData.get("customerId") ?? ""),
softwareId: String(formData.get("softwareId") ?? ""),
startDate: String(formData.get("startDate") ?? ""),
endDate: String(formData.get("endDate") ?? ""),
fee: String(formData.get("fee") ?? ""),
billingPeriod: (formData.get("billingPeriod") as "monthly" | "yearly" | "onetime" | null) ??
"monthly",
notes: String(formData.get("notes") ?? "").trim(),
};
}
function toIsoDate(v?: string): string | undefined {
if (!v) return undefined;
// input type=date sends YYYY-MM-DD; Appwrite expects ISO with timezone
if (/^\d{4}-\d{2}-\d{2}$/.test(v)) return `${v}T00:00:00.000+00:00`;
return v;
}
export async function createAssignmentAction(
_prev: SoftwareActionState,
formData: FormData,
): Promise<SoftwareActionState> {
let ctx;
try {
ctx = await requireTenant();
} catch {
return { ok: false, error: "Yetkiniz yok." };
}
const parsed = customerSoftwareSchema.safeParse(pickAssignmentFields(formData));
if (!parsed.success) {
return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) };
}
try {
const { tablesDB } = createAdminClient();
const data = {
...parsed.data,
startDate: toIsoDate(parsed.data.startDate),
endDate: toIsoDate(parsed.data.endDate),
};
const row = await tablesDB.createRow(
DATABASE_ID,
TABLES.customerSoftware,
ID.unique(),
{
tenantId: ctx.tenantId,
createdBy: ctx.user.id,
...data,
},
teamRowPermissions(ctx.tenantId),
);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "create",
entityType: "customer_software",
entityId: row.$id,
changes: data,
});
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
revalidatePath("/software");
return { ok: true };
}
export async function updateAssignmentAction(
_prev: SoftwareActionState,
formData: FormData,
): Promise<SoftwareActionState> {
const id = String(formData.get("id") ?? "");
if (!id) return { ok: false, error: "ID eksik." };
let ctx;
try {
ctx = await requireTenant();
} catch {
return { ok: false, error: "Yetkiniz yok." };
}
const parsed = customerSoftwareSchema.safeParse(pickAssignmentFields(formData));
if (!parsed.success) {
return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) };
}
try {
const { tablesDB } = createAdminClient();
const existing = (await tablesDB.getRow(
DATABASE_ID,
TABLES.customerSoftware,
id,
)) as unknown as CustomerSoftware;
if (existing.tenantId !== ctx.tenantId) {
return { ok: false, error: "Erişim engellendi." };
}
const data = {
...parsed.data,
startDate: toIsoDate(parsed.data.startDate),
endDate: toIsoDate(parsed.data.endDate),
};
await tablesDB.updateRow(DATABASE_ID, TABLES.customerSoftware, id, data);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "update",
entityType: "customer_software",
entityId: id,
changes: data,
});
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
revalidatePath("/software");
return { ok: true };
}
export async function deleteAssignmentAction(formData: FormData): Promise<SoftwareActionState> {
const id = String(formData.get("id") ?? "");
if (!id) return { ok: false, error: "ID eksik." };
let ctx;
try {
ctx = await requireTenant();
} catch {
return { ok: false, error: "Yetkiniz yok." };
}
try {
const { tablesDB } = createAdminClient();
const existing = (await tablesDB.getRow(
DATABASE_ID,
TABLES.customerSoftware,
id,
)) as unknown as CustomerSoftware;
if (existing.tenantId !== ctx.tenantId) {
return { ok: false, error: "Erişim engellendi." };
}
await tablesDB.deleteRow(DATABASE_ID, TABLES.customerSoftware, id);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "delete",
entityType: "customer_software",
entityId: id,
});
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
revalidatePath("/software");
return { ok: true };
}