feat(software): catalog + customer assignments (M2M)
Software catalog with per-customer assignments via the customer_software
join table. Two tabs in one /software page:
Catalog tab:
- Software CRUD: name, version, description, defaultFee (TRY).
- Deleting a software cascades and removes all its assignments first
(best-effort loop, then the catalog row), all wrapped in audit logs.
Assignments tab:
- M2M between customer and software with own fee (overrides defaultFee),
billingPeriod (monthly default), startDate/endDate, notes.
- Form auto-fills fee from selected software's defaultFee.
- Both Sheet forms localized; date inputs round-tripped via toIsoDate
(Appwrite expects ISO 8601 with TZ; HTML date input gives YYYY-MM-DD).
- Delete dialogs differentiated for catalog ('siliniyor') vs assignment
('kaldırılıyor').
New files:
- lib/validation/software.ts (softwareSchema + customerSoftwareSchema)
- lib/appwrite/software-actions.ts (6 server actions)
- lib/appwrite/software-queries.ts (listSoftware, listAssignments)
- lib/appwrite/software-types.ts (form state)
- /software route with SoftwareClient (Tabs), SoftwareFormSheet,
AssignmentFormSheet, inline delete dialogs.
Empty states surface the right next-step CTA: 'önce müşteri ekleyin', or
'önce yazılım ekleyin', as appropriate.
This commit is contained in:
@@ -0,0 +1,370 @@
|
||||
"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 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 {
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user