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
+21
View File
@@ -0,0 +1,21 @@
import { defineConfig } from 'drizzle-kit';
import { env } from './src/config/env';
/**
* WICHTIG: Nur mdt_*-Tabellen werden migriert.
* Die QBox-Tabellen (players, player_vehicles) werden per tablesFilter ausgeschlossen —
* Drizzle darf sie niemals anfassen.
*/
export default defineConfig({
dialect: 'mysql',
schema: './src/db/schema/mdt.ts',
out: './drizzle',
dbCredentials: {
host: env.DB_HOST,
port: env.DB_PORT,
user: env.DB_USER,
password: env.DB_PASSWORD,
database: env.DB_NAME,
},
tablesFilter: ['mdt_*'],
});
@@ -0,0 +1,121 @@
CREATE TABLE `mdt_audit_log` (
`id` int AUTO_INCREMENT NOT NULL,
`actor_citizenid` varchar(50),
`actor_name` varchar(255),
`action` varchar(64) NOT NULL,
`target_type` varchar(32),
`target_id` varchar(64),
`meta` json,
`created_at` timestamp NOT NULL DEFAULT (now()),
CONSTRAINT `mdt_audit_log_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `mdt_bolos` (
`id` int AUTO_INCREMENT NOT NULL,
`kind` enum('person','vehicle') NOT NULL,
`reference` varchar(64) NOT NULL,
`label` varchar(255) NOT NULL,
`reason` text,
`active` boolean NOT NULL DEFAULT true,
`created_by_citizenid` varchar(50),
`created_at` timestamp NOT NULL DEFAULT (now()),
CONSTRAINT `mdt_bolos_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `mdt_case_charges` (
`id` int AUTO_INCREMENT NOT NULL,
`case_id` int NOT NULL,
`catalog_id` int NOT NULL,
`count` int NOT NULL DEFAULT 1,
`fine` int NOT NULL DEFAULT 0,
`jail_time` int NOT NULL DEFAULT 0,
`points` int NOT NULL DEFAULT 0,
CONSTRAINT `mdt_case_charges_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `mdt_cases` (
`id` int AUTO_INCREMENT NOT NULL,
`title` varchar(255) NOT NULL,
`suspect_citizenid` varchar(50) NOT NULL,
`status` enum('open','closed','dismissed') NOT NULL DEFAULT 'open',
`narrative` text,
`total_fine` int NOT NULL DEFAULT 0,
`total_jail_time` int NOT NULL DEFAULT 0,
`officer_citizenid` varchar(50),
`created_at` timestamp NOT NULL DEFAULT (now()),
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `mdt_cases_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `mdt_charges_catalog` (
`id` int AUTO_INCREMENT NOT NULL,
`code` varchar(32) NOT NULL,
`title` varchar(255) NOT NULL,
`category` varchar(64) NOT NULL DEFAULT 'sonstiges',
`fine` int NOT NULL DEFAULT 0,
`jail_time` int NOT NULL DEFAULT 0,
`points` int NOT NULL DEFAULT 0,
`active` boolean NOT NULL DEFAULT true,
CONSTRAINT `mdt_charges_catalog_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `mdt_dispatch_calls` (
`id` int AUTO_INCREMENT NOT NULL,
`code` varchar(32) NOT NULL,
`title` varchar(255) NOT NULL,
`description` text,
`department` enum('police','ems','fire') NOT NULL DEFAULT 'police',
`priority` enum('low','medium','high') NOT NULL DEFAULT 'medium',
`status` enum('pending','assigned','enroute','onscene','closed') NOT NULL DEFAULT 'pending',
`location` varchar(255),
`coords_x` varchar(32),
`coords_y` varchar(32),
`coords_z` varchar(32),
`caller_citizenid` varchar(50),
`assigned_officers` json NOT NULL DEFAULT ('[]'),
`created_at` timestamp NOT NULL DEFAULT (now()),
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `mdt_dispatch_calls_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `mdt_person_records` (
`citizenid` varchar(50) NOT NULL,
`notes` text,
`flags` json NOT NULL DEFAULT ('[]'),
`is_wanted` boolean NOT NULL DEFAULT false,
`mugshot_url` varchar(512),
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
`updated_by` varchar(50),
CONSTRAINT `mdt_person_records_citizenid` PRIMARY KEY(`citizenid`)
);
--> statement-breakpoint
CREATE TABLE `mdt_users` (
`id` int AUTO_INCREMENT NOT NULL,
`citizenid` varchar(50),
`discord_id` varchar(50),
`name` varchar(255) NOT NULL,
`callsign` varchar(32),
`roles` json NOT NULL DEFAULT ('[]'),
`created_at` timestamp NOT NULL DEFAULT (now()),
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `mdt_users_id` PRIMARY KEY(`id`),
CONSTRAINT `mdt_users_citizenid_unique` UNIQUE(`citizenid`),
CONSTRAINT `mdt_users_discord_id_unique` UNIQUE(`discord_id`)
);
--> statement-breakpoint
CREATE TABLE `mdt_warrants` (
`id` int AUTO_INCREMENT NOT NULL,
`citizenid` varchar(50) NOT NULL,
`reason` text NOT NULL,
`status` enum('active','served','revoked','expired') NOT NULL DEFAULT 'active',
`case_id` int,
`issued_by_citizenid` varchar(50),
`issued_at` timestamp NOT NULL DEFAULT (now()),
`expires_at` timestamp,
CONSTRAINT `mdt_warrants_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE INDEX `audit_actor_idx` ON `mdt_audit_log` (`actor_citizenid`);--> statement-breakpoint
CREATE INDEX `audit_action_idx` ON `mdt_audit_log` (`action`);--> statement-breakpoint
CREATE INDEX `suspect_idx` ON `mdt_cases` (`suspect_citizenid`);--> statement-breakpoint
CREATE INDEX `warrant_citizen_idx` ON `mdt_warrants` (`citizenid`);
@@ -0,0 +1,12 @@
CREATE TABLE `mdt_ranks` (
`id` int AUTO_INCREMENT NOT NULL,
`department` enum('police','ems','fire') NOT NULL,
`grade` int NOT NULL,
`label` varchar(64) NOT NULL,
`permissions` json NOT NULL DEFAULT ('[]'),
CONSTRAINT `mdt_ranks_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
ALTER TABLE `mdt_users` ADD `department` enum('police','ems','fire');--> statement-breakpoint
ALTER TABLE `mdt_users` ADD `grade` int;--> statement-breakpoint
CREATE INDEX `rank_dept_grade_idx` ON `mdt_ranks` (`department`,`grade`);
+2
View File
@@ -0,0 +1,2 @@
DROP INDEX `rank_dept_grade_idx` ON `mdt_ranks`;--> statement-breakpoint
ALTER TABLE `mdt_ranks` ADD CONSTRAINT `rank_dept_grade_idx` UNIQUE(`department`,`grade`);
@@ -0,0 +1,11 @@
CREATE TABLE `mdt_announcements` (
`id` int AUTO_INCREMENT NOT NULL,
`title` varchar(255) NOT NULL,
`body` text,
`pinned` boolean NOT NULL DEFAULT false,
`important` boolean NOT NULL DEFAULT false,
`author_citizenid` varchar(50),
`author_name` varchar(255),
`created_at` timestamp NOT NULL DEFAULT (now()),
CONSTRAINT `mdt_announcements_id` PRIMARY KEY(`id`)
);
+22
View File
@@ -0,0 +1,22 @@
CREATE TABLE `mdt_doc_folders` (
`id` int AUTO_INCREMENT NOT NULL,
`name` varchar(128) NOT NULL,
`created_at` timestamp NOT NULL DEFAULT (now()),
CONSTRAINT `mdt_doc_folders_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `mdt_documents` (
`id` int AUTO_INCREMENT NOT NULL,
`reference` varchar(32) NOT NULL,
`title` varchar(255) NOT NULL,
`content` text,
`folder_id` int,
`pinned` boolean NOT NULL DEFAULT false,
`author_citizenid` varchar(50),
`author_name` varchar(255),
`created_at` timestamp NOT NULL DEFAULT (now()),
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `mdt_documents_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE INDEX `doc_folder_idx` ON `mdt_documents` (`folder_id`);
@@ -0,0 +1,2 @@
ALTER TABLE `mdt_doc_folders` ADD `departments` json DEFAULT ('[]') NOT NULL;--> statement-breakpoint
ALTER TABLE `mdt_documents` ADD `departments` json DEFAULT ('[]') NOT NULL;
@@ -0,0 +1,12 @@
CREATE TABLE `mdt_authorities` (
`id` int AUTO_INCREMENT NOT NULL,
`key` varchar(32) NOT NULL,
`name` varchar(128) NOT NULL,
`color` varchar(16) NOT NULL DEFAULT '#2f6df6',
`department` enum('police','ems','fire') NOT NULL,
`jobs` json NOT NULL DEFAULT ('[]'),
CONSTRAINT `mdt_authorities_id` PRIMARY KEY(`id`),
CONSTRAINT `mdt_authorities_key_unique` UNIQUE(`key`)
);
--> statement-breakpoint
ALTER TABLE `mdt_users` ADD `job` varchar(64);
+18
View File
@@ -0,0 +1,18 @@
CREATE TABLE `mdt_group_members` (
`id` int AUTO_INCREMENT NOT NULL,
`group_id` int NOT NULL,
`citizenid` varchar(50) NOT NULL,
`name` varchar(255) NOT NULL,
CONSTRAINT `mdt_group_members_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `mdt_groups` (
`id` int AUTO_INCREMENT NOT NULL,
`name` varchar(128) NOT NULL,
`color` varchar(16) NOT NULL DEFAULT '#8b5cf6',
`created_at` timestamp NOT NULL DEFAULT (now()),
CONSTRAINT `mdt_groups_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE INDEX `group_member_group_idx` ON `mdt_group_members` (`group_id`);--> statement-breakpoint
CREATE INDEX `group_member_citizen_idx` ON `mdt_group_members` (`citizenid`);
@@ -0,0 +1,49 @@
CREATE TABLE `mdt_citizens` (
`citizenid` varchar(50) NOT NULL,
`firstname` varchar(128) NOT NULL,
`lastname` varchar(128) NOT NULL,
`dob` varchar(32),
`gender` enum('male','female','divers','unknown') NOT NULL DEFAULT 'unknown',
`phone` varchar(32),
`nationality` varchar(64),
`legal_status` enum('citizen','resident','visitor','illegal','unknown') NOT NULL DEFAULT 'citizen',
`address` varchar(255),
`occupation` varchar(128),
`height` varchar(32),
`eye_color` varchar(32),
`hair_color` varchar(32),
`distinguishing_marks` text,
`aliases` json NOT NULL DEFAULT ('[]'),
`licenses` json NOT NULL DEFAULT ('[]'),
`mugshot_url` varchar(512),
`notes` text,
`flags` json NOT NULL DEFAULT ('[]'),
`is_wanted` boolean NOT NULL DEFAULT false,
`created_by_citizenid` varchar(50),
`created_by_name` varchar(255),
`updated_by` varchar(50),
`created_at` timestamp NOT NULL DEFAULT (now()),
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `mdt_citizens_citizenid` PRIMARY KEY(`citizenid`)
);
--> statement-breakpoint
CREATE TABLE `mdt_vehicles` (
`id` int AUTO_INCREMENT NOT NULL,
`plate` varchar(32) NOT NULL,
`model` varchar(64),
`color` varchar(64),
`owner_citizenid` varchar(50),
`plate_status` enum('registered','forged','stolen','unknown') NOT NULL DEFAULT 'registered',
`is_stolen` boolean NOT NULL DEFAULT false,
`has_bolo` boolean NOT NULL DEFAULT false,
`notes` text,
`created_by_citizenid` varchar(50),
`created_at` timestamp NOT NULL DEFAULT (now()),
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `mdt_vehicles_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE INDEX `citizen_name_idx` ON `mdt_citizens` (`lastname`,`firstname`);--> statement-breakpoint
CREATE INDEX `citizen_wanted_idx` ON `mdt_citizens` (`is_wanted`);--> statement-breakpoint
CREATE INDEX `vehicle_plate_idx` ON `mdt_vehicles` (`plate`);--> statement-breakpoint
CREATE INDEX `vehicle_owner_idx` ON `mdt_vehicles` (`owner_citizenid`);
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE `mdt_citizens` ADD `public` boolean DEFAULT true NOT NULL;--> statement-breakpoint
ALTER TABLE `mdt_citizens` ADD `acl` json DEFAULT ('[]') NOT NULL;
@@ -0,0 +1,16 @@
CREATE TABLE `mdt_treatment_records` (
`id` int AUTO_INCREMENT NOT NULL,
`reference` varchar(32) NOT NULL,
`patient_citizenid` varchar(50) NOT NULL,
`title` varchar(255) NOT NULL,
`diagnosis` text,
`treatment` text,
`status` enum('open','closed') NOT NULL DEFAULT 'open',
`author_citizenid` varchar(50),
`author_name` varchar(255),
`created_at` timestamp NOT NULL DEFAULT (now()),
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `mdt_treatment_records_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE INDEX `treatment_patient_idx` ON `mdt_treatment_records` (`patient_citizenid`);
+15
View File
@@ -0,0 +1,15 @@
CREATE TABLE `mdt_laws` (
`id` int AUTO_INCREMENT NOT NULL,
`category` varchar(64) NOT NULL DEFAULT 'Allgemein',
`paragraph` varchar(32) NOT NULL,
`title` varchar(255) NOT NULL,
`description` text,
`fine` int,
`jail_time` int,
`sort_order` int NOT NULL DEFAULT 0,
`created_at` timestamp NOT NULL DEFAULT (now()),
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `mdt_laws_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE INDEX `law_category_idx` ON `mdt_laws` (`category`);
+30
View File
@@ -0,0 +1,30 @@
CREATE TABLE `mdt_calendar_events` (
`id` int AUTO_INCREMENT NOT NULL,
`title` varchar(255) NOT NULL,
`description` text,
`category` enum('dienst','schulung','meeting','event','sonstiges') NOT NULL DEFAULT 'sonstiges',
`start_at` datetime NOT NULL,
`end_at` datetime,
`all_day` boolean NOT NULL DEFAULT false,
`location` varchar(255),
`open_signup` boolean NOT NULL DEFAULT true,
`organizer_citizenid` varchar(50),
`organizer_name` varchar(255),
`created_at` timestamp NOT NULL DEFAULT (now()),
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `mdt_calendar_events_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `mdt_calendar_attendees` (
`id` int AUTO_INCREMENT NOT NULL,
`event_id` int NOT NULL,
`citizenid` varchar(50) NOT NULL,
`name` varchar(255) NOT NULL,
`status` enum('invited','accepted','declined','maybe') NOT NULL DEFAULT 'invited',
`self` boolean NOT NULL DEFAULT false,
CONSTRAINT `mdt_calendar_attendees_id` PRIMARY KEY(`id`),
CONSTRAINT `attendee_event_citizen_idx` UNIQUE(`event_id`,`citizenid`)
);
--> statement-breakpoint
CREATE INDEX `event_start_idx` ON `mdt_calendar_events` (`start_at`);--> statement-breakpoint
CREATE INDEX `attendee_event_idx` ON `mdt_calendar_attendees` (`event_id`);
+803
View File
@@ -0,0 +1,803 @@
{
"version": "5",
"dialect": "mysql",
"id": "5406bac0-8a25-40d0-85fe-e0aaae21e670",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"mdt_audit_log": {
"name": "mdt_audit_log",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"actor_citizenid": {
"name": "actor_citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"actor_name": {
"name": "actor_name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"action": {
"name": "action",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"target_type": {
"name": "target_type",
"type": "varchar(32)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"target_id": {
"name": "target_id",
"type": "varchar(64)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"meta": {
"name": "meta",
"type": "json",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
}
},
"indexes": {
"audit_actor_idx": {
"name": "audit_actor_idx",
"columns": [
"actor_citizenid"
],
"isUnique": false
},
"audit_action_idx": {
"name": "audit_action_idx",
"columns": [
"action"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_audit_log_id": {
"name": "mdt_audit_log_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"mdt_bolos": {
"name": "mdt_bolos",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"kind": {
"name": "kind",
"type": "enum('person','vehicle')",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"reference": {
"name": "reference",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"label": {
"name": "label",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"reason": {
"name": "reason",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"active": {
"name": "active",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"created_by_citizenid": {
"name": "created_by_citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_bolos_id": {
"name": "mdt_bolos_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"mdt_case_charges": {
"name": "mdt_case_charges",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"case_id": {
"name": "case_id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"catalog_id": {
"name": "catalog_id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"count": {
"name": "count",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"fine": {
"name": "fine",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"jail_time": {
"name": "jail_time",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"points": {
"name": "points",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_case_charges_id": {
"name": "mdt_case_charges_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"mdt_cases": {
"name": "mdt_cases",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"title": {
"name": "title",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"suspect_citizenid": {
"name": "suspect_citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "enum('open','closed','dismissed')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'open'"
},
"narrative": {
"name": "narrative",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"total_fine": {
"name": "total_fine",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"total_jail_time": {
"name": "total_jail_time",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"officer_citizenid": {
"name": "officer_citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {
"suspect_idx": {
"name": "suspect_idx",
"columns": [
"suspect_citizenid"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_cases_id": {
"name": "mdt_cases_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"mdt_charges_catalog": {
"name": "mdt_charges_catalog",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"code": {
"name": "code",
"type": "varchar(32)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"category": {
"name": "category",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'sonstiges'"
},
"fine": {
"name": "fine",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"jail_time": {
"name": "jail_time",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"points": {
"name": "points",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"active": {
"name": "active",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_charges_catalog_id": {
"name": "mdt_charges_catalog_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"mdt_dispatch_calls": {
"name": "mdt_dispatch_calls",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"code": {
"name": "code",
"type": "varchar(32)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"department": {
"name": "department",
"type": "enum('police','ems','fire')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'police'"
},
"priority": {
"name": "priority",
"type": "enum('low','medium','high')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'medium'"
},
"status": {
"name": "status",
"type": "enum('pending','assigned','enroute','onscene','closed')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'pending'"
},
"location": {
"name": "location",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"coords_x": {
"name": "coords_x",
"type": "varchar(32)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"coords_y": {
"name": "coords_y",
"type": "varchar(32)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"coords_z": {
"name": "coords_z",
"type": "varchar(32)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"caller_citizenid": {
"name": "caller_citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"assigned_officers": {
"name": "assigned_officers",
"type": "json",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "('[]')"
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_dispatch_calls_id": {
"name": "mdt_dispatch_calls_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"mdt_person_records": {
"name": "mdt_person_records",
"columns": {
"citizenid": {
"name": "citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"flags": {
"name": "flags",
"type": "json",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "('[]')"
},
"is_wanted": {
"name": "is_wanted",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"mugshot_url": {
"name": "mugshot_url",
"type": "varchar(512)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
},
"updated_by": {
"name": "updated_by",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_person_records_citizenid": {
"name": "mdt_person_records_citizenid",
"columns": [
"citizenid"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"mdt_users": {
"name": "mdt_users",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"citizenid": {
"name": "citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"discord_id": {
"name": "discord_id",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"callsign": {
"name": "callsign",
"type": "varchar(32)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"roles": {
"name": "roles",
"type": "json",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "('[]')"
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_users_id": {
"name": "mdt_users_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {
"mdt_users_citizenid_unique": {
"name": "mdt_users_citizenid_unique",
"columns": [
"citizenid"
]
},
"mdt_users_discord_id_unique": {
"name": "mdt_users_discord_id_unique",
"columns": [
"discord_id"
]
}
},
"checkConstraint": {}
},
"mdt_warrants": {
"name": "mdt_warrants",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"citizenid": {
"name": "citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"reason": {
"name": "reason",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "enum('active','served','revoked','expired')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'active'"
},
"case_id": {
"name": "case_id",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"issued_by_citizenid": {
"name": "issued_by_citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"issued_at": {
"name": "issued_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"warrant_citizen_idx": {
"name": "warrant_citizen_idx",
"columns": [
"citizenid"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_warrants_id": {
"name": "mdt_warrants_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
}
},
"views": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"tables": {},
"indexes": {}
}
}
+879
View File
@@ -0,0 +1,879 @@
{
"version": "5",
"dialect": "mysql",
"id": "75763478-717d-4d51-9a00-3b0239524f59",
"prevId": "5406bac0-8a25-40d0-85fe-e0aaae21e670",
"tables": {
"mdt_audit_log": {
"name": "mdt_audit_log",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"actor_citizenid": {
"name": "actor_citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"actor_name": {
"name": "actor_name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"action": {
"name": "action",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"target_type": {
"name": "target_type",
"type": "varchar(32)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"target_id": {
"name": "target_id",
"type": "varchar(64)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"meta": {
"name": "meta",
"type": "json",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
}
},
"indexes": {
"audit_actor_idx": {
"name": "audit_actor_idx",
"columns": [
"actor_citizenid"
],
"isUnique": false
},
"audit_action_idx": {
"name": "audit_action_idx",
"columns": [
"action"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_audit_log_id": {
"name": "mdt_audit_log_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"mdt_bolos": {
"name": "mdt_bolos",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"kind": {
"name": "kind",
"type": "enum('person','vehicle')",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"reference": {
"name": "reference",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"label": {
"name": "label",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"reason": {
"name": "reason",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"active": {
"name": "active",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"created_by_citizenid": {
"name": "created_by_citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_bolos_id": {
"name": "mdt_bolos_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"mdt_case_charges": {
"name": "mdt_case_charges",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"case_id": {
"name": "case_id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"catalog_id": {
"name": "catalog_id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"count": {
"name": "count",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"fine": {
"name": "fine",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"jail_time": {
"name": "jail_time",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"points": {
"name": "points",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_case_charges_id": {
"name": "mdt_case_charges_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"mdt_cases": {
"name": "mdt_cases",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"title": {
"name": "title",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"suspect_citizenid": {
"name": "suspect_citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "enum('open','closed','dismissed')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'open'"
},
"narrative": {
"name": "narrative",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"total_fine": {
"name": "total_fine",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"total_jail_time": {
"name": "total_jail_time",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"officer_citizenid": {
"name": "officer_citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {
"suspect_idx": {
"name": "suspect_idx",
"columns": [
"suspect_citizenid"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_cases_id": {
"name": "mdt_cases_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"mdt_charges_catalog": {
"name": "mdt_charges_catalog",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"code": {
"name": "code",
"type": "varchar(32)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"category": {
"name": "category",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'sonstiges'"
},
"fine": {
"name": "fine",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"jail_time": {
"name": "jail_time",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"points": {
"name": "points",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"active": {
"name": "active",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_charges_catalog_id": {
"name": "mdt_charges_catalog_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"mdt_dispatch_calls": {
"name": "mdt_dispatch_calls",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"code": {
"name": "code",
"type": "varchar(32)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"department": {
"name": "department",
"type": "enum('police','ems','fire')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'police'"
},
"priority": {
"name": "priority",
"type": "enum('low','medium','high')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'medium'"
},
"status": {
"name": "status",
"type": "enum('pending','assigned','enroute','onscene','closed')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'pending'"
},
"location": {
"name": "location",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"coords_x": {
"name": "coords_x",
"type": "varchar(32)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"coords_y": {
"name": "coords_y",
"type": "varchar(32)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"coords_z": {
"name": "coords_z",
"type": "varchar(32)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"caller_citizenid": {
"name": "caller_citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"assigned_officers": {
"name": "assigned_officers",
"type": "json",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "('[]')"
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_dispatch_calls_id": {
"name": "mdt_dispatch_calls_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"mdt_person_records": {
"name": "mdt_person_records",
"columns": {
"citizenid": {
"name": "citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"flags": {
"name": "flags",
"type": "json",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "('[]')"
},
"is_wanted": {
"name": "is_wanted",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"mugshot_url": {
"name": "mugshot_url",
"type": "varchar(512)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
},
"updated_by": {
"name": "updated_by",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_person_records_citizenid": {
"name": "mdt_person_records_citizenid",
"columns": [
"citizenid"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"mdt_ranks": {
"name": "mdt_ranks",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"department": {
"name": "department",
"type": "enum('police','ems','fire')",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"grade": {
"name": "grade",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"label": {
"name": "label",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"permissions": {
"name": "permissions",
"type": "json",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "('[]')"
}
},
"indexes": {
"rank_dept_grade_idx": {
"name": "rank_dept_grade_idx",
"columns": [
"department",
"grade"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_ranks_id": {
"name": "mdt_ranks_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"mdt_users": {
"name": "mdt_users",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"citizenid": {
"name": "citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"discord_id": {
"name": "discord_id",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"callsign": {
"name": "callsign",
"type": "varchar(32)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"department": {
"name": "department",
"type": "enum('police','ems','fire')",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"grade": {
"name": "grade",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"roles": {
"name": "roles",
"type": "json",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "('[]')"
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_users_id": {
"name": "mdt_users_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {
"mdt_users_citizenid_unique": {
"name": "mdt_users_citizenid_unique",
"columns": [
"citizenid"
]
},
"mdt_users_discord_id_unique": {
"name": "mdt_users_discord_id_unique",
"columns": [
"discord_id"
]
}
},
"checkConstraint": {}
},
"mdt_warrants": {
"name": "mdt_warrants",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"citizenid": {
"name": "citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"reason": {
"name": "reason",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "enum('active','served','revoked','expired')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'active'"
},
"case_id": {
"name": "case_id",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"issued_by_citizenid": {
"name": "issued_by_citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"issued_at": {
"name": "issued_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"warrant_citizen_idx": {
"name": "warrant_citizen_idx",
"columns": [
"citizenid"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_warrants_id": {
"name": "mdt_warrants_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
}
},
"views": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"tables": {},
"indexes": {}
}
}
+879
View File
@@ -0,0 +1,879 @@
{
"version": "5",
"dialect": "mysql",
"id": "31cbfdaa-9652-4c1c-b4eb-2e11fd1d60b4",
"prevId": "75763478-717d-4d51-9a00-3b0239524f59",
"tables": {
"mdt_audit_log": {
"name": "mdt_audit_log",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"actor_citizenid": {
"name": "actor_citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"actor_name": {
"name": "actor_name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"action": {
"name": "action",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"target_type": {
"name": "target_type",
"type": "varchar(32)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"target_id": {
"name": "target_id",
"type": "varchar(64)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"meta": {
"name": "meta",
"type": "json",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
}
},
"indexes": {
"audit_actor_idx": {
"name": "audit_actor_idx",
"columns": [
"actor_citizenid"
],
"isUnique": false
},
"audit_action_idx": {
"name": "audit_action_idx",
"columns": [
"action"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_audit_log_id": {
"name": "mdt_audit_log_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"mdt_bolos": {
"name": "mdt_bolos",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"kind": {
"name": "kind",
"type": "enum('person','vehicle')",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"reference": {
"name": "reference",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"label": {
"name": "label",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"reason": {
"name": "reason",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"active": {
"name": "active",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"created_by_citizenid": {
"name": "created_by_citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_bolos_id": {
"name": "mdt_bolos_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"mdt_case_charges": {
"name": "mdt_case_charges",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"case_id": {
"name": "case_id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"catalog_id": {
"name": "catalog_id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"count": {
"name": "count",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"fine": {
"name": "fine",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"jail_time": {
"name": "jail_time",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"points": {
"name": "points",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_case_charges_id": {
"name": "mdt_case_charges_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"mdt_cases": {
"name": "mdt_cases",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"title": {
"name": "title",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"suspect_citizenid": {
"name": "suspect_citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "enum('open','closed','dismissed')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'open'"
},
"narrative": {
"name": "narrative",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"total_fine": {
"name": "total_fine",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"total_jail_time": {
"name": "total_jail_time",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"officer_citizenid": {
"name": "officer_citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {
"suspect_idx": {
"name": "suspect_idx",
"columns": [
"suspect_citizenid"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_cases_id": {
"name": "mdt_cases_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"mdt_charges_catalog": {
"name": "mdt_charges_catalog",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"code": {
"name": "code",
"type": "varchar(32)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"category": {
"name": "category",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'sonstiges'"
},
"fine": {
"name": "fine",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"jail_time": {
"name": "jail_time",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"points": {
"name": "points",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"active": {
"name": "active",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_charges_catalog_id": {
"name": "mdt_charges_catalog_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"mdt_dispatch_calls": {
"name": "mdt_dispatch_calls",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"code": {
"name": "code",
"type": "varchar(32)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"department": {
"name": "department",
"type": "enum('police','ems','fire')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'police'"
},
"priority": {
"name": "priority",
"type": "enum('low','medium','high')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'medium'"
},
"status": {
"name": "status",
"type": "enum('pending','assigned','enroute','onscene','closed')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'pending'"
},
"location": {
"name": "location",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"coords_x": {
"name": "coords_x",
"type": "varchar(32)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"coords_y": {
"name": "coords_y",
"type": "varchar(32)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"coords_z": {
"name": "coords_z",
"type": "varchar(32)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"caller_citizenid": {
"name": "caller_citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"assigned_officers": {
"name": "assigned_officers",
"type": "json",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "('[]')"
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_dispatch_calls_id": {
"name": "mdt_dispatch_calls_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"mdt_person_records": {
"name": "mdt_person_records",
"columns": {
"citizenid": {
"name": "citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"flags": {
"name": "flags",
"type": "json",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "('[]')"
},
"is_wanted": {
"name": "is_wanted",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"mugshot_url": {
"name": "mugshot_url",
"type": "varchar(512)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
},
"updated_by": {
"name": "updated_by",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_person_records_citizenid": {
"name": "mdt_person_records_citizenid",
"columns": [
"citizenid"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"mdt_ranks": {
"name": "mdt_ranks",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"department": {
"name": "department",
"type": "enum('police','ems','fire')",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"grade": {
"name": "grade",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"label": {
"name": "label",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"permissions": {
"name": "permissions",
"type": "json",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "('[]')"
}
},
"indexes": {
"rank_dept_grade_idx": {
"name": "rank_dept_grade_idx",
"columns": [
"department",
"grade"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_ranks_id": {
"name": "mdt_ranks_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"mdt_users": {
"name": "mdt_users",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"citizenid": {
"name": "citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"discord_id": {
"name": "discord_id",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"callsign": {
"name": "callsign",
"type": "varchar(32)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"department": {
"name": "department",
"type": "enum('police','ems','fire')",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"grade": {
"name": "grade",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"roles": {
"name": "roles",
"type": "json",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "('[]')"
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_users_id": {
"name": "mdt_users_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {
"mdt_users_citizenid_unique": {
"name": "mdt_users_citizenid_unique",
"columns": [
"citizenid"
]
},
"mdt_users_discord_id_unique": {
"name": "mdt_users_discord_id_unique",
"columns": [
"discord_id"
]
}
},
"checkConstraint": {}
},
"mdt_warrants": {
"name": "mdt_warrants",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"citizenid": {
"name": "citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"reason": {
"name": "reason",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "enum('active','served','revoked','expired')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'active'"
},
"case_id": {
"name": "case_id",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"issued_by_citizenid": {
"name": "issued_by_citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"issued_at": {
"name": "issued_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"warrant_citizen_idx": {
"name": "warrant_citizen_idx",
"columns": [
"citizenid"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_warrants_id": {
"name": "mdt_warrants_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
}
},
"views": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"tables": {},
"indexes": {}
}
}
+955
View File
@@ -0,0 +1,955 @@
{
"version": "5",
"dialect": "mysql",
"id": "21dfb804-392a-435c-804b-f5bc613ea2b2",
"prevId": "31cbfdaa-9652-4c1c-b4eb-2e11fd1d60b4",
"tables": {
"mdt_announcements": {
"name": "mdt_announcements",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"title": {
"name": "title",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"body": {
"name": "body",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"pinned": {
"name": "pinned",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"important": {
"name": "important",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"author_citizenid": {
"name": "author_citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"author_name": {
"name": "author_name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_announcements_id": {
"name": "mdt_announcements_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"mdt_audit_log": {
"name": "mdt_audit_log",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"actor_citizenid": {
"name": "actor_citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"actor_name": {
"name": "actor_name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"action": {
"name": "action",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"target_type": {
"name": "target_type",
"type": "varchar(32)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"target_id": {
"name": "target_id",
"type": "varchar(64)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"meta": {
"name": "meta",
"type": "json",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
}
},
"indexes": {
"audit_actor_idx": {
"name": "audit_actor_idx",
"columns": [
"actor_citizenid"
],
"isUnique": false
},
"audit_action_idx": {
"name": "audit_action_idx",
"columns": [
"action"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_audit_log_id": {
"name": "mdt_audit_log_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"mdt_bolos": {
"name": "mdt_bolos",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"kind": {
"name": "kind",
"type": "enum('person','vehicle')",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"reference": {
"name": "reference",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"label": {
"name": "label",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"reason": {
"name": "reason",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"active": {
"name": "active",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"created_by_citizenid": {
"name": "created_by_citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_bolos_id": {
"name": "mdt_bolos_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"mdt_case_charges": {
"name": "mdt_case_charges",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"case_id": {
"name": "case_id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"catalog_id": {
"name": "catalog_id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"count": {
"name": "count",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"fine": {
"name": "fine",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"jail_time": {
"name": "jail_time",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"points": {
"name": "points",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_case_charges_id": {
"name": "mdt_case_charges_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"mdt_cases": {
"name": "mdt_cases",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"title": {
"name": "title",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"suspect_citizenid": {
"name": "suspect_citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "enum('open','closed','dismissed')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'open'"
},
"narrative": {
"name": "narrative",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"total_fine": {
"name": "total_fine",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"total_jail_time": {
"name": "total_jail_time",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"officer_citizenid": {
"name": "officer_citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {
"suspect_idx": {
"name": "suspect_idx",
"columns": [
"suspect_citizenid"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_cases_id": {
"name": "mdt_cases_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"mdt_charges_catalog": {
"name": "mdt_charges_catalog",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"code": {
"name": "code",
"type": "varchar(32)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"category": {
"name": "category",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'sonstiges'"
},
"fine": {
"name": "fine",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"jail_time": {
"name": "jail_time",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"points": {
"name": "points",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"active": {
"name": "active",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_charges_catalog_id": {
"name": "mdt_charges_catalog_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"mdt_dispatch_calls": {
"name": "mdt_dispatch_calls",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"code": {
"name": "code",
"type": "varchar(32)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"department": {
"name": "department",
"type": "enum('police','ems','fire')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'police'"
},
"priority": {
"name": "priority",
"type": "enum('low','medium','high')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'medium'"
},
"status": {
"name": "status",
"type": "enum('pending','assigned','enroute','onscene','closed')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'pending'"
},
"location": {
"name": "location",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"coords_x": {
"name": "coords_x",
"type": "varchar(32)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"coords_y": {
"name": "coords_y",
"type": "varchar(32)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"coords_z": {
"name": "coords_z",
"type": "varchar(32)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"caller_citizenid": {
"name": "caller_citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"assigned_officers": {
"name": "assigned_officers",
"type": "json",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "('[]')"
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_dispatch_calls_id": {
"name": "mdt_dispatch_calls_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"mdt_person_records": {
"name": "mdt_person_records",
"columns": {
"citizenid": {
"name": "citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"flags": {
"name": "flags",
"type": "json",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "('[]')"
},
"is_wanted": {
"name": "is_wanted",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"mugshot_url": {
"name": "mugshot_url",
"type": "varchar(512)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
},
"updated_by": {
"name": "updated_by",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_person_records_citizenid": {
"name": "mdt_person_records_citizenid",
"columns": [
"citizenid"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"mdt_ranks": {
"name": "mdt_ranks",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"department": {
"name": "department",
"type": "enum('police','ems','fire')",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"grade": {
"name": "grade",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"label": {
"name": "label",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"permissions": {
"name": "permissions",
"type": "json",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "('[]')"
}
},
"indexes": {
"rank_dept_grade_idx": {
"name": "rank_dept_grade_idx",
"columns": [
"department",
"grade"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_ranks_id": {
"name": "mdt_ranks_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"mdt_users": {
"name": "mdt_users",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"citizenid": {
"name": "citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"discord_id": {
"name": "discord_id",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"callsign": {
"name": "callsign",
"type": "varchar(32)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"department": {
"name": "department",
"type": "enum('police','ems','fire')",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"grade": {
"name": "grade",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"roles": {
"name": "roles",
"type": "json",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "('[]')"
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_users_id": {
"name": "mdt_users_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {
"mdt_users_citizenid_unique": {
"name": "mdt_users_citizenid_unique",
"columns": [
"citizenid"
]
},
"mdt_users_discord_id_unique": {
"name": "mdt_users_discord_id_unique",
"columns": [
"discord_id"
]
}
},
"checkConstraint": {}
},
"mdt_warrants": {
"name": "mdt_warrants",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"citizenid": {
"name": "citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"reason": {
"name": "reason",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "enum('active','served','revoked','expired')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'active'"
},
"case_id": {
"name": "case_id",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"issued_by_citizenid": {
"name": "issued_by_citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"issued_at": {
"name": "issued_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"warrant_citizen_idx": {
"name": "warrant_citizen_idx",
"columns": [
"citizenid"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_warrants_id": {
"name": "mdt_warrants_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
}
},
"views": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"tables": {},
"indexes": {}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+97
View File
@@ -0,0 +1,97 @@
{
"version": "7",
"dialect": "mysql",
"entries": [
{
"idx": 0,
"version": "5",
"when": 1783309072685,
"tag": "0000_classy_speedball",
"breakpoints": true
},
{
"idx": 1,
"version": "5",
"when": 1783326830033,
"tag": "0001_whole_rafael_vega",
"breakpoints": true
},
{
"idx": 2,
"version": "5",
"when": 1783326982981,
"tag": "0002_curly_chat",
"breakpoints": true
},
{
"idx": 3,
"version": "5",
"when": 1783350650336,
"tag": "0003_breezy_rick_jones",
"breakpoints": true
},
{
"idx": 4,
"version": "5",
"when": 1783368008674,
"tag": "0004_past_shiva",
"breakpoints": true
},
{
"idx": 5,
"version": "5",
"when": 1783369524417,
"tag": "0005_crazy_quentin_quire",
"breakpoints": true
},
{
"idx": 6,
"version": "5",
"when": 1783370360413,
"tag": "0006_watery_sphinx",
"breakpoints": true
},
{
"idx": 7,
"version": "5",
"when": 1783385686508,
"tag": "0007_first_kabuki",
"breakpoints": true
},
{
"idx": 8,
"version": "5",
"when": 1783472000000,
"tag": "0008_standalone_citizens",
"breakpoints": true
},
{
"idx": 9,
"version": "5",
"when": 1783472500000,
"tag": "0009_citizen_acl",
"breakpoints": true
},
{
"idx": 10,
"version": "5",
"when": 1783473000000,
"tag": "0010_treatment_records",
"breakpoints": true
},
{
"idx": 11,
"version": "5",
"when": 1783473500000,
"tag": "0011_laws",
"breakpoints": true
},
{
"idx": 12,
"version": "5",
"when": 1783474000000,
"tag": "0012_calendar",
"breakpoints": true
}
]
}
+38
View File
@@ -0,0 +1,38 @@
{
"name": "@d4rk-tablet/api",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./src/server.ts",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc",
"start": "tsx src/server.ts",
"typecheck": "tsc",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:introspect": "drizzle-kit introspect",
"db:setup:dev": "tsx src/db/setup-dev.ts",
"test": "vitest run"
},
"dependencies": {
"@d4rk-tablet/shared": "workspace:*",
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "^10.0.1",
"@fastify/jwt": "^9.0.1",
"@fastify/static": "^8.0.3",
"dotenv": "^16.4.7",
"drizzle-orm": "^0.38.2",
"fastify": "^5.2.0",
"mysql2": "^3.11.5",
"socket.io": "^4.8.1",
"zod": "^3.24.1"
},
"devDependencies": {
"@types/node": "^20.17.10",
"drizzle-kit": "^0.30.1",
"tsx": "^4.19.2",
"typescript": "^5.6.3",
"vitest": "^2.1.8"
}
}
+80
View File
@@ -0,0 +1,80 @@
import Fastify, { type FastifyInstance } from 'fastify';
import cors from '@fastify/cors';
import fastifyStatic from '@fastify/static';
import { resolve } from 'node:path';
import { existsSync } from 'node:fs';
import { env, isDev } from './config/env';
import { registerAuth } from './plugins/auth';
import { healthRoutes } from './modules/health/health.route';
import { authRoutes } from './modules/auth/auth.route';
import { personsRoutes } from './modules/persons/persons.route';
import { vehiclesRoutes } from './modules/vehicles/vehicles.route';
import { casesRoutes } from './modules/cases/cases.route';
import { treatmentRoutes } from './modules/treatment/treatment.route';
import { lawsRoutes } from './modules/laws/laws.route';
import { calendarRoutes } from './modules/calendar/calendar.route';
import { dispatchRoutes } from './modules/dispatch/dispatch.route';
import { bridgeRoutes } from './modules/bridge/bridge.route';
import { adminRoutes } from './modules/admin/admin.route';
import { boardRoutes } from './modules/board/board.route';
import { documentsRoutes } from './modules/documents/documents.route';
import { authoritiesRoutes } from './modules/authorities/authorities.route';
import { ensureAuthoritiesSeeded } from './modules/authorities/authorities.service';
import { groupsRoutes } from './modules/groups/groups.route';
import { initSocket } from './realtime/socket';
export async function buildApp(): Promise<FastifyInstance> {
const app = Fastify({
logger: isDev
? { transport: undefined, level: 'debug' }
: { level: 'info' },
});
await app.register(cors, {
origin: env.CORS_ORIGINS,
credentials: true,
});
// GTA-Map-Tiles statisch servieren (öffentlich, kein Auth) — /tiles/{style}/{z}/{x}/{y}.jpg
const tilesRoot = resolve(process.cwd(), env.TILES_DIR);
if (existsSync(tilesRoot)) {
await app.register(fastifyStatic, {
root: tilesRoot,
prefix: '/tiles/',
decorateReply: false,
cacheControl: true,
maxAge: 7 * 24 * 60 * 60 * 1000,
});
app.log.info(`Map-Tiles: ${tilesRoot} → /tiles/`);
} else {
app.log.warn(`TILES_DIR ${tilesRoot} nicht gefunden — /tiles/ inaktiv`);
}
// JWT + Cookie + RBAC-Guards (app-weite Decorators)
await registerAuth(app);
// ── Module ──
await app.register(healthRoutes);
await app.register(authRoutes);
await app.register(personsRoutes);
await app.register(vehiclesRoutes);
await app.register(casesRoutes);
await app.register(treatmentRoutes);
await app.register(lawsRoutes);
await app.register(calendarRoutes);
await app.register(dispatchRoutes);
await app.register(bridgeRoutes);
await app.register(adminRoutes);
await app.register(boardRoutes);
await app.register(documentsRoutes);
await app.register(authoritiesRoutes);
await app.register(groupsRoutes);
// Behörden-Registry beim Start seeden (falls leer)
await ensureAuthoritiesSeeded().catch((err) => app.log.warn({ err }, 'Behörden-Seed übersprungen'));
// Realtime (Socket.io) an den HTTP-Server hängen
initSocket(app);
return app;
}
@@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest';
import { defaultPermissionsFor } from '../rankDefaults';
describe('defaultPermissionsFor', () => {
it('gibt die Rechte des exakten Grades', () => {
expect(defaultPermissionsFor('police', 0)).toEqual([
'mdt.board.view',
'mdt.persons.view',
'mdt.vehicles.view',
'mdt.cases.view',
'mdt.laws.view',
'mdt.calendar.view',
'mdt.documents.view',
'cad.dispatch.view',
]);
});
it('nimmt den höchsten definierten Rang ≤ grade (Grade über Maximum)', () => {
// police max default = grade 4; grade 9 → grade-4-Rechte
const g9 = defaultPermissionsFor('police', 9);
expect(g9).toContain('mdt.warrants.create');
expect(g9).toContain('mdt.charges.manage');
});
it('höhere Grade haben mehr Rechte als niedrigere', () => {
expect(defaultPermissionsFor('police', 3).length).toBeGreaterThan(
defaultPermissionsFor('police', 0).length,
);
});
it('unbekannt niedriger Grade fällt auf niedrigsten Rang zurück bzw. leer', () => {
// grade -1 gibt es nicht → kein Rang ≤ -1 → leer
expect(defaultPermissionsFor('police', -1)).toEqual([]);
});
});
+43
View File
@@ -0,0 +1,43 @@
import { config as loadDotenv } from 'dotenv';
import { z } from 'zod';
import { resolve } from 'node:path';
// .env liegt im Monorepo-Root
loadDotenv({ path: resolve(process.cwd(), '../../.env') });
loadDotenv(); // Fallback: lokale .env
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
API_PORT: z.coerce.number().int().default(3000),
API_HOST: z.string().default('0.0.0.0'),
CORS_ORIGINS: z
.string()
.default('http://localhost:5173')
.transform((s) => s.split(',').map((o) => o.trim()).filter(Boolean)),
DB_HOST: z.string().default('127.0.0.1'),
DB_PORT: z.coerce.number().int().default(3306),
DB_USER: z.string().default('root'),
DB_PASSWORD: z.string().default(''),
DB_NAME: z.string().default('qbox'),
DB_QBOX_PLAYERS_TABLE: z.string().default('players'),
DB_QBOX_VEHICLES_TABLE: z.string().default('player_vehicles'),
// Verzeichnis mit den GTA-Map-Tiles ({style}/{z}/{x}/{y}.jpg), served unter /tiles/
TILES_DIR: z.string().default('./tiles'),
JWT_SECRET: z.string().default('dev-insecure-jwt-secret'),
JWT_EXPIRES_IN: z.string().default('15m'),
JWT_REFRESH_EXPIRES_IN: z.string().default('7d'),
BRIDGE_HMAC_SECRET: z.string().default('dev-insecure-bridge-secret'),
DISCORD_CLIENT_ID: z.string().default(''),
DISCORD_CLIENT_SECRET: z.string().default(''),
DISCORD_REDIRECT_URI: z.string().default('http://localhost:3000/auth/discord/callback'),
});
export const env = envSchema.parse(process.env);
export type Env = typeof env;
export const isDev = env.NODE_ENV === 'development';
+18
View File
@@ -0,0 +1,18 @@
import type { Role } from '@d4rk-tablet/shared';
/** QBox job.name → Basis-Rolle im MDT. */
const JOB_ROLE_MAP: Record<string, Role> = {
police: 'police',
bcso: 'police',
sheriff: 'police',
ambulance: 'ems',
ems: 'ems',
fire: 'fire',
};
export function baseRoleForJob(job: string): Role | null {
return JOB_ROLE_MAP[job.toLowerCase()] ?? null;
}
/** Rollen, die NICHT aus dem Job kommen, sondern manuell in mdt_users vergeben werden. */
export const ELEVATED_ROLES: Role[] = ['admin', 'dispatch'];
+55
View File
@@ -0,0 +1,55 @@
import { ROLE_TEMPLATES, type Department, type Permission } from '@d4rk-tablet/shared';
export interface RankDefault {
grade: number;
label: string;
permissions: Permission[];
}
const OFFICER = ROLE_TEMPLATES.Officer!;
const SUPERVISOR = ROLE_TEMPLATES.Supervisor!;
const DISPATCH = ROLE_TEMPLATES.Dispatcher!;
// EMS-Sanitäter: Dispatch-Basis + Behandlungsakten + Gesetze
const EMS_MED: Permission[] = [
...DISPATCH,
'mdt.laws.view',
'mdt.calendar.view',
'mdt.treatment.view',
'mdt.treatment.create',
'mdt.treatment.edit',
];
/**
* Default-Rang-Matrix je Behörde (Code-Fallback, greift wenn mdt_ranks leer ist
* bzw. für Grades ohne DB-Eintrag). Über die Admin-UI überschreibbar.
*/
export const DEFAULT_RANKS: Record<Department, RankDefault[]> = {
police: [
{ grade: 0, label: 'Anwärter', permissions: ['mdt.board.view', 'mdt.persons.view', 'mdt.vehicles.view', 'mdt.cases.view', 'mdt.laws.view', 'mdt.calendar.view', 'mdt.documents.view', 'cad.dispatch.view'] },
{ grade: 1, label: 'Officer', permissions: OFFICER },
{ grade: 2, label: 'Senior Officer', permissions: [...OFFICER, 'cad.dispatch.manage', 'mdt.warrants.create'] },
{ grade: 3, label: 'Sergeant', permissions: SUPERVISOR },
{ grade: 4, label: 'Lieutenant', permissions: [...SUPERVISOR, 'mdt.charges.manage', 'admin.audit.view'] },
],
ems: [
{ grade: 0, label: 'Rettungshelfer', permissions: ['mdt.board.view', 'mdt.persons.view', 'mdt.vehicles.view', 'mdt.laws.view', 'mdt.calendar.view', 'mdt.treatment.view', 'mdt.documents.view', 'cad.dispatch.view'] },
{ grade: 1, label: 'Sanitäter', permissions: EMS_MED },
{ grade: 2, label: 'Notfallsanitäter', permissions: EMS_MED },
{ grade: 3, label: 'Ärztl. Leitung', permissions: [...EMS_MED, 'admin.audit.view'] },
],
fire: [
{ grade: 0, label: 'Anwärter', permissions: ['mdt.board.view', 'mdt.persons.view', 'mdt.vehicles.view', 'mdt.laws.view', 'mdt.calendar.view', 'mdt.documents.view', 'cad.dispatch.view'] },
{ grade: 1, label: 'Feuerwehrmann', permissions: [...DISPATCH, 'mdt.laws.view', 'mdt.calendar.view'] },
{ grade: 2, label: 'Zugführer', permissions: [...DISPATCH, 'mdt.laws.view', 'mdt.calendar.view', 'admin.audit.view'] },
],
};
/** Default-Permissions für (Department, Grade) — nimmt den höchsten definierten Rang ≤ grade. */
export function defaultPermissionsFor(department: Department, grade: number): Permission[] {
const ranks = DEFAULT_RANKS[department];
let best: RankDefault | null = null;
for (const r of ranks) {
if (r.grade <= grade && (!best || r.grade > best.grade)) best = r;
}
return best?.permissions ?? [];
}
+21
View File
@@ -0,0 +1,21 @@
import { drizzle } from 'drizzle-orm/mysql2';
import mysql from 'mysql2/promise';
import { env } from '../config/env';
import * as schema from './schema';
/**
* Verbindungspool zur bestehenden QBox-MariaDB.
* Wird sowohl für QBox-Reads als auch für mdt_*-Writes genutzt.
*/
export const pool = mysql.createPool({
host: env.DB_HOST,
port: env.DB_PORT,
user: env.DB_USER,
password: env.DB_PASSWORD,
database: env.DB_NAME,
connectionLimit: 10,
namedPlaceholders: true,
});
export const db = drizzle(pool, { schema, mode: 'default' });
export type Database = typeof db;
+2
View File
@@ -0,0 +1,2 @@
export * from './qbox';
export * from './mdt';
+433
View File
@@ -0,0 +1,433 @@
/**
* MDT-eigene Tabellen (mdt_*). Von Drizzle-Kit verwaltet/migriert.
* Liegen in derselben QBox-Datenbank.
*/
import {
mysqlTable,
varchar,
int,
boolean,
text,
timestamp,
datetime,
mysqlEnum,
index,
uniqueIndex,
customType,
} from 'drizzle-orm/mysql-core';
/**
* MariaDB's JSON-Typ ist intern LONGTEXT → der mysql2-Treiber parst ihn NICHT
* automatisch (anders als echtes MySQL). Dieser Custom-Type parst beim Lesen
* und serialisiert beim Schreiben. dataType bleibt 'json' → identisches DDL.
*/
function jsonType<T>() {
return customType<{ data: T; driverData: string }>({
dataType() {
return 'json';
},
toDriver(value: T): string {
return JSON.stringify(value);
},
fromDriver(value: string | T): T {
if (typeof value === 'string') {
try {
return JSON.parse(value) as T;
} catch {
return value as unknown as T;
}
}
return value;
},
});
}
const jsonStringArray = jsonType<string[]>();
const jsonRecord = jsonType<Record<string, unknown>>();
const jsonAcl = jsonType<import('@d4rk-tablet/shared').AclEntry[]>();
const jsonLicenses = jsonType<import('@d4rk-tablet/shared').License[]>();
// ── Users & RBAC ──
export const mdtUsers = mysqlTable('mdt_users', {
id: int('id').autoincrement().primaryKey(),
citizenid: varchar('citizenid', { length: 50 }).unique(),
discordId: varchar('discord_id', { length: 50 }).unique(),
name: varchar('name', { length: 255 }).notNull(),
callsign: varchar('callsign', { length: 32 }),
// Letzter bekannter Behörden-Rang (aus QBox-Job), für Discord-Sessions + Anzeige
department: mysqlEnum('department', ['police', 'ems', 'fire']),
grade: int('grade'),
job: varchar('job', { length: 64 }), // roher QBox-Job (für Behörden-Zuordnung)
// MDT-Override-Rollen (admin/dispatch) — zusätzlich zum Rang
roles: jsonStringArray('roles').notNull().default([]),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow().notNull(),
});
// ── Gruppen / Teams ──
export const mdtGroups = mysqlTable('mdt_groups', {
id: int('id').autoincrement().primaryKey(),
name: varchar('name', { length: 128 }).notNull(),
color: varchar('color', { length: 16 }).notNull().default('#8b5cf6'),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
export const mdtGroupMembers = mysqlTable(
'mdt_group_members',
{
id: int('id').autoincrement().primaryKey(),
groupId: int('group_id').notNull(),
citizenid: varchar('citizenid', { length: 50 }).notNull(),
name: varchar('name', { length: 255 }).notNull(),
},
(t) => ({
groupIdx: index('group_member_group_idx').on(t.groupId),
citizenIdx: index('group_member_citizen_idx').on(t.citizenid),
}),
);
// ── Behörden-Registry (LSPD, BCSO, DOJ, SAMS, LSFD …) ──
export const mdtAuthorities = mysqlTable('mdt_authorities', {
id: int('id').autoincrement().primaryKey(),
key: varchar('key', { length: 32 }).notNull().unique(),
name: varchar('name', { length: 128 }).notNull(),
color: varchar('color', { length: 16 }).notNull().default('#2f6df6'),
department: mysqlEnum('department', ['police', 'ems', 'fire']).notNull(),
jobs: jsonStringArray('jobs').notNull().default([]),
});
// ── Rang → Rechte-Matrix (editierbar über Admin-UI) ──
export const mdtRanks = mysqlTable(
'mdt_ranks',
{
id: int('id').autoincrement().primaryKey(),
department: mysqlEnum('department', ['police', 'ems', 'fire']).notNull(),
grade: int('grade').notNull(),
label: varchar('label', { length: 64 }).notNull(),
permissions: jsonStringArray('permissions').notNull().default([]),
},
(t) => ({
deptGradeIdx: uniqueIndex('rank_dept_grade_idx').on(t.department, t.grade),
}),
);
/**
* @deprecated Alt-Tabelle: früher der editierbare Overlay über QBox-Spieler.
* Seit dem Umstieg auf die eigenständige `mdt_citizens`-Registry ungenutzt.
* Bleibt definiert, damit Drizzle keinen Rename/Drop generiert (Daten unangetastet).
*/
export const mdtPersonRecords = mysqlTable('mdt_person_records', {
citizenid: varchar('citizenid', { length: 50 }).primaryKey(),
notes: text('notes'),
flags: jsonStringArray('flags').notNull().default([]),
isWanted: boolean('is_wanted').notNull().default(false),
mugshotUrl: varchar('mugshot_url', { length: 512 }),
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow().notNull(),
updatedBy: varchar('updated_by', { length: 50 }),
});
// ── Bürgerakten (MDT-eigene Personen-Registry, unabhängig von QBox) ──
export const mdtCitizens = mysqlTable(
'mdt_citizens',
{
citizenid: varchar('citizenid', { length: 50 }).primaryKey(), // MDT-generiert (z. B. LS-A1B2C3)
firstname: varchar('firstname', { length: 128 }).notNull(),
lastname: varchar('lastname', { length: 128 }).notNull(),
dob: varchar('dob', { length: 32 }),
gender: mysqlEnum('gender', ['male', 'female', 'divers', 'unknown']).notNull().default('unknown'),
phone: varchar('phone', { length: 32 }),
nationality: varchar('nationality', { length: 64 }),
legalStatus: mysqlEnum('legal_status', ['citizen', 'resident', 'visitor', 'illegal', 'unknown'])
.notNull()
.default('citizen'),
address: varchar('address', { length: 255 }),
occupation: varchar('occupation', { length: 128 }),
height: varchar('height', { length: 32 }),
eyeColor: varchar('eye_color', { length: 32 }),
hairColor: varchar('hair_color', { length: 32 }),
distinguishingMarks: text('distinguishing_marks'),
aliases: jsonStringArray('aliases').notNull().default([]),
licenses: jsonLicenses('licenses').notNull().default([]),
mugshotUrl: varchar('mugshot_url', { length: 512 }),
notes: text('notes'),
flags: jsonStringArray('flags').notNull().default([]),
isWanted: boolean('is_wanted').notNull().default(false),
// Freigabe (ACL) — Sichtbarkeit der Akte
public: boolean('public').notNull().default(true),
acl: jsonAcl('acl').notNull().default([]),
createdByCitizenid: varchar('created_by_citizenid', { length: 50 }),
createdByName: varchar('created_by_name', { length: 255 }),
updatedBy: varchar('updated_by', { length: 50 }),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow().notNull(),
},
(t) => ({
nameIdx: index('citizen_name_idx').on(t.lastname, t.firstname),
wantedIdx: index('citizen_wanted_idx').on(t.isWanted),
}),
);
// ── Fahrzeug-Registry (MDT-eigen — inkl. gefälschter/gestohlener Kennzeichen) ──
export const mdtVehicles = mysqlTable(
'mdt_vehicles',
{
id: int('id').autoincrement().primaryKey(),
plate: varchar('plate', { length: 32 }).notNull(), // NICHT unique: gefälschte Doppel-Kennzeichen möglich
model: varchar('model', { length: 64 }),
color: varchar('color', { length: 64 }),
ownerCitizenid: varchar('owner_citizenid', { length: 50 }), // null = unbekannter Halter
plateStatus: mysqlEnum('plate_status', ['registered', 'forged', 'stolen', 'unknown'])
.notNull()
.default('registered'),
isStolen: boolean('is_stolen').notNull().default(false),
hasBolo: boolean('has_bolo').notNull().default(false),
notes: text('notes'),
createdByCitizenid: varchar('created_by_citizenid', { length: 50 }),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow().notNull(),
},
(t) => ({
plateIdx: index('vehicle_plate_idx').on(t.plate),
ownerIdx: index('vehicle_owner_idx').on(t.ownerCitizenid),
}),
);
// ── Strafenkatalog ──
export const mdtChargesCatalog = mysqlTable('mdt_charges_catalog', {
id: int('id').autoincrement().primaryKey(),
code: varchar('code', { length: 32 }).notNull(),
title: varchar('title', { length: 255 }).notNull(),
category: varchar('category', { length: 64 }).notNull().default('sonstiges'),
fine: int('fine').notNull().default(0),
jailTime: int('jail_time').notNull().default(0),
points: int('points').notNull().default(0),
active: boolean('active').notNull().default(true),
});
// ── Strafakten ──
export const mdtCases = mysqlTable(
'mdt_cases',
{
id: int('id').autoincrement().primaryKey(),
title: varchar('title', { length: 255 }).notNull(),
suspectCitizenid: varchar('suspect_citizenid', { length: 50 }).notNull(),
status: mysqlEnum('status', ['open', 'closed', 'dismissed']).notNull().default('open'),
narrative: text('narrative'),
totalFine: int('total_fine').notNull().default(0),
totalJailTime: int('total_jail_time').notNull().default(0),
officerCitizenid: varchar('officer_citizenid', { length: 50 }),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow().notNull(),
},
(t) => ({
suspectIdx: index('suspect_idx').on(t.suspectCitizenid),
}),
);
export const mdtCaseCharges = mysqlTable('mdt_case_charges', {
id: int('id').autoincrement().primaryKey(),
caseId: int('case_id').notNull(),
catalogId: int('catalog_id').notNull(),
count: int('count').notNull().default(1),
fine: int('fine').notNull().default(0),
jailTime: int('jail_time').notNull().default(0),
points: int('points').notNull().default(0),
});
// ── Kalender: Termine/Events + Teilnehmer ──
export const mdtCalendarEvents = mysqlTable(
'mdt_calendar_events',
{
id: int('id').autoincrement().primaryKey(),
title: varchar('title', { length: 255 }).notNull(),
description: text('description'), // HTML aus Tiptap
category: mysqlEnum('category', ['dienst', 'schulung', 'meeting', 'event', 'sonstiges'])
.notNull()
.default('sonstiges'),
startAt: datetime('start_at', { mode: 'date' }).notNull(),
endAt: datetime('end_at', { mode: 'date' }),
allDay: boolean('all_day').notNull().default(false),
location: varchar('location', { length: 255 }),
openSignup: boolean('open_signup').notNull().default(true),
organizerCitizenid: varchar('organizer_citizenid', { length: 50 }),
organizerName: varchar('organizer_name', { length: 255 }),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow().notNull(),
},
(t) => ({
startIdx: index('event_start_idx').on(t.startAt),
}),
);
export const mdtCalendarAttendees = mysqlTable(
'mdt_calendar_attendees',
{
id: int('id').autoincrement().primaryKey(),
eventId: int('event_id').notNull(),
citizenid: varchar('citizenid', { length: 50 }).notNull(),
name: varchar('name', { length: 255 }).notNull(),
status: mysqlEnum('status', ['invited', 'accepted', 'declined', 'maybe']).notNull().default('invited'),
self: boolean('self').notNull().default(false), // hat sich selbst eingetragen
},
(t) => ({
eventIdx: index('attendee_event_idx').on(t.eventId),
uniqueMember: uniqueIndex('attendee_event_citizen_idx').on(t.eventId, t.citizenid),
}),
);
// ── Gesetze (Strafgesetzbuch-Nachschlagewerk) ──
export const mdtLaws = mysqlTable(
'mdt_laws',
{
id: int('id').autoincrement().primaryKey(),
category: varchar('category', { length: 64 }).notNull().default('Allgemein'),
paragraph: varchar('paragraph', { length: 32 }).notNull(),
title: varchar('title', { length: 255 }).notNull(),
description: text('description'), // HTML aus Tiptap
fine: int('fine'),
jailTime: int('jail_time'),
sortOrder: int('sort_order').notNull().default(0),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow().notNull(),
},
(t) => ({
categoryIdx: index('law_category_idx').on(t.category),
}),
);
// ── Behandlungsakten (EMS-Pendant zu den Strafakten) ──
export const mdtTreatmentRecords = mysqlTable(
'mdt_treatment_records',
{
id: int('id').autoincrement().primaryKey(),
reference: varchar('reference', { length: 32 }).notNull(), // TR-JJJJ-MM-TT-###
patientCitizenid: varchar('patient_citizenid', { length: 50 }).notNull(),
title: varchar('title', { length: 255 }).notNull(),
diagnosis: text('diagnosis'),
treatment: text('treatment'), // HTML aus Tiptap
status: mysqlEnum('status', ['open', 'closed']).notNull().default('open'),
authorCitizenid: varchar('author_citizenid', { length: 50 }),
authorName: varchar('author_name', { length: 255 }),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow().notNull(),
},
(t) => ({
patientIdx: index('treatment_patient_idx').on(t.patientCitizenid),
}),
);
// ── Haftbefehle ──
export const mdtWarrants = mysqlTable(
'mdt_warrants',
{
id: int('id').autoincrement().primaryKey(),
citizenid: varchar('citizenid', { length: 50 }).notNull(),
reason: text('reason').notNull(),
status: mysqlEnum('status', ['active', 'served', 'revoked', 'expired'])
.notNull()
.default('active'),
caseId: int('case_id'),
issuedByCitizenid: varchar('issued_by_citizenid', { length: 50 }),
issuedAt: timestamp('issued_at').defaultNow().notNull(),
expiresAt: timestamp('expires_at'),
},
(t) => ({
citizenIdx: index('warrant_citizen_idx').on(t.citizenid),
}),
);
// ── BOLOs / Fahndungen ──
export const mdtBolos = mysqlTable('mdt_bolos', {
id: int('id').autoincrement().primaryKey(),
kind: mysqlEnum('kind', ['person', 'vehicle']).notNull(),
reference: varchar('reference', { length: 64 }).notNull(), // citizenid oder plate
label: varchar('label', { length: 255 }).notNull(),
reason: text('reason'),
active: boolean('active').notNull().default(true),
createdByCitizenid: varchar('created_by_citizenid', { length: 50 }),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
// ── Dispatch ──
export const mdtDispatchCalls = mysqlTable('mdt_dispatch_calls', {
id: int('id').autoincrement().primaryKey(),
code: varchar('code', { length: 32 }).notNull(),
title: varchar('title', { length: 255 }).notNull(),
description: text('description'),
department: mysqlEnum('department', ['police', 'ems', 'fire']).notNull().default('police'),
priority: mysqlEnum('priority', ['low', 'medium', 'high']).notNull().default('medium'),
status: mysqlEnum('status', ['pending', 'assigned', 'enroute', 'onscene', 'closed'])
.notNull()
.default('pending'),
location: varchar('location', { length: 255 }),
coordsX: varchar('coords_x', { length: 32 }),
coordsY: varchar('coords_y', { length: 32 }),
coordsZ: varchar('coords_z', { length: 32 }),
callerCitizenid: varchar('caller_citizenid', { length: 50 }),
assignedOfficers: jsonStringArray('assigned_officers').notNull().default([]),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow().notNull(),
});
// ── Schwarzes Brett ──
export const mdtAnnouncements = mysqlTable('mdt_announcements', {
id: int('id').autoincrement().primaryKey(),
title: varchar('title', { length: 255 }).notNull(),
body: text('body'),
pinned: boolean('pinned').notNull().default(false),
important: boolean('important').notNull().default(false),
authorCitizenid: varchar('author_citizenid', { length: 50 }),
authorName: varchar('author_name', { length: 255 }),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
// ── Dokumente ──
export const mdtDocFolders = mysqlTable('mdt_doc_folders', {
id: int('id').autoincrement().primaryKey(),
name: varchar('name', { length: 128 }).notNull(),
// Freigabe (ACL)
public: boolean('public').notNull().default(false),
acl: jsonAcl('acl').notNull().default([]),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
export const mdtDocuments = mysqlTable(
'mdt_documents',
{
id: int('id').autoincrement().primaryKey(),
reference: varchar('reference', { length: 32 }).notNull(),
title: varchar('title', { length: 255 }).notNull(),
content: text('content'), // HTML aus Tiptap
folderId: int('folder_id'),
// Freigabe (ACL)
public: boolean('public').notNull().default(false),
acl: jsonAcl('acl').notNull().default([]),
pinned: boolean('pinned').notNull().default(false),
authorCitizenid: varchar('author_citizenid', { length: 50 }),
authorName: varchar('author_name', { length: 255 }),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow().notNull(),
},
(t) => ({
folderIdx: index('doc_folder_idx').on(t.folderId),
}),
);
// ── Audit-Log ──
export const mdtAuditLog = mysqlTable(
'mdt_audit_log',
{
id: int('id').autoincrement().primaryKey(),
actorCitizenid: varchar('actor_citizenid', { length: 50 }),
actorName: varchar('actor_name', { length: 255 }),
action: varchar('action', { length: 64 }).notNull(), // z. B. "person.read"
targetType: varchar('target_type', { length: 32 }),
targetId: varchar('target_id', { length: 64 }),
meta: jsonRecord('meta'),
createdAt: timestamp('created_at').defaultNow().notNull(),
},
(t) => ({
actorIdx: index('audit_actor_idx').on(t.actorCitizenid),
actionIdx: index('audit_action_idx').on(t.action),
}),
);
+29
View File
@@ -0,0 +1,29 @@
/**
* Read-only Mapping der bestehenden QBox-Tabellen.
* NIEMALS via Drizzle-Migration verändern — diese Tabellen gehören qbx_core.
* Nur die Spalten, die das MDT liest. JSON-Spalten kommen als string/unknown und
* werden im Service geparst (charinfo, job, metadata, mods).
*/
import { mysqlTable, varchar, int, longtext, timestamp, tinyint } from 'drizzle-orm/mysql-core';
export const qboxPlayers = mysqlTable('players', {
citizenid: varchar('citizenid', { length: 50 }).primaryKey(),
license: varchar('license', { length: 255 }),
name: varchar('name', { length: 255 }),
// JSON-Blobs (QBox speichert als longtext/JSON)
charinfo: longtext('charinfo'),
job: longtext('job'),
metadata: longtext('metadata'),
phone: varchar('phone_number', { length: 50 }),
lastUpdated: timestamp('last_updated'),
});
export const qboxVehicles = mysqlTable('player_vehicles', {
id: int('id').primaryKey(),
citizenid: varchar('citizenid', { length: 50 }),
plate: varchar('plate', { length: 50 }),
vehicle: varchar('vehicle', { length: 50 }), // model spawn name
mods: longtext('mods'),
garage: varchar('garage', { length: 50 }),
state: tinyint('state'), // 0 = out, 1 = garaged (QBox)
});
+247
View File
@@ -0,0 +1,247 @@
/**
* Dev-Setup: legt eine ISOLIERTE Test-DB an und seedet MDT-eigene Testdaten.
*
* pnpm db:setup:dev
*
* Voraussetzung: in .env eine DEDIZIERTE Test-DB setzen, z. B. DB_NAME=d4rk_mdt_dev.
* Das MDT ist komplett eigenständig — es liest KEINE QBox-Tabellen mehr, sondern
* führt Bürger (mdt_citizens) und Fahrzeuge (mdt_vehicles) selbst. Das Script:
* 1) spielt die mdt_*-Migrationen ein (Drizzle)
* 2) seedet Test-Bürger (inkl. illegaler Einreise) + Fahrzeuge (inkl. gefälschtem Kennzeichen)
*
* Non-destruktiv: INSERT ... ON DUPLICATE KEY UPDATE bzw. Seed nur wenn leer.
*/
import mysql from 'mysql2/promise';
import { migrate } from 'drizzle-orm/mysql2/migrator';
import { count } from 'drizzle-orm';
import { pool, db } from './client';
import { mdtCitizens, mdtVehicles, mdtLaws, mdtCalendarEvents, mdtCalendarAttendees } from './schema';
import { env } from '../config/env';
/** Legt die Ziel-DB an (separate Verbindung ohne selektierte DB). */
async function createDatabaseIfMissing(): Promise<void> {
const conn = await mysql.createConnection({
host: env.DB_HOST,
port: env.DB_PORT,
user: env.DB_USER,
password: env.DB_PASSWORD,
});
await conn.query(`CREATE DATABASE IF NOT EXISTS \`${env.DB_NAME}\``);
await conn.end();
}
async function main(): Promise<void> {
if (env.NODE_ENV === 'production') {
throw new Error('db:setup:dev darf nicht in production laufen.');
}
console.log(`[setup-dev] Ziel-DB: ${env.DB_NAME} @ ${env.DB_HOST}:${env.DB_PORT}`);
await createDatabaseIfMissing();
console.log('[setup-dev] Datenbank sichergestellt.');
// 1) mdt_*-Migrationen
await migrate(db, { migrationsFolder: './drizzle' });
console.log('[setup-dev] mdt_*-Migrationen eingespielt.');
// 2) Seed: Test-Bürger (MDT-eigen, feste IDs → idempotent)
const citizens = [
{
citizenid: 'LS-100001',
firstname: 'Max',
lastname: 'Mustermann',
dob: '1990-05-14',
gender: 'male' as const,
phone: '555-0101',
nationality: 'USA',
legalStatus: 'citizen' as const,
address: 'Alta St 12, Los Santos',
occupation: 'Mechaniker',
height: '182 cm',
eyeColor: 'braun',
hairColor: 'schwarz',
licenses: [
{ type: 'driver', label: 'Führerschein', active: true },
{ type: 'weapon', label: 'Waffenschein', active: false },
],
flags: [],
isWanted: true,
},
{
citizenid: 'LS-100002',
firstname: 'Erika',
lastname: 'Schmidt',
dob: '1988-11-02',
gender: 'female' as const,
phone: '555-0202',
nationality: 'Deutschland',
legalStatus: 'resident' as const,
address: 'Vinewood Blvd 8',
occupation: 'Anwältin',
licenses: [{ type: 'driver', label: 'Führerschein', active: true }],
flags: [],
isWanted: false,
},
{
citizenid: 'LS-100003',
firstname: 'John',
lastname: 'Doe',
dob: '1995-07-21',
gender: 'male' as const,
phone: '555-0303',
nationality: 'unbekannt',
legalStatus: 'unknown' as const,
occupation: null,
licenses: [],
flags: ['bewaffnet'],
isWanted: false,
},
{
citizenid: 'LS-100004',
firstname: 'Juan',
lastname: 'Sinpapeles',
dob: '1992-03-09',
gender: 'male' as const,
phone: null,
nationality: 'Mexiko',
legalStatus: 'illegal' as const,
address: null,
occupation: null,
aliases: ['El Fantasma'],
licenses: [],
flags: ['illegale Einreise'],
isWanted: true,
},
];
for (const c of citizens) {
await db
.insert(mdtCitizens)
.values({ ...c, createdByName: 'System-Seed' })
.onDuplicateKeyUpdate({
set: {
firstname: c.firstname,
lastname: c.lastname,
legalStatus: c.legalStatus,
isWanted: c.isWanted,
},
});
}
// Fahrzeuge (nur seeden wenn Registry leer — gefälschtes/gestohlenes Kennzeichen dabei)
const [vehCount] = await db.select({ c: count() }).from(mdtVehicles);
if ((vehCount?.c ?? 0) === 0) {
await db.insert(mdtVehicles).values([
{ plate: 'MAX 001', model: 'Sultan RS', color: 'schwarz', ownerCitizenid: 'LS-100001', plateStatus: 'registered' },
{ plate: 'JD 42', model: 'Blista', color: 'blau', ownerCitizenid: 'LS-100003', plateStatus: 'registered' },
{ plate: 'COP 007', model: 'Police Cruiser', color: 'weiß', ownerCitizenid: null, plateStatus: 'forged', notes: 'Gefälschtes Behördenkennzeichen' },
{ plate: 'GH0 5T', model: 'Kuruma', color: 'grau', ownerCitizenid: 'LS-100004', plateStatus: 'stolen', isStolen: true },
]);
}
// Gesetze (nur seeden wenn leer)
const [lawCount] = await db.select({ c: count() }).from(mdtLaws);
if ((lawCount?.c ?? 0) === 0) {
await db.insert(mdtLaws).values([
{
category: 'Eigentumsdelikte',
paragraph: '§ 242',
title: 'Diebstahl',
description:
'<p>Wer eine fremde bewegliche Sache einem anderen in der Absicht wegnimmt, sie sich rechtswidrig zuzueignen, wird bestraft.</p>',
fine: 500,
jailTime: 5,
sortOrder: 1,
},
{
category: 'Verkehrsdelikte',
paragraph: '§ 315c',
title: 'Gefährdung des Straßenverkehrs',
description: '<p>Rücksichtsloses Fahren mit Gefährdung von Leib, Leben oder Sachwerten.</p>',
fine: 800,
jailTime: 10,
sortOrder: 1,
},
{
category: 'Gewaltdelikte',
paragraph: '§ 223',
title: 'Körperverletzung',
description: '<p>Wer eine andere Person <strong>körperlich misshandelt</strong> oder an der Gesundheit schädigt.</p>',
fine: 1000,
jailTime: 15,
sortOrder: 1,
},
{
category: 'Gewaltdelikte',
paragraph: '§ 211',
title: 'Mord',
description:
'<p>Wer aus niedrigen Beweggründen, heimtückisch oder grausam einen Menschen tötet.</p><ul><li>Höchststrafe</li></ul>',
fine: 0,
jailTime: 120,
sortOrder: 2,
},
]);
}
// Kalender-Termine (nur seeden wenn leer) — Daten relativ zu heute
const [evCount] = await db.select({ c: count() }).from(mdtCalendarEvents);
if ((evCount?.c ?? 0) === 0) {
const at = (dayOffset: number, hour: number) => {
const d = new Date();
d.setDate(d.getDate() + dayOffset);
d.setHours(hour, 0, 0, 0);
return d;
};
const [ev1] = await db
.insert(mdtCalendarEvents)
.values({
title: 'Dienstbesprechung',
description: '<p>Wöchentliche Lagebesprechung im Präsidium.</p>',
category: 'meeting',
startAt: at(0, 14),
endAt: at(0, 15),
location: 'Mission Row PD',
openSignup: true,
organizerName: 'System-Seed',
})
.$returningId();
const [ev2] = await db
.insert(mdtCalendarEvents)
.values({
title: 'Schießtraining',
description: '<p>Pflichttraining am Schießstand. Bitte selbst eintragen.</p>',
category: 'schulung',
startAt: at(3, 18),
endAt: at(3, 20),
location: 'Schießstand',
openSignup: true,
organizerName: 'System-Seed',
})
.$returningId();
await db.insert(mdtCalendarAttendees).values([
{ eventId: ev1!.id, citizenid: 'LS-100002', name: 'Erika Schmidt', status: 'invited', self: false },
{ eventId: ev2!.id, citizenid: 'LS-100002', name: 'Erika Schmidt', status: 'accepted', self: true },
]);
}
// Beispiel-Strafenkatalog (idempotent: nur seeden wenn leer)
const [catRows] = await pool.query('SELECT COUNT(*) AS c FROM mdt_charges_catalog');
const catCount = Array.isArray(catRows) ? Number((catRows[0] as { c: number }).c) : 0;
if (catCount === 0) {
await pool.query(`
INSERT INTO mdt_charges_catalog (code, title, category, fine, jail_time, points)
VALUES
('§ 242', 'Diebstahl', 'eigentum', 500, 5, 2),
('§ 315c', 'Gefährdung Straßenverkehr', 'verkehr', 800, 10, 3),
('§ 223', 'Körperverletzung', 'gewalt', 1000, 15, 4)
`);
}
console.log('[setup-dev] Seed abgeschlossen ✅ (4 Bürger inkl. illegaler Einreise, 4 Fahrzeuge inkl. Fälschung, 3 Strafen)');
await pool.end();
}
main().catch((err) => {
console.error('[setup-dev] Fehler:', err);
process.exit(1);
});
@@ -0,0 +1,51 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { updateRankSchema, updateUserRolesSchema, DEPARTMENTS } from '@d4rk-tablet/shared';
import { getRankMatrix, updateRank, listUsers, updateUserRoles } from './admin.service';
const deptParam = z.object({ department: z.enum(DEPARTMENTS) });
export async function adminRoutes(app: FastifyInstance): Promise<void> {
// Rechte-Matrix einer Behörde
app.get<{ Params: { department: string } }>(
'/admin/ranks/:department',
{ preHandler: app.requirePermission('admin.ranks.manage') },
async (req, reply) => {
const parsed = deptParam.safeParse(req.params);
if (!parsed.success) return reply.status(400).send({ message: 'Unbekannte Behörde' });
return getRankMatrix(parsed.data.department);
},
);
// Einen Rang aktualisieren
app.put<{ Params: { department: string; grade: string } }>(
'/admin/ranks/:department/:grade',
{ preHandler: app.requirePermission('admin.ranks.manage') },
async (req, reply) => {
const params = deptParam.safeParse({ department: req.params.department });
const body = updateRankSchema.safeParse(req.body);
if (!params.success || !body.success) {
return reply.status(400).send({ message: 'Ungültige Anfrage' });
}
return updateRank(params.data.department, Number(req.params.grade), body.data);
},
);
// Mitarbeiterliste
app.get('/admin/users', { preHandler: app.requirePermission('admin.users.manage') }, async () => {
return listUsers();
});
// Override-Rollen eines Mitarbeiters setzen
app.patch<{ Params: { id: string } }>(
'/admin/users/:id/roles',
{ preHandler: app.requirePermission('admin.users.manage') },
async (req, reply) => {
const parsed = updateUserRolesSchema.safeParse(req.body);
if (!parsed.success) return reply.status(400).send({ message: 'Ungültige Rollen' });
const user = await updateUserRoles(Number(req.params.id), parsed.data.roles);
if (!user) return reply.status(404).send({ message: 'Mitarbeiter nicht gefunden' });
return user;
},
);
}
@@ -0,0 +1,120 @@
import { and, eq } from 'drizzle-orm';
import {
PERMISSIONS,
ROLE_PERMISSIONS,
type Department,
type Permission,
type Role,
type Rank,
type MdtUser,
} from '@d4rk-tablet/shared';
import { db } from '../../db/client';
import { mdtRanks, mdtUsers } from '../../db/schema';
import { DEFAULT_RANKS, defaultPermissionsFor } from '../../config/rankDefaults';
const OVERRIDE_ROLES: Role[] = ['admin', 'dispatch'];
function asRoles(v: unknown): Role[] {
if (!Array.isArray(v)) return [];
const set = new Set<string>(['admin', 'dispatch', 'police', 'ems', 'fire']);
return v.filter((r): r is Role => typeof r === 'string' && set.has(r));
}
/**
* Effektive Rechte eines Users: Rang-Matrix(Department, Grade) Override-Rollen.
* admin → alle Rechte. DB-Rang überschreibt Code-Default.
*/
export async function computePermissions(
department: Department | null,
grade: number | null,
overrideRoles: Role[],
): Promise<Permission[]> {
if (overrideRoles.includes('admin')) return [...PERMISSIONS];
const perms = new Set<Permission>();
if (department && grade != null) {
const [row] = await db
.select()
.from(mdtRanks)
.where(and(eq(mdtRanks.department, department), eq(mdtRanks.grade, grade)))
.limit(1);
const rankPerms = row ? (row.permissions as Permission[]) : defaultPermissionsFor(department, grade);
for (const p of rankPerms) perms.add(p);
}
for (const role of overrideRoles) {
if (role === 'dispatch') for (const p of ROLE_PERMISSIONS.dispatch) perms.add(p);
}
return [...perms];
}
/** Rechte-Matrix einer Behörde: DB-Ränge über Defaults gemerged. */
export async function getRankMatrix(department: Department): Promise<Rank[]> {
const rows = await db.select().from(mdtRanks).where(eq(mdtRanks.department, department));
const byGrade = new Map(rows.map((r) => [r.grade, r]));
const grades = new Set<number>([
...DEFAULT_RANKS[department].map((r) => r.grade),
...rows.map((r) => r.grade),
]);
return [...grades]
.sort((a, b) => a - b)
.map((grade) => {
const dbRow = byGrade.get(grade);
if (dbRow) {
return {
department,
grade,
label: dbRow.label,
permissions: dbRow.permissions as Permission[],
};
}
const def = DEFAULT_RANKS[department].find((r) => r.grade === grade)!;
return { department, grade, label: def.label, permissions: def.permissions };
});
}
export async function updateRank(
department: Department,
grade: number,
input: { label?: string; permissions: Permission[] },
): Promise<Rank> {
const label =
input.label ?? DEFAULT_RANKS[department].find((r) => r.grade === grade)?.label ?? `Grade ${grade}`;
await db
.insert(mdtRanks)
.values({ department, grade, label, permissions: input.permissions })
.onDuplicateKeyUpdate({ set: { label, permissions: input.permissions } });
return { department, grade, label, permissions: input.permissions };
}
// ── Mitarbeiter ──
function mapUser(row: typeof mdtUsers.$inferSelect): MdtUser {
return {
id: row.id,
citizenid: row.citizenid,
discordId: row.discordId,
name: row.name,
callsign: row.callsign,
department: row.department,
grade: row.grade,
roles: asRoles(row.roles),
};
}
export async function listUsers(): Promise<MdtUser[]> {
const rows = await db.select().from(mdtUsers);
return rows.map(mapUser);
}
export async function updateUserRoles(id: number, roles: string[]): Promise<MdtUser | null> {
const filtered = asRoles(roles).filter((r) => OVERRIDE_ROLES.includes(r));
await db.update(mdtUsers).set({ roles: filtered }).where(eq(mdtUsers.id, id));
const [row] = await db.select().from(mdtUsers).where(eq(mdtUsers.id, id)).limit(1);
return row ? mapUser(row) : null;
}
@@ -0,0 +1,41 @@
import { describe, it, expect } from 'vitest';
import { createHmac } from 'node:crypto';
import { verifyBridgeSignature } from '../auth.service';
import { env } from '../../../config/env';
function sign(ts: string, license: string, citizenid: string): string {
return createHmac('sha256', env.BRIDGE_HMAC_SECRET)
.update(`${ts}.${license}.${citizenid}`)
.digest('hex');
}
describe('verifyBridgeSignature', () => {
const license = 'license:abc';
const citizenid = 'ABC12345';
it('akzeptiert eine gültige, frische Signatur', () => {
const ts = String(Date.now());
const sig = sign(ts, license, citizenid);
expect(verifyBridgeSignature(ts, license, citizenid, sig)).toBe(true);
});
it('lehnt eine manipulierte Signatur ab', () => {
const ts = String(Date.now());
const sig = sign(ts, license, citizenid);
expect(verifyBridgeSignature(ts, license, 'OTHER999', sig)).toBe(false);
});
it('lehnt einen abgelaufenen Timestamp ab (Replay-Schutz)', () => {
const ts = String(Date.now() - 120_000);
const sig = sign(ts, license, citizenid);
expect(verifyBridgeSignature(ts, license, citizenid, sig)).toBe(false);
});
it('lehnt eine falsch signierte (falscher Secret) Signatur ab', () => {
const ts = String(Date.now());
const wrong = createHmac('sha256', 'wrong-secret')
.update(`${ts}.${license}.${citizenid}`)
.digest('hex');
expect(verifyBridgeSignature(ts, license, citizenid, wrong)).toBe(false);
});
});
+116
View File
@@ -0,0 +1,116 @@
import type { FastifyInstance } from 'fastify';
import { randomUUID } from 'node:crypto';
import {
fivemAuthRequestSchema,
PERMISSIONS,
type AuthUser,
type AuthTokenResponse,
} from '@d4rk-tablet/shared';
import { env, isDev } from '../../config/env';
import { COOKIE_NAME } from '../../plugins/auth';
import { verifyBridgeSignature, upsertFivemUser, findUserByDiscordId } from './auth.service';
import { bridgeSecretOk } from '../../utils/bridge-auth';
import {
getDiscordAuthUrl,
exchangeDiscordCode,
fetchDiscordUser,
getWebUrl,
} from './discord';
function issueTokens(app: FastifyInstance, user: AuthUser): AuthTokenResponse {
const token = app.jwt.sign(user, { expiresIn: env.JWT_EXPIRES_IN });
const refreshToken = app.jwt.sign({ ...user, kind: 'refresh' } as AuthUser, {
expiresIn: env.JWT_REFRESH_EXPIRES_IN,
});
return { token, refreshToken, user };
}
export async function authRoutes(app: FastifyInstance): Promise<void> {
// ── FiveM-Bridge → Token (Shared-Secret ODER HMAC, KEIN JWT) ──
app.post('/auth/fivem', async (req, reply) => {
const parsed = fivemAuthRequestSchema.safeParse(req.body);
if (!parsed.success) {
return reply.status(400).send({ message: 'Ungültiger Body', issues: parsed.error.issues });
}
const { license, citizenid, name, job, gradeLevel, callsign } = parsed.data;
const signature = req.headers['x-mdt-signature'];
const timestamp = req.headers['x-mdt-timestamp'];
const hmacOk =
typeof signature === 'string' &&
typeof timestamp === 'string' &&
verifyBridgeSignature(timestamp, license, citizenid, signature);
if (!bridgeSecretOk(req) && !hmacOk) {
return reply.status(401).send({ message: 'Bridge nicht autorisiert' });
}
const user = await upsertFivemUser({ citizenid, name, job, gradeLevel, callsign });
if (user.roles.length === 0) {
return reply.status(403).send({ message: 'Kein Behörden-Job — Zugriff verweigert' });
}
return issueTokens(app, user);
});
// ── Dev-Login (nur Development) — echtes JWT für Browser-Tests ──
if (isDev) {
app.post('/auth/dev', async () => {
const user: AuthUser = {
sub: 'dev-0',
citizenid: 'DEV00000',
discordId: null,
name: 'Dev Officer',
roles: ['police', 'admin'],
permissions: [...PERMISSIONS],
department: 'police',
grade: 4,
authorities: ['lspd'],
};
return issueTokens(app, user);
});
}
// ── Aktueller User ──
app.get('/auth/me', { preHandler: app.authenticate }, async (req) => {
return req.user;
});
// ── Discord OAuth: Login-Redirect ──
app.get('/auth/discord/login', async (_req, reply) => {
if (!env.DISCORD_CLIENT_ID) {
return reply.status(503).send({ message: 'Discord OAuth nicht konfiguriert' });
}
const state = randomUUID();
reply.setCookie('mdt_oauth_state', state, {
httpOnly: true,
sameSite: 'lax',
path: '/',
maxAge: 300,
});
return reply.redirect(getDiscordAuthUrl(state));
});
// ── Discord OAuth: Callback ──
app.get('/auth/discord/callback', async (req, reply) => {
const query = req.query as { code?: string; state?: string };
const savedState = req.cookies['mdt_oauth_state'];
if (!query.code || !query.state || query.state !== savedState) {
return reply.status(400).send({ message: 'Ungültiger OAuth-State' });
}
const accessToken = await exchangeDiscordCode(query.code);
const discordUser = await fetchDiscordUser(accessToken);
const user = await findUserByDiscordId(discordUser.id);
if (!user || user.roles.length === 0) {
return reply.status(403).send({ message: 'Discord-Account nicht für das MDT freigeschaltet' });
}
const { token } = issueTokens(app, user);
reply.setCookie(COOKIE_NAME, token, {
httpOnly: true,
sameSite: 'lax',
path: '/',
secure: !isDev,
});
return reply.redirect(getWebUrl());
});
}
@@ -0,0 +1,140 @@
import { createHmac, timingSafeEqual } from 'node:crypto';
import { eq } from 'drizzle-orm';
import type { AuthUser, Role, Department } from '@d4rk-tablet/shared';
import { ROLES, DEPARTMENTS } from '@d4rk-tablet/shared';
import { db } from '../../db/client';
import { mdtUsers } from '../../db/schema';
import { env } from '../../config/env';
import { baseRoleForJob, ELEVATED_ROLES } from '../../config/jobRoles';
import { computePermissions } from '../admin/admin.service';
import { authoritiesForJob } from '../authorities/authorities.service';
const ROLE_SET = new Set<string>(ROLES);
function asRoles(values: unknown): Role[] {
if (!Array.isArray(values)) return [];
return values.filter((v): v is Role => typeof v === 'string' && ROLE_SET.has(v));
}
const DEPT_SET = new Set<string>(DEPARTMENTS);
function asDepartment(role: Role | null): Department | null {
return role && DEPT_SET.has(role) ? (role as Department) : null;
}
/**
* Verifiziert die HMAC-Signatur der fivem-bridge.
* Signatur = HMAC-SHA256("<timestamp>.<license>.<citizenid>", BRIDGE_HMAC_SECRET).
* Timestamp-Fenster: ±60s gegen Replay.
*/
export function verifyBridgeSignature(
timestamp: string,
license: string,
citizenid: string,
signature: string,
): boolean {
const ts = Number(timestamp);
if (!Number.isFinite(ts) || Math.abs(Date.now() - ts) > 60_000) return false;
const expected = createHmac('sha256', env.BRIDGE_HMAC_SECRET)
.update(`${timestamp}.${license}.${citizenid}`)
.digest('hex');
const a = Buffer.from(expected, 'utf8');
const b = Buffer.from(signature, 'utf8');
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}
/**
* Upsert eines MDT-Users beim FiveM-Login.
* Rollen = aktuelle Job-Rolle persistierte elevated Rollen (admin/dispatch).
*/
export async function upsertFivemUser(input: {
citizenid: string;
name: string;
job: string;
gradeLevel: number;
callsign?: string | null;
}): Promise<AuthUser> {
const existing = await db
.select()
.from(mdtUsers)
.where(eq(mdtUsers.citizenid, input.citizenid))
.limit(1);
const persistedElevated = asRoles(existing[0]?.roles).filter((r) => ELEVATED_ROLES.includes(r));
const department = asDepartment(baseRoleForJob(input.job));
const grade = department ? input.gradeLevel : null;
// roles = Department (fürs Branding) + Override-Rollen (admin/dispatch)
const roles = Array.from(new Set<Role>([...(department ? [department] : []), ...persistedElevated]));
await db
.insert(mdtUsers)
.values({
citizenid: input.citizenid,
name: input.name,
callsign: input.callsign ?? null,
department,
grade,
job: input.job,
roles: persistedElevated, // nur Overrides persistieren (Department kommt aus Job)
})
.onDuplicateKeyUpdate({
set: {
name: input.name,
callsign: input.callsign ?? null,
department,
grade,
job: input.job,
roles: persistedElevated,
},
});
const [row] = await db
.select()
.from(mdtUsers)
.where(eq(mdtUsers.citizenid, input.citizenid))
.limit(1);
const permissions = await computePermissions(department, grade, persistedElevated);
const authorities = await authoritiesForJob(input.job);
return {
sub: String(row!.id),
citizenid: row!.citizenid,
discordId: row!.discordId,
name: row!.name,
roles,
permissions,
department,
grade,
authorities,
};
}
/** Sucht einen bereits verknüpften User per Discord-ID (Browser-Login). */
export async function findUserByDiscordId(discordId: string): Promise<AuthUser | null> {
const [row] = await db
.select()
.from(mdtUsers)
.where(eq(mdtUsers.discordId, discordId))
.limit(1);
if (!row) return null;
const overrides = asRoles(row.roles).filter((r) => ELEVATED_ROLES.includes(r));
const department = row.department;
const roles = Array.from(new Set<Role>([...(department ? [department] : []), ...overrides]));
const permissions = await computePermissions(department, row.grade, overrides);
const authorities = await authoritiesForJob(row.job);
return {
sub: String(row.id),
citizenid: row.citizenid,
discordId: row.discordId,
name: row.name,
roles,
permissions,
department,
grade: row.grade,
authorities,
};
}
+52
View File
@@ -0,0 +1,52 @@
import { env } from '../../config/env';
const DISCORD_API = 'https://discord.com/api';
export interface DiscordUser {
id: string;
username: string;
global_name: string | null;
}
export function getDiscordAuthUrl(state: string): string {
const params = new URLSearchParams({
client_id: env.DISCORD_CLIENT_ID,
redirect_uri: env.DISCORD_REDIRECT_URI,
response_type: 'code',
scope: 'identify',
state,
});
return `${DISCORD_API}/oauth2/authorize?${params.toString()}`;
}
export async function exchangeDiscordCode(code: string): Promise<string> {
const body = new URLSearchParams({
client_id: env.DISCORD_CLIENT_ID,
client_secret: env.DISCORD_CLIENT_SECRET,
grant_type: 'authorization_code',
code,
redirect_uri: env.DISCORD_REDIRECT_URI,
});
const res = await fetch(`${DISCORD_API}/oauth2/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
if (!res.ok) throw new Error(`Discord token exchange fehlgeschlagen (${res.status})`);
const data = (await res.json()) as { access_token: string };
return data.access_token;
}
export async function fetchDiscordUser(accessToken: string): Promise<DiscordUser> {
const res = await fetch(`${DISCORD_API}/users/@me`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!res.ok) throw new Error(`Discord user fetch fehlgeschlagen (${res.status})`);
return (await res.json()) as DiscordUser;
}
/** Web-App-Basis-URL (für Redirect nach OAuth). Erste CORS-Origin. */
export function getWebUrl(): string {
return env.CORS_ORIGINS[0] ?? 'http://localhost:5173';
}
@@ -0,0 +1,42 @@
import type { FastifyInstance } from 'fastify';
import { createAuthoritySchema, updateAuthoritySchema } from '@d4rk-tablet/shared';
import {
listAuthorities,
createAuthority,
updateAuthority,
deleteAuthority,
} from './authorities.service';
export async function authoritiesRoutes(app: FastifyInstance): Promise<void> {
// Lesen: jeder eingeloggte User (für Freigabe-Auswahl) — nur Auth, keine Spezial-Permission
app.get('/authorities', { preHandler: app.authenticate }, async () => {
return listAuthorities();
});
app.post('/admin/authorities', { preHandler: app.requirePermission('admin.users.manage') }, async (req, reply) => {
const parsed = createAuthoritySchema.safeParse(req.body);
if (!parsed.success) return reply.status(400).send({ message: 'Ungültige Behörde', issues: parsed.error.issues });
return createAuthority(parsed.data);
});
app.patch<{ Params: { id: string } }>(
'/admin/authorities/:id',
{ preHandler: app.requirePermission('admin.users.manage') },
async (req, reply) => {
const parsed = updateAuthoritySchema.safeParse(req.body);
if (!parsed.success) return reply.status(400).send({ message: 'Ungültiger Body' });
const a = await updateAuthority(Number(req.params.id), parsed.data);
if (!a) return reply.status(404).send({ message: 'Behörde nicht gefunden' });
return a;
},
);
app.delete<{ Params: { id: string } }>(
'/admin/authorities/:id',
{ preHandler: app.requirePermission('admin.users.manage') },
async (req) => {
await deleteAuthority(Number(req.params.id));
return { ok: true };
},
);
}
@@ -0,0 +1,67 @@
import { eq, count } from 'drizzle-orm';
import type { Authority, CreateAuthorityInput, UpdateAuthorityInput } from '@d4rk-tablet/shared';
import { db } from '../../db/client';
import { mdtAuthorities } from '../../db/schema';
type Row = typeof mdtAuthorities.$inferSelect;
function map(row: Row): Authority {
return {
id: row.id,
key: row.key,
name: row.name,
color: row.color,
department: row.department,
jobs: Array.isArray(row.jobs) ? row.jobs : [],
};
}
/** Standard-Behörden beim ersten Start (falls Tabelle leer). */
const DEFAULT_AUTHORITIES: Omit<Authority, 'id'>[] = [
{ key: 'lspd', name: 'Los Santos Police Dept.', color: '#2f6df6', department: 'police', jobs: ['police', 'leo'] },
{ key: 'bcso', name: 'Blaine County Sheriff', color: '#3aa675', department: 'police', jobs: ['bcso'] },
{ key: 'doj', name: 'Department of Justice', color: '#8b5cf6', department: 'police', jobs: ['doj'] },
{ key: 'lscid', name: 'Criminal Investigation Dept.', color: '#64748b', department: 'police', jobs: ['cid', 'lscid'] },
{ key: 'sams', name: 'San Andreas Medical Services', color: '#e5484d', department: 'ems', jobs: ['ambulance', 'ems'] },
{ key: 'lsfd', name: 'Los Santos Fire Dept.', color: '#f5a524', department: 'fire', jobs: ['fire'] },
];
export async function ensureAuthoritiesSeeded(): Promise<void> {
const [row] = await db.select({ c: count() }).from(mdtAuthorities);
if ((row?.c ?? 0) > 0) return;
await db.insert(mdtAuthorities).values(DEFAULT_AUTHORITIES);
}
export async function listAuthorities(): Promise<Authority[]> {
const rows = await db.select().from(mdtAuthorities).orderBy(mdtAuthorities.name);
return rows.map(map);
}
/** Behörden-Keys, denen ein QBox-Job angehört. */
export async function authoritiesForJob(job: string | null): Promise<string[]> {
if (!job) return [];
const rows = await db.select().from(mdtAuthorities);
return rows.filter((r) => (r.jobs as string[]).includes(job)).map((r) => r.key);
}
export async function createAuthority(input: CreateAuthorityInput): Promise<Authority> {
const inserted = await db.insert(mdtAuthorities).values(input).$returningId();
const [row] = await db.select().from(mdtAuthorities).where(eq(mdtAuthorities.id, inserted[0]!.id)).limit(1);
return map(row!);
}
export async function updateAuthority(id: number, input: UpdateAuthorityInput): Promise<Authority | null> {
const patch: Partial<Row> = {};
if (input.name !== undefined) patch.name = input.name;
if (input.color !== undefined) patch.color = input.color;
if (input.department !== undefined) patch.department = input.department;
if (input.jobs !== undefined) patch.jobs = input.jobs;
if (Object.keys(patch).length > 0) {
await db.update(mdtAuthorities).set(patch).where(eq(mdtAuthorities.id, id));
}
const [row] = await db.select().from(mdtAuthorities).where(eq(mdtAuthorities.id, id)).limit(1);
return row ? map(row) : null;
}
export async function deleteAuthority(id: number): Promise<void> {
await db.delete(mdtAuthorities).where(eq(mdtAuthorities.id, id));
}
@@ -0,0 +1,43 @@
import type { FastifyInstance } from 'fastify';
import { createAnnouncementSchema, updateAnnouncementSchema } from '@d4rk-tablet/shared';
import {
listAnnouncements,
createAnnouncement,
updateAnnouncement,
deleteAnnouncement,
} from './board.service';
export async function boardRoutes(app: FastifyInstance): Promise<void> {
app.get('/board', { preHandler: app.requirePermission('mdt.board.view') }, async () => {
return listAnnouncements();
});
app.post('/board', { preHandler: app.requirePermission('mdt.board.manage') }, async (req, reply) => {
const parsed = createAnnouncementSchema.safeParse(req.body);
if (!parsed.success) {
return reply.status(400).send({ message: 'Ungültiger Body', issues: parsed.error.issues });
}
return createAnnouncement(parsed.data, { citizenid: req.user.citizenid, name: req.user.name });
});
app.patch<{ Params: { id: string } }>(
'/board/:id',
{ preHandler: app.requirePermission('mdt.board.manage') },
async (req, reply) => {
const parsed = updateAnnouncementSchema.safeParse(req.body);
if (!parsed.success) return reply.status(400).send({ message: 'Ungültiger Body' });
const a = await updateAnnouncement(Number(req.params.id), parsed.data);
if (!a) return reply.status(404).send({ message: 'Beitrag nicht gefunden' });
return a;
},
);
app.delete<{ Params: { id: string } }>(
'/board/:id',
{ preHandler: app.requirePermission('mdt.board.manage') },
async (req) => {
await deleteAnnouncement(Number(req.params.id));
return { ok: true };
},
);
}
@@ -0,0 +1,76 @@
import { desc, eq } from 'drizzle-orm';
import type {
Announcement,
CreateAnnouncementInput,
UpdateAnnouncementInput,
} from '@d4rk-tablet/shared';
import { db } from '../../db/client';
import { mdtAnnouncements } from '../../db/schema';
type Row = typeof mdtAnnouncements.$inferSelect;
function map(row: Row): Announcement {
return {
id: row.id,
title: row.title,
body: row.body ?? '',
pinned: row.pinned,
important: row.important,
authorCitizenid: row.authorCitizenid,
authorName: row.authorName,
createdAt: row.createdAt.toISOString(),
};
}
/** Beiträge: angepinnte zuerst, dann nach Datum absteigend. */
export async function listAnnouncements(): Promise<Announcement[]> {
const rows = await db
.select()
.from(mdtAnnouncements)
.orderBy(desc(mdtAnnouncements.pinned), desc(mdtAnnouncements.createdAt));
return rows.map(map);
}
export async function createAnnouncement(
input: CreateAnnouncementInput,
author: { citizenid: string | null; name: string },
): Promise<Announcement> {
const inserted = await db
.insert(mdtAnnouncements)
.values({
title: input.title,
body: input.body,
important: input.important,
pinned: input.pinned,
authorCitizenid: author.citizenid,
authorName: author.name,
})
.$returningId();
const [row] = await db
.select()
.from(mdtAnnouncements)
.where(eq(mdtAnnouncements.id, inserted[0]!.id))
.limit(1);
return map(row!);
}
export async function updateAnnouncement(
id: number,
input: UpdateAnnouncementInput,
): Promise<Announcement | null> {
const patch: Partial<Row> = {};
if (input.title !== undefined) patch.title = input.title;
if (input.body !== undefined) patch.body = input.body;
if (input.important !== undefined) patch.important = input.important;
if (input.pinned !== undefined) patch.pinned = input.pinned;
if (Object.keys(patch).length > 0) {
await db.update(mdtAnnouncements).set(patch).where(eq(mdtAnnouncements.id, id));
}
const [row] = await db.select().from(mdtAnnouncements).where(eq(mdtAnnouncements.id, id)).limit(1);
return row ? map(row) : null;
}
export async function deleteAnnouncement(id: number): Promise<boolean> {
await db.delete(mdtAnnouncements).where(eq(mdtAnnouncements.id, id));
return true;
}
@@ -0,0 +1,68 @@
import type { FastifyInstance, FastifyRequest } from 'fastify';
import {
officerPositionUpdateSchema,
officerDutyUpdateSchema,
createDispatchCallSchema,
coordsSchema,
DEPARTMENTS,
} from '@d4rk-tablet/shared';
import { z } from 'zod';
import { verifyBridgeHmac, bridgeSecretOk } from '../../utils/bridge-auth';
import { setOfficerDuty, updateOfficerPosition } from '../../realtime/officers';
import { broadcastOfficerMoved, broadcastOfficerDuty } from '../../realtime/socket';
import { createCall } from '../dispatch/dispatch.service';
/** Bridge-Auth: Shared-Secret-Header ODER HMAC über `<timestamp>.<payload>`. */
function bridgeAuthorized(req: FastifyRequest, hmacPayload: string): boolean {
if (bridgeSecretOk(req)) return true;
const ts = req.headers['x-mdt-timestamp'];
const sig = req.headers['x-mdt-signature'];
return typeof ts === 'string' && typeof sig === 'string' && verifyBridgeHmac(ts, hmacPayload, sig);
}
const dutyBody = officerDutyUpdateSchema.extend({
name: z.string(),
callsign: z.string().nullable().optional(),
department: z.enum(DEPARTMENTS),
});
const positionBody = officerPositionUpdateSchema.extend({ coords: coordsSchema });
/**
* Endpoints, die die FiveM-Bridge (Server) aufruft. Server-zu-Server-Auth, KEIN JWT.
*/
export async function bridgeRoutes(app: FastifyInstance): Promise<void> {
app.post('/bridge/officer/duty', async (req, reply) => {
const parsed = dutyBody.safeParse(req.body);
if (!parsed.success) return reply.status(400).send({ message: 'Ungültige Anfrage' });
if (!bridgeAuthorized(req, parsed.data.citizenid)) {
return reply.status(401).send({ message: 'Bridge nicht autorisiert' });
}
const officer = setOfficerDuty(parsed.data);
broadcastOfficerDuty(officer.department, { citizenid: officer.citizenid, onDuty: officer.onDuty });
return { ok: true };
});
app.post('/bridge/officer/position', async (req, reply) => {
const parsed = positionBody.safeParse(req.body);
if (!parsed.success) return reply.status(400).send({ message: 'Ungültige Anfrage' });
if (!bridgeAuthorized(req, parsed.data.citizenid)) {
return reply.status(401).send({ message: 'Bridge nicht autorisiert' });
}
const officer = updateOfficerPosition(parsed.data.citizenid, parsed.data.coords);
if (!officer) return reply.status(404).send({ message: 'Officer nicht im Dienst' });
broadcastOfficerMoved(officer.department, {
citizenid: officer.citizenid,
coords: parsed.data.coords,
});
return { ok: true };
});
app.post('/bridge/dispatch', async (req, reply) => {
const parsed = createDispatchCallSchema.safeParse(req.body);
if (!parsed.success) return reply.status(400).send({ message: 'Ungültige Anfrage' });
if (!bridgeAuthorized(req, parsed.data.code)) {
return reply.status(401).send({ message: 'Bridge nicht autorisiert' });
}
return createCall(parsed.data);
});
}
@@ -0,0 +1,117 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { createEventSchema, updateEventSchema, rsvpSchema, inviteeSchema } from '@d4rk-tablet/shared';
import {
listEvents,
getEvent,
createEvent,
updateEvent,
deleteEvent,
setRsvp,
removeSelf,
inviteAttendee,
removeAttendee,
} from './calendar.service';
const rangeQuery = z.object({ from: z.string(), to: z.string() });
export async function calendarRoutes(app: FastifyInstance): Promise<void> {
app.get('/calendar/events', { preHandler: app.requirePermission('mdt.calendar.view') }, async (req, reply) => {
const parsed = rangeQuery.safeParse(req.query);
if (!parsed.success) return reply.status(400).send({ message: 'from/to erforderlich' });
return listEvents(new Date(parsed.data.from), new Date(parsed.data.to));
});
app.get<{ Params: { id: string } }>(
'/calendar/events/:id',
{ preHandler: app.requirePermission('mdt.calendar.view') },
async (req, reply) => {
const ev = await getEvent(Number(req.params.id));
if (!ev) return reply.status(404).send({ message: 'Termin nicht gefunden' });
return ev;
},
);
app.post('/calendar/events', { preHandler: app.requirePermission('mdt.calendar.manage') }, async (req, reply) => {
const parsed = createEventSchema.safeParse(req.body);
if (!parsed.success) {
return reply.status(400).send({ message: 'Ungültiger Body', issues: parsed.error.issues });
}
const ev = await createEvent(parsed.data, { citizenid: req.user.citizenid, name: req.user.name });
return reply.status(201).send(ev);
});
app.patch<{ Params: { id: string } }>(
'/calendar/events/:id',
{ preHandler: app.requirePermission('mdt.calendar.manage') },
async (req, reply) => {
const parsed = updateEventSchema.safeParse(req.body);
if (!parsed.success) return reply.status(400).send({ message: 'Ungültiger Body' });
const ev = await updateEvent(Number(req.params.id), parsed.data);
if (!ev) return reply.status(404).send({ message: 'Termin nicht gefunden' });
return ev;
},
);
app.delete<{ Params: { id: string } }>(
'/calendar/events/:id',
{ preHandler: app.requirePermission('mdt.calendar.manage') },
async (req, reply) => {
const ok = await deleteEvent(Number(req.params.id));
if (!ok) return reply.status(404).send({ message: 'Termin nicht gefunden' });
return reply.status(204).send();
},
);
// ── Eigene Teilnahme (Selbst-Eintragung / RSVP) ──
app.put<{ Params: { id: string } }>(
'/calendar/events/:id/rsvp',
{ preHandler: app.requirePermission('mdt.calendar.view') },
async (req, reply) => {
const parsed = rsvpSchema.safeParse(req.body);
if (!parsed.success) return reply.status(400).send({ message: 'Ungültiger Status' });
if (!req.user.citizenid) return reply.status(400).send({ message: 'Kein Charakter zugeordnet' });
const ev = await setRsvp(
Number(req.params.id),
{ citizenid: req.user.citizenid, name: req.user.name },
parsed.data.status,
);
if (!ev) return reply.status(404).send({ message: 'Termin nicht gefunden' });
return ev;
},
);
app.delete<{ Params: { id: string } }>(
'/calendar/events/:id/rsvp',
{ preHandler: app.requirePermission('mdt.calendar.view') },
async (req, reply) => {
if (!req.user.citizenid) return reply.status(400).send({ message: 'Kein Charakter zugeordnet' });
const ev = await removeSelf(Number(req.params.id), req.user.citizenid);
if (!ev) return reply.status(404).send({ message: 'Termin nicht gefunden' });
return ev;
},
);
// ── Organisator: Teilnehmer einladen/entfernen ──
app.post<{ Params: { id: string } }>(
'/calendar/events/:id/attendees',
{ preHandler: app.requirePermission('mdt.calendar.manage') },
async (req, reply) => {
const parsed = inviteeSchema.safeParse(req.body);
if (!parsed.success) return reply.status(400).send({ message: 'Ungültiger Body' });
const ev = await inviteAttendee(Number(req.params.id), parsed.data);
if (!ev) return reply.status(404).send({ message: 'Termin nicht gefunden' });
return ev;
},
);
app.delete<{ Params: { id: string; citizenid: string } }>(
'/calendar/events/:id/attendees/:citizenid',
{ preHandler: app.requirePermission('mdt.calendar.manage') },
async (req, reply) => {
const ev = await removeAttendee(Number(req.params.id), req.params.citizenid);
if (!ev) return reply.status(404).send({ message: 'Termin nicht gefunden' });
return ev;
},
);
}
@@ -0,0 +1,179 @@
import { and, asc, eq, inArray, lte, sql } from 'drizzle-orm';
import type {
CalendarAttendee,
CalendarEvent,
CreateEventInput,
Invitee,
UpdateEventInput,
AttendeeStatus,
} from '@d4rk-tablet/shared';
import { db } from '../../db/client';
import { mdtCalendarEvents, mdtCalendarAttendees } from '../../db/schema';
type EventRow = typeof mdtCalendarEvents.$inferSelect;
type AttendeeRow = typeof mdtCalendarAttendees.$inferSelect;
function mapAttendee(row: AttendeeRow): CalendarAttendee {
return { id: row.id, citizenid: row.citizenid, name: row.name, status: row.status, self: row.self };
}
function mapEvent(row: EventRow, attendees: CalendarAttendee[]): CalendarEvent {
return {
id: row.id,
title: row.title,
description: row.description ?? '',
category: row.category,
startAt: row.startAt.toISOString(),
endAt: row.endAt ? row.endAt.toISOString() : null,
allDay: row.allDay,
location: row.location,
openSignup: row.openSignup,
organizerCitizenid: row.organizerCitizenid,
organizerName: row.organizerName,
attendees,
createdAt: row.createdAt.toISOString(),
};
}
async function attendeesFor(eventIds: number[]): Promise<Map<number, CalendarAttendee[]>> {
const map = new Map<number, CalendarAttendee[]>();
if (eventIds.length === 0) return map;
const rows = await db
.select()
.from(mdtCalendarAttendees)
.where(inArray(mdtCalendarAttendees.eventId, eventIds));
for (const r of rows) {
const list = map.get(r.eventId) ?? [];
list.push(mapAttendee(r));
map.set(r.eventId, list);
}
return map;
}
/** Events, die den Zeitraum [from, to] berühren (auch mehrtägige). */
export async function listEvents(from: Date, to: Date): Promise<CalendarEvent[]> {
const rows = await db
.select()
.from(mdtCalendarEvents)
.where(
and(
lte(mdtCalendarEvents.startAt, to),
sql`COALESCE(${mdtCalendarEvents.endAt}, ${mdtCalendarEvents.startAt}) >= ${from}`,
),
)
.orderBy(asc(mdtCalendarEvents.startAt));
const att = await attendeesFor(rows.map((r) => r.id));
return rows.map((r) => mapEvent(r, att.get(r.id) ?? []));
}
export async function getEvent(id: number): Promise<CalendarEvent | null> {
const [row] = await db.select().from(mdtCalendarEvents).where(eq(mdtCalendarEvents.id, id)).limit(1);
if (!row) return null;
const att = await attendeesFor([id]);
return mapEvent(row, att.get(id) ?? []);
}
export async function createEvent(
input: CreateEventInput,
organizer: { citizenid: string | null; name: string | null },
): Promise<CalendarEvent> {
const inserted = await db
.insert(mdtCalendarEvents)
.values({
title: input.title,
description: input.description,
category: input.category,
startAt: new Date(input.startAt),
endAt: input.endAt ? new Date(input.endAt) : null,
allDay: input.allDay,
location: input.location,
openSignup: input.openSignup,
organizerCitizenid: organizer.citizenid,
organizerName: organizer.name,
})
.$returningId();
const id = inserted[0]!.id;
const invitees = dedupeInvitees(input.invitees);
if (invitees.length > 0) {
await db.insert(mdtCalendarAttendees).values(
invitees.map((i) => ({ eventId: id, citizenid: i.citizenid, name: i.name, status: 'invited' as const, self: false })),
);
}
const event = await getEvent(id);
if (!event) throw new Error('Termin konnte nicht geladen werden');
return event;
}
function dedupeInvitees(invitees: Invitee[]): Invitee[] {
const seen = new Set<string>();
return invitees.filter((i) => (seen.has(i.citizenid) ? false : (seen.add(i.citizenid), true)));
}
export async function updateEvent(id: number, input: UpdateEventInput): Promise<CalendarEvent | null> {
const [existing] = await db.select({ id: mdtCalendarEvents.id }).from(mdtCalendarEvents).where(eq(mdtCalendarEvents.id, id)).limit(1);
if (!existing) return null;
const patch: Partial<EventRow> = {};
if (input.title !== undefined) patch.title = input.title;
if (input.description !== undefined) patch.description = input.description;
if (input.category !== undefined) patch.category = input.category;
if (input.startAt !== undefined) patch.startAt = new Date(input.startAt);
if (input.endAt !== undefined) patch.endAt = input.endAt ? new Date(input.endAt) : null;
if (input.allDay !== undefined) patch.allDay = input.allDay;
if (input.location !== undefined) patch.location = input.location;
if (input.openSignup !== undefined) patch.openSignup = input.openSignup;
if (Object.keys(patch).length > 0) {
await db.update(mdtCalendarEvents).set(patch).where(eq(mdtCalendarEvents.id, id));
}
return getEvent(id);
}
export async function deleteEvent(id: number): Promise<boolean> {
const [existing] = await db.select({ id: mdtCalendarEvents.id }).from(mdtCalendarEvents).where(eq(mdtCalendarEvents.id, id)).limit(1);
if (!existing) return false;
await db.delete(mdtCalendarAttendees).where(eq(mdtCalendarAttendees.eventId, id));
await db.delete(mdtCalendarEvents).where(eq(mdtCalendarEvents.id, id));
return true;
}
/** Selbst eintragen / RSVP setzen (upsert der eigenen Teilnahme). */
export async function setRsvp(
eventId: number,
attendee: { citizenid: string; name: string },
status: AttendeeStatus,
): Promise<CalendarEvent | null> {
const [event] = await db.select({ id: mdtCalendarEvents.id }).from(mdtCalendarEvents).where(eq(mdtCalendarEvents.id, eventId)).limit(1);
if (!event) return null;
await db
.insert(mdtCalendarAttendees)
.values({ eventId, citizenid: attendee.citizenid, name: attendee.name, status, self: true })
.onDuplicateKeyUpdate({ set: { status } });
return getEvent(eventId);
}
/** Eigene Teilnahme entfernen (austragen). */
export async function removeSelf(eventId: number, citizenid: string): Promise<CalendarEvent | null> {
await db
.delete(mdtCalendarAttendees)
.where(and(eq(mdtCalendarAttendees.eventId, eventId), eq(mdtCalendarAttendees.citizenid, citizenid)));
return getEvent(eventId);
}
/** Organisator lädt eine Person ein. */
export async function inviteAttendee(eventId: number, invitee: Invitee): Promise<CalendarEvent | null> {
const [event] = await db.select({ id: mdtCalendarEvents.id }).from(mdtCalendarEvents).where(eq(mdtCalendarEvents.id, eventId)).limit(1);
if (!event) return null;
await db
.insert(mdtCalendarAttendees)
.values({ eventId, citizenid: invitee.citizenid, name: invitee.name, status: 'invited', self: false })
.onDuplicateKeyUpdate({ set: { name: invitee.name } });
return getEvent(eventId);
}
export async function removeAttendee(eventId: number, citizenid: string): Promise<CalendarEvent | null> {
await db
.delete(mdtCalendarAttendees)
.where(and(eq(mdtCalendarAttendees.eventId, eventId), eq(mdtCalendarAttendees.citizenid, citizenid)));
return getEvent(eventId);
}
@@ -0,0 +1,42 @@
import { describe, it, expect } from 'vitest';
import { computeCharges } from '../cases.logic';
import type { ChargeCatalogItem } from '@d4rk-tablet/shared';
const catalog: ChargeCatalogItem[] = [
{ id: 1, code: '§ 242', title: 'Diebstahl', category: 'eigentum', fine: 500, jailTime: 5, points: 2, active: true },
{ id: 2, code: '§ 223', title: 'Körperverletzung', category: 'gewalt', fine: 1000, jailTime: 15, points: 4, active: true },
];
describe('computeCharges', () => {
it('summiert Bußgeld und Haftzeit über mehrere Anklagen', () => {
const r = computeCharges([{ catalogId: 1, count: 1 }, { catalogId: 2, count: 1 }], catalog);
expect(r.totalFine).toBe(1500);
expect(r.totalJailTime).toBe(20);
expect(r.charges).toHaveLength(2);
});
it('multipliziert mit count', () => {
const r = computeCharges([{ catalogId: 1, count: 3 }], catalog);
expect(r.totalFine).toBe(1500);
expect(r.totalJailTime).toBe(15);
expect(r.charges[0]!.count).toBe(3);
expect(r.charges[0]!.points).toBe(6);
});
it('erzwingt count >= 1', () => {
const r = computeCharges([{ catalogId: 1, count: 0 }], catalog);
expect(r.charges[0]!.count).toBe(1);
expect(r.totalFine).toBe(500);
});
it('ignoriert unbekannte Katalog-IDs', () => {
const r = computeCharges([{ catalogId: 999, count: 1 }], catalog);
expect(r.charges).toHaveLength(0);
expect(r.totalFine).toBe(0);
});
it('snapshotet code/title in die Charge', () => {
const r = computeCharges([{ catalogId: 2, count: 1 }], catalog);
expect(r.charges[0]).toMatchObject({ code: '§ 223', title: 'Körperverletzung' });
});
});
@@ -0,0 +1,47 @@
import type { CaseCharge, ChargeCatalogItem } from '@d4rk-tablet/shared';
export interface ChargeSelection {
catalogId: number;
count: number;
}
export interface ComputedCharges {
charges: CaseCharge[];
totalFine: number;
totalJailTime: number;
}
/**
* Rechnet ausgewählte Anklagen gegen den Strafenkatalog hoch (Bußgeld/Haft × Anzahl).
* Reine Funktion → unit-testbar. Ignoriert Selektionen ohne Katalog-Eintrag.
*/
export function computeCharges(
selections: ChargeSelection[],
catalog: ChargeCatalogItem[],
): ComputedCharges {
const byId = new Map(catalog.map((c) => [c.id, c]));
const charges: CaseCharge[] = [];
let totalFine = 0;
let totalJailTime = 0;
for (const sel of selections) {
const item = byId.get(sel.catalogId);
if (!item) continue;
const count = Math.max(1, sel.count);
const fine = item.fine * count;
const jailTime = item.jailTime * count;
charges.push({
catalogId: item.id,
code: item.code,
title: item.title,
count,
fine,
jailTime,
points: item.points * count,
});
totalFine += fine;
totalJailTime += jailTime;
}
return { charges, totalFine, totalJailTime };
}
@@ -0,0 +1,95 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import {
createCaseSchema,
updateCaseSchema,
createWarrantSchema,
updateWarrantSchema,
} from '@d4rk-tablet/shared';
import {
listCatalog,
listCasesForPerson,
getCase,
createCase,
updateCase,
listWarrantsForPerson,
createWarrant,
updateWarrant,
} from './cases.service';
const citizenidQuery = z.object({ citizenid: z.string().min(1) });
export async function casesRoutes(app: FastifyInstance): Promise<void> {
// ── Strafenkatalog ──
app.get('/charges/catalog', { preHandler: app.requirePermission('mdt.cases.view') }, async () => {
return listCatalog();
});
// ── Akten ──
app.get('/cases', { preHandler: app.requirePermission('mdt.cases.view') }, async (req, reply) => {
const parsed = citizenidQuery.safeParse(req.query);
if (!parsed.success) return reply.status(400).send({ message: 'citizenid erforderlich' });
return listCasesForPerson(parsed.data.citizenid);
});
app.get<{ Params: { id: string } }>(
'/cases/:id',
{ preHandler: app.requirePermission('mdt.cases.view') },
async (req, reply) => {
const c = await getCase(Number(req.params.id));
if (!c) return reply.status(404).send({ message: 'Akte nicht gefunden' });
return c;
},
);
app.post('/cases', { preHandler: app.requirePermission('mdt.cases.create') }, async (req, reply) => {
const parsed = createCaseSchema.safeParse(req.body);
if (!parsed.success) {
return reply.status(400).send({ message: 'Ungültiger Body', issues: parsed.error.issues });
}
return createCase(parsed.data, req.user.citizenid);
});
app.patch<{ Params: { id: string } }>(
'/cases/:id',
{ preHandler: app.requirePermission('mdt.cases.edit') },
async (req, reply) => {
const parsed = updateCaseSchema.safeParse(req.body);
if (!parsed.success) {
return reply.status(400).send({ message: 'Ungültiger Body', issues: parsed.error.issues });
}
const c = await updateCase(Number(req.params.id), parsed.data);
if (!c) return reply.status(404).send({ message: 'Akte nicht gefunden' });
return c;
},
);
// ── Haftbefehle ──
app.get('/warrants', { preHandler: app.requirePermission('mdt.warrants.view') }, async (req, reply) => {
const parsed = citizenidQuery.safeParse(req.query);
if (!parsed.success) return reply.status(400).send({ message: 'citizenid erforderlich' });
return listWarrantsForPerson(parsed.data.citizenid);
});
app.post('/warrants', { preHandler: app.requirePermission('mdt.warrants.create') }, async (req, reply) => {
const parsed = createWarrantSchema.safeParse(req.body);
if (!parsed.success) {
return reply.status(400).send({ message: 'Ungültiger Body', issues: parsed.error.issues });
}
return createWarrant(parsed.data, req.user.citizenid);
});
app.patch<{ Params: { id: string } }>(
'/warrants/:id',
{ preHandler: app.requirePermission('mdt.warrants.revoke') },
async (req, reply) => {
const parsed = updateWarrantSchema.safeParse(req.body);
if (!parsed.success) {
return reply.status(400).send({ message: 'Ungültiger Body', issues: parsed.error.issues });
}
const w = await updateWarrant(Number(req.params.id), parsed.data);
if (!w) return reply.status(404).send({ message: 'Haftbefehl nicht gefunden' });
return w;
},
);
}
@@ -0,0 +1,255 @@
import { and, desc, eq, inArray } from 'drizzle-orm';
import type {
Case,
CaseCharge,
ChargeCatalogItem,
CreateCaseInput,
UpdateCaseInput,
Warrant,
CreateWarrantInput,
UpdateWarrantInput,
} from '@d4rk-tablet/shared';
import { db } from '../../db/client';
import { mdtCases, mdtCaseCharges, mdtChargesCatalog, mdtWarrants } from '../../db/schema';
import { resolvePlayerNames } from '../../utils/names';
import { computeCharges, type ChargeSelection } from './cases.logic';
// ── Strafenkatalog ──
export async function listCatalog(): Promise<ChargeCatalogItem[]> {
const rows = await db.select().from(mdtChargesCatalog).where(eq(mdtChargesCatalog.active, true));
return rows.map((r) => ({
id: r.id,
code: r.code,
title: r.title,
category: r.category,
fine: r.fine,
jailTime: r.jailTime,
points: r.points,
active: r.active,
}));
}
async function catalogForIds(ids: number[]): Promise<ChargeCatalogItem[]> {
if (ids.length === 0) return [];
const rows = await db.select().from(mdtChargesCatalog).where(inArray(mdtChargesCatalog.id, ids));
return rows.map((r) => ({
id: r.id,
code: r.code,
title: r.title,
category: r.category,
fine: r.fine,
jailTime: r.jailTime,
points: r.points,
active: r.active,
}));
}
// ── Charges einer Akte (Join auf Katalog für code/title) ──
async function chargesForCases(caseIds: number[]): Promise<Map<number, CaseCharge[]>> {
const map = new Map<number, CaseCharge[]>();
if (caseIds.length === 0) return map;
const rows = await db
.select({
caseId: mdtCaseCharges.caseId,
catalogId: mdtCaseCharges.catalogId,
count: mdtCaseCharges.count,
fine: mdtCaseCharges.fine,
jailTime: mdtCaseCharges.jailTime,
points: mdtCaseCharges.points,
code: mdtChargesCatalog.code,
title: mdtChargesCatalog.title,
})
.from(mdtCaseCharges)
.leftJoin(mdtChargesCatalog, eq(mdtChargesCatalog.id, mdtCaseCharges.catalogId))
.where(inArray(mdtCaseCharges.caseId, caseIds));
for (const r of rows) {
const list = map.get(r.caseId) ?? [];
list.push({
catalogId: r.catalogId,
code: r.code ?? '—',
title: r.title ?? 'Gelöschte Anklage',
count: r.count,
fine: r.fine,
jailTime: r.jailTime,
points: r.points,
});
map.set(r.caseId, list);
}
return map;
}
type CaseRow = typeof mdtCases.$inferSelect;
function mapCase(row: CaseRow, charges: CaseCharge[], names: Map<string, string | null>): Case {
return {
id: row.id,
title: row.title,
suspectCitizenid: row.suspectCitizenid,
suspectName: names.get(row.suspectCitizenid) ?? null,
status: row.status,
charges,
totalFine: row.totalFine,
totalJailTime: row.totalJailTime,
narrative: row.narrative ?? '',
officerCitizenid: row.officerCitizenid,
officerName: row.officerCitizenid ? (names.get(row.officerCitizenid) ?? null) : null,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt ? row.updatedAt.toISOString() : null,
};
}
export async function getCase(id: number): Promise<Case | null> {
const [row] = await db.select().from(mdtCases).where(eq(mdtCases.id, id)).limit(1);
if (!row) return null;
const charges = (await chargesForCases([id])).get(id) ?? [];
const names = await resolvePlayerNames([row.suspectCitizenid, row.officerCitizenid]);
return mapCase(row, charges, names);
}
export async function listCasesForPerson(citizenid: string): Promise<Case[]> {
const rows = await db
.select()
.from(mdtCases)
.where(eq(mdtCases.suspectCitizenid, citizenid))
.orderBy(desc(mdtCases.createdAt));
if (rows.length === 0) return [];
const chargesMap = await chargesForCases(rows.map((r) => r.id));
const names = await resolvePlayerNames([citizenid, ...rows.map((r) => r.officerCitizenid)]);
return rows.map((r) => mapCase(r, chargesMap.get(r.id) ?? [], names));
}
export async function createCase(input: CreateCaseInput, officer: string | null): Promise<Case> {
const catalog = await catalogForIds(input.charges.map((c) => c.catalogId));
const computed = computeCharges(input.charges as ChargeSelection[], catalog);
const inserted = await db
.insert(mdtCases)
.values({
title: input.title,
suspectCitizenid: input.suspectCitizenid,
narrative: input.narrative,
totalFine: computed.totalFine,
totalJailTime: computed.totalJailTime,
officerCitizenid: officer,
})
.$returningId();
const id = inserted[0]!.id;
if (computed.charges.length > 0) {
await db.insert(mdtCaseCharges).values(
computed.charges.map((c) => ({
caseId: id,
catalogId: c.catalogId,
count: c.count,
fine: c.fine,
jailTime: c.jailTime,
points: c.points,
})),
);
}
const created = await getCase(id);
if (!created) throw new Error('Case konnte nicht geladen werden');
return created;
}
export async function updateCase(id: number, input: UpdateCaseInput): Promise<Case | null> {
const [existing] = await db.select().from(mdtCases).where(eq(mdtCases.id, id)).limit(1);
if (!existing) return null;
const patch: Partial<CaseRow> = {};
if (input.title !== undefined) patch.title = input.title;
if (input.status !== undefined) patch.status = input.status;
if (input.narrative !== undefined) patch.narrative = input.narrative;
if (input.charges !== undefined) {
const catalog = await catalogForIds(input.charges.map((c) => c.catalogId));
const computed = computeCharges(input.charges as ChargeSelection[], catalog);
patch.totalFine = computed.totalFine;
patch.totalJailTime = computed.totalJailTime;
await db.delete(mdtCaseCharges).where(eq(mdtCaseCharges.caseId, id));
if (computed.charges.length > 0) {
await db.insert(mdtCaseCharges).values(
computed.charges.map((c) => ({
caseId: id,
catalogId: c.catalogId,
count: c.count,
fine: c.fine,
jailTime: c.jailTime,
points: c.points,
})),
);
}
}
if (Object.keys(patch).length > 0) {
await db.update(mdtCases).set(patch).where(eq(mdtCases.id, id));
}
return getCase(id);
}
// ── Haftbefehle ──
type WarrantRow = typeof mdtWarrants.$inferSelect;
function mapWarrant(row: WarrantRow, names: Map<string, string | null>): Warrant {
return {
id: row.id,
citizenid: row.citizenid,
subjectName: names.get(row.citizenid) ?? null,
reason: row.reason,
status: row.status,
caseId: row.caseId,
issuedByCitizenid: row.issuedByCitizenid,
issuedByName: row.issuedByCitizenid ? (names.get(row.issuedByCitizenid) ?? null) : null,
issuedAt: row.issuedAt.toISOString(),
expiresAt: row.expiresAt ? row.expiresAt.toISOString() : null,
};
}
export async function listWarrantsForPerson(citizenid: string): Promise<Warrant[]> {
const rows = await db
.select()
.from(mdtWarrants)
.where(eq(mdtWarrants.citizenid, citizenid))
.orderBy(desc(mdtWarrants.issuedAt));
const names = await resolvePlayerNames([citizenid, ...rows.map((r) => r.issuedByCitizenid)]);
return rows.map((r) => mapWarrant(r, names));
}
export async function createWarrant(
input: CreateWarrantInput,
issuedBy: string | null,
): Promise<Warrant> {
const inserted = await db
.insert(mdtWarrants)
.values({
citizenid: input.citizenid,
reason: input.reason,
caseId: input.caseId,
issuedByCitizenid: issuedBy,
expiresAt: input.expiresAt ? new Date(input.expiresAt) : null,
})
.$returningId();
const id = inserted[0]!.id;
const [row] = await db.select().from(mdtWarrants).where(eq(mdtWarrants.id, id)).limit(1);
const names = await resolvePlayerNames([row!.citizenid, row!.issuedByCitizenid]);
return mapWarrant(row!, names);
}
export async function updateWarrant(id: number, input: UpdateWarrantInput): Promise<Warrant | null> {
const patch: Partial<WarrantRow> = {};
if (input.status !== undefined) patch.status = input.status;
if (input.reason !== undefined) patch.reason = input.reason;
if (Object.keys(patch).length > 0) {
await db.update(mdtWarrants).set(patch).where(eq(mdtWarrants.id, id));
}
const [row] = await db.select().from(mdtWarrants).where(eq(mdtWarrants.id, id)).limit(1);
if (!row) return null;
const names = await resolvePlayerNames([row.citizenid, row.issuedByCitizenid]);
return mapWarrant(row, names);
}
@@ -0,0 +1,59 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import {
createDispatchCallSchema,
assignDispatchSchema,
dispatchStatus,
DEPARTMENTS,
} from '@d4rk-tablet/shared';
import { listCalls, createCall, assignCall, setCallStatus } from './dispatch.service';
import { officerSnapshot } from '../../realtime/officers';
const deptQuery = z.object({ department: z.enum(DEPARTMENTS).optional() });
export async function dispatchRoutes(app: FastifyInstance): Promise<void> {
app.get('/dispatch/calls', { preHandler: app.requirePermission('cad.dispatch.view') }, async (req) => {
const { department } = deptQuery.parse(req.query);
return listCalls(department);
});
// Aktuelle Officer-Positionen (Live-Snapshot, für initiales Map-Rendering)
app.get('/dispatch/officers', { preHandler: app.requirePermission('cad.dispatch.view') }, async (req) => {
const { department } = deptQuery.parse(req.query);
return officerSnapshot(department);
});
app.post('/dispatch/calls', { preHandler: app.requirePermission('cad.dispatch.manage') }, async (req, reply) => {
const parsed = createDispatchCallSchema.safeParse(req.body);
if (!parsed.success) {
return reply.status(400).send({ message: 'Ungültiger Body', issues: parsed.error.issues });
}
return createCall(parsed.data);
});
app.patch<{ Params: { id: string } }>(
'/dispatch/calls/:id/assign',
{ preHandler: app.requirePermission('cad.dispatch.manage') },
async (req, reply) => {
const parsed = assignDispatchSchema.safeParse(req.body);
if (!parsed.success) {
return reply.status(400).send({ message: 'Ungültiger Body', issues: parsed.error.issues });
}
const call = await assignCall(Number(req.params.id), parsed.data);
if (!call) return reply.status(404).send({ message: 'Einsatz nicht gefunden' });
return call;
},
);
app.patch<{ Params: { id: string } }>(
'/dispatch/calls/:id/status',
{ preHandler: app.requirePermission('cad.dispatch.manage') },
async (req, reply) => {
const parsed = z.object({ status: dispatchStatus }).safeParse(req.body);
if (!parsed.success) return reply.status(400).send({ message: 'Ungültiger Status' });
const call = await setCallStatus(Number(req.params.id), parsed.data.status);
if (!call) return reply.status(404).send({ message: 'Einsatz nicht gefunden' });
return call;
},
);
}
@@ -0,0 +1,94 @@
import { and, desc, eq, ne } from 'drizzle-orm';
import type {
DispatchCall,
CreateDispatchCallInput,
AssignDispatchInput,
DispatchStatus,
Department,
Coords,
} from '@d4rk-tablet/shared';
import { db } from '../../db/client';
import { mdtDispatchCalls } from '../../db/schema';
import { broadcastDispatchCreated, broadcastDispatchUpdated } from '../../realtime/socket';
type CallRow = typeof mdtDispatchCalls.$inferSelect;
function parseCoords(row: CallRow): Coords | null {
if (row.coordsX == null || row.coordsY == null || row.coordsZ == null) return null;
return { x: Number(row.coordsX), y: Number(row.coordsY), z: Number(row.coordsZ) };
}
function mapCall(row: CallRow): DispatchCall {
return {
id: row.id,
code: row.code,
title: row.title,
description: row.description ?? '',
department: row.department,
priority: row.priority,
status: row.status,
location: row.location,
coords: parseCoords(row),
callerCitizenid: row.callerCitizenid,
assignedOfficers: row.assignedOfficers,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt ? row.updatedAt.toISOString() : null,
};
}
/** Aktive (nicht geschlossene) Einsätze, optional nach Department gefiltert. */
export async function listCalls(department?: Department): Promise<DispatchCall[]> {
const where = department
? and(ne(mdtDispatchCalls.status, 'closed'), eq(mdtDispatchCalls.department, department))
: ne(mdtDispatchCalls.status, 'closed');
const rows = await db
.select()
.from(mdtDispatchCalls)
.where(where)
.orderBy(desc(mdtDispatchCalls.createdAt));
return rows.map(mapCall);
}
async function getCall(id: number): Promise<DispatchCall | null> {
const [row] = await db.select().from(mdtDispatchCalls).where(eq(mdtDispatchCalls.id, id)).limit(1);
return row ? mapCall(row) : null;
}
export async function createCall(input: CreateDispatchCallInput): Promise<DispatchCall> {
const inserted = await db
.insert(mdtDispatchCalls)
.values({
code: input.code,
title: input.title,
description: input.description,
department: input.department,
priority: input.priority,
location: input.location,
coordsX: input.coords ? String(input.coords.x) : null,
coordsY: input.coords ? String(input.coords.y) : null,
coordsZ: input.coords ? String(input.coords.z) : null,
callerCitizenid: input.callerCitizenid,
})
.$returningId();
const call = await getCall(inserted[0]!.id);
if (!call) throw new Error('Einsatz konnte nicht geladen werden');
broadcastDispatchCreated(call);
return call;
}
export async function assignCall(id: number, input: AssignDispatchInput): Promise<DispatchCall | null> {
const patch: Partial<CallRow> = { assignedOfficers: input.officers };
patch.status = input.status ?? 'assigned';
await db.update(mdtDispatchCalls).set(patch).where(eq(mdtDispatchCalls.id, id));
const call = await getCall(id);
if (call) broadcastDispatchUpdated(call);
return call;
}
export async function setCallStatus(id: number, status: DispatchStatus): Promise<DispatchCall | null> {
await db.update(mdtDispatchCalls).set({ status }).where(eq(mdtDispatchCalls.id, id));
const call = await getCall(id);
if (call) broadcastDispatchUpdated(call);
return call;
}
@@ -0,0 +1,102 @@
import type { FastifyInstance, FastifyRequest } from 'fastify';
import { z } from 'zod';
import { createDocumentSchema, updateDocumentSchema, createFolderSchema } from '@d4rk-tablet/shared';
import {
listFolders,
createFolder,
listDocuments,
getDocument,
getDocumentRaw,
createDocument,
updateDocument,
deleteDocument,
canWrite,
type Viewer,
} from './documents.service';
import { groupsForCitizen } from '../groups/groups.service';
const listQuery = z.object({ folderId: z.coerce.number().int().optional() });
async function viewerOf(req: FastifyRequest): Promise<Viewer> {
return {
citizenid: req.user.citizenid,
authorities: req.user.authorities,
groupIds: await groupsForCitizen(req.user.citizenid),
isAdmin: req.user.roles.includes('admin'),
};
}
const asAcl = (v: unknown) => (Array.isArray(v) ? v : []);
export async function documentsRoutes(app: FastifyInstance): Promise<void> {
// ── Ordner ──
app.get('/documents/folders', { preHandler: app.requirePermission('mdt.documents.view') }, async (req) => {
return listFolders(await viewerOf(req));
});
app.post('/documents/folders', { preHandler: app.requirePermission('mdt.documents.manage') }, async (req, reply) => {
const parsed = createFolderSchema.safeParse(req.body);
if (!parsed.success) return reply.status(400).send({ message: 'Ungültiger Name' });
return createFolder(parsed.data.name, parsed.data.public, parsed.data.acl);
});
// ── Dokumente ──
app.get('/documents', { preHandler: app.requirePermission('mdt.documents.view') }, async (req) => {
const { folderId } = listQuery.parse(req.query);
return listDocuments(await viewerOf(req), folderId ?? null);
});
app.get<{ Params: { id: string } }>(
'/documents/:id',
{ preHandler: app.requirePermission('mdt.documents.view') },
async (req, reply) => {
const doc = await getDocument(Number(req.params.id), await viewerOf(req));
if (!doc) return reply.status(404).send({ message: 'Dokument nicht gefunden' });
return doc;
},
);
app.post('/documents', { preHandler: app.requirePermission('mdt.documents.manage') }, async (req, reply) => {
const parsed = createDocumentSchema.safeParse(req.body);
if (!parsed.success) {
return reply.status(400).send({ message: 'Ungültiger Body', issues: parsed.error.issues });
}
return createDocument(parsed.data, {
citizenid: req.user.citizenid,
name: req.user.name,
authorities: req.user.authorities,
});
});
app.patch<{ Params: { id: string } }>(
'/documents/:id',
{ preHandler: app.requirePermission('mdt.documents.manage') },
async (req, reply) => {
const parsed = updateDocumentSchema.safeParse(req.body);
if (!parsed.success) return reply.status(400).send({ message: 'Ungültiger Body' });
// Per-Dokument-Schreibrecht prüfen (nicht nur globales documents.manage)
const raw = await getDocumentRaw(Number(req.params.id));
if (!raw) return reply.status(404).send({ message: 'Dokument nicht gefunden' });
if (!canWrite(asAcl(raw.acl), await viewerOf(req))) {
return reply.status(403).send({ message: 'Keine Schreibberechtigung für dieses Dokument' });
}
const doc = await updateDocument(Number(req.params.id), parsed.data);
return doc;
},
);
app.delete<{ Params: { id: string } }>(
'/documents/:id',
{ preHandler: app.requirePermission('mdt.documents.manage') },
async (req, reply) => {
const raw = await getDocumentRaw(Number(req.params.id));
if (raw && !canWrite(asAcl(raw.acl), await viewerOf(req))) {
return reply.status(403).send({ message: 'Keine Schreibberechtigung für dieses Dokument' });
}
await deleteDocument(Number(req.params.id));
return { ok: true };
},
);
}
@@ -0,0 +1,169 @@
import { count, desc, eq, like } from 'drizzle-orm';
import type {
DocFolder,
DocumentSummary,
MdtDocument,
CreateDocumentInput,
UpdateDocumentInput,
AclEntry,
} from '@d4rk-tablet/shared';
import { db } from '../../db/client';
import { mdtDocFolders, mdtDocuments } from '../../db/schema';
/** Betrachter-Kontext fürs ACL-Matching. */
export interface Viewer {
citizenid: string | null;
authorities: string[];
groupIds: number[];
isAdmin: boolean;
}
function matchesSubject(entry: AclEntry, viewer: Viewer): boolean {
switch (entry.type) {
case 'authority':
return viewer.authorities.includes(entry.id);
case 'group':
return viewer.groupIds.includes(Number(entry.id));
case 'person':
return viewer.citizenid === entry.id;
default:
return false;
}
}
const asAcl = (v: unknown): AclEntry[] => (Array.isArray(v) ? (v as AclEntry[]) : []);
export function canWrite(acl: AclEntry[], viewer: Viewer): boolean {
if (viewer.isAdmin) return true;
return acl.some((e) => e.level === 'write' && matchesSubject(e, viewer));
}
export function canRead(isPublic: boolean, acl: AclEntry[], viewer: Viewer): boolean {
if (viewer.isAdmin || isPublic) return true;
return acl.some((e) => matchesSubject(e, viewer));
}
// ── Ordner ──
export async function listFolders(viewer: Viewer): Promise<DocFolder[]> {
const folders = await db.select().from(mdtDocFolders).orderBy(mdtDocFolders.name);
const counts = await db
.select({ folderId: mdtDocuments.folderId, c: count() })
.from(mdtDocuments)
.groupBy(mdtDocuments.folderId);
const byFolder = new Map(counts.map((c) => [c.folderId, c.c]));
return folders
.filter((f) => canRead(f.public, asAcl(f.acl), viewer))
.map((f) => ({
id: f.id,
name: f.name,
public: f.public,
acl: asAcl(f.acl),
docCount: byFolder.get(f.id) ?? 0,
}));
}
export async function createFolder(name: string, isPublic: boolean, acl: AclEntry[]): Promise<DocFolder> {
const inserted = await db.insert(mdtDocFolders).values({ name, public: isPublic, acl }).$returningId();
return { id: inserted[0]!.id, name, public: isPublic, acl, docCount: 0 };
}
// ── Dokumente ──
type Row = typeof mdtDocuments.$inferSelect;
function summary(row: Row, viewer: Viewer): DocumentSummary {
const acl = asAcl(row.acl);
return {
id: row.id,
reference: row.reference,
title: row.title,
folderId: row.folderId,
public: row.public,
acl,
pinned: row.pinned,
canWrite: canWrite(acl, viewer),
authorName: row.authorName,
updatedAt: row.updatedAt.toISOString(),
};
}
export async function listDocuments(viewer: Viewer, folderId?: number | null): Promise<DocumentSummary[]> {
const rows = await db
.select()
.from(mdtDocuments)
.where(folderId != null ? eq(mdtDocuments.folderId, folderId) : undefined)
.orderBy(desc(mdtDocuments.pinned), desc(mdtDocuments.updatedAt));
return rows.filter((r) => canRead(r.public, asAcl(r.acl), viewer)).map((r) => summary(r, viewer));
}
export async function getDocument(id: number, viewer: Viewer): Promise<MdtDocument | null> {
const [row] = await db.select().from(mdtDocuments).where(eq(mdtDocuments.id, id)).limit(1);
if (!row || !canRead(row.public, asAcl(row.acl), viewer)) return null;
return { ...summary(row, viewer), content: row.content ?? '', createdAt: row.createdAt.toISOString() };
}
/** Rohes Dokument (für Schreib-Prüfung, ohne Sichtbarkeitsfilter). */
export async function getDocumentRaw(id: number): Promise<Row | null> {
const [row] = await db.select().from(mdtDocuments).where(eq(mdtDocuments.id, id)).limit(1);
return row ?? null;
}
/** Aktenzeichen DO-JJJJ-MM-TT-### (fortlaufend pro Tag). */
async function nextReference(): Promise<string> {
const now = new Date();
const datePart = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(
now.getDate(),
).padStart(2, '0')}`;
const prefix = `DO-${datePart}-`;
const [row] = await db
.select({ c: count() })
.from(mdtDocuments)
.where(like(mdtDocuments.reference, `${prefix}%`));
return prefix + String((row?.c ?? 0) + 1).padStart(3, '0');
}
export async function createDocument(
input: CreateDocumentInput,
author: { citizenid: string | null; name: string; authorities: string[] },
): Promise<MdtDocument> {
const reference = await nextReference();
// Ersteller bekommt immer Schreibrecht (Person + eigene Behörden)
const acl: AclEntry[] = [...input.acl];
if (author.citizenid && !acl.some((e) => e.type === 'person' && e.id === author.citizenid)) {
acl.push({ type: 'person', id: author.citizenid, label: author.name, level: 'write' });
}
const inserted = await db
.insert(mdtDocuments)
.values({
reference,
title: input.title,
content: input.content,
folderId: input.folderId,
public: input.public,
acl,
authorCitizenid: author.citizenid,
authorName: author.name,
})
.$returningId();
const doc = await getDocument(inserted[0]!.id, { citizenid: null, authorities: [], groupIds: [], isAdmin: true });
if (!doc) throw new Error('Dokument konnte nicht geladen werden');
return doc;
}
export async function updateDocument(id: number, input: UpdateDocumentInput): Promise<MdtDocument | null> {
const patch: Partial<Row> = {};
if (input.title !== undefined) patch.title = input.title;
if (input.content !== undefined) patch.content = input.content;
if (input.folderId !== undefined) patch.folderId = input.folderId;
if (input.public !== undefined) patch.public = input.public;
if (input.acl !== undefined) patch.acl = input.acl;
if (input.pinned !== undefined) patch.pinned = input.pinned;
if (Object.keys(patch).length > 0) {
await db.update(mdtDocuments).set(patch).where(eq(mdtDocuments.id, id));
}
return getDocument(id, { citizenid: null, authorities: [], groupIds: [], isAdmin: true });
}
export async function deleteDocument(id: number): Promise<void> {
await db.delete(mdtDocuments).where(eq(mdtDocuments.id, id));
}
@@ -0,0 +1,75 @@
import type { FastifyInstance } from 'fastify';
import { createGroupSchema, updateGroupSchema, addGroupMemberSchema } from '@d4rk-tablet/shared';
import {
listGroups,
getGroup,
createGroup,
updateGroup,
deleteGroup,
addMember,
removeMember,
} from './groups.service';
export async function groupsRoutes(app: FastifyInstance): Promise<void> {
// Lesen: jeder eingeloggte User (für Freigabe-Auswahl)
app.get('/groups', { preHandler: app.authenticate }, async () => listGroups());
app.get<{ Params: { id: string } }>(
'/groups/:id',
{ preHandler: app.authenticate },
async (req, reply) => {
const g = await getGroup(Number(req.params.id));
if (!g) return reply.status(404).send({ message: 'Gruppe nicht gefunden' });
return g;
},
);
app.post('/groups', { preHandler: app.requirePermission('admin.users.manage') }, async (req, reply) => {
const parsed = createGroupSchema.safeParse(req.body);
if (!parsed.success) return reply.status(400).send({ message: 'Ungültige Gruppe' });
return createGroup(parsed.data);
});
app.patch<{ Params: { id: string } }>(
'/groups/:id',
{ preHandler: app.requirePermission('admin.users.manage') },
async (req, reply) => {
const parsed = updateGroupSchema.safeParse(req.body);
if (!parsed.success) return reply.status(400).send({ message: 'Ungültiger Body' });
const g = await updateGroup(Number(req.params.id), parsed.data);
if (!g) return reply.status(404).send({ message: 'Gruppe nicht gefunden' });
return g;
},
);
app.delete<{ Params: { id: string } }>(
'/groups/:id',
{ preHandler: app.requirePermission('admin.users.manage') },
async (req) => {
await deleteGroup(Number(req.params.id));
return { ok: true };
},
);
app.post<{ Params: { id: string } }>(
'/groups/:id/members',
{ preHandler: app.requirePermission('admin.users.manage') },
async (req, reply) => {
const parsed = addGroupMemberSchema.safeParse(req.body);
if (!parsed.success) return reply.status(400).send({ message: 'Ungültiges Mitglied' });
const g = await addMember(Number(req.params.id), parsed.data);
if (!g) return reply.status(404).send({ message: 'Gruppe nicht gefunden' });
return g;
},
);
app.delete<{ Params: { id: string; citizenid: string } }>(
'/groups/:id/members/:citizenid',
{ preHandler: app.requirePermission('admin.users.manage') },
async (req, reply) => {
const g = await removeMember(Number(req.params.id), req.params.citizenid);
if (!g) return reply.status(404).send({ message: 'Gruppe nicht gefunden' });
return g;
},
);
}
@@ -0,0 +1,90 @@
import { eq, count, inArray } from 'drizzle-orm';
import type {
Group,
GroupDetail,
CreateGroupInput,
UpdateGroupInput,
AddGroupMemberInput,
} from '@d4rk-tablet/shared';
import { db } from '../../db/client';
import { mdtGroups, mdtGroupMembers } from '../../db/schema';
type GroupRow = typeof mdtGroups.$inferSelect;
export async function listGroups(): Promise<Group[]> {
const groups = await db.select().from(mdtGroups).orderBy(mdtGroups.name);
const counts = await db
.select({ groupId: mdtGroupMembers.groupId, c: count() })
.from(mdtGroupMembers)
.groupBy(mdtGroupMembers.groupId);
const byGroup = new Map(counts.map((c) => [c.groupId, c.c]));
return groups.map((g) => ({ id: g.id, name: g.name, color: g.color, memberCount: byGroup.get(g.id) ?? 0 }));
}
export async function getGroup(id: number): Promise<GroupDetail | null> {
const [g] = await db.select().from(mdtGroups).where(eq(mdtGroups.id, id)).limit(1);
if (!g) return null;
const members = await db
.select({ citizenid: mdtGroupMembers.citizenid, name: mdtGroupMembers.name })
.from(mdtGroupMembers)
.where(eq(mdtGroupMembers.groupId, id));
return { id: g.id, name: g.name, color: g.color, memberCount: members.length, members };
}
export async function createGroup(input: CreateGroupInput): Promise<Group> {
const inserted = await db.insert(mdtGroups).values(input).$returningId();
return { id: inserted[0]!.id, name: input.name, color: input.color, memberCount: 0 };
}
export async function updateGroup(id: number, input: UpdateGroupInput): Promise<Group | null> {
const patch: Partial<GroupRow> = {};
if (input.name !== undefined) patch.name = input.name;
if (input.color !== undefined) patch.color = input.color;
if (Object.keys(patch).length > 0) {
await db.update(mdtGroups).set(patch).where(eq(mdtGroups.id, id));
}
const [g] = await db.select().from(mdtGroups).where(eq(mdtGroups.id, id)).limit(1);
if (!g) return null;
const [c] = await db.select({ c: count() }).from(mdtGroupMembers).where(eq(mdtGroupMembers.groupId, id));
return { id: g.id, name: g.name, color: g.color, memberCount: c?.c ?? 0 };
}
export async function deleteGroup(id: number): Promise<void> {
await db.delete(mdtGroupMembers).where(eq(mdtGroupMembers.groupId, id));
await db.delete(mdtGroups).where(eq(mdtGroups.id, id));
}
export async function addMember(groupId: number, input: AddGroupMemberInput): Promise<GroupDetail | null> {
const existing = await db
.select()
.from(mdtGroupMembers)
.where(eq(mdtGroupMembers.groupId, groupId));
if (!existing.some((m) => m.citizenid === input.citizenid)) {
await db.insert(mdtGroupMembers).values({ groupId, citizenid: input.citizenid, name: input.name });
}
return getGroup(groupId);
}
export async function removeMember(groupId: number, citizenid: string): Promise<GroupDetail | null> {
const rows = await db.select().from(mdtGroupMembers).where(eq(mdtGroupMembers.groupId, groupId));
const target = rows.find((r) => r.citizenid === citizenid);
if (target) await db.delete(mdtGroupMembers).where(eq(mdtGroupMembers.id, target.id));
return getGroup(groupId);
}
/** Gruppen-IDs, denen ein Bürger angehört (für ACL-Matching). */
export async function groupsForCitizen(citizenid: string | null): Promise<number[]> {
if (!citizenid) return [];
const rows = await db
.select({ groupId: mdtGroupMembers.groupId })
.from(mdtGroupMembers)
.where(eq(mdtGroupMembers.citizenid, citizenid));
return [...new Set(rows.map((r) => r.groupId))];
}
/** Namen mehrerer Gruppen (für Anzeige). */
export async function groupNames(ids: number[]): Promise<Map<number, string>> {
if (ids.length === 0) return new Map();
const rows = await db.select().from(mdtGroups).where(inArray(mdtGroups.id, ids));
return new Map(rows.map((g) => [g.id, g.name]));
}
@@ -0,0 +1,21 @@
import type { FastifyInstance } from 'fastify';
import { pool } from '../../db/client';
export async function healthRoutes(app: FastifyInstance): Promise<void> {
app.get('/health', async () => {
return { status: 'ok', service: 'd4rk-tablet-api', ts: Date.now() };
});
// Prüft DB-Erreichbarkeit (QBox-MariaDB)
app.get('/health/db', async (_req, reply) => {
try {
const conn = await pool.getConnection();
await conn.ping();
conn.release();
return { status: 'ok', db: 'reachable' };
} catch (err) {
app.log.error(err);
return reply.status(503).send({ status: 'error', db: 'unreachable' });
}
});
}
@@ -0,0 +1,41 @@
import type { FastifyInstance } from 'fastify';
import { createLawSchema, updateLawSchema } from '@d4rk-tablet/shared';
import { listLaws, createLaw, updateLaw, deleteLaw } from './laws.service';
export async function lawsRoutes(app: FastifyInstance): Promise<void> {
app.get('/laws', { preHandler: app.requirePermission('mdt.laws.view') }, async () => {
return listLaws();
});
app.post('/laws', { preHandler: app.requirePermission('mdt.laws.manage') }, async (req, reply) => {
const parsed = createLawSchema.safeParse(req.body);
if (!parsed.success) {
return reply.status(400).send({ message: 'Ungültiger Body', issues: parsed.error.issues });
}
return reply.status(201).send(await createLaw(parsed.data));
});
app.patch<{ Params: { id: string } }>(
'/laws/:id',
{ preHandler: app.requirePermission('mdt.laws.manage') },
async (req, reply) => {
const parsed = updateLawSchema.safeParse(req.body);
if (!parsed.success) {
return reply.status(400).send({ message: 'Ungültiger Body', issues: parsed.error.issues });
}
const law = await updateLaw(Number(req.params.id), parsed.data);
if (!law) return reply.status(404).send({ message: 'Gesetz nicht gefunden' });
return law;
},
);
app.delete<{ Params: { id: string } }>(
'/laws/:id',
{ preHandler: app.requirePermission('mdt.laws.manage') },
async (req, reply) => {
const ok = await deleteLaw(Number(req.params.id));
if (!ok) return reply.status(404).send({ message: 'Gesetz nicht gefunden' });
return reply.status(204).send();
},
);
}
@@ -0,0 +1,70 @@
import { asc, eq } from 'drizzle-orm';
import type { CreateLawInput, Law, UpdateLawInput } from '@d4rk-tablet/shared';
import { db } from '../../db/client';
import { mdtLaws } from '../../db/schema';
type Row = typeof mdtLaws.$inferSelect;
function mapLaw(row: Row): Law {
return {
id: row.id,
category: row.category,
paragraph: row.paragraph,
title: row.title,
description: row.description ?? '',
fine: row.fine,
jailTime: row.jailTime,
sortOrder: row.sortOrder,
};
}
export async function listLaws(): Promise<Law[]> {
const rows = await db
.select()
.from(mdtLaws)
.orderBy(asc(mdtLaws.category), asc(mdtLaws.sortOrder), asc(mdtLaws.paragraph));
return rows.map(mapLaw);
}
export async function createLaw(input: CreateLawInput): Promise<Law> {
const inserted = await db
.insert(mdtLaws)
.values({
category: input.category,
paragraph: input.paragraph,
title: input.title,
description: input.description,
fine: input.fine,
jailTime: input.jailTime,
sortOrder: input.sortOrder,
})
.$returningId();
const [row] = await db.select().from(mdtLaws).where(eq(mdtLaws.id, inserted[0]!.id)).limit(1);
return mapLaw(row!);
}
export async function updateLaw(id: number, input: UpdateLawInput): Promise<Law | null> {
const [existing] = await db.select({ id: mdtLaws.id }).from(mdtLaws).where(eq(mdtLaws.id, id)).limit(1);
if (!existing) return null;
const patch: Partial<Row> = {};
if (input.category !== undefined) patch.category = input.category;
if (input.paragraph !== undefined) patch.paragraph = input.paragraph;
if (input.title !== undefined) patch.title = input.title;
if (input.description !== undefined) patch.description = input.description;
if (input.fine !== undefined) patch.fine = input.fine;
if (input.jailTime !== undefined) patch.jailTime = input.jailTime;
if (input.sortOrder !== undefined) patch.sortOrder = input.sortOrder;
if (Object.keys(patch).length > 0) {
await db.update(mdtLaws).set(patch).where(eq(mdtLaws.id, id));
}
const [row] = await db.select().from(mdtLaws).where(eq(mdtLaws.id, id)).limit(1);
return row ? mapLaw(row) : null;
}
export async function deleteLaw(id: number): Promise<boolean> {
const [existing] = await db.select({ id: mdtLaws.id }).from(mdtLaws).where(eq(mdtLaws.id, id)).limit(1);
if (!existing) return false;
await db.delete(mdtLaws).where(eq(mdtLaws.id, id));
return true;
}
@@ -0,0 +1,86 @@
import { describe, it, expect } from 'vitest';
import { mapToPersonSummary, mapToPerson, type CitizenRow } from '../persons.mapper';
const baseRow: CitizenRow = {
citizenid: 'LS-100001',
firstname: 'Max',
lastname: 'Mustermann',
dob: '1990-01-01',
gender: 'male',
phone: '555-1',
nationality: 'USA',
legalStatus: 'citizen',
address: 'Alta St 12',
occupation: 'Mechaniker',
height: '182 cm',
eyeColor: 'braun',
hairColor: 'schwarz',
distinguishingMarks: 'Narbe li. Wange',
aliases: ['Maxi'],
licenses: [{ type: 'driver', label: 'Führerschein', active: true }],
mugshotUrl: 'https://cdn.example/mug.png',
notes: 'Notiz',
flags: ['bewaffnet'],
isWanted: true,
public: true,
acl: [],
createdByName: 'Officer X',
updatedBy: 'LS-999',
createdAt: new Date('2026-01-01T10:00:00Z'),
updatedAt: new Date('2026-02-01T10:00:00Z'),
};
describe('persons.mapper', () => {
it('mappt die Kurzform inkl. mugshot + legalStatus', () => {
const s = mapToPersonSummary(baseRow);
expect(s).toMatchObject({
citizenid: 'LS-100001',
firstname: 'Max',
lastname: 'Mustermann',
dob: '1990-01-01',
phone: '555-1',
isWanted: true,
mugshotUrl: 'https://cdn.example/mug.png',
legalStatus: 'citizen',
public: true,
acl: [],
});
});
it('übernimmt Freigabe (public/acl) in die Kurzform', () => {
const acl = [{ type: 'authority' as const, id: 'lspd', label: 'LSPD', level: 'read' as const }];
const s = mapToPersonSummary({ ...baseRow, public: false, acl });
expect(s.public).toBe(false);
expect(s.acl).toEqual(acl);
});
it('mapToPerson übernimmt alle Akten-Felder + Zähler', () => {
const vehicles = [{ id: 5, plate: 'MAX 001', model: 'Sultan', plateStatus: 'registered' as const, isStolen: false }];
const p = mapToPerson(baseRow, { openWarrants: 2, totalCases: 5 }, vehicles);
expect(p.occupation).toBe('Mechaniker');
expect(p.aliases).toEqual(['Maxi']);
expect(p.licenses[0]?.label).toBe('Führerschein');
expect(p.flags).toEqual(['bewaffnet']);
expect(p.vehicles).toHaveLength(1);
expect(p.openWarrants).toBe(2);
expect(p.totalCases).toBe(5);
expect(p.createdAt).toBe('2026-01-01T10:00:00.000Z');
});
it('leere/null Felder werden zu sauberen Defaults', () => {
const row: CitizenRow = {
...baseRow,
distinguishingMarks: null,
notes: null,
nationality: null,
createdAt: null,
updatedAt: null,
};
const p = mapToPerson(row, { openWarrants: 0, totalCases: 0 });
expect(p.distinguishingMarks).toBe('');
expect(p.notes).toBe('');
expect(p.nationality).toBeNull();
expect(p.createdAt).toBeNull();
expect(p.vehicles).toEqual([]);
});
});
@@ -0,0 +1,75 @@
import type { AclEntry, Gender, LegalStatus, License, Person, PersonSummary, OwnedVehicle } from '@d4rk-tablet/shared';
/** Roh-Zeile aus mdt_citizens (Custom-JSON-Typen sind bereits geparst). */
export interface CitizenRow {
citizenid: string;
firstname: string;
lastname: string;
dob: string | null;
gender: Gender;
phone: string | null;
nationality: string | null;
legalStatus: LegalStatus;
address: string | null;
occupation: string | null;
height: string | null;
eyeColor: string | null;
hairColor: string | null;
distinguishingMarks: string | null;
aliases: string[];
licenses: License[];
mugshotUrl: string | null;
notes: string | null;
flags: string[];
isWanted: boolean;
public: boolean;
acl: AclEntry[];
createdByName: string | null;
updatedBy: string | null;
createdAt: Date | null;
updatedAt: Date | null;
}
export function mapToPersonSummary(row: CitizenRow): PersonSummary {
return {
citizenid: row.citizenid,
firstname: row.firstname,
lastname: row.lastname,
dob: row.dob ?? null,
phone: row.phone ?? null,
isWanted: row.isWanted,
mugshotUrl: row.mugshotUrl ?? null,
legalStatus: row.legalStatus,
public: row.public,
acl: Array.isArray(row.acl) ? row.acl : [],
};
}
export function mapToPerson(
row: CitizenRow,
counts: { openWarrants: number; totalCases: number },
vehicles: OwnedVehicle[] = [],
): Person {
return {
...mapToPersonSummary(row),
gender: row.gender,
nationality: row.nationality ?? null,
address: row.address ?? null,
occupation: row.occupation ?? null,
height: row.height ?? null,
eyeColor: row.eyeColor ?? null,
hairColor: row.hairColor ?? null,
distinguishingMarks: row.distinguishingMarks ?? '',
aliases: row.aliases ?? [],
licenses: row.licenses ?? [],
notes: row.notes ?? '',
flags: row.flags ?? [],
createdByName: row.createdByName ?? null,
createdAt: row.createdAt ? row.createdAt.toISOString() : null,
updatedAt: row.updatedAt ? row.updatedAt.toISOString() : null,
updatedBy: row.updatedBy ?? null,
vehicles,
openWarrants: counts.openWarrants,
totalCases: counts.totalCases,
};
}
@@ -0,0 +1,102 @@
import type { FastifyInstance, FastifyRequest } from 'fastify';
import { z } from 'zod';
import {
personSearchQuerySchema,
createCitizenSchema,
updateCitizenSchema,
paginationSchema,
} from '@d4rk-tablet/shared';
import {
searchPersons,
listCitizens,
getPerson,
getCitizenAccess,
createCitizen,
updateCitizen,
deleteCitizen,
} from './persons.service';
import { canRead, type Viewer } from '../documents/documents.service';
import { groupsForCitizen } from '../groups/groups.service';
async function viewerOf(req: FastifyRequest): Promise<Viewer> {
return {
citizenid: req.user.citizenid,
authorities: req.user.authorities,
groupIds: await groupsForCitizen(req.user.citizenid),
isAdmin: req.user.roles.includes('admin'),
};
}
export async function personsRoutes(app: FastifyInstance): Promise<void> {
app.get('/persons', { preHandler: app.requirePermission('mdt.persons.view') }, async (req, reply) => {
const parsed = personSearchQuerySchema.safeParse(req.query);
if (!parsed.success) return reply.status(400).send({ message: 'query erforderlich' });
return searchPersons(parsed.data.query, await viewerOf(req));
});
// Paginierte Bürger-Registry (optional gefiltert; nur sichtbare Akten)
app.get('/citizens', { preHandler: app.requirePermission('mdt.persons.view') }, async (req) => {
const { page, pageSize } = paginationSchema.parse(req.query);
const { query } = z.object({ query: z.string().trim().optional() }).parse(req.query);
return listCitizens(page, pageSize, query && query.length >= 2 ? query : undefined, await viewerOf(req));
});
// Neuen Bürger anlegen
app.post('/citizens', { preHandler: app.requirePermission('mdt.persons.create') }, async (req, reply) => {
const parsed = createCitizenSchema.safeParse(req.body);
if (!parsed.success) {
return reply.status(400).send({ message: 'Ungültiger Body', issues: parsed.error.issues });
}
const person = await createCitizen(parsed.data, {
citizenid: req.user.citizenid,
name: req.user.name,
});
return reply.status(201).send(person);
});
app.get<{ Params: { citizenid: string } }>(
'/persons/:citizenid',
{ preHandler: app.requirePermission('mdt.persons.view') },
async (req, reply) => {
const person = await getPerson(req.params.citizenid, await viewerOf(req));
if (!person) return reply.status(404).send({ message: 'Person nicht gefunden' });
return person;
},
);
// Bürgerakte bearbeiten (alle Felder) — nur wer die Akte sehen darf
app.patch<{ Params: { citizenid: string } }>(
'/persons/:citizenid',
{ preHandler: app.requirePermission('mdt.persons.edit') },
async (req, reply) => {
const parsed = updateCitizenSchema.safeParse(req.body);
if (!parsed.success) {
return reply.status(400).send({ message: 'Ungültiger Body', issues: parsed.error.issues });
}
const access = await getCitizenAccess(req.params.citizenid);
if (!access) return reply.status(404).send({ message: 'Person nicht gefunden' });
if (!canRead(access.public, access.acl, await viewerOf(req))) {
return reply.status(403).send({ message: 'Keine Berechtigung für diese Akte' });
}
const person = await updateCitizen(req.params.citizenid, parsed.data, req.user.citizenid);
if (!person) return reply.status(404).send({ message: 'Person nicht gefunden' });
return person;
},
);
// Bürger löschen — nur wer die Akte sehen darf
app.delete<{ Params: { citizenid: string } }>(
'/persons/:citizenid',
{ preHandler: app.requirePermission('mdt.persons.delete') },
async (req, reply) => {
const access = await getCitizenAccess(req.params.citizenid);
if (!access) return reply.status(404).send({ message: 'Person nicht gefunden' });
if (!canRead(access.public, access.acl, await viewerOf(req))) {
return reply.status(403).send({ message: 'Keine Berechtigung für diese Akte' });
}
const ok = await deleteCitizen(req.params.citizenid);
if (!ok) return reply.status(404).send({ message: 'Person nicht gefunden' });
return reply.status(204).send();
},
);
}
@@ -0,0 +1,238 @@
import { and, count, eq, like, or } from 'drizzle-orm';
import type {
AclEntry,
CreateCitizenInput,
OwnedVehicle,
Person,
PersonSummary,
UpdateCitizenInput,
} from '@d4rk-tablet/shared';
import { db } from '../../db/client';
import { mdtCitizens, mdtVehicles, mdtWarrants, mdtCases } from '../../db/schema';
import { mapToPerson, mapToPersonSummary, type CitizenRow } from './persons.mapper';
import { canRead, type Viewer } from '../documents/documents.service';
const asAcl = (v: unknown): AclEntry[] => (Array.isArray(v) ? (v as AclEntry[]) : []);
const visibleTo = (row: { public: boolean; acl: unknown }, viewer: Viewer): boolean =>
canRead(row.public, asAcl(row.acl), viewer);
function searchWhere(q: string) {
const l = `%${q}%`;
return or(like(mdtCitizens.citizenid, l), like(mdtCitizens.firstname, l), like(mdtCitizens.lastname, l));
}
/** Leerer/whitespace String → null (für optionale URL/Text-Felder). */
function nullify(v: string | null | undefined): string | null {
if (v === undefined || v === null) return null;
const t = v.trim();
return t.length ? t : null;
}
/** Kollisionsarme MDT-Bürger-ID (kein QBox-citizenid mehr). */
function genCitizenId(): string {
return 'LS-' + Math.random().toString(36).slice(2, 8).toUpperCase();
}
async function idExists(citizenid: string): Promise<boolean> {
const [row] = await db
.select({ id: mdtCitizens.citizenid })
.from(mdtCitizens)
.where(eq(mdtCitizens.citizenid, citizenid))
.limit(1);
return Boolean(row);
}
export async function searchPersons(query: string, viewer: Viewer): Promise<PersonSummary[]> {
const rows = await db.select().from(mdtCitizens).where(searchWhere(query)).limit(60);
return rows
.filter((r) => visibleTo(r, viewer))
.slice(0, 25)
.map((r) => mapToPersonSummary(r as CitizenRow));
}
export interface CitizenPage {
items: PersonSummary[];
total: number;
page: number;
pageSize: number;
}
/**
* Paginierte Bürger-Registry (optional gefiltert nach query).
* Sichtbarkeit (ACL) wird in-memory geprüft, damit total/Seitenzahl stimmen —
* bei sehr großen Registern ggf. auf SQL-seitige Filterung umstellen.
*/
export async function listCitizens(
page: number,
pageSize: number,
query: string | undefined,
viewer: Viewer,
): Promise<CitizenPage> {
const where = query ? searchWhere(query) : undefined;
const rows = await db
.select()
.from(mdtCitizens)
.where(where)
.orderBy(mdtCitizens.lastname, mdtCitizens.firstname);
const visible = rows.filter((r) => visibleTo(r, viewer));
const items = visible
.slice((page - 1) * pageSize, page * pageSize)
.map((r) => mapToPersonSummary(r as CitizenRow));
return { items, total: visible.length, page, pageSize };
}
async function ownedVehicles(citizenid: string): Promise<OwnedVehicle[]> {
const rows = await db
.select({
id: mdtVehicles.id,
plate: mdtVehicles.plate,
model: mdtVehicles.model,
plateStatus: mdtVehicles.plateStatus,
isStolen: mdtVehicles.isStolen,
})
.from(mdtVehicles)
.where(eq(mdtVehicles.ownerCitizenid, citizenid));
return rows.map((v) => ({
id: v.id,
plate: (v.plate ?? '').trim().toUpperCase(),
model: v.model ?? null,
plateStatus: v.plateStatus,
isStolen: v.isStolen,
}));
}
/** Rohe Freigabe-Info (für Schreib-/Sichtbarkeitsprüfung in der Route). */
export async function getCitizenAccess(
citizenid: string,
): Promise<{ public: boolean; acl: AclEntry[] } | null> {
const [row] = await db
.select({ public: mdtCitizens.public, acl: mdtCitizens.acl })
.from(mdtCitizens)
.where(eq(mdtCitizens.citizenid, citizenid))
.limit(1);
return row ? { public: row.public, acl: asAcl(row.acl) } : null;
}
export async function getPerson(citizenid: string, viewer: Viewer): Promise<Person | null> {
const [row] = await db
.select()
.from(mdtCitizens)
.where(eq(mdtCitizens.citizenid, citizenid))
.limit(1);
if (!row || !visibleTo(row, viewer)) return null;
const [[warrants], [cases], vehicles] = await Promise.all([
db
.select({ c: count() })
.from(mdtWarrants)
.where(and(eq(mdtWarrants.citizenid, citizenid), eq(mdtWarrants.status, 'active'))),
db.select({ c: count() }).from(mdtCases).where(eq(mdtCases.suspectCitizenid, citizenid)),
ownedVehicles(citizenid),
]);
return mapToPerson(
row as CitizenRow,
{ openWarrants: warrants?.c ?? 0, totalCases: cases?.c ?? 0 },
vehicles,
);
}
/** Interner Voll-Zugriff (Re-Read nach Insert/Update). */
const ADMIN_VIEWER: Viewer = { citizenid: null, authorities: [], groupIds: [], isAdmin: true };
export async function createCitizen(
input: CreateCitizenInput,
actor: { citizenid: string | null; name: string | null },
): Promise<Person> {
let citizenid = genCitizenId();
for (let i = 0; i < 6 && (await idExists(citizenid)); i++) citizenid = genCitizenId();
// Ersteller nicht aussperren: bei eingeschränkter Akte selbst als Person eintragen
const acl: AclEntry[] = [...(input.acl ?? [])];
if (input.public === false && actor.citizenid && !acl.some((e) => e.type === 'person' && e.id === actor.citizenid)) {
acl.push({ type: 'person', id: actor.citizenid, label: actor.name ?? actor.citizenid, level: 'write' });
}
await db.insert(mdtCitizens).values({
citizenid,
firstname: input.firstname,
lastname: input.lastname,
dob: nullify(input.dob),
gender: input.gender ?? 'unknown',
phone: nullify(input.phone),
nationality: nullify(input.nationality),
legalStatus: input.legalStatus ?? 'citizen',
address: nullify(input.address),
occupation: nullify(input.occupation),
height: nullify(input.height),
eyeColor: nullify(input.eyeColor),
hairColor: nullify(input.hairColor),
distinguishingMarks: nullify(input.distinguishingMarks),
aliases: input.aliases ?? [],
licenses: input.licenses ?? [],
mugshotUrl: nullify(input.mugshotUrl),
notes: nullify(input.notes),
flags: input.flags ?? [],
isWanted: input.isWanted ?? false,
public: input.public ?? true,
acl,
createdByCitizenid: actor.citizenid,
createdByName: actor.name,
updatedBy: actor.citizenid,
});
const person = await getPerson(citizenid, ADMIN_VIEWER);
if (!person) throw new Error('Bürger konnte nach Anlage nicht gelesen werden');
return person;
}
const EDITABLE_FIELDS = [
'firstname',
'lastname',
'gender',
'legalStatus',
'aliases',
'licenses',
'flags',
'isWanted',
'public',
'acl',
] as const;
const NULLIFY_FIELDS = [
'dob',
'phone',
'nationality',
'address',
'occupation',
'height',
'eyeColor',
'hairColor',
'distinguishingMarks',
'notes',
'mugshotUrl',
] as const;
export async function updateCitizen(
citizenid: string,
input: UpdateCitizenInput,
actorCitizenid: string | null,
): Promise<Person | null> {
if (!(await idExists(citizenid))) return null;
const set: Record<string, unknown> = { updatedBy: actorCitizenid };
for (const f of EDITABLE_FIELDS) {
if (input[f] !== undefined) set[f] = input[f];
}
for (const f of NULLIFY_FIELDS) {
if (input[f] !== undefined) set[f] = nullify(input[f] as string | null | undefined);
}
await db.update(mdtCitizens).set(set).where(eq(mdtCitizens.citizenid, citizenid));
return getPerson(citizenid, ADMIN_VIEWER);
}
export async function deleteCitizen(citizenid: string): Promise<boolean> {
if (!(await idExists(citizenid))) return false;
await db.delete(mdtCitizens).where(eq(mdtCitizens.citizenid, citizenid));
return true;
}
@@ -0,0 +1,55 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { createTreatmentSchema, updateTreatmentSchema } from '@d4rk-tablet/shared';
import {
listTreatmentsForPatient,
getTreatment,
createTreatment,
updateTreatment,
} from './treatment.service';
const citizenidQuery = z.object({ citizenid: z.string().min(1) });
export async function treatmentRoutes(app: FastifyInstance): Promise<void> {
app.get('/treatments', { preHandler: app.requirePermission('mdt.treatment.view') }, async (req, reply) => {
const parsed = citizenidQuery.safeParse(req.query);
if (!parsed.success) return reply.status(400).send({ message: 'citizenid erforderlich' });
return listTreatmentsForPatient(parsed.data.citizenid);
});
app.get<{ Params: { id: string } }>(
'/treatments/:id',
{ preHandler: app.requirePermission('mdt.treatment.view') },
async (req, reply) => {
const rec = await getTreatment(Number(req.params.id));
if (!rec) return reply.status(404).send({ message: 'Behandlungsakte nicht gefunden' });
return rec;
},
);
app.post('/treatments', { preHandler: app.requirePermission('mdt.treatment.create') }, async (req, reply) => {
const parsed = createTreatmentSchema.safeParse(req.body);
if (!parsed.success) {
return reply.status(400).send({ message: 'Ungültiger Body', issues: parsed.error.issues });
}
const rec = await createTreatment(parsed.data, {
citizenid: req.user.citizenid,
name: req.user.name,
});
return reply.status(201).send(rec);
});
app.patch<{ Params: { id: string } }>(
'/treatments/:id',
{ preHandler: app.requirePermission('mdt.treatment.edit') },
async (req, reply) => {
const parsed = updateTreatmentSchema.safeParse(req.body);
if (!parsed.success) {
return reply.status(400).send({ message: 'Ungültiger Body', issues: parsed.error.issues });
}
const rec = await updateTreatment(Number(req.params.id), parsed.data);
if (!rec) return reply.status(404).send({ message: 'Behandlungsakte nicht gefunden' });
return rec;
},
);
}
@@ -0,0 +1,104 @@
import { count, desc, eq, like } from 'drizzle-orm';
import type {
CreateTreatmentInput,
TreatmentRecord,
UpdateTreatmentInput,
} from '@d4rk-tablet/shared';
import { db } from '../../db/client';
import { mdtTreatmentRecords } from '../../db/schema';
import { resolvePlayerNames } from '../../utils/names';
type Row = typeof mdtTreatmentRecords.$inferSelect;
function mapRecord(row: Row, names: Map<string, string | null>): TreatmentRecord {
return {
id: row.id,
reference: row.reference,
patientCitizenid: row.patientCitizenid,
patientName: names.get(row.patientCitizenid) ?? null,
title: row.title,
diagnosis: row.diagnosis ?? '',
treatment: row.treatment ?? '',
status: row.status,
authorCitizenid: row.authorCitizenid,
authorName: row.authorCitizenid ? (names.get(row.authorCitizenid) ?? null) : row.authorName ?? null,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt ? row.updatedAt.toISOString() : null,
};
}
/** Aktenzeichen TR-JJJJ-MM-TT-### (fortlaufend pro Tag). */
async function nextReference(): Promise<string> {
const now = new Date();
const datePart = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(
now.getDate(),
).padStart(2, '0')}`;
const prefix = `TR-${datePart}-`;
const [row] = await db
.select({ c: count() })
.from(mdtTreatmentRecords)
.where(like(mdtTreatmentRecords.reference, `${prefix}%`));
return prefix + String((row?.c ?? 0) + 1).padStart(3, '0');
}
export async function listTreatmentsForPatient(citizenid: string): Promise<TreatmentRecord[]> {
const rows = await db
.select()
.from(mdtTreatmentRecords)
.where(eq(mdtTreatmentRecords.patientCitizenid, citizenid))
.orderBy(desc(mdtTreatmentRecords.createdAt));
if (rows.length === 0) return [];
const names = await resolvePlayerNames([citizenid, ...rows.map((r) => r.authorCitizenid)]);
return rows.map((r) => mapRecord(r, names));
}
export async function getTreatment(id: number): Promise<TreatmentRecord | null> {
const [row] = await db.select().from(mdtTreatmentRecords).where(eq(mdtTreatmentRecords.id, id)).limit(1);
if (!row) return null;
const names = await resolvePlayerNames([row.patientCitizenid, row.authorCitizenid]);
return mapRecord(row, names);
}
export async function createTreatment(
input: CreateTreatmentInput,
author: { citizenid: string | null; name: string | null },
): Promise<TreatmentRecord> {
const reference = await nextReference();
const inserted = await db
.insert(mdtTreatmentRecords)
.values({
reference,
patientCitizenid: input.patientCitizenid,
title: input.title,
diagnosis: input.diagnosis,
treatment: input.treatment,
authorCitizenid: author.citizenid,
authorName: author.name,
})
.$returningId();
const created = await getTreatment(inserted[0]!.id);
if (!created) throw new Error('Behandlungsakte konnte nicht geladen werden');
return created;
}
export async function updateTreatment(
id: number,
input: UpdateTreatmentInput,
): Promise<TreatmentRecord | null> {
const [existing] = await db
.select({ id: mdtTreatmentRecords.id })
.from(mdtTreatmentRecords)
.where(eq(mdtTreatmentRecords.id, id))
.limit(1);
if (!existing) return null;
const patch: Partial<Row> = {};
if (input.title !== undefined) patch.title = input.title;
if (input.diagnosis !== undefined) patch.diagnosis = input.diagnosis;
if (input.treatment !== undefined) patch.treatment = input.treatment;
if (input.status !== undefined) patch.status = input.status;
if (Object.keys(patch).length > 0) {
await db.update(mdtTreatmentRecords).set(patch).where(eq(mdtTreatmentRecords.id, id));
}
return getTreatment(id);
}
@@ -0,0 +1,41 @@
import { describe, it, expect } from 'vitest';
import { mapToVehicle, type VehicleRow } from '../vehicles.mapper';
const row: VehicleRow = {
id: 1,
plate: 'ab 123 ',
model: 'Sultan RS',
color: 'schwarz',
ownerCitizenid: 'LS-100001',
plateStatus: 'registered',
isStolen: false,
hasBolo: false,
notes: null,
ownerFirst: null,
ownerLast: null,
};
describe('vehicles.mapper', () => {
it('normalisiert Kennzeichen (trim + uppercase)', () => {
expect(mapToVehicle(row).plate).toBe('AB 123');
});
it('baut ownerName aus Halter-Feldern', () => {
expect(mapToVehicle({ ...row, ownerFirst: 'Max', ownerLast: 'Mustermann' }).ownerName).toBe('Max Mustermann');
});
it('ownerName null bei unbekanntem Halter', () => {
expect(mapToVehicle(row).ownerName).toBeNull();
});
it('übernimmt plateStatus und Flags (gefälscht/gestohlen)', () => {
const v = mapToVehicle({ ...row, plateStatus: 'forged', isStolen: true, hasBolo: true });
expect(v.plateStatus).toBe('forged');
expect(v.isStolen).toBe(true);
expect(v.hasBolo).toBe(true);
});
it('notes fällt auf leeren String zurück', () => {
expect(mapToVehicle(row).notes).toBe('');
});
});
@@ -0,0 +1,36 @@
import type { PlateStatus, Vehicle } from '@d4rk-tablet/shared';
/** Roh-Zeile aus mdt_vehicles (+ optional aufgelöster Halter aus mdt_citizens). */
export interface VehicleRow {
id: number;
plate: string | null;
model: string | null;
color: string | null;
ownerCitizenid: string | null;
plateStatus: PlateStatus;
isStolen: boolean;
hasBolo: boolean;
notes: string | null;
ownerFirst: string | null;
ownerLast: string | null;
}
export function mapToVehicle(row: VehicleRow): Vehicle {
const ownerName =
row.ownerFirst || row.ownerLast
? `${row.ownerFirst ?? ''} ${row.ownerLast ?? ''}`.trim() || null
: null;
return {
id: row.id,
plate: (row.plate ?? '').trim().toUpperCase(),
model: row.model ?? null,
color: row.color ?? null,
ownerCitizenid: row.ownerCitizenid ?? null,
ownerName,
plateStatus: row.plateStatus,
isStolen: row.isStolen,
hasBolo: row.hasBolo,
notes: row.notes ?? '',
};
}
@@ -0,0 +1,95 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import {
vehicleLookupParamsSchema,
flagVehicleSchema,
createVehicleSchema,
updateVehicleSchema,
paginationSchema,
} from '@d4rk-tablet/shared';
import {
getVehicleByPlate,
getVehicleById,
listVehicles,
createVehicle,
updateVehicle,
deleteVehicle,
flagVehicle,
} from './vehicles.service';
export async function vehiclesRoutes(app: FastifyInstance): Promise<void> {
// Paginierte Fahrzeug-Registry (optional gefiltert)
app.get('/vehicles', { preHandler: app.requirePermission('mdt.vehicles.view') }, async (req) => {
const { page, pageSize } = paginationSchema.parse(req.query);
const { query } = z.object({ query: z.string().trim().optional() }).parse(req.query);
return listVehicles(page, pageSize, query && query.length >= 2 ? query : undefined);
});
// Neues Fahrzeug anlegen (auch mit gefälschtem Kennzeichen)
app.post('/vehicles', { preHandler: app.requirePermission('mdt.vehicles.create') }, async (req, reply) => {
const parsed = createVehicleSchema.safeParse(req.body);
if (!parsed.success) {
return reply.status(400).send({ message: 'Ungültiger Body', issues: parsed.error.issues });
}
const vehicle = await createVehicle(parsed.data, req.user.citizenid);
return reply.status(201).send(vehicle);
});
// Fahrzeug per DB-ID bearbeiten
app.patch<{ Params: { id: string } }>(
'/vehicles/id/:id',
{ preHandler: app.requirePermission('mdt.vehicles.flag') },
async (req, reply) => {
const id = Number(req.params.id);
const parsed = updateVehicleSchema.safeParse(req.body);
if (!Number.isInteger(id) || !parsed.success) {
return reply.status(400).send({ message: 'Ungültige Anfrage' });
}
const vehicle = await updateVehicle(id, parsed.data);
if (!vehicle) return reply.status(404).send({ message: 'Fahrzeug nicht gefunden' });
return vehicle;
},
);
// Fahrzeug per DB-ID löschen
app.delete<{ Params: { id: string } }>(
'/vehicles/id/:id',
{ preHandler: app.requirePermission('mdt.vehicles.delete') },
async (req, reply) => {
const id = Number(req.params.id);
if (!Number.isInteger(id)) return reply.status(400).send({ message: 'Ungültige ID' });
const ok = await deleteVehicle(id);
if (!ok) return reply.status(404).send({ message: 'Fahrzeug nicht gefunden' });
return reply.status(204).send();
},
);
// Kennzeichen-Abfrage
app.get<{ Params: { plate: string } }>(
'/vehicles/:plate',
{ preHandler: app.requirePermission('mdt.vehicles.view') },
async (req, reply) => {
const parsed = vehicleLookupParamsSchema.safeParse(req.params);
if (!parsed.success) return reply.status(400).send({ message: 'Ungültiges Kennzeichen' });
const vehicle = await getVehicleByPlate(parsed.data.plate);
if (!vehicle) return reply.status(404).send({ message: 'Fahrzeug nicht gefunden' });
return vehicle;
},
);
// Schneller „als gestohlen"-Umschalter
app.patch<{ Params: { plate: string } }>(
'/vehicles/:plate/flag',
{ preHandler: app.requirePermission('mdt.vehicles.flag') },
async (req, reply) => {
const params = vehicleLookupParamsSchema.safeParse(req.params);
const body = flagVehicleSchema.safeParse(req.body);
if (!params.success || !body.success) {
return reply.status(400).send({ message: 'Ungültige Anfrage' });
}
const vehicle = await flagVehicle(params.data.plate, body.data);
if (!vehicle) return reply.status(404).send({ message: 'Fahrzeug nicht gefunden' });
return vehicle;
},
);
}
@@ -0,0 +1,156 @@
import { and, count, desc, eq, like, or, sql } from 'drizzle-orm';
import type {
CreateVehicleInput,
FlagVehicleInput,
UpdateVehicleInput,
Vehicle,
} from '@d4rk-tablet/shared';
import { db } from '../../db/client';
import { mdtVehicles, mdtCitizens } from '../../db/schema';
import { mapToVehicle, type VehicleRow } from './vehicles.mapper';
const SELECT = {
id: mdtVehicles.id,
plate: mdtVehicles.plate,
model: mdtVehicles.model,
color: mdtVehicles.color,
ownerCitizenid: mdtVehicles.ownerCitizenid,
plateStatus: mdtVehicles.plateStatus,
isStolen: mdtVehicles.isStolen,
hasBolo: mdtVehicles.hasBolo,
notes: mdtVehicles.notes,
ownerFirst: mdtCitizens.firstname,
ownerLast: mdtCitizens.lastname,
} as const;
function baseQuery() {
return db
.select(SELECT)
.from(mdtVehicles)
.leftJoin(mdtCitizens, eq(mdtCitizens.citizenid, mdtVehicles.ownerCitizenid));
}
function nullify(v: string | null | undefined): string | null {
if (v === undefined || v === null) return null;
const t = v.trim();
return t.length ? t : null;
}
export async function getVehicleByPlate(plate: string): Promise<Vehicle | null> {
const [row] = await baseQuery()
.where(sql`TRIM(${mdtVehicles.plate}) = ${plate}`)
.orderBy(desc(mdtVehicles.id))
.limit(1);
return row ? mapToVehicle(row as VehicleRow) : null;
}
export async function getVehicleById(id: number): Promise<Vehicle | null> {
const [row] = await baseQuery().where(eq(mdtVehicles.id, id)).limit(1);
return row ? mapToVehicle(row as VehicleRow) : null;
}
export interface VehiclePage {
items: Vehicle[];
total: number;
page: number;
pageSize: number;
}
export async function listVehicles(
page: number,
pageSize: number,
query?: string,
): Promise<VehiclePage> {
const where = query
? or(like(mdtVehicles.plate, `%${query}%`), like(mdtVehicles.model, `%${query}%`))
: undefined;
const [totalRow] = await db.select({ c: count() }).from(mdtVehicles).where(where);
const rows = await baseQuery()
.where(where)
.orderBy(desc(mdtVehicles.id))
.limit(pageSize)
.offset((page - 1) * pageSize);
return {
items: rows.map((r) => mapToVehicle(r as VehicleRow)),
total: totalRow?.c ?? 0,
page,
pageSize,
};
}
export async function createVehicle(
input: CreateVehicleInput,
actorCitizenid: string | null,
): Promise<Vehicle> {
const inserted = await db
.insert(mdtVehicles)
.values({
plate: input.plate.trim().toUpperCase(),
model: nullify(input.model),
color: nullify(input.color),
ownerCitizenid: nullify(input.ownerCitizenid),
plateStatus: input.plateStatus ?? 'registered',
isStolen: input.isStolen ?? false,
hasBolo: input.hasBolo ?? false,
notes: nullify(input.notes),
createdByCitizenid: actorCitizenid,
})
.$returningId();
const id = inserted[0]!.id;
const vehicle = await getVehicleById(id);
if (!vehicle) throw new Error('Fahrzeug konnte nach Anlage nicht gelesen werden');
return vehicle;
}
export async function updateVehicle(
id: number,
input: UpdateVehicleInput,
): Promise<Vehicle | null> {
const existing = await getVehicleById(id);
if (!existing) return null;
const set: Record<string, unknown> = {};
if (input.plate !== undefined) set.plate = input.plate.trim().toUpperCase();
if (input.model !== undefined) set.model = nullify(input.model);
if (input.color !== undefined) set.color = nullify(input.color);
if (input.ownerCitizenid !== undefined) set.ownerCitizenid = nullify(input.ownerCitizenid);
if (input.plateStatus !== undefined) set.plateStatus = input.plateStatus;
if (input.isStolen !== undefined) set.isStolen = input.isStolen;
if (input.hasBolo !== undefined) set.hasBolo = input.hasBolo;
if (input.notes !== undefined) set.notes = nullify(input.notes);
if (Object.keys(set).length > 0) {
await db.update(mdtVehicles).set(set).where(eq(mdtVehicles.id, id));
}
return getVehicleById(id);
}
export async function deleteVehicle(id: number): Promise<boolean> {
const existing = await getVehicleById(id);
if (!existing) return false;
await db.delete(mdtVehicles).where(eq(mdtVehicles.id, id));
return true;
}
/** Schneller „als gestohlen"-Umschalter aus der Kennzeichen-Abfrage. */
export async function flagVehicle(
plate: string,
input: FlagVehicleInput,
): Promise<Vehicle | null> {
const vehicle = await getVehicleByPlate(plate);
if (!vehicle) return null;
if (input.isStolen !== undefined) {
await db
.update(mdtVehicles)
.set({
isStolen: input.isStolen,
plateStatus: input.isStolen
? 'stolen'
: vehicle.plateStatus === 'stolen'
? 'registered'
: vehicle.plateStatus,
})
.where(eq(mdtVehicles.id, vehicle.id));
}
return getVehicleByPlate(plate);
}
+42
View File
@@ -0,0 +1,42 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import fastifyJwt from '@fastify/jwt';
import fastifyCookie from '@fastify/cookie';
import { can, type Permission } from '@d4rk-tablet/shared';
import { env } from '../config/env';
const COOKIE_NAME = 'mdt_token';
/**
* Registriert JWT + Cookie und dekoriert die App mit `authenticate` und
* `requirePermission`. Wird mit der Root-Instanz aufgerufen → Decorators sind app-weit.
*/
export async function registerAuth(app: FastifyInstance): Promise<void> {
await app.register(fastifyCookie);
await app.register(fastifyJwt, {
secret: env.JWT_SECRET,
cookie: { cookieName: COOKIE_NAME, signed: false },
});
app.decorate('authenticate', async (req: FastifyRequest, reply: FastifyReply) => {
try {
await req.jwtVerify();
} catch {
return reply.status(401).send({ message: 'Nicht authentifiziert' });
}
});
app.decorate('requirePermission', (perm: Permission) => {
return async (req: FastifyRequest, reply: FastifyReply) => {
try {
await req.jwtVerify();
} catch {
return reply.status(401).send({ message: 'Nicht authentifiziert' });
}
if (!can(req.user.permissions, perm)) {
return reply.status(403).send({ message: 'Fehlende Berechtigung' });
}
};
});
}
export { COOKIE_NAME };
+63
View File
@@ -0,0 +1,63 @@
import type { Officer, Coords, Department } from '@d4rk-tablet/shared';
/**
* In-Memory-Registry der aktuell im Dienst befindlichen Officer.
* Bewusst NICHT persistiert — Live-Zustand, kommt via Bridge/Socket.
*/
const officers = new Map<string, Officer>();
export function setOfficerDuty(input: {
citizenid: string;
name: string;
callsign?: string | null;
department: Department;
onDuty: boolean;
}): Officer {
const existing = officers.get(input.citizenid);
const officer: Officer = {
citizenid: input.citizenid,
name: input.name,
callsign: input.callsign ?? existing?.callsign ?? null,
department: input.department,
onDuty: input.onDuty,
coords: existing?.coords ?? null,
updatedAt: Date.now(),
};
if (input.onDuty) {
officers.set(input.citizenid, officer);
} else {
officers.delete(input.citizenid);
}
return officer;
}
export function updateOfficerPosition(citizenid: string, coords: Coords): Officer | null {
const officer = officers.get(citizenid);
if (!officer) return null;
officer.coords = coords;
officer.updatedAt = Date.now();
return officer;
}
export function getOfficer(citizenid: string): Officer | null {
return officers.get(citizenid) ?? null;
}
/** Snapshot aller Officer (optional gefiltert nach Department). */
export function officerSnapshot(department?: Department): Officer[] {
const all = [...officers.values()];
return department ? all.filter((o) => o.department === department) : all;
}
/** Aufräumen: Officer, die länger als `maxAgeMs` kein Update hatten, entfernen. */
export function pruneStaleOfficers(maxAgeMs = 60_000): string[] {
const now = Date.now();
const removed: string[] = [];
for (const [id, o] of officers) {
if (now - o.updatedAt > maxAgeMs) {
officers.delete(id);
removed.push(id);
}
}
return removed;
}
+97
View File
@@ -0,0 +1,97 @@
import { Server as IOServer } from 'socket.io';
import type { FastifyInstance } from 'fastify';
import {
SOCKET_EVENTS,
departmentRoom,
DEPARTMENTS,
type ServerToClientEvents,
type ClientToServerEvents,
type AuthUser,
type Department,
type Officer,
type OfficerPositionUpdate,
type OfficerDutyUpdate,
type DispatchCall,
type Warrant,
type Coords,
} from '@d4rk-tablet/shared';
import { env } from '../config/env';
import { officerSnapshot } from './officers';
type AppIO = IOServer<ClientToServerEvents, ServerToClientEvents>;
let io: AppIO | null = null;
/** Departments, in denen der User Mitglied ist (für Room-Zuordnung). */
function userDepartments(user: AuthUser): Department[] {
return DEPARTMENTS.filter((d) => user.roles.includes(d));
}
export function initSocket(app: FastifyInstance): AppIO {
io = new IOServer<ClientToServerEvents, ServerToClientEvents>(app.server, {
cors: { origin: env.CORS_ORIGINS, credentials: true },
transports: ['websocket'],
});
// JWT-Auth beim Handshake
io.use((socket, next) => {
try {
const token = (socket.handshake.auth as { token?: string })?.token;
if (!token) return next(new Error('Kein Token'));
const user = app.jwt.verify<AuthUser>(token);
socket.data.user = user;
next();
} catch {
next(new Error('Ungültiges Token'));
}
});
io.on('connection', (socket) => {
const user = socket.data.user as AuthUser;
const depts = userDepartments(user);
for (const d of depts) void socket.join(departmentRoom(d));
// Initialer Snapshot der Officer der eigenen Departments
const snapshot: Officer[] = depts.flatMap((d) => officerSnapshot(d));
socket.emit(SOCKET_EVENTS.OFFICER_SNAPSHOT, snapshot);
// Client → Server: Waypoint an einen Officer senden (→ Bridge in M7)
socket.on(SOCKET_EVENTS.OFFICER_SET_WAYPOINT, (payload) => {
app.log.info({ payload }, 'setWaypoint (Bridge-Weiterleitung folgt in M7)');
});
});
app.addHook('onClose', async () => {
await io?.close();
});
return io;
}
function getIO(): AppIO {
if (!io) throw new Error('Socket.io nicht initialisiert');
return io;
}
// ── Broadcast-Helfer (von Services genutzt) ──
export function broadcastDispatchCreated(call: DispatchCall): void {
getIO().to(departmentRoom(call.department)).emit(SOCKET_EVENTS.DISPATCH_CREATED, call);
}
export function broadcastDispatchUpdated(call: DispatchCall): void {
getIO().to(departmentRoom(call.department)).emit(SOCKET_EVENTS.DISPATCH_UPDATED, call);
}
export function broadcastOfficerMoved(dept: Department, update: OfficerPositionUpdate): void {
getIO().to(departmentRoom(dept)).emit(SOCKET_EVENTS.OFFICER_MOVED, update);
}
export function broadcastOfficerDuty(dept: Department, update: OfficerDutyUpdate): void {
getIO().to(departmentRoom(dept)).emit(SOCKET_EVENTS.OFFICER_DUTY, update);
}
export function broadcastWarrantUpdated(dept: Department, warrant: Warrant): void {
getIO().to(departmentRoom(dept)).emit(SOCKET_EVENTS.WARRANT_UPDATED, warrant);
}
export type { Coords };
+16
View File
@@ -0,0 +1,16 @@
import { buildApp } from './app';
import { env } from './config/env';
async function main(): Promise<void> {
const app = await buildApp();
try {
await app.listen({ port: env.API_PORT, host: env.API_HOST });
app.log.info(`d4rk_tablet API läuft auf http://${env.API_HOST}:${env.API_PORT}`);
} catch (err) {
app.log.error(err);
process.exit(1);
}
}
void main();
+18
View File
@@ -0,0 +1,18 @@
import type { AuthUser, Permission } from '@d4rk-tablet/shared';
import type { preHandlerHookHandler } from 'fastify';
declare module '@fastify/jwt' {
interface FastifyJWT {
payload: AuthUser;
user: AuthUser;
}
}
declare module 'fastify' {
interface FastifyInstance {
/** preHandler: verifiziert JWT (Header oder Cookie), sonst 401. */
authenticate: preHandlerHookHandler;
/** Factory: preHandler, der zusätzlich eine Permission erzwingt (sonst 403). */
requirePermission: (perm: Permission) => preHandlerHookHandler;
}
}
+34
View File
@@ -0,0 +1,34 @@
import { createHmac, timingSafeEqual } from 'node:crypto';
import type { FastifyRequest } from 'fastify';
import { env } from '../config/env';
/**
* Server-zu-Server Shared-Secret: die FiveM-Bridge sendet `x-bridge-secret`.
* Einfach in Lua (kein Crypto). Der Secret liegt nur auf dem FiveM-Server.
*/
export function bridgeSecretOk(req: FastifyRequest): boolean {
const s = req.headers['x-bridge-secret'];
if (typeof s !== 'string' || s.length === 0) return false;
const a = Buffer.from(s, 'utf8');
const b = Buffer.from(env.BRIDGE_HMAC_SECRET, 'utf8');
return a.length === b.length && timingSafeEqual(a, b);
}
/**
* Generische HMAC-Verifikation für Bridge-Requests.
* Signatur = HMAC-SHA256("<timestamp>.<payload>", BRIDGE_HMAC_SECRET), Fenster ±60s.
* `payload` ist ein stabiles Kennfeld des Requests (z. B. citizenid oder code).
*/
export function verifyBridgeHmac(timestamp: string, payload: string, signature: string): boolean {
const ts = Number(timestamp);
if (!Number.isFinite(ts) || Math.abs(Date.now() - ts) > 60_000) return false;
const expected = createHmac('sha256', env.BRIDGE_HMAC_SECRET)
.update(`${timestamp}.${payload}`)
.digest('hex');
const a = Buffer.from(expected, 'utf8');
const b = Buffer.from(signature, 'utf8');
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}
+13
View File
@@ -0,0 +1,13 @@
/** Parst QBox-JSON-Spalten defensiv (können string, bereits-Objekt oder null sein). */
export function safeParseJson<T = Record<string, unknown>>(value: unknown): T | null {
if (value == null) return null;
if (typeof value === 'object') return value as T;
if (typeof value === 'string') {
try {
return JSON.parse(value) as T;
} catch {
return null;
}
}
return null;
}
+23
View File
@@ -0,0 +1,23 @@
import { inArray } from 'drizzle-orm';
import { db } from '../db/client';
import { mdtCitizens } from '../db/schema';
/** Löst citizenids → "Vorname Nachname" auf (ein Query, kein N+1). */
export async function resolvePlayerNames(
citizenids: (string | null | undefined)[],
): Promise<Map<string, string | null>> {
const ids = [...new Set(citizenids.filter((c): c is string => !!c))];
const map = new Map<string, string | null>();
if (ids.length === 0) return map;
const rows = await db
.select({ citizenid: mdtCitizens.citizenid, firstname: mdtCitizens.firstname, lastname: mdtCitizens.lastname })
.from(mdtCitizens)
.where(inArray(mdtCitizens.citizenid, ids));
for (const r of rows) {
const name = `${r.firstname ?? ''} ${r.lastname ?? ''}`.trim() || null;
map.set(r.citizenid, name);
}
return map;
}
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": ".",
"types": ["node"],
"lib": ["ES2022"],
"noEmit": true
},
"include": ["src/**/*.ts", "drizzle.config.ts"]
}