Merge pull request 'Implement UUID-based tracking for OBS groups' (#4) from obs-uuid-tracking into main
All checks were successful
Lint and Build / build (push) Successful in 2m52s

Reviewed-on: #4
This commit is contained in:
Decobus 2025-07-20 22:55:31 +03:00
commit 319bada9b7
12 changed files with 430 additions and 42 deletions

View file

@ -17,6 +17,7 @@ This is a Next.js web application that controls multiple OBS Source Switchers. I
### Database Management
- `npm run create-sat-summer-2025-tables` - Create database tables with seasonal naming convention
- `npm run migrate-add-group-uuid` - Add group_uuid column to existing teams table (one-time migration)
## Architecture Overview
@ -33,11 +34,13 @@ This is a Next.js web application that controls multiple OBS Source Switchers. I
- `/streams` - Streams management page (add new streams and view existing)
- `/teams` - Team management page
- `/edit/[id]` - Individual stream editing
- `/components` - Reusable React components (Header, Footer, Dropdown)
- `/components` - Reusable React components (Header, Footer, Dropdown, Toast)
- `/lib` - Core utilities and database connection
- `database.ts` - SQLite database initialization and connection management
- `obsClient.js` - OBS WebSocket client with persistent connection management
- `constants.ts` - Dynamic table naming system for seasonal deployments
- `useToast.ts` - Toast notification system for user feedback
- `security.ts` - Input validation and sanitization utilities
- `/types` - TypeScript type definitions
- `/files` - Default directory for SQLite database and text files (configurable via .env.local)
- `/scripts` - Database setup and management scripts
@ -63,6 +66,12 @@ This is a Next.js web application that controls multiple OBS Source Switchers. I
6. **Real-time Status Monitoring**: Footer component polls OBS status every 30 seconds showing connection, streaming, and recording status
7. **UUID-based OBS Group Tracking**: Robust synchronization between database teams and OBS scenes using UUID identifiers to handle manual renames and ensure data consistency
8. **Toast Notification System**: User-friendly feedback system with success, error, and informational messages for all operations
9. **Stream Deletion with Confirmation**: Safe deletion workflow that removes streams from both OBS and database with user confirmation prompts
### Environment Configuration
- `FILE_DIRECTORY`: Directory for database and text files (default: ./files)
- `OBS_WEBSOCKET_HOST`: OBS WebSocket host (default: 127.0.0.1)
@ -73,19 +82,24 @@ This is a Next.js web application that controls multiple OBS Source Switchers. I
### API Endpoints
#### Stream Management
- `POST /api/addStream` - Add new stream to database and create browser source in OBS
- `POST /api/addStream` - Add new stream to database and create browser source in OBS (accepts Twitch username, auto-generates URL)
- `GET /api/streams` - Get all available streams
- `GET /api/streams/[id]` - Individual stream operations
- `GET /api/streams/[id]` - Get individual stream details
- `DELETE /api/streams/[id]` - Delete stream from both OBS and database with confirmation
#### Source Control
- `POST /api/setActive` - Set active stream for specific screen position
- `GET /api/getActive` - Get currently active sources for all screens
#### Team Management
- `GET /api/teams` - Get all teams
- `GET /api/teams` - Get all teams with group information
- `POST /api/teams` - Create new team
- `PUT /api/teams/[id]` - Update team name, group_name, or group_uuid
- `DELETE /api/teams/[id]` - Delete team and associated streams
- `GET /api/getTeamName` - Get team name by ID
- `POST /api/createGroup` - Create OBS group from team
- `POST /api/createGroup` - Create OBS group from team and store UUID
- `POST /api/syncGroups` - Synchronize all teams with OBS groups
- `GET /api/verifyGroups` - Verify database groups exist in OBS with UUID tracking
#### System Status
- `GET /api/obsStatus` - Real-time OBS connection and streaming status
@ -94,7 +108,13 @@ This is a Next.js web application that controls multiple OBS Source Switchers. I
Dynamic table names with seasonal configuration:
- `streams_YYYY_SEASON_SUFFIX`: id, name, obs_source_name, url, team_id
- `teams_YYYY_SEASON_SUFFIX`: team_id, team_name, group_name
- `teams_YYYY_SEASON_SUFFIX`: team_id, team_name, group_name, group_uuid
**Database Fields**:
- `streams` table: Stores stream information with team associations
- `teams` table: Stores team information with optional OBS group mapping
- `group_name`: Human-readable OBS scene name
- `group_uuid`: OBS scene UUID for reliable tracking (handles renames)
### OBS Integration Pattern
@ -102,7 +122,11 @@ The app uses a sophisticated dual integration approach:
1. **WebSocket Connection**: Direct OBS control using obs-websocket-js with persistent connection management
2. **Text File System**: Each screen position has a corresponding text file that OBS Source Switcher monitors
3. **Group Management**: Teams can be mapped to OBS groups (implemented as scenes) for organized source management
3. **UUID-based Group Management**: Teams mapped to OBS scenes with UUID tracking for reliable synchronization
- Primary matching by UUID for rename-safe tracking
- Fallback to name matching for backward compatibility
- Automatic detection of name changes and sync issues
- UI actions for resolving synchronization problems
**Required OBS Source Switchers** (must be created with these exact names):
- `ss_large` - Large screen source switcher
@ -123,12 +147,26 @@ See [OBS Setup Guide](./docs/OBS_SETUP.md) for detailed configuration instructio
**Connection Management**: The OBS client ensures a single persistent connection across all API requests with automatic reconnection handling and connection state validation.
**Group Synchronization Workflow**:
1. Team creation optionally creates corresponding OBS scene
2. UUID stored in database for reliable tracking
3. Verification system detects sync issues (missing groups, name changes)
4. UI provides actions to fix sync problems:
- "Clear Invalid" - Remove broken group assignments
- "Update Name" - Sync database with OBS name changes
- Visual indicators show sync status and UUID linking
### Component Patterns
- **Client Components**: All interactive components use `'use client'` directive for React 19 compatibility
- **Optimistic Updates**: UI updates immediately with error rollback for responsive user experience
- **Toast Notifications**: Comprehensive feedback system with success/error messages for all operations
- **Confirmation Dialogs**: Safe deletion workflows with user confirmation prompts
- **Real-time Validation**: Client-side form validation with immediate feedback
- **Dropdown Components**: Portal-based dropdowns with proper z-index handling and scroll-aware positioning
- **Consistent Layout**: Glass morphism design with unified component styling across all pages
- **Responsive Design**: Grid layouts adapt to different screen sizes with mobile-first approach
- **Accessibility**: High contrast ratios, keyboard navigation, and screen reader support
### Security Architecture
@ -142,4 +180,36 @@ See [OBS Setup Guide](./docs/OBS_SETUP.md) for detailed configuration instructio
**Path Protection**: File operations are restricted to allowlisted screen names, preventing directory traversal
**Error Handling**: Secure error responses that don't leak system information
**Error Handling**: Secure error responses that don't leak system information
## Key Features & Recent Enhancements
### Stream Management
- **Twitch Integration**: Simplified stream addition using just Twitch username (auto-generates full URL)
- **Stream Deletion**: Safe deletion workflow with confirmation that removes from both OBS and database
- **Visual Feedback**: Clear "View Stream" links with proper contrast for accessibility
- **Team Association**: Streams can be organized under teams for better management
### Team & Group Management
- **UUID-based Tracking**: Robust OBS group synchronization using scene UUIDs
- **Sync Verification**: Real-time verification of database-OBS group synchronization
- **Conflict Resolution**: UI actions to resolve sync issues (missing groups, name changes)
- **Visual Indicators**: Clear status indicators for group linking and sync problems
- 🆔 "Linked by UUID" - Group tracked by reliable UUID
- 📝 "Name changed in OBS" - Group renamed in OBS, database needs update
- ⚠️ "Not found in OBS" - Group in database but missing from OBS
### User Experience Improvements
- **Toast Notifications**: Real-time feedback for all operations (success/error/info)
- **Form Validation**: Client-side validation with immediate error feedback
- **Confirmation Prompts**: Safe deletion workflows prevent accidental data loss
- **Responsive Design**: Mobile-friendly interface with glass morphism styling
- **Loading States**: Clear indicators during API operations
- **Error Recovery**: Graceful error handling with user-friendly messages
### Developer Experience
- **Type Safety**: Comprehensive TypeScript definitions throughout
- **API Documentation**: Well-documented endpoints with clear parameter validation
- **Migration Scripts**: Database migration tools for schema updates
- **Security**: Input validation, sanitization, and secure API design
- **Testing**: Comprehensive error handling and edge case management

View file

@ -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({

View file

@ -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: (string | number | null)[] = [];
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) {

View file

@ -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);

View file

@ -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 }
);
}
}

