From cb1dd60bb862982c4c5fd917bb2763fa305cfd94 Mon Sep 17 00:00:00 2001 From: Decobus Date: Sun, 20 Jul 2025 15:30:18 -0400 Subject: [PATCH] Implement UUID-based tracking for OBS groups to handle renames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add group_uuid column to teams table for reliable OBS scene tracking - Update createGroup API to store OBS scene UUID when creating groups - Enhance verifyGroups API with UUID-first matching and name fallback - Add comprehensive verification system to detect sync issues between database and OBS - Implement UI indicators for UUID linking, name mismatches, and invalid groups - Add "Clear Invalid" and "Update Name" actions for fixing synchronization problems - Create migration script for existing databases to add UUID column - Update Team type definition to include optional group_uuid field This resolves issues where manually renaming groups in OBS would break the synchronization between the database and OBS, providing a more robust group management system. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- app/api/createGroup/route.ts | 14 +-- app/api/teams/[teamId]/route.ts | 33 +++++- app/api/teams/route.ts | 5 +- app/api/verifyGroups/route.ts | 85 ++++++++++++++++ app/teams/page.tsx | 171 +++++++++++++++++++++++++++++--- lib/obsClient.js | 25 +++-- package.json | 3 +- scripts/addGroupUuidColumn.ts | 48 +++++++++ types/index.ts | 1 + 9 files changed, 351 insertions(+), 34 deletions(-) create mode 100644 app/api/verifyGroups/route.ts create mode 100644 scripts/addGroupUuidColumn.ts diff --git a/app/api/createGroup/route.ts b/app/api/createGroup/route.ts index 10c3c44..af86197 100644 --- a/app/api/createGroup/route.ts +++ b/app/api/createGroup/route.ts @@ -43,15 +43,15 @@ export async function POST(request: NextRequest) { suffix: 'sat' }); - // Update team with group name - await db.run( - `UPDATE ${teamsTableName} SET group_name = ? WHERE team_id = ?`, - [sanitizedGroupName, validTeamId] - ); - - // Create group in OBS + // Create group in OBS first to get UUID const result = await createGroupIfNotExists(sanitizedGroupName); + // Update team with group name and UUID + await db.run( + `UPDATE ${teamsTableName} SET group_name = ?, group_uuid = ? WHERE team_id = ?`, + [sanitizedGroupName, result.sceneUuid, validTeamId] + ); + await db.close(); return NextResponse.json({ diff --git a/app/api/teams/[teamId]/route.ts b/app/api/teams/[teamId]/route.ts index a132725..f7d5086 100644 --- a/app/api/teams/[teamId]/route.ts +++ b/app/api/teams/[teamId]/route.ts @@ -9,17 +9,40 @@ export async function PUT( try { const { teamId: teamIdParam } = await params; const teamId = parseInt(teamIdParam); - const { team_name } = await request.json(); + const body = await request.json(); + const { team_name, group_name, group_uuid } = body; - if (!team_name) { - return NextResponse.json({ error: 'Team name is required' }, { status: 400 }); + // Allow updating any combination of fields + if (!team_name && group_name === undefined && group_uuid === undefined) { + return NextResponse.json({ error: 'At least one field (team_name, group_name, or group_uuid) must be provided' }, { status: 400 }); } const db = await getDatabase(); + // Build dynamic query based on what fields are being updated + const updates: string[] = []; + const values: any[] = []; + + if (team_name) { + updates.push('team_name = ?'); + values.push(team_name); + } + + if (group_name !== undefined) { + updates.push('group_name = ?'); + values.push(group_name); + } + + if (group_uuid !== undefined) { + updates.push('group_uuid = ?'); + values.push(group_uuid); + } + + values.push(teamId); + const result = await db.run( - `UPDATE ${TABLE_NAMES.TEAMS} SET team_name = ? WHERE team_id = ?`, - [team_name, teamId] + `UPDATE ${TABLE_NAMES.TEAMS} SET ${updates.join(', ')} WHERE team_id = ?`, + values ); if (result.changes === 0) { diff --git a/app/api/teams/route.ts b/app/api/teams/route.ts index 36addd1..2ccdaf0 100644 --- a/app/api/teams/route.ts +++ b/app/api/teams/route.ts @@ -45,7 +45,7 @@ function validateTeamInput(data: unknown): { export const GET = withErrorHandling(async () => { try { const db = await getDatabase(); - const teams: Team[] = await db.all(`SELECT team_id, team_name, group_name FROM ${TABLE_NAMES.TEAMS} ORDER BY team_name ASC`); + const teams: Team[] = await db.all(`SELECT team_id, team_name, group_name, group_uuid FROM ${TABLE_NAMES.TEAMS} ORDER BY team_name ASC`); return createSuccessResponse(teams); } catch (error) { @@ -86,7 +86,8 @@ export const POST = withErrorHandling(async (request: Request) => { const newTeam: Team = { team_id: result.lastID!, team_name: team_name, - group_name: null + group_name: null, + group_uuid: null }; return createSuccessResponse(newTeam, 201); diff --git a/app/api/verifyGroups/route.ts b/app/api/verifyGroups/route.ts new file mode 100644 index 0000000..df210fb --- /dev/null +++ b/app/api/verifyGroups/route.ts @@ -0,0 +1,85 @@ +import { NextResponse } from 'next/server'; +import { getDatabase } from '../../../lib/database'; +import { TABLE_NAMES } from '../../../lib/constants'; +import { getOBSClient } from '../../../lib/obsClient'; + +interface OBSScene { + sceneName: string; + sceneUuid: string; +} + +interface GetSceneListResponse { + scenes: OBSScene[]; +} + +export async function GET() { + try { + // Get teams from database + const db = await getDatabase(); + const teams = await db.all(`SELECT team_id, team_name, group_name, group_uuid FROM ${TABLE_NAMES.TEAMS} WHERE group_name IS NOT NULL OR group_uuid IS NOT NULL`); + + // Get scenes (groups) from OBS + const obs = await getOBSClient(); + const response = await obs.call('GetSceneList'); + const obsData = response as GetSceneListResponse; + const obsScenes = obsData.scenes; + + // Compare database groups with OBS scenes using both UUID and name + const verification = teams.map(team => { + let exists_in_obs = false; + let matched_by = null; + let current_name = null; + + if (team.group_uuid) { + // Try to match by UUID first (most reliable) + const matchedScene = obsScenes.find(scene => scene.sceneUuid === team.group_uuid); + if (matchedScene) { + exists_in_obs = true; + matched_by = 'uuid'; + current_name = matchedScene.sceneName; + } + } + + if (!exists_in_obs && team.group_name) { + // Fallback to name matching + const matchedScene = obsScenes.find(scene => scene.sceneName === team.group_name); + if (matchedScene) { + exists_in_obs = true; + matched_by = 'name'; + current_name = matchedScene.sceneName; + } + } + + return { + team_id: team.team_id, + team_name: team.team_name, + group_name: team.group_name, + group_uuid: team.group_uuid, + exists_in_obs, + matched_by, + current_name, + name_changed: exists_in_obs && matched_by === 'uuid' && current_name !== team.group_name + }; + }); + + return NextResponse.json({ + success: true, + data: { + teams_with_groups: verification, + obs_scenes: obsScenes.map(s => ({ name: s.sceneName, uuid: s.sceneUuid })), + missing_in_obs: verification.filter(team => !team.exists_in_obs), + name_mismatches: verification.filter(team => team.name_changed), + orphaned_in_obs: obsScenes.filter(scene => + !teams.some(team => team.group_uuid === scene.sceneUuid || team.group_name === scene.sceneName) + ).map(s => ({ name: s.sceneName, uuid: s.sceneUuid })) + } + }); + + } catch (error) { + console.error('Error verifying groups:', error); + return NextResponse.json( + { error: 'Failed to verify groups with OBS' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/teams/page.tsx b/app/teams/page.tsx index bbbc38a..7b877e4 100644 --- a/app/teams/page.tsx +++ b/app/teams/page.tsx @@ -5,9 +5,22 @@ import { Team } from '@/types'; import { useToast } from '@/lib/useToast'; import { ToastContainer } from '@/components/Toast'; +interface GroupVerification { + team_id: number; + team_name: string; + group_name: string; + group_uuid: string | null; + exists_in_obs: boolean; + matched_by: 'uuid' | 'name' | null; + current_name: string | null; + name_changed: boolean; +} + export default function Teams() { const [teams, setTeams] = useState([]); + const [groupVerification, setGroupVerification] = useState([]); const [isLoading, setIsLoading] = useState(true); + const [isVerifying, setIsVerifying] = useState(false); const [newTeamName, setNewTeamName] = useState(''); const [editingTeam, setEditingTeam] = useState(null); const [editingName, setEditingName] = useState(''); @@ -38,6 +51,35 @@ export default function Teams() { } }; + const verifyGroups = async () => { + setIsVerifying(true); + try { + const res = await fetch('/api/verifyGroups'); + const data = await res.json(); + if (data.success) { + setGroupVerification(data.data.teams_with_groups); + const missing = data.data.missing_in_obs.length; + const orphaned = data.data.orphaned_in_obs.length; + const nameChanges = data.data.name_mismatches?.length || 0; + + if (missing > 0 || orphaned > 0 || nameChanges > 0) { + const issues = []; + if (missing > 0) issues.push(`${missing} missing in OBS`); + if (orphaned > 0) issues.push(`${orphaned} orphaned in OBS`); + if (nameChanges > 0) issues.push(`${nameChanges} name mismatches`); + showError('Groups Out of Sync', issues.join(', ')); + } else { + showSuccess('Groups Verified', 'All groups are in sync with OBS'); + } + } + } catch (error) { + console.error('Error verifying groups:', error); + showError('Verification Failed', 'Could not verify groups with OBS'); + } finally { + setIsVerifying(false); + } + }; + const handleAddTeam = async (e: React.FormEvent) => { e.preventDefault(); @@ -193,6 +235,7 @@ export default function Teams() { if (res.ok) { fetchTeams(); + verifyGroups(); // Refresh verification after creating showSuccess('Group Created', `OBS group "${groupName}" created for team "${teamName}"`); } else { const error = await res.json(); @@ -206,6 +249,58 @@ export default function Teams() { } }; + const handleClearInvalidGroup = async (teamId: number, teamName: string) => { + if (!confirm(`Clear the invalid group assignment for team "${teamName}"? This will only update the database, not delete anything from OBS.`)) { + return; + } + + try { + const res = await fetch(`/api/teams/${teamId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ group_name: null, group_uuid: null }), + }); + + if (res.ok) { + fetchTeams(); + verifyGroups(); + showSuccess('Group Cleared', `Invalid group assignment cleared for "${teamName}"`); + } else { + const error = await res.json(); + showError('Failed to Clear Group', error.error || 'Unknown error occurred'); + } + } catch (error) { + console.error('Error clearing group:', error); + showError('Failed to Clear Group', 'Network error or server unavailable'); + } + }; + + const handleUpdateGroupName = async (teamId: number, teamName: string, currentName: string) => { + if (!confirm(`Update the group name for team "${teamName}" from "${teamName}" to "${currentName}" to match OBS?`)) { + return; + } + + try { + const res = await fetch(`/api/teams/${teamId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ group_name: currentName }), + }); + + if (res.ok) { + fetchTeams(); + verifyGroups(); + showSuccess('Group Name Updated', `Group name updated to "${currentName}"`); + } else { + const error = await res.json(); + showError('Failed to Update Group Name', error.error || 'Unknown error occurred'); + } + } catch (error) { + console.error('Error updating group name:', error); + showError('Failed to Update Group Name', 'Network error or server unavailable'); + } + }; + const startEditing = (team: Team) => { setEditingTeam(team); setEditingName(team.team_name); @@ -271,15 +366,26 @@ export default function Teams() {

