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:
@@ -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.") };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user