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:
@@ -5,14 +5,19 @@ import { AppwriteException, ID, Permission, Query, Role } from "node-appwrite";
|
||||
import { z } from "zod";
|
||||
|
||||
import { logAudit } from "./audit";
|
||||
import { syncFinanceForJob } from "./finance-sync";
|
||||
import { createNotification } from "./notification-helpers";
|
||||
import {
|
||||
DATABASE_ID,
|
||||
TABLES,
|
||||
type Connection,
|
||||
type Job,
|
||||
type JobStep,
|
||||
} from "./schema";
|
||||
import { createAdminClient } from "./server";
|
||||
import { requireRole, requireTenant, requireTenantKind } from "./tenant-guard";
|
||||
import type { JobFormState } from "./job-types";
|
||||
import { JOB_STEP_ORDER } from "./job-types";
|
||||
import type { JobActionState, JobFormState } from "./job-types";
|
||||
import { createJobSchema } from "@/lib/validation/job";
|
||||
|
||||
function appwriteError(e: unknown, fallback = "Beklenmeyen bir hata oluştu."): string {
|
||||
@@ -133,6 +138,11 @@ export async function createJobAction(
|
||||
entityId: created.$id,
|
||||
changes: { labTenantId: parsed.data.labTenantId, patientCode: parsed.data.patientCode },
|
||||
});
|
||||
await createNotification({
|
||||
tenantId: parsed.data.labTenantId,
|
||||
jobId: created.$id,
|
||||
message: `${ctx.settings?.companyName ?? "Bir klinik"} yeni iş yayınladı (${parsed.data.patientCode}).`,
|
||||
});
|
||||
revalidatePath("/jobs/outbound");
|
||||
revalidatePath("/dashboard");
|
||||
return { ok: true, jobId: created.$id };
|
||||
@@ -140,3 +150,301 @@ export async function createJobAction(
|
||||
return { ok: false, error: appwriteError(e, "İş oluşturulamadı.") };
|
||||
}
|
||||
}
|
||||
|
||||
function jobHistoryPermissions(clinicTenantId: string, labTenantId: string): string[] {
|
||||
return [
|
||||
Permission.read(Role.team(clinicTenantId)),
|
||||
Permission.read(Role.team(labTenantId)),
|
||||
];
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
async function appendJobHistory(args: {
|
||||
job: Job;
|
||||
step: JobStep;
|
||||
completedBy: string;
|
||||
note?: string;
|
||||
}): Promise<void> {
|
||||
const { tablesDB } = createAdminClient();
|
||||
try {
|
||||
await tablesDB.createRow(
|
||||
DATABASE_ID,
|
||||
TABLES.jobStatusHistory,
|
||||
ID.unique(),
|
||||
{
|
||||
jobId: args.job.$id,
|
||||
clinicTenantId: args.job.clinicTenantId,
|
||||
labTenantId: args.job.labTenantId,
|
||||
step: args.step,
|
||||
completedBy: args.completedBy,
|
||||
completedAt: new Date().toISOString(),
|
||||
note: args.note,
|
||||
},
|
||||
jobHistoryPermissions(args.job.clinicTenantId, args.job.labTenantId),
|
||||
);
|
||||
} catch {
|
||||
// history failures must never block the main mutation
|
||||
}
|
||||
}
|
||||
|
||||
export async function acceptJobAction(
|
||||
_prev: JobActionState,
|
||||
formData: FormData,
|
||||
): Promise<JobActionState> {
|
||||
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"]);
|
||||
requireTenantKind(ctx, ["lab"]);
|
||||
} catch {
|
||||
return { ok: false, error: "Sadece laboratuvar bu işi kabul edebilir." };
|
||||
}
|
||||
|
||||
const job = await loadJobForTenant(jobId, ctx.tenantId);
|
||||
if (!job || job.labTenantId !== ctx.tenantId) {
|
||||
return { ok: false, error: "İş bulunamadı." };
|
||||
}
|
||||
if (job.status !== "pending") {
|
||||
return { ok: false, error: "Bu iş zaten işleme alınmış." };
|
||||
}
|
||||
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.jobs, jobId, {
|
||||
status: "in_progress",
|
||||
currentStep: "olcu",
|
||||
});
|
||||
await appendJobHistory({ job, step: "olcu", completedBy: ctx.user.id });
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "update",
|
||||
entityType: "job",
|
||||
entityId: jobId,
|
||||
changes: { status: "in_progress", currentStep: "olcu" },
|
||||
});
|
||||
await createNotification({
|
||||
tenantId: job.clinicTenantId,
|
||||
jobId,
|
||||
message: `${ctx.settings?.companyName ?? "Lab"} hasta ${job.patientCode} işini işleme aldı.`,
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e, "Kabul edilemedi.") };
|
||||
}
|
||||
|
||||
revalidatePath(`/jobs/${jobId}`);
|
||||
revalidatePath("/jobs/inbound");
|
||||
revalidatePath("/jobs/outbound");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function advanceStepAction(
|
||||
_prev: JobActionState,
|
||||
formData: FormData,
|
||||
): Promise<JobActionState> {
|
||||
const jobId = String(formData.get("jobId") ?? "").trim();
|
||||
if (!jobId) return { ok: false, error: "İş bulunamadı." };
|
||||
const note = String(formData.get("note") ?? "").trim() || undefined;
|
||||
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
requireRole(ctx, ["owner", "admin", "member"]);
|
||||
requireTenantKind(ctx, ["lab"]);
|
||||
} catch {
|
||||
return { ok: false, error: "Sadece laboratuvar aşama ilerletebilir." };
|
||||
}
|
||||
|
||||
const job = await loadJobForTenant(jobId, ctx.tenantId);
|
||||
if (!job || job.labTenantId !== ctx.tenantId) {
|
||||
return { ok: false, error: "İş bulunamadı." };
|
||||
}
|
||||
if (job.status !== "in_progress") {
|
||||
return { ok: false, error: "Yalnızca işleme alınmış işler ilerletilebilir." };
|
||||
}
|
||||
const currentIdx = job.currentStep ? JOB_STEP_ORDER.indexOf(job.currentStep) : -1;
|
||||
if (currentIdx < 0) return { ok: false, error: "Mevcut aşama bilinmiyor." };
|
||||
|
||||
const nextIdx = currentIdx + 1;
|
||||
const isFinalStepComplete = currentIdx === JOB_STEP_ORDER.length - 1;
|
||||
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
if (isFinalStepComplete) {
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.jobs, jobId, {
|
||||
status: "sent",
|
||||
});
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "update",
|
||||
entityType: "job",
|
||||
entityId: jobId,
|
||||
changes: { status: "sent" },
|
||||
});
|
||||
} else {
|
||||
const nextStep = JOB_STEP_ORDER[nextIdx];
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.jobs, jobId, {
|
||||
currentStep: nextStep,
|
||||
});
|
||||
await appendJobHistory({
|
||||
job,
|
||||
step: job.currentStep!,
|
||||
completedBy: ctx.user.id,
|
||||
note,
|
||||
});
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "update",
|
||||
entityType: "job",
|
||||
entityId: jobId,
|
||||
changes: { currentStep: nextStep, completedStep: job.currentStep },
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e, "İlerletilemedi.") };
|
||||
}
|
||||
|
||||
if (isFinalStepComplete) {
|
||||
// Record completion of the last step too, then mark sent.
|
||||
await appendJobHistory({
|
||||
job,
|
||||
step: job.currentStep!,
|
||||
completedBy: ctx.user.id,
|
||||
note,
|
||||
});
|
||||
await syncFinanceForJob({ ...job, status: "sent" });
|
||||
await createNotification({
|
||||
tenantId: job.clinicTenantId,
|
||||
jobId,
|
||||
message: `Hasta ${job.patientCode} işi gönderildi. Teslim alındığında onaylayın.`,
|
||||
});
|
||||
}
|
||||
|
||||
revalidatePath(`/jobs/${jobId}`);
|
||||
revalidatePath("/jobs/inbound");
|
||||
revalidatePath("/jobs/outbound");
|
||||
revalidatePath("/finance");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function markDeliveredAction(
|
||||
_prev: JobActionState,
|
||||
formData: FormData,
|
||||
): Promise<JobActionState> {
|
||||
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"]);
|
||||
requireTenantKind(ctx, ["clinic"]);
|
||||
} catch {
|
||||
return { ok: false, error: "Teslim almayı yalnızca klinik yapabilir." };
|
||||
}
|
||||
|
||||
const job = await loadJobForTenant(jobId, ctx.tenantId);
|
||||
if (!job || job.clinicTenantId !== ctx.tenantId) {
|
||||
return { ok: false, error: "İş bulunamadı." };
|
||||
}
|
||||
if (job.status !== "sent") {
|
||||
return { ok: false, error: "Sadece gönderilmiş işler teslim alınabilir." };
|
||||
}
|
||||
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.jobs, jobId, {
|
||||
status: "delivered",
|
||||
});
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "update",
|
||||
entityType: "job",
|
||||
entityId: jobId,
|
||||
changes: { status: "delivered" },
|
||||
});
|
||||
await syncFinanceForJob({ ...job, status: "delivered" });
|
||||
await createNotification({
|
||||
tenantId: job.labTenantId,
|
||||
jobId,
|
||||
message: `Hasta ${job.patientCode} işi teslim alındı.`,
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e, "Teslim alınamadı.") };
|
||||
}
|
||||
|
||||
revalidatePath(`/jobs/${jobId}`);
|
||||
revalidatePath("/jobs/outbound");
|
||||
revalidatePath("/jobs/inbound");
|
||||
revalidatePath("/finance");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function cancelJobAction(
|
||||
_prev: JobActionState,
|
||||
formData: FormData,
|
||||
): Promise<JobActionState> {
|
||||
const jobId = String(formData.get("jobId") ?? "").trim();
|
||||
if (!jobId) return { ok: false, error: "İş bulunamadı." };
|
||||
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
requireRole(ctx, ["owner", "admin"]);
|
||||
} catch {
|
||||
return { ok: false, error: "Bu işlem için yetkiniz yok." };
|
||||
}
|
||||
|
||||
const job = await loadJobForTenant(jobId, ctx.tenantId);
|
||||
if (!job) return { ok: false, error: "İş bulunamadı." };
|
||||
if (job.status !== "pending") {
|
||||
return { ok: false, error: "Yalnızca bekleyen işler iptal edilebilir." };
|
||||
}
|
||||
if (ctx.kind === "clinic" && job.clinicTenantId !== ctx.tenantId) {
|
||||
return { ok: false, error: "Yetkiniz yok." };
|
||||
}
|
||||
if (ctx.kind === "lab" && job.labTenantId !== ctx.tenantId) {
|
||||
return { ok: false, error: "Yetkiniz yok." };
|
||||
}
|
||||
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
await tablesDB.updateRow(DATABASE_ID, TABLES.jobs, jobId, {
|
||||
status: "cancelled",
|
||||
});
|
||||
await logAudit({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.user.id,
|
||||
action: "update",
|
||||
entityType: "job",
|
||||
entityId: jobId,
|
||||
changes: { status: "cancelled" },
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: appwriteError(e, "İptal edilemedi.") };
|
||||
}
|
||||
|
||||
revalidatePath(`/jobs/${jobId}`);
|
||||
revalidatePath("/jobs/inbound");
|
||||
revalidatePath("/jobs/outbound");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user