196036c0d8
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
389 lines
10 KiB
TypeScript
389 lines
10 KiB
TypeScript
"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 };
|
||
}
|