Overview
The service layer (src/services/) provides a clean abstraction over Supabase interactions. All database queries, RPC calls, and storage operations go through services.
Key Principle : Components should never directly call supabase. All data access must go through service functions.
Architecture
Component → Service Function → Supabase Client → Database
Benefits
Separation of concerns : Business logic lives in services, not components
Type safety : Services enforce TypeScript types
Error handling : Centralized error mapping and validation
Testability : Services can be mocked for testing
Cache invalidation : Services trigger data invalidation events
Supabase Client
The authenticated client is initialized in lib/supabaseClient.ts:
src/lib/supabaseClient.ts
import { createClient } from '@supabase/supabase-js' ;
const supabaseUrl = import . meta . env . VITE_SUPABASE_URL ;
const supabaseAnonKey = import . meta . env . VITE_SUPABASE_ANON_KEY ;
if ( ! supabaseUrl || ! supabaseAnonKey ) {
throw new Error ( 'Missing Supabase URL or anon key' );
}
export const supabase = createClient ( supabaseUrl , supabaseAnonKey , {
auth: {
persistSession: true ,
autoRefreshToken: true ,
detectSessionInUrl: true ,
},
});
The frontend client uses only the anon key . Never expose the service role key in the frontend.
Service Structure
Core Services
Ticket Service
User Service
Storage Service
ticketService.ts handles all ticket/work order operations:src/services/ticketService.ts
import { supabase } from '../lib/supabaseClient' ;
import { invalidateData } from '../lib/dataInvalidation' ;
import type { Ticket , WorkOrder } from '../types/Ticket' ;
const PAGE_SIZE = 20 ;
export async function getAllTickets ( page : number ) : Promise < Ticket []> {
const from = page * PAGE_SIZE ;
const to = from + PAGE_SIZE - 1 ;
const { data , error } = await supabase
. from ( 'v_tickets_compat' )
. select ( '*' )
. eq ( 'is_archived' , false )
. order ( 'id' , { ascending: false })
. range ( from , to );
if ( error ) {
console . error ( 'Error al obtener tickets:' , error . message );
return [];
}
return data ?? [];
}
export async function createTicket (
ticket : Omit < Ticket , 'id' | 'status' | 'created_by' >
) {
const { data : { user }, error : userErr } = await supabase . auth . getUser ();
if ( userErr ) throw userErr ;
if ( ! user ) throw new Error ( 'No hay sesión activa.' );
const { data , error } = await supabase
. from ( 'tickets' )
. insert ([{ ... ticket , status: 'Pendiente' , created_by: user . id }])
. select ( 'id, title' )
. single ();
if ( error ) throw new Error ( error . message );
invalidateData ( 'tickets' ); // Trigger cache invalidation
return data ;
}
userService.ts manages user data:src/services/userService.ts
export async function getCurrentUser () {
const { data : { user }, error } = await supabase . auth . getUser ();
if ( error ) throw error ;
return user ;
}
export async function getUserProfile ( userId : string ) {
const { data , error } = await supabase
. from ( 'user_profiles' )
. select ( '*' )
. eq ( 'user_id' , userId )
. single ();
if ( error ) throw error ;
return data ;
}
storageService.ts handles file uploads:src/services/storageService.ts
export async function uploadFile (
bucket : string ,
path : string ,
file : File
) {
const { data , error } = await supabase . storage
. from ( bucket )
. upload ( path , file );
if ( error ) throw error ;
return data ;
}
export function getPublicUrl ( bucket : string , path : string ) {
const { data } = supabase . storage
. from ( bucket )
. getPublicUrl ( path );
return data . publicUrl ;
}
Inventory Services
Inventory operations are organized in services/inventory/:
services/inventory/
├── index.ts # Re-exports all inventory services
├── inventoryClient.ts # Shared inventory client
├── partsService.ts # Parts catalog
├── warehousesService.ts # Warehouse management
├── stockService.ts # Stock queries
├── docsService.ts # Inventory documents
├── ledgerService.ts # Transaction ledger
├── kardexService.ts # Movement history
├── uomsService.ts # Units of measure
├── vendorsService.ts # Vendor management
├── reorderPoliciesService.ts # Reorder policies
└── reorderSuggestionsService.ts # Reorder suggestions
Example: Parts Service
src/services/inventory/partsService.ts
import { supabase } from '../../lib/supabaseClient' ;
import type { Part } from '../../types/inventory/master' ;
export async function getParts () : Promise < Part []> {
const { data , error } = await supabase
. from ( 'parts' )
. select ( '*' )
. order ( 'part_code' );
if ( error ) throw error ;
return data ?? [];
}
export async function createPart ( part : Omit < Part , 'id' >) {
const { data , error } = await supabase
. from ( 'parts' )
. insert ([ part ])
. select ()
. single ();
if ( error ) throw error ;
return data ;
}
Service Patterns
export async function getTicketsPaginated (
page : number ,
pageSize : number
) : Promise <{ data : Ticket []; count : number }> {
const from = page * pageSize ;
const to = from + pageSize - 1 ;
const { data , error , count } = await supabase
. from ( 'tickets' )
. select ( '*' , { count: 'exact' })
. range ( from , to );
if ( error ) throw error ;
return { data: data ?? [], count: count ?? 0 };
}
Filtering
export async function getFilteredTickets (
filters : TicketFilters
) : Promise < Ticket []> {
let query = supabase
. from ( 'tickets' )
. select ( '*' )
. eq ( 'is_archived' , false );
if ( filters . status ) {
query = query . eq ( 'status' , filters . status );
}
if ( filters . location_id ) {
query = query . eq ( 'location_id' , filters . location_id );
}
if ( filters . search ) {
query = query . or ( `title.ilike.% ${ filters . search } %,requester.ilike.% ${ filters . search } %` );
}
const { data , error } = await query . order ( 'id' , { ascending: false });
if ( error ) throw error ;
return data ?? [];
}
RPC Calls
export async function getTicketCounts () : Promise < TicketCounts > {
const { data , error } = await supabase . rpc ( 'ticket_counts' , {
p_location: null ,
p_term: null ,
});
if ( error ) {
console . error ( 'RPC ticket_counts error:' , error . message );
}
const counts : TicketCounts = {
Pendiente: 0 ,
'En Ejecución' : 0 ,
Finalizadas: 0 ,
};
( data ?? []). forEach (( row : { status : string ; total : number }) => {
if ( row . status ) {
counts [ row . status ] = row . total ;
}
});
return counts ;
}
Mutations with Invalidation
import { invalidateData } from '../lib/dataInvalidation' ;
export async function updateTicket (
id : number ,
updates : Partial < Ticket >
) {
const { error } = await supabase
. from ( 'tickets' )
. update ( updates )
. eq ( 'id' , id );
if ( error ) throw new Error ( `Error al actualizar: ${ error . message } ` );
// Invalidate cache to trigger refetch
invalidateData ( 'tickets' );
}
Always call invalidateData() after mutations to ensure UI consistency.
Data Invalidation
The invalidateData function triggers cache invalidation for dependent contexts:
import { invalidateData } from '../lib/dataInvalidation' ;
// After creating a ticket
invalidateData ( 'tickets' );
// After updating user
invalidateData ( 'users' );
// After inventory mutation
invalidateData ( 'inventory' );
Listening for Invalidations
import { onDataInvalidated } from '../lib/dataInvalidation' ;
useEffect (() => {
const unsubscribe = onDataInvalidated ( 'tickets' , () => {
// Refetch tickets
refetch ();
});
return () => unsubscribe ();
}, []);
Error Handling
Standard Error Pattern
export async function fetchData () {
const { data , error } = await supabase
. from ( 'table' )
. select ( '*' );
if ( error ) {
console . error ( 'Error fetching data:' , error . message );
throw new Error ( `Failed to fetch: ${ error . message } ` );
}
return data ?? [];
}
Session Validation
function isSessionMissingError ( error : unknown ) : boolean {
const msg = getErrorMessage ( error ). toLowerCase ();
return (
msg . includes ( 'auth session missing' ) ||
msg . includes ( 'session missing' ) ||
msg . includes ( 'invalid refresh token' )
);
}
Type Safety
Services enforce strict TypeScript types:
import type { Ticket , WorkOrder } from '../types/Ticket' ;
// Input validation
export async function createTicket (
ticket : Omit < Ticket , 'id' | 'status' | 'created_by' >
) : Promise < Pick < Ticket , 'id' | 'title' >> {
// Implementation
}
// Return type enforcement
export async function getTicketById ( id : number ) : Promise < WorkOrder | null > {
// Implementation
}
Real-time Subscriptions
Services can set up real-time subscriptions:
export function subscribeToTicketChanges (
ticketId : number ,
callback : ( ticket : Ticket ) => void
) {
const channel = supabase
. channel ( `ticket_ ${ ticketId } ` )
. on (
'postgres_changes' ,
{
event: '*' ,
schema: 'public' ,
table: 'tickets' ,
filter: `id=eq. ${ ticketId } ` ,
},
( payload ) => {
callback ( payload . new as Ticket );
}
)
. subscribe ();
return () => {
supabase . removeChannel ( channel );
};
}
Best Practices
Never expose service role credentials
Only use the anon key in frontend code. Service role operations must happen in Edge Functions or server-side code.
Always validate input at the service level
Use views for complex queries
Prefer database views (like v_tickets_compat) over complex joins in the frontend: // Good: Use a view
const { data } = await supabase . from ( 'v_tickets_compat' ). select ( '*' );
// Avoid: Complex joins in frontend
const { data } = await supabase
. from ( 'tickets' )
. select ( '*, users(*), locations(*)' );
Implement backward-compatible fallbacks
When new RPCs are introduced, provide fallbacks for environments that may not have them yet: try {
const { data } = await supabase . rpc ( 'new_function' );
return data ;
} catch ( error ) {
// Fallback to old approach
return await legacyFetch ();
}
Next Steps
Components Learn about the UI component library
RBAC Understand permissions and access control