feat: job status/step flow, file upload, finance sync, notifications

Job lifecycle
  - acceptJobAction (lab): pending → in_progress + currentStep=olcu
  - advanceStepAction (lab): step ilerletir, son adım sonrası status=sent
  - markDeliveredAction (clinic): sent → delivered
  - cancelJobAction: pending iş iptali (her iki taraf)
  - job_status_history her step transition'da idempotent kayıt
  - Detay sayfası interactive panel + Aşama Geçmişi kartı

Job files (Appwrite Storage job-files bucket, 30MB/file)
  - uploadJobFilesAction: çoklu dosya, mimeType'tan kind sınıflandırma
    (scan/image/document), her iki team'e read permission, partial-fail
    rollback (storage + row temizliği)
  - deleteJobFileAction: yetkilendirilmiş silme, file + row birlikte
  - JobFilesPanel: client-side select + upload + liste + indir + sil
  - next.config bodySizeLimit 3mb → 100mb (toplu yükleme için)

Finance sync (idempotent)
  - syncFinanceForJob helper: sent/delivered transition'larında klinik
    payable + lab receivable rows (jobId+tenantId+type unique kontrolü,
    her tarafta tek satır garanti)
  - markFinancePaidAction / reopenFinanceAction: manuel ödendi/geri al
  - /finance sayfası: stat kartlar (bekleyen alacak/borç, aylık gelir/gider)
    + hareketler tablosu, role-aware kopyalar
  - Memory rule [[feedback_cross_entity_sync_helpers]]: best-effort, never
    re-throws

Notifications
  - createNotification helper, connection (request/approve) ve job
    (create/accept/sent/delivered) eventlerinde tetikleniyor
  - /notifications sayfası + tek tek / hepsi okundu işaretle
  - Header'a Bell ikonu + okunmamış count badge (layout SSR'de besler)
  - Middleware PROTECTED_PREFIXES'e /notifications ekli
