From 4ea6dbac6ce0a1318b04e85f736207ca9d961345 Mon Sep 17 00:00:00 2001 From: Temi Date: Mon, 13 Apr 2026 16:09:50 -0400 Subject: [PATCH 1/4] Add location spotlight notifications with native geofencing --- admin/src/App.tsx | 8 + admin/src/all.dto.ts | 129 + admin/src/components/ServerApi.tsx | 14 + admin/src/components/Spotlights.tsx | 552 +++ game/android/app/build.gradle | 2 +- game/android/app/src/main/AndroidManifest.xml | 14 + game/ios/Runner/AppDelegate.swift | 6 + game/ios/Runner/Info.plist | 1 + game/lib/api/game_api.dart | 9 + game/lib/api/game_client_dto.dart | 593 +++ game/lib/api/game_server_api.dart | 9 + game/lib/api/spotlight_service.dart | 212 + game/pubspec.lock | 8 + game/pubspec.yaml | 1 + server/package-lock.json | 3979 ++++++++++------- server/prisma/schema.prisma | 41 + server/src/app.module.ts | 2 + server/src/check-in/check-in.service.ts | 29 +- server/src/spotlight/spotlight.controller.ts | 44 + server/src/spotlight/spotlight.dto.ts | 43 + server/src/spotlight/spotlight.gateway.ts | 84 + server/src/spotlight/spotlight.module.ts | 15 + server/src/spotlight/spotlight.service.ts | 267 ++ server/src/utils/geo.util.ts | 25 + 24 files changed, 4417 insertions(+), 1670 deletions(-) create mode 100644 admin/src/components/Spotlights.tsx create mode 100644 game/lib/api/spotlight_service.dart create mode 100644 server/src/spotlight/spotlight.controller.ts create mode 100644 server/src/spotlight/spotlight.dto.ts create mode 100644 server/src/spotlight/spotlight.gateway.ts create mode 100644 server/src/spotlight/spotlight.module.ts create mode 100644 server/src/spotlight/spotlight.service.ts create mode 100644 server/src/utils/geo.util.ts diff --git a/admin/src/App.tsx b/admin/src/App.tsx index c155c354..a0a138b0 100644 --- a/admin/src/App.tsx +++ b/admin/src/App.tsx @@ -5,6 +5,7 @@ import { Organizations } from './components/Organizations'; import { ErrorAlert } from './components/ErrorAlert'; import { Users } from './components/Users'; import { Notifications } from './components/Notifications'; +import { Spotlights } from './components/Spotlights'; import { AppBar, @@ -30,6 +31,7 @@ import { faBuilding, faPerson, faBell, + faMapPin, } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; @@ -91,6 +93,12 @@ const routes = [ icon: faBell, name: 'Notifications', }, + { + path: '/spotlights', + element: , + icon: faMapPin, + name: 'Spotlights', + }, ]; const SidebarIcon = styled.span` diff --git a/admin/src/all.dto.ts b/admin/src/all.dto.ts index 8544a099..aec1f201 100644 --- a/admin/src/all.dto.ts +++ b/admin/src/all.dto.ts @@ -43,6 +43,34 @@ export enum ChallengeLocationDto { ANY = 'ANY', } +export enum CheckInResultCheckInMethodDto { + LOCATION = 'LOCATION', + QR_CODE = 'QR_CODE', +} + +export enum CheckInErrorCodeDto { + EVENT_NOT_FOUND = 'EVENT_NOT_FOUND', + EVENT_NOT_ACTIVE = 'EVENT_NOT_ACTIVE', + EVENT_NOT_APPROVED = 'EVENT_NOT_APPROVED', + ALREADY_CHECKED_IN = 'ALREADY_CHECKED_IN', + OUT_OF_RADIUS = 'OUT_OF_RADIUS', + METHOD_NOT_ALLOWED = 'METHOD_NOT_ALLOWED', + INVALID_QR_CODE = 'INVALID_QR_CODE', + UNKNOWN_ERROR = 'UNKNOWN_ERROR', +} + +export enum ClubSubmissionCategoryDto { + SOCIAL = 'SOCIAL', + CULTURAL = 'CULTURAL', + ATHLETIC = 'ATHLETIC', + WELLNESS = 'WELLNESS', + ACADEMIC = 'ACADEMIC', + ARTS = 'ARTS', + CAREER = 'CAREER', + COMMUNITY = 'COMMUNITY', + OTHER = 'OTHER', +} + export enum EventCategoryDto { FOOD = 'FOOD', NATURE = 'NATURE', @@ -250,11 +278,54 @@ export interface AvailableChallengesResponseDto { challenges: ChallengeDto[]; } +export interface LocationCheckInDto { + campusEventId: string; + latitude: number; + longitude: number; +} + +export interface QrCodeCheckInDto { + qrCode: string; +} + +export interface GenerateQrCodeDto { + campusEventId: string; +} + +export interface CheckInResultDto { + attendanceId: string; + campusEventId: string; + checkInMethod: CheckInResultCheckInMethodDto; + pointsAwarded: number; + newTotalScore: number; +} + +export interface CheckInErrorDto { + message: string; + code: CheckInErrorCodeDto; +} + export interface UpdateErrorDto { id: string; message: string; } +export interface ClubSubmissionDto { + clubName: string; + contactEmail: string; + eventTitle: string; + description: string; + startTime: string; + endTime: string; + location: string; + latitude?: number; + longitude?: number; + category: ClubSubmissionCategoryDto; + address?: string; + imageUrl?: string; + registrationLink?: string; +} + export interface RequestFilteredEventsDto { difficulty: string[]; location: string[]; @@ -344,6 +415,25 @@ export interface UpdateEventDataDto { export interface UseEventTrackerHintDto {} +export interface TriggerEventSyncDto { + days?: number; +} + +export interface EventSyncResultDto { + created: number; + updated: number; + archived: number; + totalFetched: number; + syncedAt: string; +} + +export interface RequestEventSyncStatusDto {} + +export interface UpdateEventSyncStatusDto { + running: boolean; + lastResult: EventSyncResultDto; +} + export interface JoinGroupDto { groupId: string; } @@ -479,6 +569,45 @@ export interface UpdateQuizQuestionDataDto { deleted: boolean; } +export interface SpotlightDto { + id: string; + title: string; + body: string; + latitude: number; + longitude: number; + radiusMeters: number; + cooldownDays: number; + startDate: string; + endDate: string; + startHour: number; + endHour: number; + isActive: boolean; + linkedEventId?: string; + linkedCampusEventId?: string; +} + +export interface ActiveSpotlightDto { + id: string; + latitude: number; + longitude: number; + radiusMeters: number; +} + +export interface RequestSpotlightNotificationDto { + spotlightId: string; + latitude: number; + longitude: number; +} + +export interface SpotlightNotificationResultDto { + sent: boolean; + reason?: string; +} + +export interface DeleteSpotlightDto { + id: string; +} + export interface TimerStartedDto { timerId: string; endTime: string; diff --git a/admin/src/components/ServerApi.tsx b/admin/src/components/ServerApi.tsx index 41a66f25..a8e6b3e2 100644 --- a/admin/src/components/ServerApi.tsx +++ b/admin/src/components/ServerApi.tsx @@ -71,6 +71,16 @@ export class ServerApi { >; } + checkInWithLocation(data: dto.LocationCheckInDto) { + return this.send('checkInWithLocation', data) as Promise< + boolean | undefined + >; + } + + checkInWithQrCode(data: dto.QrCodeCheckInDto) { + return this.send('checkInWithQrCode', data) as Promise; + } + requestEventData(data: dto.RequestEventDataDto) { return this.send('requestEventData', data) as Promise; } @@ -181,6 +191,10 @@ export class ServerApi { >; } + deleteSpotlight(data: dto.DeleteSpotlightDto) { + return this.send('deleteSpotlight', data) as Promise; + } + startChallengeTimer(data: dto.StartChallengeTimerDto) { return this.send('startChallengeTimer', data) as Promise< string | undefined diff --git a/admin/src/components/Spotlights.tsx b/admin/src/components/Spotlights.tsx new file mode 100644 index 00000000..2f005938 --- /dev/null +++ b/admin/src/components/Spotlights.tsx @@ -0,0 +1,552 @@ +import { useContext, useEffect, useState } from 'react'; +import styled from 'styled-components'; +import { ServerConnectionContext } from './ServerConnection'; + +const PageContainer = styled.div` + max-width: 800px; +`; + +const Title = styled.h2` + margin-bottom: 8px; + color: #333; +`; + +const Subtitle = styled.p` + color: #666; + margin-bottom: 24px; +`; + +const FormGroup = styled.div` + margin-bottom: 16px; +`; + +const Label = styled.label` + display: block; + font-weight: 500; + margin-bottom: 6px; + color: #333; +`; + +const Input = styled.input` + width: 100%; + padding: 10px 12px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 14px; + box-sizing: border-box; + + &:focus { + outline: none; + border-color: #b31b1b; + } +`; + +const TextArea = styled.textarea` + width: 100%; + padding: 10px 12px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 14px; + min-height: 80px; + resize: vertical; + box-sizing: border-box; + font-family: inherit; + + &:focus { + outline: none; + border-color: #b31b1b; + } +`; + +const Select = styled.select` + width: 100%; + padding: 10px 12px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 14px; + background: white; + box-sizing: border-box; + + &:focus { + outline: none; + border-color: #b31b1b; + } +`; + +const Button = styled.button<{ disabled?: boolean; variant?: string }>` + background-color: ${props => + props.disabled + ? '#ccc' + : props.variant === 'danger' + ? '#d32f2f' + : props.variant === 'secondary' + ? '#666' + : '#b31b1b'}; + color: white; + border: none; + padding: 10px 20px; + border-radius: 4px; + font-size: 14px; + font-weight: 500; + cursor: ${props => (props.disabled ? 'not-allowed' : 'pointer')}; + margin-right: 8px; + transition: background-color 0.2s; + + &:hover { + opacity: ${props => (props.disabled ? 1 : 0.85)}; + } +`; + +const Table = styled.table` + width: 100%; + border-collapse: collapse; + margin-bottom: 24px; +`; + +const Th = styled.th` + text-align: left; + padding: 10px 12px; + border-bottom: 2px solid #ddd; + color: #333; + font-size: 14px; +`; + +const Td = styled.td` + padding: 10px 12px; + border-bottom: 1px solid #eee; + font-size: 14px; +`; + +const StatusBadge = styled.span<{ active: boolean }>` + display: inline-block; + padding: 2px 8px; + border-radius: 12px; + font-size: 12px; + font-weight: 500; + background-color: ${props => (props.active ? '#e8f5e9' : '#ffebee')}; + color: ${props => (props.active ? '#2e7d32' : '#c62828')}; +`; + +const Row = styled.div` + display: flex; + gap: 16px; +`; + +const HalfWidth = styled.div` + flex: 1; +`; + +const InfoBox = styled.div` + margin-top: 16px; + padding: 12px 16px; + border-radius: 4px; + background-color: #e3f2fd; + color: #1565c0; + border: 1px solid #90caf9; + font-size: 14px; +`; + +interface SpotlightData { + id: string; + title: string; + body: string; + latitude: number; + longitude: number; + radiusMeters: number; + cooldownDays: number; + startDate: string; + endDate: string; + startHour: number; + endHour: number; + isActive: boolean; + linkedEventId?: string; + linkedCampusEventId?: string; +} + +const emptySpotlight: Omit = { + title: '', + body: '', + latitude: 42.4534, + longitude: -76.4735, + radiusMeters: 200, + cooldownDays: 7, + startDate: new Date().toISOString().split('T')[0], + endDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) + .toISOString() + .split('T')[0], + startHour: 9, + endHour: 21, + isActive: true, +}; + +export function Spotlights() { + const connection = useContext(ServerConnectionContext); + const [spotlights, setSpotlights] = useState([]); + const [editing, setEditing] = useState(null); + const [creating, setCreating] = useState(false); + const [form, setForm] = useState>(emptySpotlight); + const [saving, setSaving] = useState(false); + + const loadSpotlights = async () => { + try { + const result = await connection.connection + ?.timeout(10000) + .emitWithAck('getAllSpotlights'); + if (result) { + setSpotlights(result as SpotlightData[]); + } + } catch (err) { + console.error('Failed to load spotlights:', err); + } + }; + + useEffect(() => { + loadSpotlights(); + }, [connection.connection]); + + const handleSave = async () => { + if (!form.title.trim() || !form.body.trim()) return; + setSaving(true); + + try { + const payload = { + ...form, + startDate: new Date(form.startDate).toISOString(), + endDate: new Date(form.endDate).toISOString(), + }; + + if (editing) { + await connection.connection + ?.timeout(10000) + .emitWithAck('updateSpotlight', { ...payload, id: editing.id }); + } else { + await connection.connection + ?.timeout(10000) + .emitWithAck('createSpotlight', payload); + } + + setEditing(null); + setCreating(false); + setForm(emptySpotlight); + await loadSpotlights(); + } catch (err) { + console.error('Failed to save spotlight:', err); + } finally { + setSaving(false); + } + }; + + const handleDelete = async (id: string) => { + if (!window.confirm('Delete this spotlight?')) return; + try { + await connection.connection + ?.timeout(10000) + .emitWithAck('deleteSpotlight', { id }); + await loadSpotlights(); + } catch (err) { + console.error('Failed to delete spotlight:', err); + } + }; + + const startEdit = (s: SpotlightData) => { + setEditing(s); + setCreating(false); + setForm({ + title: s.title, + body: s.body, + latitude: s.latitude, + longitude: s.longitude, + radiusMeters: s.radiusMeters, + cooldownDays: s.cooldownDays, + startDate: s.startDate.split('T')[0], + endDate: s.endDate.split('T')[0], + startHour: s.startHour, + endHour: s.endHour, + isActive: s.isActive, + linkedEventId: s.linkedEventId, + linkedCampusEventId: s.linkedCampusEventId, + }); + }; + + const startCreate = () => { + setEditing(null); + setCreating(true); + setForm(emptySpotlight); + }; + + const cancel = () => { + setEditing(null); + setCreating(false); + setForm(emptySpotlight); + }; + + const isFormOpen = editing || creating; + + const getStatus = (s: SpotlightData) => { + if (!s.isActive) return false; + const now = new Date(); + return new Date(s.startDate) <= now && new Date(s.endDate) >= now; + }; + + const hourOptions = Array.from({ length: 24 }, (_, i) => ({ + value: i, + label: `${i === 0 ? '12' : i > 12 ? i - 12 : i}:00 ${i < 12 ? 'AM' : 'PM'}`, + })); + + return ( + + Location Spotlights + + Configure location-based notification zones. Users near these locations + will receive a push notification (with smart anti-spam controls). + + + {!isFormOpen && ( + <> + + + + + + + + + + + + + + + {spotlights.map(s => ( + + + + + + + + + ))} + {spotlights.length === 0 && ( + + + + )} + +
TitleStatusRadiusCooldownDate RangeActions
{s.title} + + {getStatus(s) ? 'Active' : 'Inactive'} + + {s.radiusMeters}m{s.cooldownDays} days + {new Date(s.startDate).toLocaleDateString()} –{' '} + {new Date(s.endDate).toLocaleDateString()} + + + +
+ No spotlights configured yet. +
+ + )} + + {isFormOpen && ( + <> +

{editing ? 'Edit Spotlight' : 'Create Spotlight'}

+ + + + setForm({ ...form, title: e.target.value })} + placeholder="e.g., Challenge nearby!" + maxLength={100} + /> + + + + +