implemented connectedDispatch record for dispatcher

This commit is contained in:
PxlLoewe
2025-05-01 21:48:25 -07:00
parent 504ef3cdb8
commit 26e71bcaa8
16 changed files with 287 additions and 115 deletions

View File

@@ -1,19 +1,15 @@
import { prisma } from "@repo/db";
import { Router } from "express"; import { Router } from "express";
import { pubClient } from "modules/redis"; import { pubClient } from "modules/redis";
const router = Router(); const router = Router();
router.get("/", async (req, res) => { router.get("/", async (req, res) => {
const keys = await pubClient.keys("Dispatcher:*"); const user = await prisma.connectedDispatcher.findMany({
const user = await Promise.all( where: {
keys.map(async (key) => { logoutTime: null,
const data = await pubClient.json.get(key); },
return { });
...(typeof data === "object" && data !== null ? data : {}),
userId: key.split(":")[1],
};
}),
);
res.json(user); res.json(user);
}); });

View File

@@ -2,11 +2,13 @@ import { Router } from "express";
import livekitRouter from "./livekit"; import livekitRouter from "./livekit";
import dispatcherRotuer from "./dispatcher"; import dispatcherRotuer from "./dispatcher";
import missionRouter from "./mission"; import missionRouter from "./mission";
import statusRouter from "./status";
const router = Router(); const router = Router();
router.use("/livekit", livekitRouter); router.use("/livekit", livekitRouter);
router.use("/dispatcher", dispatcherRotuer); router.use("/dispatcher", dispatcherRotuer);
router.use("/mission", missionRouter); router.use("/mission", missionRouter);
router.use("/status", statusRouter);
export default router; export default router;

View File

@@ -0,0 +1,22 @@
import { prisma } from "@repo/db";
import { Router } from "express";
const router = Router();
router.get("/connected-users", async (req, res) => {
const connectedDispatcher = await prisma.connectedDispatcher.findMany({
where: {
logoutTime: null,
},
});
const connectedAircraft = await prisma.connectedAircraft.findMany({
where: {
logoutTime: null,
},
});
res.json([...connectedDispatcher, ...connectedAircraft]);
});
export default router;

View File

@@ -1,4 +1,4 @@
import { prisma } from "@repo/db"; import { getPublicUser, prisma } from "@repo/db";
import { pubClient } from "modules/redis"; import { pubClient } from "modules/redis";
import { Server, Socket } from "socket.io"; import { Server, Socket } from "socket.io";
@@ -11,47 +11,76 @@ export const handleConnectDispatch =
logoffTime: string; logoffTime: string;
selectedZone: string; selectedZone: string;
}) => { }) => {
const userId = socket.data.user.id; // User ID aus dem JWT-Token try {
console.log("User connected to dispatch server"); const userId = socket.data.user.id; // User ID aus dem JWT-Token
const user = await prisma.user.findUnique({ console.log("User connected to dispatch server");
where: { const user = await prisma.user.findUnique({
id: userId,
},
});
const connectedDispatcherEntry = await prisma.connectedDispatcher.create({
data: {
publicUser: {},
esimatedLogoutTime: logoffTime,
lastHeartbeat: new Date(),
userId: userId,
loginTime: new Date().toISOString(),
},
});
socket.join("dispatchers"); // Dem Dispatcher-Raum beitreten
socket.join(`user:${userId}`); // Dem User-Raum beitreten
/* const keys = await pubClient.keys("Dispatcher:*");
await Promise.all(
keys.map(async (key) => {
return await pubClient.json.get(key);
}),
); */
io.to("dispatchers").emit("dispatcher-update");
io.to("pilots").emit("dispatcher-update");
socket.on("disconnect", async () => {
console.log("Disconnected from dispatch server");
await prisma.connectedDispatcher.update({
where: { where: {
id: connectedDispatcherEntry.id, id: userId,
},
data: {
logoutTime: new Date().toISOString(),
}, },
}); });
});
socket.on("reconnect", async () => { if (!user) return Error("User not found");
console.log("Reconnected to dispatch server");
}); let parsedLogoffDate = null;
if (logoffTime.length > 0) {
const now = new Date();
const [hours, minutes] = logoffTime.split(":").map(Number);
if (!hours || !minutes) {
throw new Error("Invalid logoffTime format");
}
parsedLogoffDate = new Date(now);
parsedLogoffDate.setHours(hours, minutes, 0, 0);
// If the calculated time is earlier than now, add one day to make it tomorrow
if (parsedLogoffDate <= now) {
parsedLogoffDate.setDate(parsedLogoffDate.getDate() + 1);
}
// If the calculated time is in the past, add one day to make it in the future
if (parsedLogoffDate <= now) {
parsedLogoffDate.setDate(parsedLogoffDate.getDate() + 1);
}
}
const connectedDispatcherEntry = await prisma.connectedDispatcher.create({
data: {
publicUser: getPublicUser(user) as any,
esimatedLogoutTime: parsedLogoffDate?.toISOString() || null,
lastHeartbeat: new Date().toISOString(),
userId: userId,
loginTime: new Date().toISOString(),
},
});
socket.join("dispatchers"); // Dem Dispatcher-Raum beitreten
socket.join(`user:${userId}`); // Dem User-Raum beitreten
/* const keys = await pubClient.keys("Dispatcher:*");
await Promise.all(
keys.map(async (key) => {
return await pubClient.json.get(key);
}),
); */
io.to("dispatchers").emit("dispatcher-update");
io.to("pilots").emit("dispatcher-update");
socket.on("disconnect", async () => {
console.log("Disconnected from dispatch server");
await prisma.connectedDispatcher.update({
where: {
id: connectedDispatcherEntry.id,
},
data: {
logoutTime: new Date().toISOString(),
},
});
});
socket.on("reconnect", async () => {
console.log("Reconnected to dispatch server");
});
} catch (error) {
console.error("Error connecting to dispatch server:", error);
console.log("Error connecting to dispatch server:", error);
}
}; };