This commit is contained in:
kovakmedya
2026-05-21 20:17:33 +03:00
parent 76e02754b8
commit 2c6c074a06
24 changed files with 2066 additions and 21 deletions
+218
View File
@@ -0,0 +1,218 @@
"use server";
import { revalidatePath } from "next/cache";
import { AppwriteException, ID, Permission, Role } from "node-appwrite";
import { logAudit } from "./audit";
import {
BUCKETS,
DATABASE_ID,
TABLES,
type Job,
type JobFile,
type JobFileKind,
} from "./schema";
import { createAdminClient } from "./server";
import { requireRole, requireTenant } from "./tenant-guard";
import type {
JobFileActionState,
JobFileUploadState,
} from "./job-file-types";
const MAX_FILE_BYTES = 30 * 1024 * 1024; // 30MB — bucket limit
function appwriteError(e: unknown, fallback = "Beklenmeyen bir hata oluştu."): string {
if (e instanceof AppwriteException) return e.message || fallback;
return process.env.NODE_ENV !== "production" && e instanceof Error
? `${fallback} (${e.message})`
: fallback;
}
function classifyFile(mimeType: string | undefined, name: string): JobFileKind {
const lower = (mimeType || name).toLowerCase();
if (/\.(stl|obj|ply|3mf|dcm)$/i.test(name)) return "scan";
if (lower.startsWith("image/") || /\.(png|jpe?g|webp|tiff?|heic|heif|bmp)$/i.test(name)) {
return "image";
}
return "document";
}
function filePermissions(clinicTenantId: string, labTenantId: string): string[] {
return [
Permission.read(Role.team(clinicTenantId)),
Permission.read(Role.team(labTenantId)),
Permission.delete(Role.team(clinicTenantId, "owner")),
Permission.delete(Role.team(clinicTenantId, "admin")),
Permission.delete(Role.team(labTenantId, "owner")),
Permission.delete(Role.team(labTenantId, "admin")),
];
}
async function loadJobForTenant(jobId: string, tenantId: string): Promise<Job | null> {
try {
const { tablesDB } = createAdminClient();
const row = await tablesDB.getRow(DATABASE_ID, TABLES.jobs, jobId);
const job = row as unknown as Job;
if (job.clinicTenantId !== tenantId && job.labTenantId !== tenantId) {
return null;
}
return job;
} catch {
return null;
}
}
export async function uploadJobFilesAction(
_prev: JobFileUploadState,
formData: FormData,
): Promise<JobFileUploadState> {
const jobId = String(formData.get("jobId") ?? "").trim();
if (!jobId) return { ok: false, error: "İş bulunamadı." };
let ctx;
try {
ctx = await requireTenant();
requireRole(ctx, ["owner", "admin", "member"]);
} catch {
return { ok: false, error: "Yüklemek için yetkiniz yok." };
}
const job = await loadJobForTenant(jobId, ctx.tenantId);
if (!job) return { ok: false, error: "İş bulunamadı." };
const files = formData.getAll("files").filter((v): v is File => v instanceof File && v.size > 0);
if (files.length === 0) {
return { ok: false, error: "Dosya seçin." };
}
for (const f of files) {
if (f.size > MAX_FILE_BYTES) {
return { ok: false, error: `${f.name} 30MB sınırını aşıyor.` };
}
}
const { storage, tablesDB } = createAdminClient();
const uploadedFileIds: string[] = [];
const createdRowIds: string[] = [];
try {
for (const f of files) {
const fileId = ID.unique();
await storage.createFile({
bucketId: BUCKETS.jobFiles,
fileId,
file: f,
permissions: filePermissions(job.clinicTenantId, job.labTenantId),
});
uploadedFileIds.push(fileId);
const kind = classifyFile(f.type, f.name);
const row = await tablesDB.createRow(
DATABASE_ID,
TABLES.jobFiles,
ID.unique(),
{
jobId: job.$id,
clinicTenantId: job.clinicTenantId,
labTenantId: job.labTenantId,
uploadedBy: ctx.user.id,
kind,
fileId,
name: f.name.slice(0, 255),
size: f.size,
mimeType: f.type ? f.type.slice(0, 100) : undefined,
},
[
Permission.read(Role.team(job.clinicTenantId)),
Permission.read(Role.team(job.labTenantId)),
Permission.delete(Role.team(job.clinicTenantId, "owner")),
Permission.delete(Role.team(job.clinicTenantId, "admin")),
Permission.delete(Role.team(job.labTenantId, "owner")),
Permission.delete(Role.team(job.labTenantId, "admin")),
],
);
createdRowIds.push(row.$id);
}
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "create",
entityType: "job_files",
entityId: jobId,
changes: { count: files.length },
});
} catch (e) {
// Rollback: best-effort cleanup of partially uploaded files and rows.
for (const id of createdRowIds) {
try {
await tablesDB.deleteRow(DATABASE_ID, TABLES.jobFiles, id);
} catch {
/* ignore */
}
}
for (const id of uploadedFileIds) {
try {
await storage.deleteFile({ bucketId: BUCKETS.jobFiles, fileId: id });
} catch {
/* ignore */
}
}
return { ok: false, error: appwriteError(e, "Dosya yüklenemedi.") };
}
revalidatePath(`/jobs/${jobId}`);
return { ok: true, uploaded: files.length };
}
export async function deleteJobFileAction(
_prev: JobFileActionState,
formData: FormData,
): Promise<JobFileActionState> {
const rowId = String(formData.get("rowId") ?? "").trim();
if (!rowId) return { ok: false, error: "Dosya bulunamadı." };
let ctx;
try {
ctx = await requireTenant();
requireRole(ctx, ["owner", "admin"]);
} catch {
return { ok: false, error: "Bu işlem için yetkiniz yok." };
}
try {
const { storage, tablesDB } = createAdminClient();
const row = (await tablesDB.getRow(
DATABASE_ID,
TABLES.jobFiles,
rowId,
)) as unknown as JobFile;
if (
row.clinicTenantId !== ctx.tenantId &&
row.labTenantId !== ctx.tenantId
) {
return { ok: false, error: "Yetkiniz yok." };
}
await tablesDB.deleteRow(DATABASE_ID, TABLES.jobFiles, rowId);
try {
await storage.deleteFile({ bucketId: BUCKETS.jobFiles, fileId: row.fileId });
} catch {
// File may already be gone; row is the source of truth.
}
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "delete",
entityType: "job_file",
entityId: rowId,
changes: { fileId: row.fileId, name: row.name },
});
revalidatePath(`/jobs/${row.jobId}`);
return { ok: true };
} catch (e) {
return { ok: false, error: appwriteError(e, "Silinemedi.") };
}
}