View file

@ -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<Team[]>([]);
const [groupVerification, setGroupVerification] = useState<GroupVerification[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isVerifying, setIsVerifying] = useState(false);
const [newTeamName, setNewTeamName] = useState('');
const [editingTeam, setEditingTeam] = useState<Team | null>(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() {
<div className="glass p-6">
<div className="flex justify-between items-center mb-6">
<h2 className="card-title">Existing Teams</h2>
<button
onClick={handleSyncAllGroups}
disabled={isSyncing || isLoading}
className="btn btn-success"
title="Create OBS groups for all teams without groups"
>
<span className="icon">🔄</span>
{isSyncing ? 'Syncing...' : 'Sync All Groups'}
</button>
<div className="button-group">
<button
onClick={verifyGroups}
disabled={isVerifying || isLoading}
className="btn btn-secondary"
title="Check if database groups exist in OBS"
>
<span className="icon">🔍</span>
{isVerifying ? 'Verifying...' : 'Verify Groups'}
</button>
<button
onClick={handleSyncAllGroups}
disabled={isSyncing || isLoading}
className="btn btn-success"
title="Create OBS groups for all teams without groups"
>
<span className="icon">🔄</span>
{isSyncing ? 'Syncing...' : 'Sync All Groups'}
</button>
</div>
</div>
{isLoading ? (
@ -297,7 +403,10 @@ export default function Teams() {
</div>
) : (
<div className="space-y-4">
{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 (
<div key={team.team_id} className="glass p-4 mb-4">
{editingTeam?.team_id === team.team_id ? (
<div className="form-row">
@ -338,14 +447,27 @@ export default function Teams() {
<div className="font-semibold text-white">{team.team_name}</div>
<div className="text-sm text-white/60">ID: {team.team_id}</div>
{team.group_name ? (
<div className="text-sm text-green-400">OBS Group: {team.group_name}</div>
<div className="text-sm">
<span className={verification && !verification.exists_in_obs ? 'text-red-400' : 'text-green-400'}>
OBS Group: {verification?.current_name || team.group_name}
</span>
{verification && !verification.exists_in_obs && (
<span className="text-red-400 ml-2"> Not found in OBS</span>
)}
{verification && verification.name_changed && (
<span className="text-yellow-400 ml-2">📝 Name changed in OBS</span>
)}
{verification?.matched_by === 'uuid' && (
<span className="text-blue-400 ml-2">🆔 Linked by UUID</span>
)}
</div>
) : (
<div className="text-sm text-orange-400">No OBS Group</div>
)}
</div>
</div>
<div className="button-group">
{!team.group_name && (
{shouldShowCreateButton && (
<button
onClick={() => handleCreateGroup(team.team_id, team.team_name)}
disabled={creatingGroupForTeam === team.team_id || deletingTeamId === team.team_id || updatingTeamId === team.team_id}
@ -356,6 +478,28 @@ export default function Teams() {
{creatingGroupForTeam === team.team_id ? 'Creating...' : 'Create Group'}
</button>
)}
{verification && !verification.exists_in_obs && (
<button
onClick={() => handleClearInvalidGroup(team.team_id, team.team_name)}
disabled={updatingTeamId === team.team_id || deletingTeamId === team.team_id}
className="btn-danger btn-sm"
title="Clear invalid group assignment"
>
<span className="icon">🗑</span>
Clear Invalid
</button>
)}
{verification && verification.name_changed && verification.current_name && (
<button
onClick={() => handleUpdateGroupName(team.team_id, team.team_name, verification.current_name!)}
disabled={updatingTeamId === team.team_id || deletingTeamId === team.team_id}
className="btn btn-secondary btn-sm"
title="Update database to match OBS name"
>
<span className="icon">📝</span>
Update Name
</button>
)}
<button
onClick={() => startEditing(team)}
disabled={deletingTeamId === team.team_id || updatingTeamId === team.team_id}
@ -378,7 +522,8 @@ export default function Teams() {
</div>
)}
</div>
))}
);
})}
</div>
)}
</div>

View file

@ -140,7 +140,7 @@ export async function parseRequestBody<T>(
}
return { success: true, data: body as T };
} catch (_error) {
} catch {
return {
success: false,
response: createErrorResponse(

View file

@ -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` };
// 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);

View file

@ -193,5 +193,6 @@ export function useSmartPolling(
intervalRef.current = null;
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [interval, isVisible, ...dependencies]);
}

View file

@ -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",

View file

@ -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);
});

View file

@ -15,4 +15,5 @@ export type Team = {
team_id: number;
team_name: string;
group_name?: string | null;
group_uuid?: string | null;
};