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
+14
View File
@@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache";
import { AppwriteException, ID, Permission, Query, Role } from "node-appwrite";
import { logAudit } from "./audit";
import { createNotification } from "./notification-helpers";
import {
DATABASE_ID,
TABLES,
@@ -139,6 +140,12 @@ export async function requestConnectionAction(
entityId: created.$id,
changes: { clinicTenantId, labTenantId, status: "pending" },
});
const counterpartId = counterpart.tenantId;
await createNotification({
tenantId: counterpartId,
connectionId: created.$id,
message: `${ctx.settings?.companyName ?? "Bir hesap"} bağlantı talebi gönderdi.`,
});
}
} catch (e) {
return { ok: false, error: appwriteError(e, "Bağlantı talebi gönderilemedi.") };
@@ -218,6 +225,13 @@ export async function approveConnectionAction(
entityId: connectionId,
changes: { status: "approved" },
});
const requesterTenant =
conn.clinicTenantId === ctx.tenantId ? conn.labTenantId : conn.clinicTenantId;
await createNotification({
tenantId: requesterTenant,
connectionId,
message: `${ctx.settings?.companyName ?? "Karşı taraf"} bağlantı talebinizi onayladı.`,
});
} catch (e) {
return { ok: false, error: appwriteError(e, "Onaylanamadı.") };
}
+112
View File
@@ -0,0 +1,112 @@
"use server";
import { revalidatePath } from "next/cache";
import { AppwriteException } from "node-appwrite";
import { logAudit } from "./audit";
import { DATABASE_ID, TABLES, type FinanceEntry } from "./schema";
import { createAdminClient } from "./server";
import { requireRole, requireTenant } from "./tenant-guard";
import type { FinanceActionState } from "./finance-types";
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;
}
async function loadEntryForTenant(
id: string,
tenantId: string,
): Promise<FinanceEntry | null> {
try {
const { tablesDB } = createAdminClient();
const row = await tablesDB.getRow(DATABASE_ID, TABLES.financeEntries, id);
const entry = row as unknown as FinanceEntry;
if (entry.tenantId !== tenantId) return null;
return entry;
} catch {
return null;
}
}
export async function markFinancePaidAction(
_prev: FinanceActionState,
formData: FormData,
): Promise<FinanceActionState> {
const id = String(formData.get("id") ?? "").trim();
if (!id) return { ok: false, error: "Kayıt bulunamadı." };
let ctx;
try {
ctx = await requireTenant();
requireRole(ctx, ["owner", "admin"]);
} catch {
return { ok: false, error: "Bu işlem için yetkiniz yok." };
}
const entry = await loadEntryForTenant(id, ctx.tenantId);
if (!entry) return { ok: false, error: "Kayıt bulunamadı." };
if (entry.status === "paid") return { ok: false, error: "Bu kayıt zaten ödenmiş." };
try {
const { tablesDB } = createAdminClient();
await tablesDB.updateRow(DATABASE_ID, TABLES.financeEntries, id, {
status: "paid",
});
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "update",
entityType: "finance_entry",
entityId: id,
changes: { status: "paid" },
});
} catch (e) {
return { ok: false, error: appwriteError(e, "Güncellenemedi.") };
}
revalidatePath("/finance");
return { ok: true };
}
export async function reopenFinanceAction(
_prev: FinanceActionState,
formData: FormData,
): Promise<FinanceActionState> {
const id = String(formData.get("id") ?? "").trim();
if (!id) return { ok: false, error: "Kayıt bulunamadı." };
let ctx;
try {
ctx = await requireTenant();
requireRole(ctx, ["owner", "admin"]);
} catch {
return { ok: false, error: "Bu işlem için yetkiniz yok." };
}
const entry = await loadEntryForTenant(id, ctx.tenantId);
if (!entry) return { ok: false, error: "Kayıt bulunamadı." };
if (entry.status === "pending") return { ok: false, error: "Bu kayıt zaten bekliyor." };
try {
const { tablesDB } = createAdminClient();
await tablesDB.updateRow(DATABASE_ID, TABLES.financeEntries, id, {
status: "pending",
});
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "update",
entityType: "finance_entry",
entityId: id,
changes: { status: "pending" },
});
} catch (e) {
return { ok: false, error: appwriteError(e, "Güncellenemedi.") };
}
revalidatePath("/finance");
return { ok: true };
}
+98
View File
@@ -0,0 +1,98 @@
import "server-only";
import { Query } from "node-appwrite";
import {
DATABASE_ID,
TABLES,
type FinanceEntry,
type TenantKind,
type TenantSettings,
} from "./schema";
import { createAdminClient } from "./server";
export type FinanceCounterpart = {
tenantId: string;
companyName: string;
kind: TenantKind;
};
export type FinanceEntryWithCounterpart = FinanceEntry & {
counterpart: FinanceCounterpart | null;
};
export async function listFinanceEntries(
tenantId: string,
): Promise<FinanceEntryWithCounterpart[]> {
const { tablesDB } = createAdminClient();
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.financeEntries,
queries: [
Query.equal("tenantId", tenantId),
Query.orderDesc("date"),
Query.limit(200),
],
});
const rows = result.rows as unknown as FinanceEntry[];
const counterpartIds = Array.from(
new Set(rows.map((r) => r.counterpartTenantId).filter((v): v is string => Boolean(v))),
);
if (counterpartIds.length === 0) {
return rows.map((r) => ({ ...r, counterpart: null }));
}
const counterpartsRes = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.tenantSettings,
queries: [Query.equal("tenantId", counterpartIds), Query.limit(200)],
});
const map = new Map<string, FinanceCounterpart>();
for (const s of counterpartsRes.rows as unknown as TenantSettings[]) {
map.set(s.tenantId, {
tenantId: s.tenantId,
companyName: s.companyName,
kind: s.kind,
});
}
return rows.map((r) => ({
...r,
counterpart: r.counterpartTenantId ? map.get(r.counterpartTenantId) ?? null : null,
}));
}
export function summarizeFinance(
entries: FinanceEntryWithCounterpart[],
): {
receivablePending: number;
payablePending: number;
incomeThisMonth: number;
expenseThisMonth: number;
currency: string;
} {
const now = new Date();
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1).toISOString();
let receivablePending = 0;
let payablePending = 0;
let incomeThisMonth = 0;
let expenseThisMonth = 0;
const currency = entries.find((e) => e.currency)?.currency || "TRY";
for (const e of entries) {
if (e.status === "cancelled") continue;
if (e.type === "receivable" && e.status === "pending") receivablePending += e.amount;
if (e.type === "payable" && e.status === "pending") payablePending += e.amount;
if (e.date >= monthStart) {
if ((e.type === "income" || e.type === "receivable") && e.status === "paid") {
incomeThisMonth += e.amount;
}
if ((e.type === "expense" || e.type === "payable") && e.status === "paid") {
expenseThisMonth += e.amount;
}
}
}
return { receivablePending, payablePending, incomeThisMonth, expenseThisMonth, currency };
}
+85
View File
@@ -0,0 +1,85 @@
import "server-only";
import { ID, Permission, Query, Role } from "node-appwrite";
import {
DATABASE_ID,
TABLES,
type FinanceType,
type Job,
} from "./schema";
import { createAdminClient } from "./server";
/**
* Idempotent finance reconciliation for a job. Safe to call from every job
* mutation that might land the job in `sent` or `delivered`. Memory rule
* [[feedback_cross_entity_sync_helpers]]: never re-throw, best-effort, single
* named function called from every write path.
*
* Two rows are created the first time a job becomes sent/delivered:
* - clinic side → payable (status: pending)
* - lab side → receivable (status: pending)
*
* On `delivered`, pending rows can stay pending — actually settling them is
* the user's job through the /finance UI (mark as paid). We just ensure both
* rows exist.
*/
export async function syncFinanceForJob(job: Job): Promise<void> {
try {
if (!job.price || job.price <= 0) return;
if (job.status !== "sent" && job.status !== "delivered") return;
const { tablesDB } = createAdminClient();
const currency = job.currency || "TRY";
const sides: Array<{
tenantId: string;
counterpartTenantId: string;
type: FinanceType;
}> = [
{ tenantId: job.clinicTenantId, counterpartTenantId: job.labTenantId, type: "payable" },
{ tenantId: job.labTenantId, counterpartTenantId: job.clinicTenantId, type: "receivable" },
];
for (const side of sides) {
const existing = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.financeEntries,
queries: [
Query.equal("jobId", job.$id),
Query.equal("tenantId", side.tenantId),
Query.equal("type", side.type),
Query.limit(1),
],
});
if (existing.total > 0) continue;
await tablesDB.createRow(
DATABASE_ID,
TABLES.financeEntries,
ID.unique(),
{
tenantId: side.tenantId,
createdBy: job.createdBy,
jobId: job.$id,
counterpartTenantId: side.counterpartTenantId,
type: side.type,
amount: job.price,
currency,
status: "pending",
date: new Date().toISOString(),
description: `Hasta ${job.patientCode}${job.prostheticType.replace(/_/g, " ")}`.slice(0, 1000),
},
[
Permission.read(Role.team(side.tenantId)),
Permission.update(Role.team(side.tenantId, "owner")),
Permission.update(Role.team(side.tenantId, "admin")),
Permission.delete(Role.team(side.tenantId, "owner")),
],
);
}
} catch (err) {
// Best-effort: never block the parent job mutation.
console.error("[syncFinanceForJob]", err);
}
}
+21
View File
@@ -0,0 +1,21 @@
import type { FinanceType, FinanceStatus } from "./schema";
export type FinanceActionState = {
ok: boolean;
error?: string;
};
export const initialFinanceActionState: FinanceActionState = { ok: false };
export const FINANCE_TYPE_LABELS: Record<FinanceType, string> = {
income: "Gelir",
expense: "Gider",
receivable: "Alacak",
payable: "Borç",
};
export const FINANCE_STATUS_LABELS: Record<FinanceStatus, string> = {
pending: "Bekliyor",
paid: "Ödendi",
cancelled: "İptal",
};
+309 -1
View File
@@ -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 };
}
+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.") };
}
}
+29
View File
@@ -0,0 +1,29 @@
import "server-only";
import { Query } from "node-appwrite";
import { BUCKETS, DATABASE_ID, TABLES, type JobFile } from "./schema";
import { createAdminClient } from "./server";
import { getFileViewUrl } from "./storage";
export type JobFileWithUrl = JobFile & {
url: string;
};
export async function listJobFiles(jobId: string): Promise<JobFileWithUrl[]> {
const { tablesDB } = createAdminClient();
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.jobFiles,
queries: [
Query.equal("jobId", jobId),
Query.orderDesc("$createdAt"),
Query.limit(200),
],
});
const rows = result.rows as unknown as JobFile[];
return rows.map((r) => ({
...r,
url: getFileViewUrl(BUCKETS.jobFiles, r.fileId),
}));
}
+20
View File
@@ -0,0 +1,20 @@
export type JobFileUploadState = {
ok: boolean;
error?: string;
uploaded?: number;
};
export const initialJobFileUploadState: JobFileUploadState = { ok: false };
export type JobFileActionState = {
ok: boolean;
error?: string;
};
export const initialJobFileActionState: JobFileActionState = { ok: false };
export const JOB_FILE_KIND_LABELS: Record<string, string> = {
scan: "Tarama",
image: "Görsel",
document: "Belge",
};
+20
View File
@@ -0,0 +1,20 @@
import "server-only";
import { Query } from "node-appwrite";
import { DATABASE_ID, TABLES, type JobStatusHistory } from "./schema";
import { createAdminClient } from "./server";
export async function listJobHistory(jobId: string): Promise<JobStatusHistory[]> {
const { tablesDB } = createAdminClient();
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.jobStatusHistory,
queries: [
Query.equal("jobId", jobId),
Query.orderAsc("completedAt"),
Query.limit(100),
],
});
return result.rows as unknown as JobStatusHistory[];
}
+86
View File
@@ -0,0 +1,86 @@
"use server";
import { revalidatePath } from "next/cache";
import { AppwriteException, Query } from "node-appwrite";
import { DATABASE_ID, TABLES, type Notification } from "./schema";
import { createAdminClient } from "./server";
import { requireTenant } from "./tenant-guard";
export type NotificationActionState = {
ok: boolean;
error?: string;
};
export const initialNotificationActionState: NotificationActionState = { ok: false };
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;
}
export async function markNotificationReadAction(
_prev: NotificationActionState,
formData: FormData,
): Promise<NotificationActionState> {
const id = String(formData.get("id") ?? "").trim();
if (!id) return { ok: false, error: "Bildirim bulunamadı." };
let ctx;
try {
ctx = await requireTenant();
} catch {
return { ok: false, error: "Oturum yok." };
}
try {
const { tablesDB } = createAdminClient();
const row = (await tablesDB.getRow(
DATABASE_ID,
TABLES.notifications,
id,
)) as unknown as Notification;
if (row.tenantId !== ctx.tenantId) return { ok: false, error: "Yetki yok." };
if (row.read) return { ok: true };
await tablesDB.updateRow(DATABASE_ID, TABLES.notifications, id, { read: true });
} catch (e) {
return { ok: false, error: appwriteError(e, "İşaretlenemedi.") };
}
revalidatePath("/notifications");
return { ok: true };
}
export async function markAllNotificationsReadAction(): Promise<NotificationActionState> {
let ctx;
try {
ctx = await requireTenant();
} catch {
return { ok: false, error: "Oturum yok." };
}
try {
const { tablesDB } = createAdminClient();
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.notifications,
queries: [
Query.equal("tenantId", ctx.tenantId),
Query.equal("read", false),
Query.limit(100),
],
});
await Promise.allSettled(
(result.rows as unknown as Notification[]).map((row) =>
tablesDB.updateRow(DATABASE_ID, TABLES.notifications, row.$id, { read: true }),
),
);
} catch (e) {
return { ok: false, error: appwriteError(e, "İşaretlenemedi.") };
}
revalidatePath("/notifications");
return { ok: true };
}
+80
View File
@@ -0,0 +1,80 @@
import "server-only";
import { ID, Permission, Query, Role } from "node-appwrite";
import { DATABASE_ID, TABLES, type Notification } from "./schema";
import { createAdminClient } from "./server";
type CreateNotificationInput = {
tenantId: string;
userId?: string;
jobId?: string;
connectionId?: string;
message: string;
};
/**
* Idempotent notification creator. Best-effort: never re-throws so callers
* (server actions in the hot path) stay reliable.
*/
export async function createNotification(input: CreateNotificationInput): Promise<void> {
try {
const { tablesDB } = createAdminClient();
await tablesDB.createRow(
DATABASE_ID,
TABLES.notifications,
ID.unique(),
{
tenantId: input.tenantId,
userId: input.userId,
jobId: input.jobId,
connectionId: input.connectionId,
message: input.message.slice(0, 500),
read: false,
},
[
Permission.read(Role.team(input.tenantId)),
Permission.update(Role.team(input.tenantId)),
Permission.delete(Role.team(input.tenantId, "owner")),
Permission.delete(Role.team(input.tenantId, "admin")),
],
);
} catch (err) {
console.error("[createNotification]", err);
}
}
export async function countUnreadNotifications(tenantId: string): Promise<number> {
try {
const { tablesDB } = createAdminClient();
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.notifications,
queries: [
Query.equal("tenantId", tenantId),
Query.equal("read", false),
Query.limit(1),
],
});
return result.total;
} catch {
return 0;
}
}
export async function listNotifications(
tenantId: string,
limit = 50,
): Promise<Notification[]> {
const { tablesDB } = createAdminClient();
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.notifications,
queries: [
Query.equal("tenantId", tenantId),
Query.orderDesc("$createdAt"),
Query.limit(limit),
],
});
return result.rows as unknown as Notification[];
}