Skip to content

Commit 0b870ee

Browse files
Jawnnypooclaude
andcommitted
Standardize runner operations to use API endpoints
- Create unified RunnerApiService for DELETE and PUT operations - Replace Firestore direct operations with API calls matching CLI/Flutter pattern - Add proper authentication headers and error handling - Remove deprecated updateRunnerInFirestore and deleteRunnerFromFirestore - Improve error messages with specific API error details 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent 374d0f4 commit 0b870ee

File tree

3 files changed

+309
-40
lines changed

3 files changed

+309
-40
lines changed

src/pages/Account/Account.tsx

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import './Account.css';
33
import {useAuth} from '../../contexts/AuthContext';
44
import Footer from '../../components/Footer';
55
import Header from '../../components/Header';
6-
import {getPlansFromFirestore, Plan, getRunnersFromFirestore, Runner, updateRunnerInFirestore, deleteRunnerFromFirestore} from '../../services/userService';
6+
import {getPlansFromFirestore, Plan, getRunnersFromFirestore, Runner} from '../../services/userService';
7+
import {RunnerApiService, ApiError} from '../../services/apiService';
78

89
const Account: React.FC = () => {
910
const {user, logout, refreshUserData} = useAuth();
@@ -94,7 +95,8 @@ const Account: React.FC = () => {
9495

9596
const handleRenameRunner = async (runnerId: string, newName: string) => {
9697
try {
97-
await updateRunnerInFirestore(runnerId, { name: newName });
98+
// Use the new API service that matches CLI pattern
99+
await RunnerApiService.updateRunnerName(runnerId, newName);
98100
setRunners(runners.map(runner =>
99101
runner.id === runnerId
100102
? { ...runner, name: newName }
@@ -104,7 +106,13 @@ const Account: React.FC = () => {
104106
setEditingName('');
105107
} catch (error) {
106108
console.error('Error renaming runner:', error);
107-
alert('Failed to rename runner. Please try again.');
109+
110+
// Provide more specific error messages
111+
if (error instanceof ApiError) {
112+
alert(`Failed to rename runner: ${error.message}`);
113+
} else {
114+
alert('Failed to rename runner. Please try again.');
115+
}
108116
}
109117
};
110118

@@ -114,11 +122,18 @@ const Account: React.FC = () => {
114122
);
115123
if (confirmed) {
116124
try {
117-
await deleteRunnerFromFirestore(runnerId);
125+
// Use the new API service that matches CLI and Flutter pattern
126+
await RunnerApiService.deleteRunner(runnerId);
118127
setRunners(runners.filter(runner => runner.id !== runnerId));
119128
} catch (error) {
120129
console.error('Error deleting runner:', error);
121-
alert('Failed to delete runner. Please try again.');
130+
131+
// Provide more specific error messages
132+
if (error instanceof ApiError) {
133+
alert(`Failed to delete runner: ${error.message}`);
134+
} else {
135+
alert('Failed to delete runner. Please try again.');
136+
}
122137
}
123138
}
124139
};

src/services/apiService.ts

Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
/**
2+
* Unified API Service for Salamander Web
3+
*
4+
* This service provides a standardized way to interact with the Salamander API,
5+
* following the same patterns used by the CLI and Flutter applications.
6+
*
7+
* Key features:
8+
* - Uses HTTP API endpoints instead of direct Firestore access
9+
* - Extensible base class for different resource types
10+
* - Consistent error handling across all operations
11+
* - Authentication token management
12+
*/
13+
14+
export class ApiError extends Error {
15+
constructor(
16+
message: string,
17+
public status: number,
18+
public response?: any
19+
) {
20+
super(message);
21+
this.name = 'ApiError';
22+
}
23+
}
24+
25+
export interface ApiResponse<T = any> {
26+
data?: T;
27+
error?: string;
28+
message?: string;
29+
}
30+
31+
/**
32+
* Base API Service class that can be extended for specific resources
33+
*/
34+
export abstract class BaseApiService {
35+
protected static readonly API_BASE_URL = 'https://api.salamander.space/v1';
36+
37+
/**
38+
* Get authentication token from localStorage
39+
*/
40+
protected static getAuthToken(): string {
41+
const token = localStorage.getItem('salamander_token');
42+
if (!token) {
43+
throw new Error('User not authenticated - no token found');
44+
}
45+
return token;
46+
}
47+
48+
/**
49+
* Make authenticated HTTP request to the API
50+
*/
51+
protected static async makeRequest<T>(
52+
endpoint: string,
53+
options: RequestInit = {}
54+
): Promise<T> {
55+
const token = this.getAuthToken();
56+
const url = `${this.API_BASE_URL}${endpoint}`;
57+
58+
const config: RequestInit = {
59+
...options,
60+
headers: {
61+
'Authorization': `Bearer ${token}`,
62+
'Content-Type': 'application/json',
63+
...options.headers,
64+
},
65+
};
66+
67+
try {
68+
const response = await fetch(url, config);
69+
70+
if (!response.ok) {
71+
let errorMessage = `HTTP ${response.status}`;
72+
let errorResponse;
73+
74+
try {
75+
errorResponse = await response.json();
76+
errorMessage = errorResponse.message || errorMessage;
77+
} catch {
78+
errorMessage = await response.text() || errorMessage;
79+
}
80+
81+
throw new ApiError(errorMessage, response.status, errorResponse);
82+
}
83+
84+
// Handle 204 No Content responses
85+
if (response.status === 204) {
86+
return {} as T;
87+
}
88+
89+
return await response.json();
90+
} catch (error) {
91+
if (error instanceof ApiError) {
92+
throw error;
93+
}
94+
95+
// Handle network errors, CORS issues, etc.
96+
throw new ApiError(
97+
error instanceof Error ? error.message : 'Network error occurred',
98+
0,
99+
{ originalError: error }
100+
);
101+
}
102+
}
103+
104+
/**
105+
* Generic GET request
106+
*/
107+
protected static async get<T>(endpoint: string): Promise<T> {
108+
return this.makeRequest<T>(endpoint, { method: 'GET' });
109+
}
110+
111+
/**
112+
* Generic POST request
113+
*/
114+
protected static async post<T>(endpoint: string, data?: any): Promise<T> {
115+
return this.makeRequest<T>(endpoint, {
116+
method: 'POST',
117+
body: data ? JSON.stringify(data) : undefined,
118+
});
119+
}
120+
121+
/**
122+
* Generic PUT request
123+
*/
124+
protected static async put<T>(endpoint: string, data?: any): Promise<T> {
125+
return this.makeRequest<T>(endpoint, {
126+
method: 'PUT',
127+
body: data ? JSON.stringify(data) : undefined,
128+
});
129+
}
130+
131+
/**
132+
* Generic DELETE request
133+
*/
134+
protected static async delete<T>(endpoint: string): Promise<T> {
135+
return this.makeRequest<T>(endpoint, { method: 'DELETE' });
136+
}
137+
}
138+
139+
/**
140+
* Runner API Service - extends BaseApiService for runner-specific operations
141+
*/
142+
export class RunnerApiService extends BaseApiService {
143+
/**
144+
* Delete a runner using the API endpoint (matches CLI and Flutter pattern)
145+
*/
146+
static async deleteRunner(runnerId: string): Promise<void> {
147+
if (!runnerId || typeof runnerId !== 'string') {
148+
throw new Error('Runner ID is required and must be a string');
149+
}
150+
151+
try {
152+
await this.delete(`/runner/${runnerId}`);
153+
} catch (error) {
154+
if (error instanceof ApiError) {
155+
// Re-throw API errors with more context
156+
throw new ApiError(
157+
`Failed to delete runner: ${error.message}`,
158+
error.status,
159+
error.response
160+
);
161+
}
162+
throw error;
163+
}
164+
}
165+
166+
/**
167+
* Create a runner using the API endpoint
168+
*/
169+
static async createRunner(data: {
170+
name: string;
171+
directory?: string;
172+
machineId?: string;
173+
machineName?: string;
174+
}): Promise<{ id: string }> {
175+
if (!data.name) {
176+
throw new Error('Runner name is required');
177+
}
178+
179+
try {
180+
return await this.post('/runner', data);
181+
} catch (error) {
182+
if (error instanceof ApiError) {
183+
throw new ApiError(
184+
`Failed to create runner: ${error.message}`,
185+
error.status,
186+
error.response
187+
);
188+
}
189+
throw error;
190+
}
191+
}
192+
193+
/**
194+
* Update a runner's name using the API endpoint
195+
* Uses PUT /v1/runner/{runner_id} with "name" in request body (matches CLI pattern)
196+
*/
197+
static async updateRunnerName(runnerId: string, name: string): Promise<void> {
198+
if (!runnerId) {
199+
throw new Error('Runner ID is required');
200+
}
201+
if (!name || typeof name !== 'string' || !name.trim()) {
202+
throw new Error('Runner name is required and must be a non-empty string');
203+
}
204+
205+
try {
206+
await this.put(`/runner/${runnerId}`, { name: name.trim() });
207+
} catch (error) {
208+
if (error instanceof ApiError) {
209+
throw new ApiError(
210+
`Failed to update runner name: ${error.message}`,
211+
error.status,
212+
error.response
213+
);
214+
}
215+
throw error;
216+
}
217+
}
218+
219+
/**
220+
* Generic update runner method for other properties
221+
*/
222+
static async updateRunner(runnerId: string, data: {
223+
name?: string;
224+
lastMessage?: string;
225+
}): Promise<void> {
226+
if (!runnerId) {
227+
throw new Error('Runner ID is required');
228+
}
229+
230+
try {
231+
await this.put(`/runner/${runnerId}`, data);
232+
} catch (error) {
233+
if (error instanceof ApiError) {
234+
throw new ApiError(
235+
`Failed to update runner: ${error.message}`,
236+
error.status,
237+
error.response
238+
);
239+
}
240+
throw error;
241+
}
242+
}
243+
}
244+
245+
/**
246+
* Usage example:
247+
*
248+
* // Delete a runner
249+
* try {
250+
* await RunnerApiService.deleteRunner('runner-id-123');
251+
* console.log('Runner deleted successfully');
252+
* } catch (error) {
253+
* if (error instanceof ApiError) {
254+
* console.error(`API Error (${error.status}): ${error.message}`);
255+
* } else {
256+
* console.error('Unexpected error:', error);
257+
* }
258+
* }
259+
*
260+
* // Create a new runner
261+
* try {
262+
* const { id } = await RunnerApiService.createRunner({
263+
* name: 'My New Runner',
264+
* directory: '/path/to/project'
265+
* });
266+
* console.log('Created runner with ID:', id);
267+
* } catch (error) {
268+
* console.error('Failed to create runner:', error);
269+
* }
270+
*
271+
* // Rename a runner
272+
* try {
273+
* await RunnerApiService.updateRunnerName('runner-id-123', 'Updated Runner Name');
274+
* console.log('Runner renamed successfully');
275+
* } catch (error) {
276+
* if (error instanceof ApiError) {
277+
* console.error(`Failed to rename runner: ${error.message}`);
278+
* } else {
279+
* console.error('Unexpected error:', error);
280+
* }
281+
* }
282+
*/

src/services/userService.ts

Lines changed: 7 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {collection, doc, getDoc, getDocs, updateDoc, query, where, writeBatch} from 'firebase/firestore';
1+
import {collection, doc, getDoc, getDocs, updateDoc, query, where} from 'firebase/firestore';
22
import {db} from '../config/firebase';
33

44
export interface User {
@@ -140,38 +140,10 @@ export const getRunnersFromFirestore = async (userId: string): Promise<Runner[]>
140140
}
141141
};
142142

143-
export const updateRunnerInFirestore = async (runnerId: string, updates: Partial<Runner>): Promise<void> => {
144-
try {
145-
await updateDoc(doc(db, 'runners', runnerId), {
146-
...updates,
147-
updatedAt: new Date(),
148-
});
149-
} catch (error) {
150-
console.error('Error updating runner in Firestore:', error);
151-
throw error;
152-
}
153-
};
143+
// NOTE: updateRunnerInFirestore has been removed and replaced with RunnerApiService.updateRunnerName()
144+
// This change standardizes runner updates across all platforms (web, CLI, Flutter)
145+
// to use the same API endpoint pattern: PUT /v1/runner/{id} with auth headers
154146

155-
export const deleteRunnerFromFirestore = async (runnerId: string): Promise<void> => {
156-
try {
157-
const batch = writeBatch(db);
158-
159-
// Delete all messages in the subcollection
160-
const runnerDocRef = doc(db, 'runners', runnerId);
161-
const messagesCollectionRef = collection(runnerDocRef, 'messages');
162-
const messagesSnapshot = await getDocs(messagesCollectionRef);
163-
164-
for (const messageDoc of messagesSnapshot.docs) {
165-
batch.delete(messageDoc.ref);
166-
}
167-
168-
// Delete the runner document
169-
batch.delete(runnerDocRef);
170-
171-
// Commit the batch
172-
await batch.commit();
173-
} catch (error) {
174-
console.error('Error deleting runner from Firestore:', error);
175-
throw error;
176-
}
177-
};
147+
// NOTE: deleteRunnerFromFirestore has been removed and replaced with RunnerApiService.deleteRunner()
148+
// This change standardizes runner deletion across all platforms (web, CLI, Flutter)
149+
// to use the same API endpoint pattern instead of direct Firestore operations.

0 commit comments

Comments
 (0)