Architektur: Craftvia-Domänenmodell, Verträge und Team-Schnitte
- Migration 0002_craftvia_domain: 27 Fachtabellen inkl. RLS (enable_tenant_rls) - TENANT_MODELS (db.ts, backup/topology.ts) um alle Fachmodelle ergänzt - moduleGuard liefert DB-autoritative Rechte; ServiceCtx für Domänen-Services - Verträge: Statusmaschine, Events, Nummernkreise, Sichtbarkeits-Scopes, Job-Queues + Worker, KI-Provider-Interfaces, Sync-Envelope - docs/craftvia/ARCHITEKTUR.md mit Lanes, Ownership und DoD Gate: tsc, lint, build, 22/22 Tests grün. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
// Domain event catalogue (client-safe). Lanes emit via src/server/events.ts#emitEvent;
|
||||
// the notifications lane maps events to recipients, in-app notifications and e-mails.
|
||||
|
||||
export const EVENT_TYPES = [
|
||||
"work_order.assigned",
|
||||
"work_order.changed",
|
||||
"work_order.cancelled",
|
||||
"work_order.started",
|
||||
"work_order.daily_report_created",
|
||||
"work_order.technically_completed",
|
||||
"work_order.signature_missing",
|
||||
"work_order.missing_required",
|
||||
"work_order.released_for_billing",
|
||||
"report.submitted",
|
||||
"report.approved",
|
||||
"report.rejected",
|
||||
"emergency.created",
|
||||
"emergency.completed",
|
||||
"import.ready_for_review",
|
||||
"import.failed",
|
||||
"sync.failed",
|
||||
] as const;
|
||||
|
||||
export type EventType = (typeof EVENT_TYPES)[number];
|
||||
|
||||
export type DomainEvent = {
|
||||
type: EventType;
|
||||
entityType: "work_order" | "report" | "import_job" | "sync_operation";
|
||||
entityId: string;
|
||||
/** Short, human-readable facts for templates (no PII beyond what the recipient may see). */
|
||||
data?: Record<string, string | number | boolean | null>;
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Offline sync envelope (ARCHITEKTUR §4.6). The per-op payload schemas live in
|
||||
* src/lib/sync/ops.ts (owned by lane field); this file only fixes the wire format.
|
||||
*/
|
||||
|
||||
export const SYNC_OP_TYPES = [
|
||||
"session.start",
|
||||
"session.pause",
|
||||
"session.resume",
|
||||
"session.end",
|
||||
"work_order.transition",
|
||||
"note.create",
|
||||
"checklist.toggle",
|
||||
"material.upsert",
|
||||
"photo.attach",
|
||||
"voice.attach",
|
||||
"report.save_draft",
|
||||
"report.submit",
|
||||
"signature.capture",
|
||||
"emergency.create",
|
||||
] as const;
|
||||
|
||||
export type SyncOpType = (typeof SYNC_OP_TYPES)[number];
|
||||
|
||||
/** Ops that compare WorkOrder.version and may produce a conflict. All others are additive. */
|
||||
export const CONFLICTING_OPS: readonly SyncOpType[] = ["work_order.transition", "report.submit"];
|
||||
|
||||
export const syncOperationSchema = z.object({
|
||||
clientOpId: z.string().uuid(),
|
||||
opType: z.enum(SYNC_OP_TYPES),
|
||||
entityType: z.string().max(40).optional(),
|
||||
entityId: z.string().max(64).optional(),
|
||||
baseVersion: z.number().int().positive().optional(),
|
||||
payload: z.record(z.string(), z.unknown()),
|
||||
clientCreatedAt: z.string().datetime(),
|
||||
});
|
||||
|
||||
export type SyncOperationInput = z.infer<typeof syncOperationSchema>;
|
||||
|
||||
export const syncRequestSchema = z.object({
|
||||
deviceId: z.string().max(64),
|
||||
operations: z.array(syncOperationSchema).min(1).max(100),
|
||||
});
|
||||
|
||||
export type SyncOpResult = {
|
||||
clientOpId: string;
|
||||
status: "applied" | "duplicate" | "conflict" | "rejected";
|
||||
/** server ids created by the op, keyed by the client id they replace */
|
||||
idMap?: Record<string, string>;
|
||||
entityVersion?: number;
|
||||
errorCode?: "not_found" | "forbidden" | "invalid" | "conflict" | "blocked" | "internal";
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export type SyncResponse = { results: SyncOpResult[]; serverTime: string };
|
||||
@@ -0,0 +1,129 @@
|
||||
// Work order status machine (client-safe: no server imports).
|
||||
// Server enforcement lives in src/server/services/work-orders/transition.ts,
|
||||
// which MUST call canTransition()/requiredPermission() — never update status directly.
|
||||
|
||||
export const WORK_ORDER_STATUSES = [
|
||||
"draft",
|
||||
"review_required",
|
||||
"planned",
|
||||
"assigned",
|
||||
"accepted",
|
||||
"en_route",
|
||||
"in_progress",
|
||||
"paused",
|
||||
"waiting_material",
|
||||
"daily_report_created",
|
||||
"technically_completed",
|
||||
"signature_pending",
|
||||
"in_review",
|
||||
"released_for_billing",
|
||||
"billed",
|
||||
"cancelled",
|
||||
] as const;
|
||||
|
||||
export type WorkOrderStatus = (typeof WORK_ORDER_STATUSES)[number];
|
||||
|
||||
const T: Record<WorkOrderStatus, readonly WorkOrderStatus[]> = {
|
||||
draft: ["review_required", "planned", "assigned", "cancelled"],
|
||||
review_required: ["planned", "assigned", "cancelled"],
|
||||
planned: ["assigned", "cancelled"],
|
||||
assigned: ["accepted", "en_route", "in_progress", "planned", "cancelled"],
|
||||
accepted: ["en_route", "in_progress", "assigned", "cancelled"],
|
||||
en_route: ["in_progress", "cancelled"],
|
||||
in_progress: ["paused", "waiting_material", "daily_report_created", "technically_completed", "cancelled"],
|
||||
paused: ["in_progress", "en_route", "cancelled"],
|
||||
waiting_material: ["in_progress", "en_route", "cancelled"],
|
||||
daily_report_created: ["en_route", "in_progress", "cancelled"],
|
||||
technically_completed: ["signature_pending", "in_review", "in_progress", "cancelled"],
|
||||
signature_pending: ["in_review", "cancelled"],
|
||||
in_review: ["released_for_billing", "in_progress", "cancelled"],
|
||||
released_for_billing: ["billed", "in_review"],
|
||||
billed: [],
|
||||
cancelled: [],
|
||||
};
|
||||
|
||||
export function allowedTransitions(from: WorkOrderStatus): readonly WorkOrderStatus[] {
|
||||
return T[from];
|
||||
}
|
||||
|
||||
export function canTransition(from: WorkOrderStatus, to: WorkOrderStatus): boolean {
|
||||
return T[from].includes(to);
|
||||
}
|
||||
|
||||
/** Permission required for a transition (server additionally checks work order scope for field roles). */
|
||||
export function requiredPermission(from: WorkOrderStatus, to: WorkOrderStatus): string {
|
||||
if (to === "cancelled") return "work_order:cancel";
|
||||
if (to === "released_for_billing" || to === "billed") return "work_order:release_billing";
|
||||
if (from === "released_for_billing" && to === "in_review") return "work_order:release_billing";
|
||||
if (from === "in_review" && to === "in_progress") return "report:approve_team"; // or report:approve (checked server-side)
|
||||
if (["draft", "review_required", "planned"].includes(from)) return to === "assigned" ? "work_order:assign" : "work_order:write";
|
||||
if (from === "assigned" && to === "planned") return "work_order:assign";
|
||||
return "field:execute";
|
||||
}
|
||||
|
||||
/** Statuses in which field users may record time/material/photos/notes. */
|
||||
export const FIELD_EDITABLE: readonly WorkOrderStatus[] = [
|
||||
"assigned",
|
||||
"accepted",
|
||||
"en_route",
|
||||
"in_progress",
|
||||
"paused",
|
||||
"waiting_material",
|
||||
"daily_report_created",
|
||||
"technically_completed",
|
||||
"signature_pending",
|
||||
];
|
||||
|
||||
export const OPEN_STATUSES: readonly WorkOrderStatus[] = WORK_ORDER_STATUSES.filter(
|
||||
(s) => !["billed", "cancelled", "released_for_billing"].includes(s),
|
||||
);
|
||||
|
||||
/** UI groups per Brandbook §12.3 — label keys live in messages/<locale>/workOrders.json → statusGroup.<key>. */
|
||||
export const STATUS_GROUP: Record<WorkOrderStatus, StatusGroup> = {
|
||||
draft: "new",
|
||||
review_required: "new",
|
||||
planned: "planned",
|
||||
assigned: "planned",
|
||||
accepted: "planned",
|
||||
en_route: "en_route",
|
||||
in_progress: "in_progress",
|
||||
paused: "in_progress",
|
||||
waiting_material: "in_progress",
|
||||
daily_report_created: "in_progress",
|
||||
technically_completed: "documentation_incomplete",
|
||||
signature_pending: "documentation_incomplete",
|
||||
in_review: "in_review",
|
||||
released_for_billing: "ready_for_billing",
|
||||
billed: "billed",
|
||||
cancelled: "cancelled",
|
||||
};
|
||||
|
||||
export type StatusGroup =
|
||||
| "new"
|
||||
| "planned"
|
||||
| "en_route"
|
||||
| "in_progress"
|
||||
| "documentation_incomplete"
|
||||
| "in_review"
|
||||
| "ready_for_billing"
|
||||
| "billed"
|
||||
| "cancelled";
|
||||
|
||||
/** Semantic tone for badges (always combined with text, Brandbook §11.4). */
|
||||
export const STATUS_GROUP_TONE: Record<StatusGroup, "neutral" | "info" | "accent" | "warning" | "success" | "danger"> = {
|
||||
new: "neutral",
|
||||
planned: "info",
|
||||
en_route: "accent",
|
||||
in_progress: "accent",
|
||||
documentation_incomplete: "warning",
|
||||
in_review: "info",
|
||||
ready_for_billing: "success",
|
||||
billed: "success",
|
||||
cancelled: "danger",
|
||||
};
|
||||
|
||||
export type CompletionBlocker =
|
||||
| { kind: "checklist_item"; itemId: string; label: string }
|
||||
| { kind: "photo_requirement"; requirementId: string; label: string }
|
||||
| { kind: "running_session"; sessionId: string; userId: string }
|
||||
| { kind: "missing_field"; field: string };
|
||||
+10
-10
@@ -73,18 +73,18 @@ export function moduleGuard(moduleKey: string) {
|
||||
}
|
||||
if (identity.mustChangePassword) throw new Error("Passwortwechsel erforderlich.");
|
||||
|
||||
if (permissions.length > 0) {
|
||||
const effective = new Set(
|
||||
account.userRoles.flatMap((ur) =>
|
||||
ur.role.rolePermissions.map((rp) => rp.permission.key),
|
||||
),
|
||||
);
|
||||
for (const permission of permissions) {
|
||||
if (!effective.has(permission)) throw new ForbiddenError(permission);
|
||||
}
|
||||
const effective = new Set(
|
||||
account.userRoles.flatMap((ur) =>
|
||||
ur.role.rolePermissions.map((rp) => rp.permission.key),
|
||||
),
|
||||
);
|
||||
for (const permission of permissions) {
|
||||
if (!effective.has(permission)) throw new ForbiddenError(permission);
|
||||
}
|
||||
|
||||
await assertModuleEnabled(session, moduleKey);
|
||||
return { session, db };
|
||||
// `permissions` = DB-authoritative effective set; domain services derive their
|
||||
// scope decisions from it (src/server/services/context.ts#ctxFromGuard).
|
||||
return { session, db, permissions: effective as ReadonlySet<string> };
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// Provider abstraction for AI/OCR (spec §31). Concrete implementations:
|
||||
// src/server/ai/extraction/anthropic.ts (imports lane)
|
||||
// src/server/ai/transcription/openai-compatible.ts (lotse lane)
|
||||
// src/server/ai/lotse/anthropic.ts (lotse lane)
|
||||
// Every call must be recorded as AiGeneration. Without configuration the getters return null
|
||||
// and callers degrade gracefully (manual entry).
|
||||
|
||||
export type ExtractedField<T = string> = {
|
||||
value: T | null;
|
||||
/** 0..1 */
|
||||
confidence: number;
|
||||
/** optional source snippet for the review mask */
|
||||
source?: string;
|
||||
};
|
||||
|
||||
export type WorkOrderExtraction = {
|
||||
orderNumber: ExtractedField;
|
||||
offerNumber: ExtractedField;
|
||||
customerNumber: ExtractedField;
|
||||
companyName: ExtractedField;
|
||||
customerFirstName: ExtractedField;
|
||||
customerLastName: ExtractedField;
|
||||
customerAddress: ExtractedField<{ street?: string; houseNumber?: string; postalCode?: string; city?: string; country?: string }>;
|
||||
siteName: ExtractedField;
|
||||
siteAddress: ExtractedField<{ street?: string; houseNumber?: string; postalCode?: string; city?: string; country?: string }>;
|
||||
contactName: ExtractedField;
|
||||
phone: ExtractedField;
|
||||
email: ExtractedField;
|
||||
orderDate: ExtractedField; // ISO date
|
||||
documentDate: ExtractedField; // ISO date
|
||||
plannedStart: ExtractedField; // ISO date
|
||||
plannedEnd: ExtractedField; // ISO date
|
||||
title: ExtractedField;
|
||||
description: ExtractedField;
|
||||
positions: ExtractedField<Array<{ position?: string; name: string; articleNumber?: string; quantity?: number; unit?: string; isMaterial?: boolean }>>;
|
||||
notes: ExtractedField;
|
||||
totalAmount: ExtractedField<number>;
|
||||
references: ExtractedField<string[]>;
|
||||
};
|
||||
|
||||
export type ProviderMeta = { provider: string; model: string; inputTokens?: number; outputTokens?: number };
|
||||
|
||||
export interface DocumentExtractionProvider {
|
||||
readonly name: string;
|
||||
readonly model: string;
|
||||
extract(input: { bytes: Buffer; mimeType: string; fileName: string }): Promise<{
|
||||
text: string;
|
||||
extraction: WorkOrderExtraction;
|
||||
meta: ProviderMeta;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface TranscriptionProvider {
|
||||
readonly name: string;
|
||||
readonly model: string;
|
||||
transcribe(input: { bytes: Buffer; mimeType: string; language: "de" | "en" }): Promise<{ text: string; meta: ProviderMeta }>;
|
||||
}
|
||||
|
||||
export type ReportDraftInput = {
|
||||
locale: "de" | "en";
|
||||
addressForm: "sie" | "du";
|
||||
workOrder: { title: string; description?: string | null; scope?: string | null; orderType?: string | null };
|
||||
notes: Array<{ kind: string; text: string; at: string }>;
|
||||
checklist: Array<{ label: string; checked: boolean; comment?: string | null }>;
|
||||
materials: Array<{ name: string; planned?: string; actual: string; unit: string; status: string; reason?: string | null }>;
|
||||
photos: Array<{ phase?: string | null; comment?: string | null; requirement?: string | null }>;
|
||||
time: Array<{ type: string; minutes: number; user: string }>;
|
||||
};
|
||||
|
||||
export type ReportDraftOutput = {
|
||||
workPerformed: string;
|
||||
deviations: string;
|
||||
additionalWork: string;
|
||||
openItems: string;
|
||||
nextSteps: string;
|
||||
hints: string;
|
||||
missingInformation: string[];
|
||||
meta: ProviderMeta;
|
||||
};
|
||||
|
||||
export interface LotseProvider {
|
||||
readonly name: string;
|
||||
readonly model: string;
|
||||
draftReport(input: ReportDraftInput): Promise<ReportDraftOutput>;
|
||||
}
|
||||
@@ -44,7 +44,34 @@ export const TENANT_MODELS: readonly string[] = [
|
||||
"AuthToken",
|
||||
"TenantSettings",
|
||||
"TenantModule",
|
||||
// Craftvia-Fachmodelle hier ergänzen (identisch zu src/server/db.ts).
|
||||
// Craftvia domain (0002_craftvia_domain) — identisch zu src/server/db.ts.
|
||||
"NumberSequence",
|
||||
"OrderType",
|
||||
"ChecklistTemplate",
|
||||
"Customer",
|
||||
"Contact",
|
||||
"Site",
|
||||
"Team",
|
||||
"TeamMember",
|
||||
"WorkOrder",
|
||||
"WorkOrderAssignee",
|
||||
"WorkOrderStatusChange",
|
||||
"ChecklistItem",
|
||||
"PhotoRequirement",
|
||||
"MaterialPlan",
|
||||
"MaterialUsage",
|
||||
"WorkSession",
|
||||
"TimeEntry",
|
||||
"ActivityNote",
|
||||
"Document",
|
||||
"Photo",
|
||||
"VoiceNote",
|
||||
"Report",
|
||||
"Signature",
|
||||
"ImportJob",
|
||||
"Notification",
|
||||
"SyncOperation",
|
||||
"AiGeneration",
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -94,6 +94,34 @@ const TENANT_MODELS = new Set<string>([
|
||||
"AuthToken",
|
||||
"TenantSettings",
|
||||
"TenantModule",
|
||||
// Craftvia domain (0002_craftvia_domain)
|
||||
"NumberSequence",
|
||||
"OrderType",
|
||||
"ChecklistTemplate",
|
||||
"Customer",
|
||||
"Contact",
|
||||
"Site",
|
||||
"Team",
|
||||
"TeamMember",
|
||||
"WorkOrder",
|
||||
"WorkOrderAssignee",
|
||||
"WorkOrderStatusChange",
|
||||
"ChecklistItem",
|
||||
"PhotoRequirement",
|
||||
"MaterialPlan",
|
||||
"MaterialUsage",
|
||||
"WorkSession",
|
||||
"TimeEntry",
|
||||
"ActivityNote",
|
||||
"Document",
|
||||
"Photo",
|
||||
"VoiceNote",
|
||||
"Report",
|
||||
"Signature",
|
||||
"ImportJob",
|
||||
"Notification",
|
||||
"SyncOperation",
|
||||
"AiGeneration",
|
||||
// WebAuthnCredential/Identity sind identitäts-global (kein tenant_id) → NICHT hier.
|
||||
// Craftvia-Fachmodelle hier ergänzen — UND in src/server/backup/topology.ts
|
||||
// (TENANT_MODELS) sowie per `SELECT enable_tenant_rls('<table>')` in der Migration
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { DomainEvent } from "@/lib/events";
|
||||
import type { ServiceCtx } from "@/server/services/context";
|
||||
|
||||
/**
|
||||
* Emit a domain event AFTER the mutation succeeded. Never throws: a failing notification
|
||||
* must not roll back business data — failures are logged and surfaced via sync/notification
|
||||
* monitoring. Lanes call ONLY this function; the notifications lane owns the handler.
|
||||
*/
|
||||
export async function emitEvent(ctx: ServiceCtx, event: DomainEvent): Promise<void> {
|
||||
try {
|
||||
const { handleEvent } = await import("@/server/services/notifications/handle-event");
|
||||
await handleEvent(ctx, event);
|
||||
} catch (err) {
|
||||
console.error(`[events] ${event.type} for ${event.entityType}:${event.entityId} failed:`, (err as Error).message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { enqueueJob, type JobPayload, type JobQueueName } from "./queues";
|
||||
import { runInline } from "./processors";
|
||||
|
||||
/**
|
||||
* Queue a background job, or run it inline when no queue is available.
|
||||
* Inline runs are awaited so dev/demo behaves deterministically.
|
||||
*/
|
||||
export async function dispatchJob(name: JobQueueName, payload: JobPayload): Promise<"queued" | "inline"> {
|
||||
try {
|
||||
if (await enqueueJob(name, payload)) return "queued";
|
||||
} catch (err) {
|
||||
console.error(`[jobs] enqueue ${name} failed, running inline:`, (err as Error).message);
|
||||
}
|
||||
await runInline(name, payload);
|
||||
return "inline";
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { JobPayload, JobQueueName } from "../queues";
|
||||
|
||||
export type JobProcessor = (payload: JobPayload) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Processor registry. Each lane adds exactly ONE line for its queue, e.g.
|
||||
* [JOB_QUEUES.importExtraction]: () => import("./import-extraction").then((m) => m.process),
|
||||
* Lazy imports keep the app bundle free of worker-only dependencies (Playwright etc.).
|
||||
*/
|
||||
export const PROCESSORS: Partial<Record<JobQueueName, () => Promise<JobProcessor>>> = {
|
||||
// lane-imports: "import-extraction": () => import("./import-extraction").then((m) => m.process),
|
||||
// lane-lotse: "transcription": () => import("./transcription").then((m) => m.process),
|
||||
// lane-reports: "report-pdf": () => import("./report-pdf").then((m) => m.process),
|
||||
// lane-field: "image-derivatives": () => import("./image-derivatives").then((m) => m.process),
|
||||
};
|
||||
|
||||
/** Inline fallback when no Redis is available (dev/demo). */
|
||||
export async function runInline(name: JobQueueName, payload: JobPayload): Promise<void> {
|
||||
const load = PROCESSORS[name];
|
||||
if (!load) throw new Error(`no processor registered for ${name}`);
|
||||
const process = await load();
|
||||
await process(payload);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Queue } from "bullmq";
|
||||
import IORedis, { type Redis } from "ioredis";
|
||||
|
||||
/**
|
||||
* Craftvia background job queues (spec §30.4). Same operating model as src/server/mail/queue.ts:
|
||||
* with REDIS_URL jobs run asynchronously in `npm run worker:craftvia`; without Redis
|
||||
* enqueueJob() returns false and callers run the processor inline (dev/demo only).
|
||||
*/
|
||||
|
||||
export const JOB_QUEUES = {
|
||||
importExtraction: "import-extraction",
|
||||
transcription: "transcription",
|
||||
reportPdf: "report-pdf",
|
||||
imageDerivatives: "image-derivatives",
|
||||
} as const;
|
||||
|
||||
export type JobQueueName = (typeof JOB_QUEUES)[keyof typeof JOB_QUEUES];
|
||||
|
||||
/** Every job carries its tenant; processors MUST use dbForTenant(tenantId). */
|
||||
export type JobPayload = { tenantId: string; entityId: string; actorId?: string | null };
|
||||
|
||||
let producer: Redis | null = null;
|
||||
let worker: Redis | null = null;
|
||||
const queues = new Map<JobQueueName, Queue<JobPayload>>();
|
||||
|
||||
function url(): string | undefined {
|
||||
return process.env.REDIS_URL?.trim() || undefined;
|
||||
}
|
||||
|
||||
function producerConnection(): Redis | null {
|
||||
const u = url();
|
||||
if (!u) return null;
|
||||
if (!producer) {
|
||||
producer = new IORedis(u, {
|
||||
maxRetriesPerRequest: 1,
|
||||
enableReadyCheck: false,
|
||||
enableOfflineQueue: false,
|
||||
connectTimeout: 3_000,
|
||||
});
|
||||
producer.on("error", (err) => console.error("[jobs] Redis (producer) unavailable:", err.message));
|
||||
}
|
||||
return producer;
|
||||
}
|
||||
|
||||
export function workerConnection(): Redis | null {
|
||||
const u = url();
|
||||
if (!u) return null;
|
||||
if (!worker) {
|
||||
worker = new IORedis(u, { maxRetriesPerRequest: null, enableReadyCheck: false });
|
||||
worker.on("error", (err) => console.error("[jobs] Redis (worker) error:", err.message));
|
||||
}
|
||||
return worker;
|
||||
}
|
||||
|
||||
function queue(name: JobQueueName): Queue<JobPayload> | null {
|
||||
const conn = producerConnection();
|
||||
if (!conn || conn.status !== "ready") return null;
|
||||
let q = queues.get(name);
|
||||
if (!q) {
|
||||
q = new Queue<JobPayload>(name, {
|
||||
connection: conn,
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
backoff: { type: "exponential", delay: 15_000 },
|
||||
removeOnComplete: { age: 7 * 24 * 3600, count: 1000 },
|
||||
removeOnFail: { age: 30 * 24 * 3600 },
|
||||
},
|
||||
});
|
||||
queues.set(name, q);
|
||||
}
|
||||
return q;
|
||||
}
|
||||
|
||||
/** Returns true if the job was queued; false means: caller must process inline. */
|
||||
export async function enqueueJob(name: JobQueueName, payload: JobPayload): Promise<boolean> {
|
||||
const q = queue(name);
|
||||
if (!q) return false;
|
||||
await q.add(name, payload, { jobId: `${name}:${payload.tenantId}:${payload.entityId}:${Date.now()}` });
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function closeJobQueues(): Promise<void> {
|
||||
await Promise.all([...queues.values()].map((q) => q.close()));
|
||||
queues.clear();
|
||||
producer?.disconnect();
|
||||
producer = null;
|
||||
worker?.disconnect();
|
||||
worker = null;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { Session } from "next-auth";
|
||||
import type { TenantDb } from "@/server/db";
|
||||
|
||||
/**
|
||||
* Context passed to every domain service. Created by server actions (from moduleGuard)
|
||||
* and by /api/v1 route handlers (from requireApiContext). Services never read the
|
||||
* session themselves and never create their own db client.
|
||||
*/
|
||||
export type ServiceCtx = {
|
||||
db: TenantDb;
|
||||
tenantId: string;
|
||||
userId: string;
|
||||
/** DB-authoritative effective permissions (never the JWT copy). */
|
||||
permissions: ReadonlySet<string>;
|
||||
};
|
||||
|
||||
export function ctxFromGuard(g: { session: Session; db: TenantDb; permissions: ReadonlySet<string> }): ServiceCtx {
|
||||
return {
|
||||
db: g.db,
|
||||
tenantId: g.session.user.tenantId,
|
||||
userId: g.session.user.id,
|
||||
permissions: g.permissions,
|
||||
};
|
||||
}
|
||||
|
||||
export function can(ctx: ServiceCtx, permission: string): boolean {
|
||||
return ctx.permissions.has(permission);
|
||||
}
|
||||
|
||||
export class ServiceError extends Error {
|
||||
constructor(
|
||||
public code: "not_found" | "forbidden" | "invalid" | "conflict" | "blocked",
|
||||
message: string,
|
||||
public details?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ServiceError";
|
||||
}
|
||||
}
|
||||
|
||||
export function assertCan(ctx: ServiceCtx, permission: string): void {
|
||||
if (!can(ctx, permission)) throw new ServiceError("forbidden", `missing permission ${permission}`);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { DomainEvent } from "@/lib/events";
|
||||
import type { ServiceCtx } from "@/server/services/context";
|
||||
|
||||
/**
|
||||
* Placeholder — replaced by lane "notifications" (recipient rules, in-app Notification rows,
|
||||
* e-mail via the mail queue). Keeps emitEvent() callable for all other lanes meanwhile.
|
||||
*/
|
||||
export async function handleEvent(_ctx: ServiceCtx, _event: DomainEvent): Promise<void> {
|
||||
// intentionally empty
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { TenantDb } from "@/server/db";
|
||||
|
||||
export type NumberKey = "customer" | "work_order" | "emergency" | "report";
|
||||
|
||||
const DEFAULT_PREFIX: Record<NumberKey, string> = {
|
||||
customer: "K-",
|
||||
work_order: "A-",
|
||||
emergency: "N-",
|
||||
report: "B-",
|
||||
};
|
||||
|
||||
/**
|
||||
* Allocate the next number of a tenant sequence, e.g. "A-00042".
|
||||
* The increment is a single UPDATE … RETURNING (atomic); the very first call per key
|
||||
* creates the row and retries once if a concurrent call created it first.
|
||||
*/
|
||||
export async function nextNumber(db: TenantDb, tenantId: string, key: NumberKey): Promise<string> {
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
const existing = await db.numberSequence.findFirst({ where: { tenantId, key }, select: { id: true } });
|
||||
if (existing) {
|
||||
const seq = await db.numberSequence.update({
|
||||
where: { id: existing.id },
|
||||
data: { nextValue: { increment: 1 } },
|
||||
});
|
||||
return format(seq.prefix, seq.nextValue - 1, seq.padding);
|
||||
}
|
||||
try {
|
||||
const seq = await db.numberSequence.create({
|
||||
data: { tenantId, key, prefix: DEFAULT_PREFIX[key], nextValue: 2 },
|
||||
});
|
||||
return format(seq.prefix, 1, seq.padding);
|
||||
} catch (err) {
|
||||
if ((err as { code?: string }).code !== "P2002") throw err; // unique race → retry via update path
|
||||
}
|
||||
}
|
||||
throw new Error(`could not allocate number for ${key}`);
|
||||
}
|
||||
|
||||
function format(prefix: string, value: number, padding: number): string {
|
||||
return `${prefix}${String(value).padStart(padding, "0")}`;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { can, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||
|
||||
/**
|
||||
* Central work order visibility (spec §4.4: technicians only see their own/team orders).
|
||||
* EVERY read of work orders or dependent entities for non-backoffice users MUST use these scopes.
|
||||
* The tenant itself is enforced by dbForTenant/RLS; these filters restrict within the tenant.
|
||||
*/
|
||||
|
||||
/** Active team ids of the current user (member or leader). */
|
||||
export async function activeTeamIds(ctx: ServiceCtx): Promise<string[]> {
|
||||
const now = new Date();
|
||||
const [memberships, led] = await Promise.all([
|
||||
ctx.db.teamMember.findMany({
|
||||
where: { userId: ctx.userId, validFrom: { lte: now }, OR: [{ validTo: null }, { validTo: { gt: now } }], team: { status: "active", deletedAt: null } },
|
||||
select: { teamId: true },
|
||||
}),
|
||||
ctx.db.team.findMany({ where: { leaderUserId: ctx.userId, status: "active", deletedAt: null }, select: { id: true } }),
|
||||
]);
|
||||
return [...new Set([...memberships.map((m) => m.teamId), ...led.map((t) => t.id)])];
|
||||
}
|
||||
|
||||
export async function workOrderScope(ctx: ServiceCtx): Promise<Prisma.WorkOrderWhereInput> {
|
||||
if (can(ctx, "work_order:read_all")) return { deletedAt: null };
|
||||
if (!can(ctx, "work_order:read_team")) return { id: "__none__" };
|
||||
const teamIds = await activeTeamIds(ctx);
|
||||
return {
|
||||
deletedAt: null,
|
||||
OR: [
|
||||
...(teamIds.length ? [{ assignedTeamId: { in: teamIds } }] : []),
|
||||
{ assignees: { some: { userId: ctx.userId } } },
|
||||
{ teamLeadUserId: ctx.userId },
|
||||
{ isEmergency: true, createdById: ctx.userId },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** Customers readable by the user: all for customer:read + read_all, otherwise only via visible orders. */
|
||||
export async function customerScope(ctx: ServiceCtx): Promise<Prisma.CustomerWhereInput> {
|
||||
if (!can(ctx, "customer:read")) return { id: "__none__" };
|
||||
if (can(ctx, "work_order:read_all")) return { deletedAt: null };
|
||||
return { deletedAt: null, workOrders: { some: await workOrderScope(ctx) } };
|
||||
}
|
||||
|
||||
export async function siteScope(ctx: ServiceCtx): Promise<Prisma.SiteWhereInput> {
|
||||
if (!can(ctx, "site:read")) return { id: "__none__" };
|
||||
if (can(ctx, "work_order:read_all")) return { deletedAt: null };
|
||||
return { deletedAt: null, workOrders: { some: await workOrderScope(ctx) } };
|
||||
}
|
||||
|
||||
/** Load a work order the user may see, or throw not_found (never reveal existence). */
|
||||
export async function requireVisibleWorkOrder<S extends Prisma.WorkOrderSelect | undefined = undefined>(
|
||||
ctx: ServiceCtx,
|
||||
workOrderId: string,
|
||||
select?: S,
|
||||
) {
|
||||
const scope = await workOrderScope(ctx);
|
||||
const wo = await ctx.db.workOrder.findFirst({ where: { AND: [{ id: workOrderId }, scope] }, ...(select ? { select } : {}) });
|
||||
if (!wo) throw new ServiceError("not_found", "work order not found");
|
||||
return wo;
|
||||
}
|
||||
|
||||
/** Document visibility levels the user may read (spec §24.3). */
|
||||
export function allowedDocumentVisibility(ctx: ServiceCtx): Array<"backoffice_only" | "team_lead" | "team" | "customer_report"> {
|
||||
if (can(ctx, "document:read_internal")) return ["backoffice_only", "team_lead", "team", "customer_report"];
|
||||
if (can(ctx, "report:approve_team")) return ["team_lead", "team", "customer_report"];
|
||||
return ["team", "customer_report"];
|
||||
}
|
||||
Reference in New Issue
Block a user