diff --git a/frontend/src/App.vue b/frontend/src/App.vue index e4ec154..678e596 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -185,6 +185,16 @@ type BillingNotificationData = { text?: string title?: string } + +type CrewLinkNotificationData = { + actor?: string + device?: PhoneNotificationDevicePayload + groupName?: string + kind?: 'invite' | 'member_joined' | 'ping' | 'role' | 'removed' + pingLabel?: string + text?: string + title?: string +} const REFERENCE_VIEWPORT_WIDTH = 1920 const REFERENCE_VIEWPORT_HEIGHT = 1080 const PHONE_BASE_SCALE = 0.69 @@ -656,6 +666,28 @@ function onMessage(event: MessageEvent): void { } } notifications.show(notification) + } else if ( + event.data?.type === 'crewlink:notification' && + event.data.data + ) { + const data = event.data.data as CrewLinkNotificationData + const notification: PhoneNotificationInput = { + appId: 'crewlink', + subtitle: data.groupName, + text: data.text ?? phone.t('Apps.crewlink.notifications.default'), + title: data.title ?? phone.t('Apps.crewlink.name'), + } + if ( + data.device && + (!phone.isOpen || data.device.imei !== phone.device?.imei) + ) { + notification.device = { + imei: data.device.imei, + name: data.device.name, + preferences: parsePhonePreferences(data.device.settings ?? null), + } + } + notifications.show(notification) } else if ( (event.data?.type === 'call:incoming' || event.data?.type === 'call:state') && diff --git a/frontend/src/assets/img/app-icons/crewlink.svg b/frontend/src/assets/img/app-icons/crewlink.svg new file mode 100644 index 0000000..27e2f21 --- /dev/null +++ b/frontend/src/assets/img/app-icons/crewlink.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/config/apps.test.ts b/frontend/src/config/apps.test.ts index 8fba0d7..f69db3a 100644 --- a/frontend/src/config/apps.test.ts +++ b/frontend/src/config/apps.test.ts @@ -150,6 +150,7 @@ describe('app registry', () => { 'flare', 'radio', 'local-pages', + 'crewlink', 'phone', 'darkchat', 'banking', diff --git a/frontend/src/config/apps.ts b/frontend/src/config/apps.ts index 6d5638d..efcac80 100644 --- a/frontend/src/config/apps.ts +++ b/frontend/src/config/apps.ts @@ -31,6 +31,7 @@ import { Music2, Feather, ReceiptText, + UsersRound, } from 'lucide-vue-next' import { defineAsyncComponent, markRaw } from 'vue' @@ -68,6 +69,7 @@ import picstagramIcon from '@/assets/img/app-icons/picstagram.webp' import skyRideIcon from '@/assets/img/app-icons/skyride.svg' import musicIcon from '@/assets/img/app-icons/music.svg' import featherIcon from '@/assets/img/app-icons/feather.svg' +import crewLinkIcon from '@/assets/img/app-icons/crewlink.svg' import type { LaunchablePhoneAppDefinition, LaunchablePhoneAppId, @@ -187,6 +189,20 @@ export const PHONE_APPS: PhoneAppDefinition[] = [ labelKey: 'Apps.localPages.name', route: '/apps/local-pages', }, + { + category: 'social', + component: markRaw( + defineAsyncComponent(() => import('@/views/apps/CrewLinkApp.vue')), + ), + dockOrder: null, + gridOrder: 27, + icon: markRaw(UsersRound), + iconClass: 'app-icon--crewlink', + iconImage: crewLinkIcon, + id: 'crewlink', + labelKey: 'Apps.crewlink.name', + route: '/apps/crewlink', + }, { category: 'social', component: markRaw( diff --git a/frontend/src/stores/crewlink.test.ts b/frontend/src/stores/crewlink.test.ts new file mode 100644 index 0000000..64901c6 --- /dev/null +++ b/frontend/src/stores/crewlink.test.ts @@ -0,0 +1,96 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' + +vi.mock('@/utils/nui', () => ({ + nuiCall: vi.fn(), +})) + +import { useCrewLinkStore } from '@/stores/crewlink' +import { nuiCall } from '@/utils/nui' + +describe('CrewLink store', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.mocked(nuiCall).mockReset() + }) + + it('hydrates a profile and active group from bootstrap', async () => { + vi.mocked(nuiCall).mockResolvedValue({ + success: true, + data: { + activeGroup: { + allowMemberPings: true, + colour: 'cyan', + id: 'group-1', + isOwner: true, + memberCount: 1, + members: [], + name: 'Night Shift', + overheadAllowed: true, + pings: [], + role: 'owner', + }, + groups: [], + invitations: [], + profile: { + activeGroupId: 'group-1', + id: 'profile-1', + mapVisible: true, + overheadVisible: false, + username: 'Skyline', + }, + }, + }) + const store = useCrewLinkStore() + expect(await store.bootstrap()).toBe(true) + expect(store.profile?.username).toBe('Skyline') + expect(store.activeGroup?.name).toBe('Night Shift') + }) + + it('keeps the server error from a rejected request', async () => { + vi.mocked(nuiCall).mockResolvedValue({ + success: false, + error: 'invalid_code', + }) + const store = useCrewLinkStore() + const response = await store.joinCode('BADCODE') + expect(response.success).toBe(false) + expect(store.error).toBe('invalid_code') + }) + + it('applies live members without replacing group metadata', async () => { + const store = useCrewLinkStore() + store.activeGroup = { + allowMemberPings: true, + colour: 'blue', + id: 'group-1', + isOwner: false, + memberCount: 2, + members: [], + name: 'Road Crew', + overheadAllowed: true, + pings: [], + role: 'member', + } + vi.mocked(nuiCall).mockResolvedValue({ + success: true, + data: { + members: [ + { + id: 'profile-2', + joinedAt: 1, + mapVisible: true, + online: true, + overheadVisible: true, + role: 'member', + username: 'Nova', + }, + ], + pings: [], + }, + }) + expect(await store.refreshLive()).toBe(true) + expect(store.activeGroup.name).toBe('Road Crew') + expect(store.activeGroup.members[0].online).toBe(true) + }) +}) diff --git a/frontend/src/stores/crewlink.ts b/frontend/src/stores/crewlink.ts new file mode 100644 index 0000000..0b8ea0e --- /dev/null +++ b/frontend/src/stores/crewlink.ts @@ -0,0 +1,180 @@ +import { defineStore } from 'pinia' + +import type { + CrewLinkBootstrap, + CrewLinkColour, + CrewLinkGroup, + CrewLinkLive, + CrewLinkNearbyPlayer, + CrewLinkPing, + CrewLinkPingType, + CrewLinkRole, +} from '@/types/crewlink' +import { nuiCall, type NuiResponse } from '@/utils/nui' + +export const useCrewLinkStore = defineStore('crewlink', { + state: () => ({ + activeGroup: null as CrewLinkGroup | null, + error: '', + groups: [] as CrewLinkBootstrap['groups'], + invitations: [] as CrewLinkBootstrap['invitations'], + isLoading: false, + limits: null as CrewLinkBootstrap['limits'] | null, + profile: null as CrewLinkBootstrap['profile'], + }), + actions: { + applyBootstrap(data: CrewLinkBootstrap): void { + this.profile = data.profile + this.groups = data.groups ?? [] + this.activeGroup = data.activeGroup ?? null + this.invitations = data.invitations ?? [] + this.limits = data.limits ?? null + this.error = '' + }, + async bootstrap(): Promise { + this.isLoading = true + const response = await nuiCall('crewlink:bootstrap') + this.isLoading = false + if (response.success && response.data) { + this.applyBootstrap(response.data) + return true + } + this.error = response.error ?? 'request_failed' + return false + }, + async request( + endpoint: string, + data: Record = {}, + refresh = true, + ): Promise> { + this.isLoading = true + const response = await nuiCall(endpoint, data) + this.isLoading = false + if (!response.success) { + this.error = response.error ?? 'request_failed' + return response + } + this.error = '' + if (response.data?.profile !== undefined) { + this.applyBootstrap(response.data) + } else if (refresh) { + await this.bootstrap() + } + return response + }, + createProfile(username: string): Promise> { + return this.request('crewlink:create-profile', { username }) + }, + updateProfile( + username: string, + mapVisible: boolean, + overheadVisible: boolean, + ): Promise> { + return this.request('crewlink:update-profile', { + mapVisible, + overheadVisible, + username, + }) + }, + createGroup( + name: string, + colour: CrewLinkColour, + ): Promise> { + return this.request('crewlink:create-group', { colour, name }) + }, + updateGroup( + groupId: string, + name: string, + colour: CrewLinkColour, + allowMemberPings: boolean, + overheadAllowed: boolean, + ): Promise> { + return this.request('crewlink:update-group', { + allowMemberPings, + colour, + groupId, + name, + overheadAllowed, + }) + }, + deleteGroup(groupId: string): Promise> { + return this.request('crewlink:delete-group', { groupId }) + }, + setActive(groupId: string): Promise> { + return this.request('crewlink:set-active', { groupId }) + }, + joinCode(code: string): Promise> { + return this.request('crewlink:join-code', { code }) + }, + rotateCode(groupId: string): Promise> { + return nuiCall<{ inviteCode: string }>('crewlink:rotate-code', { groupId }) + }, + nearby(): Promise> { + return nuiCall('crewlink:nearby') + }, + inviteNearby(targetSource: number): Promise { + return this.request( + 'crewlink:invite-nearby', + { targetSource }, + false, + ) + }, + respondInvite( + invitationId: string, + accepted: boolean, + ): Promise> { + return this.request('crewlink:respond-invite', { + accepted, + invitationId, + }) + }, + updateMember( + groupId: string, + profileId: string, + role: CrewLinkRole, + ): Promise> { + return this.request('crewlink:update-member', { + groupId, + profileId, + role, + }) + }, + transferOwner( + groupId: string, + profileId: string, + ): Promise> { + return this.request('crewlink:transfer-owner', { groupId, profileId }) + }, + removeMember( + groupId: string, + profileId: string, + ): Promise> { + return this.request('crewlink:remove-member', { groupId, profileId }) + }, + leave(groupId: string): Promise> { + return this.request('crewlink:leave', { groupId }) + }, + createPing( + type: CrewLinkPingType, + label: string, + coords?: { x: number; y: number; z: number }, + ): Promise> { + return nuiCall('crewlink:create-ping', { + coords, + label, + type, + useCurrent: !coords, + }) + }, + removePing(pingId: string): Promise { + return this.request('crewlink:remove-ping', { pingId }) + }, + async refreshLive(): Promise { + const response = await nuiCall('crewlink:live') + if (!response.success || !response.data || !this.activeGroup) return false + this.activeGroup.members = response.data.members + this.activeGroup.pings = response.data.pings + return true + }, + }, +}) diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index 6f8027e..e9288b9 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -40,6 +40,205 @@ const namespaceQueues = new Map>() const defaultLocales: LocaleTree = { Apps: { + crewlink: { + name: 'CrewLink', + connecting: 'Connecting your crew...', + privateNetwork: 'Private location network', + signInTitle: 'Connect your iFruit Account', + signInBody: + 'CrewLink uses your private iFruit identity to keep groups and roles available across your phones.', + openSettings: 'Open iFruit Settings', + welcomeEyebrow: 'Your crew. One signal.', + welcomeTitle: 'Find your people', + welcomeBody: + 'Choose a private CrewLink username. Only confirmed group members can see your presence and shared location.', + username: 'CrewLink username', + usernamePlaceholder: 'e.g. Skyline', + createProfile: 'Create CrewLink Profile', + profileCreated: 'Your CrewLink profile is ready.', + privacyNote: 'Private by design. No public player search.', + noGroupEyebrow: 'No active crew', + noGroupTitle: 'Build your private network', + noGroupBody: + 'Create a crew or join friends with a private invitation code.', + createGroup: 'Create Group', + createGroupBody: + 'Give your crew a recognizable name and signal colour.', + joinGroup: 'Join Group', + joinWithCode: 'Join with Code', + joinWithCodeBody: + 'Enter the private eight-character code shared by a coordinator.', + groupName: 'Group name', + groupNamePlaceholder: 'e.g. Night Shift', + groupColour: 'Signal colour', + createCrew: 'Create Crew', + joinCrew: 'Join Crew', + inviteCode: 'Invitation code', + inviteCodePlaceholder: 'AB12CD34', + groupCreated: 'Crew created.', + joinedGroup: 'You joined the crew.', + activeGroupChanged: 'Active crew changed.', + pendingInvitations: 'Invitations', + invitedBy: 'Invited by @{username}', + inviteAccepted: 'Invitation accepted.', + inviteDeclined: 'Invitation declined.', + navigation: 'CrewLink navigation', + map: 'Map', + crew: 'Crew', + pings: 'Pings', + profile: 'Profile', + onlineNow: 'online now', + zoomIn: 'Zoom in', + zoomOut: 'Zoom out', + myLocation: 'Center my location', + member: 'Member', + members: 'Members', + openCrew: 'View team', + newPing: 'New Ping', + shareLocation: 'Mark a place', + locationUnavailable: 'Your live location is currently hidden.', + activeCrew: 'Active private crew', + groupSummarySingle: '{online} of {total} member online', + groupSummary: '{online} of {total} members online', + private: 'Private', + nearby: 'Nearby', + copyCode: 'Copy code', + manage: 'Manage', + refresh: 'Refresh', + codeCopied: 'Invitation code copied.', + codeRotated: 'A new invitation code is active.', + peopleNearby: 'People Nearby', + peopleNearbyBody: + 'Only CrewLink users within {distance} meters appear here.', + metersAway: '{distance} m away', + invite: 'Invite', + inviteSent: 'Invitation sent to @{username}.', + nobodyNearby: 'Nobody in range', + nobodyNearbyBody: + 'Move closer to another CrewLink user and scan again.', + scanAgain: 'Scan Again', + liveCoordination: 'Live coordination', + noPings: 'No active pings', + noPingsBody: 'Share a meeting point, warning, or target with your crew.', + expiresMinutes: '{count} min left', + expiresSeconds: '{count} sec left', + sharedBy: 'Shared by @{username}', + setRoute: 'Set Route', + routeSet: 'GPS route set.', + pingCreated: 'Ping shared with your crew.', + pingRemoved: 'Ping removed.', + newPingBody: + 'Share your current position or place the ping at the center of the map.', + pingLabel: 'Ping label', + pingLabelPlaceholder: 'e.g. Meet at the garage', + placeOnMap: 'Use map center', + placeOnMapBody: 'Otherwise your current position is used.', + sharePing: 'Share Ping', + pingTypes: { + meeting: 'Meet', + danger: 'Danger', + help: 'Help', + target: 'Target', + info: 'Info', + }, + status: { live: 'Live', hidden: 'Location hidden', offline: 'Offline' }, + roles: { + owner: 'Owner', + coordinator: 'Coordinator', + moderator: 'Moderator', + member: 'Member', + guest: 'Guest', + }, + roleDescriptions: { + coordinator: 'Manage invitations, settings, roles, and pings.', + moderator: 'Invite nearby users and moderate group pings.', + member: 'Share presence and create pings when enabled.', + guest: 'View the active crew without coordination permissions.', + }, + manageCrew: 'Manage Crew', + manageCrewBody: 'Update identity, permissions, and invitations.', + memberPings: 'Member pings', + memberPingsBody: 'Allow members and guests to create pings.', + allowOverhead: 'Overhead labels', + allowOverheadBody: 'Permit nearby opt-in member labels in the world.', + groupSaved: 'Crew settings saved.', + assignRole: 'Assign Role', + roleUpdated: 'Member role updated.', + transferOwnership: 'Transfer Ownership', + removeMember: 'Remove from Crew', + yourCrewLinkId: 'Your CrewLink ID', + privacyVisibility: 'Privacy & Visibility', + shareOnMap: 'Share on group map', + shareOnMapBody: 'Active members can see your live position.', + overheadLabels: 'Nearby overhead label', + overheadLabelsBody: 'Show your CrewLink name to nearby opted-in members.', + yourGroups: 'Your Groups', + createAnotherGroup: 'Create another group', + account: 'CrewLink Account', + externalApi: 'Connected resources', + externalApiBody: 'Approved scripts may add temporary group pings.', + editUsername: 'Edit Username', + editUsernameBody: 'Your username is unique across CrewLink.', + profileSaved: 'CrewLink profile saved.', + deleteGroup: 'Delete Group', + leaveGroup: 'Leave Group', + confirmAction: 'Confirm', + confirm: { + 'delete-group': { + title: 'Delete this crew?', + body: 'The group, memberships, invitations, and active pings will be permanently removed.', + }, + 'leave-group': { + title: 'Leave this crew?', + body: 'You will lose access to its members, map, and pings.', + }, + 'remove-member': { + title: 'Remove this member?', + body: 'They will immediately lose access to this crew.', + }, + 'transfer-owner': { + title: 'Transfer ownership?', + body: 'The selected member becomes owner and you become coordinator.', + }, + }, + 'delete-groupDone': 'Crew deleted.', + 'leave-groupDone': 'You left the crew.', + 'remove-memberDone': 'Member removed.', + 'transfer-ownerDone': 'Ownership transferred.', + notifications: { + invite: '{actor} invited you to {group}.', + member_joined: '{actor} joined {group}.', + ping: '{actor} shared “{ping}” with {group}.', + role: 'Your CrewLink role changed in {group}.', + removed: 'You were removed from {group}.', + default: 'Your CrewLink group has an update.', + }, + errors: { + not_authenticated: 'Sign in to your iFruit Account first.', + profile_required: 'Create your CrewLink profile first.', + invalid_username: 'Use 3–20 letters, numbers, dots, or underscores.', + invalid_profile: 'Check your profile details.', + username_taken: 'That CrewLink username is already taken.', + invalid_group: 'Choose a valid group name and signal colour.', + group_limit: 'You have reached your group limit.', + member_limit: 'This group has reached its member limit.', + group_not_found: 'This group is no longer available.', + invalid_code: 'That invitation code is invalid.', + already_member: 'You are already a member of this group.', + forbidden: 'Your current role cannot perform this action.', + player_not_nearby: 'That player is no longer nearby.', + player_unavailable: 'That player cannot receive a CrewLink invitation.', + invalid_invitation: 'This invitation is invalid.', + invitation_expired: 'This invitation has expired.', + invalid_role: 'That role cannot be assigned.', + owner_must_transfer: 'Transfer ownership before leaving this group.', + invalid_ping: 'Choose a valid ping type, label, and position.', + ping_limit: 'This crew already has too many active pings.', + ping_not_found: 'That ping is no longer active.', + rate_limited: 'Too many CrewLink actions. Try again shortly.', + request_failed: 'CrewLink is temporarily unavailable.', + }, + }, flare: { name: 'Flare', signInTitle: 'Sign in to Flare', diff --git a/frontend/src/types/apps.ts b/frontend/src/types/apps.ts index af95753..6483166 100644 --- a/frontend/src/types/apps.ts +++ b/frontend/src/types/apps.ts @@ -35,6 +35,7 @@ export type PhoneAppId = | 'picstagram' | 'skyride' | 'feather' + | 'crewlink' export type LaunchablePhoneAppId = PhoneAppId diff --git a/frontend/src/types/crewlink.ts b/frontend/src/types/crewlink.ts new file mode 100644 index 0000000..ae54bda --- /dev/null +++ b/frontend/src/types/crewlink.ts @@ -0,0 +1,108 @@ +import type { MapPoint } from '@/features/map/defaultMapGeometry' + +export type CrewLinkRole = + | 'owner' + | 'coordinator' + | 'moderator' + | 'member' + | 'guest' + +export type CrewLinkColour = + | 'cyan' + | 'blue' + | 'violet' + | 'orange' + | 'green' + | 'rose' + +export type CrewLinkPingType = + | 'meeting' + | 'danger' + | 'help' + | 'target' + | 'info' + +export type CrewLinkProfile = { + activeGroupId: string | null + id: string + mapVisible: boolean + overheadVisible: boolean + username: string +} + +export type CrewLinkMember = { + coords?: MapPoint & { z: number } + id: string + joinedAt: number + mapVisible: boolean + online: boolean + overheadVisible: boolean + role: CrewLinkRole + source?: number + username: string +} + +export type CrewLinkPing = { + coords: MapPoint & { z: number } + createdAt: number + creatorProfileId: string | null + creatorUsername: string + expiresAt: number + id: string + label: string + sourceResource?: string | null + type: CrewLinkPingType +} + +export type CrewLinkGroupSummary = { + allowMemberPings: boolean + colour: CrewLinkColour + id: string + inviteCode?: string + isOwner: boolean + memberCount: number + name: string + overheadAllowed: boolean + role: CrewLinkRole +} + +export type CrewLinkGroup = CrewLinkGroupSummary & { + members: CrewLinkMember[] + pings: CrewLinkPing[] +} + +export type CrewLinkInvitation = { + colour: CrewLinkColour + expiresAt: number + groupId: string + groupName: string + id: string + inviterUsername: string +} + +export type CrewLinkLimits = { + maximumGroups: number + maximumMembers: number + nearbyDistance: number + overheadDistance: number + pingLifetimeSeconds: number +} + +export type CrewLinkBootstrap = { + activeGroup?: CrewLinkGroup | null + groups: CrewLinkGroupSummary[] + invitations: CrewLinkInvitation[] + limits?: CrewLinkLimits + profile: CrewLinkProfile | null +} + +export type CrewLinkNearbyPlayer = { + distance: number + source: number + username: string +} + +export type CrewLinkLive = { + members: CrewLinkMember[] + pings: CrewLinkPing[] +} diff --git a/frontend/src/utils/preferences.ts b/frontend/src/utils/preferences.ts index 0f63fdc..3a5464d 100644 --- a/frontend/src/utils/preferences.ts +++ b/frontend/src/utils/preferences.ts @@ -84,6 +84,7 @@ const DEFAULT_APP_NOTIFICATIONS: Record< picstagram: { enabled: true, sounds: true }, fliptok: { enabled: true, sounds: true }, feather: { enabled: true, sounds: true }, + crewlink: { enabled: true, sounds: true }, camera: { enabled: true, sounds: true }, clock: { enabled: true, sounds: true }, calendar: { enabled: true, sounds: true }, diff --git a/frontend/src/views/apps/CrewLinkApp.vue b/frontend/src/views/apps/CrewLinkApp.vue new file mode 100644 index 0000000..35ae297 --- /dev/null +++ b/frontend/src/views/apps/CrewLinkApp.vue @@ -0,0 +1,1165 @@ + + + + + diff --git a/frontend/testserver/index.cjs b/frontend/testserver/index.cjs index 9d30391..c9b57f2 100644 --- a/frontend/testserver/index.cjs +++ b/frontend/testserver/index.cjs @@ -142,6 +142,243 @@ let mockMapMarkers = [ label: 'Meeting point', }, ] +let crewLinkProfile = { + activeGroupId: 'crewlink-group-night-shift', + id: 'crewlink-profile-skyline', + mapVisible: true, + overheadVisible: false, + username: 'Skyline', +} +let crewLinkGroups = [ + { + allowMemberPings: true, + colour: 'cyan', + id: 'crewlink-group-night-shift', + inviteCode: 'N1GHT247', + isOwner: true, + memberCount: 6, + name: 'Night Shift', + overheadAllowed: true, + role: 'owner', + }, + { + allowMemberPings: false, + colour: 'violet', + id: 'crewlink-group-coastline', + isOwner: false, + memberCount: 4, + name: 'Coastline Crew', + overheadAllowed: true, + role: 'member', + }, +] +const crewLinkMembers = { + 'crewlink-group-night-shift': [ + { + coords: { x: -155.2, y: -1005.8, z: 28.4 }, + id: 'crewlink-profile-skyline', + joinedAt: Date.now() - 36 * 86400000, + mapVisible: true, + online: true, + overheadVisible: false, + role: 'owner', + source: 1, + username: 'Skyline', + }, + { + coords: { x: -67.6, y: -818.1, z: 326.2 }, + id: 'crewlink-profile-nova', + joinedAt: Date.now() - 30 * 86400000, + mapVisible: true, + online: true, + overheadVisible: true, + role: 'coordinator', + source: 22, + username: 'Nova', + }, + { + coords: { x: 214.8, y: -810.4, z: 30.7 }, + id: 'crewlink-profile-ghost', + joinedAt: Date.now() - 20 * 86400000, + mapVisible: true, + online: true, + overheadVisible: true, + role: 'moderator', + source: 38, + username: 'Ghost', + }, + { + id: 'crewlink-profile-luna', + joinedAt: Date.now() - 12 * 86400000, + mapVisible: false, + online: true, + overheadVisible: false, + role: 'member', + source: 41, + username: 'Luna', + }, + { + id: 'crewlink-profile-mason', + joinedAt: Date.now() - 8 * 86400000, + mapVisible: true, + online: false, + overheadVisible: false, + role: 'member', + username: 'Mason', + }, + { + id: 'crewlink-profile-raven', + joinedAt: Date.now() - 2 * 86400000, + mapVisible: true, + online: false, + overheadVisible: false, + role: 'guest', + username: 'Raven', + }, + ], + 'crewlink-group-coastline': [ + { + coords: { x: -1204.3, y: -1488.9, z: 4.4 }, + id: 'crewlink-profile-skyline', + joinedAt: Date.now() - 5 * 86400000, + mapVisible: true, + online: true, + overheadVisible: false, + role: 'member', + source: 1, + username: 'Skyline', + }, + { + coords: { x: -1315.5, y: -1520.2, z: 4.4 }, + id: 'crewlink-profile-wave', + joinedAt: Date.now() - 40 * 86400000, + mapVisible: true, + online: true, + overheadVisible: true, + role: 'owner', + source: 18, + username: 'Wave', + }, + { + id: 'crewlink-profile-sunset', + joinedAt: Date.now() - 20 * 86400000, + mapVisible: true, + online: false, + overheadVisible: false, + role: 'moderator', + username: 'Sunset', + }, + { + id: 'crewlink-profile-finn', + joinedAt: Date.now() - 10 * 86400000, + mapVisible: false, + online: true, + overheadVisible: false, + role: 'guest', + source: 56, + username: 'Finn', + }, + ], +} +const crewLinkPings = { + 'crewlink-group-night-shift': [ + { + coords: { x: 233.1, y: -876.4, z: 30.5 }, + createdAt: Date.now() - 45000, + creatorProfileId: 'crewlink-profile-ghost', + creatorUsername: 'Ghost', + expiresAt: Date.now() + 255000, + id: 'crewlink-ping-meeting', + label: 'Meet behind the garage', + type: 'meeting', + }, + { + coords: { x: 452.6, y: -980.2, z: 30.7 }, + createdAt: Date.now() - 110000, + creatorProfileId: 'crewlink-profile-nova', + creatorUsername: 'Nova', + expiresAt: Date.now() + 190000, + id: 'crewlink-ping-danger', + label: 'Avoid Mission Row', + type: 'danger', + }, + { + coords: { x: -1034.2, y: -2732.1, z: 20.1 }, + createdAt: Date.now() - 25000, + creatorProfileId: null, + creatorUsername: 'sky_mission', + expiresAt: Date.now() + 275000, + id: 'crewlink-ping-target', + label: 'Airport pickup', + sourceResource: 'sky_mission', + type: 'target', + }, + ], + 'crewlink-group-coastline': [ + { + coords: { x: -1302.2, y: -1541.4, z: 4.2 }, + createdAt: Date.now() - 80000, + creatorProfileId: 'crewlink-profile-wave', + creatorUsername: 'Wave', + expiresAt: Date.now() + 220000, + id: 'crewlink-ping-coast', + label: 'Boardwalk meeting', + type: 'info', + }, + ], +} +let crewLinkInvitations = [ + { + colour: 'orange', + expiresAt: Date.now() + 25000, + groupId: 'crewlink-group-sandy', + groupName: 'Sandy Trails', + id: 'crewlink-invite-sandy', + inviterUsername: 'Dusty', + }, +] +const crewLinkNearby = [ + { distance: 2.4, source: 72, username: 'Echo' }, + { distance: 4.7, source: 83, username: 'Atlas' }, +] +const crewLinkLimits = { + maximumGroups: 5, + maximumMembers: 16, + nearbyDistance: 5, + overheadDistance: 50, + pingLifetimeSeconds: 300, +} + +function crewLinkBootstrap(testScenario = '') { + if (testScenario === 'crewlink-onboarding') { + return { groups: [], invitations: [], profile: null } + } + if (testScenario === 'crewlink-empty') { + return { + activeGroup: null, + groups: [], + invitations: crewLinkInvitations, + limits: crewLinkLimits, + profile: { ...crewLinkProfile, activeGroupId: null }, + } + } + const activeSummary = crewLinkGroups.find( + (group) => group.id === crewLinkProfile.activeGroupId, + ) + return { + activeGroup: activeSummary + ? { + ...activeSummary, + members: crewLinkMembers[activeSummary.id] ?? [], + pings: crewLinkPings[activeSummary.id] ?? [], + } + : null, + groups: crewLinkGroups, + invitations: crewLinkInvitations, + limits: crewLinkLimits, + profile: crewLinkProfile, + } +} const flipTokProfile = { id: 1, handle: 'skyline', @@ -2422,6 +2659,260 @@ app.post('/api/:endpoint', (request, response) => { console.log(`[NUI] ${request.params.endpoint}`, request.body) const endpoint = request.params.endpoint const testScenario = String(request.body._testScenario ?? '') + if (endpoint === 'crewlink:bootstrap') { + response.json({ success: true, data: crewLinkBootstrap(testScenario) }) + return + } + if (endpoint === 'crewlink:create-profile') { + const username = String(request.body.username ?? '').trim() + if (!/^[A-Za-z0-9][A-Za-z0-9._]{1,18}[A-Za-z0-9]$/.test(username)) { + response.json({ success: false, error: 'invalid_username' }) + return + } + crewLinkProfile = { + activeGroupId: null, + id: `crewlink-profile-${Date.now()}`, + mapVisible: true, + overheadVisible: false, + username, + } + response.json({ success: true, data: crewLinkBootstrap() }) + return + } + if (endpoint === 'crewlink:update-profile') { + crewLinkProfile = { + ...crewLinkProfile, + mapVisible: request.body.mapVisible === true, + overheadVisible: request.body.overheadVisible === true, + username: String(request.body.username ?? crewLinkProfile.username), + } + for (const members of Object.values(crewLinkMembers)) { + const own = members.find((member) => member.id === crewLinkProfile.id) + if (own) { + own.mapVisible = crewLinkProfile.mapVisible + own.overheadVisible = crewLinkProfile.overheadVisible + own.username = crewLinkProfile.username + } + } + response.json({ success: true, data: crewLinkBootstrap() }) + return + } + if (endpoint === 'crewlink:create-group') { + const name = String(request.body.name ?? '').trim() + if (name.length < 3) { + response.json({ success: false, error: 'invalid_group' }) + return + } + const id = `crewlink-group-${Date.now()}` + crewLinkGroups.unshift({ + allowMemberPings: true, + colour: request.body.colour ?? 'cyan', + id, + inviteCode: Math.random().toString(36).slice(2, 10).toUpperCase(), + isOwner: true, + memberCount: 1, + name, + overheadAllowed: true, + role: 'owner', + }) + crewLinkMembers[id] = [ + { + coords: { x: -155.2, y: -1005.8, z: 28.4 }, + id: crewLinkProfile.id, + joinedAt: Date.now(), + mapVisible: crewLinkProfile.mapVisible, + online: true, + overheadVisible: crewLinkProfile.overheadVisible, + role: 'owner', + source: 1, + username: crewLinkProfile.username, + }, + ] + crewLinkPings[id] = [] + crewLinkProfile.activeGroupId = id + response.json({ success: true, data: crewLinkBootstrap() }) + return + } + if (endpoint === 'crewlink:update-group') { + const group = crewLinkGroups.find((item) => item.id === request.body.groupId) + if (!group) { + response.json({ success: false, error: 'group_not_found' }) + return + } + Object.assign(group, { + allowMemberPings: request.body.allowMemberPings, + colour: request.body.colour, + name: request.body.name, + overheadAllowed: request.body.overheadAllowed, + }) + response.json({ success: true }) + return + } + if (endpoint === 'crewlink:delete-group') { + crewLinkGroups = crewLinkGroups.filter((item) => item.id !== request.body.groupId) + delete crewLinkMembers[request.body.groupId] + delete crewLinkPings[request.body.groupId] + crewLinkProfile.activeGroupId = crewLinkGroups[0]?.id ?? null + response.json({ success: true }) + return + } + if (endpoint === 'crewlink:set-active') { + const group = crewLinkGroups.find((item) => item.id === request.body.groupId) + if (!group) { + response.json({ success: false, error: 'group_not_found' }) + return + } + crewLinkProfile.activeGroupId = group.id + response.json({ success: true, data: crewLinkBootstrap() }) + return + } + if (endpoint === 'crewlink:join-code') { + if (String(request.body.code).toUpperCase() !== 'SANDY247') { + response.json({ success: false, error: 'invalid_code' }) + return + } + const id = 'crewlink-group-sandy' + if (!crewLinkGroups.some((group) => group.id === id)) { + crewLinkGroups.push({ + allowMemberPings: true, + colour: 'orange', + id, + isOwner: false, + memberCount: 3, + name: 'Sandy Trails', + overheadAllowed: false, + role: 'member', + }) + crewLinkMembers[id] = [ + { + coords: { x: 1702.1, y: 3591.4, z: 35.4 }, + id: crewLinkProfile.id, + joinedAt: Date.now(), + mapVisible: true, + online: true, + overheadVisible: false, + role: 'member', + username: crewLinkProfile.username, + }, + ] + crewLinkPings[id] = [] + } + crewLinkProfile.activeGroupId = id + response.json({ success: true, data: crewLinkBootstrap() }) + return + } + if (endpoint === 'crewlink:rotate-code') { + const group = crewLinkGroups.find((item) => item.id === request.body.groupId) + if (group) group.inviteCode = 'FRESH247' + response.json({ success: true, data: { inviteCode: 'FRESH247' } }) + return + } + if (endpoint === 'crewlink:nearby') { + response.json({ success: true, data: crewLinkNearby }) + return + } + if (endpoint === 'crewlink:invite-nearby') { + response.json({ success: true }) + return + } + if (endpoint === 'crewlink:respond-invite') { + const invitation = crewLinkInvitations.find( + (item) => item.id === request.body.invitationId, + ) + crewLinkInvitations = crewLinkInvitations.filter( + (item) => item.id !== request.body.invitationId, + ) + if (request.body.accepted && invitation) { + crewLinkGroups.push({ + allowMemberPings: true, + colour: invitation.colour, + id: invitation.groupId, + isOwner: false, + memberCount: 4, + name: invitation.groupName, + overheadAllowed: true, + role: 'member', + }) + crewLinkMembers[invitation.groupId] = [] + crewLinkPings[invitation.groupId] = [] + crewLinkProfile.activeGroupId = invitation.groupId + } + response.json({ success: true, data: crewLinkBootstrap() }) + return + } + if (endpoint === 'crewlink:update-member') { + const members = crewLinkMembers[request.body.groupId] ?? [] + const member = members.find((item) => item.id === request.body.profileId) + if (member) member.role = request.body.role + response.json({ success: true }) + return + } + if (endpoint === 'crewlink:transfer-owner') { + const group = crewLinkGroups.find((item) => item.id === request.body.groupId) + const members = crewLinkMembers[request.body.groupId] ?? [] + const current = members.find((item) => item.id === crewLinkProfile.id) + const next = members.find((item) => item.id === request.body.profileId) + if (group) { + group.isOwner = false + group.role = 'coordinator' + delete group.inviteCode + } + if (current) current.role = 'coordinator' + if (next) next.role = 'owner' + response.json({ success: true }) + return + } + if (endpoint === 'crewlink:remove-member') { + const members = crewLinkMembers[request.body.groupId] ?? [] + crewLinkMembers[request.body.groupId] = members.filter( + (item) => item.id !== request.body.profileId, + ) + const group = crewLinkGroups.find((item) => item.id === request.body.groupId) + if (group) group.memberCount = crewLinkMembers[request.body.groupId].length + response.json({ success: true }) + return + } + if (endpoint === 'crewlink:leave') { + crewLinkGroups = crewLinkGroups.filter((item) => item.id !== request.body.groupId) + crewLinkProfile.activeGroupId = crewLinkGroups[0]?.id ?? null + response.json({ success: true, data: crewLinkBootstrap() }) + return + } + if (endpoint === 'crewlink:create-ping') { + const groupId = crewLinkProfile.activeGroupId + const ping = { + coords: request.body.coords ?? { x: -155.2, y: -1005.8, z: 28.4 }, + createdAt: Date.now(), + creatorProfileId: crewLinkProfile.id, + creatorUsername: crewLinkProfile.username, + expiresAt: Date.now() + 300000, + id: `crewlink-ping-${Date.now()}`, + label: request.body.label, + type: request.body.type, + } + crewLinkPings[groupId] = [ping, ...(crewLinkPings[groupId] ?? [])] + response.json({ success: true, data: ping }) + return + } + if (endpoint === 'crewlink:remove-ping') { + const groupId = crewLinkProfile.activeGroupId + crewLinkPings[groupId] = (crewLinkPings[groupId] ?? []).filter( + (item) => item.id !== request.body.pingId, + ) + response.json({ success: true }) + return + } + if (endpoint === 'crewlink:live') { + const groupId = crewLinkProfile.activeGroupId + response.json({ + success: true, + data: { + members: crewLinkMembers[groupId] ?? [], + pings: crewLinkPings[groupId] ?? [], + }, + }) + return + } if (endpoint === 'feather:bootstrap') { const empty = testScenario === 'feather-empty' const visibleProfiles = featherProfiles.filter( diff --git a/sky_phone/config/config.lua b/sky_phone/config/config.lua index 2e0d2d7..43c91be 100644 --- a/sky_phone/config/config.lua +++ b/sky_phone/config/config.lua @@ -365,6 +365,29 @@ Config.MapMarkers = { ActionsPerMinute = 60, } +Config.CrewLink = { + UsernameMinLength = 3, + UsernameMaxLength = 20, + GroupNameMinLength = 3, + GroupNameMaxLength = 32, + MaximumGroupsPerProfile = 5, + MaximumMembersPerGroup = 16, + InviteCodeLength = 8, + InviteLifetimeSeconds = 30, + NearbyInviteDistance = 5.0, + NearbyScanLimit = 12, + PingLabelMaxLength = 48, + PingLifetimeSeconds = 300, + MaximumActivePings = 12, + ActionsPerMinute = 30, + LiveRequestsPerMinute = 120, + OverheadRefreshMilliseconds = 3000, + OverheadDistance = 50.0, + ExternalPingResources = { + -- ["example_resource"] = true, + }, +} + Config.Calendar = { TitleMaxLength = 120, NoteMaxLength = 2000, diff --git a/sky_phone/config/locales/en.lua b/sky_phone/config/locales/en.lua index a8b1bb4..002c341 100644 --- a/sky_phone/config/locales/en.lua +++ b/sky_phone/config/locales/en.lua @@ -80,6 +80,43 @@ Locales["en"] = { }, }, Apps = { + crewlink = { + name = "CrewLink", connecting = "Connecting your crew...", privateNetwork = "Private location network", + signInTitle = "Connect your iFruit Account", signInBody = "CrewLink uses your private iFruit identity to keep groups and roles available across your phones.", openSettings = "Open iFruit Settings", + welcomeEyebrow = "Your crew. One signal.", welcomeTitle = "Find your people", welcomeBody = "Choose a private CrewLink username. Only confirmed group members can see your presence and shared location.", + username = "CrewLink username", usernamePlaceholder = "e.g. Skyline", createProfile = "Create CrewLink Profile", profileCreated = "Your CrewLink profile is ready.", privacyNote = "Private by design. No public player search.", + noGroupEyebrow = "No active crew", noGroupTitle = "Build your private network", noGroupBody = "Create a crew or join friends with a private invitation code.", + createGroup = "Create Group", createGroupBody = "Give your crew a recognizable name and signal colour.", joinGroup = "Join Group", joinWithCode = "Join with Code", joinWithCodeBody = "Enter the private eight-character code shared by a coordinator.", + groupName = "Group name", groupNamePlaceholder = "e.g. Night Shift", groupColour = "Signal colour", createCrew = "Create Crew", joinCrew = "Join Crew", inviteCode = "Invitation code", inviteCodePlaceholder = "AB12CD34", + groupCreated = "Crew created.", joinedGroup = "You joined the crew.", activeGroupChanged = "Active crew changed.", pendingInvitations = "Invitations", invitedBy = "Invited by @{username}", inviteAccepted = "Invitation accepted.", inviteDeclined = "Invitation declined.", + navigation = "CrewLink navigation", map = "Map", crew = "Crew", pings = "Pings", profile = "Profile", onlineNow = "online now", zoomIn = "Zoom in", zoomOut = "Zoom out", myLocation = "Center my location", + member = "Member", members = "Members", openCrew = "View team", newPing = "New Ping", shareLocation = "Mark a place", locationUnavailable = "Your live location is currently hidden.", activeCrew = "Active private crew", groupSummarySingle = "{online} of {total} member online", groupSummary = "{online} of {total} members online", private = "Private", + nearby = "Nearby", copyCode = "Copy code", manage = "Manage", refresh = "Refresh", codeCopied = "Invitation code copied.", codeRotated = "A new invitation code is active.", + peopleNearby = "People Nearby", peopleNearbyBody = "Only CrewLink users within {distance} meters appear here.", metersAway = "{distance} m away", invite = "Invite", inviteSent = "Invitation sent to @{username}.", nobodyNearby = "Nobody in range", nobodyNearbyBody = "Move closer to another CrewLink user and scan again.", scanAgain = "Scan Again", + liveCoordination = "Live coordination", noPings = "No active pings", noPingsBody = "Share a meeting point, warning, or target with your crew.", expiresMinutes = "{count} min left", expiresSeconds = "{count} sec left", sharedBy = "Shared by @{username}", setRoute = "Set Route", routeSet = "GPS route set.", pingCreated = "Ping shared with your crew.", pingRemoved = "Ping removed.", + newPingBody = "Share your current position or place the ping at the center of the map.", pingLabel = "Ping label", pingLabelPlaceholder = "e.g. Meet at the garage", placeOnMap = "Use map center", placeOnMapBody = "Otherwise your current position is used.", sharePing = "Share Ping", + pingTypes = { meeting = "Meet", danger = "Danger", help = "Help", target = "Target", info = "Info" }, + status = { live = "Live", hidden = "Location hidden", offline = "Offline" }, + roles = { owner = "Owner", coordinator = "Coordinator", moderator = "Moderator", member = "Member", guest = "Guest" }, + roleDescriptions = { coordinator = "Manage invitations, settings, roles, and pings.", moderator = "Invite nearby users and moderate group pings.", member = "Share presence and create pings when enabled.", guest = "View the active crew without coordination permissions." }, + manageCrew = "Manage Crew", manageCrewBody = "Update identity, permissions, and invitations.", memberPings = "Member pings", memberPingsBody = "Allow members and guests to create pings.", allowOverhead = "Overhead labels", allowOverheadBody = "Permit nearby opt-in member labels in the world.", groupSaved = "Crew settings saved.", + assignRole = "Assign Role", roleUpdated = "Member role updated.", transferOwnership = "Transfer Ownership", removeMember = "Remove from Crew", yourCrewLinkId = "Your CrewLink ID", privacyVisibility = "Privacy & Visibility", shareOnMap = "Share on group map", shareOnMapBody = "Active members can see your live position.", overheadLabels = "Nearby overhead label", overheadLabelsBody = "Show your CrewLink name to nearby opted-in members.", + yourGroups = "Your Groups", createAnotherGroup = "Create another group", account = "CrewLink Account", externalApi = "Connected resources", externalApiBody = "Approved scripts may add temporary group pings.", editUsername = "Edit Username", editUsernameBody = "Your username is unique across CrewLink.", profileSaved = "CrewLink profile saved.", deleteGroup = "Delete Group", leaveGroup = "Leave Group", confirmAction = "Confirm", + confirm = { + ["delete-group"] = { title = "Delete this crew?", body = "The group, memberships, invitations, and active pings will be permanently removed." }, + ["leave-group"] = { title = "Leave this crew?", body = "You will lose access to its members, map, and pings." }, + ["remove-member"] = { title = "Remove this member?", body = "They will immediately lose access to this crew." }, + ["transfer-owner"] = { title = "Transfer ownership?", body = "The selected member becomes owner and you become coordinator." }, + }, + ["delete-groupDone"] = "Crew deleted.", ["leave-groupDone"] = "You left the crew.", ["remove-memberDone"] = "Member removed.", ["transfer-ownerDone"] = "Ownership transferred.", + notifications = { invite = "{actor} invited you to {group}.", member_joined = "{actor} joined {group}.", ping = "{actor} shared '{ping}' with {group}.", role = "Your CrewLink role changed in {group}.", removed = "You were removed from {group}.", default = "Your CrewLink group has an update." }, + errors = { + not_authenticated = "Sign in to your iFruit Account first.", profile_required = "Create your CrewLink profile first.", invalid_username = "Use 3-20 letters, numbers, dots, or underscores.", invalid_profile = "Check your profile details.", username_taken = "That CrewLink username is already taken.", + invalid_group = "Choose a valid group name and signal colour.", group_limit = "You have reached your group limit.", member_limit = "This group has reached its member limit.", group_not_found = "This group is no longer available.", invalid_code = "That invitation code is invalid.", already_member = "You are already a member of this group.", forbidden = "Your current role cannot perform this action.", + player_not_nearby = "That player is no longer nearby.", player_unavailable = "That player cannot receive a CrewLink invitation.", invalid_invitation = "This invitation is invalid.", invitation_expired = "This invitation has expired.", invalid_role = "That role cannot be assigned.", owner_must_transfer = "Transfer ownership before leaving this group.", + invalid_ping = "Choose a valid ping type, label, and position.", ping_limit = "This crew already has too many active pings.", ping_not_found = "That ping is no longer active.", rate_limited = "Too many CrewLink actions. Try again shortly.", request_failed = "CrewLink is temporarily unavailable.", + }, + }, flare = { name = "Flare", signInTitle = "Sign in to Flare", signInBody = "Flare profiles and matches are linked to your private iFruit account.", createProfile = "Create your profile", welcome = "Find your spark", welcomeBody = "Build a profile and meet people across Los Santos.", diff --git a/sky_phone/fxmanifest.lua b/sky_phone/fxmanifest.lua index ff3f1f9..7a6ec92 100644 --- a/sky_phone/fxmanifest.lua +++ b/sky_phone/fxmanifest.lua @@ -31,6 +31,7 @@ client_scripts { 'source/client/garage.lua', 'source/client/skyride.lua', 'source/client/housing.lua', + 'source/client/crewlink.lua', 'source/bridge/client/radio.lua', 'source/client/main.lua', 'source/client/radio.lua', @@ -71,6 +72,7 @@ server_scripts { 'source/server/picstagram.lua', 'source/server/feather.lua', 'source/server/map.lua', + 'source/server/crewlink.lua', 'source/server/skyride.lua', 'source/server/calendar.lua', 'source/server/music.lua', diff --git a/sky_phone/source/client/crewlink.lua b/sky_phone/source/client/crewlink.lua new file mode 100644 index 0000000..d554d0a --- /dev/null +++ b/sky_phone/source/client/crewlink.lua @@ -0,0 +1,44 @@ +local overhead_members = {} + +local function draw_overhead_label(coords, username, role) + SetDrawOrigin(coords.x, coords.y, coords.z + 1.05, 0) + SetTextScale(0.0, 0.29) + SetTextFont(4) + SetTextProportional(true) + SetTextColour(235, 245, 255, 235) + SetTextCentre(true) + SetTextOutline() + BeginTextCommandDisplayText("STRING") + AddTextComponentSubstringPlayerName(("~b~%s~s~ %s"):format(username, role)) + EndTextCommandDisplayText(0.0, 0.0) + ClearDrawOrigin() +end + +CreateThread(function() + while true do + local result = Bridge.Callbacks.Trigger("sky_phone:crewlink:overhead", {}) + overhead_members = result and result.success and result.data and result.data.members or {} + Wait(Config.CrewLink.OverheadRefreshMilliseconds) + end +end) + +CreateThread(function() + while true do + local sleep = 1000 + if #overhead_members > 0 then + sleep = 0 + local player_coords = GetEntityCoords(PlayerPedId()) + for _, member in ipairs(overhead_members) do + local player = GetPlayerFromServerId(member.source) + if player ~= -1 then + local ped = GetPlayerPed(player) + local coords = GetEntityCoords(ped) + if #(player_coords - coords) <= Config.CrewLink.OverheadDistance then + draw_overhead_label(coords, member.username, member.roleLabel) + end + end + end + end + Wait(sleep) + end +end) diff --git a/sky_phone/source/client/main.lua b/sky_phone/source/client/main.lua index 6ca456d..7162d64 100644 --- a/sky_phone/source/client/main.lua +++ b/sky_phone/source/client/main.lua @@ -147,6 +147,25 @@ local server_callbacks = { "map:markers", "map:create-marker", "map:delete-marker", + "crewlink:bootstrap", + "crewlink:create-profile", + "crewlink:update-profile", + "crewlink:create-group", + "crewlink:update-group", + "crewlink:delete-group", + "crewlink:set-active", + "crewlink:join-code", + "crewlink:rotate-code", + "crewlink:nearby", + "crewlink:invite-nearby", + "crewlink:respond-invite", + "crewlink:update-member", + "crewlink:transfer-owner", + "crewlink:remove-member", + "crewlink:leave", + "crewlink:create-ping", + "crewlink:remove-ping", + "crewlink:live", "sim:insert", "sim:eject", "contacts:list", @@ -617,6 +636,22 @@ RegisterNetEvent("sky_phone:billing:new", function(data) SendNUIMessage({ type = "billing:new", data = data }) end) +RegisterNetEvent("sky_phone:crewlink:changed", function(data) + SendNUIMessage({ type = "crewlink:changed", data = data }) +end) + +RegisterNetEvent("sky_phone:crewlink:notification", function(data) + local crewlink_locale = get_locale().Nui.Apps.crewlink + local notification_text = crewlink_locale.notifications[data.kind] + or crewlink_locale.notifications.default + data.title = crewlink_locale.name + data.text = notification_text + :gsub("{actor}", tostring(data.actor or "")) + :gsub("{group}", tostring(data.groupName or "")) + :gsub("{ping}", tostring(data.pingLabel or "")) + SendNUIMessage({ type = "crewlink:notification", data = data }) +end) + RegisterNetEvent("sky_phone:messages:changed", function(data) SendNUIMessage({ type = "messages:changed", data = data }) end) diff --git a/sky_phone/source/server/crewlink.lua b/sky_phone/source/server/crewlink.lua new file mode 100644 index 0000000..7dca0f3 --- /dev/null +++ b/sky_phone/source/server/crewlink.lua @@ -0,0 +1,1213 @@ +Bridge.Database.AfterMigration("sky_phone", function() +local role_levels = { + guest = 1, + member = 2, + moderator = 3, + coordinator = 4, + owner = 5, +} +local member_roles = { + guest = true, + member = true, + moderator = true, + coordinator = true, +} +local group_colours = { + cyan = true, + blue = true, + violet = true, + orange = true, + green = true, + rose = true, +} +local ping_types = { + meeting = true, + danger = true, + help = true, + target = true, + info = true, +} +local live_sources_cache = { + expires_at = 0, + sources = {}, +} + +local function affected_rows(result) + if type(result) == "number" then + return result + end + return type(result) == "table" and tonumber(result.affectedRows) or 0 +end + +local function trim(value) + if type(value) ~= "string" then + return nil + end + return value:match("^%s*(.-)%s*$") +end + +local function valid_text(value, minimum, maximum) + local length = type(value) == "string" and utf8.len(value) or nil + return length and length >= minimum and length <= maximum +end + +local function new_id() + local rows = Bridge.Database.Query("SELECT UUID() AS `id`", {}) + if not rows[1] or type(rows[1].id) ~= "string" then + error("[sky_phone] Database did not generate a CrewLink id.") + end + return rows[1].id +end + +local function new_invite_code() + for _ = 1, 5 do + local rows = Bridge.Database.Query([[ + SELECT UPPER(SUBSTRING(REPLACE(UUID(), '-', ''), 1, ?)) AS `code` + ]], { Config.CrewLink.InviteCodeLength }) + local code = rows[1] and rows[1].code + if type(code) == "string" then + local existing = Bridge.Database.Query( + "SELECT 1 FROM `sky_phone_crewlink_groups` WHERE `invite_code` = ? LIMIT 1", + { code } + ) + if not existing[1] then + return code + end + end + end + error("[sky_phone] Could not generate a unique CrewLink invite code.") +end + +local function profile_dto(row) + return { + id = row.id, + username = row.username, + activeGroupId = row.active_group_id, + mapVisible = tonumber(row.map_visible) == 1, + overheadVisible = tonumber(row.overhead_visible) == 1, + } +end + +local function require_profile(source) + local account, error_response = SkyPhone.RequireAccount(source) + if not account then + return nil, error_response + end + local rows = Bridge.Database.Query([[ + SELECT `id`, `account_id`, `username`, `active_group_id`, `map_visible`, `overhead_visible` + FROM `sky_phone_crewlink_profiles` + WHERE `account_id` = ? + LIMIT 1 + ]], { account.id }) + if not rows[1] then + return nil, { success = false, error = "profile_required" } + end + rows[1].account_id = tonumber(rows[1].account_id) + return rows[1] +end + +local function membership(profile_id, group_id) + local rows = Bridge.Database.Query([[ + SELECT m.`group_id`, m.`profile_id`, m.`role`, m.`joined_at`, + g.`name` AS `group_name`, g.`colour`, g.`allow_member_pings`, + g.`overhead_allowed`, g.`invite_code`, g.`owner_profile_id` + FROM `sky_phone_crewlink_memberships` m + JOIN `sky_phone_crewlink_groups` g ON g.`id` = m.`group_id` + WHERE m.`profile_id` = ? AND m.`group_id` = ? + LIMIT 1 + ]], { profile_id, group_id }) + return rows[1] +end + +local function group_count(group_id) + local rows = Bridge.Database.Query([[ + SELECT COUNT(*) AS `count` + FROM `sky_phone_crewlink_memberships` + WHERE `group_id` = ? + ]], { group_id }) + return tonumber(rows[1] and rows[1].count) or 0 +end + +local function member_dtos(group_id) + local rows = Bridge.Database.Query([[ + SELECT p.`id`, p.`account_id`, p.`username`, p.`map_visible`, p.`overhead_visible`, + m.`role`, UNIX_TIMESTAMP(m.`joined_at`) AS `joined_at` + FROM `sky_phone_crewlink_memberships` m + JOIN `sky_phone_crewlink_profiles` p ON p.`id` = m.`profile_id` + WHERE m.`group_id` = ? + ORDER BY FIELD(m.`role`, 'owner', 'coordinator', 'moderator', 'member', 'guest'), m.`joined_at` + ]], { group_id }) + for _, row in ipairs(rows) do + row.account_id = tonumber(row.account_id) + row.mapVisible = tonumber(row.map_visible) == 1 + row.overheadVisible = tonumber(row.overhead_visible) == 1 + row.map_visible = nil + row.overhead_visible = nil + row.joinedAt = (tonumber(row.joined_at) or 0) * 1000 + row.joined_at = nil + end + return rows +end + +local function group_dto(row, profile_id) + local role = row.role + return { + id = row.id or row.group_id, + name = row.name or row.group_name, + colour = row.colour, + role = role, + inviteCode = role_levels[role] >= role_levels.coordinator and row.invite_code or nil, + allowMemberPings = tonumber(row.allow_member_pings) == 1, + overheadAllowed = tonumber(row.overhead_allowed) == 1, + memberCount = tonumber(row.member_count) or group_count(row.id or row.group_id), + isOwner = row.owner_profile_id == profile_id, + } +end + +local function list_groups(profile) + local rows = Bridge.Database.Query([[ + SELECT g.`id`, g.`name`, g.`colour`, g.`invite_code`, g.`owner_profile_id`, + g.`allow_member_pings`, g.`overhead_allowed`, m.`role`, + (SELECT COUNT(*) FROM `sky_phone_crewlink_memberships` gm + WHERE gm.`group_id` = g.`id`) AS `member_count` + FROM `sky_phone_crewlink_memberships` m + JOIN `sky_phone_crewlink_groups` g ON g.`id` = m.`group_id` + WHERE m.`profile_id` = ? + ORDER BY (g.`id` = ?) DESC, g.`name` + ]], { profile.id, profile.active_group_id or "" }) + local groups = {} + for index, row in ipairs(rows) do + groups[index] = group_dto(row, profile.id) + end + return groups +end + +local function active_pings(group_id) + local rows = Bridge.Database.Query([[ + SELECT pg.`id`, pg.`type`, pg.`label`, pg.`position_x`, pg.`position_y`, + pg.`position_z`, pg.`creator_profile_id`, pg.`source_resource`, + cp.`username` AS `creator_username`, + UNIX_TIMESTAMP(pg.`created_at`) AS `created_at`, + UNIX_TIMESTAMP(pg.`expires_at`) AS `expires_at` + FROM `sky_phone_crewlink_pings` pg + LEFT JOIN `sky_phone_crewlink_profiles` cp ON cp.`id` = pg.`creator_profile_id` + WHERE pg.`group_id` = ? AND pg.`expires_at` > CURRENT_TIMESTAMP + ORDER BY pg.`created_at` DESC + LIMIT ? + ]], { group_id, Config.CrewLink.MaximumActivePings }) + for _, row in ipairs(rows) do + row.coords = { + x = tonumber(row.position_x) or 0.0, + y = tonumber(row.position_y) or 0.0, + z = tonumber(row.position_z) or 0.0, + } + row.position_x = nil + row.position_y = nil + row.position_z = nil + row.creatorProfileId = row.creator_profile_id + row.creatorUsername = row.creator_username or row.source_resource or "CrewLink" + row.sourceResource = row.source_resource + row.creator_profile_id = nil + row.creator_username = nil + row.source_resource = nil + row.createdAt = (tonumber(row.created_at) or 0) * 1000 + row.expiresAt = (tonumber(row.expires_at) or 0) * 1000 + row.created_at = nil + row.expires_at = nil + end + return rows +end + +local function pending_invitations(profile_id) + local rows = Bridge.Database.Query([[ + SELECT i.`id`, i.`group_id`, g.`name` AS `group_name`, g.`colour`, + p.`username` AS `inviter_username`, + UNIX_TIMESTAMP(i.`expires_at`) AS `expires_at` + FROM `sky_phone_crewlink_invitations` i + JOIN `sky_phone_crewlink_groups` g ON g.`id` = i.`group_id` + JOIN `sky_phone_crewlink_profiles` p ON p.`id` = i.`inviter_profile_id` + WHERE i.`invitee_profile_id` = ? AND i.`status` = 'pending' + AND i.`expires_at` > CURRENT_TIMESTAMP + ORDER BY i.`created_at` DESC + ]], { profile_id }) + for _, row in ipairs(rows) do + row.groupId = row.group_id + row.groupName = row.group_name + row.inviterUsername = row.inviter_username + row.expiresAt = (tonumber(row.expires_at) or 0) * 1000 + row.group_id = nil + row.group_name = nil + row.inviter_username = nil + row.expires_at = nil + end + return rows +end + +local function live_sources_by_account() + local now = os.time() + if live_sources_cache.expires_at >= now then + return live_sources_cache.sources + end + local live = {} + for _, player_source in ipairs(Bridge.Framework.GetPlayers()) do + local source = tonumber(player_source) or player_source + local account = SkyPhone.RequireAccount(source) + if account and not live[account.id] then + live[account.id] = source + end + end + live_sources_cache.expires_at = now + 1 + live_sources_cache.sources = live + return live +end + +local function live_group(group_id) + local members = member_dtos(group_id) + local live_sources = live_sources_by_account() + for _, member in ipairs(members) do + local source = live_sources[member.account_id] + member.online = source ~= nil + member.source = source + if source and member.mapVisible then + local ped = GetPlayerPed(source) + local coords = GetEntityCoords(ped) + member.coords = { x = coords.x, y = coords.y, z = coords.z } + end + member.account_id = nil + end + return members +end + +local function group_account_ids(group_id) + local rows = Bridge.Database.Query([[ + SELECT p.`account_id` + FROM `sky_phone_crewlink_memberships` m + JOIN `sky_phone_crewlink_profiles` p ON p.`id` = m.`profile_id` + WHERE m.`group_id` = ? + ]], { group_id }) + local account_ids = {} + for _, row in ipairs(rows) do + account_ids[#account_ids + 1] = tonumber(row.account_id) + end + return account_ids +end + +local function profile_account_id(profile_id) + local rows = Bridge.Database.Query( + "SELECT `account_id` FROM `sky_phone_crewlink_profiles` WHERE `id` = ? LIMIT 1", + { profile_id } + ) + return rows[1] and tonumber(rows[1].account_id) or nil +end + +local function notify_group(group_id, kind, actor, extra) + for _, account_id in ipairs(group_account_ids(group_id)) do + local payload = { kind = kind, actor = actor } + for key, value in pairs(extra or {}) do + payload[key] = value + end + SkyPhone.NotifyAccountDevices(account_id, "sky_phone:crewlink:notification", payload) + end +end + +local function refresh_group(group_id) + for _, account_id in ipairs(group_account_ids(group_id)) do + SkyPhone.NotifyAccount(account_id, "sky_phone:crewlink:changed", { groupId = group_id }) + end +end + +local function bootstrap(profile) + local groups = list_groups(profile) + local active_group = nil + if profile.active_group_id then + local active_membership = membership(profile.id, profile.active_group_id) + if active_membership then + active_group = group_dto(active_membership, profile.id) + active_group.members = live_group(profile.active_group_id) + active_group.pings = active_pings(profile.active_group_id) + end + end + return { + profile = profile_dto(profile), + groups = groups, + activeGroup = active_group, + invitations = pending_invitations(profile.id), + limits = { + maximumGroups = Config.CrewLink.MaximumGroupsPerProfile, + maximumMembers = Config.CrewLink.MaximumMembersPerGroup, + nearbyDistance = Config.CrewLink.NearbyInviteDistance, + pingLifetimeSeconds = Config.CrewLink.PingLifetimeSeconds, + overheadDistance = Config.CrewLink.OverheadDistance, + }, + } +end + +local function valid_username(value) + local username = trim(value) + if not valid_text(username, Config.CrewLink.UsernameMinLength, Config.CrewLink.UsernameMaxLength) + or not username:match("^[A-Za-z0-9][A-Za-z0-9._]*[A-Za-z0-9]$") + then + return nil + end + return username +end + +local function valid_group_name(value) + local name = trim(value) + return valid_text(name, Config.CrewLink.GroupNameMinLength, Config.CrewLink.GroupNameMaxLength) + and name or nil +end + +local function validate_coords(value) + if type(value) ~= "table" then + return nil + end + local x = tonumber(value.x) + local y = tonumber(value.y) + local z = tonumber(value.z) or 0.0 + if not x or not y or x ~= x or y ~= y or z ~= z + or math.abs(x) > 10000.0 or math.abs(y) > 10000.0 + or z < -1000.0 or z > 3000.0 + then + return nil + end + return { x = x, y = y, z = z } +end + +local function allow(source, operation) + return SkyPhone.AllowOperation( + source, + "crewlink:" .. operation, + Config.CrewLink.ActionsPerMinute, + 60 + ) +end + +Bridge.Callbacks.Register("sky_phone:crewlink:bootstrap", function(source) + if not SkyPhone.AllowOperation( + source, + "crewlink:bootstrap", + Config.CrewLink.LiveRequestsPerMinute, + 60 + ) then + return { success = false, error = "rate_limited" } + end + local account, error_response = SkyPhone.RequireAccount(source) + if not account then + return error_response + end + local rows = Bridge.Database.Query([[ + SELECT `id`, `account_id`, `username`, `active_group_id`, `map_visible`, `overhead_visible` + FROM `sky_phone_crewlink_profiles` WHERE `account_id` = ? LIMIT 1 + ]], { account.id }) + if not rows[1] then + return { success = true, data = { profile = nil, groups = {}, invitations = {} } } + end + rows[1].account_id = tonumber(rows[1].account_id) + return { success = true, data = bootstrap(rows[1]) } +end) + +Bridge.Callbacks.Register("sky_phone:crewlink:create-profile", function(source, data) + if not allow(source, "profile") then + return { success = false, error = "rate_limited" } + end + local account, error_response = SkyPhone.RequireAccount(source) + if not account then + return error_response + end + local username = valid_username(data and data.username) + if not username then + return { success = false, error = "invalid_username" } + end + local result = Bridge.Database.Query([[ + INSERT IGNORE INTO `sky_phone_crewlink_profiles` (`id`, `account_id`, `username`) + VALUES (?, ?, ?) + ]], { new_id(), account.id, username }) + if affected_rows(result) ~= 1 then + return { success = false, error = "username_taken" } + end + local profile = require_profile(source) + return { success = true, data = bootstrap(profile) } +end) + +Bridge.Callbacks.Register("sky_phone:crewlink:update-profile", function(source, data) + if not allow(source, "profile") then + return { success = false, error = "rate_limited" } + end + local profile, error_response = require_profile(source) + if not profile then + return error_response + end + local username = valid_username(data and data.username) + if not username or type(data.mapVisible) ~= "boolean" or type(data.overheadVisible) ~= "boolean" then + return { success = false, error = "invalid_profile" } + end + local result = Bridge.Database.Query([[ + UPDATE IGNORE `sky_phone_crewlink_profiles` + SET `username` = ?, `map_visible` = ?, `overhead_visible` = ? + WHERE `id` = ? + ]], { username, data.mapVisible and 1 or 0, data.overheadVisible and 1 or 0, profile.id }) + if affected_rows(result) ~= 1 and username:lower() ~= tostring(profile.username):lower() then + return { success = false, error = "username_taken" } + end + if profile.active_group_id then + refresh_group(profile.active_group_id) + end + local updated = require_profile(source) + return { success = true, data = bootstrap(updated) } +end) + +Bridge.Callbacks.Register("sky_phone:crewlink:create-group", function(source, data) + if not allow(source, "group") then + return { success = false, error = "rate_limited" } + end + local profile, error_response = require_profile(source) + if not profile then + return error_response + end + local name = valid_group_name(data and data.name) + local colour = data and data.colour + if not name or not group_colours[colour] then + return { success = false, error = "invalid_group" } + end + local groups = list_groups(profile) + if #groups >= Config.CrewLink.MaximumGroupsPerProfile then + return { success = false, error = "group_limit" } + end + local group_id = new_id() + local success = Bridge.Database.Transaction({ + { + query = [[INSERT INTO `sky_phone_crewlink_groups` + (`id`, `name`, `colour`, `owner_profile_id`, `invite_code`) + VALUES (?, ?, ?, ?, ?)]], + params = { group_id, name, colour, profile.id, new_invite_code() }, + }, + { + query = [[INSERT INTO `sky_phone_crewlink_memberships` + (`group_id`, `profile_id`, `role`) VALUES (?, ?, 'owner')]], + params = { group_id, profile.id }, + }, + { + query = "UPDATE `sky_phone_crewlink_profiles` SET `active_group_id` = ? WHERE `id` = ?", + params = { group_id, profile.id }, + }, + }) + if not success then + return { success = false, error = "request_failed" } + end + TriggerEvent("sky_phone:crewlink:memberJoined", group_id, profile.id) + TriggerEvent("sky_phone:crewlink:activeChanged", profile.id, group_id) + local updated = require_profile(source) + return { success = true, data = bootstrap(updated) } +end) + +Bridge.Callbacks.Register("sky_phone:crewlink:update-group", function(source, data) + if not allow(source, "group") then + return { success = false, error = "rate_limited" } + end + local profile, error_response = require_profile(source) + if not profile then + return error_response + end + local member = type(data) == "table" and membership(profile.id, data.groupId) or nil + if not member or role_levels[member.role] < role_levels.coordinator then + return { success = false, error = "forbidden" } + end + local name = valid_group_name(data.name) + if not name or not group_colours[data.colour] + or type(data.allowMemberPings) ~= "boolean" or type(data.overheadAllowed) ~= "boolean" + then + return { success = false, error = "invalid_group" } + end + Bridge.Database.Query([[ + UPDATE `sky_phone_crewlink_groups` + SET `name` = ?, `colour` = ?, `allow_member_pings` = ?, `overhead_allowed` = ? + WHERE `id` = ? + ]], { name, data.colour, data.allowMemberPings and 1 or 0, data.overheadAllowed and 1 or 0, data.groupId }) + refresh_group(data.groupId) + return { success = true } +end) + +Bridge.Callbacks.Register("sky_phone:crewlink:delete-group", function(source, data) + if not allow(source, "group") then + return { success = false, error = "rate_limited" } + end + local profile, error_response = require_profile(source) + if not profile then + return error_response + end + local member = type(data) == "table" and membership(profile.id, data.groupId) or nil + if not member or member.role ~= "owner" then + return { success = false, error = "forbidden" } + end + local affected_accounts = group_account_ids(data.groupId) + local success = Bridge.Database.Transaction({ + { + query = "UPDATE `sky_phone_crewlink_profiles` SET `active_group_id` = NULL WHERE `active_group_id` = ?", + params = { data.groupId }, + }, + { + query = "DELETE FROM `sky_phone_crewlink_groups` WHERE `id` = ? AND `owner_profile_id` = ?", + params = { data.groupId, profile.id }, + }, + }) + if not success then + return { success = false, error = "request_failed" } + end + for _, account_id in ipairs(affected_accounts) do + SkyPhone.NotifyAccount(account_id, "sky_phone:crewlink:changed", { groupId = data.groupId }) + end + TriggerEvent("sky_phone:crewlink:memberLeft", data.groupId, profile.id) + return { success = true } +end) + +Bridge.Callbacks.Register("sky_phone:crewlink:set-active", function(source, data) + if not allow(source, "group") then + return { success = false, error = "rate_limited" } + end + local profile, error_response = require_profile(source) + if not profile then + return error_response + end + if type(data) ~= "table" or type(data.groupId) ~= "string" or not membership(profile.id, data.groupId) then + return { success = false, error = "group_not_found" } + end + Bridge.Database.Query( + "UPDATE `sky_phone_crewlink_profiles` SET `active_group_id` = ? WHERE `id` = ?", + { data.groupId, profile.id } + ) + TriggerEvent("sky_phone:crewlink:activeChanged", profile.id, data.groupId) + local updated = require_profile(source) + return { success = true, data = bootstrap(updated) } +end) + +Bridge.Callbacks.Register("sky_phone:crewlink:join-code", function(source, data) + if not allow(source, "invite") then + return { success = false, error = "rate_limited" } + end + local profile, error_response = require_profile(source) + if not profile then + return error_response + end + local code = type(data) == "table" and trim(data.code) or nil + if not code or #code ~= Config.CrewLink.InviteCodeLength then + return { success = false, error = "invalid_code" } + end + local rows = Bridge.Database.Query([[ + SELECT `id`, `name` FROM `sky_phone_crewlink_groups` + WHERE `invite_code` = ? LIMIT 1 + ]], { code:upper() }) + local group = rows[1] + if not group then + return { success = false, error = "invalid_code" } + end + if membership(profile.id, group.id) then + return { success = false, error = "already_member" } + end + if #list_groups(profile) >= Config.CrewLink.MaximumGroupsPerProfile then + return { success = false, error = "group_limit" } + end + if group_count(group.id) >= Config.CrewLink.MaximumMembersPerGroup then + return { success = false, error = "member_limit" } + end + local success = Bridge.Database.Transaction({ + { + query = [[INSERT INTO `sky_phone_crewlink_memberships` + (`group_id`, `profile_id`, `role`) VALUES (?, ?, 'member')]], + params = { group.id, profile.id }, + }, + { + query = "UPDATE `sky_phone_crewlink_profiles` SET `active_group_id` = ? WHERE `id` = ?", + params = { group.id, profile.id }, + }, + }) + if not success then + return { success = false, error = "request_failed" } + end + notify_group(group.id, "member_joined", profile.username, { groupName = group.name }) + refresh_group(group.id) + TriggerEvent("sky_phone:crewlink:memberJoined", group.id, profile.id) + local updated = require_profile(source) + return { success = true, data = bootstrap(updated) } +end) + +Bridge.Callbacks.Register("sky_phone:crewlink:rotate-code", function(source, data) + if not allow(source, "invite") then + return { success = false, error = "rate_limited" } + end + local profile, error_response = require_profile(source) + if not profile then + return error_response + end + local member = type(data) == "table" and membership(profile.id, data.groupId) or nil + if not member or role_levels[member.role] < role_levels.coordinator then + return { success = false, error = "forbidden" } + end + local code = new_invite_code() + Bridge.Database.Query("UPDATE `sky_phone_crewlink_groups` SET `invite_code` = ? WHERE `id` = ?", { + code, + data.groupId, + }) + refresh_group(data.groupId) + return { success = true, data = { inviteCode = code } } +end) + +Bridge.Callbacks.Register("sky_phone:crewlink:nearby", function(source) + if not allow(source, "nearby") then + return { success = false, error = "rate_limited" } + end + local profile, error_response = require_profile(source) + if not profile then + return error_response + end + local member = profile.active_group_id and membership(profile.id, profile.active_group_id) or nil + if not member or role_levels[member.role] < role_levels.moderator then + return { success = false, error = "forbidden" } + end + local player_ped = GetPlayerPed(source) + local player_coords = GetEntityCoords(player_ped) + local player_bucket = GetPlayerRoutingBucket(source) + local candidates = {} + for _, target_value in ipairs(Bridge.Framework.GetPlayers()) do + local target = tonumber(target_value) or target_value + if target ~= source and GetPlayerRoutingBucket(target) == player_bucket then + local target_coords = GetEntityCoords(GetPlayerPed(target)) + local distance = #(player_coords - target_coords) + if distance <= Config.CrewLink.NearbyInviteDistance then + local target_account = SkyPhone.RequireAccount(target) + if target_account then + local rows = Bridge.Database.Query([[ + SELECT `id`, `username` FROM `sky_phone_crewlink_profiles` + WHERE `account_id` = ? LIMIT 1 + ]], { target_account.id }) + local target_profile = rows[1] + if target_profile and not membership(target_profile.id, profile.active_group_id) then + candidates[#candidates + 1] = { + source = target, + username = target_profile.username, + distance = math.floor(distance * 10 + 0.5) / 10, + } + if #candidates >= Config.CrewLink.NearbyScanLimit then + break + end + end + end + end + end + end + return { success = true, data = candidates } +end) + +Bridge.Callbacks.Register("sky_phone:crewlink:invite-nearby", function(source, data) + if not allow(source, "invite") then + return { success = false, error = "rate_limited" } + end + local profile, error_response = require_profile(source) + if not profile then + return error_response + end + local member = profile.active_group_id and membership(profile.id, profile.active_group_id) or nil + if not member or role_levels[member.role] < role_levels.moderator then + return { success = false, error = "forbidden" } + end + local target = type(data) == "table" and math.floor(tonumber(data.targetSource) or 0) or 0 + if target <= 0 or GetPlayerRoutingBucket(target) ~= GetPlayerRoutingBucket(source) then + return { success = false, error = "player_not_nearby" } + end + local distance = #(GetEntityCoords(GetPlayerPed(source)) - GetEntityCoords(GetPlayerPed(target))) + if distance > Config.CrewLink.NearbyInviteDistance then + return { success = false, error = "player_not_nearby" } + end + local target_account = SkyPhone.RequireAccount(target) + if not target_account then + return { success = false, error = "player_unavailable" } + end + local rows = Bridge.Database.Query([[ + SELECT `id`, `username` FROM `sky_phone_crewlink_profiles` + WHERE `account_id` = ? LIMIT 1 + ]], { target_account.id }) + local target_profile = rows[1] + if not target_profile or membership(target_profile.id, profile.active_group_id) then + return { success = false, error = "player_unavailable" } + end + local invitation_id = new_id() + Bridge.Database.Query([[ + INSERT INTO `sky_phone_crewlink_invitations` + (`id`, `group_id`, `inviter_profile_id`, `invitee_profile_id`, `expires_at`) + VALUES (?, ?, ?, ?, DATE_ADD(CURRENT_TIMESTAMP, INTERVAL ? SECOND)) + ON DUPLICATE KEY UPDATE `inviter_profile_id` = VALUES(`inviter_profile_id`), + `status` = 'pending', `expires_at` = VALUES(`expires_at`), `created_at` = CURRENT_TIMESTAMP + ]], { + invitation_id, + profile.active_group_id, + profile.id, + target_profile.id, + Config.CrewLink.InviteLifetimeSeconds, + }) + SkyPhone.NotifyAccountDevices(target_account.id, "sky_phone:crewlink:notification", { + kind = "invite", + actor = profile.username, + groupName = member.group_name, + }) + TriggerClientEvent("sky_phone:crewlink:changed", target, { groupId = profile.active_group_id }) + return { success = true } +end) + +Bridge.Callbacks.Register("sky_phone:crewlink:respond-invite", function(source, data) + if not allow(source, "invite") then + return { success = false, error = "rate_limited" } + end + local profile, error_response = require_profile(source) + if not profile then + return error_response + end + local accepted = type(data) == "table" and data.accepted + if type(accepted) ~= "boolean" or type(data.invitationId) ~= "string" then + return { success = false, error = "invalid_invitation" } + end + local rows = Bridge.Database.Query([[ + SELECT i.`id`, i.`group_id`, g.`name` AS `group_name` + FROM `sky_phone_crewlink_invitations` i + JOIN `sky_phone_crewlink_groups` g ON g.`id` = i.`group_id` + WHERE i.`id` = ? AND i.`invitee_profile_id` = ? AND i.`status` = 'pending' + AND i.`expires_at` > CURRENT_TIMESTAMP + LIMIT 1 + ]], { data.invitationId, profile.id }) + local invitation = rows[1] + if not invitation then + return { success = false, error = "invitation_expired" } + end + if not accepted then + Bridge.Database.Query( + "UPDATE `sky_phone_crewlink_invitations` SET `status` = 'declined' WHERE `id` = ?", + { invitation.id } + ) + return { success = true } + end + if #list_groups(profile) >= Config.CrewLink.MaximumGroupsPerProfile then + return { success = false, error = "group_limit" } + end + if group_count(invitation.group_id) >= Config.CrewLink.MaximumMembersPerGroup then + return { success = false, error = "member_limit" } + end + local success = Bridge.Database.Transaction({ + { + query = [[INSERT INTO `sky_phone_crewlink_memberships` + (`group_id`, `profile_id`, `role`) VALUES (?, ?, 'member')]], + params = { invitation.group_id, profile.id }, + }, + { + query = "UPDATE `sky_phone_crewlink_invitations` SET `status` = 'accepted' WHERE `id` = ? AND `status` = 'pending'", + params = { invitation.id }, + }, + { + query = "UPDATE `sky_phone_crewlink_profiles` SET `active_group_id` = ? WHERE `id` = ?", + params = { invitation.group_id, profile.id }, + }, + }) + if not success then + return { success = false, error = "request_failed" } + end + notify_group(invitation.group_id, "member_joined", profile.username, { groupName = invitation.group_name }) + refresh_group(invitation.group_id) + TriggerEvent("sky_phone:crewlink:memberJoined", invitation.group_id, profile.id) + local updated = require_profile(source) + return { success = true, data = bootstrap(updated) } +end) + +Bridge.Callbacks.Register("sky_phone:crewlink:update-member", function(source, data) + if not allow(source, "group") then + return { success = false, error = "rate_limited" } + end + local profile, error_response = require_profile(source) + if not profile then + return error_response + end + if type(data) ~= "table" or not member_roles[data.role] or type(data.profileId) ~= "string" then + return { success = false, error = "invalid_role" } + end + local actor = membership(profile.id, data.groupId) + local target = membership(data.profileId, data.groupId) + if not actor or not target or role_levels[actor.role] < role_levels.coordinator + or role_levels[target.role] >= role_levels[actor.role] + or (actor.role ~= "owner" and role_levels[data.role] >= role_levels[actor.role]) + then + return { success = false, error = "forbidden" } + end + Bridge.Database.Query([[ + UPDATE `sky_phone_crewlink_memberships` SET `role` = ? + WHERE `group_id` = ? AND `profile_id` = ? + ]], { data.role, data.groupId, data.profileId }) + local target_account_id = profile_account_id(data.profileId) + if target_account_id then + SkyPhone.NotifyAccountDevices(target_account_id, "sky_phone:crewlink:notification", { + kind = "role", + actor = profile.username, + groupName = target.group_name, + }) + end + refresh_group(data.groupId) + return { success = true } +end) + +Bridge.Callbacks.Register("sky_phone:crewlink:transfer-owner", function(source, data) + if not allow(source, "group") then + return { success = false, error = "rate_limited" } + end + local profile, error_response = require_profile(source) + if not profile then + return error_response + end + local actor = type(data) == "table" and membership(profile.id, data.groupId) or nil + local target = type(data) == "table" and membership(data.profileId, data.groupId) or nil + if not actor or actor.role ~= "owner" or not target or target.profile_id == profile.id then + return { success = false, error = "forbidden" } + end + local success = Bridge.Database.Transaction({ + { + query = "UPDATE `sky_phone_crewlink_groups` SET `owner_profile_id` = ? WHERE `id` = ? AND `owner_profile_id` = ?", + params = { data.profileId, data.groupId, profile.id }, + }, + { + query = "UPDATE `sky_phone_crewlink_memberships` SET `role` = 'coordinator' WHERE `group_id` = ? AND `profile_id` = ?", + params = { data.groupId, profile.id }, + }, + { + query = "UPDATE `sky_phone_crewlink_memberships` SET `role` = 'owner' WHERE `group_id` = ? AND `profile_id` = ?", + params = { data.groupId, data.profileId }, + }, + }) + if not success then + return { success = false, error = "request_failed" } + end + refresh_group(data.groupId) + return { success = true } +end) + +Bridge.Callbacks.Register("sky_phone:crewlink:remove-member", function(source, data) + if not allow(source, "group") then + return { success = false, error = "rate_limited" } + end + local profile, error_response = require_profile(source) + if not profile then + return error_response + end + local actor = type(data) == "table" and membership(profile.id, data.groupId) or nil + local target = type(data) == "table" and membership(data.profileId, data.groupId) or nil + if not actor or not target or role_levels[actor.role] < role_levels.moderator + or role_levels[target.role] >= role_levels[actor.role] + then + return { success = false, error = "forbidden" } + end + local target_account_id = profile_account_id(data.profileId) + local success = Bridge.Database.Transaction({ + { + query = "DELETE FROM `sky_phone_crewlink_memberships` WHERE `group_id` = ? AND `profile_id` = ?", + params = { data.groupId, data.profileId }, + }, + { + query = "UPDATE `sky_phone_crewlink_profiles` SET `active_group_id` = NULL WHERE `id` = ? AND `active_group_id` = ?", + params = { data.profileId, data.groupId }, + }, + }) + if not success then + return { success = false, error = "request_failed" } + end + if target_account_id then + SkyPhone.NotifyAccount(target_account_id, "sky_phone:crewlink:changed", { groupId = data.groupId }) + SkyPhone.NotifyAccountDevices(target_account_id, "sky_phone:crewlink:notification", { + kind = "removed", + actor = profile.username, + groupName = target.group_name, + }) + end + TriggerEvent("sky_phone:crewlink:memberLeft", data.groupId, data.profileId) + refresh_group(data.groupId) + return { success = true } +end) + +Bridge.Callbacks.Register("sky_phone:crewlink:leave", function(source, data) + if not allow(source, "group") then + return { success = false, error = "rate_limited" } + end + local profile, error_response = require_profile(source) + if not profile then + return error_response + end + local member = type(data) == "table" and membership(profile.id, data.groupId) or nil + if not member then + return { success = false, error = "group_not_found" } + end + if member.role == "owner" then + return { success = false, error = "owner_must_transfer" } + end + local success = Bridge.Database.Transaction({ + { + query = "DELETE FROM `sky_phone_crewlink_memberships` WHERE `group_id` = ? AND `profile_id` = ?", + params = { data.groupId, profile.id }, + }, + { + query = "UPDATE `sky_phone_crewlink_profiles` SET `active_group_id` = NULL WHERE `id` = ? AND `active_group_id` = ?", + params = { profile.id, data.groupId }, + }, + }) + if not success then + return { success = false, error = "request_failed" } + end + TriggerEvent("sky_phone:crewlink:memberLeft", data.groupId, profile.id) + refresh_group(data.groupId) + local updated = require_profile(source) + return { success = true, data = bootstrap(updated) } +end) + +local function create_ping(group_id, creator_profile_id, source_resource, data) + local label = trim(data and data.label) + local coords = validate_coords(data and data.coords) + if not ping_types[data and data.type] or not coords + or not valid_text(label, 1, Config.CrewLink.PingLabelMaxLength) + then + return nil, "invalid_ping" + end + local count_rows = Bridge.Database.Query([[ + SELECT COUNT(*) AS `count` FROM `sky_phone_crewlink_pings` + WHERE `group_id` = ? AND `expires_at` > CURRENT_TIMESTAMP + ]], { group_id }) + if (tonumber(count_rows[1] and count_rows[1].count) or 0) >= Config.CrewLink.MaximumActivePings then + return nil, "ping_limit" + end + local lifetime = math.max(15, math.min( + Config.CrewLink.PingLifetimeSeconds, + math.floor(tonumber(data.lifetimeSeconds) or Config.CrewLink.PingLifetimeSeconds) + )) + local id = new_id() + Bridge.Database.Query([[ + INSERT INTO `sky_phone_crewlink_pings` + (`id`, `group_id`, `creator_profile_id`, `source_resource`, `type`, `label`, + `position_x`, `position_y`, `position_z`, `expires_at`) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, DATE_ADD(CURRENT_TIMESTAMP, INTERVAL ? SECOND)) + ]], { + id, + group_id, + creator_profile_id, + source_resource, + data.type, + label, + coords.x, + coords.y, + coords.z, + lifetime, + }) + local ping = { + id = id, + type = data.type, + label = label, + coords = coords, + creatorProfileId = creator_profile_id, + sourceResource = source_resource, + createdAt = os.time() * 1000, + expiresAt = (os.time() + lifetime) * 1000, + } + TriggerEvent("sky_phone:crewlink:pingCreated", group_id, ping) + return ping +end + +Bridge.Callbacks.Register("sky_phone:crewlink:create-ping", function(source, data) + if not allow(source, "ping") then + return { success = false, error = "rate_limited" } + end + local profile, error_response = require_profile(source) + if not profile then + return error_response + end + local group_id = profile.active_group_id + local member = group_id and membership(profile.id, group_id) or nil + if not member or (role_levels[member.role] < role_levels.moderator and tonumber(member.allow_member_pings) ~= 1) then + return { success = false, error = "forbidden" } + end + local ping_data = data or {} + if data and data.useCurrent then + local coords = GetEntityCoords(GetPlayerPed(source)) + ping_data = { + type = data.type, + label = data.label, + coords = { x = coords.x, y = coords.y, z = coords.z }, + } + end + local ping, error_code = create_ping(group_id, profile.id, nil, ping_data) + if not ping then + return { success = false, error = error_code } + end + notify_group(group_id, "ping", profile.username, { + groupName = member.group_name, + pingType = ping.type, + pingLabel = ping.label, + }) + refresh_group(group_id) + return { success = true, data = ping } +end) + +Bridge.Callbacks.Register("sky_phone:crewlink:remove-ping", function(source, data) + if not allow(source, "ping") then + return { success = false, error = "rate_limited" } + end + local profile, error_response = require_profile(source) + if not profile then + return error_response + end + local group_id = profile.active_group_id + local member = group_id and membership(profile.id, group_id) or nil + if not member or type(data) ~= "table" or type(data.pingId) ~= "string" then + return { success = false, error = "ping_not_found" } + end + local rows = Bridge.Database.Query([[ + SELECT `creator_profile_id` FROM `sky_phone_crewlink_pings` + WHERE `id` = ? AND `group_id` = ? LIMIT 1 + ]], { data.pingId, group_id }) + local ping = rows[1] + if not ping or (ping.creator_profile_id ~= profile.id and role_levels[member.role] < role_levels.moderator) then + return { success = false, error = "forbidden" } + end + Bridge.Database.Query("DELETE FROM `sky_phone_crewlink_pings` WHERE `id` = ? AND `group_id` = ?", { + data.pingId, + group_id, + }) + TriggerEvent("sky_phone:crewlink:pingRemoved", group_id, data.pingId) + refresh_group(group_id) + return { success = true } +end) + +Bridge.Callbacks.Register("sky_phone:crewlink:live", function(source) + if not SkyPhone.AllowOperation( + source, + "crewlink:live", + Config.CrewLink.LiveRequestsPerMinute, + 60 + ) then + return { success = false, error = "rate_limited" } + end + local profile, error_response = require_profile(source) + if not profile then + return error_response + end + if not profile.active_group_id or not membership(profile.id, profile.active_group_id) then + return { success = true, data = { members = {}, pings = {} } } + end + return { + success = true, + data = { + members = live_group(profile.active_group_id), + pings = active_pings(profile.active_group_id), + }, + } +end) + +Bridge.Callbacks.Register("sky_phone:crewlink:overhead", function(source) + if not SkyPhone.AllowOperation( + source, + "crewlink:overhead", + Config.CrewLink.LiveRequestsPerMinute, + 60 + ) then + return { success = false, error = "rate_limited" } + end + local profile = require_profile(source) + if not profile or not profile.active_group_id or tonumber(profile.overhead_visible) ~= 1 then + return { success = true, data = { members = {} } } + end + local active_membership = membership(profile.id, profile.active_group_id) + if not active_membership or tonumber(active_membership.overhead_allowed) ~= 1 then + return { success = true, data = { members = {} } } + end + local live_sources = live_sources_by_account() + local members = {} + for _, member in ipairs(member_dtos(profile.active_group_id)) do + local member_source = live_sources[member.account_id] + if member_source and member_source ~= source and member.overheadVisible then + members[#members + 1] = { + source = member_source, + username = member.username, + role = member.role, + roleLabel = member.role:sub(1, 1):upper() .. member.role:sub(2), + } + end + end + return { success = true, data = { members = members } } +end) + +exports("GetCrewLinkActiveGroup", function(source) + local profile = require_profile(source) + if not profile or not profile.active_group_id then + return nil + end + local member = membership(profile.id, profile.active_group_id) + return member and group_dto(member, profile.id) or nil +end) + +exports("GetCrewLinkGroupMembers", function(group_id) + if type(group_id) ~= "string" then + return {} + end + local members = {} + for _, member in ipairs(member_dtos(group_id)) do + members[#members + 1] = { + id = member.id, + username = member.username, + role = member.role, + joinedAt = member.joinedAt, + } + end + return members +end) + +exports("IsCrewLinkGroupMember", function(source, group_id) + local profile = require_profile(source) + return profile and type(group_id) == "string" and membership(profile.id, group_id) ~= nil or false +end) + +exports("GetCrewLinkGroupRole", function(source, group_id) + local profile = require_profile(source) + local member = profile and type(group_id) == "string" and membership(profile.id, group_id) or nil + return member and member.role or nil +end) + +exports("CreateCrewLinkPing", function(group_id, data) + local invoking_resource = GetInvokingResource() + if not invoking_resource or not Config.CrewLink.ExternalPingResources[invoking_resource] then + return nil, "resource_not_allowed" + end + local groups = Bridge.Database.Query( + "SELECT `id`, `name` FROM `sky_phone_crewlink_groups` WHERE `id` = ? LIMIT 1", + { group_id } + ) + if not groups[1] then + return nil, "group_not_found" + end + local ping, error_code = create_ping(group_id, nil, invoking_resource, data) + if ping then + notify_group(group_id, "ping", invoking_resource, { + groupName = groups[1].name, + pingType = ping.type, + pingLabel = ping.label, + }) + refresh_group(group_id) + end + return ping, error_code +end) + +exports("RemoveCrewLinkPing", function(group_id, ping_id) + local invoking_resource = GetInvokingResource() + if not invoking_resource or not Config.CrewLink.ExternalPingResources[invoking_resource] then + return false + end + local result = Bridge.Database.Query([[ + DELETE FROM `sky_phone_crewlink_pings` + WHERE `id` = ? AND `group_id` = ? AND `source_resource` = ? + ]], { ping_id, group_id, invoking_resource }) + if affected_rows(result) ~= 1 then + return false + end + TriggerEvent("sky_phone:crewlink:pingRemoved", group_id, ping_id) + refresh_group(group_id) + return true +end) +end) diff --git a/sky_phone/source/server/db_migrate.lua b/sky_phone/source/server/db_migrate.lua index 0b55ed7..4b113ba 100644 --- a/sky_phone/source/server/db_migrate.lua +++ b/sky_phone/source/server/db_migrate.lua @@ -1947,6 +1947,128 @@ local schema = { }, tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", }, + { + name = "sky_phone_crewlink_profiles", + columns = { + { name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "account_id", type = "BIGINT UNSIGNED NOT NULL" }, + { name = "username", type = "VARCHAR(20) NOT NULL", characterSet = "ascii", collation = "ascii_general_ci" }, + { name = "active_group_id", type = "CHAR(36) NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "map_visible", type = "TINYINT(1) NOT NULL DEFAULT 1" }, + { name = "overhead_visible", type = "TINYINT(1) NOT NULL DEFAULT 0" }, + { name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" }, + { name = "updated_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" }, + }, + primaryKey = "id", + uniqueKeys = { + { name = "uniq_sky_phone_crewlink_account", columns = "(`account_id`)" }, + { name = "uniq_sky_phone_crewlink_username", columns = "(`username`)" }, + }, + indexes = { + { name = "idx_sky_phone_crewlink_active", columns = "(`active_group_id`)" }, + }, + foreignKeys = { + { column = "account_id", references = "`sky_phone_accounts` (`id`) ON DELETE CASCADE" }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, + { + name = "sky_phone_crewlink_groups", + columns = { + { name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "name", type = "VARCHAR(32) NOT NULL" }, + { name = "colour", type = "ENUM('cyan','blue','violet','orange','green','rose') NOT NULL DEFAULT 'cyan'" }, + { name = "owner_profile_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "invite_code", type = "VARCHAR(12) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "allow_member_pings", type = "TINYINT(1) NOT NULL DEFAULT 1" }, + { name = "overhead_allowed", type = "TINYINT(1) NOT NULL DEFAULT 1" }, + { name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" }, + { name = "updated_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" }, + }, + primaryKey = "id", + uniqueKeys = { + { name = "uniq_sky_phone_crewlink_invite_code", columns = "(`invite_code`)" }, + }, + indexes = { + { name = "idx_sky_phone_crewlink_owner", columns = "(`owner_profile_id`)" }, + }, + foreignKeys = { + { column = "owner_profile_id", references = "`sky_phone_crewlink_profiles` (`id`) ON DELETE CASCADE" }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, + { + name = "sky_phone_crewlink_memberships", + columns = { + { name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" }, + { name = "group_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "profile_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "role", type = "ENUM('owner','coordinator','moderator','member','guest') NOT NULL DEFAULT 'member'" }, + { name = "joined_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" }, + }, + primaryKey = "id", + uniqueKeys = { + { name = "uniq_sky_phone_crewlink_membership", columns = "(`group_id`, `profile_id`)" }, + }, + indexes = { + { name = "idx_sky_phone_crewlink_profile_groups", columns = "(`profile_id`, `joined_at`)" }, + }, + foreignKeys = { + { column = "group_id", references = "`sky_phone_crewlink_groups` (`id`) ON DELETE CASCADE" }, + { column = "profile_id", references = "`sky_phone_crewlink_profiles` (`id`) ON DELETE CASCADE" }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, + { + name = "sky_phone_crewlink_invitations", + columns = { + { name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "group_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "inviter_profile_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "invitee_profile_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "status", type = "ENUM('pending','accepted','declined','expired') NOT NULL DEFAULT 'pending'" }, + { name = "expires_at", type = "DATETIME NOT NULL" }, + { name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" }, + }, + primaryKey = "id", + uniqueKeys = { + { name = "uniq_sky_phone_crewlink_pending_invite", columns = "(`group_id`, `invitee_profile_id`)" }, + }, + indexes = { + { name = "idx_sky_phone_crewlink_invitee", columns = "(`invitee_profile_id`, `status`, `expires_at`)" }, + }, + foreignKeys = { + { column = "group_id", references = "`sky_phone_crewlink_groups` (`id`) ON DELETE CASCADE" }, + { column = "inviter_profile_id", references = "`sky_phone_crewlink_profiles` (`id`) ON DELETE CASCADE" }, + { column = "invitee_profile_id", references = "`sky_phone_crewlink_profiles` (`id`) ON DELETE CASCADE" }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, + { + name = "sky_phone_crewlink_pings", + columns = { + { name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "group_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "creator_profile_id", type = "CHAR(36) NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "source_resource", type = "VARCHAR(64) NULL", characterSet = "ascii", collation = "ascii_general_ci" }, + { name = "type", type = "ENUM('meeting','danger','help','target','info') NOT NULL" }, + { name = "label", type = "VARCHAR(48) NOT NULL" }, + { name = "position_x", type = "DECIMAL(10,3) NOT NULL" }, + { name = "position_y", type = "DECIMAL(10,3) NOT NULL" }, + { name = "position_z", type = "DECIMAL(10,3) NOT NULL" }, + { name = "expires_at", type = "DATETIME NOT NULL" }, + { name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" }, + }, + primaryKey = "id", + indexes = { + { name = "idx_sky_phone_crewlink_pings", columns = "(`group_id`, `expires_at`)" }, + }, + foreignKeys = { + { column = "group_id", references = "`sky_phone_crewlink_groups` (`id`) ON DELETE CASCADE" }, + { column = "creator_profile_id", references = "`sky_phone_crewlink_profiles` (`id`) ON DELETE SET NULL" }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, } Bridge.Database.Migrate("sky_phone", schema) diff --git a/sky_phone/sql/install.sql b/sky_phone/sql/install.sql index 7d7e29b..7e93c5d 100644 --- a/sky_phone/sql/install.sql +++ b/sky_phone/sql/install.sql @@ -850,3 +850,82 @@ CREATE TABLE IF NOT EXISTS `sky_phone_feather_reports` ( FOREIGN KEY (`reporter_id`) REFERENCES `sky_phone_feather_profiles` (`id`) ON DELETE CASCADE, FOREIGN KEY (`post_id`) REFERENCES `sky_phone_feather_posts` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `sky_phone_crewlink_profiles` ( + `id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `account_id` BIGINT UNSIGNED NOT NULL, + `username` VARCHAR(20) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL, + `active_group_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL, + `map_visible` TINYINT(1) NOT NULL DEFAULT 1, + `overhead_visible` TINYINT(1) NOT NULL DEFAULT 0, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_sky_phone_crewlink_account` (`account_id`), + UNIQUE KEY `uniq_sky_phone_crewlink_username` (`username`), + KEY `idx_sky_phone_crewlink_active` (`active_group_id`), + FOREIGN KEY (`account_id`) REFERENCES `sky_phone_accounts` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `sky_phone_crewlink_groups` ( + `id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `name` VARCHAR(32) NOT NULL, + `colour` ENUM('cyan','blue','violet','orange','green','rose') NOT NULL DEFAULT 'cyan', + `owner_profile_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `invite_code` VARCHAR(12) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `allow_member_pings` TINYINT(1) NOT NULL DEFAULT 1, + `overhead_allowed` TINYINT(1) NOT NULL DEFAULT 1, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_sky_phone_crewlink_invite_code` (`invite_code`), + KEY `idx_sky_phone_crewlink_owner` (`owner_profile_id`), + FOREIGN KEY (`owner_profile_id`) REFERENCES `sky_phone_crewlink_profiles` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `sky_phone_crewlink_memberships` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `group_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `profile_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `role` ENUM('owner','coordinator','moderator','member','guest') NOT NULL DEFAULT 'member', + `joined_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_sky_phone_crewlink_membership` (`group_id`,`profile_id`), + KEY `idx_sky_phone_crewlink_profile_groups` (`profile_id`,`joined_at`), + FOREIGN KEY (`group_id`) REFERENCES `sky_phone_crewlink_groups` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`profile_id`) REFERENCES `sky_phone_crewlink_profiles` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `sky_phone_crewlink_invitations` ( + `id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `group_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `inviter_profile_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `invitee_profile_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `status` ENUM('pending','accepted','declined','expired') NOT NULL DEFAULT 'pending', + `expires_at` DATETIME NOT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_sky_phone_crewlink_pending_invite` (`group_id`,`invitee_profile_id`), + KEY `idx_sky_phone_crewlink_invitee` (`invitee_profile_id`,`status`,`expires_at`), + FOREIGN KEY (`group_id`) REFERENCES `sky_phone_crewlink_groups` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`inviter_profile_id`) REFERENCES `sky_phone_crewlink_profiles` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`invitee_profile_id`) REFERENCES `sky_phone_crewlink_profiles` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `sky_phone_crewlink_pings` ( + `id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `group_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `creator_profile_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL, + `source_resource` VARCHAR(64) CHARACTER SET ascii COLLATE ascii_general_ci NULL, + `type` ENUM('meeting','danger','help','target','info') NOT NULL, + `label` VARCHAR(48) NOT NULL, + `position_x` DECIMAL(10,3) NOT NULL, + `position_y` DECIMAL(10,3) NOT NULL, + `position_z` DECIMAL(10,3) NOT NULL, + `expires_at` DATETIME NOT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_sky_phone_crewlink_pings` (`group_id`,`expires_at`), + FOREIGN KEY (`group_id`) REFERENCES `sky_phone_crewlink_groups` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`creator_profile_id`) REFERENCES `sky_phone_crewlink_profiles` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;