View File

@@ -0,0 +1,94 @@
import { getPublicUser, prisma } from "@repo/db";
import { Server, Socket } from "socket.io";
export const handleConnectDispatch =
(socket: Socket, io: Server) =>
async ({
logoffTime,
stationId,
}: {
logoffTime: string;
stationId: number;
}) => {
try {
const userId = socket.data.user.id; // User ID aus dem JWT-Token
console.log("User connected to dispatch server");
const user = await prisma.user.findUnique({
where: {
id: userId,
},
});
if (!user) return Error("User not found");
let parsedLogoffDate = null;
if (logoffTime.length > 0) {
const now = new Date();
const [hours, minutes] = logoffTime.split(":").map(Number);
if (!hours || !minutes) {
throw new Error("Invalid logoffTime format");
}
parsedLogoffDate = new Date(now);
parsedLogoffDate.setHours(hours, minutes, 0, 0);
// If the calculated time is earlier than now, add one day to make it tomorrow
if (parsedLogoffDate <= now) {
parsedLogoffDate.setDate(parsedLogoffDate.getDate() + 1);
}
}
const connectedAircraftEntry = await prisma.connectedAircraft.create({
data: {
publicUser: getPublicUser(user) as any,
esimatedLogoutTime: parsedLogoffDate?.toISOString() || null,
lastHeartbeat: new Date().toISOString(),
userId: userId,
loginTime: new Date().toISOString(),
stationId: stationId,
/* user: { connect: { id: userId } }, // Ensure the user relationship is set
station: { connect: { id: stationId } }, // Ensure the station relationship is set */
},
});
socket.join("dispatchers"); // Join the dispatchers room
socket.join(`user:${userId}`); // Join the user-specific room
socket.join(`station:${stationId}`); // Join the station-specific room
io.to("dispatchers").emit("dispatcher-update");
io.to("pilots").emit("dispatcher-update");
// Add a listener for station-specific events
socket.on(`station:${stationId}:event`, async (data) => {
console.log(`Received event for station ${stationId}:`, data);
// Handle station-specific logic here
io.to(`station:${stationId}`).emit("station-event-update", data);
});
socket.on("disconnect", async () => {
console.log("Disconnected from dispatch server");
await prisma.connectedDispatcher.update({
where: {
id: connectedAircraftEntry.id,
},
data: {
logoutTime: new Date().toISOString(),
},
});
});
socket.on("reconnect", async () => {
console.log("Reconnected to dispatch server");
await prisma.connectedDispatcher.update({
where: {
id: connectedAircraftEntry.id,
},
data: {
lastHeartbeat: new Date().toISOString(),
logoutTime: null,
},
});
});
} catch (error) {
console.error("Error connecting to dispatch server:", error);
}
};

