first commit

This commit is contained in:
2026-07-16 09:35:32 +02:00
commit dd0746e2e6
179 changed files with 25679 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
{
"name": "@d4rk-tablet/shared",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./dto": "./src/dto/index.ts",
"./events": "./src/events/index.ts",
"./rbac": "./src/rbac/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit",
"build": "tsc"
},
"dependencies": {
"zod": "^3.24.1"
},
"devDependencies": {
"typescript": "^5.6.3"
}
}
+31
View File
@@ -0,0 +1,31 @@
import { z } from 'zod';
import { isoDate } from './common';
/** Beitrag am Schwarzen Brett (Tabelle mdt_announcements). */
export const announcementSchema = z.object({
id: z.number().int(),
title: z.string(),
body: z.string(),
pinned: z.boolean(),
important: z.boolean(),
authorCitizenid: z.string().nullable(),
authorName: z.string().nullable(),
createdAt: isoDate,
});
export type Announcement = z.infer<typeof announcementSchema>;
export const createAnnouncementSchema = z.object({
title: z.string().min(1),
body: z.string().default(''),
important: z.boolean().default(false),
pinned: z.boolean().default(false),
});
export type CreateAnnouncementInput = z.infer<typeof createAnnouncementSchema>;
export const updateAnnouncementSchema = z.object({
title: z.string().min(1).optional(),
body: z.string().optional(),
important: z.boolean().optional(),
pinned: z.boolean().optional(),
});
export type UpdateAnnouncementInput = z.infer<typeof updateAnnouncementSchema>;
+40
View File
@@ -0,0 +1,40 @@
import { z } from 'zod';
import { citizenId } from './common';
import { ROLES, DEPARTMENTS } from '../rbac';
import { permissionSchema } from './rank';
/** JWT-Payload (in Web-App aus Token dekodiert). */
export const authUserSchema = z.object({
sub: z.string(), // interne mdt_users id
citizenid: citizenId.nullable(),
discordId: z.string().nullable(),
name: z.string(),
roles: z.array(z.enum(ROLES)),
/** Effektive Rechte (server-seitig aus Rang-Matrix + Overrides berechnet). */
permissions: z.array(permissionSchema),
/** Behörde + Rang (aus QBox-Job), für Branding & Anzeige. */
department: z.enum(DEPARTMENTS).nullable(),
grade: z.number().int().nullable(),
/** Behörden-Keys, denen der User angehört (aus QBox-Job über die Registry). */
authorities: z.array(z.string()),
});
export type AuthUser = z.infer<typeof authUserSchema>;
/** Body von /auth/fivem — von der fivem-bridge (Server) gesendet, HMAC-signiert. */
export const fivemAuthRequestSchema = z.object({
license: z.string().min(1),
citizenid: citizenId,
name: z.string(),
job: z.string(),
jobGrade: z.string().optional(), // Rang-Bezeichnung (Anzeige)
gradeLevel: z.coerce.number().int().min(0).default(0), // QBox job.grade.level
callsign: z.string().nullable().optional(),
});
export type FivemAuthRequest = z.infer<typeof fivemAuthRequestSchema>;
export const authTokenResponseSchema = z.object({
token: z.string(),
refreshToken: z.string(),
user: authUserSchema,
});
export type AuthTokenResponse = z.infer<typeof authTokenResponseSchema>;
+34
View File
@@ -0,0 +1,34 @@
import { z } from 'zod';
import { DEPARTMENTS } from '../rbac';
/**
* Eine Behörde/Fraktion (LSPD, BCSO, DOJ, SAMS, LSFD …).
* `jobs` = QBox-Job-Namen, die zu dieser Behörde gehören.
* `department` = Basis-Kategorie (police/ems/fire) für Branding & Rang-Matrix.
*/
export const authoritySchema = z.object({
id: z.number().int(),
key: z.string(), // kurzer eindeutiger Schlüssel, z. B. "lspd"
name: z.string(),
color: z.string(),
department: z.enum(DEPARTMENTS),
jobs: z.array(z.string()),
});
export type Authority = z.infer<typeof authoritySchema>;
export const createAuthoritySchema = z.object({
key: z.string().min(1).regex(/^[a-z0-9_]+$/, 'nur a-z, 0-9, _'),
name: z.string().min(1),
color: z.string().min(1).default('#2f6df6'),
department: z.enum(DEPARTMENTS),
jobs: z.array(z.string()).default([]),
});
export type CreateAuthorityInput = z.infer<typeof createAuthoritySchema>;
export const updateAuthoritySchema = z.object({
name: z.string().min(1).optional(),
color: z.string().min(1).optional(),
department: z.enum(DEPARTMENTS).optional(),
jobs: z.array(z.string()).optional(),
});
export type UpdateAuthorityInput = z.infer<typeof updateAuthoritySchema>;
+69
View File
@@ -0,0 +1,69 @@
import { z } from 'zod';
import { citizenId, isoDate } from './common';
/** RSVP-Status eines Teilnehmers. */
export const attendeeStatus = z.enum(['invited', 'accepted', 'declined', 'maybe']);
export type AttendeeStatus = z.infer<typeof attendeeStatus>;
/** Kategorie/Typ eines Termins (Farbe im Frontend). */
export const eventCategory = z.enum(['dienst', 'schulung', 'meeting', 'event', 'sonstiges']);
export type EventCategory = z.infer<typeof eventCategory>;
export const calendarAttendeeSchema = z.object({
id: z.number().int(),
citizenid: citizenId,
name: z.string(),
status: attendeeStatus,
self: z.boolean().default(false), // hat sich selbst eingetragen (kein Invite)
});
export type CalendarAttendee = z.infer<typeof calendarAttendeeSchema>;
export const calendarEventSchema = z.object({
id: z.number().int(),
title: z.string(),
description: z.string().default(''), // HTML (Tiptap)
category: eventCategory.default('sonstiges'),
startAt: isoDate,
endAt: isoDate.nullable(),
allDay: z.boolean().default(false),
location: z.string().nullable(),
openSignup: z.boolean().default(true), // dürfen sich Nicht-Eingeladene selbst eintragen?
organizerCitizenid: citizenId.nullable(),
organizerName: z.string().nullable(),
attendees: z.array(calendarAttendeeSchema).default([]),
createdAt: isoDate,
});
export type CalendarEvent = z.infer<typeof calendarEventSchema>;
/** Einzuladende Person (Snapshot). */
export const inviteeSchema = z.object({ citizenid: citizenId, name: z.string() });
export type Invitee = z.infer<typeof inviteeSchema>;
export const createEventSchema = z.object({
title: z.string().trim().min(1),
description: z.string().default(''),
category: eventCategory.default('sonstiges'),
startAt: z.string(), // ISO
endAt: z.string().nullable().default(null),
allDay: z.boolean().default(false),
location: z.string().trim().nullable().default(null),
openSignup: z.boolean().default(true),
invitees: z.array(inviteeSchema).default([]),
});
export type CreateEventInput = z.infer<typeof createEventSchema>;
export const updateEventSchema = z.object({
title: z.string().trim().min(1).optional(),
description: z.string().optional(),
category: eventCategory.optional(),
startAt: z.string().optional(),
endAt: z.string().nullable().optional(),
allDay: z.boolean().optional(),
location: z.string().trim().nullable().optional(),
openSignup: z.boolean().optional(),
});
export type UpdateEventInput = z.infer<typeof updateEventSchema>;
/** Eigene Teilnahme setzen (Selbst-Eintragung / RSVP). */
export const rsvpSchema = z.object({ status: attendeeStatus });
export type RsvpInput = z.infer<typeof rsvpSchema>;
+49
View File
@@ -0,0 +1,49 @@
import { z } from 'zod';
import { citizenId, isoDate } from './common';
import { caseChargeSchema } from './charge';
export const caseStatus = z.enum(['open', 'closed', 'dismissed']);
export type CaseStatus = z.infer<typeof caseStatus>;
/** Strafakte (Tabelle mdt_cases + mdt_case_charges). */
export const caseSchema = z.object({
id: z.number().int(),
title: z.string(),
suspectCitizenid: citizenId,
suspectName: z.string().nullable(),
status: caseStatus.default('open'),
charges: z.array(caseChargeSchema).default([]),
totalFine: z.number().int().default(0),
totalJailTime: z.number().int().default(0),
narrative: z.string().default(''),
officerCitizenid: citizenId.nullable(),
officerName: z.string().nullable(),
createdAt: isoDate,
updatedAt: isoDate.nullable(),
});
export type Case = z.infer<typeof caseSchema>;
export const createCaseSchema = z.object({
title: z.string().min(1),
suspectCitizenid: citizenId,
narrative: z.string().default(''),
charges: z
.array(
z.object({
catalogId: z.number().int(),
count: z.number().int().min(1).default(1),
}),
)
.default([]),
});
export type CreateCaseInput = z.infer<typeof createCaseSchema>;
export const updateCaseSchema = z.object({
title: z.string().min(1).optional(),
status: caseStatus.optional(),
narrative: z.string().optional(),
charges: z
.array(z.object({ catalogId: z.number().int(), count: z.number().int().min(1).default(1) }))
.optional(),
});
export type UpdateCaseInput = z.infer<typeof updateCaseSchema>;
+31
View File
@@ -0,0 +1,31 @@
import { z } from 'zod';
/** Eintrag im Strafenkatalog (Tabelle mdt_charges_catalog). */
export const chargeCatalogItemSchema = z.object({
id: z.number().int(),
code: z.string(), // Paragraph, z. B. "§ 242"
title: z.string(),
category: z.string(),
fine: z.number().int().default(0), // Bußgeld in €
jailTime: z.number().int().default(0), // Haftzeit in Monaten
points: z.number().int().default(0), // Strafpunkte
active: z.boolean().default(true),
});
export type ChargeCatalogItem = z.infer<typeof chargeCatalogItemSchema>;
export const upsertChargeCatalogItemSchema = chargeCatalogItemSchema
.omit({ id: true })
.partial({ active: true, fine: true, jailTime: true, points: true });
export type UpsertChargeCatalogItemInput = z.infer<typeof upsertChargeCatalogItemSchema>;
/** Eine konkret in einer Akte verhängte Anklage (mdt_case_charges), mit Multiplikator. */
export const caseChargeSchema = z.object({
catalogId: z.number().int(),
code: z.string(),
title: z.string(),
count: z.number().int().min(1).default(1),
fine: z.number().int(),
jailTime: z.number().int(),
points: z.number().int(),
});
export type CaseCharge = z.infer<typeof caseChargeSchema>;
+35
View File
@@ -0,0 +1,35 @@
import { z } from 'zod';
/** ISO-Datumsstring (z. B. Geburtsdatum, Timestamps). */
export const isoDate = z.string();
/** QBox citizenid (Primärschlüssel für Personen). */
export const citizenId = z.string().min(1);
/** Kennzeichen. QBox speichert i. d. R. mit trailing spaces → wir trimmen. */
export const plate = z.string().min(1).transform((s) => s.trim().toUpperCase());
/** Koordinaten (Live-Map / Dispatch). */
export const coordsSchema = z.object({
x: z.number(),
y: z.number(),
z: z.number(),
heading: z.number().optional(),
});
export type Coords = z.infer<typeof coordsSchema>;
/** Standard-Paginierung für Listen-Endpoints. */
export const paginationSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(25),
});
export type Pagination = z.infer<typeof paginationSchema>;
export function paginated<T extends z.ZodTypeAny>(item: T) {
return z.object({
items: z.array(item),
total: z.number().int(),
page: z.number().int(),
pageSize: z.number().int(),
});
}
+45
View File
@@ -0,0 +1,45 @@
import { z } from 'zod';
import { citizenId, isoDate, coordsSchema } from './common';
import { DEPARTMENTS } from '../rbac';
export const dispatchStatus = z.enum(['pending', 'assigned', 'enroute', 'onscene', 'closed']);
export type DispatchStatus = z.infer<typeof dispatchStatus>;
export const dispatchPriority = z.enum(['low', 'medium', 'high']);
export type DispatchPriority = z.infer<typeof dispatchPriority>;
/** Live-Einsatz (Tabelle mdt_dispatch_calls + Realtime via Socket). */
export const dispatchCallSchema = z.object({
id: z.number().int(),
code: z.string(), // 10-Code / Kategorie
title: z.string(),
description: z.string().default(''),
department: z.enum(DEPARTMENTS),
priority: dispatchPriority.default('medium'),
status: dispatchStatus.default('pending'),
location: z.string().nullable(),
coords: coordsSchema.nullable(),
callerCitizenid: citizenId.nullable(),
assignedOfficers: z.array(citizenId).default([]),
createdAt: isoDate,
updatedAt: isoDate.nullable(),
});
export type DispatchCall = z.infer<typeof dispatchCallSchema>;
export const createDispatchCallSchema = z.object({
code: z.string().min(1),
title: z.string().min(1),
description: z.string().default(''),
department: z.enum(DEPARTMENTS).default('police'),
priority: dispatchPriority.default('medium'),
location: z.string().nullable().default(null),
coords: coordsSchema.nullable().default(null),
callerCitizenid: citizenId.nullable().default(null),
});
export type CreateDispatchCallInput = z.infer<typeof createDispatchCallSchema>;
export const assignDispatchSchema = z.object({
officers: z.array(citizenId).min(1),
status: dispatchStatus.optional(),
});
export type AssignDispatchInput = z.infer<typeof assignDispatchSchema>;
+81
View File
@@ -0,0 +1,81 @@
import { z } from 'zod';
import { isoDate } from './common';
/** Ein Freigabe-Eintrag: wer (Subjekt) darf was (Level). */
export const aclSubjectType = z.enum(['authority', 'group', 'person']);
export type AclSubjectType = z.infer<typeof aclSubjectType>;
export const aclLevel = z.enum(['read', 'write']);
export type AclLevel = z.infer<typeof aclLevel>;
export const aclEntrySchema = z.object({
type: aclSubjectType,
id: z.string(), // authority-key | group-id | citizenid
label: z.string(), // Anzeigename (Snapshot)
level: aclLevel,
});
export type AclEntry = z.infer<typeof aclEntrySchema>;
/** Freigabe-Zustand eines Ordners/Dokuments. */
export const accessSchema = z.object({
public: z.boolean().default(false), // öffentlich lesbar (jeder mit documents.view)
acl: z.array(aclEntrySchema).default([]),
});
export type Access = z.infer<typeof accessSchema>;
export const docFolderSchema = z.object({
id: z.number().int(),
name: z.string(),
public: z.boolean(),
acl: z.array(aclEntrySchema),
docCount: z.number().int().default(0),
});
export type DocFolder = z.infer<typeof docFolderSchema>;
/** Kurzform für Listen. */
export const documentSummarySchema = z.object({
id: z.number().int(),
reference: z.string(), // DO-2026-07-06-001
title: z.string(),
folderId: z.number().int().nullable(),
public: z.boolean(),
acl: z.array(aclEntrySchema),
pinned: z.boolean(),
canWrite: z.boolean(), // darf der aktuelle Betrachter bearbeiten?
authorName: z.string().nullable(),
updatedAt: isoDate,
});
export type DocumentSummary = z.infer<typeof documentSummarySchema>;
/** Volles Dokument inkl. Rich-Text-Body (HTML aus Tiptap). */
export const documentSchema = documentSummarySchema.extend({
content: z.string(), // HTML
createdAt: isoDate,
});
export type MdtDocument = z.infer<typeof documentSchema>;
export const createDocumentSchema = z.object({
title: z.string().min(1),
content: z.string().default(''),
folderId: z.number().int().nullable().default(null),
public: z.boolean().default(false),
acl: z.array(aclEntrySchema).default([]),
});
export type CreateDocumentInput = z.infer<typeof createDocumentSchema>;
export const updateDocumentSchema = z.object({
title: z.string().min(1).optional(),
content: z.string().optional(),
folderId: z.number().int().nullable().optional(),
public: z.boolean().optional(),
acl: z.array(aclEntrySchema).optional(),
pinned: z.boolean().optional(),
});
export type UpdateDocumentInput = z.infer<typeof updateDocumentSchema>;
export const createFolderSchema = z.object({
name: z.string().min(1),
public: z.boolean().default(false),
acl: z.array(aclEntrySchema).default([]),
});
export type CreateFolderInput = z.infer<typeof createFolderSchema>;
+39
View File
@@ -0,0 +1,39 @@
import { z } from 'zod';
import { citizenId } from './common';
export const groupMemberSchema = z.object({
citizenid: citizenId,
name: z.string(),
});
export type GroupMember = z.infer<typeof groupMemberSchema>;
export const groupSchema = z.object({
id: z.number().int(),
name: z.string(),
color: z.string(),
memberCount: z.number().int().default(0),
});
export type Group = z.infer<typeof groupSchema>;
export const groupDetailSchema = groupSchema.extend({
members: z.array(groupMemberSchema),
});
export type GroupDetail = z.infer<typeof groupDetailSchema>;
export const createGroupSchema = z.object({
name: z.string().min(1),
color: z.string().min(1).default('#8b5cf6'),
});
export type CreateGroupInput = z.infer<typeof createGroupSchema>;
export const updateGroupSchema = z.object({
name: z.string().min(1).optional(),
color: z.string().min(1).optional(),
});
export type UpdateGroupInput = z.infer<typeof updateGroupSchema>;
export const addGroupMemberSchema = z.object({
citizenid: citizenId,
name: z.string().min(1),
});
export type AddGroupMemberInput = z.infer<typeof addGroupMemberSchema>;
+17
View File
@@ -0,0 +1,17 @@
export * from './common';
export * from './person';
export * from './vehicle';
export * from './charge';
export * from './case';
export * from './treatment';
export * from './law';
export * from './calendar';
export * from './warrant';
export * from './dispatch';
export * from './officer';
export * from './rank';
export * from './announcement';
export * from './document';
export * from './authority';
export * from './group';
export * from './auth';
+28
View File
@@ -0,0 +1,28 @@
import { z } from 'zod';
/** Gesetzes-/Paragraphen-Eintrag (Strafgesetzbuch-Nachschlagewerk). */
export const lawSchema = z.object({
id: z.number().int(),
category: z.string(), // z. B. "Eigentumsdelikte"
paragraph: z.string(), // z. B. "§ 242"
title: z.string(),
description: z.string().default(''), // HTML (Tiptap)
fine: z.number().int().nullable().default(null),
jailTime: z.number().int().nullable().default(null), // Monate
sortOrder: z.number().int().default(0),
});
export type Law = z.infer<typeof lawSchema>;
export const createLawSchema = z.object({
category: z.string().trim().min(1),
paragraph: z.string().trim().min(1),
title: z.string().trim().min(1),
description: z.string().default(''),
fine: z.number().int().nullable().default(null),
jailTime: z.number().int().nullable().default(null),
sortOrder: z.number().int().default(0),
});
export type CreateLawInput = z.infer<typeof createLawSchema>;
export const updateLawSchema = createLawSchema.partial();
export type UpdateLawInput = z.infer<typeof updateLawSchema>;
+28
View File
@@ -0,0 +1,28 @@
import { z } from 'zod';
import { citizenId, coordsSchema } from './common';
import { DEPARTMENTS } from '../rbac';
/** Live-Zustand eines Officers (nur RAM/Socket, nicht persistiert). */
export const officerSchema = z.object({
citizenid: citizenId,
name: z.string(),
callsign: z.string().nullable(),
department: z.enum(DEPARTMENTS),
onDuty: z.boolean().default(false),
coords: coordsSchema.nullable().default(null),
updatedAt: z.number().int(), // epoch ms
});
export type Officer = z.infer<typeof officerSchema>;
/** Position-Update, das die fivem-bridge (throttled) an das Backend pusht. */
export const officerPositionUpdateSchema = z.object({
citizenid: citizenId,
coords: coordsSchema,
});
export type OfficerPositionUpdate = z.infer<typeof officerPositionUpdateSchema>;
export const officerDutyUpdateSchema = z.object({
citizenid: citizenId,
onDuty: z.boolean(),
});
export type OfficerDutyUpdate = z.infer<typeof officerDutyUpdateSchema>;
+108
View File
@@ -0,0 +1,108 @@
import { z } from 'zod';
import { citizenId, isoDate } from './common';
import { aclEntrySchema } from './document';
/** Aufenthaltsstatus — u. a. für illegale Einreise (Behörden-Führung, nicht QBox). */
export const legalStatusValues = ['citizen', 'resident', 'visitor', 'illegal', 'unknown'] as const;
export const legalStatusSchema = z.enum(legalStatusValues);
export type LegalStatus = z.infer<typeof legalStatusSchema>;
export const genderValues = ['male', 'female', 'divers', 'unknown'] as const;
export const genderSchema = z.enum(genderValues);
export type Gender = z.infer<typeof genderSchema>;
/** Führerschein-/Schein-Eintrag (editierbar). */
export const licenseSchema = z.object({
type: z.string(),
label: z.string(),
active: z.boolean().default(true),
});
export type License = z.infer<typeof licenseSchema>;
export const plateStatusValues = ['registered', 'forged', 'stolen', 'unknown'] as const;
export const plateStatusSchema = z.enum(plateStatusValues);
export type PlateStatus = z.infer<typeof plateStatusSchema>;
/** Auf einen Bürger geführtes Fahrzeug (aus mdt_vehicles). */
export const ownedVehicleSchema = z.object({
id: z.number().int().optional(),
plate: z.string(),
model: z.string().nullable(),
plateStatus: plateStatusSchema.default('registered'),
isStolen: z.boolean().default(false),
});
export type OwnedVehicle = z.infer<typeof ownedVehicleSchema>;
/** Kurzform für Registry-Liste + Suche. */
export const personSummarySchema = z.object({
citizenid: citizenId,
firstname: z.string(),
lastname: z.string(),
dob: z.string().nullable(),
phone: z.string().nullable(),
isWanted: z.boolean().default(false),
mugshotUrl: z.string().nullable().default(null),
legalStatus: legalStatusSchema.default('citizen'),
// Freigabe/Sichtbarkeit — welche Behörden/Gruppen/Personen die Akte sehen dürfen
public: z.boolean().default(true),
acl: z.array(aclEntrySchema).default([]),
});
export type PersonSummary = z.infer<typeof personSummarySchema>;
/** Volle, editierbare Behörden-Bürgerakte (MDT-eigen, unabhängig von QBox). */
export const personSchema = personSummarySchema.extend({
gender: genderSchema.default('unknown'),
nationality: z.string().nullable().default(null),
address: z.string().nullable().default(null),
occupation: z.string().nullable().default(null),
height: z.string().nullable().default(null),
eyeColor: z.string().nullable().default(null),
hairColor: z.string().nullable().default(null),
distinguishingMarks: z.string().default(''),
aliases: z.array(z.string()).default([]),
licenses: z.array(licenseSchema).default([]),
notes: z.string().default(''),
flags: z.array(z.string()).default([]),
createdByName: z.string().nullable().default(null),
createdAt: isoDate.nullable().default(null),
updatedAt: isoDate.nullable().default(null),
updatedBy: z.string().nullable().default(null),
vehicles: z.array(ownedVehicleSchema).default([]),
// Zähler; Detaildaten kommen aus /warrants bzw. /cases
openWarrants: z.number().int().default(0),
totalCases: z.number().int().default(0),
});
export type Person = z.infer<typeof personSchema>;
export const personSearchQuerySchema = z.object({
query: z.string().trim().min(1),
});
/** Bearbeitbare Felder einer Bürgerakte (Create verlangt Name, Update macht alles optional). */
export const createCitizenSchema = z.object({
firstname: z.string().trim().min(1),
lastname: z.string().trim().min(1),
dob: z.string().trim().nullable().optional(),
gender: genderSchema.optional(),
phone: z.string().trim().nullable().optional(),
nationality: z.string().trim().nullable().optional(),
legalStatus: legalStatusSchema.optional(),
address: z.string().trim().nullable().optional(),
occupation: z.string().trim().nullable().optional(),
height: z.string().trim().nullable().optional(),
eyeColor: z.string().trim().nullable().optional(),
hairColor: z.string().trim().nullable().optional(),
distinguishingMarks: z.string().optional(),
aliases: z.array(z.string()).optional(),
licenses: z.array(licenseSchema).optional(),
mugshotUrl: z.string().trim().nullable().optional(),
notes: z.string().optional(),
flags: z.array(z.string()).optional(),
isWanted: z.boolean().optional(),
public: z.boolean().optional(),
acl: z.array(aclEntrySchema).optional(),
});
export type CreateCitizenInput = z.infer<typeof createCitizenSchema>;
export const updateCitizenSchema = createCitizenSchema.partial();
export type UpdateCitizenInput = z.infer<typeof updateCitizenSchema>;
+39
View File
@@ -0,0 +1,39 @@
import { z } from 'zod';
import { PERMISSIONS, DEPARTMENTS, type Permission } from '../rbac';
export const permissionSchema = z.enum(PERMISSIONS as unknown as [Permission, ...Permission[]]);
/** Ein Rang einer Behörde (Department + QBox-Grade) mit seinem Rechte-Set. */
export const rankSchema = z.object({
department: z.enum(DEPARTMENTS),
grade: z.number().int().min(0),
label: z.string(),
permissions: z.array(permissionSchema),
});
export type Rank = z.infer<typeof rankSchema>;
/** Update aus der Admin-Rechte-Matrix. */
export const updateRankSchema = z.object({
label: z.string().min(1).optional(),
permissions: z.array(permissionSchema),
});
export type UpdateRankInput = z.infer<typeof updateRankSchema>;
/** Mitarbeiter-Eintrag (mdt_users) für das Verwaltungs-Modul. */
export const mdtUserSchema = z.object({
id: z.number().int(),
citizenid: z.string().nullable(),
discordId: z.string().nullable(),
name: z.string(),
callsign: z.string().nullable(),
department: z.enum(DEPARTMENTS).nullable(),
grade: z.number().int().nullable(),
roles: z.array(z.string()),
});
export type MdtUser = z.infer<typeof mdtUserSchema>;
/** Override-Rollen eines Mitarbeiters setzen (admin/dispatch). */
export const updateUserRolesSchema = z.object({
roles: z.array(z.string()),
});
export type UpdateUserRolesInput = z.infer<typeof updateUserRolesSchema>;
+39
View File
@@ -0,0 +1,39 @@
import { z } from 'zod';
import { citizenId, isoDate } from './common';
/** Status einer Behandlungsakte (EMS-Pendant zu den Strafakten). */
export const treatmentStatus = z.enum(['open', 'closed']);
export type TreatmentStatus = z.infer<typeof treatmentStatus>;
/** Behandlungsakte (Tabelle mdt_treatment_records). */
export const treatmentRecordSchema = z.object({
id: z.number().int(),
reference: z.string(), // TR-JJJJ-MM-TT-###
patientCitizenid: citizenId,
patientName: z.string().nullable(),
title: z.string(),
diagnosis: z.string().default(''),
treatment: z.string().default(''), // HTML aus Tiptap
status: treatmentStatus.default('open'),
authorCitizenid: citizenId.nullable(),
authorName: z.string().nullable(),
createdAt: isoDate,
updatedAt: isoDate.nullable(),
});
export type TreatmentRecord = z.infer<typeof treatmentRecordSchema>;
export const createTreatmentSchema = z.object({
patientCitizenid: citizenId,
title: z.string().min(1),
diagnosis: z.string().default(''),
treatment: z.string().default(''),
});
export type CreateTreatmentInput = z.infer<typeof createTreatmentSchema>;
export const updateTreatmentSchema = z.object({
title: z.string().min(1).optional(),
diagnosis: z.string().optional(),
treatment: z.string().optional(),
status: treatmentStatus.optional(),
});
export type UpdateTreatmentInput = z.infer<typeof updateTreatmentSchema>;
+44
View File
@@ -0,0 +1,44 @@
import { z } from 'zod';
import { citizenId, plate } from './common';
import { plateStatusSchema } from './person';
/** MDT-geführtes Fahrzeug (eigene Registry, unabhängig von QBox — inkl. gefälschter Kennzeichen). */
export const vehicleSchema = z.object({
id: z.number().int(),
plate: z.string(),
model: z.string().nullable(),
color: z.string().nullable(),
ownerCitizenid: citizenId.nullable(),
ownerName: z.string().nullable(),
plateStatus: plateStatusSchema.default('registered'),
isStolen: z.boolean().default(false),
hasBolo: z.boolean().default(false),
notes: z.string().default(''),
});
export type Vehicle = z.infer<typeof vehicleSchema>;
export const vehicleLookupParamsSchema = z.object({
plate,
});
export const createVehicleSchema = z.object({
plate: z.string().trim().min(1),
model: z.string().trim().nullable().optional(),
color: z.string().trim().nullable().optional(),
ownerCitizenid: z.string().trim().nullable().optional(),
plateStatus: plateStatusSchema.optional(),
isStolen: z.boolean().optional(),
hasBolo: z.boolean().optional(),
notes: z.string().optional(),
});
export type CreateVehicleInput = z.infer<typeof createVehicleSchema>;
export const updateVehicleSchema = createVehicleSchema.partial();
export type UpdateVehicleInput = z.infer<typeof updateVehicleSchema>;
/** Schneller „als gestohlen"-Umschalter (aus der Kennzeichen-Abfrage). */
export const flagVehicleSchema = z.object({
isStolen: z.boolean().optional(),
reason: z.string().optional(),
});
export type FlagVehicleInput = z.infer<typeof flagVehicleSchema>;
+34
View File
@@ -0,0 +1,34 @@
import { z } from 'zod';
import { citizenId, isoDate } from './common';
export const warrantStatus = z.enum(['active', 'served', 'revoked', 'expired']);
export type WarrantStatus = z.infer<typeof warrantStatus>;
/** Haftbefehl (Tabelle mdt_warrants). */
export const warrantSchema = z.object({
id: z.number().int(),
citizenid: citizenId,
subjectName: z.string().nullable(),
reason: z.string(),
status: warrantStatus.default('active'),
caseId: z.number().int().nullable(),
issuedByCitizenid: citizenId.nullable(),
issuedByName: z.string().nullable(),
issuedAt: isoDate,
expiresAt: isoDate.nullable(),
});
export type Warrant = z.infer<typeof warrantSchema>;
export const createWarrantSchema = z.object({
citizenid: citizenId,
reason: z.string().min(1),
caseId: z.number().int().nullable().default(null),
expiresAt: isoDate.nullable().default(null),
});
export type CreateWarrantInput = z.infer<typeof createWarrantSchema>;
export const updateWarrantSchema = z.object({
status: warrantStatus.optional(),
reason: z.string().min(1).optional(),
});
export type UpdateWarrantInput = z.infer<typeof updateWarrantSchema>;
+46
View File
@@ -0,0 +1,46 @@
import type { DispatchCall } from '../dto/dispatch';
import type { Officer, OfficerPositionUpdate, OfficerDutyUpdate } from '../dto/officer';
import type { Warrant } from '../dto/warrant';
import type { Coords } from '../dto/common';
/**
* Socket.io Event-Namen — zentral, damit Server & Client nie divergieren.
*/
export const SOCKET_EVENTS = {
// Server -> Client
DISPATCH_CREATED: 'dispatch.created',
DISPATCH_UPDATED: 'dispatch.updated',
OFFICER_MOVED: 'officer.moved',
OFFICER_DUTY: 'officer.duty',
OFFICER_SNAPSHOT: 'officer.snapshot',
BOLO_CREATED: 'bolo.created',
WARRANT_UPDATED: 'warrant.updated',
// Client -> Server
DISPATCH_ASSIGN: 'dispatch.assign',
OFFICER_SET_WAYPOINT: 'officer.setWaypoint',
} as const;
export type SocketEventName = (typeof SOCKET_EVENTS)[keyof typeof SOCKET_EVENTS];
/** Server -> Client Payloads. */
export interface ServerToClientEvents {
[SOCKET_EVENTS.DISPATCH_CREATED]: (call: DispatchCall) => void;
[SOCKET_EVENTS.DISPATCH_UPDATED]: (call: DispatchCall) => void;
[SOCKET_EVENTS.OFFICER_MOVED]: (update: OfficerPositionUpdate) => void;
[SOCKET_EVENTS.OFFICER_DUTY]: (update: OfficerDutyUpdate) => void;
[SOCKET_EVENTS.OFFICER_SNAPSHOT]: (officers: Officer[]) => void;
[SOCKET_EVENTS.BOLO_CREATED]: (payload: { id: number; kind: 'person' | 'vehicle'; label: string }) => void;
[SOCKET_EVENTS.WARRANT_UPDATED]: (warrant: Warrant) => void;
}
/** Client -> Server Payloads. */
export interface ClientToServerEvents {
[SOCKET_EVENTS.DISPATCH_ASSIGN]: (payload: { callId: number; officers: string[] }) => void;
[SOCKET_EVENTS.OFFICER_SET_WAYPOINT]: (payload: { targetCitizenid: string; coords: Coords }) => void;
}
/** Socket-Rooms je Department (z. B. "dept:police"). */
export function departmentRoom(dept: string): string {
return `dept:${dept}`;
}
+3
View File
@@ -0,0 +1,3 @@
export * from './rbac';
export * from './dto';
export * from './events';
+234
View File
@@ -0,0 +1,234 @@
/**
* RBAC — granulare Permission-Nodes (Hybrid-Rang-System).
* Nodes sind hierarchisch benannt: `<bereich>.<ressource>.<aktion>`.
* Serverseitig erzwungen; die effektiven Rechte werden bei Login berechnet
* (Rang-Matrix Override-Rollen) und ins JWT gebacken.
*/
export const ROLES = ['police', 'ems', 'fire', 'dispatch', 'admin'] as const;
export type Role = (typeof ROLES)[number];
export const DEPARTMENTS = ['police', 'ems', 'fire'] as const;
export type Department = (typeof DEPARTMENTS)[number];
// ── Permission-Nodes (granular, an den echten Endpoints ausgerichtet) ──
export const PERMISSIONS = [
// MDT · Schwarzes Brett
'mdt.board.view',
'mdt.board.manage',
// MDT · Personen (Bürgerakten)
'mdt.persons.view',
'mdt.persons.create',
'mdt.persons.edit',
'mdt.persons.delete',
// MDT · Fahrzeuge
'mdt.vehicles.view',
'mdt.vehicles.create',
'mdt.vehicles.flag',
'mdt.vehicles.delete',
// MDT · Strafakten
'mdt.cases.view',
'mdt.cases.create',
'mdt.cases.edit',
'mdt.charges.manage',
// MDT · Gesetze
'mdt.laws.view',
'mdt.laws.manage',
// MDT · Kalender
'mdt.calendar.view',
'mdt.calendar.manage',
// MDT · Behandlungsakten (EMS)
'mdt.treatment.view',
'mdt.treatment.create',
'mdt.treatment.edit',
// MDT · Haftbefehle
'mdt.warrants.view',
'mdt.warrants.create',
'mdt.warrants.revoke',
// MDT · Dokumente
'mdt.documents.view',
'mdt.documents.manage',
// CAD · Dispatch
'cad.dispatch.view',
'cad.dispatch.manage',
// Administration
'admin.ranks.manage',
'admin.users.manage',
'admin.audit.view',
] as const;
export type Permission = (typeof PERMISSIONS)[number];
// ── Baum-Struktur für den visuellen Rechte-Explorer ──
export interface PermissionLeaf {
id: Permission;
label: string;
}
export interface PermissionGroup {
id: string;
label: string;
children: PermissionLeaf[];
}
export interface PermissionArea {
id: string;
label: string;
groups: PermissionGroup[];
}
export const PERMISSION_TREE: PermissionArea[] = [
{
id: 'mdt',
label: 'MDT',
groups: [
{
id: 'board',
label: 'Schwarzes Brett',
children: [
{ id: 'mdt.board.view', label: 'Ansehen' },
{ id: 'mdt.board.manage', label: 'Beiträge verwalten' },
],
},
{
id: 'persons',
label: 'Bürgerakten',
children: [
{ id: 'mdt.persons.view', label: 'Anzeigen & Suchen' },
{ id: 'mdt.persons.create', label: 'Bürger anlegen' },
{ id: 'mdt.persons.edit', label: 'Akte bearbeiten' },
{ id: 'mdt.persons.delete', label: 'Bürger löschen' },
],
},
{
id: 'vehicles',
label: 'Fahrzeuge',
children: [
{ id: 'mdt.vehicles.view', label: 'Anzeigen' },
{ id: 'mdt.vehicles.create', label: 'Fahrzeug anlegen' },
{ id: 'mdt.vehicles.flag', label: 'Als gestohlen markieren' },
{ id: 'mdt.vehicles.delete', label: 'Fahrzeug löschen' },
],
},
{
id: 'cases',
label: 'Strafakten',
children: [
{ id: 'mdt.cases.view', label: 'Anzeigen' },
{ id: 'mdt.cases.create', label: 'Anlegen' },
{ id: 'mdt.cases.edit', label: 'Bearbeiten' },
{ id: 'mdt.charges.manage', label: 'Strafenkatalog pflegen' },
],
},
{
id: 'laws',
label: 'Gesetze',
children: [
{ id: 'mdt.laws.view', label: 'Anzeigen' },
{ id: 'mdt.laws.manage', label: 'Gesetze pflegen' },
],
},
{
id: 'calendar',
label: 'Kalender',
children: [
{ id: 'mdt.calendar.view', label: 'Anzeigen & selbst eintragen' },
{ id: 'mdt.calendar.manage', label: 'Termine verwalten' },
],
},
{
id: 'treatment',
label: 'Behandlungsakten (EMS)',
children: [
{ id: 'mdt.treatment.view', label: 'Anzeigen' },
{ id: 'mdt.treatment.create', label: 'Anlegen' },
{ id: 'mdt.treatment.edit', label: 'Bearbeiten' },
],
},
{
id: 'warrants',
label: 'Haftbefehle',
children: [
{ id: 'mdt.warrants.view', label: 'Anzeigen' },
{ id: 'mdt.warrants.create', label: 'Ausstellen' },
{ id: 'mdt.warrants.revoke', label: 'Aufheben' },
],
},
{
id: 'documents',
label: 'Dokumente',
children: [
{ id: 'mdt.documents.view', label: 'Anzeigen' },
{ id: 'mdt.documents.manage', label: 'Erstellen/Bearbeiten' },
],
},
],
},
{
id: 'cad',
label: 'CAD / Dispatch',
groups: [
{
id: 'dispatch',
label: 'Dispatch',
children: [
{ id: 'cad.dispatch.view', label: 'Einsätze & Karte ansehen' },
{ id: 'cad.dispatch.manage', label: 'Einsätze verwalten/zuweisen' },
],
},
],
},
{
id: 'admin',
label: 'Administration',
groups: [
{
id: 'admin',
label: 'Verwaltung',
children: [
{ id: 'admin.ranks.manage', label: 'Rechte-Matrix / Ränge' },
{ id: 'admin.users.manage', label: 'Mitarbeiter verwalten' },
{ id: 'admin.audit.view', label: 'Audit-Log einsehen' },
],
},
],
},
];
// ── Rollen-Templates (im Explorer anwendbar; auch Basis der Rang-Defaults) ──
export const ROLE_TEMPLATES: Record<string, Permission[]> = {
Officer: [
'mdt.board.view',
'mdt.persons.view', 'mdt.persons.create', 'mdt.persons.edit',
'mdt.vehicles.view', 'mdt.vehicles.create', 'mdt.vehicles.flag',
'mdt.cases.view', 'mdt.cases.create', 'mdt.cases.edit',
'mdt.laws.view',
'mdt.calendar.view',
'mdt.warrants.view',
'mdt.documents.view',
'cad.dispatch.view',
],
Supervisor: [
'mdt.board.view', 'mdt.board.manage',
'mdt.persons.view', 'mdt.persons.create', 'mdt.persons.edit', 'mdt.persons.delete',
'mdt.vehicles.view', 'mdt.vehicles.create', 'mdt.vehicles.flag', 'mdt.vehicles.delete',
'mdt.cases.view', 'mdt.cases.create', 'mdt.cases.edit',
'mdt.laws.view', 'mdt.laws.manage',
'mdt.calendar.view', 'mdt.calendar.manage',
'mdt.warrants.view', 'mdt.warrants.create', 'mdt.warrants.revoke',
'mdt.documents.view', 'mdt.documents.manage',
'cad.dispatch.view', 'cad.dispatch.manage',
],
Chief: [...([] as Permission[])], // unten gefüllt
Dispatcher: ['mdt.board.view', 'mdt.persons.view', 'mdt.vehicles.view', 'mdt.calendar.view', 'mdt.documents.view', 'cad.dispatch.view', 'cad.dispatch.manage'],
'EMS/Fire': ['mdt.board.view', 'mdt.persons.view', 'mdt.vehicles.view', 'mdt.laws.view', 'mdt.calendar.view', 'mdt.treatment.view', 'mdt.treatment.create', 'mdt.treatment.edit', 'mdt.documents.view', 'cad.dispatch.view', 'cad.dispatch.manage'],
};
ROLE_TEMPLATES.Chief = [...PERMISSIONS]; // Chief = alles
// ── Override-Rollen (nicht aus dem Job): admin = alles, dispatch = CAD ──
export const ROLE_PERMISSIONS: Record<'admin' | 'dispatch', Permission[]> = {
admin: [...PERMISSIONS],
dispatch: ['mdt.persons.view', 'mdt.vehicles.view', 'cad.dispatch.view', 'cad.dispatch.manage'],
};
/** Prüft eine berechnete Permission-Liste. */
export function can(permissions: readonly Permission[], perm: Permission): boolean {
return permissions.includes(perm);
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*.ts"]
}