Existing Teams

- +
+ + +
{isLoading ? ( @@ -297,7 +403,10 @@ export default function Teams() {
) : (
- {teams.map((team) => ( + {teams.map((team) => { + const shouldShowCreateButton = !team.group_name || (typeof team.group_name === 'string' && team.group_name.trim() === ''); + const verification = groupVerification.find(v => v.team_id === team.team_id); + return (
{editingTeam?.team_id === team.team_id ? (
@@ -338,14 +447,27 @@ export default function Teams() {
{team.team_name}
ID: {team.team_id}
{team.group_name ? ( -
OBS Group: {team.group_name}
+
+ + OBS Group: {verification?.current_name || team.group_name} + + {verification && !verification.exists_in_obs && ( + ⚠️ Not found in OBS + )} + {verification && verification.name_changed && ( + 📝 Name changed in OBS + )} + {verification?.matched_by === 'uuid' && ( + 🆔 Linked by UUID + )} +
) : (
No OBS Group
)}
- {!team.group_name && ( + {shouldShowCreateButton && ( )} + {verification && !verification.exists_in_obs && ( + + )} + {verification && verification.name_changed && verification.current_name && ( + + )}
)}
- ))} + ); + })} )} diff --git a/lib/obsClient.js b/lib/obsClient.js index 5465302..f32b955 100644 --- a/lib/obsClient.js +++ b/lib/obsClient.js @@ -124,17 +124,30 @@ async function createGroupIfNotExists(groupName) { try { const obsClient = await getOBSClient(); - // Check if the group (scene) exists + // Check if the group (scene) exists and get its UUID const { scenes } = await obsClient.call('GetSceneList'); - const groupExists = scenes.some((scene) => scene.sceneName === groupName); + const existingScene = scenes.find((scene) => scene.sceneName === groupName); - if (!groupExists) { + if (!existingScene) { console.log(`Creating group "${groupName}"`); - await obsClient.call('CreateScene', { sceneName: groupName }); - return { created: true, message: `Group "${groupName}" created successfully` }; + const createResult = await obsClient.call('CreateScene', { sceneName: groupName }); + + // Get the scene UUID after creation + const { scenes: updatedScenes } = await obsClient.call('GetSceneList'); + const newScene = updatedScenes.find((scene) => scene.sceneName === groupName); + + return { + created: true, + message: `Group "${groupName}" created successfully`, + sceneUuid: newScene?.sceneUuid || null + }; } else { console.log(`Group "${groupName}" already exists`); - return { created: false, message: `Group "${groupName}" already exists` }; + return { + created: false, + message: `Group "${groupName}" already exists`, + sceneUuid: existingScene.sceneUuid + }; } } catch (error) { console.error('Error creating group:', error.message); diff --git a/package.json b/package.json index 0102daa..7af2f60 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "test:watch": "jest --watch", "test:coverage": "jest --coverage", "test:ci": "jest --coverage --watchAll=false", - "create-sat-summer-2025-tables": "tsx scripts/createSatSummer2025Tables.ts" + "create-sat-summer-2025-tables": "tsx scripts/createSatSummer2025Tables.ts", + "add-group-uuid-column": "tsx scripts/addGroupUuidColumn.ts" }, "dependencies": { "@tailwindcss/postcss": "^4.1.11", diff --git a/scripts/addGroupUuidColumn.ts b/scripts/addGroupUuidColumn.ts new file mode 100644 index 0000000..949af9e --- /dev/null +++ b/scripts/addGroupUuidColumn.ts @@ -0,0 +1,48 @@ +import { open } from 'sqlite'; +import sqlite3 from 'sqlite3'; +import path from 'path'; +import { getTableName, BASE_TABLE_NAMES } from '../lib/constants'; + +async function addGroupUuidColumn() { + const FILE_DIRECTORY = path.resolve(process.env.FILE_DIRECTORY || './files'); + const dbPath = path.join(FILE_DIRECTORY, 'sources.db'); + + try { + const db = await open({ + filename: dbPath, + driver: sqlite3.Database, + }); + + const teamsTableName = getTableName(BASE_TABLE_NAMES.TEAMS, { + year: 2025, + season: 'summer', + suffix: 'sat' + }); + + // Check if column already exists + const columns = await db.all(`PRAGMA table_info(${teamsTableName})`); + const hasGroupUuid = columns.some((col: any) => col.name === 'group_uuid'); + + if (hasGroupUuid) { + console.log('group_uuid column already exists'); + await db.close(); + return; + } + + // Add the new column + await db.run(`ALTER TABLE ${teamsTableName} ADD COLUMN group_uuid TEXT NULL`); + + console.log('Successfully added group_uuid column to teams table'); + + await db.close(); + } catch (error) { + console.error('Error adding group_uuid column:', error); + process.exit(1); + } +} + +// Run the migration +addGroupUuidColumn().then(() => { + console.log('Migration completed'); + process.exit(0); +}); \ No newline at end of file diff --git a/types/index.ts b/types/index.ts index fcb17f1..8933587 100644 --- a/types/index.ts +++ b/types/index.ts @@ -15,4 +15,5 @@ export type Team = { team_id: number; team_name: string; group_name?: string | null; + group_uuid?: string | null; }; \ No newline at end of file