View File

@@ -3,9 +3,9 @@ import { ChatBubbleIcon, PaperPlaneIcon } from "@radix-ui/react-icons";
import { useLeftMenuStore } from "_store/leftMenuStore"; import { useLeftMenuStore } from "_store/leftMenuStore";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { Fragment, useEffect, useRef, useState } from "react"; import { Fragment, useEffect, useRef, useState } from "react";
import { Dispatcher } from "dispatch/_components/navbar/_components/action";
import { cn } from "helpers/cn"; import { cn } from "helpers/cn";
import { getDispatcher } from "pilot/_components/navbar/action"; import { getConenctedUsers } from "helpers/axios";
import { asPublicUser, ConnectedAircraft, ConnectedDispatcher } from "@repo/db";
export const Chat = () => { export const Chat = () => {
const { const {
@@ -24,7 +24,9 @@ export const Chat = () => {
const session = useSession(); const session = useSession();
const [addTabValue, setAddTabValue] = useState<string>(""); const [addTabValue, setAddTabValue] = useState<string>("");
const [message, setMessage] = useState<string>(""); const [message, setMessage] = useState<string>("");
const [dispatcher, setDispatcher] = useState<Dispatcher[] | null>(null); const [connectedUser, setConnectedUser] = useState<
(ConnectedAircraft | ConnectedDispatcher)[] | null
>(null);
const timeout = useRef<NodeJS.Timeout | null>(null); const timeout = useRef<NodeJS.Timeout | null>(null);
useEffect(() => { useEffect(() => {
@@ -33,24 +35,26 @@ export const Chat = () => {
}, [session, setOwnId]); }, [session, setOwnId]);
useEffect(() => { useEffect(() => {
const fetchDispatcher = async () => { const fetchConnectedUser = async () => {
const data = await getDispatcher(); const data = await getConenctedUsers();
if (data) { if (data) {
const filteredDispatcher = data.filter((user) => { const filteredConnectedUser = data.filter((user) => {
return ( /* return (
user.userId !== session.data?.user.id && user.userId !== session.data?.user.id &&
!Object.keys(chats).includes(user.userId) !Object.keys(chats).includes(user.userId)
); ); */
return true;
}); });
setDispatcher(filteredDispatcher); setConnectedUser(filteredConnectedUser);
} }
if (!addTabValue && data[0]) setAddTabValue(data[0].userId); if (!addTabValue && data[0]) setAddTabValue(data[0].userId);
}; };
timeout.current = setInterval(() => { timeout.current = setInterval(() => {
fetchDispatcher(); fetchConnectedUser();
}, 1000); }, 1000);
fetchDispatcher(); fetchConnectedUser();
return () => { return () => {
if (timeout.current) { if (timeout.current) {
@@ -61,6 +65,8 @@ export const Chat = () => {
}; };
}, [addTabValue, chats, session.data?.user.id]); }, [addTabValue, chats, session.data?.user.id]);
console.log("connectedUser", connectedUser);
return ( return (
<div className={cn("dropdown dropdown-right", chatOpen && "dropdown-open")}> <div className={cn("dropdown dropdown-right", chatOpen && "dropdown-open")}>
<div className="indicator"> <div className="indicator">
@@ -95,23 +101,23 @@ export const Chat = () => {
value={addTabValue} value={addTabValue}
onChange={(e) => setAddTabValue(e.target.value)} onChange={(e) => setAddTabValue(e.target.value)}
> >
{!dispatcher?.length && ( {!connectedUser?.length && (
<option disabled={true}>Keine Chatpartner gefunden</option> <option disabled={true}>Keine Chatpartner gefunden</option>
)} )}
{dispatcher?.map((user) => ( {connectedUser?.map((user) => (
<option key={user.userId} value={user.userId}> <option key={user.userId} value={user.userId}>
{user.name} {asPublicUser(user.publicUser).fullName}
</option> </option>
))} ))}
</select> </select>
<button <button
className="btn btn-sm btn-soft btn-primary join-item" className="btn btn-sm btn-soft btn-primary join-item"
onClick={() => { onClick={() => {
const user = dispatcher?.find( const user = connectedUser?.find(
(user) => user.userId === addTabValue, (user) => user.userId === addTabValue,
); );
if (!user) return; if (!user) return;
addChat(addTabValue, user.name); addChat(addTabValue, asPublicUser(user.publicUser).fullName);
setSelectedChat(addTabValue); setSelectedChat(addTabValue);
}} }}
> >

View File

@@ -2,11 +2,10 @@
import { ExclamationTriangleIcon, PaperPlaneIcon } from "@radix-ui/react-icons"; import { ExclamationTriangleIcon, PaperPlaneIcon } from "@radix-ui/react-icons";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Dispatcher } from "dispatch/_components/navbar/_components/action";
import { cn } from "helpers/cn"; import { cn } from "helpers/cn";
import { serverApi } from "helpers/axios"; import { getConenctedUsers, serverApi } from "helpers/axios";
import { getDispatcher } from "pilot/_components/navbar/action";
import { useLeftMenuStore } from "_store/leftMenuStore"; import { useLeftMenuStore } from "_store/leftMenuStore";
import { asPublicUser, ConnectedAircraft, ConnectedDispatcher } from "@repo/db";
export const Report = () => { export const Report = () => {
const { setChatOpen, setReportTabOpen, reportTabOpen, setOwnId } = const { setChatOpen, setReportTabOpen, reportTabOpen, setOwnId } =
@@ -15,7 +14,9 @@ export const Report = () => {
const session = useSession(); const session = useSession();
const [selectedPlayer, setSelectedPlayer] = useState<string>(""); const [selectedPlayer, setSelectedPlayer] = useState<string>("");
const [message, setMessage] = useState<string>(""); const [message, setMessage] = useState<string>("");
const [dispatcher, setDispatcher] = useState<Dispatcher[] | null>(null); const [connectedUser, setConnectedUser] = useState<
(ConnectedAircraft | ConnectedDispatcher)[] | null
>(null);
const timeout = useRef<NodeJS.Timeout | null>(null); const timeout = useRef<NodeJS.Timeout | null>(null);
useEffect(() => { useEffect(() => {
@@ -24,21 +25,21 @@ export const Report = () => {
}, [session, setOwnId]); }, [session, setOwnId]);
useEffect(() => { useEffect(() => {
const fetchDispatcher = async () => { const fetchConnectedUser = async () => {
const data = await getDispatcher(); const data = await getConenctedUsers();
if (data) { if (data) {
const filteredDispatcher = data.filter( const filteredConnectedUser = data.filter(
(user) => user.userId !== session.data?.user.id, (user) => user.userId !== session.data?.user.id,
); );
setDispatcher(filteredDispatcher); setConnectedUser(filteredConnectedUser);
} }
if (!selectedPlayer && data[0]) setSelectedPlayer(data[0].userId); if (!selectedPlayer && data[0]) setSelectedPlayer(data[0].userId);
}; };
timeout.current = setInterval(() => { timeout.current = setInterval(() => {
fetchDispatcher(); fetchConnectedUser();
}, 1000000); }, 1000);
fetchDispatcher(); fetchConnectedUser();
return () => { return () => {
if (timeout.current) { if (timeout.current) {
@@ -81,12 +82,12 @@ export const Report = () => {
value={selectedPlayer} value={selectedPlayer}
onChange={(e) => setSelectedPlayer(e.target.value)} onChange={(e) => setSelectedPlayer(e.target.value)}
> >
{!dispatcher?.length && ( {!connectedUser?.length && (
<option disabled={true}>Keine Spieler gefunden</option> <option disabled={true}>Keine Spieler gefunden</option>
)} )}
{dispatcher?.map((user) => ( {connectedUser?.map((user) => (
<option key={user.userId} value={user.userId}> <option key={user.userId} value={user.userId}>
{user.name} {asPublicUser(user).fullName}
</option> </option>
))} ))}
</select> </select>

View File

@@ -1,3 +1,4 @@
import { ConnectedAircraft, ConnectedDispatcher } from "@repo/db";
import axios from "axios"; import axios from "axios";
export const serverApi = axios.create({ export const serverApi = axios.create({
@@ -7,3 +8,15 @@ export const serverApi = axios.create({
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
}); });
export const getConenctedUsers = async (): Promise<
(ConnectedDispatcher | ConnectedAircraft)[]
> => {
const res = await serverApi.get<(ConnectedDispatcher | ConnectedAircraft)[]>(
"/status/connected-users",
);
if (res.status !== 200) {
throw new Error("Failed to fetch connected users");
}
return res.data;
};

View File

@@ -1,13 +1,16 @@
"use client"; "use client";
import { ChatBubbleIcon, PaperPlaneIcon } from "@radix-ui/react-icons"; import { ChatBubbleIcon, PaperPlaneIcon } from "@radix-ui/react-icons";
import { leftMenuStore } from "_store/leftMenuStore"; import { useLeftMenuStore } from "_store/leftMenuStore";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { Fragment, useEffect, useRef, useState } from "react"; import { Fragment, useEffect, useRef, useState } from "react";
import {
Dispatcher,
getDispatcher,
} from "dispatch/_components/navbar/_components/action";
import { cn } from "helpers/cn"; import { cn } from "helpers/cn";
import { getConenctedUsers } from "helpers/axios";
import {
asPublicUser,
ConnectedAircraft,
ConnectedDispatcher,
PublicUser,
} from "@repo/db";
export const Chat = () => { export const Chat = () => {
const { const {
@@ -20,38 +23,40 @@ export const Chat = () => {
selectedChat, selectedChat,
setSelectedChat, setSelectedChat,
setChatNotification, setChatNotification,
} = leftMenuStore(); } = useLeftMenuStore();
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const session = useSession(); const session = useSession();
const [addTabValue, setAddTabValue] = useState<string>(""); const [addTabValue, setAddTabValue] = useState<string>("");
const [message, setMessage] = useState<string>(""); const [message, setMessage] = useState<string>("");
const [dispatcher, setDispatcher] = useState<Dispatcher[] | null>(null); const [connectedUser, setConnectedUser] = useState<
(ConnectedAircraft | ConnectedDispatcher)[] | null
>(null);
const timeout = useRef<NodeJS.Timeout | null>(null); const timeout = useRef<NodeJS.Timeout | null>(null);
useEffect(() => { useEffect(() => {
if (!session.data?.user.id) return; if (!session.data?.user.id) return;
setOwnId(session.data.user.id); setOwnId(session.data.user.id);
}, [session]); }, [session, setOwnId]);
useEffect(() => { useEffect(() => {
const fetchDispatcher = async () => { const fetchConnectedUser = async () => {
const data = await getDispatcher(); const data = await getConenctedUsers();
if (data) { if (data) {
const filteredDispatcher = data.filter((user) => { const filteredConnectedUser = data.filter((user) => {
return ( return (
user.userId !== session.data?.user.id && user.userId !== session.data?.user.id &&
!Object.keys(chats).includes(user.userId) !Object.keys(chats).includes(user.userId)
); );
}); });
setDispatcher(filteredDispatcher); setConnectedUser(filteredConnectedUser);
} }
if (!addTabValue && data[0]) setAddTabValue(data[0].userId); if (!addTabValue && data[0]) setAddTabValue(data[0].userId);
}; };
timeout.current = setInterval(() => { timeout.current = setInterval(() => {
fetchDispatcher(); fetchConnectedUser();
}, 1000000); }, 1000000);
fetchDispatcher(); fetchConnectedUser();
return () => { return () => {
if (timeout.current) { if (timeout.current) {
@@ -60,7 +65,7 @@ export const Chat = () => {
console.log("cleared"); console.log("cleared");
} }
}; };
}, [addTabValue, chats]); }, [addTabValue, chats, session]);
return ( return (
<div <div
@@ -95,23 +100,24 @@ export const Chat = () => {
value={addTabValue} value={addTabValue}
onChange={(e) => setAddTabValue(e.target.value)} onChange={(e) => setAddTabValue(e.target.value)}
> >
{!dispatcher?.length && ( {!connectedUser?.length && (
<option disabled={true}>Keine Chatpartner gefunden</option> <option disabled={true}>Keine Chatpartner gefunden</option>
)} )}
{dispatcher?.map((user) => ( {connectedUser?.map((user) => (
<option key={user.userId} value={user.userId}> <option key={user.userId} value={user.userId}>
{user.name} {(user.publicUser as unknown as PublicUser).firstname}{" "}
{(user.publicUser as unknown as PublicUser).lastname}
</option> </option>
))} ))}
</select> </select>
<button <button
className="btn btn-sm btn-soft btn-primary join-item" className="btn btn-sm btn-soft btn-primary join-item"
onClick={() => { onClick={() => {
const user = dispatcher?.find( const user = connectedUser?.find(
(user) => user.userId === addTabValue, (user) => user.userId === addTabValue,
); );
if (!user) return; if (!user) return;
addChat(addTabValue, user.name); addChat(addTabValue, asPublicUser(user.publicUser).fullName);
setSelectedChat(addTabValue); setSelectedChat(addTabValue);
}} }}
> >
@@ -203,7 +209,7 @@ export const Chat = () => {
}} }}
disabled={sending} disabled={sending}
role="button" role="button"
onSubmit={(e) => false} onSubmit={() => false}
> >
{sending ? ( {sending ? (
<span className="loading loading-spinner loading-sm"></span> <span className="loading loading-spinner loading-sm"></span>

View File

@@ -1,5 +1,3 @@
"use server";
export interface Dispatcher { export interface Dispatcher {
userId: string; userId: string;
lastSeen: string; lastSeen: string;
@@ -9,10 +7,3 @@ export interface Dispatcher {
name: string; name: string;
socketId: string; socketId: string;
} }
export const getDispatcher = async () => {
const res = await fetch(`
${process.env.NEXT_PUBLIC_DISPATCH_SERVER_URL}/dispatcher`);
const data = await res.json();
return data as Dispatcher[];
};

View File

@@ -93,4 +93,4 @@ volumes:
moodle_database: moodle_database:
moodle_moodledata: moodle_moodledata:
redis_data: redis_data:
driver: local driver: local

Binary file not shown.

View File

@@ -5,6 +5,7 @@ export interface PublicUser {
lastname: string; lastname: string;
publicId: string; publicId: string;
badges: string[]; badges: string[];
fullName: string;
} }
export const getPublicUser = (user: User): PublicUser => { export const getPublicUser = (user: User): PublicUser => {
@@ -14,7 +15,18 @@ export const getPublicUser = (user: User): PublicUser => {
.split(" ") .split(" ")
.map((part) => `${part[0]}.`) .map((part) => `${part[0]}.`)
.join(" "), // Only take the first part of the name .join(" "), // Only take the first part of the name
fullName: `${user.firstname} ${user.lastname
.split(" ")
.map((part) => `${part[0]}.`)
.join(" ")}`,
publicId: user.publicId, publicId: user.publicId,
badges: user.badges, badges: user.badges,
}; };
}; };
export const asPublicUser = (publicUSerJson: unknown): PublicUser => {
if (typeof publicUSerJson !== "object" || publicUSerJson === null) {
throw new Error("Invalid JSON format for PublicUser");
}
return publicUSerJson as PublicUser;
};

View File

@@ -1,4 +1,4 @@
model connectedAircraft { model ConnectedAircraft {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
userId String userId String
publicUser Json publicUser Json

View File

@@ -35,5 +35,5 @@ model Station {
MissionsOnStations MissionsOnStations[] MissionsOnStations MissionsOnStations[]
MissionOnStationUsers MissionOnStationUsers[] MissionOnStationUsers MissionOnStationUsers[]
connectedAircraft connectedAircraft[] ConnectedAircraft ConnectedAircraft[]
} }

View File

@@ -49,7 +49,7 @@ model User {
Mission Mission[] Mission Mission[]
MissionOnStationUsers MissionOnStationUsers[] MissionOnStationUsers MissionOnStationUsers[]
ConnectedDispatcher ConnectedDispatcher[] ConnectedDispatcher ConnectedDispatcher[]
connectedAircraft connectedAircraft[] ConnectedAircraft ConnectedAircraft[]
PositionLog PositionLog[] PositionLog PositionLog[]
@@map(name: "users") @@map(name: "users")